diff --git a/.core_files.yaml b/.core_files.yaml index f5ffdee9142..067a6a2b41d 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -120,21 +120,18 @@ tests: &tests - pylint/** - requirements_test_pre_commit.txt - requirements_test.txt + - tests/*.py - tests/auth/** - tests/backports/** - - tests/common.py - tests/components/history/** - tests/components/logbook/** - tests/components/recorder/** - tests/components/sensor/** - - tests/conftest.py - tests/hassfest/** - tests/helpers/** - - tests/ignore_uncaught_exceptions.py - tests/mock/** - tests/pylint/** - tests/scripts/** - - tests/syrupy.py - tests/test_util/** - tests/testing_config/** - tests/util/** diff --git a/.coveragerc b/.coveragerc index 7986785d86e..003b4908b17 100644 --- a/.coveragerc +++ b/.coveragerc @@ -58,13 +58,18 @@ omit = homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual_pro/__init__.py homeassistant/components/airvisual_pro/sensor.py + homeassistant/components/aladdin_connect/__init__.py + homeassistant/components/aladdin_connect/api.py + homeassistant/components/aladdin_connect/application_credentials.py + homeassistant/components/aladdin_connect/cover.py + homeassistant/components/aladdin_connect/sensor.py homeassistant/components/alarmdecoder/__init__.py homeassistant/components/alarmdecoder/alarm_control_panel.py homeassistant/components/alarmdecoder/binary_sensor.py + homeassistant/components/alarmdecoder/entity.py homeassistant/components/alarmdecoder/sensor.py homeassistant/components/alpha_vantage/sensor.py homeassistant/components/amazon_polly/* - homeassistant/components/ambiclimate/climate.py homeassistant/components/ambient_station/__init__.py homeassistant/components/ambient_station/binary_sensor.py homeassistant/components/ambient_station/entity.py @@ -82,6 +87,12 @@ omit = homeassistant/components/aprilaire/climate.py homeassistant/components/aprilaire/coordinator.py homeassistant/components/aprilaire/entity.py + homeassistant/components/aprilaire/sensor.py + homeassistant/components/apsystems/__init__.py + homeassistant/components/apsystems/coordinator.py + homeassistant/components/apsystems/entity.py + homeassistant/components/apsystems/number.py + homeassistant/components/apsystems/sensor.py homeassistant/components/aqualogic/* homeassistant/components/aquostv/media_player.py homeassistant/components/arcam_fmj/__init__.py @@ -111,6 +122,7 @@ omit = homeassistant/components/awair/coordinator.py homeassistant/components/azure_service_bus/* homeassistant/components/baf/__init__.py + homeassistant/components/baf/binary_sensor.py homeassistant/components/baf/climate.py homeassistant/components/baf/entity.py homeassistant/components/baf/fan.py @@ -119,8 +131,6 @@ omit = homeassistant/components/baf/sensor.py homeassistant/components/baf/switch.py homeassistant/components/baidu/tts.py - homeassistant/components/bang_olufsen/__init__.py - homeassistant/components/bang_olufsen/const.py homeassistant/components/bang_olufsen/entity.py homeassistant/components/bang_olufsen/media_player.py homeassistant/components/bang_olufsen/util.py @@ -141,12 +151,7 @@ omit = homeassistant/components/bloomsky/* homeassistant/components/bluesound/* homeassistant/components/bluetooth_tracker/* - homeassistant/components/bmw_connected_drive/__init__.py - homeassistant/components/bmw_connected_drive/binary_sensor.py - homeassistant/components/bmw_connected_drive/coordinator.py - homeassistant/components/bmw_connected_drive/lock.py homeassistant/components/bmw_connected_drive/notify.py - homeassistant/components/bmw_connected_drive/sensor.py homeassistant/components/bosch_shc/__init__.py homeassistant/components/bosch_shc/binary_sensor.py homeassistant/components/bosch_shc/cover.py @@ -177,7 +182,6 @@ omit = homeassistant/components/canary/camera.py homeassistant/components/cert_expiry/helper.py homeassistant/components/channels/* - homeassistant/components/circuit/* homeassistant/components/cisco_ios/device_tracker.py homeassistant/components/cisco_mobility_express/device_tracker.py homeassistant/components/cisco_webex_teams/notify.py @@ -192,7 +196,6 @@ omit = homeassistant/components/comelit/__init__.py homeassistant/components/comelit/alarm_control_panel.py homeassistant/components/comelit/climate.py - homeassistant/components/comelit/const.py homeassistant/components/comelit/coordinator.py homeassistant/components/comelit/cover.py homeassistant/components/comelit/humidifier.py @@ -255,9 +258,6 @@ omit = homeassistant/components/dormakaba_dkey/sensor.py homeassistant/components/dovado/* homeassistant/components/downloader/__init__.py - homeassistant/components/dsmr_reader/__init__.py - homeassistant/components/dsmr_reader/definitions.py - homeassistant/components/dsmr_reader/sensor.py homeassistant/components/dte_energy_bridge/sensor.py homeassistant/components/dublin_bus_transport/sensor.py homeassistant/components/dunehd/__init__.py @@ -269,7 +269,6 @@ omit = homeassistant/components/duotecno/entity.py homeassistant/components/duotecno/light.py homeassistant/components/duotecno/switch.py - homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* @@ -326,8 +325,7 @@ omit = homeassistant/components/elmax/__init__.py homeassistant/components/elmax/alarm_control_panel.py homeassistant/components/elmax/binary_sensor.py - homeassistant/components/elmax/common.py - homeassistant/components/elmax/const.py + homeassistant/components/elmax/coordinator.py homeassistant/components/elmax/cover.py homeassistant/components/elmax/switch.py homeassistant/components/elv/* @@ -370,7 +368,6 @@ omit = homeassistant/components/epson/media_player.py homeassistant/components/eq3btsmart/__init__.py homeassistant/components/eq3btsmart/climate.py - homeassistant/components/eq3btsmart/const.py homeassistant/components/eq3btsmart/entity.py homeassistant/components/eq3btsmart/models.py homeassistant/components/escea/__init__.py @@ -462,8 +459,8 @@ omit = homeassistant/components/freebox/camera.py homeassistant/components/freebox/home_base.py homeassistant/components/freebox/switch.py - homeassistant/components/fritz/common.py - homeassistant/components/fritz/device_tracker.py + homeassistant/components/fritz/coordinator.py + homeassistant/components/fritz/entity.py homeassistant/components/fritz/services.py homeassistant/components/fritz/switch.py homeassistant/components/fritzbox_callmonitor/__init__.py @@ -473,10 +470,6 @@ omit = homeassistant/components/frontier_silicon/browse_media.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py - homeassistant/components/fyta/__init__.py - homeassistant/components/fyta/coordinator.py - homeassistant/components/fyta/entity.py - homeassistant/components/fyta/sensor.py homeassistant/components/garadget/cover.py homeassistant/components/garages_amsterdam/__init__.py homeassistant/components/garages_amsterdam/binary_sensor.py @@ -505,7 +498,6 @@ omit = homeassistant/components/gpsd/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/growatt_server/__init__.py - homeassistant/components/growatt_server/const.py homeassistant/components/growatt_server/sensor.py homeassistant/components/growatt_server/sensor_types/* homeassistant/components/gstreamer/media_player.py @@ -545,7 +537,6 @@ omit = homeassistant/components/hko/weather.py homeassistant/components/hlk_sw16/__init__.py homeassistant/components/hlk_sw16/switch.py - homeassistant/components/home_connect/binary_sensor.py homeassistant/components/home_connect/entity.py homeassistant/components/home_connect/light.py homeassistant/components/home_connect/switch.py @@ -599,7 +590,9 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/incomfort/* + homeassistant/components/incomfort/__init__.py + homeassistant/components/incomfort/climate.py + homeassistant/components/incomfort/water_heater.py homeassistant/components/insteon/binary_sensor.py homeassistant/components/insteon/climate.py homeassistant/components/insteon/cover.py @@ -629,6 +622,7 @@ omit = homeassistant/components/irish_rail_transport/sensor.py homeassistant/components/iss/__init__.py homeassistant/components/iss/sensor.py + homeassistant/components/ista_ecotrend/coordinator.py homeassistant/components/isy994/__init__.py homeassistant/components/isy994/binary_sensor.py homeassistant/components/isy994/button.py @@ -685,6 +679,7 @@ omit = homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py homeassistant/components/kostal_plenticore/__init__.py + homeassistant/components/kostal_plenticore/coordinator.py homeassistant/components/kostal_plenticore/helper.py homeassistant/components/kostal_plenticore/select.py homeassistant/components/kostal_plenticore/sensor.py @@ -732,7 +727,6 @@ omit = homeassistant/components/lookin/sensor.py homeassistant/components/loqed/sensor.py homeassistant/components/luci/device_tracker.py - homeassistant/components/luftdaten/sensor.py homeassistant/components/lupusec/__init__.py homeassistant/components/lupusec/alarm_control_panel.py homeassistant/components/lupusec/binary_sensor.py @@ -762,6 +756,7 @@ omit = homeassistant/components/matrix/__init__.py homeassistant/components/matrix/notify.py homeassistant/components/matter/__init__.py + homeassistant/components/matter/fan.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py homeassistant/components/medcom_ble/__init__.py @@ -788,7 +783,7 @@ omit = homeassistant/components/microbees/application_credentials.py homeassistant/components/microbees/binary_sensor.py homeassistant/components/microbees/button.py - homeassistant/components/microbees/const.py + homeassistant/components/microbees/climate.py homeassistant/components/microbees/coordinator.py homeassistant/components/microbees/cover.py homeassistant/components/microbees/entity.py @@ -796,7 +791,7 @@ omit = homeassistant/components/microbees/sensor.py homeassistant/components/microbees/switch.py homeassistant/components/microsoft/tts.py - homeassistant/components/mikrotik/hub.py + homeassistant/components/mikrotik/coordinator.py homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py homeassistant/components/minio/minio_helper.py @@ -807,10 +802,10 @@ omit = homeassistant/components/mochad/switch.py homeassistant/components/modem_callerid/button.py homeassistant/components/modem_callerid/sensor.py - homeassistant/components/moehlenhoff_alpha2/__init__.py - homeassistant/components/moehlenhoff_alpha2/binary_sensor.py homeassistant/components/moehlenhoff_alpha2/climate.py - homeassistant/components/moehlenhoff_alpha2/sensor.py + homeassistant/components/moehlenhoff_alpha2/coordinator.py + homeassistant/components/monzo/__init__.py + homeassistant/components/monzo/api.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/coordinator.py homeassistant/components/motion_blinds/cover.py @@ -821,6 +816,7 @@ omit = homeassistant/components/motionblinds_ble/cover.py homeassistant/components/motionblinds_ble/entity.py homeassistant/components/motionblinds_ble/select.py + homeassistant/components/motionblinds_ble/sensor.py homeassistant/components/motionmount/__init__.py homeassistant/components/motionmount/binary_sensor.py homeassistant/components/motionmount/entity.py @@ -858,7 +854,9 @@ omit = homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/__init__.py homeassistant/components/nanoleaf/button.py + homeassistant/components/nanoleaf/coordinator.py homeassistant/components/nanoleaf/entity.py + homeassistant/components/nanoleaf/event.py homeassistant/components/nanoleaf/light.py homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py @@ -920,9 +918,8 @@ omit = homeassistant/components/notion/util.py homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nuki/__init__.py - homeassistant/components/nuki/binary_sensor.py + homeassistant/components/nuki/coordinator.py homeassistant/components/nuki/lock.py - homeassistant/components/nuki/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py homeassistant/components/obihai/__init__.py @@ -935,7 +932,7 @@ omit = homeassistant/components/ohmconnect/sensor.py homeassistant/components/ombi/* homeassistant/components/omnilogic/__init__.py - homeassistant/components/omnilogic/common.py + homeassistant/components/omnilogic/coordinator.py homeassistant/components/omnilogic/sensor.py homeassistant/components/omnilogic/switch.py homeassistant/components/ondilo_ico/__init__.py @@ -963,7 +960,6 @@ omit = homeassistant/components/opengarage/sensor.py homeassistant/components/openhardwaremonitor/sensor.py homeassistant/components/openhome/__init__.py - homeassistant/components/openhome/const.py homeassistant/components/openhome/media_player.py homeassistant/components/opensensemap/air_quality.py homeassistant/components/opentherm_gw/__init__.py @@ -975,9 +971,10 @@ omit = homeassistant/components/openuv/coordinator.py homeassistant/components/openuv/sensor.py homeassistant/components/openweathermap/__init__.py + homeassistant/components/openweathermap/coordinator.py + homeassistant/components/openweathermap/repairs.py homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather.py - homeassistant/components/openweathermap/weather_update_coordinator.py homeassistant/components/opnsense/__init__.py homeassistant/components/opnsense/device_tracker.py homeassistant/components/opower/__init__.py @@ -987,7 +984,8 @@ omit = homeassistant/components/oru/* homeassistant/components/orvibo/switch.py homeassistant/components/osoenergy/__init__.py - homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/binary_sensor.py + homeassistant/components/osoenergy/entity.py homeassistant/components/osoenergy/sensor.py homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py @@ -1024,6 +1022,7 @@ omit = homeassistant/components/permobil/entity.py homeassistant/components/permobil/sensor.py homeassistant/components/philips_js/__init__.py + homeassistant/components/philips_js/coordinator.py homeassistant/components/philips_js/light.py homeassistant/components/philips_js/media_player.py homeassistant/components/philips_js/remote.py @@ -1032,7 +1031,6 @@ omit = homeassistant/components/picotts/tts.py homeassistant/components/pilight/base_class.py homeassistant/components/pilight/binary_sensor.py - homeassistant/components/pilight/const.py homeassistant/components/pilight/light.py homeassistant/components/pilight/switch.py homeassistant/components/ping/__init__.py @@ -1051,11 +1049,6 @@ omit = homeassistant/components/point/alarm_control_panel.py homeassistant/components/point/binary_sensor.py homeassistant/components/point/sensor.py - homeassistant/components/poolsense/__init__.py - homeassistant/components/poolsense/binary_sensor.py - homeassistant/components/poolsense/coordinator.py - homeassistant/components/poolsense/entity.py - homeassistant/components/poolsense/sensor.py homeassistant/components/powerwall/__init__.py homeassistant/components/progettihwsw/__init__.py homeassistant/components/progettihwsw/binary_sensor.py @@ -1071,7 +1064,6 @@ omit = homeassistant/components/pushbullet/sensor.py homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py - homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/coordinator.py homeassistant/components/qbittorrent/sensor.py @@ -1082,7 +1074,6 @@ omit = homeassistant/components/quantum_gateway/device_tracker.py homeassistant/components/qvr_pro/* homeassistant/components/rabbitair/__init__.py - homeassistant/components/rabbitair/const.py homeassistant/components/rabbitair/coordinator.py homeassistant/components/rabbitair/entity.py homeassistant/components/rabbitair/fan.py @@ -1105,6 +1096,7 @@ omit = homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/binary_sensor.py homeassistant/components/rainmachine/button.py + homeassistant/components/rainmachine/coordinator.py homeassistant/components/rainmachine/select.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py @@ -1119,6 +1111,7 @@ omit = homeassistant/components/refoss/bridge.py homeassistant/components/refoss/coordinator.py homeassistant/components/refoss/entity.py + homeassistant/components/refoss/sensor.py homeassistant/components/refoss/switch.py homeassistant/components/refoss/util.py homeassistant/components/rejseplanen/sensor.py @@ -1127,7 +1120,6 @@ omit = homeassistant/components/renson/__init__.py homeassistant/components/renson/binary_sensor.py homeassistant/components/renson/button.py - homeassistant/components/renson/const.py homeassistant/components/renson/coordinator.py homeassistant/components/renson/entity.py homeassistant/components/renson/fan.py @@ -1196,13 +1188,11 @@ omit = homeassistant/components/schluter/* homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/climate.py - homeassistant/components/screenlogic/const.py homeassistant/components/screenlogic/coordinator.py homeassistant/components/screenlogic/entity.py homeassistant/components/screenlogic/light.py homeassistant/components/screenlogic/number.py homeassistant/components/screenlogic/sensor.py - homeassistant/components/screenlogic/services.py homeassistant/components/screenlogic/switch.py homeassistant/components/scsgate/* homeassistant/components/sendgrid/notify.py @@ -1255,7 +1245,6 @@ omit = homeassistant/components/smappee/switch.py homeassistant/components/smarty/* homeassistant/components/sms/__init__.py - homeassistant/components/sms/const.py homeassistant/components/sms/coordinator.py homeassistant/components/sms/gateway.py homeassistant/components/sms/notify.py @@ -1271,9 +1260,6 @@ omit = homeassistant/components/solaredge/__init__.py homeassistant/components/solaredge/coordinator.py homeassistant/components/solaredge_local/sensor.py - homeassistant/components/solarlog/__init__.py - homeassistant/components/solarlog/coordinator.py - homeassistant/components/solarlog/sensor.py homeassistant/components/solax/__init__.py homeassistant/components/solax/sensor.py homeassistant/components/soma/__init__.py @@ -1349,6 +1335,7 @@ omit = homeassistant/components/supla/* homeassistant/components/surepetcare/__init__.py homeassistant/components/surepetcare/binary_sensor.py + homeassistant/components/surepetcare/coordinator.py homeassistant/components/surepetcare/entity.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py @@ -1377,6 +1364,7 @@ omit = homeassistant/components/switchbot_cloud/climate.py homeassistant/components/switchbot_cloud/coordinator.py homeassistant/components/switchbot_cloud/entity.py + homeassistant/components/switchbot_cloud/sensor.py homeassistant/components/switchbot_cloud/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py @@ -1435,12 +1423,11 @@ omit = homeassistant/components/tensorflow/image_processing.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py - homeassistant/components/thethingsnetwork/* homeassistant/components/thingspeak/* homeassistant/components/thinkingcleaner/* homeassistant/components/thomson/device_tracker.py homeassistant/components/tibber/__init__.py - homeassistant/components/tibber/notify.py + homeassistant/components/tibber/coordinator.py homeassistant/components/tibber/sensor.py homeassistant/components/tikteck/light.py homeassistant/components/tile/__init__.py @@ -1482,12 +1469,6 @@ omit = homeassistant/components/traccar_server/entity.py homeassistant/components/traccar_server/helpers.py homeassistant/components/traccar_server/sensor.py - homeassistant/components/tractive/__init__.py - homeassistant/components/tractive/binary_sensor.py - homeassistant/components/tractive/device_tracker.py - homeassistant/components/tractive/entity.py - homeassistant/components/tractive/sensor.py - homeassistant/components/tractive/switch.py homeassistant/components/tradfri/__init__.py homeassistant/components/tradfri/base_class.py homeassistant/components/tradfri/coordinator.py @@ -1546,8 +1527,9 @@ omit = homeassistant/components/v2c/coordinator.py homeassistant/components/v2c/entity.py homeassistant/components/v2c/number.py - homeassistant/components/v2c/sensor.py homeassistant/components/v2c/switch.py + homeassistant/components/vallox/__init__.py + homeassistant/components/vallox/coordinator.py homeassistant/components/vasttrafik/sensor.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py @@ -1562,9 +1544,8 @@ omit = homeassistant/components/velux/__init__.py homeassistant/components/velux/cover.py homeassistant/components/velux/light.py - homeassistant/components/venstar/__init__.py - homeassistant/components/venstar/binary_sensor.py homeassistant/components/venstar/climate.py + homeassistant/components/venstar/coordinator.py homeassistant/components/venstar/sensor.py homeassistant/components/verisure/__init__.py homeassistant/components/verisure/alarm_control_panel.py @@ -1575,6 +1556,7 @@ omit = homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py homeassistant/components/versasense/* + homeassistant/components/vesync/__init__.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py homeassistant/components/vesync/sensor.py @@ -1597,7 +1579,6 @@ omit = homeassistant/components/vlc_telnet/media_player.py homeassistant/components/vodafone_station/__init__.py homeassistant/components/vodafone_station/button.py - homeassistant/components/vodafone_station/const.py homeassistant/components/vodafone_station/coordinator.py homeassistant/components/vodafone_station/device_tracker.py homeassistant/components/vodafone_station/sensor.py @@ -1622,10 +1603,8 @@ omit = homeassistant/components/watttime/__init__.py homeassistant/components/watttime/sensor.py homeassistant/components/weatherflow/__init__.py - homeassistant/components/weatherflow/const.py homeassistant/components/weatherflow/sensor.py homeassistant/components/weatherflow_cloud/__init__.py - homeassistant/components/weatherflow_cloud/const.py homeassistant/components/weatherflow_cloud/coordinator.py homeassistant/components/weatherflow_cloud/weather.py homeassistant/components/wiffi/__init__.py @@ -1643,6 +1622,7 @@ omit = homeassistant/components/xbox/base_sensor.py homeassistant/components/xbox/binary_sensor.py homeassistant/components/xbox/browse_media.py + homeassistant/components/xbox/coordinator.py homeassistant/components/xbox/media_player.py homeassistant/components/xbox/remote.py homeassistant/components/xbox/sensor.py @@ -1675,10 +1655,7 @@ omit = homeassistant/components/xs1/* homeassistant/components/yale_smart_alarm/__init__.py homeassistant/components/yale_smart_alarm/alarm_control_panel.py - homeassistant/components/yale_smart_alarm/binary_sensor.py - homeassistant/components/yale_smart_alarm/button.py homeassistant/components/yale_smart_alarm/entity.py - homeassistant/components/yale_smart_alarm/lock.py homeassistant/components/yalexs_ble/__init__.py homeassistant/components/yalexs_ble/binary_sensor.py homeassistant/components/yalexs_ble/entity.py @@ -1719,10 +1696,6 @@ omit = homeassistant/components/zeroconf/models.py homeassistant/components/zeroconf/usage.py homeassistant/components/zestimate/sensor.py - homeassistant/components/zeversolar/__init__.py - homeassistant/components/zeversolar/coordinator.py - homeassistant/components/zeversolar/entity.py - homeassistant/components/zeversolar/sensor.py homeassistant/components/zha/core/cluster_handlers/* homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2bdb6f99aad..2b15a65ff1d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,9 +5,11 @@ "postCreateCommand": "script/setup", "postStartCommand": "script/bootstrap", "containerEnv": { - "DEVCONTAINER": "1", "PYTHONASYNCIODEBUG": "1" }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, // Port 5683 udp is used by Shelly integration "appPort": ["8123:8123", "5683:5683/udp"], "runArgs": ["-e", "GIT_EDITOR=code --wait"], @@ -20,13 +22,17 @@ "visualstudioexptteam.vscodeintellicode", "redhat.vscode-yaml", "esbenp.prettier-vscode", - "GitHub.vscode-pull-request-github" + "GitHub.vscode-pull-request-github", + "GitHub.copilot" ], // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json "settings": { "python.experiments.optOutFrom": ["pythonTestAdapter"], - "python.pythonPath": "/usr/local/bin/python", + "python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python", + "python.pythonPath": "/home/vscode/.local/ha-venv/bin/python", + "python.terminal.activateEnvInCurrentTerminal": true, "python.testing.pytestArgs": ["--no-cov"], + "pylint.importStrategy": "fromEnvironment", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5cbfb4b0602..92a13078ce1 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -27,7 +27,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 with: fetch-depth: 0 @@ -90,11 +90,11 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v3.1.4 + uses: dawidd6/action-download-artifact@v6 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v3.1.4 + uses: dawidd6/action-download-artifact@v6 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.1.0 + uses: docker/login-action@v3.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -242,7 +242,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set build additional args run: | @@ -256,7 +256,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.1.0 + uses: docker/login-action@v3.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -279,7 +279,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -320,7 +320,7 @@ jobs: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Install Cosign uses: sigstore/cosign-installer@v3.5.0 @@ -329,14 +329,14 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.1.0 + uses: docker/login-action@v3.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.1.0 + uses: docker/login-action@v3.2.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -450,7 +450,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 10353f39bdb..af29c00af9e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,10 +33,10 @@ on: type: boolean env: - CACHE_VERSION: 8 + CACHE_VERSION: 9 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 8 - HA_SHORT_VERSION: "2024.6" + HA_SHORT_VERSION: "2024.7" DEFAULT_PYTHON: "3.12" ALL_PYTHON_VERSIONS: "['3.12']" # 10.3 is the oldest supported version @@ -89,7 +89,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Generate partial Python venv restore key id: generate_python_cache_key run: | @@ -226,7 +226,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.1.0 @@ -272,7 +272,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.0 id: python @@ -312,7 +312,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.0 id: python @@ -351,7 +351,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.0 id: python @@ -445,7 +445,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.1.0 @@ -522,7 +522,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.1.0 @@ -554,7 +554,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.1.0 @@ -587,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.1.0 @@ -611,14 +611,59 @@ jobs: run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant + pylint --ignore-missing-annotations=y homeassistant - name: Run pylint (partially) if: needs.info.outputs.test_full_suite == 'false' shell: bash run: | . venv/bin/activate python --version - pylint --ignore-missing-annotations=y --ignore-wrong-coordinator-module=y homeassistant/components/${{ needs.info.outputs.integrations_glob }} + pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }} + + pylint-tests: + name: Check pylint on tests + runs-on: ubuntu-22.04 + timeout-minutes: 20 + if: | + (github.event.inputs.mypy-only != 'true' || github.event.inputs.pylint-only == 'true') + && (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true') + needs: + - info + - base + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.7 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + id: python + uses: actions/setup-python@v5.1.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + check-latest: true + - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + id: cache-venv + uses: actions/cache/restore@v4.0.2 + with: + path: venv + fail-on-cache-miss: true + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.python_cache_key }} + - name: Register pylint problem matcher + run: | + echo "::add-matcher::.github/workflows/matchers/pylint.json" + - name: Run pylint (fully) + if: needs.info.outputs.test_full_suite == 'true' + run: | + . venv/bin/activate + python --version + pylint --ignore-missing-annotations=y tests + - name: Run pylint (partially) + if: needs.info.outputs.test_full_suite == 'false' + shell: bash + run: | + . venv/bin/activate + python --version + pylint --ignore-missing-annotations=y tests/components/${{ needs.info.outputs.tests_glob }} mypy: name: Check mypy @@ -631,7 +676,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.1.0 @@ -704,7 +749,7 @@ jobs: ffmpeg \ libgammu-dev - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.1.0 @@ -746,6 +791,7 @@ jobs: - hassfest - lint-other - lint-ruff + - lint-ruff-format - mypy - prepare-pytest-full strategy: @@ -765,7 +811,7 @@ jobs: ffmpeg \ libgammu-dev - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.1.0 @@ -863,6 +909,7 @@ jobs: - hassfest - lint-other - lint-ruff + - lint-ruff-format - mypy strategy: fail-fast: false @@ -881,7 +928,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.1.0 @@ -986,6 +1033,7 @@ jobs: - hassfest - lint-other - lint-ruff + - lint-ruff-format - mypy strategy: fail-fast: false @@ -1004,7 +1052,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.1.0 @@ -1099,18 +1147,19 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Download all coverage artifacts uses: actions/download-artifact@v4.1.7 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v4.3.1 + uses: codecov/codecov-action@v4.5.0 with: fail_ci_if_error: true flags: full-suite token: ${{ secrets.CODECOV_TOKEN }} + version: v0.6.0 pytest-partial: runs-on: ubuntu-22.04 @@ -1128,6 +1177,7 @@ jobs: - hassfest - lint-other - lint-ruff + - lint-ruff-format - mypy strategy: fail-fast: false @@ -1146,7 +1196,7 @@ jobs: ffmpeg \ libgammu-dev - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.1.0 @@ -1233,14 +1283,15 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Download all coverage artifacts uses: actions/download-artifact@v4.1.7 with: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v4.3.1 + uses: codecov/codecov-action@v4.5.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} + version: v0.6.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4f624c582d7..641f349408a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,14 +21,14 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.25.3 + uses: github/codeql-action/init@v3.25.10 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.25.3 + uses: github/codeql-action/analyze@v3.25.10 with: category: "/language:python" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 3cf5a7ed089..318a1898987 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -10,7 +10,7 @@ on: - "**strings.json" env: - DEFAULT_PYTHON: "3.11" + DEFAULT_PYTHON: "3.12" jobs: upload: @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v5.1.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 8edee24a524..f197a80b294 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -32,7 +32,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python @@ -118,7 +118,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Download env_file uses: actions/download-artifact@v4.1.7 @@ -156,7 +156,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.4 + uses: actions/checkout@v4.1.7 - name: Download env_file uses: actions/download-artifact@v4.1.7 diff --git a/.gitignore b/.gitignore index 206595f06c9..9bbf5bb81d4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ Icon # GITHUB Proposed Python stuff: *.py[cod] +__pycache__ # C extensions *.so diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07d6c785168..023f917d89c 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.4.3 + rev: v0.4.9 hooks: - id: ruff args: @@ -8,11 +8,11 @@ repos: - id: ruff-format files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$ - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell args: - - --ignore-words-list=additionals,alle,alot,astroid,bund,caf,convencional,currenty,datas,farenheit,falsy,fo,frequence,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,vor,withing,zar + - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn,pres,ser,ue - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json, html] @@ -61,15 +61,15 @@ repos: name: mypy entry: script/run-in-env.sh mypy language: script - types: [python] + types_or: [python, pyi] require_serial: true files: ^(homeassistant|pylint)/.+\.(py|pyi)$ - id: pylint name: pylint entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y language: script - types: [python] - files: ^homeassistant/.+\.py$ + types_or: [python, pyi] + files: ^(homeassistant|tests)/.+\.(py|pyi)$ - id: gen_requirements_all name: gen_requirements_all entry: script/run-in-env.sh python3 -m script.gen_requirements_all diff --git a/.strict-typing b/.strict-typing index 28f484b3334..2a6edfedd32 100644 --- a/.strict-typing +++ b/.strict-typing @@ -48,6 +48,7 @@ homeassistant.components.adax.* homeassistant.components.adguard.* homeassistant.components.aftership.* homeassistant.components.air_quality.* +homeassistant.components.airgradient.* homeassistant.components.airly.* homeassistant.components.airnow.* homeassistant.components.airq.* @@ -65,7 +66,6 @@ homeassistant.components.alexa.* homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* -homeassistant.components.ambiclimate.* homeassistant.components.ambient_network.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* @@ -84,6 +84,7 @@ homeassistant.components.api.* homeassistant.components.apple_tv.* homeassistant.components.apprise.* homeassistant.components.aprs.* +homeassistant.components.apsystems.* homeassistant.components.aqualogic.* homeassistant.components.aquostv.* homeassistant.components.aranet.* @@ -260,6 +261,7 @@ homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* homeassistant.components.jvc_projector.* homeassistant.components.kaleidescape.* +homeassistant.components.knocki.* homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lacrosse.* @@ -301,6 +303,7 @@ homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* homeassistant.components.modem_callerid.* +homeassistant.components.monzo.* homeassistant.components.moon.* homeassistant.components.mopeka.* homeassistant.components.motionmount.* @@ -339,7 +342,6 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* -homeassistant.components.poolsense.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.prometheus.* @@ -427,6 +429,7 @@ homeassistant.components.tcp.* homeassistant.components.technove.* homeassistant.components.tedee.* homeassistant.components.text.* +homeassistant.components.thethingsnetwork.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index e0792a360f1..681698d08b3 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -4,5 +4,7 @@ // https://github.com/microsoft/vscode-python/issues/14067 "python.testing.pytestArgs": ["--no-cov"], // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings - "python.testing.pytestEnabled": false + "python.testing.pytestEnabled": false, + // https://code.visualstudio.com/docs/python/linting#_general-settings + "pylint.importStrategy": "fromEnvironment" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d6657f04557..23126fd4b52 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -103,7 +103,7 @@ { "label": "Install all Requirements", "type": "shell", - "command": "pip3 install -r requirements_all.txt", + "command": "uv pip install -r requirements_all.txt", "group": { "kind": "build", "isDefault": true @@ -117,7 +117,7 @@ { "label": "Install all Test Requirements", "type": "shell", - "command": "pip3 install -r requirements_test_all.txt", + "command": "uv pip install -r requirements_test_all.txt", "group": { "kind": "build", "isDefault": true diff --git a/CODEOWNERS b/CODEOWNERS index f1fb578155b..9b23b5cc83a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,8 @@ build.json @home-assistant/supervisor /tests/components/agent_dvr/ @ispysoftware /homeassistant/components/air_quality/ @home-assistant/core /tests/components/air_quality/ @home-assistant/core +/homeassistant/components/airgradient/ @airgradienthq @joostlek +/tests/components/airgradient/ @airgradienthq @joostlek /homeassistant/components/airly/ @bieniu /tests/components/airly/ @bieniu /homeassistant/components/airnow/ @asymworks @@ -78,18 +80,17 @@ build.json @home-assistant/supervisor /tests/components/airzone/ @Noltari /homeassistant/components/airzone_cloud/ @Noltari /tests/components/airzone_cloud/ @Noltari -/homeassistant/components/aladdin_connect/ @mkmer -/tests/components/aladdin_connect/ @mkmer +/homeassistant/components/aladdin_connect/ @swcloudgenie +/tests/components/aladdin_connect/ @swcloudgenie /homeassistant/components/alarm_control_panel/ @home-assistant/core /tests/components/alarm_control_panel/ @home-assistant/core /homeassistant/components/alert/ @home-assistant/core @frenck /tests/components/alert/ @home-assistant/core @frenck /homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh +/homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot -/homeassistant/components/ambiclimate/ @danielhiversen -/tests/components/ambiclimate/ @danielhiversen /homeassistant/components/ambient_network/ @thomaskistler /tests/components/ambient_network/ @thomaskistler /homeassistant/components/ambient_station/ @bachya @@ -127,6 +128,10 @@ build.json @home-assistant/supervisor /tests/components/aprilaire/ @chamberlain2007 /homeassistant/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW +/homeassistant/components/apsystems/ @mawoka-myblock @SonnenladenGmbH +/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH +/homeassistant/components/aquacell/ @Jordi1990 +/tests/components/aquacell/ @Jordi1990 /homeassistant/components/aranet/ @aschmitz @thecode @anrijs /tests/components/aranet/ @aschmitz @thecode @anrijs /homeassistant/components/arcam_fmj/ @elupus @@ -161,6 +166,8 @@ build.json @home-assistant/supervisor /tests/components/awair/ @ahayworth @danielsjf /homeassistant/components/axis/ @Kane610 /tests/components/axis/ @Kane610 +/homeassistant/components/azure_data_explorer/ @kaareseras +/tests/components/azure_data_explorer/ @kaareseras /homeassistant/components/azure_devops/ @timmo001 /tests/components/azure_devops/ @timmo001 /homeassistant/components/azure_event_hub/ @eavanvalkenburg @@ -180,8 +187,8 @@ build.json @home-assistant/supervisor /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @riokuu @swistakm -/tests/components/blebox/ @bbx-a @riokuu @swistakm +/homeassistant/components/blebox/ @bbx-a @swistakm +/tests/components/blebox/ @bbx-a @swistakm /homeassistant/components/blink/ @fronzbot @mkmer /tests/components/blink/ @fronzbot @mkmer /homeassistant/components/blue_current/ @Floris272 @gleeuwen @@ -232,7 +239,6 @@ build.json @home-assistant/supervisor /tests/components/ccm15/ @ocalvo /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren -/homeassistant/components/circuit/ @braam /homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl /homeassistant/components/cisco_webex_teams/ @fbradyirl @@ -338,8 +344,8 @@ build.json @home-assistant/supervisor /tests/components/drop_connect/ @ChandlerSystems @pfrazer /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck -/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox -/tests/components/dsmr_reader/ @sorted-bits @glodenox +/homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna +/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo @@ -375,7 +381,7 @@ build.json @home-assistant/supervisor /homeassistant/components/elvia/ @ludeeus /tests/components/elvia/ @ludeeus /homeassistant/components/emby/ @mezz64 -/homeassistant/components/emoncms/ @borpin +/homeassistant/components/emoncms/ @borpin @alexandrecuer /homeassistant/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco /homeassistant/components/emulated_hue/ @bdraco @Tho85 @@ -654,7 +660,8 @@ build.json @home-assistant/supervisor /tests/components/imgw_pib/ @bieniu /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery -/homeassistant/components/incomfort/ @zxdavb +/homeassistant/components/incomfort/ @jbouwh +/tests/components/incomfort/ @jbouwh /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 /homeassistant/components/inkbird/ @bdraco @@ -692,10 +699,14 @@ build.json @home-assistant/supervisor /homeassistant/components/iqvia/ @bachya /tests/components/iqvia/ @bachya /homeassistant/components/irish_rail_transport/ @ttroy50 +/homeassistant/components/isal/ @bdraco +/tests/components/isal/ @bdraco /homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair /tests/components/islamic_prayer_times/ @engrbm87 @cpfair /homeassistant/components/iss/ @DurgNomis-drol /tests/components/iss/ @DurgNomis-drol +/homeassistant/components/ista_ecotrend/ @tr4nt0r +/tests/components/ista_ecotrend/ @tr4nt0r /homeassistant/components/isy994/ @bdraco @shbatm /tests/components/isy994/ @bdraco @shbatm /homeassistant/components/izone/ @Swamp-Ig @@ -726,6 +737,8 @@ build.json @home-assistant/supervisor /tests/components/kitchen_sink/ @home-assistant/core /homeassistant/components/kmtronic/ @dgomes /tests/components/kmtronic/ @dgomes +/homeassistant/components/knocki/ @joostlek @jgatto1 +/tests/components/knocki/ @joostlek @jgatto1 /homeassistant/components/knx/ @Julius2342 @farmio @marvin-w /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund @@ -815,6 +828,8 @@ build.json @home-assistant/supervisor /tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter +/homeassistant/components/mealie/ @joostlek +/tests/components/mealie/ @joostlek /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery /homeassistant/components/medcom_ble/ @elafargue @@ -826,6 +841,8 @@ build.json @home-assistant/supervisor /homeassistant/components/media_source/ @hunterjm /tests/components/media_source/ @hunterjm /homeassistant/components/mediaroom/ @dgomes +/homeassistant/components/melcloud/ @erwindouna +/tests/components/melcloud/ @erwindouna /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator @@ -867,6 +884,8 @@ build.json @home-assistant/supervisor /tests/components/moehlenhoff_alpha2/ @j-a-n /homeassistant/components/monoprice/ @etsinko @OnFreund /tests/components/monoprice/ @etsinko @OnFreund +/homeassistant/components/monzo/ @jakemartin-icl +/tests/components/monzo/ @jakemartin-icl /homeassistant/components/moon/ @fabaff @frenck /tests/components/moon/ @fabaff @frenck /homeassistant/components/mopeka/ @bdraco @@ -896,8 +915,8 @@ build.json @home-assistant/supervisor /tests/components/myuplink/ @pajzo @astrandb /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu -/homeassistant/components/nanoleaf/ @milanmeu -/tests/components/nanoleaf/ @milanmeu +/homeassistant/components/nanoleaf/ @milanmeu @joostlek +/tests/components/nanoleaf/ @milanmeu @joostlek /homeassistant/components/neato/ @Santobert /tests/components/neato/ @Santobert /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @@ -1086,6 +1105,8 @@ build.json @home-assistant/supervisor /tests/components/pvoutput/ @frenck /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue +/homeassistant/components/pyload/ @tr4nt0r +/tests/components/pyload/ @tr4nt0r /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 /tests/components/qbittorrent/ @geoffreylagaisse @finder39 /homeassistant/components/qingping/ @bdraco @@ -1273,8 +1294,6 @@ build.json @home-assistant/supervisor /tests/components/smappee/ @bsmappee /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler -/homeassistant/components/smartthings/ @andrewsayre -/tests/components/smartthings/ @andrewsayre /homeassistant/components/smarttub/ @mdz /tests/components/smarttub/ @mdz /homeassistant/components/smarty/ @z0mbieprocess @@ -1291,8 +1310,8 @@ build.json @home-assistant/supervisor /homeassistant/components/solaredge/ @frenck @bdraco /tests/components/solaredge/ @frenck @bdraco /homeassistant/components/solaredge_local/ @drobtravels @scheric -/homeassistant/components/solarlog/ @Ernst79 -/tests/components/solarlog/ @Ernst79 +/homeassistant/components/solarlog/ @Ernst79 @dontinelli +/tests/components/solarlog/ @Ernst79 @dontinelli /homeassistant/components/solax/ @squishykid /tests/components/solax/ @squishykid /homeassistant/components/soma/ @ratsept @sebfortier2288 @@ -1361,8 +1380,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski -/homeassistant/components/switchbot_cloud/ @SeraphicRav -/tests/components/switchbot_cloud/ @SeraphicRav +/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland +/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland /homeassistant/components/switcher_kis/ @thecode /tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li @@ -1415,7 +1434,8 @@ build.json @home-assistant/supervisor /tests/components/thermobeacon/ @bdraco /homeassistant/components/thermopro/ @bdraco @h3ss /tests/components/thermopro/ @bdraco @h3ss -/homeassistant/components/thethingsnetwork/ @fabaff +/homeassistant/components/thethingsnetwork/ @angelnu +/tests/components/thethingsnetwork/ @angelnu /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core /homeassistant/components/tibber/ @danielhiversen @@ -1479,8 +1499,6 @@ build.json @home-assistant/supervisor /tests/components/unifi/ @Kane610 /homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk -/homeassistant/components/unifiprotect/ @AngellusMortis @bdraco -/tests/components/unifiprotect/ @AngellusMortis @bdraco /homeassistant/components/upb/ @gwww /tests/components/upb/ @gwww /homeassistant/components/upc_connect/ @pvizeli @fabaff diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index fab04fe3972..45dd06fbe7e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -5,7 +5,7 @@ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, +identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation. diff --git a/Dockerfile b/Dockerfile index 93865bc21f8..925f6370624 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.1.39 +RUN pip3 install uv==0.2.13 WORKDIR /usr/src diff --git a/Dockerfile.dev b/Dockerfile.dev index 507cc9a7bb2..d7a2f2b7bf9 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -35,21 +35,30 @@ RUN \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# Install uv +RUN pip3 install uv + WORKDIR /usr/src # Setup hass-release RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ - && pip3 install -e hass-release/ + && uv pip install --system -e hass-release/ -WORKDIR /workspaces +USER vscode +ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" +RUN uv venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +WORKDIR /tmp # Install Python dependencies from requirements COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt -RUN pip3 install -r requirements.txt +RUN uv pip install -r requirements.txt COPY requirements_test.txt requirements_test_pre_commit.txt ./ -RUN pip3 install -r requirements_test.txt -RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ +RUN uv pip install -r requirements_test.txt + +WORKDIR /workspaces # Set the default shell to bash instead of sh ENV SHELL /bin/bash diff --git a/build.yaml b/build.yaml index 044358b1f9d..13618740ab8 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.03.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.03.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.03.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.03.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.03.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 0c0d535753c..4c870e94b24 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from contextlib import suppress import faulthandler import os import sys @@ -208,8 +209,10 @@ def main() -> int: exit_code = runner.run(runtime_conf) faulthandler.disable() - if os.path.getsize(fault_file_name) == 0: - os.remove(fault_file_name) + # It's possible for the fault file to disappear, so suppress obvious errors + with suppress(FileNotFoundError): + if os.path.getsize(fault_file_name) == 0: + os.remove(fault_file_name) check_threads() diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2a9525181f6..c39657b6147 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -28,15 +28,14 @@ from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRA from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .models import AuthFlowResult from .providers import AuthProvider, LoginFlow, auth_provider_from_config -from .session import SessionManager EVENT_USER_ADDED = "user_added" EVENT_USER_UPDATED = "user_updated" EVENT_USER_REMOVED = "user_removed" -_MfaModuleDict = dict[str, MultiFactorAuthModule] -_ProviderKey = tuple[str, str | None] -_ProviderDict = dict[_ProviderKey, AuthProvider] +type _MfaModuleDict = dict[str, MultiFactorAuthModule] +type _ProviderKey = tuple[str, str | None] +type _ProviderDict = dict[_ProviderKey, AuthProvider] class InvalidAuthError(Exception): @@ -54,7 +53,7 @@ async def auth_manager_from_config( ) -> AuthManager: """Initialize an auth manager from config. - CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or + CORE_CONFIG_SCHEMA will make sure no duplicated auth providers or mfa modules exist in configs. """ store = auth_store.AuthStore(hass) @@ -181,7 +180,6 @@ class AuthManager: self._remove_expired_job = HassJob( self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback ) - self.session = SessionManager(hass, self) async def async_setup(self) -> None: """Set up the auth manager.""" @@ -192,7 +190,6 @@ class AuthManager: ) ) self._async_track_next_refresh_token_expiration() - await self.session.async_setup() @property def auth_providers(self) -> list[AuthProvider]: @@ -519,6 +516,13 @@ class AuthManager: for revoke_callback in callbacks: revoke_callback() + @callback + def async_set_expiry( + self, refresh_token: models.RefreshToken, *, enable_expiry: bool + ) -> None: + """Enable or disable expiry of a refresh token.""" + self._store.async_set_expiry(refresh_token, enable_expiry=enable_expiry) + @callback def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None: """Remove expired refresh tokens.""" diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index b3481acca3c..3bf025c058c 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -62,6 +62,7 @@ class AuthStore: self._store = Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True ) + self._token_id_to_user_id: dict[str, str] = {} async def async_get_groups(self) -> list[models.Group]: """Retrieve all users.""" @@ -135,7 +136,10 @@ class AuthStore: async def async_remove_user(self, user: models.User) -> None: """Remove a user.""" - self._users.pop(user.id) + user = self._users.pop(user.id) + for refresh_token_id in user.refresh_tokens: + del self._token_id_to_user_id[refresh_token_id] + user.refresh_tokens.clear() self._async_schedule_save() async def async_update_user( @@ -218,7 +222,9 @@ class AuthStore: kwargs["client_icon"] = client_icon refresh_token = models.RefreshToken(**kwargs) - user.refresh_tokens[refresh_token.id] = refresh_token + token_id = refresh_token.id + user.refresh_tokens[token_id] = refresh_token + self._token_id_to_user_id[token_id] = user.id self._async_schedule_save() return refresh_token @@ -226,19 +232,17 @@ class AuthStore: @callback def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None: """Remove a refresh token.""" - for user in self._users.values(): - if user.refresh_tokens.pop(refresh_token.id, None): - self._async_schedule_save() - break + refresh_token_id = refresh_token.id + if user_id := self._token_id_to_user_id.get(refresh_token_id): + del self._users[user_id].refresh_tokens[refresh_token_id] + del self._token_id_to_user_id[refresh_token_id] + self._async_schedule_save() @callback def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None: """Get refresh token by id.""" - for user in self._users.values(): - refresh_token = user.refresh_tokens.get(token_id) - if refresh_token is not None: - return refresh_token - + if user_id := self._token_id_to_user_id.get(token_id): + return self._users[user_id].refresh_tokens.get(token_id) return None @callback @@ -277,6 +281,21 @@ class AuthStore: ) self._async_schedule_save() + @callback + def async_set_expiry( + self, refresh_token: models.RefreshToken, *, enable_expiry: bool + ) -> None: + """Enable or disable expiry of a refresh token.""" + if enable_expiry: + if refresh_token.expire_at is None: + refresh_token.expire_at = ( + refresh_token.last_used_at or dt_util.utcnow() + ).timestamp() + REFRESH_TOKEN_EXPIRATION + self._async_schedule_save() + else: + refresh_token.expire_at = None + self._async_schedule_save() + async def async_load(self) -> None: # noqa: C901 """Load the users.""" if self._loaded: @@ -290,8 +309,6 @@ class AuthStore: perm_lookup = PermissionLookup(ent_reg, dev_reg) self._perm_lookup = perm_lookup - now_ts = dt_util.utcnow().timestamp() - if data is None or not isinstance(data, dict): self._set_defaults() return @@ -445,14 +462,6 @@ class AuthStore: else: last_used_at = None - if ( - expire_at := rt_dict.get("expire_at") - ) is None and token_type == models.TOKEN_TYPE_NORMAL: - if last_used_at: - expire_at = last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION - else: - expire_at = now_ts + REFRESH_TOKEN_EXPIRATION - token = models.RefreshToken( id=rt_dict["id"], user=users[rt_dict["user_id"]], @@ -469,7 +478,7 @@ class AuthStore: jwt_key=rt_dict["jwt_key"], last_used_at=last_used_at, last_used_ip=rt_dict.get("last_used_ip"), - expire_at=expire_at, + expire_at=rt_dict.get("expire_at"), version=rt_dict.get("version"), ) if "credential_id" in rt_dict: @@ -478,9 +487,18 @@ class AuthStore: self._groups = groups self._users = users - + self._build_token_id_to_user_id() self._async_schedule_save(INITIAL_LOAD_SAVE_DELAY) + @callback + def _build_token_id_to_user_id(self) -> None: + """Build a map of token id to user id.""" + self._token_id_to_user_id = { + token_id: user_id + for user_id, user in self._users.items() + for token_id in user.refresh_tokens + } + @callback def _async_schedule_save(self, delay: float = DEFAULT_SAVE_DELAY) -> None: """Save users.""" @@ -574,6 +592,7 @@ class AuthStore: read_only_group = _system_read_only_group() groups[read_only_group.id] = read_only_group self._groups = groups + self._build_token_id_to_user_id() def _system_admin_group() -> models.Group: diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index fd4072ea88a..d57a274c7ff 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -16,6 +16,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.importlib import async_import_module from homeassistant.util.decorator import Registry +from homeassistant.util.hass_dict import HassKey MULTI_FACTOR_AUTH_MODULES: Registry[str, type[MultiFactorAuthModule]] = Registry() @@ -29,7 +30,7 @@ MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -DATA_REQS = "mfa_auth_module_reqs_processed" +DATA_REQS: HassKey[set[str]] = HassKey("mfa_auth_module_reqs_processed") _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 72edb195a81..d2010dc2c9d 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -88,7 +88,7 @@ class NotifySetting: target: str | None = attr.ib(default=None) -_UsersDict = dict[str, NotifySetting] +type _UsersDict = dict[str, NotifySetting] @MULTI_FACTOR_AUTH_MODULES.register("notify") diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py index 3411ae860fb..a4bef86241b 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -4,17 +4,17 @@ from collections.abc import Mapping # MyPy doesn't support recursion yet. So writing it out as far as we need. -ValueType = ( +type ValueType = ( # Example: entities.all = { read: true, control: true } Mapping[str, bool] | bool | None ) # Example: entities.domains = { light: … } -SubCategoryDict = Mapping[str, ValueType] +type SubCategoryDict = Mapping[str, ValueType] -SubCategoryType = SubCategoryDict | bool | None +type SubCategoryType = SubCategoryDict | bool | None -CategoryType = ( +type CategoryType = ( # Example: entities.domains Mapping[str, SubCategoryType] # Example: entities.all @@ -24,4 +24,4 @@ CategoryType = ( ) # Example: { entities: … } -PolicyType = Mapping[str, CategoryType] +type PolicyType = Mapping[str, CategoryType] diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index db85e18f60c..e1d1f660d75 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -10,8 +10,8 @@ from .const import SUBCAT_ALL from .models import PermissionLookup from .types import CategoryType, SubCategoryDict, ValueType -LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], ValueType | None] -SubCatLookupType = dict[str, LookupFunc] +type LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], ValueType | None] +type SubCatLookupType = dict[str, LookupFunc] def lookup_all( diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 63028f54d2e..debdd0b1a05 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -17,13 +17,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.importlib import async_import_module from homeassistant.util import dt as dt_util from homeassistant.util.decorator import Registry +from homeassistant.util.hass_dict import HassKey from ..auth_store import AuthStore from ..const import MFA_SESSION_EXPIRATION from ..models import AuthFlowResult, Credentials, RefreshToken, User, UserMeta _LOGGER = logging.getLogger(__name__) -DATA_REQS = "auth_prov_reqs_processed" +DATA_REQS: HassKey[set[str]] = HassKey("auth_prov_reqs_processed") AUTH_PROVIDERS: Registry[str, type[AuthProvider]] = Registry() diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py deleted file mode 100644 index f04490a354e..00000000000 --- a/homeassistant/auth/providers/legacy_api_password.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Support Legacy API password auth provider. - -It will be removed when auth system production ready -""" - -from __future__ import annotations - -from collections.abc import Mapping -import hmac -from typing import Any, cast - -import voluptuous as vol - -from homeassistant.core import async_get_hass, callback -from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue - -from ..models import AuthFlowResult, Credentials, UserMeta -from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow - -AUTH_PROVIDER_TYPE = "legacy_api_password" -CONF_API_PASSWORD = "api_password" - -_CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( - {vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA -) - - -def _create_repair_and_validate(config: dict[str, Any]) -> dict[str, Any]: - async_create_issue( - async_get_hass(), - "auth", - "deprecated_legacy_api_password", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_legacy_api_password", - ) - - return _CONFIG_SCHEMA(config) # type: ignore[no-any-return] - - -CONFIG_SCHEMA = _create_repair_and_validate - - -LEGACY_USER_NAME = "Legacy API password user" - - -class InvalidAuthError(HomeAssistantError): - """Raised when submitting invalid authentication.""" - - -@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE) -class LegacyApiPasswordAuthProvider(AuthProvider): - """An auth provider support legacy api_password.""" - - DEFAULT_TITLE = "Legacy API Password" - - @property - def api_password(self) -> str: - """Return api_password.""" - return str(self.config[CONF_API_PASSWORD]) - - async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow: - """Return a flow to login.""" - return LegacyLoginFlow(self) - - @callback - def async_validate_login(self, password: str) -> None: - """Validate password.""" - api_password = str(self.config[CONF_API_PASSWORD]) - - if not hmac.compare_digest( - api_password.encode("utf-8"), password.encode("utf-8") - ): - raise InvalidAuthError - - async def async_get_or_create_credentials( - self, flow_result: Mapping[str, str] - ) -> Credentials: - """Return credentials for this login.""" - credentials = await self.async_credentials() - if credentials: - return credentials[0] - - return self.async_create_credentials({}) - - async def async_user_meta_for_credentials( - self, credentials: Credentials - ) -> UserMeta: - """Return info for the user. - - Will be used to populate info when creating a new user. - """ - return UserMeta(name=LEGACY_USER_NAME, is_active=True) - - -class LegacyLoginFlow(LoginFlow): - """Handler for the login flow.""" - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> AuthFlowResult: - """Handle the step of the form.""" - errors = {} - - if user_input is not None: - try: - cast( - LegacyApiPasswordAuthProvider, self._auth_provider - ).async_validate_login(user_input["password"]) - except InvalidAuthError: - errors["base"] = "invalid_auth" - - if not errors: - return await self.async_finish({}) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema({vol.Required("password"): str}), - errors=errors, - ) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 32d1934e093..564633073fc 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -28,8 +28,8 @@ from .. import InvalidAuthError from ..models import AuthFlowResult, Credentials, RefreshToken, UserMeta from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow -IPAddress = IPv4Address | IPv6Address -IPNetwork = IPv4Network | IPv6Network +type IPAddress = IPv4Address | IPv6Address +type IPNetwork = IPv4Network | IPv6Network CONF_TRUSTED_NETWORKS = "trusted_networks" CONF_TRUSTED_USERS = "trusted_users" diff --git a/homeassistant/auth/session.py b/homeassistant/auth/session.py deleted file mode 100644 index 88297b50d90..00000000000 --- a/homeassistant/auth/session.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Session auth module.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -import secrets -from typing import TYPE_CHECKING, Final, TypedDict - -from aiohttp.web import Request -from aiohttp_session import Session, get_session, new_session -from cryptography.fernet import Fernet - -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.storage import Store -from homeassistant.util import dt as dt_util - -from .models import RefreshToken - -if TYPE_CHECKING: - from . import AuthManager - - -TEMP_TIMEOUT = timedelta(minutes=5) -TEMP_TIMEOUT_SECONDS = TEMP_TIMEOUT.total_seconds() - -SESSION_ID = "id" -STORAGE_VERSION = 1 -STORAGE_KEY = "auth.session" - - -class StrictConnectionTempSessionData: - """Data for accessing unauthorized resources for a short period of time.""" - - __slots__ = ("cancel_remove", "absolute_expiry") - - def __init__(self, cancel_remove: CALLBACK_TYPE) -> None: - """Initialize the temp session data.""" - self.cancel_remove: Final[CALLBACK_TYPE] = cancel_remove - self.absolute_expiry: Final[datetime] = dt_util.utcnow() + TEMP_TIMEOUT - - -class StoreData(TypedDict): - """Data to store.""" - - unauthorized_sessions: dict[str, str] - key: str - - -class SessionManager: - """Session manager.""" - - def __init__(self, hass: HomeAssistant, auth: AuthManager) -> None: - """Initialize the strict connection manager.""" - self._auth = auth - self._hass = hass - self._temp_sessions: dict[str, StrictConnectionTempSessionData] = {} - self._strict_connection_sessions: dict[str, str] = {} - self._store = Store[StoreData]( - hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True - ) - self._key: str | None = None - self._refresh_token_revoke_callbacks: dict[str, CALLBACK_TYPE] = {} - - @property - def key(self) -> str: - """Return the encryption key.""" - if self._key is None: - self._key = Fernet.generate_key().decode() - self._async_schedule_save() - return self._key - - async def async_validate_request_for_strict_connection_session( - self, - request: Request, - ) -> bool: - """Check if a request has a valid strict connection session.""" - session = await get_session(request) - if session.new or session.empty: - return False - result = self.async_validate_strict_connection_session(session) - if result is False: - session.invalidate() - return result - - @callback - def async_validate_strict_connection_session( - self, - session: Session, - ) -> bool: - """Validate a strict connection session.""" - if not (session_id := session.get(SESSION_ID)): - return False - - if token_id := self._strict_connection_sessions.get(session_id): - if self._auth.async_get_refresh_token(token_id): - return True - # refresh token is invalid, delete entry - self._strict_connection_sessions.pop(session_id) - self._async_schedule_save() - - if data := self._temp_sessions.get(session_id): - if dt_util.utcnow() <= data.absolute_expiry: - return True - # session expired, delete entry - self._temp_sessions.pop(session_id).cancel_remove() - - return False - - @callback - def _async_register_revoke_token_callback(self, refresh_token_id: str) -> None: - """Register a callback to revoke all sessions for a refresh token.""" - if refresh_token_id in self._refresh_token_revoke_callbacks: - return - - @callback - def async_invalidate_auth_sessions() -> None: - """Invalidate all sessions for a refresh token.""" - self._strict_connection_sessions = { - session_id: token_id - for session_id, token_id in self._strict_connection_sessions.items() - if token_id != refresh_token_id - } - self._async_schedule_save() - - self._refresh_token_revoke_callbacks[refresh_token_id] = ( - self._auth.async_register_revoke_token_callback( - refresh_token_id, async_invalidate_auth_sessions - ) - ) - - async def async_create_session( - self, - request: Request, - refresh_token: RefreshToken, - ) -> None: - """Create new session for given refresh token. - - Caller needs to make sure that the refresh token is valid. - By creating a session, we are implicitly revoking all other - sessions for the given refresh token as there is one refresh - token per device/user case. - """ - self._strict_connection_sessions = { - session_id: token_id - for session_id, token_id in self._strict_connection_sessions.items() - if token_id != refresh_token.id - } - - self._async_register_revoke_token_callback(refresh_token.id) - session_id = await self._async_create_new_session(request) - self._strict_connection_sessions[session_id] = refresh_token.id - self._async_schedule_save() - - async def async_create_temp_unauthorized_session(self, request: Request) -> None: - """Create a temporary unauthorized session.""" - session_id = await self._async_create_new_session( - request, max_age=int(TEMP_TIMEOUT_SECONDS) - ) - - @callback - def remove(_: datetime) -> None: - self._temp_sessions.pop(session_id, None) - - self._temp_sessions[session_id] = StrictConnectionTempSessionData( - async_call_later(self._hass, TEMP_TIMEOUT_SECONDS, remove) - ) - - async def _async_create_new_session( - self, - request: Request, - *, - max_age: int | None = None, - ) -> str: - session_id = secrets.token_hex(64) - - session = await new_session(request) - session[SESSION_ID] = session_id - if max_age is not None: - session.max_age = max_age - return session_id - - @callback - def _async_schedule_save(self, delay: float = 1) -> None: - """Save sessions.""" - self._store.async_delay_save(self._data_to_save, delay) - - @callback - def _data_to_save(self) -> StoreData: - """Return the data to store.""" - return StoreData( - unauthorized_sessions=self._strict_connection_sessions, - key=self.key, - ) - - async def async_setup(self) -> None: - """Set up session manager.""" - data = await self._store.async_load() - if data is None: - return - - self._key = data["key"] - self._strict_connection_sessions = data["unauthorized_sessions"] - for token_id in self._strict_connection_sessions.values(): - self._async_register_revoke_token_callback(token_id) diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index a2c187fc537..5b8ba535b5a 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -1,9 +1,15 @@ """Block blocking calls being done in asyncio.""" +import builtins +from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass +import glob from http.client import HTTPConnection import importlib +import os import sys +import threading import time from typing import Any @@ -12,12 +18,21 @@ from .util.loop import protect_loop _IN_TESTS = "unittest" in sys.modules +ALLOWED_FILE_PREFIXES = ("/proc",) + def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool: # If the module is already imported, we can ignore it. return bool((args := mapped_args.get("args")) and args[0] in sys.modules) +def _check_file_allowed(mapped_args: dict[str, Any]) -> bool: + # If the file is in /proc we can ignore it. + args = mapped_args["args"] + path = args[0] if type(args[0]) is str else str(args[0]) # noqa: E721 + return path.startswith(ALLOWED_FILE_PREFIXES) + + def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool: # # Avoid extracting the stack unless we need to since it @@ -25,7 +40,7 @@ def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool: # I/O and we are trying to avoid blocking calls. # # frame[0] is us - # frame[1] is check_loop + # frame[1] is raise_for_blocking_call # frame[2] is protected_loop_func # frame[3] is the offender with suppress(ValueError): @@ -33,28 +48,131 @@ def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool: return False +@dataclass(slots=True, frozen=True) +class BlockingCall: + """Class to hold information about a blocking call.""" + + original_func: Callable + object: object + function: str + check_allowed: Callable[[dict[str, Any]], bool] | None + strict: bool + strict_core: bool + skip_for_tests: bool + + +_BLOCKING_CALLS: tuple[BlockingCall, ...] = ( + BlockingCall( + original_func=HTTPConnection.putrequest, + object=HTTPConnection, + function="putrequest", + check_allowed=None, + strict=True, + strict_core=True, + skip_for_tests=False, + ), + BlockingCall( + original_func=time.sleep, + object=time, + function="sleep", + check_allowed=_check_sleep_call_allowed, + strict=True, + strict_core=True, + skip_for_tests=False, + ), + BlockingCall( + original_func=glob.glob, + object=glob, + function="glob", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=False, + ), + BlockingCall( + original_func=glob.iglob, + object=glob, + function="iglob", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=False, + ), + BlockingCall( + original_func=os.walk, + object=os, + function="walk", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=False, + ), + BlockingCall( + original_func=os.listdir, + object=os, + function="listdir", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=True, + ), + BlockingCall( + original_func=os.scandir, + object=os, + function="scandir", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=True, + ), + BlockingCall( + original_func=builtins.open, + object=builtins, + function="open", + check_allowed=_check_file_allowed, + strict=False, + strict_core=False, + skip_for_tests=True, + ), + BlockingCall( + original_func=importlib.import_module, + object=importlib, + function="import_module", + check_allowed=_check_import_call_allowed, + strict=False, + strict_core=False, + skip_for_tests=True, + ), +) + + +@dataclass(slots=True) +class BlockedCalls: + """Class to track which calls are blocked.""" + + calls: set[BlockingCall] + + +_BLOCKED_CALLS = BlockedCalls(set()) + + def enable() -> None: """Enable the detection of blocking calls in the event loop.""" - # Prevent urllib3 and requests doing I/O in event loop - HTTPConnection.putrequest = protect_loop( # type: ignore[method-assign] - HTTPConnection.putrequest - ) + calls = _BLOCKED_CALLS.calls + if calls: + raise RuntimeError("Blocking call detection is already enabled") - # Prevent sleeping in event loop. Non-strict since 2022.02 - time.sleep = protect_loop( - time.sleep, strict=False, check_allowed=_check_sleep_call_allowed - ) + loop_thread_id = threading.get_ident() + for blocking_call in _BLOCKING_CALLS: + if _IN_TESTS and blocking_call.skip_for_tests: + continue - # Currently disabled. pytz doing I/O when getting timezone. - # Prevent files being opened inside the event loop - # builtins.open = protect_loop(builtins.open) - - if not _IN_TESTS: - # unittest uses `importlib.import_module` to do mocking - # so we cannot protect it if we are running tests - importlib.import_module = protect_loop( - importlib.import_module, - strict_core=False, - strict=False, - check_allowed=_check_import_call_allowed, + protected_function = protect_loop( + blocking_call.original_func, + strict=blocking_call.strict, + strict_core=blocking_call.strict_core, + check_allowed=blocking_call.check_allowed, + loop_thread_id=loop_thread_id, ) + setattr(blocking_call.object, blocking_call.function, protected_function) + calls.add(blocking_call) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 741947a2e23..8435fe73d40 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -9,6 +9,7 @@ from functools import partial from itertools import chain import logging import logging.handlers +import mimetypes from operator import contains, itemgetter import os import platform @@ -62,6 +63,7 @@ from .components import ( ) from .components.sensor import recorder as sensor_recorder # noqa: F401 from .const import ( + BASE_PLATFORMS, FORMAT_DATETIME, KEY_DATA_LOGGING as DATA_LOGGING, REQUIRED_NEXT_PYTHON_HA_RELEASE, @@ -84,12 +86,11 @@ from .helpers import ( template, translation, ) -from .helpers.dispatcher import async_dispatcher_send +from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager from .helpers.system_info import async_get_system_info from .helpers.typing import ConfigType from .setup import ( - BASE_PLATFORMS, # _setup_started is marked as protected to make it clear # that it is not part of the public API and should not be used # by integrations. It is only used for internal tracking of @@ -101,6 +102,7 @@ from .setup import ( async_setup_component, ) from .util.async_ import create_eager_task +from .util.hass_dict import HassKey from .util.logging import async_activate_log_queue_handler from .util.package import async_get_user_site, is_virtual_env @@ -120,7 +122,7 @@ SETUP_ORDER_SORT_KEY = partial(contains, BASE_PLATFORMS) ERROR_LOG_FILENAME = "home-assistant.log" # hass.data key for logging information. -DATA_REGISTRIES_LOADED = "bootstrap_registries_loaded" +DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded") LOG_SLOW_STARTUP_INTERVAL = 60 SLOW_STARTUP_CHECK_INTERVAL = 1 @@ -132,8 +134,15 @@ COOLDOWN_TIME = 60 DEBUGGER_INTEGRATIONS = {"debugpy"} + +# Core integrations are unconditionally loaded CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"} -LOGGING_INTEGRATIONS = { + +# Integrations that are loaded right after the core is set up +LOGGING_AND_HTTP_DEPS_INTEGRATIONS = { + # isal is loaded right away before `http` to ensure if its + # enabled, that `isal` is up to date. + "isal", # Set log levels "logger", # Error logging @@ -212,8 +221,8 @@ CRITICAL_INTEGRATIONS = { } SETUP_ORDER = ( - # Load logging as soon as possible - ("logging", LOGGING_INTEGRATIONS), + # Load logging and http deps as soon as possible + ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS), # Setup frontend and recorder ("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}), # Start up debuggers. Start these first in case they want to wait. @@ -247,22 +256,39 @@ async def async_setup_hass( runtime_config: RuntimeConfig, ) -> core.HomeAssistant | None: """Set up Home Assistant.""" - hass = core.HomeAssistant(runtime_config.config_dir) - async_enable_logging( - hass, - runtime_config.verbose, - runtime_config.log_rotate_days, - runtime_config.log_file, - runtime_config.log_no_color, - ) + def create_hass() -> core.HomeAssistant: + """Create the hass object and do basic setup.""" + hass = core.HomeAssistant(runtime_config.config_dir) + loader.async_setup(hass) - if runtime_config.debug or hass.loop.get_debug(): - hass.config.debug = True + async_enable_logging( + hass, + runtime_config.verbose, + runtime_config.log_rotate_days, + runtime_config.log_file, + runtime_config.log_no_color, + ) + + if runtime_config.debug or hass.loop.get_debug(): + hass.config.debug = True + + hass.config.safe_mode = runtime_config.safe_mode + hass.config.skip_pip = runtime_config.skip_pip + hass.config.skip_pip_packages = runtime_config.skip_pip_packages + + return hass + + async def stop_hass(hass: core.HomeAssistant) -> None: + """Stop hass.""" + # Ask integrations to shut down. It's messy but we can't + # do a clean stop without knowing what is broken + with contextlib.suppress(TimeoutError): + async with hass.timeout.async_timeout(10): + await hass.async_stop() + + hass = create_hass() - hass.config.safe_mode = runtime_config.safe_mode - hass.config.skip_pip = runtime_config.skip_pip - hass.config.skip_pip_packages = runtime_config.skip_pip_packages if runtime_config.skip_pip or runtime_config.skip_pip_packages: _LOGGER.warning( "Skipping pip installation of required modules. This may cause issues" @@ -274,7 +300,6 @@ async def async_setup_hass( _LOGGER.info("Config directory: %s", runtime_config.config_dir) - loader.async_setup(hass) block_async_io.enable() config_dict = None @@ -300,27 +325,28 @@ async def async_setup_hass( if config_dict is None: recovery_mode = True + await stop_hass(hass) + hass = create_hass() elif not basic_setup_success: _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") recovery_mode = True + await stop_hass(hass) + hass = create_hass() elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS): _LOGGER.warning( "Detected that %s did not load. Activating recovery mode", ",".join(CRITICAL_INTEGRATIONS), ) - # Ask integrations to shut down. It's messy but we can't - # do a clean stop without knowing what is broken - with contextlib.suppress(TimeoutError): - async with hass.timeout.async_timeout(10): - await hass.async_stop() - recovery_mode = True old_config = hass.config old_logging = hass.data.get(DATA_LOGGING) - hass = core.HomeAssistant(old_config.config_dir) + recovery_mode = True + await stop_hass(hass) + hass = create_hass() + if old_logging: hass.data[DATA_LOGGING] = old_logging hass.config.debug = old_config.debug @@ -370,23 +396,24 @@ def open_hass_ui(hass: core.HomeAssistant) -> None: ) +def _init_blocking_io_modules_in_executor() -> None: + """Initialize modules that do blocking I/O in executor.""" + # Cache the result of platform.uname().processor in the executor. + # Multiple modules call this function at startup which + # executes a blocking subprocess call. This is a problem for the + # asyncio event loop. By priming the cache of uname we can + # avoid the blocking call in the event loop. + _ = platform.uname().processor + # Initialize the mimetypes module to avoid blocking calls + # to the filesystem to load the mime.types file. + mimetypes.init() + + async def async_load_base_functionality(hass: core.HomeAssistant) -> None: - """Load the registries and cache the result of platform.uname().processor.""" + """Load the registries and modules that will do blocking I/O.""" if DATA_REGISTRIES_LOADED in hass.data: return hass.data[DATA_REGISTRIES_LOADED] = None - - def _cache_uname_processor() -> None: - """Cache the result of platform.uname().processor in the executor. - - Multiple modules call this function at startup which - executes a blocking subprocess call. This is a problem for the - asyncio event loop. By primeing the cache of uname we can - avoid the blocking call in the event loop. - """ - _ = platform.uname().processor - - # Load the registries and cache the result of platform.uname().processor translation.async_setup(hass) entity.async_setup(hass) template.async_setup(hass) @@ -399,7 +426,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: create_eager_task(floor_registry.async_load(hass)), create_eager_task(issue_registry.async_load(hass)), create_eager_task(label_registry.async_load(hass)), - hass.async_add_executor_job(_cache_uname_processor), + hass.async_add_executor_job(_init_blocking_io_modules_in_executor), create_eager_task(template.async_load_custom_templates(hass)), create_eager_task(restore_state.async_load(hass)), create_eager_task(hass.config_entries.async_initialize()), @@ -418,6 +445,9 @@ async def async_from_config_dict( start = monotonic() hass.config_entries = config_entries.ConfigEntries(hass, config) + # Prime custom component cache early so we know if registry entries are tied + # to a custom integration + await loader.async_get_custom_components(hass) await async_load_base_functionality(hass) # Set up core. @@ -426,7 +456,11 @@ async def async_from_config_dict( if not all( await asyncio.gather( *( - create_eager_task(async_setup_component(hass, domain, config)) + create_eager_task( + async_setup_component(hass, domain, config), + name=f"bootstrap setup {domain}", + loop=hass.loop, + ) for domain in CORE_INTEGRATIONS ) ) @@ -680,7 +714,7 @@ class _WatchPendingSetups: if remaining_with_setup_started: _LOGGER.debug("Integration remaining: %s", remaining_with_setup_started) - elif waiting_tasks := self._hass._active_tasks: # pylint: disable=protected-access + elif waiting_tasks := self._hass._active_tasks: # noqa: SLF001 _LOGGER.debug("Waiting on tasks: %s", waiting_tasks) self._async_dispatch(remaining_with_setup_started) if ( @@ -700,7 +734,7 @@ class _WatchPendingSetups: def _async_dispatch(self, remaining_with_setup_started: dict[str, float]) -> None: """Dispatch the signal.""" if remaining_with_setup_started or not self._previous_was_empty: - async_dispatcher_send( + async_dispatcher_send_internal( self._hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started ) self._previous_was_empty = not remaining_with_setup_started @@ -984,7 +1018,7 @@ async def _async_set_up_integrations( except TimeoutError: _LOGGER.warning( "Setup timed out for stage 1 waiting on %s - moving forward", - hass._active_tasks, # pylint: disable=protected-access + hass._active_tasks, # noqa: SLF001 ) # Add after dependencies when setting up stage 2 domains @@ -1000,7 +1034,7 @@ async def _async_set_up_integrations( except TimeoutError: _LOGGER.warning( "Setup timed out for stage 2 waiting on %s - moving forward", - hass._active_tasks, # pylint: disable=protected-access + hass._active_tasks, # noqa: SLF001 ) # Wrap up startup @@ -1011,7 +1045,7 @@ async def _async_set_up_integrations( except TimeoutError: _LOGGER.warning( "Setup timed out for bootstrap waiting on %s - moving forward", - hass._active_tasks, # pylint: disable=protected-access + hass._active_tasks, # noqa: SLF001 ) watcher.async_stop() diff --git a/homeassistant/brands/ambient_weather.json b/homeassistant/brands/ambient_weather.json new file mode 100644 index 00000000000..157f2a5b7bc --- /dev/null +++ b/homeassistant/brands/ambient_weather.json @@ -0,0 +1,5 @@ +{ + "domain": "ambient_weather", + "name": "Ambient Weather", + "integrations": ["ambient_network", "ambient_station"] +} diff --git a/homeassistant/brands/rainforest.json b/homeassistant/brands/rainforest_automation.json similarity index 100% rename from homeassistant/brands/rainforest.json rename to homeassistant/brands/rainforest_automation.json diff --git a/homeassistant/brands/ruuvi.json b/homeassistant/brands/ruuvi.json new file mode 100644 index 00000000000..b174424c13c --- /dev/null +++ b/homeassistant/brands/ruuvi.json @@ -0,0 +1,5 @@ +{ + "domain": "ruuvi", + "name": "Ruuvi", + "integrations": ["ruuvi_gateway", "ruuvitag_ble"] +} diff --git a/homeassistant/brands/weatherflow.json b/homeassistant/brands/weatherflow.json new file mode 100644 index 00000000000..e1043c88b9b --- /dev/null +++ b/homeassistant/brands/weatherflow.json @@ -0,0 +1,5 @@ +{ + "domain": "weatherflow", + "name": "WeatherFlow", + "integrations": ["weatherflow", "weatherflow_cloud"] +} diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index a27c2d93ead..a27eda2cf12 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -5,9 +5,7 @@ from __future__ import annotations from dataclasses import dataclass, field from functools import partial -from jaraco.abode.automation import Automation as AbodeAuto from jaraco.abode.client import Client as Abode -from jaraco.abode.devices.base import Device as AbodeDev from jaraco.abode.exceptions import ( AuthenticationException as AbodeAuthenticationException, Exception as AbodeException, @@ -29,11 +27,11 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.typing import ConfigType -from .const import ATTRIBUTION, CONF_POLLING, DOMAIN, LOGGER +from .const import CONF_POLLING, DOMAIN, LOGGER SERVICE_SETTINGS = "change_setting" SERVICE_CAPTURE_IMAGE = "capture_image" @@ -83,6 +81,12 @@ class AbodeSystem: logout_listener: CALLBACK_TYPE | None = None +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Abode component.""" + setup_hass_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Abode integration from a config entry.""" username = entry.data[CONF_USERNAME] @@ -111,7 +115,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await setup_hass_events(hass) - await hass.async_add_executor_job(setup_hass_services, hass) await hass.async_add_executor_job(setup_abode_events, hass) return True @@ -119,10 +122,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - hass.services.async_remove(DOMAIN, SERVICE_SETTINGS) - hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE) - hass.services.async_remove(DOMAIN, SERVICE_TRIGGER_AUTOMATION) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop) @@ -175,15 +174,15 @@ def setup_hass_services(hass: HomeAssistant) -> None: signal = f"abode_trigger_automation_{entity_id}" dispatcher_send(hass, signal) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA ) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA ) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA ) @@ -247,108 +246,3 @@ def setup_abode_events(hass: HomeAssistant) -> None: hass.data[DOMAIN].abode.events.add_event_callback( event, partial(event_callback, event) ) - - -class AbodeEntity(entity.Entity): - """Representation of an Abode entity.""" - - _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True - - def __init__(self, data: AbodeSystem) -> None: - """Initialize Abode entity.""" - self._data = data - self._attr_should_poll = data.polling - - async def async_added_to_hass(self) -> None: - """Subscribe to Abode connection status updates.""" - await self.hass.async_add_executor_job( - self._data.abode.events.add_connection_status_callback, - self.unique_id, - self._update_connection_status, - ) - - self.hass.data[DOMAIN].entity_ids.add(self.entity_id) - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe from Abode connection status updates.""" - await self.hass.async_add_executor_job( - self._data.abode.events.remove_connection_status_callback, self.unique_id - ) - - def _update_connection_status(self) -> None: - """Update the entity available property.""" - self._attr_available = self._data.abode.events.connected - self.schedule_update_ha_state() - - -class AbodeDevice(AbodeEntity): - """Representation of an Abode device.""" - - def __init__(self, data: AbodeSystem, device: AbodeDev) -> None: - """Initialize Abode device.""" - super().__init__(data) - self._device = device - self._attr_unique_id = device.uuid - - async def async_added_to_hass(self) -> None: - """Subscribe to device events.""" - await super().async_added_to_hass() - await self.hass.async_add_executor_job( - self._data.abode.events.add_device_callback, - self._device.id, - self._update_callback, - ) - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe from device events.""" - await super().async_will_remove_from_hass() - await self.hass.async_add_executor_job( - self._data.abode.events.remove_all_device_callbacks, self._device.id - ) - - def update(self) -> None: - """Update device state.""" - self._device.refresh() - - @property - def extra_state_attributes(self) -> dict[str, str]: - """Return the state attributes.""" - return { - "device_id": self._device.id, - "battery_low": self._device.battery_low, - "no_response": self._device.no_response, - "device_type": self._device.type, - } - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device.id)}, - manufacturer="Abode", - model=self._device.type, - name=self._device.name, - ) - - def _update_callback(self, device: AbodeDev) -> None: - """Update the device state.""" - self.schedule_update_ha_state() - - -class AbodeAutomation(AbodeEntity): - """Representation of an Abode automation.""" - - def __init__(self, data: AbodeSystem, automation: AbodeAuto) -> None: - """Initialize for Abode automation.""" - super().__init__(data) - self._automation = automation - self._attr_name = automation.name - self._attr_unique_id = automation.automation_id - self._attr_extra_state_attributes = { - "type": "CUE automation", - } - - def update(self) -> None: - """Update automation state.""" - self._automation.refresh() diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 333462a4d9f..b58a4757785 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -17,8 +17,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN +from .entity import AbodeDevice async def async_setup_entry( diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 4968d5378e1..1bccbf61701 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -22,8 +22,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN +from .entity import AbodeDevice async def async_setup_entry( diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 8ffa90a9b82..57fcbf1fca4 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -19,8 +19,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle -from . import AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN, LOGGER +from .entity import AbodeDevice MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py index e3fbb1a5b8f..96270cfd966 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -10,8 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN +from .entity import AbodeDevice async def async_setup_entry( diff --git a/homeassistant/components/abode/entity.py b/homeassistant/components/abode/entity.py new file mode 100644 index 00000000000..adbb68d86c6 --- /dev/null +++ b/homeassistant/components/abode/entity.py @@ -0,0 +1,115 @@ +"""Support for Abode Security System entities.""" + +from jaraco.abode.automation import Automation as AbodeAuto +from jaraco.abode.devices.base import Device as AbodeDev + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from . import AbodeSystem +from .const import ATTRIBUTION, DOMAIN + + +class AbodeEntity(Entity): + """Representation of an Abode entity.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__(self, data: AbodeSystem) -> None: + """Initialize Abode entity.""" + self._data = data + self._attr_should_poll = data.polling + + async def async_added_to_hass(self) -> None: + """Subscribe to Abode connection status updates.""" + await self.hass.async_add_executor_job( + self._data.abode.events.add_connection_status_callback, + self.unique_id, + self._update_connection_status, + ) + + self.hass.data[DOMAIN].entity_ids.add(self.entity_id) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from Abode connection status updates.""" + await self.hass.async_add_executor_job( + self._data.abode.events.remove_connection_status_callback, self.unique_id + ) + + def _update_connection_status(self) -> None: + """Update the entity available property.""" + self._attr_available = self._data.abode.events.connected + self.schedule_update_ha_state() + + +class AbodeDevice(AbodeEntity): + """Representation of an Abode device.""" + + def __init__(self, data: AbodeSystem, device: AbodeDev) -> None: + """Initialize Abode device.""" + super().__init__(data) + self._device = device + self._attr_unique_id = device.uuid + + async def async_added_to_hass(self) -> None: + """Subscribe to device events.""" + await super().async_added_to_hass() + await self.hass.async_add_executor_job( + self._data.abode.events.add_device_callback, + self._device.id, + self._update_callback, + ) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from device events.""" + await super().async_will_remove_from_hass() + await self.hass.async_add_executor_job( + self._data.abode.events.remove_all_device_callbacks, self._device.id + ) + + def update(self) -> None: + """Update device state.""" + self._device.refresh() + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return the state attributes.""" + return { + "device_id": self._device.id, + "battery_low": self._device.battery_low, + "no_response": self._device.no_response, + "device_type": self._device.type, + } + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.id)}, + manufacturer="Abode", + model=self._device.type, + name=self._device.name, + ) + + def _update_callback(self, device: AbodeDev) -> None: + """Update the device state.""" + self.schedule_update_ha_state() + + +class AbodeAutomation(AbodeEntity): + """Representation of an Abode automation.""" + + def __init__(self, data: AbodeSystem, automation: AbodeAuto) -> None: + """Initialize for Abode automation.""" + super().__init__(data) + self._automation = automation + self._attr_name = automation.name + self._attr_unique_id = automation.automation_id + self._attr_extra_state_attributes = { + "type": "CUE automation", + } + + def update(self) -> None: + """Update automation state.""" + self._automation.refresh() diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index 188d3c18e40..83f00e417ad 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -23,8 +23,9 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, ) -from . import AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN +from .entity import AbodeDevice async def async_setup_entry( diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py index 1135d3c3b36..3a65fa4d6dc 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -10,8 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN +from .entity import AbodeDevice async def async_setup_entry( diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index 89e5cf574fb..b57b3e77abc 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -27,8 +27,9 @@ from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN +from .entity import AbodeDevice ABODE_TEMPERATURE_UNIT_HA_UNIT = { UNIT_FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 9a33a04e341..64eb3529aab 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -13,8 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AbodeAutomation, AbodeDevice, AbodeSystem +from . import AbodeSystem from .const import DOMAIN +from .entity import AbodeAutomation, AbodeDevice DEVICE_TYPES = [TYPE_SWITCH, TYPE_VALVE] diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index d52ef5e0ec6..3d52df765e6 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -33,7 +33,10 @@ class AccuWeatherData: coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type AccuWeatherConfigEntry = ConfigEntry[AccuWeatherData] + + +async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry) -> bool: """Set up AccuWeather as config entry.""" api_key: str = entry.data[CONF_API_KEY] name: str = entry.data[CONF_NAME] @@ -64,9 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator_observation.async_config_entry_first_refresh() await coordinator_daily_forecast.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(update_listener)) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AccuWeatherData( + entry.runtime_data = AccuWeatherData( coordinator_observation=coordinator_observation, coordinator_daily_forecast=coordinator_daily_forecast, ) @@ -84,16 +85,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AccuWeatherConfigEntry +) -> 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 - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener.""" - await hass.config_entries.async_reload(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/accuweather/diagnostics.py b/homeassistant/components/accuweather/diagnostics.py index 810638a1e49..85c06a6140a 100644 --- a/homeassistant/components/accuweather/diagnostics.py +++ b/homeassistant/components/accuweather/diagnostics.py @@ -5,21 +5,19 @@ 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 from homeassistant.core import HomeAssistant -from . import AccuWeatherData -from .const import DOMAIN +from . import AccuWeatherConfigEntry, AccuWeatherData TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: AccuWeatherConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - accuweather_data: AccuWeatherData = hass.data[DOMAIN][config_entry.entry_id] + accuweather_data: AccuWeatherData = config_entry.runtime_data return { "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), diff --git a/homeassistant/components/accuweather/icons.json b/homeassistant/components/accuweather/icons.json new file mode 100644 index 00000000000..183b4d2731d --- /dev/null +++ b/homeassistant/components/accuweather/icons.json @@ -0,0 +1,51 @@ +{ + "entity": { + "sensor": { + "cloud_ceiling": { + "default": "mdi:weather-fog" + }, + "cloud_cover": { + "default": "mdi:weather-cloudy" + }, + "cloud_cover_day": { + "default": "mdi:weather-cloudy" + }, + "cloud_cover_night": { + "default": "mdi:weather-cloudy" + }, + "grass_pollen": { + "default": "mdi:grass" + }, + "hours_of_sun": { + "default": "mdi:weather-partly-cloudy" + }, + "mold_pollen": { + "default": "mdi:blur" + }, + "pressure_tendency": { + "default": "mdi:gauge" + }, + "ragweed_pollen": { + "default": "mdi:sprout" + }, + "thunderstorm_probability_day": { + "default": "mdi:weather-lightning" + }, + "thunderstorm_probability_night": { + "default": "mdi:weather-lightning" + }, + "translation_key": { + "default": "mdi:air-filter" + }, + "tree_pollen": { + "default": "mdi:tree-outline" + }, + "uv_index": { + "default": "mdi:weather-sunny" + }, + "uv_index_forecast": { + "default": "mdi:weather-sunny" + } + } + } +} diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 95274297828..fac3a2a4ba3 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_CUBIC_METER, PERCENTAGE, @@ -28,7 +27,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AccuWeatherData +from . import AccuWeatherConfigEntry from .const import ( API_METRIC, ATTR_CATEGORY, @@ -38,7 +37,6 @@ from .const import ( ATTR_SPEED, ATTR_VALUE, ATTRIBUTION, - DOMAIN, MAX_FORECAST_DAYS, ) from .coordinator import ( @@ -57,284 +55,174 @@ class AccuWeatherSensorDescription(SensorEntityDescription): attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {} -@dataclass(frozen=True, kw_only=True) -class AccuWeatherForecastSensorDescription(AccuWeatherSensorDescription): - """Class describing AccuWeather sensor entities.""" - - day: int - - -FORECAST_SENSOR_TYPES: tuple[AccuWeatherForecastSensorDescription, ...] = ( - *( - AccuWeatherForecastSensorDescription( - key="AirQuality", - icon="mdi:air-filter", - value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), - device_class=SensorDeviceClass.ENUM, - options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], - translation_key=f"air_quality_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) +FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( + AccuWeatherSensorDescription( + key="AirQuality", + value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), + device_class=SensorDeviceClass.ENUM, + options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], + translation_key="air_quality", ), - *( - AccuWeatherForecastSensorDescription( - key="CloudCoverDay", - icon="mdi:weather-cloudy", - entity_registry_enabled_default=False, - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), - translation_key=f"cloud_cover_day_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="CloudCoverDay", + entity_registry_enabled_default=False, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key="cloud_cover_day", ), - *( - AccuWeatherForecastSensorDescription( - key="CloudCoverNight", - icon="mdi:weather-cloudy", - entity_registry_enabled_default=False, - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), - translation_key=f"cloud_cover_night_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="CloudCoverNight", + entity_registry_enabled_default=False, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key="cloud_cover_night", ), - *( - AccuWeatherForecastSensorDescription( - key="Grass", - icon="mdi:grass", - entity_registry_enabled_default=False, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key=f"grass_pollen_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="Grass", + entity_registry_enabled_default=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="grass_pollen", ), - *( - AccuWeatherForecastSensorDescription( - key="HoursOfSun", - icon="mdi:weather-partly-cloudy", - native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data: cast(float, data), - translation_key=f"hours_of_sun_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="HoursOfSun", + native_unit_of_measurement=UnitOfTime.HOURS, + value_fn=lambda data: cast(float, data), + translation_key="hours_of_sun", ), - *( - AccuWeatherForecastSensorDescription( - key="LongPhraseDay", - value_fn=lambda data: cast(str, data), - translation_key=f"condition_day_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="LongPhraseDay", + value_fn=lambda data: cast(str, data), + translation_key="condition_day", ), - *( - AccuWeatherForecastSensorDescription( - key="LongPhraseNight", - value_fn=lambda data: cast(str, data), - translation_key=f"condition_night_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="LongPhraseNight", + value_fn=lambda data: cast(str, data), + translation_key="condition_night", ), - *( - AccuWeatherForecastSensorDescription( - key="Mold", - icon="mdi:blur", - entity_registry_enabled_default=False, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key=f"mold_pollen_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="Mold", + entity_registry_enabled_default=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="mold_pollen", ), - *( - AccuWeatherForecastSensorDescription( - key="Ragweed", - icon="mdi:sprout", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - entity_registry_enabled_default=False, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key=f"ragweed_pollen_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="Ragweed", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="ragweed_pollen", ), - *( - AccuWeatherForecastSensorDescription( - key="RealFeelTemperatureMax", - device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), - translation_key=f"realfeel_temperature_max_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="RealFeelTemperatureMax", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key="realfeel_temperature_max", ), - *( - AccuWeatherForecastSensorDescription( - key="RealFeelTemperatureMin", - device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), - translation_key=f"realfeel_temperature_min_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="RealFeelTemperatureMin", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key="realfeel_temperature_min", ), - *( - AccuWeatherForecastSensorDescription( - key="RealFeelTemperatureShadeMax", - device_class=SensorDeviceClass.TEMPERATURE, - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), - translation_key=f"realfeel_temperature_shade_max_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMax", + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key="realfeel_temperature_shade_max", ), - *( - AccuWeatherForecastSensorDescription( - key="RealFeelTemperatureShadeMin", - device_class=SensorDeviceClass.TEMPERATURE, - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), - translation_key=f"realfeel_temperature_shade_min_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMin", + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key="realfeel_temperature_shade_min", ), - *( - AccuWeatherForecastSensorDescription( - key="SolarIrradianceDay", - icon="mdi:weather-sunny", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), - translation_key=f"solar_irradiance_day_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="SolarIrradianceDay", + device_class=SensorDeviceClass.IRRADIANCE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key="solar_irradiance_day", ), - *( - AccuWeatherForecastSensorDescription( - key="SolarIrradianceNight", - icon="mdi:weather-sunny", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), - translation_key=f"solar_irradiance_night_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="SolarIrradianceNight", + device_class=SensorDeviceClass.IRRADIANCE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key="solar_irradiance_night", ), - *( - AccuWeatherForecastSensorDescription( - key="ThunderstormProbabilityDay", - icon="mdi:weather-lightning", - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), - translation_key=f"thunderstorm_probability_day_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="ThunderstormProbabilityDay", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key="thunderstorm_probability_day", ), - *( - AccuWeatherForecastSensorDescription( - key="ThunderstormProbabilityNight", - icon="mdi:weather-lightning", - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), - translation_key=f"thunderstorm_probability_night_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="ThunderstormProbabilityNight", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key="thunderstorm_probability_night", ), - *( - AccuWeatherForecastSensorDescription( - key="Tree", - icon="mdi:tree-outline", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - entity_registry_enabled_default=False, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key=f"tree_pollen_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="Tree", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="tree_pollen", ), - *( - AccuWeatherForecastSensorDescription( - key="UVIndex", - icon="mdi:weather-sunny", - native_unit_of_measurement=UV_INDEX, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key=f"uv_index_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="UVIndex", + native_unit_of_measurement=UV_INDEX, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key="uv_index_forecast", ), - *( - AccuWeatherForecastSensorDescription( - key="WindGustDay", - device_class=SensorDeviceClass.WIND_SPEED, - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, - translation_key=f"wind_gust_speed_day_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="WindGustDay", + device_class=SensorDeviceClass.WIND_SPEED, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key="wind_gust_speed_day", ), - *( - AccuWeatherForecastSensorDescription( - key="WindGustNight", - device_class=SensorDeviceClass.WIND_SPEED, - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, - translation_key=f"wind_gust_speed_night_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="WindGustNight", + device_class=SensorDeviceClass.WIND_SPEED, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key="wind_gust_speed_night", ), - *( - AccuWeatherForecastSensorDescription( - key="WindDay", - device_class=SensorDeviceClass.WIND_SPEED, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, - translation_key=f"wind_speed_day_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="WindDay", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key="wind_speed_day", ), - *( - AccuWeatherForecastSensorDescription( - key="WindNight", - device_class=SensorDeviceClass.WIND_SPEED, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, - translation_key=f"wind_speed_night_{day}d", - day=day, - ) - for day in range(MAX_FORECAST_DAYS + 1) + AccuWeatherSensorDescription( + key="WindNight", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key="wind_speed_night", ), ) @@ -351,7 +239,6 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( AccuWeatherSensorDescription( key="Ceiling", device_class=SensorDeviceClass.DISTANCE, - icon="mdi:weather-fog", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.METERS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), @@ -360,7 +247,6 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( ), AccuWeatherSensorDescription( key="CloudCover", - icon="mdi:weather-cloudy", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -405,14 +291,12 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( AccuWeatherSensorDescription( key="PressureTendency", device_class=SensorDeviceClass.ENUM, - icon="mdi:gauge", options=["falling", "rising", "steady"], value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), translation_key="pressure_tendency", ), AccuWeatherSensorDescription( key="UVIndex", - icon="mdi:weather-sunny", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, value_fn=lambda data: cast(int, data), @@ -458,17 +342,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AccuWeatherConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add AccuWeather entities from a config_entry.""" - - accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id] - observation_coordinator: AccuWeatherObservationDataUpdateCoordinator = ( - accuweather_data.coordinator_observation + entry.runtime_data.coordinator_observation ) forecast_daily_coordinator: AccuWeatherDailyForecastDataUpdateCoordinator = ( - accuweather_data.coordinator_daily_forecast + entry.runtime_data.coordinator_daily_forecast ) sensors: list[AccuWeatherSensor | AccuWeatherForecastSensor] = [ @@ -478,9 +361,10 @@ async def async_setup_entry( sensors.extend( [ - AccuWeatherForecastSensor(forecast_daily_coordinator, description) + AccuWeatherForecastSensor(forecast_daily_coordinator, description, day) + for day in range(MAX_FORECAST_DAYS + 1) for description in FORECAST_SENSOR_TYPES - if description.key in forecast_daily_coordinator.data[description.day] + if description.key in forecast_daily_coordinator.data[day] ] ) @@ -546,25 +430,27 @@ class AccuWeatherForecastSensor( _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - entity_description: AccuWeatherForecastSensorDescription + entity_description: AccuWeatherSensorDescription def __init__( self, coordinator: AccuWeatherDailyForecastDataUpdateCoordinator, - description: AccuWeatherForecastSensorDescription, + description: AccuWeatherSensorDescription, + forecast_day: int, ) -> None: """Initialize.""" super().__init__(coordinator) - self.forecast_day = description.day self.entity_description = description self._sensor_data = self._get_sensor_data( - coordinator.data, description.key, self.forecast_day + coordinator.data, description.key, forecast_day ) self._attr_unique_id = ( - f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower() + f"{coordinator.location_key}-{description.key}-{forecast_day}".lower() ) self._attr_device_info = coordinator.device_info + self._attr_translation_placeholders = {"forecast_day": str(forecast_day)} + self.forecast_day = forecast_day @property def native_value(self) -> str | int | float | None: diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 9d8fce865fd..78a49b8b877 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -21,8 +21,8 @@ }, "entity": { "sensor": { - "air_quality_0d": { - "name": "Air quality today", + "air_quality": { + "name": "Air quality day {forecast_day}", "state": { "good": "Good", "hazardous": "Hazardous", @@ -32,50 +32,6 @@ "unhealthy": "Unhealthy" } }, - "air_quality_1d": { - "name": "Air quality day 1", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - }, - "air_quality_2d": { - "name": "Air quality day 2", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - }, - "air_quality_3d": { - "name": "Air quality day 3", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - }, - "air_quality_4d": { - "name": "Air quality day 4", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - }, "apparent_temperature": { "name": "Apparent temperature" }, @@ -85,240 +41,52 @@ "cloud_cover": { "name": "Cloud cover" }, - "cloud_cover_day_0d": { - "name": "Cloud cover today" + "cloud_cover_day": { + "name": "Cloud cover day {forecast_day}" }, - "cloud_cover_day_1d": { - "name": "Cloud cover day 1" + "cloud_cover_night": { + "name": "Cloud cover night {forecast_day}" }, - "cloud_cover_day_2d": { - "name": "Cloud cover day 2" + "condition_day": { + "name": "Condition day {forecast_day}" }, - "cloud_cover_day_3d": { - "name": "Cloud cover day 3" - }, - "cloud_cover_day_4d": { - "name": "Cloud cover day 4" - }, - "cloud_cover_night_0d": { - "name": "Cloud cover tonight" - }, - "cloud_cover_night_1d": { - "name": "Cloud cover night 1" - }, - "cloud_cover_night_2d": { - "name": "Cloud cover night 2" - }, - "cloud_cover_night_3d": { - "name": "Cloud cover night 3" - }, - "cloud_cover_night_4d": { - "name": "Cloud cover night 4" - }, - "condition_day_0d": { - "name": "Condition today" - }, - "condition_day_1d": { - "name": "Condition day 1" - }, - "condition_day_2d": { - "name": "Condition day 2" - }, - "condition_day_3d": { - "name": "Condition day 3" - }, - "condition_day_4d": { - "name": "Condition day 4" - }, - "condition_night_0d": { - "name": "Condition tonight" - }, - "condition_night_1d": { - "name": "Condition night 1" - }, - "condition_night_2d": { - "name": "Condition night 2" - }, - "condition_night_3d": { - "name": "Condition night 3" - }, - "condition_night_4d": { - "name": "Condition night 4" + "condition_night": { + "name": "Condition night {forecast_day}" }, "dew_point": { "name": "Dew point" }, - "grass_pollen_0d": { - "name": "Grass pollen today", + "grass_pollen": { + "name": "Grass pollen day {forecast_day}", "state_attributes": { "level": { "name": "Level", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" } } } }, - "grass_pollen_1d": { - "name": "Grass pollen day 1", + "hours_of_sun": { + "name": "Hours of sun day {forecast_day}" + }, + "mold_pollen": { + "name": "Mold pollen day {forecast_day}", "state_attributes": { "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "grass_pollen_2d": { - "name": "Grass pollen day 2", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "grass_pollen_3d": { - "name": "Grass pollen day 3", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "grass_pollen_4d": { - "name": "Grass pollen day 4", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "hours_of_sun_0d": { - "name": "Hours of sun today" - }, - "hours_of_sun_1d": { - "name": "Hours of sun day 1" - }, - "hours_of_sun_2d": { - "name": "Hours of sun day 2" - }, - "hours_of_sun_3d": { - "name": "Hours of sun day 3" - }, - "hours_of_sun_4d": { - "name": "Hours of sun day 4" - }, - "mold_pollen_0d": { - "name": "Mold pollen today", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "mold_pollen_1d": { - "name": "Mold pollen day 1", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "mold_pollen_2d": { - "name": "Mold pollen day 2", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "mold_pollen_3d": { - "name": "Mold pollen day 3", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "mold_pollen_4d": { - "name": "Mold pollen day 4", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" } } } @@ -334,82 +102,18 @@ "falling": "Falling" } }, - "ragweed_pollen_0d": { - "name": "Ragweed pollen today", + "ragweed_pollen": { + "name": "Ragweed pollen day {forecast_day}", "state_attributes": { "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "ragweed_pollen_1d": { - "name": "Ragweed pollen day 1", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "ragweed_pollen_2d": { - "name": "Ragweed pollen day 2", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "ragweed_pollen_3d": { - "name": "Ragweed pollen day 3", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "ragweed_pollen_4d": { - "name": "Ragweed pollen day 4", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" } } } @@ -417,205 +121,45 @@ "realfeel_temperature": { "name": "RealFeel temperature" }, - "realfeel_temperature_max_0d": { - "name": "RealFeel temperature max today" + "realfeel_temperature_max": { + "name": "RealFeel temperature max day {forecast_day}" }, - "realfeel_temperature_max_1d": { - "name": "RealFeel temperature max day 1" - }, - "realfeel_temperature_max_2d": { - "name": "RealFeel temperature max day 2" - }, - "realfeel_temperature_max_3d": { - "name": "RealFeel temperature max day 3" - }, - "realfeel_temperature_max_4d": { - "name": "RealFeel temperature max day 4" - }, - "realfeel_temperature_min_0d": { - "name": "RealFeel temperature min today" - }, - "realfeel_temperature_min_1d": { - "name": "RealFeel temperature min day 1" - }, - "realfeel_temperature_min_2d": { - "name": "RealFeel temperature min day 2" - }, - "realfeel_temperature_min_3d": { - "name": "RealFeel temperature min day 3" - }, - "realfeel_temperature_min_4d": { - "name": "RealFeel temperature min day 4" + "realfeel_temperature_min": { + "name": "RealFeel temperature min day {forecast_day}" }, "realfeel_temperature_shade": { "name": "RealFeel temperature shade" }, - "realfeel_temperature_shade_max_0d": { - "name": "RealFeel temperature shade max today" + "realfeel_temperature_shade_max": { + "name": "RealFeel temperature shade max day {forecast_day}" }, - "realfeel_temperature_shade_max_1d": { - "name": "RealFeel temperature shade max day 1" + "realfeel_temperature_shade_min": { + "name": "RealFeel temperature shade min day {forecast_day}" }, - "realfeel_temperature_shade_max_2d": { - "name": "RealFeel temperature shade max day 2" + "solar_irradiance_day": { + "name": "Solar irradiance day {forecast_day}" }, - "realfeel_temperature_shade_max_3d": { - "name": "RealFeel temperature shade max day 3" + "solar_irradiance_night": { + "name": "Solar irradiance night {forecast_day}" }, - "realfeel_temperature_shade_max_4d": { - "name": "RealFeel temperature shade max day 4" + "thunderstorm_probability_day": { + "name": "Thunderstorm probability day {forecast_day}" }, - "realfeel_temperature_shade_min_0d": { - "name": "RealFeel temperature shade min today" + "thunderstorm_probability_night": { + "name": "Thunderstorm probability night {forecast_day}" }, - "realfeel_temperature_shade_min_1d": { - "name": "RealFeel temperature shade min day 1" - }, - "realfeel_temperature_shade_min_2d": { - "name": "RealFeel temperature shade min day 2" - }, - "realfeel_temperature_shade_min_3d": { - "name": "RealFeel temperature shade min day 3" - }, - "realfeel_temperature_shade_min_4d": { - "name": "RealFeel temperature shade min day 4" - }, - "solar_irradiance_day_0d": { - "name": "Solar irradiance today" - }, - "solar_irradiance_day_1d": { - "name": "Solar irradiance day 1" - }, - "solar_irradiance_day_2d": { - "name": "Solar irradiance day 2" - }, - "solar_irradiance_day_3d": { - "name": "Solar irradiance day 3" - }, - "solar_irradiance_day_4d": { - "name": "Solar irradiance day 4" - }, - "solar_irradiance_night_0d": { - "name": "Solar irradiance tonight" - }, - "solar_irradiance_night_1d": { - "name": "Solar irradiance night 1" - }, - "solar_irradiance_night_2d": { - "name": "Solar irradiance night 2" - }, - "solar_irradiance_night_3d": { - "name": "Solar irradiance night 3" - }, - "solar_irradiance_night_4d": { - "name": "Solar irradiance night 4" - }, - "thunderstorm_probability_day_0d": { - "name": "Thunderstorm probability today" - }, - "thunderstorm_probability_day_1d": { - "name": "Thunderstorm probability day 1" - }, - "thunderstorm_probability_day_2d": { - "name": "Thunderstorm probability day 2" - }, - "thunderstorm_probability_day_3d": { - "name": "Thunderstorm probability day 3" - }, - "thunderstorm_probability_day_4d": { - "name": "Thunderstorm probability day 4" - }, - "thunderstorm_probability_night_0d": { - "name": "Thunderstorm probability tonight" - }, - "thunderstorm_probability_night_1d": { - "name": "Thunderstorm probability night 1" - }, - "thunderstorm_probability_night_2d": { - "name": "Thunderstorm probability night 2" - }, - "thunderstorm_probability_night_3d": { - "name": "Thunderstorm probability night 3" - }, - "thunderstorm_probability_night_4d": { - "name": "Thunderstorm probability night 4" - }, - "tree_pollen_0d": { - "name": "Tree pollen today", + "tree_pollen": { + "name": "Tree pollen day {forecast_day}", "state_attributes": { "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "tree_pollen_1d": { - "name": "Tree pollen day 1", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "tree_pollen_2d": { - "name": "Tree pollen day 2", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "tree_pollen_3d": { - "name": "Tree pollen day 3", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "tree_pollen_4d": { - "name": "Tree pollen day 4", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" } } } @@ -624,94 +168,30 @@ "name": "UV index", "state_attributes": { "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" } } } }, - "uv_index_0d": { - "name": "UV index today", + "uv_index_forecast": { + "name": "UV index day {forecast_day}", "state_attributes": { "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "uv_index_1d": { - "name": "UV index day 1", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "uv_index_2d": { - "name": "UV index day 2", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "uv_index_3d": { - "name": "UV index day 3", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" - } - } - } - }, - "uv_index_4d": { - "name": "UV index day 4", - "state_attributes": { - "level": { - "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", - "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" } } } @@ -728,65 +208,17 @@ "wind_gust_speed": { "name": "[%key:component::weather::entity_component::_::state_attributes::wind_gust_speed::name%]" }, - "wind_gust_speed_day_0d": { - "name": "Wind gust speed today" + "wind_gust_speed_day": { + "name": "Wind gust speed day {forecast_day}" }, - "wind_gust_speed_day_1d": { - "name": "Wind gust speed day 1" + "wind_gust_speed_night": { + "name": "Wind gust speed night {forecast_day}" }, - "wind_gust_speed_day_2d": { - "name": "Wind gust speed day 2" + "wind_speed_day": { + "name": "Wind speed day {forecast_day}" }, - "wind_gust_speed_day_3d": { - "name": "Wind gust speed day 3" - }, - "wind_gust_speed_day_4d": { - "name": "Wind gust speed day 4" - }, - "wind_gust_speed_night_0d": { - "name": "Wind gust speed tonight" - }, - "wind_gust_speed_night_1d": { - "name": "Wind gust speed night 1" - }, - "wind_gust_speed_night_2d": { - "name": "Wind gust speed night 2" - }, - "wind_gust_speed_night_3d": { - "name": "Wind gust speed night 3" - }, - "wind_gust_speed_night_4d": { - "name": "Wind gust speed night 4" - }, - "wind_speed_day_0d": { - "name": "Wind speed today" - }, - "wind_speed_day_1d": { - "name": "Wind speed day 1" - }, - "wind_speed_day_2d": { - "name": "Wind speed day 2" - }, - "wind_speed_day_3d": { - "name": "Wind speed day 3" - }, - "wind_speed_day_4d": { - "name": "Wind speed day 4" - }, - "wind_speed_night_0d": { - "name": "Wind speed tonight" - }, - "wind_speed_night_1d": { - "name": "Wind speed night 1" - }, - "wind_speed_night_2d": { - "name": "Wind speed night 2" - }, - "wind_speed_night_3d": { - "name": "Wind speed night 3" - }, - "wind_speed_night_4d": { - "name": "Wind speed night 4" + "wind_speed_night": { + "name": "Wind speed night {forecast_day}" } } }, diff --git a/homeassistant/components/accuweather/system_health.py b/homeassistant/components/accuweather/system_health.py index f47828cb5a3..eab16498248 100644 --- a/homeassistant/components/accuweather/system_health.py +++ b/homeassistant/components/accuweather/system_health.py @@ -9,6 +9,7 @@ from accuweather.const import ENDPOINT from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback +from . import AccuWeatherConfigEntry from .const import DOMAIN @@ -22,9 +23,11 @@ def async_register( async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - remaining_requests = list(hass.data[DOMAIN].values())[ - 0 - ].coordinator_observation.accuweather.requests_remaining + config_entry: AccuWeatherConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + + remaining_requests = ( + config_entry.runtime_data.coordinator_observation.accuweather.requests_remaining + ) return { "can_reach_server": system_health.async_check_can_reach_url(hass, ENDPOINT), diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 576b77ee0cb..72d717f2703 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -7,6 +7,7 @@ from typing import cast from homeassistant.components.weather import ( ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, @@ -21,7 +22,6 @@ from homeassistant.components.weather import ( Forecast, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPrecipitationDepth, @@ -33,7 +33,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp -from . import AccuWeatherData +from . import AccuWeatherConfigEntry, AccuWeatherData from .const import ( API_METRIC, ATTR_DIRECTION, @@ -41,7 +41,6 @@ from .const import ( ATTR_VALUE, ATTRIBUTION, CONDITION_MAP, - DOMAIN, ) from .coordinator import ( AccuWeatherDailyForecastDataUpdateCoordinator, @@ -52,12 +51,12 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AccuWeatherConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add a AccuWeather weather entity from a config_entry.""" - accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([AccuWeatherEntity(accuweather_data)]) + async_add_entities([AccuWeatherEntity(entry.runtime_data)]) class AccuWeatherEntity( @@ -185,6 +184,7 @@ class AccuWeatherEntity( { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), ATTR_FORECAST_CLOUD_COVERAGE: item["CloudCoverDay"], + ATTR_FORECAST_HUMIDITY: item["RelativeHumidityDay"]["Average"], ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"][ATTR_VALUE], ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"][ATTR_VALUE], ATTR_FORECAST_NATIVE_APPARENT_TEMP: item["RealFeelTemperatureMax"][ diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py index 418e8997239..d6491767dcc 100644 --- a/homeassistant/components/acmeda/__init__.py +++ b/homeassistant/components/acmeda/__init__.py @@ -10,7 +10,7 @@ CONF_HUBS = "hubs" PLATFORMS = [Platform.COVER, Platform.SENSOR] -AcmedaConfigEntry = ConfigEntry[PulseHub] +type AcmedaConfigEntry = ConfigEntry[PulseHub] async def async_setup_entry( diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index d6274659f1d..9e531c683da 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -43,7 +43,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema( ) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] -AdGuardConfigEntry = ConfigEntry["AdGuardData"] +type AdGuardConfigEntry = ConfigEntry[AdGuardData] @dataclass diff --git a/homeassistant/components/ads/manifest.json b/homeassistant/components/ads/manifest.json index e5adb593755..0a2cd118a19 100644 --- a/homeassistant/components/ads/manifest.json +++ b/homeassistant/components/ads/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ads", "iot_class": "local_push", "loggers": ["pyads"], - "requirements": ["pyads==3.2.2"] + "requirements": ["pyads==3.4.0"] } diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index c89d6f609b8..752c1ec26fc 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -12,9 +12,11 @@ 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 +from .const import ADVANTAGE_AIR_RETRY from .models import AdvantageAirData +type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData] + ADVANTAGE_AIR_SYNC_INTERVAL = 15 PLATFORMS = [ Platform.BINARY_SENSOR, @@ -31,7 +33,9 @@ _LOGGER = logging.getLogger(__name__) REQUEST_REFRESH_DELAY = 0.5 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: AdvantageAirDataConfigEntry +) -> bool: """Set up Advantage Air config.""" ip_address = entry.data[CONF_IP_ADDRESS] port = entry.data[CONF_PORT] @@ -61,19 +65,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = AdvantageAirData(coordinator, api) + entry.runtime_data = AdvantageAirData(coordinator, api) 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, entry: AdvantageAirDataConfigEntry +) -> bool: """Unload Advantage Air Config.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index cf813a429e5..2ad8c2217a2 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -6,12 +6,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.entity_platform import AddEntitiesCallback -from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN +from . import AdvantageAirDataConfigEntry from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity from .models import AdvantageAirData @@ -20,12 +19,12 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir Binary Sensor platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[BinarySensorEntity] = [] if aircons := instance.coordinator.data.get("aircons"): diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 49b8224a902..7f9d3f2dc65 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -16,19 +16,18 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AdvantageAirDataConfigEntry from .const import ( ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, ADVANTAGE_AIR_STATE_OPEN, - DOMAIN as ADVANTAGE_AIR_DOMAIN, ) from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity from .models import AdvantageAirData @@ -76,12 +75,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir climate platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[ClimateEntity] = [] if aircons := instance.coordinator.data.get("aircons"): diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index 3c6e3ffa3a6..b091f0077a1 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -8,15 +8,11 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OPEN, - DOMAIN as ADVANTAGE_AIR_DOMAIN, -) +from . import AdvantageAirDataConfigEntry +from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity from .models import AdvantageAirData @@ -25,12 +21,12 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir cover platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[CoverEntity] = [] if aircons := instance.coordinator.data.get("aircons"): diff --git a/homeassistant/components/advantage_air/diagnostics.py b/homeassistant/components/advantage_air/diagnostics.py index 9eebb97d3c5..8d998d1ee90 100644 --- a/homeassistant/components/advantage_air/diagnostics.py +++ b/homeassistant/components/advantage_air/diagnostics.py @@ -5,10 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN +from . import AdvantageAirDataConfigEntry TO_REDACT = [ "dealerPhoneNumber", @@ -25,10 +24,10 @@ TO_REDACT = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id].coordinator.data + data = config_entry.runtime_data.coordinator.data # Return only the relevant children return { diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index 30617c52acf..7dd0a0a183b 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -3,11 +3,11 @@ from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirEntity, AdvantageAirThingEntity from .models import AdvantageAirData @@ -15,12 +15,12 @@ from .models import AdvantageAirData async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir light platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[LightEntity] = [] if my_lights := instance.coordinator.data.get("myLights"): diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py index c3739717ef1..84c37f38d7f 100644 --- a/homeassistant/components/advantage_air/select.py +++ b/homeassistant/components/advantage_air/select.py @@ -1,11 +1,10 @@ """Select platform for Advantage Air integration.""" 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 as ADVANTAGE_AIR_DOMAIN +from . import AdvantageAirDataConfigEntry from .entity import AdvantageAirAcEntity from .models import AdvantageAirData @@ -14,12 +13,12 @@ ADVANTAGE_AIR_INACTIVE = "Inactive" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir select platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data if aircons := instance.coordinator.data.get("aircons"): async_add_entities(AdvantageAirMyZone(instance, ac_key) for ac_key in aircons) diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 6bfa6bbad4b..bd3fa970fb9 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -12,13 +12,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN +from . import AdvantageAirDataConfigEntry +from .const import ADVANTAGE_AIR_STATE_OPEN from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity from .models import AdvantageAirData @@ -31,12 +31,12 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir sensor platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[SensorEntity] = [] if aircons := instance.coordinator.data.get("aircons"): diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 6d21f2e705c..876875a2510 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -3,15 +3,14 @@ from typing import Any from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AdvantageAirDataConfigEntry from .const import ( ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, - DOMAIN as ADVANTAGE_AIR_DOMAIN, ) from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity from .models import AdvantageAirData @@ -19,12 +18,12 @@ from .models import AdvantageAirData async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir switch platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data entities: list[SwitchEntity] = [] if aircons := instance.coordinator.data.get("aircons"): diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index 8afde183110..b639e4df867 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -1,11 +1,11 @@ """Advantage Air Update platform.""" from homeassistant.components.update import UpdateEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AdvantageAirDataConfigEntry from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN from .entity import AdvantageAirEntity from .models import AdvantageAirData @@ -13,12 +13,12 @@ from .models import AdvantageAirData async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AdvantageAirDataConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdvantageAir update platform.""" - instance: AdvantageAirData = hass.data[ADVANTAGE_AIR_DOMAIN][config_entry.entry_id] + instance = config_entry.runtime_data async_add_entities([AdvantageAirApp(instance)]) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index f019325fb79..e242d62a580 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,5 +1,6 @@ """The AEMET OpenData component.""" +from dataclasses import dataclass import logging from aemet_opendata.exceptions import AemetError, TownNotFound @@ -11,19 +12,23 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import ( - CONF_STATION_UPDATES, - DOMAIN, - ENTRY_NAME, - ENTRY_WEATHER_COORDINATOR, - PLATFORMS, -) +from .const import CONF_STATION_UPDATES, PLATFORMS from .coordinator import WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type AemetConfigEntry = ConfigEntry[AemetData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class AemetData: + """Aemet runtime data.""" + + name: str + coordinator: WeatherUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> bool: """Set up AEMET OpenData as config entry.""" name = entry.data[CONF_NAME] api_key = entry.data[CONF_API_KEY] @@ -44,11 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: weather_coordinator = WeatherUpdateCoordinator(hass, aemet) await weather_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - ENTRY_NAME: name, - ENTRY_WEATHER_COORDINATOR: weather_coordinator, - } + entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -64,9 +65,4 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 337b7e0790c..665075c4093 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -55,8 +55,6 @@ CONF_STATION_UPDATES = "station_updates" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] DEFAULT_NAME = "AEMET" DOMAIN = "aemet" -ENTRY_NAME = "name" -ENTRY_WEATHER_COORDINATOR = "weather_coordinator" ATTR_API_CONDITION = "condition" ATTR_API_FORECAST_CONDITION = "condition" diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py index 20b6c208514..cc39d1adc32 100644 --- a/homeassistant/components/aemet/diagnostics.py +++ b/homeassistant/components/aemet/diagnostics.py @@ -7,7 +7,6 @@ from typing import Any from aemet_opendata.const import AOD_COORDS from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -16,8 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN, ENTRY_WEATHER_COORDINATOR -from .coordinator import WeatherUpdateCoordinator +from . import AemetConfigEntry TO_REDACT_CONFIG = [ CONF_API_KEY, @@ -32,11 +30,10 @@ TO_REDACT_COORD = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: AemetConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - aemet_entry = hass.data[DOMAIN][config_entry.entry_id] - coordinator: WeatherUpdateCoordinator = aemet_entry[ENTRY_WEATHER_COORDINATOR] + coordinator = config_entry.runtime_data.coordinator return { "api_data": coordinator.aemet.raw_data(), diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index b8a19bcd27a..8a22385f82b 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.5.1"] + "requirements": ["AEMET-OpenData==0.5.2"] } diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 0952af19d43..268112070e8 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -56,6 +56,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util +from . import AemetConfigEntry from .const import ( ATTR_API_CONDITION, ATTR_API_FORECAST_CONDITION, @@ -87,9 +88,6 @@ from .const import ( ATTR_API_WIND_SPEED, ATTRIBUTION, CONDITIONS_MAP, - DOMAIN, - ENTRY_NAME, - ENTRY_WEATHER_COORDINATOR, ) from .coordinator import WeatherUpdateCoordinator from .entity import AemetEntity @@ -360,13 +358,13 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AemetConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AEMET OpenData sensor entities based on a config entry.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - name: str = domain_data[ENTRY_NAME] - coordinator: WeatherUpdateCoordinator = domain_data[ENTRY_WEATHER_COORDINATOR] + domain_data = config_entry.runtime_data + name = domain_data.name + coordinator = domain_data.coordinator async_add_entities( AemetSensor( diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 0d5abdcf967..4df0b1081f5 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -18,7 +18,6 @@ from homeassistant.components.weather import ( SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfPrecipitationDepth, UnitOfPressure, @@ -28,32 +27,24 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTRIBUTION, - CONDITIONS_MAP, - DOMAIN, - ENTRY_NAME, - ENTRY_WEATHER_COORDINATOR, -) +from . import AemetConfigEntry +from .const import ATTRIBUTION, CONDITIONS_MAP from .coordinator import WeatherUpdateCoordinator from .entity import AemetEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AemetConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AEMET OpenData weather entity based on a config entry.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] + domain_data = config_entry.runtime_data + name = domain_data.name + weather_coordinator = domain_data.coordinator async_add_entities( - [ - AemetWeather( - domain_data[ENTRY_NAME], config_entry.unique_id, weather_coordinator - ) - ], + [AemetWeather(name, config_entry.unique_id, weather_coordinator)], False, ) diff --git a/homeassistant/components/aftership/__init__.py b/homeassistant/components/aftership/__init__.py index 10e4293bc51..9632217e960 100644 --- a/homeassistant/components/aftership/__init__.py +++ b/homeassistant/components/aftership/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession PLATFORMS: list[Platform] = [Platform.SENSOR] -AfterShipConfigEntry = ConfigEntry[AfterShip] +type AfterShipConfigEntry = ConfigEntry[AfterShip] async def async_setup_entry(hass: HomeAssistant, entry: AfterShipConfigEntry) -> bool: diff --git a/homeassistant/components/agent_dvr/__init__.py b/homeassistant/components/agent_dvr/__init__.py index 6dc83d3766d..2cb32b6c80e 100644 --- a/homeassistant/components/agent_dvr/__init__.py +++ b/homeassistant/components/agent_dvr/__init__.py @@ -10,18 +10,20 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONNECTION, DOMAIN as AGENT_DOMAIN, SERVER_URL +from .const import DOMAIN as AGENT_DOMAIN, SERVER_URL ATTRIBUTION = "ispyconnect.com" DEFAULT_BRAND = "Agent DVR by ispyconnect.com" PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] +AgentDVRConfigEntry = ConfigEntry[Agent] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: AgentDVRConfigEntry +) -> bool: """Set up the Agent component.""" - hass.data.setdefault(AGENT_DOMAIN, {}) - server_origin = config_entry.data[SERVER_URL] agent_client = Agent(server_origin, async_get_clientsession(hass)) @@ -34,9 +36,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not agent_client.is_available: raise ConfigEntryNotReady + config_entry.async_on_unload(agent_client.close) + await agent_client.get_devices() - hass.data[AGENT_DOMAIN][config_entry.entry_id] = {CONNECTION: agent_client} + config_entry.runtime_data = agent_client device_registry = dr.async_get(hass) @@ -54,15 +58,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: AgentDVRConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - await hass.data[AGENT_DOMAIN][config_entry.entry_id][CONNECTION].close() - - if unload_ok: - hass.data[AGENT_DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 8dae49aa0ea..f098184321f 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -6,7 +6,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -17,7 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONNECTION, DOMAIN as AGENT_DOMAIN +from . import AgentDVRConfigEntry +from .const import DOMAIN as AGENT_DOMAIN CONF_HOME_MODE_NAME = "home" CONF_AWAY_MODE_NAME = "away" @@ -28,13 +28,11 @@ CONST_ALARM_CONTROL_PANEL_NAME = "Alarm Panel" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AgentDVRConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Agent DVR Alarm Control Panels.""" - async_add_entities( - [AgentBaseStation(hass.data[AGENT_DOMAIN][config_entry.entry_id][CONNECTION])] - ) + async_add_entities([AgentBaseStation(config_entry.runtime_data)]) class AgentBaseStation(AlarmControlPanelEntity): @@ -45,6 +43,7 @@ class AgentBaseStation(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False _attr_has_entity_name = True _attr_name = None diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index e2012ee13ca..4438bf72a1a 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -7,7 +7,6 @@ from agent import AgentError from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( @@ -15,12 +14,8 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) -from .const import ( - ATTRIBUTION, - CAMERA_SCAN_INTERVAL_SECS, - CONNECTION, - DOMAIN as AGENT_DOMAIN, -) +from . import AgentDVRConfigEntry +from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN as AGENT_DOMAIN SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS) @@ -43,14 +38,14 @@ CAMERA_SERVICES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AgentDVRConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Agent cameras.""" filter_urllib3_logging() cameras = [] - server = hass.data[AGENT_DOMAIN][config_entry.entry_id][CONNECTION] + server = config_entry.runtime_data if not server.devices: _LOGGER.warning("Could not fetch cameras from Agent server") return @@ -80,11 +75,11 @@ class AgentCamera(MjpegCamera): """Initialize as a subclass of MjpegCamera.""" self.device = device self._removed = False - self._attr_unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" + self._attr_unique_id = f"{device.client.unique}_{device.typeID}_{device.id}" super().__init__( name=device.name, - mjpeg_url=f"{device.client._server_url}{device.mjpeg_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", - still_image_url=f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", + mjpeg_url=f"{device.client._server_url}{device.mjpeg_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", # noqa: SLF001 + still_image_url=f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", # noqa: SLF001 ) self._attr_device_info = DeviceInfo( identifiers={(AGENT_DOMAIN, self.unique_id)}, diff --git a/homeassistant/components/agent_dvr/const.py b/homeassistant/components/agent_dvr/const.py index cd0284ca87c..8557f0595ed 100644 --- a/homeassistant/components/agent_dvr/const.py +++ b/homeassistant/components/agent_dvr/const.py @@ -9,4 +9,3 @@ SERVICE_UPDATE = "update" SIGNAL_UPDATE_AGENT = "agent_update" ATTRIBUTION = "Data provided by ispyconnect.com" SERVER_URL = "server_url" -CONNECTION = "connection" diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index e33fbd34367..78f2616a74d 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -17,7 +17,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType -from . import group as group_pre_import # noqa: F401 from .const import DOMAIN _LOGGER: Final = logging.getLogger(__name__) diff --git a/homeassistant/components/air_quality/group.py b/homeassistant/components/air_quality/group.py deleted file mode 100644 index 2bc4a122fdc..00000000000 --- a/homeassistant/components/air_quality/group.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.core import HomeAssistant, callback - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - -from .const import DOMAIN - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.exclude_domain(DOMAIN) diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py new file mode 100644 index 00000000000..91ee0a440a6 --- /dev/null +++ b/homeassistant/components/airgradient/__init__.py @@ -0,0 +1,67 @@ +"""The Airgradient integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from airgradient import AirGradientClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator + +PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] + + +@dataclass +class AirGradientData: + """AirGradient data class.""" + + measurement: AirGradientMeasurementCoordinator + config: AirGradientConfigCoordinator + + +type AirGradientConfigEntry = ConfigEntry[AirGradientData] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airgradient from a config entry.""" + + client = AirGradientClient( + entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + + measurement_coordinator = AirGradientMeasurementCoordinator(hass, client) + config_coordinator = AirGradientConfigCoordinator(hass, client) + + await measurement_coordinator.async_config_entry_first_refresh() + await config_coordinator.async_config_entry_first_refresh() + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, measurement_coordinator.serial_number)}, + manufacturer="AirGradient", + model=measurement_coordinator.data.model, + serial_number=measurement_coordinator.data.serial_number, + sw_version=measurement_coordinator.data.firmware_version, + ) + + entry.runtime_data = AirGradientData( + measurement=measurement_coordinator, + config=config_coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py new file mode 100644 index 00000000000..6fc12cf7397 --- /dev/null +++ b/homeassistant/components/airgradient/config_flow.py @@ -0,0 +1,102 @@ +"""Config flow for Airgradient.""" + +from typing import Any + +from airgradient import AirGradientClient, AirGradientError, ConfigurationControl +from awesomeversion import AwesomeVersion +from mashumaro import MissingField +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +MIN_VERSION = AwesomeVersion("3.1.1") + + +class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): + """AirGradient config flow.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + self.client: AirGradientClient | None = None + + async def set_configuration_source(self) -> None: + """Set configuration source to local if it hasn't been set yet.""" + assert self.client + config = await self.client.get_config() + if config.configuration_control is ConfigurationControl.NOT_INITIALIZED: + await self.client.set_configuration_control(ConfigurationControl.LOCAL) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + self.data[CONF_MODEL] = discovery_info.properties["model"] + + await self.async_set_unique_id(discovery_info.properties["serialno"]) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + if AwesomeVersion(discovery_info.properties["fw_ver"]) < MIN_VERSION: + return self.async_abort(reason="invalid_version") + + session = async_get_clientsession(self.hass) + self.client = AirGradientClient(host, session=session) + await self.client.get_current_measures() + + self.context["title_placeholders"] = { + "model": self.data[CONF_MODEL], + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + await self.set_configuration_source() + return self.async_create_entry( + title=self.data[CONF_MODEL], + data={CONF_HOST: self.data[CONF_HOST]}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "model": self.data[CONF_MODEL], + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + session = async_get_clientsession(self.hass) + self.client = AirGradientClient(user_input[CONF_HOST], session=session) + try: + current_measures = await self.client.get_current_measures() + except AirGradientError: + errors["base"] = "cannot_connect" + except MissingField: + return self.async_abort(reason="invalid_version") + else: + await self.async_set_unique_id(current_measures.serial_number) + self._abort_if_unique_id_configured() + await self.set_configuration_source() + return self.async_create_entry( + title=current_measures.model, + data={CONF_HOST: user_input[CONF_HOST]}, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) diff --git a/homeassistant/components/airgradient/const.py b/homeassistant/components/airgradient/const.py new file mode 100644 index 00000000000..bbb15a3741d --- /dev/null +++ b/homeassistant/components/airgradient/const.py @@ -0,0 +1,7 @@ +"""Constants for the Airgradient integration.""" + +import logging + +DOMAIN = "airgradient" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py new file mode 100644 index 00000000000..fbc1505f9c3 --- /dev/null +++ b/homeassistant/components/airgradient/coordinator.py @@ -0,0 +1,62 @@ +"""Define an object to manage fetching AirGradient data.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from airgradient import AirGradientClient, AirGradientError, Config, Measures + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +if TYPE_CHECKING: + from . import AirGradientConfigEntry + + +class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Class to manage fetching AirGradient data.""" + + _update_interval: timedelta + config_entry: AirGradientConfigEntry + + def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=LOGGER, + name=f"AirGradient {client.host}", + update_interval=self._update_interval, + ) + self.client = client + assert self.config_entry.unique_id + self.serial_number = self.config_entry.unique_id + + async def _async_update_data(self) -> _DataT: + try: + return await self._update_data() + except AirGradientError as error: + raise UpdateFailed(error) from error + + async def _update_data(self) -> _DataT: + raise NotImplementedError + + +class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]): + """Class to manage fetching AirGradient data.""" + + _update_interval = timedelta(minutes=1) + + async def _update_data(self) -> Measures: + return await self.client.get_current_measures() + + +class AirGradientConfigCoordinator(AirGradientCoordinator[Config]): + """Class to manage fetching AirGradient data.""" + + _update_interval = timedelta(minutes=5) + + async def _update_data(self) -> Config: + return await self.client.get_config() diff --git a/homeassistant/components/airgradient/entity.py b/homeassistant/components/airgradient/entity.py new file mode 100644 index 00000000000..4de07904bba --- /dev/null +++ b/homeassistant/components/airgradient/entity.py @@ -0,0 +1,20 @@ +"""Base class for AirGradient entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AirGradientCoordinator + + +class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]): + """Defines a base AirGradient entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AirGradientCoordinator) -> None: + """Initialize airgradient entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.serial_number)}, + ) diff --git a/homeassistant/components/airgradient/icons.json b/homeassistant/components/airgradient/icons.json new file mode 100644 index 00000000000..cf0c80c873e --- /dev/null +++ b/homeassistant/components/airgradient/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "total_volatile_organic_component_index": { + "default": "mdi:molecule" + }, + "nitrogen_index": { + "default": "mdi:molecule" + }, + "pm003_count": { + "default": "mdi:blur" + } + } + } +} diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json new file mode 100644 index 00000000000..7b892c4658a --- /dev/null +++ b/homeassistant/components/airgradient/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airgradient", + "name": "AirGradient", + "codeowners": ["@airgradienthq", "@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airgradient", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["airgradient==0.6.0"], + "zeroconf": ["_airgradient._tcp.local."] +} diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py new file mode 100644 index 00000000000..8fac06917fd --- /dev/null +++ b/homeassistant/components/airgradient/select.py @@ -0,0 +1,157 @@ +"""Support for AirGradient select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from airgradient import AirGradientClient, Config +from airgradient.models import ( + ConfigurationControl, + LedBarMode, + PmStandard, + TemperatureUnit, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AirGradientConfigEntry +from .const import DOMAIN +from .coordinator import AirGradientConfigCoordinator +from .entity import AirGradientEntity + +PM_STANDARD = { + PmStandard.UGM3: "ugm3", + PmStandard.USAQI: "us_aqi", +} +PM_STANDARD_REVERSE = {v: k for k, v in PM_STANDARD.items()} + + +@dataclass(frozen=True, kw_only=True) +class AirGradientSelectEntityDescription(SelectEntityDescription): + """Describes AirGradient select entity.""" + + value_fn: Callable[[Config], str | None] + set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]] + requires_display: bool = False + requires_led_bar: bool = False + + +CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription( + key="configuration_control", + translation_key="configuration_control", + options=[ConfigurationControl.CLOUD.value, ConfigurationControl.LOCAL.value], + entity_category=EntityCategory.CONFIG, + value_fn=lambda config: ( + config.configuration_control + if config.configuration_control is not ConfigurationControl.NOT_INITIALIZED + else None + ), + set_value_fn=lambda client, value: client.set_configuration_control( + ConfigurationControl(value) + ), +) + +PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = ( + AirGradientSelectEntityDescription( + key="display_temperature_unit", + translation_key="display_temperature_unit", + options=[x.value for x in TemperatureUnit], + entity_category=EntityCategory.CONFIG, + value_fn=lambda config: config.temperature_unit, + set_value_fn=lambda client, value: client.set_temperature_unit( + TemperatureUnit(value) + ), + requires_display=True, + ), + AirGradientSelectEntityDescription( + key="display_pm_standard", + translation_key="display_pm_standard", + options=list(PM_STANDARD_REVERSE), + entity_category=EntityCategory.CONFIG, + value_fn=lambda config: PM_STANDARD.get(config.pm_standard), + set_value_fn=lambda client, value: client.set_pm_standard( + PM_STANDARD_REVERSE[value] + ), + requires_display=True, + ), + AirGradientSelectEntityDescription( + key="led_bar_mode", + translation_key="led_bar_mode", + options=[x.value for x in LedBarMode], + entity_category=EntityCategory.CONFIG, + value_fn=lambda config: config.led_bar_mode, + set_value_fn=lambda client, value: client.set_led_bar_mode(LedBarMode(value)), + requires_led_bar=True, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirGradientConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AirGradient select entities based on a config entry.""" + + config_coordinator = entry.runtime_data.config + measurement_coordinator = entry.runtime_data.measurement + + entities = [AirGradientSelect(config_coordinator, CONFIG_CONTROL_ENTITY)] + + entities.extend( + AirGradientProtectedSelect(config_coordinator, description) + for description in PROTECTED_SELECT_TYPES + if ( + description.requires_display + and measurement_coordinator.data.model.startswith("I") + ) + or (description.requires_led_bar and "L" in measurement_coordinator.data.model) + ) + + async_add_entities(entities) + + +class AirGradientSelect(AirGradientEntity, SelectEntity): + """Defines an AirGradient select entity.""" + + entity_description: AirGradientSelectEntityDescription + coordinator: AirGradientConfigCoordinator + + def __init__( + self, + coordinator: AirGradientConfigCoordinator, + description: AirGradientSelectEntityDescription, + ) -> None: + """Initialize AirGradient select.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + + @property + def current_option(self) -> str | None: + """Return the state of the select.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_value_fn(self.coordinator.client, option) + await self.coordinator.async_request_refresh() + + +class AirGradientProtectedSelect(AirGradientSelect): + """Defines a protected AirGradient select entity.""" + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if ( + self.coordinator.data.configuration_control + is not ConfigurationControl.LOCAL + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_local_configuration", + ) + await super().async_select_option(option) diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py new file mode 100644 index 00000000000..6123d4289f9 --- /dev/null +++ b/homeassistant/components/airgradient/sensor.py @@ -0,0 +1,182 @@ +"""Support for AirGradient sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from airgradient.models import Measures + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import AirGradientConfigEntry +from .coordinator import AirGradientMeasurementCoordinator +from .entity import AirGradientEntity + + +@dataclass(frozen=True, kw_only=True) +class AirGradientSensorEntityDescription(SensorEntityDescription): + """Describes AirGradient sensor entity.""" + + value_fn: Callable[[Measures], StateType] + + +SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = ( + AirGradientSensorEntityDescription( + key="pm01", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm01, + ), + AirGradientSensorEntityDescription( + key="pm02", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm02, + ), + AirGradientSensorEntityDescription( + key="pm10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm10, + ), + AirGradientSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.ambient_temperature, + ), + AirGradientSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.relative_humidity, + ), + AirGradientSensorEntityDescription( + key="signal_strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.signal_strength, + ), + AirGradientSensorEntityDescription( + key="tvoc", + translation_key="total_volatile_organic_component_index", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.total_volatile_organic_component_index, + ), + AirGradientSensorEntityDescription( + key="nitrogen_index", + translation_key="nitrogen_index", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.nitrogen_index, + ), + AirGradientSensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.rco2, + ), + AirGradientSensorEntityDescription( + key="pm003", + translation_key="pm003_count", + native_unit_of_measurement="particles/dL", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm003_count, + ), + AirGradientSensorEntityDescription( + key="nox_raw", + translation_key="raw_nitrogen", + native_unit_of_measurement="ticks", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.raw_nitrogen, + ), + AirGradientSensorEntityDescription( + key="tvoc_raw", + translation_key="raw_total_volatile_organic_component", + native_unit_of_measurement="ticks", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.raw_total_volatile_organic_component, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirGradientConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AirGradient sensor entities based on a config entry.""" + + coordinator = entry.runtime_data.measurement + listener: Callable[[], None] | None = None + not_setup: set[AirGradientSensorEntityDescription] = set(SENSOR_TYPES) + + @callback + def add_entities() -> None: + """Add new entities based on the latest data.""" + nonlocal not_setup, listener + sensor_descriptions = not_setup + not_setup = set() + sensors = [] + for description in sensor_descriptions: + if description.value_fn(coordinator.data) is None: + not_setup.add(description) + else: + sensors.append(AirGradientSensor(coordinator, description)) + + if sensors: + async_add_entities(sensors) + if not_setup: + if not listener: + listener = coordinator.async_add_listener(add_entities) + elif listener: + listener() + + add_entities() + + +class AirGradientSensor(AirGradientEntity, SensorEntity): + """Defines an AirGradient sensor.""" + + entity_description: AirGradientSensorEntityDescription + coordinator: AirGradientMeasurementCoordinator + + def __init__( + self, + coordinator: AirGradientMeasurementCoordinator, + description: AirGradientSensorEntityDescription, + ) -> None: + """Initialize airgradient sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json new file mode 100644 index 00000000000..f4b558cf31a --- /dev/null +++ b/homeassistant/components/airgradient/strings.json @@ -0,0 +1,81 @@ +{ + "config": { + "flow_title": "{model}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Airgradient device." + } + }, + "discovery_confirm": { + "description": "Do you want to setup {model}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "select": { + "configuration_control": { + "name": "Configuration source", + "state": { + "cloud": "Cloud", + "local": "Local" + } + }, + "display_temperature_unit": { + "name": "Display temperature unit", + "state": { + "c": "Celsius", + "f": "Fahrenheit" + } + }, + "display_pm_standard": { + "name": "Display PM standard", + "state": { + "ugm3": "µg/m³", + "us_aqi": "US AQI" + } + }, + "led_bar_mode": { + "name": "LED bar mode", + "state": { + "off": "Off", + "co2": "Carbon dioxide", + "pm": "Particulate matter" + } + } + }, + "sensor": { + "total_volatile_organic_component_index": { + "name": "VOC index" + }, + "nitrogen_index": { + "name": "NOx index" + }, + "pm003_count": { + "name": "PM0.3" + }, + "raw_total_volatile_organic_component": { + "name": "Raw VOC" + }, + "raw_nitrogen": { + "name": "Raw NOx" + } + } + }, + "exceptions": { + "no_local_configuration": { + "message": "Device should be configured with local configuration to be able to change settings." + } + } +} diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 651caee272c..ad3ee5fca4d 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -19,8 +19,10 @@ PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type AirlyConfigEntry = ConfigEntry[AirlyDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> bool: """Set up Airly as config entry.""" api_key = entry.data[CONF_API_KEY] latitude = entry.data[CONF_LATITUDE] @@ -62,8 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -79,11 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AirlyConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/airly/diagnostics.py b/homeassistant/components/airly/diagnostics.py index d21d126c60e..8bf75baf1d1 100644 --- a/homeassistant/components/airly/diagnostics.py +++ b/homeassistant/components/airly/diagnostics.py @@ -5,7 +5,6 @@ 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, @@ -14,17 +13,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import AirlyDataUpdateCoordinator -from .const import DOMAIN +from . import AirlyConfigEntry TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIQUE_ID} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: AirlyConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: AirlyDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 3d80a0870d8..2126b838269 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, @@ -25,7 +24,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AirlyDataUpdateCoordinator +from . import AirlyConfigEntry, AirlyDataUpdateCoordinator from .const import ( ATTR_ADVICE, ATTR_API_ADVICE, @@ -174,12 +173,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirlyConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Airly sensor entities based on a config entry.""" name = entry.data[CONF_NAME] - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/airly/system_health.py b/homeassistant/components/airly/system_health.py index 6e56b15ef92..688b6d06189 100644 --- a/homeassistant/components/airly/system_health.py +++ b/homeassistant/components/airly/system_health.py @@ -9,6 +9,7 @@ from airly import Airly from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback +from . import AirlyConfigEntry from .const import DOMAIN @@ -22,8 +23,10 @@ def async_register( async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - requests_remaining = list(hass.data[DOMAIN].values())[0].airly.requests_remaining - requests_per_day = list(hass.data[DOMAIN].values())[0].airly.requests_per_day + config_entry: AirlyConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + + requests_remaining = config_entry.runtime_data.airly.requests_remaining + requests_per_day = config_entry.runtime_data.airly.requests_per_day return { "can_reach_server": system_health.async_check_can_reach_url( diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 5b06a25f13a..cff6b8c2795 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -21,7 +21,7 @@ from .coordinator import AirNowDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -AirNowConfigEntry = ConfigEntry[AirNowDataUpdateCoordinator] +type AirNowConfigEntry = ConfigEntry[AirNowDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool: diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index dd17e7f98db..e839acdcb7b 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -82,7 +82,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except InvalidLocation: errors["base"] = "invalid_location" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/airnow/const.py b/homeassistant/components/airnow/const.py index c61136b3eeb..054a5cbfea7 100644 --- a/homeassistant/components/airnow/const.py +++ b/homeassistant/components/airnow/const.py @@ -8,11 +8,13 @@ ATTR_API_CATEGORY = "Category" ATTR_API_CAT_LEVEL = "Number" ATTR_API_CAT_DESCRIPTION = "Name" ATTR_API_O3 = "O3" +ATTR_API_PM10 = "PM10" ATTR_API_PM25 = "PM2.5" ATTR_API_POLLUTANT = "Pollutant" ATTR_API_REPORT_DATE = "DateObserved" ATTR_API_REPORT_HOUR = "HourObserved" ATTR_API_REPORT_TZ = "LocalTimeZone" +ATTR_API_REPORT_TZINFO = "LocalTimeZoneInfo" ATTR_API_STATE = "StateCode" ATTR_API_STATION = "ReportingArea" ATTR_API_STATION_LATITUDE = "Latitude" diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 32185080d25..35f8a0e0abf 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -12,6 +12,7 @@ from pyairnow.errors import AirNowError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( ATTR_API_AQI, @@ -26,6 +27,7 @@ from .const import ( ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, ATTR_API_REPORT_TZ, + ATTR_API_REPORT_TZINFO, ATTR_API_STATE, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, @@ -96,7 +98,9 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Copy Report Details data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] - data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] + data[ATTR_API_REPORT_TZINFO] = await dt_util.async_get_time_zone( + obv[ATTR_API_REPORT_TZ] + ) # Copy Station Details data[ATTR_API_STATE] = obv[ATTR_API_STATE] diff --git a/homeassistant/components/airnow/icons.json b/homeassistant/components/airnow/icons.json index 0815109b6e9..96f97e06df6 100644 --- a/homeassistant/components/airnow/icons.json +++ b/homeassistant/components/airnow/icons.json @@ -4,6 +4,9 @@ "aqi": { "default": "mdi:blur" }, + "pm10": { + "default": "mdi:blur" + }, "pm25": { "default": "mdi:blur" }, diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 559478a69d3..722c0d6f4a9 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -23,7 +23,6 @@ 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 homeassistant.util.dt import get_time_zone from . import AirNowConfigEntry, AirNowDataUpdateCoordinator from .const import ( @@ -31,10 +30,11 @@ from .const import ( ATTR_API_AQI_DESCRIPTION, ATTR_API_AQI_LEVEL, ATTR_API_O3, + ATTR_API_PM10, ATTR_API_PM25, ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, - ATTR_API_REPORT_TZ, + ATTR_API_REPORT_TZINFO, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, ATTR_API_STATION_LONGITUDE, @@ -83,10 +83,19 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}", "%Y-%m-%d %H", ) - .replace(tzinfo=get_time_zone(data[ATTR_API_REPORT_TZ])) + .replace(tzinfo=data[ATTR_API_REPORT_TZINFO]) .isoformat(), }, ), + AirNowEntityDescription( + key=ATTR_API_PM10, + translation_key="pm10", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM10, + value_fn=lambda data: data.get(ATTR_API_PM10), + extra_state_attributes_fn=None, + ), AirNowEntityDescription( key=ATTR_API_PM25, translation_key="pm25", diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 93ca14710b7..d5fb22106f9 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -36,7 +36,7 @@ "name": "[%key:component::sensor::entity_component::ozone::name%]" }, "station": { - "name": "PM2.5 reporting station", + "name": "Reporting station", "state_attributes": { "lat": { "name": "[%key:common::config_flow::data::latitude%]" }, "long": { "name": "[%key:common::config_flow::data::longitude%]" } diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index 219a72042ef..ab64915c8ae 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE from .coordinator import AirQCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -16,7 +17,12 @@ AirQConfigEntry = ConfigEntry[AirQCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool: """Set up air-Q from a config entry.""" - coordinator = AirQCoordinator(hass, entry) + coordinator = AirQCoordinator( + hass, + entry, + clip_negative=entry.options.get(CONF_CLIP_NEGATIVE, True), + return_average=entry.options.get(CONF_RETURN_AVERAGE, True), + ) # Query the device for the first time and initialise coordinator.data await coordinator.async_config_entry_first_refresh() @@ -24,6 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -31,3 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: AirQConfigEntry) -> bool: """Unload a 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/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 9e51552a309..0c57b399b1b 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -9,11 +9,17 @@ from aioairq import AirQ, InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaFlowFormStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import BooleanSelector -from .const import DOMAIN +from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -23,6 +29,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_PASSWORD): str, } ) +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + schema=vol.Schema( + { + vol.Optional(CONF_RETURN_AVERAGE, default=True): BooleanSelector(), + vol.Optional(CONF_CLIP_NEGATIVE, default=True): BooleanSelector(), + } + ) + ), +} class AirQConfigFlow(ConfigFlow, domain=DOMAIN): @@ -72,3 +88,11 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> SchemaOptionsFlowHandler: + """Return the options flow.""" + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 845fa7f1de8..7a5abe47a8d 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -2,6 +2,8 @@ from typing import Final +CONF_RETURN_AVERAGE: Final = "return_average" +CONF_CLIP_NEGATIVE: Final = "clip_negatives" DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index b03ce36d776..362b65b5828 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -26,6 +26,8 @@ class AirQCoordinator(DataUpdateCoordinator): self, hass: HomeAssistant, entry: ConfigEntry, + clip_negative: bool = True, + return_average: bool = True, ) -> None: """Initialise a custom coordinator.""" super().__init__( @@ -44,6 +46,8 @@ class AirQCoordinator(DataUpdateCoordinator): manufacturer=MANUFACTURER, identifiers={(DOMAIN, self.device_id)}, ) + self.clip_negative = clip_negative + self.return_average = return_average async def _async_update_data(self) -> dict: """Fetch the data from the device.""" @@ -57,4 +61,7 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - return await self.airq.get_latest_data() # type: ignore[no-any-return] + return await self.airq.get_latest_data( # type: ignore[no-any-return] + return_average=self.return_average, + clip_negative_values=self.clip_negative, + ) diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 8628ede4116..26b944467e6 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -19,6 +19,21 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "options": { + "step": { + "init": { + "title": "Configure air-Q integration", + "data": { + "return_average": "Show values averaged by the device", + "clip_negatives": "Clip negative values" + }, + "data_description": { + "return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)", + "clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behaviour is to clip such values to 0" + } + } + } + }, "entity": { "sensor": { "acetaldehyde": { diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index c2c4e452730..22138c7d4fc 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -20,9 +20,8 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] SCAN_INTERVAL = timedelta(minutes=6) -AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]] - -AirthingsConfigEntry = ConfigEntry[AirthingsDataCoordinatorType] +type AirthingsDataCoordinatorType = DataUpdateCoordinator[dict[str, AirthingsDevice]] +type AirthingsConfigEntry = ConfigEntry[AirthingsDataCoordinatorType] async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool: diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index eae7d35c62b..ab453ede20c 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -56,7 +56,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except airthings.AirthingsAuthError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index a1053f6856e..79384eed4ef 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MAX_RETRIES_AFTER_STARTUP PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -66,6 +66,12 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() + # Once its setup and we know we are not going to delay + # the startup of Home Assistant, we can set the max attempts + # to a higher value. If the first connection attempt fails, + # Home Assistant's built-in retry logic will take over. + airthings.set_max_attempts(MAX_RETRIES_AFTER_STARTUP) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index d525aee04b1..48c7219cbaf 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -102,7 +102,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return self.async_abort(reason="unknown") name = get_name(device) @@ -160,7 +160,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return self.async_abort(reason="unknown") name = get_name(device) self._discovered_devices[address] = Discovery(name, discovery_info, device) diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py index 96372919e70..fdfebea8bff 100644 --- a/homeassistant/components/airthings_ble/const.py +++ b/homeassistant/components/airthings_ble/const.py @@ -7,3 +7,5 @@ VOLUME_BECQUEREL = "Bq/m³" VOLUME_PICOCURIE = "pCi/L" DEFAULT_SCAN_INTERVAL = 300 + +MAX_RETRIES_AFTER_STARTUP = 5 diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index d93e3a0b8cb..b86bc314819 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.8.0"] + "requirements": ["airthings-ble==0.9.0"] } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 2883c2b351e..b1ae7d533d8 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -23,16 +23,12 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import ( - CONNECTION_BLUETOOTH, - DeviceInfo, - async_get as device_async_get, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import ( RegistryEntry, async_entries_for_device, - async_get as entity_async_get, ) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -115,13 +111,13 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { @callback def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: """Migrate entities to new unique ids (with BLE Address).""" - ent_reg = entity_async_get(hass) + ent_reg = er.async_get(hass) unique_id_trailer = f"_{sensor_name}" new_unique_id = f"{address}{unique_id_trailer}" if ent_reg.async_get_entity_id(DOMAIN, Platform.SENSOR, new_unique_id): # New unique id already exists return - dev_reg = device_async_get(hass) + dev_reg = dr.async_get(hass) if not ( device := dev_reg.async_get_device( connections={(CONNECTION_BLUETOOTH, address)} diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index 5f63fe023dc..1a4c87a940c 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -7,15 +7,15 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN from .coordinator import AirtouchDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE] +type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: """Set up AirTouch4 from a config entry.""" - hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] airtouch = AirTouch(host) await airtouch.UpdateInfo() @@ -24,18 +24,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 3fdace0f553..29fd2bc4bed 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -16,13 +16,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AirTouch4ConfigEntry from .const import DOMAIN AT_TO_HA_STATE = { @@ -63,11 +63,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AirTouch4ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Airtouch 4.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data info = coordinator.data entities: list[ClimateEntity] = [ AirtouchGroup(coordinator, group["group_number"], info) diff --git a/homeassistant/components/airtouch5/__init__.py b/homeassistant/components/airtouch5/__init__.py index 4ae6c1f1fee..1931098282d 100644 --- a/homeassistant/components/airtouch5/__init__.py +++ b/homeassistant/components/airtouch5/__init__.py @@ -13,7 +13,7 @@ from .const import DOMAIN PLATFORMS: list[Platform] = [Platform.CLIMATE] -Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient] +type Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient] async def async_setup_entry(hass: HomeAssistant, entry: Airtouch5ConfigEntry) -> bool: diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py index 3c4671cf54e..d96aaed96b7 100644 --- a/homeassistant/components/airtouch5/config_flow.py +++ b/homeassistant/components/airtouch5/config_flow.py @@ -32,7 +32,7 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN): client = Airtouch5SimpleClient(user_input[CONF_HOST]) try: await client.test_connection() - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 errors = {"base": "cannot_connect"} else: await self.async_set_unique_id(user_input[CONF_HOST]) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index c0a6b8d38ef..4d0563ddce8 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from collections.abc import Mapping from datetime import timedelta from math import ceil @@ -307,15 +306,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # domain: new_entry_data = {**entry.data} new_entry_data.pop(CONF_INTEGRATION_TYPE) - tasks = [ + + # Schedule the removal in a task to avoid a deadlock + # since we cannot remove a config entry that is in + # the process of being setup. + hass.async_create_background_task( hass.config_entries.async_remove(entry.entry_id), - hass.config_entries.flow.async_init( - DOMAIN_AIRVISUAL_PRO, - context={"source": SOURCE_IMPORT}, - data=new_entry_data, - ), - ] - await asyncio.gather(*tasks) + name="remove config legacy airvisual entry {entry.title}", + ) + await hass.config_entries.flow.async_init( + DOMAIN_AIRVISUAL_PRO, + context={"source": SOURCE_IMPORT}, + data=new_entry_data, + ) # After the migration has occurred, grab the new config and device entries # (now under the `airvisual_pro` domain): diff --git a/homeassistant/components/airvisual_pro/__init__.py b/homeassistant/components/airvisual_pro/__init__.py index a02e735a5d6..7397f279021 100644 --- a/homeassistant/components/airvisual_pro/__init__.py +++ b/homeassistant/components/airvisual_pro/__init__.py @@ -38,7 +38,7 @@ PLATFORMS = [Platform.SENSOR] UPDATE_INTERVAL = timedelta(minutes=1) -AirVisualProConfigEntry = ConfigEntry["AirVisualProData"] +type AirVisualProConfigEntry = ConfigEntry[AirVisualProData] @dataclass diff --git a/homeassistant/components/airvisual_pro/config_flow.py b/homeassistant/components/airvisual_pro/config_flow.py index 97265b33913..ebdbc807b18 100644 --- a/homeassistant/components/airvisual_pro/config_flow.py +++ b/homeassistant/components/airvisual_pro/config_flow.py @@ -60,7 +60,7 @@ async def async_validate_credentials( except NodeProError as err: LOGGER.error("Unknown Pro error while connecting to %s: %s", ip_address, err) errors["base"] = "unknown" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.exception("Unknown error while connecting to %s: %s", ip_address, err) errors["base"] = "unknown" else: diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index 1a65b92c3f4..754dfe90dce 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -17,7 +17,6 @@ from homeassistant.helpers import ( entity_registry as er, ) -from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -30,10 +29,12 @@ PLATFORMS: list[Platform] = [ _LOGGER = logging.getLogger(__name__) +type AirzoneConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] + async def _async_migrate_unique_ids( hass: HomeAssistant, - entry: ConfigEntry, + entry: AirzoneConfigEntry, coordinator: AirzoneUpdateCoordinator, ) -> None: """Migrate entities when the mac address gets discovered.""" @@ -71,7 +72,7 @@ async def _async_migrate_unique_ids( await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool: """Set up Airzone from a config entry.""" options = ConnectionOptions( entry.data[CONF_HOST], @@ -84,16 +85,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() await _async_migrate_unique_ids(hass, entry, coordinator) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index e25751f2a47..20878c08b82 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AirzoneConfigEntry from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity @@ -75,10 +75,12 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone binary sensors from a config_entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data binary_sensors: list[AirzoneBinarySensor] = [ AirzoneSystemBinarySensor( diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index f5b42c4ccbd..33c84b67501 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -50,7 +50,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS +from . import AirzoneConfigEntry +from .const import API_TEMPERATURE_STEP, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneZoneEntity @@ -97,10 +98,12 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone sensors from a config_entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AirzoneClimate( coordinator, diff --git a/homeassistant/components/airzone/diagnostics.py b/homeassistant/components/airzone/diagnostics.py index 8c75302d692..6c75b750eaf 100644 --- a/homeassistant/components/airzone/diagnostics.py +++ b/homeassistant/components/airzone/diagnostics.py @@ -7,12 +7,10 @@ from typing import Any from aioairzone.const import API_MAC, AZD_MAC from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import AirzoneUpdateCoordinator +from . import AirzoneConfigEntry TO_REDACT_API = [ API_MAC, @@ -28,10 +26,10 @@ TO_REDACT_COORD = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: AirzoneConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "api_data": async_redact_data(coordinator.airzone.raw_data(), TO_REDACT_API), diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index b360db61897..61f79eabf52 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -31,6 +31,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import AirzoneConfigEntry from .const import DOMAIN, MANUFACTURER from .coordinator import AirzoneUpdateCoordinator @@ -53,7 +54,7 @@ class AirzoneSystemEntity(AirzoneEntity): def __init__( self, coordinator: AirzoneUpdateCoordinator, - entry: ConfigEntry, + entry: AirzoneConfigEntry, system_data: dict[str, Any], ) -> None: """Initialize.""" diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index a14215fea6b..889170e31d7 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.7.6"] + "requirements": ["aioairzone==0.7.7"] } diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 6e92394bb05..8ffe86851b8 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -22,7 +22,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AirzoneConfigEntry from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity @@ -79,10 +79,12 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone sensors from a config_entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AirzoneZoneSelect( diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index e2f9eabc6f6..7cba0dc515c 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -30,7 +30,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS +from . import AirzoneConfigEntry +from .const import TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import ( AirzoneEntity, @@ -77,10 +78,12 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone sensors from a config_entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors: list[AirzoneSensor] = [ AirzoneZoneSensor( diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py index 4e502776185..ed1c2069c27 100644 --- a/homeassistant/components/airzone/water_heater.py +++ b/homeassistant/components/airzone/water_heater.py @@ -30,7 +30,8 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS +from . import AirzoneConfigEntry +from .const import TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneHotWaterEntity @@ -56,10 +57,12 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone sensors from a config_entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if AZD_HOT_WATER in coordinator.data: async_add_entities([AirzoneWaterHeater(coordinator, entry)]) diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index e53c01e0f81..b1d7900f2e8 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -10,7 +10,6 @@ from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -21,8 +20,12 @@ PLATFORMS: list[Platform] = [ Platform.WATER_HEATER, ] +type AirzoneCloudConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: AirzoneCloudConfigEntry +) -> bool: """Set up Airzone Cloud from a config entry.""" options = ConnectionOptions( entry.data[CONF_USERNAME], @@ -41,18 +44,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = AirzoneUpdateCoordinator(hass, airzone) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AirzoneCloudConfigEntry +) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + coordinator = entry.runtime_data await coordinator.airzone.logout() return unload_ok diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 9266ee3445e..3013a2eeadc 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -8,8 +8,10 @@ from typing import Any, Final from aioairzone_cloud.const import ( AZD_ACTIVE, AZD_AIDOOS, + AZD_AIR_DEMAND, AZD_AQ_ACTIVE, AZD_ERRORS, + AZD_FLOOR_DEMAND, AZD_PROBLEMS, AZD_SYSTEMS, AZD_WARNINGS, @@ -21,12 +23,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AirzoneCloudConfigEntry from .coordinator import AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, @@ -78,10 +79,20 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] device_class=BinarySensorDeviceClass.RUNNING, key=AZD_ACTIVE, ), + AirzoneBinarySensorEntityDescription( + device_class=BinarySensorDeviceClass.RUNNING, + key=AZD_AIR_DEMAND, + translation_key="air_demand", + ), AirzoneBinarySensorEntityDescription( key=AZD_AQ_ACTIVE, translation_key="air_quality_active", ), + AirzoneBinarySensorEntityDescription( + device_class=BinarySensorDeviceClass.RUNNING, + key=AZD_FLOOR_DEMAND, + translation_key="floor_demand", + ), AirzoneBinarySensorEntityDescription( attributes={ "warnings": AZD_WARNINGS, @@ -94,10 +105,12 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneCloudConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone Cloud binary sensors from a config_entry.""" - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data binary_sensors: list[AirzoneBinarySensor] = [ AirzoneAidooBinarySensor( diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 8fcdee11535..3658c073795 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -11,11 +11,14 @@ from aioairzone_cloud.const import ( API_PARAMS, API_POWER, API_SETPOINT, + API_SP_AIR_COOL, + API_SP_AIR_HEAT, API_SPEED_CONF, API_UNITS, API_VALUE, AZD_ACTION, AZD_AIDOOS, + AZD_DOUBLE_SET_POINT, AZD_GROUPS, AZD_HUMIDITY, AZD_INSTALLATIONS, @@ -29,6 +32,8 @@ from aioairzone_cloud.const import ( AZD_SPEEDS, AZD_TEMP, AZD_TEMP_SET, + AZD_TEMP_SET_COOL_AIR, + AZD_TEMP_SET_HOT_AIR, AZD_TEMP_SET_MAX, AZD_TEMP_SET_MIN, AZD_TEMP_STEP, @@ -37,6 +42,8 @@ from aioairzone_cloud.const import ( from homeassistant.components.climate import ( ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -46,13 +53,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AirzoneCloudConfigEntry from .coordinator import AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, @@ -112,10 +118,12 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneCloudConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone climate from a config_entry.""" - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[AirzoneClimate] = [] @@ -171,6 +179,27 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _enable_turn_on_off_backwards_compatibility = False + def _init_attributes(self) -> None: + """Init common climate device attributes.""" + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + if self.get_airzone_value(AZD_DOUBLE_SET_POINT): + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + + if ( + self.get_airzone_value(AZD_SPEED) is not None + and self.get_airzone_value(AZD_SPEEDS) is not None + ): + self._initialize_fan_speeds() + @callback def _handle_coordinator_update(self) -> None: """Update attributes when the coordinator updates.""" @@ -185,6 +214,8 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ self.get_airzone_value(AZD_ACTION) ] + if self.supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) if self.get_airzone_value(AZD_POWER): self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ self.get_airzone_value(AZD_MODE) @@ -193,7 +224,15 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.OFF self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) - self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + self._attr_target_temperature_high = self.get_airzone_value( + AZD_TEMP_SET_COOL_AIR + ) + self._attr_target_temperature_low = self.get_airzone_value( + AZD_TEMP_SET_HOT_AIR + ) + else: + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) class AirzoneDeviceClimate(AirzoneClimate): @@ -204,6 +243,37 @@ class AirzoneDeviceClimate(AirzoneClimate): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) + _speeds: dict[int, str] + _speeds_reverse: dict[str, int] + + def _initialize_fan_speeds(self) -> None: + """Initialize fan speeds.""" + azd_speeds: dict[int, int] = self.get_airzone_value(AZD_SPEEDS) + max_speed = max(azd_speeds) + + fan_speeds: dict[int, str] + if speeds_map := FAN_SPEED_MAPS.get(max_speed): + fan_speeds = speeds_map + else: + fan_speeds = {} + + for speed in azd_speeds: + if speed != 0: + fan_speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%" + + if 0 in azd_speeds: + fan_speeds = FAN_SPEED_AUTO | fan_speeds + + self._speeds = {} + for key, value in fan_speeds.items(): + _key = azd_speeds.get(key) + if _key is not None: + self._speeds[_key] = value + + self._speeds_reverse = {v: k for k, v in self._speeds.items()} + self._attr_fan_modes = list(self._speeds_reverse) + + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE async def async_turn_on(self) -> None: """Turn the entity on.""" @@ -223,6 +293,15 @@ class AirzoneDeviceClimate(AirzoneClimate): } await self._async_update_params(params) + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new fan mode.""" + params: dict[str, Any] = { + API_SPEED_CONF: { + API_VALUE: self._speeds_reverse.get(fan_mode), + } + } + await self._async_update_params(params) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" params: dict[str, Any] = {} @@ -233,6 +312,19 @@ class AirzoneDeviceClimate(AirzoneClimate): API_UNITS: TemperatureUnit.CELSIUS.value, }, } + if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: + params[API_SP_AIR_COOL] = { + API_VALUE: kwargs[ATTR_TARGET_TEMP_HIGH], + API_OPTS: { + API_UNITS: TemperatureUnit.CELSIUS.value, + }, + } + params[API_SP_AIR_HEAT] = { + API_VALUE: kwargs[ATTR_TARGET_TEMP_LOW], + API_OPTS: { + API_UNITS: TemperatureUnit.CELSIUS.value, + }, + } await self._async_update_params(params) if ATTR_HVAC_MODE in kwargs: @@ -298,9 +390,6 @@ class AirzoneDeviceGroupClimate(AirzoneClimate): class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate): """Define an Airzone Cloud Aidoo climate.""" - _speeds: dict[int, str] - _speeds_reverse: dict[str, int] - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -311,58 +400,10 @@ class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate): super().__init__(coordinator, aidoo_id, aidoo_data) self._attr_unique_id = aidoo_id - self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) - self._attr_hvac_modes = [ - HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) - ] - if HVACMode.OFF not in self._attr_hvac_modes: - self._attr_hvac_modes += [HVACMode.OFF] - if ( - self.get_airzone_value(AZD_SPEED) is not None - and self.get_airzone_value(AZD_SPEEDS) is not None - ): - self._initialize_fan_speeds() + self._init_attributes() self._async_update_attrs() - def _initialize_fan_speeds(self) -> None: - """Initialize Aidoo fan speeds.""" - azd_speeds: dict[int, int] = self.get_airzone_value(AZD_SPEEDS) - max_speed = max(azd_speeds) - - fan_speeds: dict[int, str] - if speeds_map := FAN_SPEED_MAPS.get(max_speed): - fan_speeds = speeds_map - else: - fan_speeds = {} - - for speed in azd_speeds: - if speed != 0: - fan_speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%" - - if 0 in azd_speeds: - fan_speeds = FAN_SPEED_AUTO | fan_speeds - - self._speeds = {} - for key, value in fan_speeds.items(): - _key = azd_speeds.get(key) - if _key is not None: - self._speeds[_key] = value - - self._speeds_reverse = {v: k for k, v in self._speeds.items()} - self._attr_fan_modes = list(self._speeds_reverse) - - self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - - async def async_set_fan_mode(self, fan_mode: str) -> None: - """Set Aidoo fan mode.""" - params: dict[str, Any] = { - API_SPEED_CONF: { - API_VALUE: self._speeds_reverse.get(fan_mode), - } - } - await self._async_update_params(params) - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" params: dict[str, Any] = {} @@ -380,14 +421,6 @@ class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate): } await self._async_update_params(params) - @callback - def _async_update_attrs(self) -> None: - """Update Aidoo climate attributes.""" - super()._async_update_attrs() - - if self.supported_features & ClimateEntityFeature.FAN_MODE: - self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) - class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneDeviceGroupClimate): """Define an Airzone Cloud Group climate.""" @@ -402,12 +435,7 @@ class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneDeviceGroupClimate): super().__init__(coordinator, group_id, group_data) self._attr_unique_id = group_id - self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) - self._attr_hvac_modes = [ - HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) - ] - if HVACMode.OFF not in self._attr_hvac_modes: - self._attr_hvac_modes += [HVACMode.OFF] + self._init_attributes() self._async_update_attrs() @@ -425,12 +453,7 @@ class AirzoneInstallationClimate(AirzoneInstallationEntity, AirzoneDeviceGroupCl super().__init__(coordinator, inst_id, inst_data) self._attr_unique_id = inst_id - self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) - self._attr_hvac_modes = [ - HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) - ] - if HVACMode.OFF not in self._attr_hvac_modes: - self._attr_hvac_modes += [HVACMode.OFF] + self._init_attributes() self._async_update_attrs() @@ -448,12 +471,7 @@ class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneDeviceClimate): super().__init__(coordinator, system_zone_id, zone_data) self._attr_unique_id = system_zone_id - self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) - self._attr_hvac_modes = [ - HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) - ] - if HVACMode.OFF not in self._attr_hvac_modes: - self._attr_hvac_modes += [HVACMode.OFF] + self._init_attributes() self._async_update_attrs() diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py index 372455a4597..516a8fcb165 100644 --- a/homeassistant/components/airzone_cloud/diagnostics.py +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -22,12 +22,10 @@ from aioairzone_cloud.const import ( ) from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import AirzoneUpdateCoordinator +from . import AirzoneCloudConfigEntry TO_REDACT_API = [ API_CITY, @@ -137,10 +135,10 @@ def redact_all( async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: AirzoneCloudConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data raw_data = coordinator.airzone.raw_data() ids = gather_ids(raw_data) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 366f8214bc1..555514ecf2a 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.5.1"] + "requirements": ["aioairzone-cloud==0.5.3"] } diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py index c5c9f664503..9bc0bdd1f5b 100644 --- a/homeassistant/components/airzone_cloud/select.py +++ b/homeassistant/components/airzone_cloud/select.py @@ -14,12 +14,11 @@ from aioairzone_cloud.const import ( ) from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AirzoneCloudConfigEntry from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity @@ -52,10 +51,12 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneCloudConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone Cloud select from a config_entry.""" - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data # Zones async_add_entities( diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index febbbcc7ef6..f5dc2d7f9eb 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -23,7 +23,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, @@ -34,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AirzoneCloudConfigEntry from .coordinator import AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, @@ -103,10 +102,12 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneCloudConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone Cloud sensors from a config_entry.""" - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data # Aidoos sensors: list[AirzoneSensor] = [ diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index fe9455aa69e..daeb360719b 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -18,8 +18,14 @@ }, "entity": { "binary_sensor": { + "air_demand": { + "name": "Air demand" + }, "air_quality_active": { "name": "Air Quality active" + }, + "floor_demand": { + "name": "Floor demand" } }, "select": { diff --git a/homeassistant/components/airzone_cloud/water_heater.py b/homeassistant/components/airzone_cloud/water_heater.py index fd1c772b38a..51228ae6b90 100644 --- a/homeassistant/components/airzone_cloud/water_heater.py +++ b/homeassistant/components/airzone_cloud/water_heater.py @@ -27,12 +27,11 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AirzoneCloudConfigEntry from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneHotWaterEntity @@ -68,10 +67,12 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirzoneCloudConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Add Airzone Cloud Water Heater from a config_entry.""" - coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AirzoneWaterHeater( diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 84710c3f74e..436e797271f 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,48 +1,94 @@ -"""The aladdin_connect component.""" +"""The Aladdin Connect Genie integration.""" -import logging -from typing import Final +from __future__ import annotations -from AIOAladdinConnect import AladdinConnectClient -import AIOAladdinConnect.session_manager as Aladdin -from aiohttp import ClientError +from genie_partner_sdk.client import AladdinConnectClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) -from .const import CLIENT_ID, DOMAIN - -_LOGGER: Final = logging.getLogger(__name__) +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import AladdinConnectCoordinator PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR] +type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up platform from a ConfigEntry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - acc = AladdinConnectClient( - username, password, async_get_clientsession(hass), CLIENT_ID - ) - try: - await acc.login() - except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex: - raise ConfigEntryNotReady("Can not connect to host") from ex - except Aladdin.InvalidPasswordError as ex: - raise ConfigEntryAuthFailed("Incorrect Password") from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = acc +async def async_setup_entry( + hass: HomeAssistant, entry: AladdinConnectConfigEntry +) -> bool: + """Set up Aladdin Connect Genie from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + coordinator = AladdinConnectCoordinator(hass, AladdinConnectClient(auth)) + + await coordinator.async_setup() + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async_remove_stale_devices(hass, entry) + return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AladdinConnectConfigEntry +) -> 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 await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: AladdinConnectConfigEntry +) -> bool: + """Migrate old config.""" + if config_entry.version < 2: + config_entry.async_start_reauth(hass) + hass.config_entries.async_update_entry( + config_entry, + version=2, + minor_version=1, + ) + + return True + + +def async_remove_stale_devices( + hass: HomeAssistant, config_entry: AladdinConnectConfigEntry +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors} + + for device_entry in device_entries: + device_id: str | None = None + + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + device_id = identifier[1] + break + + if device_id is None or device_id not in all_device_ids: + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py new file mode 100644 index 00000000000..c4a19ef0081 --- /dev/null +++ b/homeassistant/components/aladdin_connect/api.py @@ -0,0 +1,32 @@ +"""API for Aladdin Connect Genie bound to Home Assistant OAuth.""" + +from typing import cast + +from aiohttp import ClientSession +from genie_partner_sdk.auth import Auth + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + +API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1" +API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3" + + +class AsyncConfigEntryAuth(Auth): # type: ignore[misc] + """Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: OAuth2Session, + ) -> None: + """Initialize Aladdin Connect Genie auth.""" + super().__init__( + websession, API_URL, oauth_session.token["access_token"], API_KEY + ) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/aladdin_connect/application_credentials.py b/homeassistant/components/aladdin_connect/application_credentials.py new file mode 100644 index 00000000000..e8e959f1fa3 --- /dev/null +++ b/homeassistant/components/aladdin_connect/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Aladdin Connect Genie integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index e960138853a..507085fa27f 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -1,137 +1,70 @@ -"""Config flow for Aladdin Connect cover integration.""" - -from __future__ import annotations +"""Config flow for Aladdin Connect Genie.""" from collections.abc import Mapping +import logging from typing import Any -from AIOAladdinConnect import AladdinConnectClient -import AIOAladdinConnect.session_manager as Aladdin -from aiohttp.client_exceptions import ClientError -import voluptuous as vol +import jwt -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import CLIENT_ID, DOMAIN - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - -REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) +from .const import DOMAIN -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: - """Validate the user input allows us to connect. +class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Aladdin Connect Genie OAuth2 authentication.""" - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - acc = AladdinConnectClient( - data[CONF_USERNAME], - data[CONF_PASSWORD], - async_get_clientsession(hass), - CLIENT_ID, - ) - try: - await acc.login() - except (ClientError, TimeoutError, Aladdin.ConnectionError): - raise + DOMAIN = DOMAIN + VERSION = 2 + MINOR_VERSION = 1 - except Aladdin.InvalidPasswordError as ex: - raise InvalidAuth from ex - - -class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Aladdin Connect.""" - - VERSION = 1 - entry: ConfigEntry | None + reauth_entry: ConfigEntry | None = None async def async_step_reauth( - self, entry_data: Mapping[str, Any] + self, user_input: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle re-authentication with Aladdin Connect.""" - - self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + """Perform reauth upon API auth error or upgrade from v1 to v2.""" + 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 + self, user_input: Mapping[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm re-authentication with Aladdin Connect.""" - errors: dict[str, str] = {} - - if user_input: - assert self.entry is not None - password = user_input[CONF_PASSWORD] - data = { - CONF_USERNAME: self.entry.data[CONF_USERNAME], - CONF_PASSWORD: password, - } - - try: - await validate_input(self.hass, data) - - except InvalidAuth: - errors["base"] = "invalid_auth" - - except (ClientError, TimeoutError, Aladdin.ConnectionError): - errors["base"] = "cannot_connect" - - else: - self.hass.config_entries.async_update_entry( - self.entry, - data={ - **self.entry.data, - CONF_PASSWORD: password, - }, - ) - await 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=REAUTH_SCHEMA, - errors=errors, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" + """Dialog that informs the user that reauth is required.""" if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() - errors = {} - - try: - await validate_input(self.hass, user_input) - except InvalidAuth: - errors["base"] = "invalid_auth" - - except (ClientError, TimeoutError, Aladdin.ConnectionError): - errors["base"] = "cannot_connect" - - else: - await self.async_set_unique_id( - user_input["username"].lower(), raise_on_progress=False - ) - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Aladdin Connect", data=user_input) - - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + token_payload = jwt.decode( + data[CONF_TOKEN][CONF_ACCESS_TOKEN], options={"verify_signature": False} ) + if not self.reauth_entry: + await self.async_set_unique_id(token_payload["sub"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=token_payload["username"], + data=data, + ) -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" + if self.reauth_entry.unique_id == token_payload["username"]: + return self.async_update_reload_and_abort( + self.reauth_entry, + data=data, + unique_id=token_payload["sub"], + ) + if self.reauth_entry.unique_id == token_payload["sub"]: + return self.async_update_reload_and_abort(self.reauth_entry, data=data) + + return self.async_abort(reason="wrong_account") + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/aladdin_connect/const.py b/homeassistant/components/aladdin_connect/const.py index bf77c032d1b..a87147c8f09 100644 --- a/homeassistant/components/aladdin_connect/const.py +++ b/homeassistant/components/aladdin_connect/const.py @@ -1,22 +1,6 @@ -"""Platform for the Aladdin Connect cover component.""" - -from __future__ import annotations - -from typing import Final - -from homeassistant.components.cover import CoverEntityFeature -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING - -NOTIFICATION_ID: Final = "aladdin_notification" -NOTIFICATION_TITLE: Final = "Aladdin Connect Cover Setup" - -STATES_MAP: Final[dict[str, str]] = { - "open": STATE_OPEN, - "opening": STATE_OPENING, - "closed": STATE_CLOSED, - "closing": STATE_CLOSING, -} +"""Constants for the Aladdin Connect Genie integration.""" DOMAIN = "aladdin_connect" -SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE -CLIENT_ID = "1000" + +OAUTH2_AUTHORIZE = "https://app.aladdinconnect.net/login.html" +OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token" diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py new file mode 100644 index 00000000000..d9af0da9450 --- /dev/null +++ b/homeassistant/components/aladdin_connect/coordinator.py @@ -0,0 +1,38 @@ +"""Define an object to coordinate fetching Aladdin Connect data.""" + +from datetime import timedelta +import logging + +from genie_partner_sdk.client import AladdinConnectClient +from genie_partner_sdk.model import GarageDoor + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AladdinConnectCoordinator(DataUpdateCoordinator[None]): + """Aladdin Connect Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, acc: AladdinConnectClient) -> None: + """Initialize.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=15), + ) + self.acc = acc + self.doors: list[GarageDoor] = [] + + async def async_setup(self) -> None: + """Fetch initial data.""" + self.doors = await self.acc.get_doors() + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + for door in self.doors: + await self.acc.update_door(door.device_id, door.door_number) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 61c8df92eaf..b8c48048192 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,147 +1,84 @@ -"""Platform for the Aladdin Connect cover component.""" +"""Cover Entity for Genie Garage Door.""" -from __future__ import annotations - -from datetime import timedelta from typing import Any -from AIOAladdinConnect import AladdinConnectClient, session_manager +from genie_partner_sdk.model import GarageDoor -from homeassistant.components.cover import CoverDeviceClass, CoverEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) 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 -from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES -from .model import DoorDevice - -SCAN_INTERVAL = timedelta(seconds=300) +from . import AladdinConnectConfigEntry, AladdinConnectCoordinator +from .entity import AladdinConnectEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AladdinConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aladdin Connect platform.""" - acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] - doors = await acc.get_doors() - if doors is None: - raise PlatformNotReady("Error from Aladdin Connect getting doors") - async_add_entities( - (AladdinDevice(acc, door, config_entry) for door in doors), - ) - remove_stale_devices(hass, config_entry, doors) + coordinator = config_entry.runtime_data + + async_add_entities(AladdinDevice(coordinator, door) for door in coordinator.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): +class AladdinDevice(AladdinConnectEntity, CoverEntity): """Representation of Aladdin Connect cover.""" _attr_device_class = CoverDeviceClass.GARAGE - _attr_supported_features = SUPPORTED_FEATURES - _attr_has_entity_name = True + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE _attr_name = None def __init__( - self, acc: AladdinConnectClient, device: DoorDevice, entry: ConfigEntry + self, coordinator: AladdinConnectCoordinator, device: GarageDoor ) -> None: """Initialize the Aladdin Connect cover.""" - self._acc = acc - self._device_id = device["device_id"] - self._number = device["door_number"] - self._serial = device["serial"] - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=device["name"], - manufacturer="Overhead Door", - model=device["model"], - ) - self._attr_unique_id = f"{self._device_id}-{self._number}" - - async def async_added_to_hass(self) -> None: - """Connect Aladdin Connect to the cloud.""" - - self._acc.register_callback( - self.async_write_ha_state, self._serial, self._number - ) - await self._acc.get_doors(self._serial) - - async def async_will_remove_from_hass(self) -> None: - """Close Aladdin Connect before removing.""" - self._acc.unregister_callback(self._serial, self._number) - await self._acc.close() - - async def async_close_cover(self, **kwargs: Any) -> None: - """Issue close command to cover.""" - if not await self._acc.close_door(self._device_id, self._number): - raise HomeAssistantError("Aladdin Connect API failed to close the cover") + super().__init__(coordinator, device) + self._attr_unique_id = device.unique_id async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - if not await self._acc.open_door(self._device_id, self._number): - raise HomeAssistantError("Aladdin Connect API failed to open the cover") + await self.coordinator.acc.open_door( + self._device.device_id, self._device.door_number + ) - async def async_update(self) -> None: - """Update status of cover.""" - try: - await self._acc.get_doors(self._serial) - self._attr_available = True - - except (session_manager.ConnectionError, session_manager.InvalidPasswordError): - self._attr_available = False + async def async_close_cover(self, **kwargs: Any) -> None: + """Issue close command to cover.""" + await self.coordinator.acc.close_door( + self._device.device_id, self._device.door_number + ) @property def is_closed(self) -> bool | None: """Update is closed attribute.""" - value = STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + value = self.coordinator.acc.get_door_status( + self._device.device_id, self._device.door_number + ) if value is None: return None - return value == STATE_CLOSED + return bool(value == "closed") @property - def is_closing(self) -> bool: + def is_closing(self) -> bool | None: """Update is closing attribute.""" - return ( - STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) - == STATE_CLOSING + value = self.coordinator.acc.get_door_status( + self._device.device_id, self._device.door_number ) + if value is None: + return None + return bool(value == "closing") @property - def is_opening(self) -> bool: + def is_opening(self) -> bool | None: """Update is opening attribute.""" - return ( - STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) - == STATE_OPENING + value = self.coordinator.acc.get_door_status( + self._device.device_id, self._device.door_number ) + if value is None: + return None + return bool(value == "opening") diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py deleted file mode 100644 index 67a31079f14..00000000000 --- a/homeassistant/components/aladdin_connect/diagnostics.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Diagnostics support for Aladdin Connect.""" - -from __future__ import annotations - -from typing import Any - -from AIOAladdinConnect import AladdinConnectClient - -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .const import DOMAIN - -TO_REDACT = {"serial", "device_id"} - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, - config_entry: ConfigEntry, -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - - acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] - - return { - "doors": async_redact_data(acc.doors, TO_REDACT), - } diff --git a/homeassistant/components/aladdin_connect/entity.py b/homeassistant/components/aladdin_connect/entity.py new file mode 100644 index 00000000000..8d9eeefcdfb --- /dev/null +++ b/homeassistant/components/aladdin_connect/entity.py @@ -0,0 +1,27 @@ +"""Defines a base Aladdin Connect entity.""" + +from genie_partner_sdk.model import GarageDoor + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AladdinConnectCoordinator + + +class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]): + """Defines a base Aladdin Connect entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: AladdinConnectCoordinator, device: GarageDoor + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device = device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + name=device.name, + manufacturer="Overhead Door", + ) diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 344c77dcb73..69b38399cce 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -1,11 +1,10 @@ { "domain": "aladdin_connect", "name": "Aladdin Connect", - "codeowners": ["@mkmer"], + "codeowners": ["@swcloudgenie"], "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", - "loggers": ["aladdin_connect"], - "quality_scale": "platinum", - "requirements": ["AIOAladdinConnect==0.1.58"] + "requirements": ["genie-partner-sdk==1.0.2"] } diff --git a/homeassistant/components/aladdin_connect/model.py b/homeassistant/components/aladdin_connect/model.py deleted file mode 100644 index 73e445f2f3b..00000000000 --- a/homeassistant/components/aladdin_connect/model.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Models for Aladdin connect cover platform.""" - -from __future__ import annotations - -from typing import TypedDict - - -class DoorDevice(TypedDict): - """Aladdin door device.""" - - device_id: str - door_number: int - name: str - status: str - serial: str - model: str diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index 22aa9c6faf0..2bd0168a500 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import cast -from AIOAladdinConnect import AladdinConnectClient +from genie_partner_sdk.client import AladdinConnectClient +from genie_partner_sdk.model import GarageDoor from homeassistant.components.sensor import ( SensorDeviceClass, @@ -14,21 +14,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .model import DoorDevice +from . import AladdinConnectConfigEntry, AladdinConnectCoordinator +from .entity import AladdinConnectEntity @dataclass(frozen=True, kw_only=True) class AccSensorEntityDescription(SensorEntityDescription): """Describes AladdinConnect sensor entity.""" - value_fn: Callable + value_fn: Callable[[AladdinConnectClient, str, int], float | None] SENSORS: tuple[AccSensorEntityDescription, ...] = ( @@ -40,79 +38,43 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_fn=AladdinConnectClient.get_battery_status, ), - AccSensorEntityDescription( - key="rssi", - translation_key="wifi_strength", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_registry_enabled_default=False, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - state_class=SensorStateClass.MEASUREMENT, - value_fn=AladdinConnectClient.get_rssi_status, - ), - AccSensorEntityDescription( - key="ble_strength", - translation_key="ble_strength", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_registry_enabled_default=False, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - state_class=SensorStateClass.MEASUREMENT, - value_fn=AladdinConnectClient.get_ble_strength, - ), ) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AladdinConnectConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Aladdin Connect sensor devices.""" + coordinator = entry.runtime_data - acc: AladdinConnectClient = hass.data[DOMAIN][entry.entry_id] - - entities = [] - doors = await acc.get_doors() - - for door in doors: - entities.extend( - [AladdinConnectSensor(acc, door, description) for description in SENSORS] - ) - - async_add_entities(entities) + async_add_entities( + AladdinConnectSensor(coordinator, door, description) + for description in SENSORS + for door in coordinator.doors + ) -class AladdinConnectSensor(SensorEntity): +class AladdinConnectSensor(AladdinConnectEntity, SensorEntity): """A sensor implementation for Aladdin Connect devices.""" entity_description: AccSensorEntityDescription - _attr_has_entity_name = True def __init__( self, - acc: AladdinConnectClient, - device: DoorDevice, + coordinator: AladdinConnectCoordinator, + device: GarageDoor, description: AccSensorEntityDescription, ) -> None: """Initialize a sensor for an Aladdin Connect device.""" - self._device_id = device["device_id"] - self._number = device["door_number"] - self._acc = acc + super().__init__(coordinator, device) self.entity_description = description - self._attr_unique_id = f"{self._device_id}-{self._number}-{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=device["name"], - manufacturer="Overhead Door", - model=device["model"], - ) - if device["model"] == "01" and description.key in ( - "battery_level", - "ble_strength", - ): - self._attr_entity_registry_enabled_default = True + self._attr_unique_id = f"{device.unique_id}-{description.key}" @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return cast( - float, - self.entity_description.value_fn(self._acc, self._device_id, self._number), + return self.entity_description.value_fn( + self.coordinator.acc, self._device.device_id, self._device.door_number ) diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index bfe932b039c..48f9b299a1d 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -1,39 +1,29 @@ { "config": { "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Aladdin Connect integration needs to re-authenticate your account", - "data": { - "password": "[%key:common::config_flow::data::password%]" - } + "description": "Aladdin Connect needs to re-authenticate your account" } }, - - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "entity": { - "sensor": { - "wifi_strength": { - "name": "Wi-Fi RSSI" - }, - "ble_strength": { - "name": "BLE Strength" - } + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" } } } diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 3260454826a..f33e168c031 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -21,7 +21,8 @@ from homeassistant.const import ( SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.deprecation import ( @@ -33,7 +34,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from . import group as group_pre_import # noqa: F401 from .const import ( # noqa: F401 _DEPRECATED_FORMAT_NUMBER, _DEPRECATED_FORMAT_TEXT, @@ -55,6 +55,8 @@ _LOGGER: Final = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" +CONF_DEFAULT_CODE = "default_code" + ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( {vol.Optional(ATTR_CODE): cv.string} ) @@ -74,36 +76,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_ALARM_DISARM, ALARM_SERVICE_SCHEMA, "async_alarm_disarm" + SERVICE_ALARM_DISARM, + ALARM_SERVICE_SCHEMA, + "async_handle_alarm_disarm", ) component.async_register_entity_service( SERVICE_ALARM_ARM_HOME, ALARM_SERVICE_SCHEMA, - "async_alarm_arm_home", + "async_handle_alarm_arm_home", [AlarmControlPanelEntityFeature.ARM_HOME], ) component.async_register_entity_service( SERVICE_ALARM_ARM_AWAY, ALARM_SERVICE_SCHEMA, - "async_alarm_arm_away", + "async_handle_alarm_arm_away", [AlarmControlPanelEntityFeature.ARM_AWAY], ) component.async_register_entity_service( SERVICE_ALARM_ARM_NIGHT, ALARM_SERVICE_SCHEMA, - "async_alarm_arm_night", + "async_handle_alarm_arm_night", [AlarmControlPanelEntityFeature.ARM_NIGHT], ) component.async_register_entity_service( SERVICE_ALARM_ARM_VACATION, ALARM_SERVICE_SCHEMA, - "async_alarm_arm_vacation", + "async_handle_alarm_arm_vacation", [AlarmControlPanelEntityFeature.ARM_VACATION], ) component.async_register_entity_service( SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA, - "async_alarm_arm_custom_bypass", + "async_handle_alarm_arm_custom_bypass", [AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS], ) component.async_register_entity_service( @@ -150,6 +154,21 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A _attr_supported_features: AlarmControlPanelEntityFeature = ( AlarmControlPanelEntityFeature(0) ) + _alarm_control_panel_option_default_code: str | None = None + + @final + @callback + def code_or_default_code(self, code: str | None) -> str | None: + """Return code to use for a service call. + + If the passed in code is not None, it will be returned. Otherwise return the + default code, if set, or None if not set, is returned. + """ + if code: + # Return code provided by user + return code + # Fallback to default code or None if not set + return self._alarm_control_panel_option_default_code @cached_property def code_format(self) -> CodeFormat | None: @@ -166,6 +185,26 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A """Whether the code is required for arm actions.""" return self._attr_code_arm_required + @final + @callback + def check_code_arm_required(self, code: str | None) -> str | None: + """Check if arm code is required, raise if no code is given.""" + if not (_code := self.code_or_default_code(code)) and self.code_arm_required: + raise ServiceValidationError( + f"Arming requires a code but none was given for {self.entity_id}", + translation_domain=DOMAIN, + translation_key="code_arm_required", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + return _code + + @final + async def async_handle_alarm_disarm(self, code: str | None = None) -> None: + """Add default code and disarm.""" + await self.async_alarm_disarm(self.code_or_default_code(code)) + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" raise NotImplementedError @@ -174,6 +213,11 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A """Send disarm command.""" await self.hass.async_add_executor_job(self.alarm_disarm, code) + @final + async def async_handle_alarm_arm_home(self, code: str | None = None) -> None: + """Add default code and arm home.""" + await self.async_alarm_arm_home(self.check_code_arm_required(code)) + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" raise NotImplementedError @@ -182,6 +226,11 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A """Send arm home command.""" await self.hass.async_add_executor_job(self.alarm_arm_home, code) + @final + async def async_handle_alarm_arm_away(self, code: str | None = None) -> None: + """Add default code and arm away.""" + await self.async_alarm_arm_away(self.check_code_arm_required(code)) + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" raise NotImplementedError @@ -190,6 +239,11 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A """Send arm away command.""" await self.hass.async_add_executor_job(self.alarm_arm_away, code) + @final + async def async_handle_alarm_arm_night(self, code: str | None = None) -> None: + """Add default code and arm night.""" + await self.async_alarm_arm_night(self.check_code_arm_required(code)) + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" raise NotImplementedError @@ -198,6 +252,11 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A """Send arm night command.""" await self.hass.async_add_executor_job(self.alarm_arm_night, code) + @final + async def async_handle_alarm_arm_vacation(self, code: str | None = None) -> None: + """Add default code and arm vacation.""" + await self.async_alarm_arm_vacation(self.check_code_arm_required(code)) + def alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" raise NotImplementedError @@ -214,6 +273,13 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A """Send alarm trigger command.""" await self.hass.async_add_executor_job(self.alarm_trigger, code) + @final + async def async_handle_alarm_arm_custom_bypass( + self, code: str | None = None + ) -> None: + """Add default code and arm custom bypass.""" + await self.async_alarm_arm_custom_bypass(self.check_code_arm_required(code)) + def alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" raise NotImplementedError @@ -242,6 +308,33 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A ATTR_CODE_ARM_REQUIRED: self.code_arm_required, } + async def async_internal_added_to_hass(self) -> None: + """Call when the alarm control panel entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self._async_read_entity_options() + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_read_entity_options() + + @callback + def _async_read_entity_options(self) -> None: + """Read entity options from entity registry. + + Called when the entity registry entry has been updated and before the + alarm control panel is added to the state machine. + """ + assert self.registry_entry + if (alarm_options := self.registry_entry.options.get(DOMAIN)) and ( + default_code := alarm_options.get(CONF_DEFAULT_CODE) + ): + self._alarm_control_panel_option_default_code = default_code + return + self._alarm_control_panel_option_default_code = None + # As we import constants of the const module here, we need to add the following # functions to check for deprecated constants again diff --git a/homeassistant/components/alarm_control_panel/group.py b/homeassistant/components/alarm_control_panel/group.py deleted file mode 100644 index 5b90b255ada..00000000000 --- a/homeassistant/components/alarm_control_panel/group.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_VACATION, - STATE_ALARM_TRIGGERED, - STATE_OFF, - STATE_ON, -) -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states( - DOMAIN, - { - STATE_ON, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_VACATION, - STATE_ALARM_TRIGGERED, - }, - STATE_ON, - STATE_OFF, - ) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index deaab6d75ee..6dac4d069a1 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -17,6 +17,10 @@ "is_armed_night": "{entity_name} is armed night", "is_armed_vacation": "{entity_name} is armed vacation" }, + "extra_fields": { + "code": "Code", + "for": "[%key:common::device_automation::extra_fields::for%]" + }, "trigger_type": { "triggered": "{entity_name} triggered", "disarmed": "{entity_name} disarmed", diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index c05c6ea6119..4abf45b74fa 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -1,5 +1,7 @@ """Support for AlarmDecoder devices.""" +from collections.abc import Callable +from dataclasses import dataclass from datetime import timedelta import logging @@ -22,11 +24,6 @@ from homeassistant.helpers.event import async_call_later from .const import ( CONF_DEVICE_BAUD, CONF_DEVICE_PATH, - DATA_AD, - DATA_REMOVE_STOP_LISTENER, - DATA_REMOVE_UPDATE_LISTENER, - DATA_RESTART, - DOMAIN, PROTOCOL_SERIAL, PROTOCOL_SOCKET, SIGNAL_PANEL_MESSAGE, @@ -44,8 +41,22 @@ PLATFORMS = [ Platform.SENSOR, ] +type AlarmDecoderConfigEntry = ConfigEntry[AlarmDecoderData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class AlarmDecoderData: + """Runtime data for the AlarmDecoder class.""" + + client: AdExt + remove_update_listener: Callable[[], None] + remove_stop_listener: Callable[[], None] + restart: bool + + +async def async_setup_entry( + hass: HomeAssistant, entry: AlarmDecoderConfigEntry +) -> bool: """Set up AlarmDecoder config flow.""" undo_listener = entry.add_update_listener(_update_listener) @@ -54,10 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def stop_alarmdecoder(event): """Handle the shutdown of AlarmDecoder.""" - if not hass.data.get(DOMAIN): + if not entry.runtime_data: return _LOGGER.debug("Shutting down alarmdecoder") - hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = False + entry.runtime_data.restart = False controller.close() async def open_connection(now=None): @@ -69,13 +80,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_call_later(hass, timedelta(seconds=5), open_connection) return _LOGGER.debug("Established a connection with the alarmdecoder") - hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = True + entry.runtime_data.restart = True def handle_closed_connection(event): """Restart after unexpected loss of connection.""" - if not hass.data[DOMAIN][entry.entry_id][DATA_RESTART]: + if not entry.runtime_data.restart: return - hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = False + entry.runtime_data.restart = False _LOGGER.warning("AlarmDecoder unexpectedly lost connection") hass.add_job(open_connection) @@ -119,43 +130,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_AD: controller, - DATA_REMOVE_UPDATE_LISTENER: undo_listener, - DATA_REMOVE_STOP_LISTENER: remove_stop_listener, - DATA_RESTART: False, - } + entry.runtime_data = AlarmDecoderData( + controller, undo_listener, remove_stop_listener, False + ) await open_connection() + await controller.is_init() + 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, entry: AlarmDecoderConfigEntry +) -> bool: """Unload a AlarmDecoder entry.""" - hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = False + data = entry.runtime_data + data.restart = False unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: return False - hass.data[DOMAIN][entry.entry_id][DATA_REMOVE_UPDATE_LISTENER]() - hass.data[DOMAIN][entry.entry_id][DATA_REMOVE_STOP_LISTENER]() - await hass.async_add_executor_job(hass.data[DOMAIN][entry.entry_id][DATA_AD].close) - - if hass.data[DOMAIN][entry.entry_id]: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + data.remove_update_listener() + data.remove_stop_listener() + await hass.async_add_executor_job(data.client.close) return True -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _update_listener(hass: HomeAssistant, entry: AlarmDecoderConfigEntry) -> None: """Handle options update.""" _LOGGER.debug("AlarmDecoder options updated: %s", entry.as_dict()["options"]) await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 2e2db6f070f..7375320f800 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -9,7 +9,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, STATE_ALARM_ARMED_AWAY, @@ -24,16 +23,16 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AlarmDecoderConfigEntry from .const import ( CONF_ALT_NIGHT_MODE, CONF_AUTO_BYPASS, CONF_CODE_ARM_REQUIRED, - DATA_AD, DEFAULT_ARM_OPTIONS, - DOMAIN, OPTIONS_ARM, SIGNAL_PANEL_MESSAGE, ) +from .entity import AlarmDecoderEntity SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime" @@ -42,15 +41,16 @@ ATTR_KEYPRESS = "keypress" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AlarmDecoderConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up for AlarmDecoder alarm panels.""" options = entry.options arm_options = options.get(OPTIONS_ARM, DEFAULT_ARM_OPTIONS) - client = hass.data[DOMAIN][entry.entry_id][DATA_AD] entity = AlarmDecoderAlarmPanel( - client=client, + client=entry.runtime_data.client, auto_bypass=arm_options[CONF_AUTO_BYPASS], code_arm_required=arm_options[CONF_CODE_ARM_REQUIRED], alt_night_mode=arm_options[CONF_ALT_NIGHT_MODE], @@ -75,7 +75,7 @@ async def async_setup_entry( ) -class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): +class AlarmDecoderAlarmPanel(AlarmDecoderEntity, AlarmControlPanelEntity): """Representation of an AlarmDecoder-based alarm panel.""" _attr_name = "Alarm Panel" @@ -89,7 +89,8 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): def __init__(self, client, auto_bypass, code_arm_required, alt_night_mode): """Initialize the alarm panel.""" - self._client = client + super().__init__(client) + self._attr_unique_id = f"{client.serial_number}-panel" self._auto_bypass = auto_bypass self._attr_code_arm_required = code_arm_required self._alt_night_mode = alt_night_mode diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index 1d41dcd2364..1234c9f349b 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -3,11 +3,11 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AlarmDecoderConfigEntry from .const import ( CONF_RELAY_ADDR, CONF_RELAY_CHAN, @@ -23,6 +23,7 @@ from .const import ( SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE, ) +from .entity import AlarmDecoderEntity _LOGGER = logging.getLogger(__name__) @@ -37,10 +38,13 @@ ATTR_RF_LOOP1 = "rf_loop1" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AlarmDecoderConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up for AlarmDecoder sensor.""" + client = entry.runtime_data.client zones = entry.options.get(OPTIONS_ZONES, DEFAULT_ZONE_OPTIONS) entities = [] @@ -53,20 +57,28 @@ async def async_setup_entry( relay_addr = zone_info.get(CONF_RELAY_ADDR) relay_chan = zone_info.get(CONF_RELAY_CHAN) entity = AlarmDecoderBinarySensor( - zone_num, zone_name, zone_type, zone_rfid, zone_loop, relay_addr, relay_chan + client, + zone_num, + zone_name, + zone_type, + zone_rfid, + zone_loop, + relay_addr, + relay_chan, ) entities.append(entity) async_add_entities(entities) -class AlarmDecoderBinarySensor(BinarySensorEntity): +class AlarmDecoderBinarySensor(AlarmDecoderEntity, BinarySensorEntity): """Representation of an AlarmDecoder binary sensor.""" _attr_should_poll = False def __init__( self, + client, zone_number, zone_name, zone_type, @@ -76,6 +88,8 @@ class AlarmDecoderBinarySensor(BinarySensorEntity): relay_chan, ): """Initialize the binary_sensor.""" + super().__init__(client) + self._attr_unique_id = f"{client.serial_number}-zone-{zone_number}" self._zone_number = int(zone_number) self._zone_type = zone_type self._attr_name = zone_name diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index a775375b835..779951dd0b0 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -128,7 +128,7 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN): ) except NoDeviceError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception during AlarmDecoder setup") errors["base"] = "unknown" diff --git a/homeassistant/components/alarmdecoder/const.py b/homeassistant/components/alarmdecoder/const.py index 4aba16a9cf8..cefd47fc0a5 100644 --- a/homeassistant/components/alarmdecoder/const.py +++ b/homeassistant/components/alarmdecoder/const.py @@ -13,11 +13,6 @@ CONF_ZONE_NUMBER = "zone_number" CONF_ZONE_RFID = "zone_rfid" CONF_ZONE_TYPE = "zone_type" -DATA_AD = "alarmdecoder" -DATA_REMOVE_STOP_LISTENER = "rm_stop_listener" -DATA_REMOVE_UPDATE_LISTENER = "rm_update_listener" -DATA_RESTART = "restart" - DEFAULT_ALT_NIGHT_MODE = False DEFAULT_AUTO_BYPASS = False DEFAULT_CODE_ARM_REQUIRED = True diff --git a/homeassistant/components/alarmdecoder/entity.py b/homeassistant/components/alarmdecoder/entity.py new file mode 100644 index 00000000000..821b9221eed --- /dev/null +++ b/homeassistant/components/alarmdecoder/entity.py @@ -0,0 +1,22 @@ +"""Support for AlarmDecoder-based alarm control panels entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class AlarmDecoderEntity(Entity): + """Define a base AlarmDecoder entity.""" + + _attr_has_entity_name = True + + def __init__(self, client): + """Initialize the alarm decoder entity.""" + self._client = client + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, client.serial_number)}, + manufacturer="NuTech", + serial_number=client.serial_number, + sw_version=client.version_number, + ) diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index 656cc35505a..ae1a2f4684d 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -4,7 +4,8 @@ "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", + "integration_type": "device", "iot_class": "local_push", "loggers": ["adext", "alarmdecoder"], - "requirements": ["adext==0.4.2"] + "requirements": ["adext==0.4.3"] } diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index e796334a91c..f5e744457fd 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -1,30 +1,38 @@ """Support for AlarmDecoder sensors (Shows Panel Display).""" from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AlarmDecoderConfigEntry from .const import SIGNAL_PANEL_MESSAGE +from .entity import AlarmDecoderEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AlarmDecoderConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up for AlarmDecoder sensor.""" - entity = AlarmDecoderSensor() + entity = AlarmDecoderSensor(client=entry.runtime_data.client) async_add_entities([entity]) -class AlarmDecoderSensor(SensorEntity): +class AlarmDecoderSensor(AlarmDecoderEntity, SensorEntity): """Representation of an AlarmDecoder keypad.""" _attr_translation_key = "alarm_panel_display" _attr_name = "Alarm Panel Display" _attr_should_poll = False + def __init__(self, client): + """Initialize the alarm decoder sensor.""" + super().__init__(client) + self._attr_unique_id = f"{client.serial_number}-display" + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index df32220895d..047e981ab0d 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Generator import logging from typing import Any +from typing_extensions import Generator + from homeassistant.components import ( button, climate, @@ -260,7 +261,7 @@ class AlexaCapability: return result - def serialize_properties(self) -> Generator[dict[str, Any], None, None]: + def serialize_properties(self) -> Generator[dict[str, Any]]: """Return properties serialized for an API response.""" for prop in self.properties_supported(): prop_name = prop["name"] @@ -268,7 +269,7 @@ class AlexaCapability: prop_value = self.get_property(prop_name) except UnsupportedProperty: raise - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Unexpected error getting %s.%s property from %s", self.name(), diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index ca7b78f7ff5..8d45ac3a11b 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -2,10 +2,12 @@ from __future__ import annotations -from collections.abc import Generator, Iterable +from collections.abc import Iterable import logging from typing import TYPE_CHECKING, Any +from typing_extensions import Generator + from homeassistant.components import ( alarm_control_panel, alert, @@ -319,7 +321,7 @@ class AlexaEntity: """ raise NotImplementedError - def serialize_properties(self) -> Generator[dict[str, Any], None, None]: + def serialize_properties(self) -> Generator[dict[str, Any]]: """Yield each supported property in API format.""" for interface in self.interfaces(): if not interface.properties_proactively_reported(): @@ -353,7 +355,7 @@ class AlexaEntity: try: capabilities.append(i.serialize_discovery()) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error serializing %s discovery for %s", i.name(), self.entity ) @@ -379,7 +381,7 @@ def async_get_entities( try: alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) interfaces = list(alexa_entity.interfaces()) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unable to serialize %s for discovery", state.entity_id) else: if not interfaces: @@ -405,7 +407,7 @@ class GenericCapabilities(AlexaEntity): return [DisplayCategory.OTHER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) yield AlexaEndpointHealth(self.hass, self.entity) @@ -428,7 +430,7 @@ class SwitchCapabilities(AlexaEntity): return [DisplayCategory.SWITCH] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) yield AlexaContactSensor(self.hass, self.entity) @@ -445,7 +447,7 @@ class ButtonCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.ACTIVITY_TRIGGER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaSceneController(self.entity, supports_deactivation=False) yield AlexaEventDetectionSensor(self.hass, self.entity) @@ -464,7 +466,7 @@ class ClimateCapabilities(AlexaEntity): return [DisplayCategory.WATER_HEATER] return [DisplayCategory.THERMOSTAT] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" # If we support two modes, one being off, we allow turning on too. supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -532,7 +534,7 @@ class CoverCapabilities(AlexaEntity): return [DisplayCategory.OTHER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) if device_class not in ( @@ -570,7 +572,7 @@ class EventCapabilities(AlexaEntity): return [DisplayCategory.DOORBELL] return None - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" if self.default_display_categories() is not None: yield AlexaDoorbellEventSource(self.entity) @@ -586,7 +588,7 @@ class LightCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.LIGHT] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) @@ -610,7 +612,7 @@ class FanCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.FAN] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) force_range_controller = True @@ -653,7 +655,7 @@ class HumidifierCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.OTHER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -677,7 +679,7 @@ class LockCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.SMARTLOCK] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaLockController(self.entity) yield AlexaEndpointHealth(self.hass, self.entity) @@ -696,7 +698,7 @@ class MediaPlayerCapabilities(AlexaEntity): return [DisplayCategory.TV] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) @@ -766,7 +768,7 @@ class SceneCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.SCENE_TRIGGER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaSceneController(self.entity, supports_deactivation=False) yield Alexa(self.entity) @@ -780,7 +782,7 @@ class ScriptCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.ACTIVITY_TRIGGER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaSceneController(self.entity, supports_deactivation=True) yield Alexa(self.entity) @@ -796,7 +798,7 @@ class SensorCapabilities(AlexaEntity): # sensors are currently ignored. return [DisplayCategory.TEMPERATURE_SENSOR] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" attrs = self.entity.attributes if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in { @@ -827,7 +829,7 @@ class BinarySensorCapabilities(AlexaEntity): return [DisplayCategory.CAMERA] return None - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" sensor_type = self.get_type() if sensor_type is self.TYPE_CONTACT: @@ -883,7 +885,7 @@ class AlarmControlPanelCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.SECURITY_PANEL] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" if not self.entity.attributes.get("code_arm_required"): yield AlexaSecurityPanelController(self.hass, self.entity) @@ -899,7 +901,7 @@ class ImageProcessingCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.CAMERA] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaEventDetectionSensor(self.hass, self.entity) yield AlexaEndpointHealth(self.hass, self.entity) @@ -915,7 +917,7 @@ class InputNumberCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.OTHER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" domain = self.entity.domain yield AlexaRangeController(self.entity, instance=f"{domain}.value") @@ -931,7 +933,7 @@ class TimerCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.OTHER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaTimeHoldController(self.entity, allow_remote_resume=True) yield AlexaPowerController(self.entity) @@ -946,7 +948,7 @@ class VacuumCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.VACUUM_CLEANER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( @@ -981,7 +983,7 @@ class ValveCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.OTHER] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & valve.ValveEntityFeature.SET_POSITION: @@ -1006,7 +1008,7 @@ class CameraCapabilities(AlexaEntity): """Return the display categories for this entity.""" return [DisplayCategory.CAMERA] - def interfaces(self) -> Generator[AlexaCapability, None, None]: + def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" if self._check_requirements(): supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index c28b1923399..47e09db1166 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -126,7 +126,7 @@ async def async_api_discovery( continue try: discovered_serialized_entity = alexa_entity.serialize_discovery() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Unable to serialize %s for discovery", alexa_entity.entity_id ) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 81ce2981acb..57c1ba791ba 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -219,7 +219,7 @@ async def async_handle_message( error_message=err.error_message, payload=err.payload, ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Uncaught exception processing Alexa %s/%s request (%s)", directive.namespace, diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index dc6c8ee3186..3eb761dacde 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -415,13 +415,14 @@ async def async_send_changereport_message( if invalidate_access_token: # Invalidate the access token and try again config.async_invalidate_access_token() - return await async_send_changereport_message( + await async_send_changereport_message( hass, config, alexa_entity, alexa_properties, invalidate_access_token=False, ) + return await config.set_authorized(False) _LOGGER.error( diff --git a/homeassistant/components/amazon_polly/const.py b/homeassistant/components/amazon_polly/const.py index 66084735c39..bb196544fc3 100644 --- a/homeassistant/components/amazon_polly/const.py +++ b/homeassistant/components/amazon_polly/const.py @@ -66,7 +66,7 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Hans", # German "Hiujin", # Chinese (Cantonese), Neural "Ida", # Norwegian, Neural - "Ines", # Portuguese, European + "Ines", # Portuguese, European # codespell:ignore ines "Ivy", # English "Jacek", # Polish "Jan", # Polish diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index 803bf8b80aa..73bbdd67162 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -1,7 +1,7 @@ { "domain": "amazon_polly", "name": "Amazon Polly", - "codeowners": [], + "codeowners": ["@jschlyter"], "documentation": "https://www.home-assistant.io/integrations/amazon_polly", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], diff --git a/homeassistant/components/ambiclimate/__init__.py b/homeassistant/components/ambiclimate/__init__.py deleted file mode 100644 index 75691aebbf8..00000000000 --- a/homeassistant/components/ambiclimate/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Support for Ambiclimate devices.""" - -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType - -from . import config_flow -from .const import DOMAIN - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -PLATFORMS = [Platform.CLIMATE] - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Ambiclimate components.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - config_flow.register_flow_implementation( - hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET] - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Ambiclimate from a config entry.""" - ir.async_create_issue( - hass, - DOMAIN, - DOMAIN, - breaks_in_ha_version="2024.4.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="integration_removed", - translation_placeholders={ - "entries": "/config/integrations/integration/ambiclimate", - }, - ) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py deleted file mode 100644 index e9554b08724..00000000000 --- a/homeassistant/components/ambiclimate/climate.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Support for Ambiclimate ac.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -import ambiclimate -from ambiclimate import AmbiclimateDevice -import voluptuous as vol - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - ATTR_TEMPERATURE, - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import ( - ATTR_VALUE, - DOMAIN, - SERVICE_COMFORT_FEEDBACK, - SERVICE_COMFORT_MODE, - SERVICE_TEMPERATURE_MODE, - STORAGE_KEY, - STORAGE_VERSION, -) - -_LOGGER = logging.getLogger(__name__) - -SEND_COMFORT_FEEDBACK_SCHEMA = vol.Schema( - {vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.string} -) - -SET_COMFORT_MODE_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) - -SET_TEMPERATURE_MODE_SCHEMA = vol.Schema( - {vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_VALUE): cv.string} -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Ambiclimate device.""" - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the Ambiclimate device from config entry.""" - config = entry.data - websession = async_get_clientsession(hass) - store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - token_info = await store.async_load() - - oauth = ambiclimate.AmbiclimateOAuth( - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - config["callback_url"], - websession, - ) - - try: - token_info = await oauth.refresh_access_token(token_info) - except ambiclimate.AmbiclimateOauthError: - token_info = None - - if not token_info: - _LOGGER.error("Failed to refresh access token") - return - - await store.async_save(token_info) - - data_connection = ambiclimate.AmbiclimateConnection( - oauth, token_info=token_info, websession=websession - ) - - if not await data_connection.find_devices(): - _LOGGER.error("No devices found") - return - - tasks = [ - asyncio.create_task(heater.update_device_info()) - for heater in data_connection.get_devices() - ] - await asyncio.wait(tasks) - - async_add_entities( - (AmbiclimateEntity(heater, store) for heater in data_connection.get_devices()), - True, - ) - - async def send_comfort_feedback(service: ServiceCall) -> None: - """Send comfort feedback.""" - device_name = service.data[ATTR_NAME] - device = data_connection.find_device_by_room_name(device_name) - if device: - await device.set_comfort_feedback(service.data[ATTR_VALUE]) - - hass.services.async_register( - DOMAIN, - SERVICE_COMFORT_FEEDBACK, - send_comfort_feedback, - schema=SEND_COMFORT_FEEDBACK_SCHEMA, - ) - - async def set_comfort_mode(service: ServiceCall) -> None: - """Set comfort mode.""" - device_name = service.data[ATTR_NAME] - device = data_connection.find_device_by_room_name(device_name) - if device: - await device.set_comfort_mode() - - hass.services.async_register( - DOMAIN, SERVICE_COMFORT_MODE, set_comfort_mode, schema=SET_COMFORT_MODE_SCHEMA - ) - - async def set_temperature_mode(service: ServiceCall) -> None: - """Set temperature mode.""" - device_name = service.data[ATTR_NAME] - device = data_connection.find_device_by_room_name(device_name) - if device: - await device.set_temperature_mode(service.data[ATTR_VALUE]) - - hass.services.async_register( - DOMAIN, - SERVICE_TEMPERATURE_MODE, - set_temperature_mode, - schema=SET_TEMPERATURE_MODE_SCHEMA, - ) - - -class AmbiclimateEntity(ClimateEntity): - """Representation of a Ambiclimate Thermostat device.""" - - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_target_temperature_step = 1 - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_has_entity_name = True - _attr_name = None - _enable_turn_on_off_backwards_compatibility = False - - def __init__(self, heater: AmbiclimateDevice, store: Store[dict[str, Any]]) -> None: - """Initialize the thermostat.""" - self._heater = heater - self._store = store - self._attr_unique_id = heater.device_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, # type: ignore[arg-type] - manufacturer="Ambiclimate", - name=heater.name, - ) - - async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return - await self._heater.set_target_temperature(temperature) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - if hvac_mode == HVACMode.HEAT: - await self._heater.turn_on() - return - if hvac_mode == HVACMode.OFF: - await self._heater.turn_off() - - async def async_update(self) -> None: - """Retrieve latest state.""" - try: - token_info = await self._heater.control.refresh_access_token() - except ambiclimate.AmbiclimateOauthError: - _LOGGER.error("Failed to refresh access token") - return - - if token_info: - await self._store.async_save(token_info) - - data = await self._heater.update_device() - self._attr_min_temp = self._heater.get_min_temp() - self._attr_max_temp = self._heater.get_max_temp() - self._attr_target_temperature = data.get("target_temperature") - self._attr_current_temperature = data.get("temperature") - self._attr_current_humidity = data.get("humidity") - self._attr_hvac_mode = ( - HVACMode.HEAT if data.get("power", "").lower() == "on" else HVACMode.OFF - ) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py deleted file mode 100644 index 9d5848ea899..00000000000 --- a/homeassistant/components/ambiclimate/config_flow.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Config flow for Ambiclimate.""" - -import logging -from typing import Any - -from aiohttp import web -import ambiclimate - -from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.network import get_url -from homeassistant.helpers.storage import Store - -from .const import ( - AUTH_CALLBACK_NAME, - AUTH_CALLBACK_PATH, - DOMAIN, - STORAGE_KEY, - STORAGE_VERSION, -) - -DATA_AMBICLIMATE_IMPL = "ambiclimate_flow_implementation" - -_LOGGER = logging.getLogger(__name__) - - -@callback -def register_flow_implementation( - hass: HomeAssistant, client_id: str, client_secret: str -) -> None: - """Register a ambiclimate implementation. - - client_id: Client id. - client_secret: Client secret. - """ - hass.data.setdefault(DATA_AMBICLIMATE_IMPL, {}) - - hass.data[DATA_AMBICLIMATE_IMPL] = { - CONF_CLIENT_ID: client_id, - CONF_CLIENT_SECRET: client_secret, - } - - -class AmbiclimateFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize flow.""" - self._registered_view = False - self._oauth = None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle external yaml configuration.""" - self._async_abort_entries_match() - - config = self.hass.data.get(DATA_AMBICLIMATE_IMPL, {}) - - if not config: - _LOGGER.debug("No config") - return self.async_abort(reason="missing_configuration") - - return await self.async_step_auth() - - async def async_step_auth( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow start.""" - self._async_abort_entries_match() - - errors = {} - - if user_input is not None: - errors["base"] = "follow_link" - - if not self._registered_view: - self._generate_view() - - return self.async_show_form( - step_id="auth", - description_placeholders={ - "authorization_url": await self._get_authorize_url(), - "cb_url": self._cb_url(), - }, - errors=errors, - ) - - async def async_step_code(self, code: str | None = None) -> ConfigFlowResult: - """Received code for authentication.""" - self._async_abort_entries_match() - - if await self._get_token_info(code) is None: - return self.async_abort(reason="access_token") - - config = self.hass.data[DATA_AMBICLIMATE_IMPL].copy() - config["callback_url"] = self._cb_url() - - return self.async_create_entry(title="Ambiclimate", data=config) - - 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) - except ambiclimate.AmbiclimateOauthError: - _LOGGER.exception("Failed to get access token") - return None - - store = Store[dict[str, Any]](self.hass, STORAGE_VERSION, STORAGE_KEY) - await store.async_save(token_info) - - return token_info # type: ignore[no-any-return] - - def _generate_view(self) -> None: - self.hass.http.register_view(AmbiclimateAuthCallbackView()) - self._registered_view = True - - def _generate_oauth(self) -> ambiclimate.AmbiclimateOAuth: - config = self.hass.data[DATA_AMBICLIMATE_IMPL] - clientsession = async_get_clientsession(self.hass) - callback_url = self._cb_url() - - return ambiclimate.AmbiclimateOAuth( - config.get(CONF_CLIENT_ID), - config.get(CONF_CLIENT_SECRET), - callback_url, - clientsession, - ) - - def _cb_url(self) -> str: - return f"{get_url(self.hass, prefer_external=True)}{AUTH_CALLBACK_PATH}" - - async def _get_authorize_url(self) -> str: - oauth = self._generate_oauth() - return oauth.get_authorize_url() # type: ignore[no-any-return] - - -class AmbiclimateAuthCallbackView(HomeAssistantView): - """Ambiclimate Authorization Callback View.""" - - requires_auth = False - url = AUTH_CALLBACK_PATH - name = AUTH_CALLBACK_NAME - - async def get(self, request: web.Request) -> str: - """Receive authorization token.""" - if (code := request.query.get("code")) is None: - return "No code" - hass = request.app[KEY_HASS] - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": "code"}, data=code - ) - ) - return "OK!" diff --git a/homeassistant/components/ambiclimate/const.py b/homeassistant/components/ambiclimate/const.py deleted file mode 100644 index 6393e97569a..00000000000 --- a/homeassistant/components/ambiclimate/const.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Constants used by the Ambiclimate component.""" - -DOMAIN = "ambiclimate" - -ATTR_VALUE = "value" - -SERVICE_COMFORT_FEEDBACK = "send_comfort_feedback" -SERVICE_COMFORT_MODE = "set_comfort_mode" -SERVICE_TEMPERATURE_MODE = "set_temperature_mode" - -STORAGE_KEY = "ambiclimate_auth" -STORAGE_VERSION = 1 - -AUTH_CALLBACK_NAME = "api:ambiclimate" -AUTH_CALLBACK_PATH = "/api/ambiclimate" diff --git a/homeassistant/components/ambiclimate/icons.json b/homeassistant/components/ambiclimate/icons.json deleted file mode 100644 index cce21c18c20..00000000000 --- a/homeassistant/components/ambiclimate/icons.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "services": { - "set_comfort_mode": "mdi:auto-mode", - "send_comfort_feedback": "mdi:thermometer-checked", - "set_temperature_mode": "mdi:thermometer" - } -} diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json deleted file mode 100644 index 315490b2d62..00000000000 --- a/homeassistant/components/ambiclimate/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "ambiclimate", - "name": "Ambiclimate", - "codeowners": ["@danielhiversen"], - "config_flow": true, - "dependencies": ["http"], - "documentation": "https://www.home-assistant.io/integrations/ambiclimate", - "iot_class": "cloud_polling", - "loggers": ["ambiclimate"], - "requirements": ["Ambiclimate==0.2.1"] -} diff --git a/homeassistant/components/ambiclimate/services.yaml b/homeassistant/components/ambiclimate/services.yaml deleted file mode 100644 index bf72d18b259..00000000000 --- a/homeassistant/components/ambiclimate/services.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Describes the format for available services for ambiclimate - -set_comfort_mode: - fields: - name: - required: true - example: Bedroom - selector: - text: - -send_comfort_feedback: - fields: - name: - required: true - example: Bedroom - selector: - text: - value: - required: true - example: bit_warm - selector: - text: - -set_temperature_mode: - fields: - name: - required: true - example: Bedroom - selector: - text: - value: - required: true - example: 22 - selector: - text: diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json deleted file mode 100644 index 15a1a4e1f35..00000000000 --- a/homeassistant/components/ambiclimate/strings.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "config": { - "step": { - "auth": { - "title": "Authenticate Ambiclimate", - "description": "Please follow this [link]({authorization_url}) and **Allow** access to your Ambiclimate account, then come back and press **Submit** below.\n(Make sure the specified callback URL is {cb_url})" - } - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - }, - "error": { - "no_token": "Not authenticated with Ambiclimate", - "follow_link": "Please follow the link and authenticate before pressing Submit" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "access_token": "Unknown error generating an access token." - } - }, - "issues": { - "integration_removed": { - "title": "The Ambiclimate integration has been deprecated and will be removed", - "description": "All Ambiclimate services will be terminated, effective March 31, 2024, as Ambi Labs winds down business operations, and the Ambiclimate integration will be removed from Home Assistant.\n\nTo resolve this issue, please remove the integration entries from your Home Assistant setup. [Click here to see your existing Logi Circle integration entries]({entries})." - } - }, - "services": { - "set_comfort_mode": { - "name": "Set comfort mode", - "description": "Enables comfort mode on your AC.", - "fields": { - "name": { - "name": "Device name", - "description": "String with device name." - } - } - }, - "send_comfort_feedback": { - "name": "Send comfort feedback", - "description": "Sends feedback for comfort mode.", - "fields": { - "name": { - "name": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::name%]", - "description": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::description%]" - }, - "value": { - "name": "Comfort value", - "description": "Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing." - } - } - }, - "set_temperature_mode": { - "name": "Set temperature mode", - "description": "Enables temperature mode on your AC.", - "fields": { - "name": { - "name": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::name%]", - "description": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::description%]" - }, - "value": { - "name": "Temperature", - "description": "Target value in celsius." - } - } - } - } -} diff --git a/homeassistant/components/ambient_network/coordinator.py b/homeassistant/components/ambient_network/coordinator.py index f26ddd47b24..2f51c3bc0cb 100644 --- a/homeassistant/components/ambient_network/coordinator.py +++ b/homeassistant/components/ambient_network/coordinator.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import API_LAST_DATA, DOMAIN, LOGGER from .helper import get_station_name @@ -24,6 +25,7 @@ class AmbientNetworkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]) config_entry: ConfigEntry station_name: str + last_measured: datetime | None = None def __init__(self, hass: HomeAssistant, api: OpenAPI) -> None: """Initialize the coordinator.""" @@ -47,19 +49,13 @@ class AmbientNetworkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]) f"Station '{self.config_entry.title}' did not report any data" ) - # Eliminate data if the station hasn't been updated for a while. - if (created_at := last_data.get("created_at")) is None: - raise UpdateFailed( - f"Station '{self.config_entry.title}' did not report a time stamp" - ) - - # Eliminate data that has been generated more than an hour ago. The station is - # probably offline. - if int(created_at / 1000) < int( - (datetime.now() - timedelta(hours=1)).timestamp() - ): - raise UpdateFailed( - f"Station '{self.config_entry.title}' reported stale data" + # Some stations do not report a "created_at" or "dateutc". + # See https://github.com/home-assistant/core/issues/116917 + if (ts := last_data.get("created_at")) is not None or ( + ts := last_data.get("dateutc") + ) is not None: + self.last_measured = datetime.fromtimestamp( + ts / 1000, tz=dt_util.DEFAULT_TIME_ZONE ) return cast(dict[str, Any], last_data) diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index c28b69229d8..132fc7dbd0d 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -299,17 +299,22 @@ class AmbientNetworkSensor(AmbientNetworkEntity, SensorEntity): mac_address: str, ) -> None: """Initialize a sensor object.""" - super().__init__(coordinator, description, mac_address) def _update_attrs(self) -> None: """Update sensor attributes.""" - value = self.coordinator.data.get(self.entity_description.key) # Treatments for special units. if value is not None and self.device_class == SensorDeviceClass.TIMESTAMP: - value = datetime.fromtimestamp(value / 1000, tz=dt_util.DEFAULT_TIME_ZONE) + value = datetime.fromtimestamp( + value / 1000, tz=dt_util.get_default_time_zone() + ) self._attr_available = value is not None self._attr_native_value = value + + if self.coordinator.last_measured is not None: + self._attr_extra_state_attributes = { + "last_measured": self.coordinator.last_measured + } diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 39586f4dbf4..d0b04e53e67 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -39,7 +39,7 @@ DEFAULT_SOCKET_MIN_RETRY = 15 CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -AmbientStationConfigEntry = ConfigEntry["AmbientStation"] +type AmbientStationConfigEntry = ConfigEntry[AmbientStation] @callback diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index c12aa6d7916..624e0145b86 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -35,7 +35,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -177,7 +177,8 @@ class AmcrestChecker(ApiWrapper): """Return event flag that indicates if camera's API is responding.""" return self._async_wrap_event_flag - def _start_recovery(self) -> None: + @callback + def _async_start_recovery(self) -> None: self.available_flag.clear() self.async_available_flag.clear() async_dispatcher_send( @@ -222,50 +223,98 @@ class AmcrestChecker(ApiWrapper): yield except LoginError as ex: async with self._async_wrap_lock: - self._handle_offline(ex) + self._async_handle_offline(ex) raise except AmcrestError: async with self._async_wrap_lock: - self._handle_error() + self._async_handle_error() raise async with self._async_wrap_lock: - self._set_online() + self._async_set_online() - def _handle_offline(self, ex: Exception) -> None: + def _handle_offline_thread_safe(self, ex: Exception) -> bool: + """Handle camera offline status shared between threads and event loop. + + Returns if the camera was online as a bool. + """ with self._wrap_lock: was_online = self.available was_login_err = self._wrap_login_err self._wrap_login_err = True if not was_login_err: _LOGGER.error("%s camera offline: Login error: %s", self._wrap_name, ex) - if was_online: - self._start_recovery() + return was_online - def _handle_error(self) -> None: + def _handle_offline(self, ex: Exception) -> None: + """Handle camera offline status from a thread.""" + if self._handle_offline_thread_safe(ex): + self._hass.loop.call_soon_threadsafe(self._async_start_recovery) + + @callback + def _async_handle_offline(self, ex: Exception) -> None: + if self._handle_offline_thread_safe(ex): + self._async_start_recovery() + + def _handle_error_thread_safe(self) -> bool: + """Handle camera error status shared between threads and event loop. + + Returns if the camera was online and is now offline as + a bool. + """ with self._wrap_lock: was_online = self.available errs = self._wrap_errors = self._wrap_errors + 1 offline = not self.available _LOGGER.debug("%s camera errs: %i", self._wrap_name, errs) - if was_online and offline: - _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) - self._start_recovery() + return was_online and offline - def _set_online(self) -> None: + def _handle_error(self) -> None: + """Handle camera error status from a thread.""" + if self._handle_error_thread_safe(): + _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) + self._hass.loop.call_soon_threadsafe(self._async_start_recovery) + + @callback + def _async_handle_error(self) -> None: + """Handle camera error status from the event loop.""" + if self._handle_error_thread_safe(): + _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) + self._async_start_recovery() + + def _set_online_thread_safe(self) -> bool: + """Set camera online status shared between threads and event loop. + + Returns if the camera was offline as a bool. + """ with self._wrap_lock: was_offline = not self.available self._wrap_errors = 0 self._wrap_login_err = False - if was_offline: - assert self._unsub_recheck is not None - self._unsub_recheck() - self._unsub_recheck = None - _LOGGER.error("%s camera back online", self._wrap_name) - self.available_flag.set() - self.async_available_flag.set() - async_dispatcher_send( - self._hass, service_signal(SERVICE_UPDATE, self._wrap_name) - ) + return was_offline + + def _set_online(self) -> None: + """Set camera online status from a thread.""" + if self._set_online_thread_safe(): + self._hass.loop.call_soon_threadsafe(self._async_signal_online) + + @callback + def _async_set_online(self) -> None: + """Set camera online status from the event loop.""" + if self._set_online_thread_safe(): + self._async_signal_online() + + @callback + def _async_signal_online(self) -> None: + """Signal that camera is back online.""" + assert self._unsub_recheck is not None + self._unsub_recheck() + self._unsub_recheck = None + _LOGGER.error("%s camera back online", self._wrap_name) + self.available_flag.set() + self.async_available_flag.set() + async_dispatcher_send( + self._hass, service_signal(SERVICE_UPDATE, self._wrap_name) + ) async def _wrap_test_online(self, now: datetime) -> None: """Test if camera is back online.""" diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 1cbf5af4b70..a55f9c81e64 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -16,7 +16,7 @@ import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, @@ -325,7 +325,8 @@ class AmcrestCam(Camera): # Other Entity method overrides - async def async_on_demand_update(self) -> None: + @callback + def async_on_demand_update(self) -> None: """Update state.""" self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 3069e8dd12d..69ad98db9df 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -19,7 +19,7 @@ from .const import CONF_TRACKED_INTEGRATIONS from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -AnalyticsInsightsConfigEntry = ConfigEntry["AnalyticsInsightsData"] +type AnalyticsInsightsConfigEntry = ConfigEntry[AnalyticsInsightsData] @dataclass(frozen=True) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index cef5ac2e9e5..909290b1035 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -82,6 +82,9 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + return self.async_abort(reason="unknown") options = [ SelectOptionDict( diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 00c9cfa4404..3b770f189a4 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -13,7 +13,8 @@ } }, "abort": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "no_integration_selected": "You must select at least one integration to track" diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 884a06bca68..34b324db169 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass import os from typing import Any @@ -36,8 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from .const import ( - ANDROID_DEV, - ANDROID_DEV_OPT, CONF_ADB_SERVER_IP, CONF_ADB_SERVER_PORT, CONF_ADBKEY, @@ -45,7 +44,6 @@ from .const import ( DEFAULT_ADB_SERVER_PORT, DEVICE_ANDROIDTV, DEVICE_FIRETV, - DOMAIN, PROP_ETHMAC, PROP_WIFIMAC, SIGNAL_CONFIG_ENTITY, @@ -63,12 +61,23 @@ ADB_PYTHON_EXCEPTIONS: tuple = ( ) ADB_TCP_EXCEPTIONS: tuple = (ConnectionResetError, RuntimeError) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES] _INVALID_MACS = {"ff:ff:ff:ff:ff:ff"} +@dataclass +class AndroidTVRuntimeData: + """Runtime data definition.""" + + aftv: AndroidTVAsync | FireTVAsync + dev_opt: dict[str, Any] + + +AndroidTVConfigEntry = ConfigEntry[AndroidTVRuntimeData] + + def get_androidtv_mac(dev_props: dict[str, Any]) -> str | None: """Return formatted mac from device properties.""" for prop_mac in (PROP_ETHMAC, PROP_WIFIMAC): @@ -148,7 +157,7 @@ async def async_connect_androidtv( return aftv, None -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool: """Set up Android Debug Bridge platform.""" state_det_rules = entry.options.get(CONF_STATE_DETECTION_RULES) @@ -176,30 +185,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - ANDROID_DEV: aftv, - ANDROID_DEV_OPT: entry.options.copy(), - } + entry.runtime_data = AndroidTVRuntimeData(aftv, entry.options.copy()) 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, entry: AndroidTVConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] + aftv = entry.runtime_data.aftv await aftv.adb_close() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> None: """Update when config_entry options update.""" reload_opt = False - old_options = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV_OPT] + old_options = entry.runtime_data.dev_opt for opt_key, opt_val in entry.options.items(): if opt_key in RELOAD_OPTIONS: old_val = old_options.get(opt_key) @@ -211,5 +216,5 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) return - hass.data[DOMAIN][entry.entry_id][ANDROID_DEV_OPT] = entry.options.copy() + entry.runtime_data.dev_opt = entry.options.copy() async_dispatcher_send(hass, f"{SIGNAL_CONFIG_ENTITY}_{entry.entry_id}") diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index 20396b20bb9..1ed4b0f6782 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -119,7 +119,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): try: aftv, error_message = await async_connect_androidtv(self.hass, user_input) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Unknown error connecting with Android device at %s", user_input[CONF_HOST], diff --git a/homeassistant/components/androidtv/const.py b/homeassistant/components/androidtv/const.py index fb43e0af090..ee279c0fb3a 100644 --- a/homeassistant/components/androidtv/const.py +++ b/homeassistant/components/androidtv/const.py @@ -2,9 +2,6 @@ DOMAIN = "androidtv" -ANDROID_DEV = DOMAIN -ANDROID_DEV_OPT = "androidtv_opt" - CONF_ADB_SERVER_IP = "adb_server_ip" CONF_ADB_SERVER_PORT = "adb_server_port" CONF_ADBKEY = "adbkey" diff --git a/homeassistant/components/androidtv/diagnostics.py b/homeassistant/components/androidtv/diagnostics.py index 5dba4109f32..3e4244d6d9f 100644 --- a/homeassistant/components/androidtv/diagnostics.py +++ b/homeassistant/components/androidtv/diagnostics.py @@ -7,12 +7,12 @@ from typing import Any import attr from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import ANDROID_DEV, DOMAIN, PROP_ETHMAC, PROP_SERIALNO, PROP_WIFIMAC +from . import AndroidTVConfigEntry +from .const import DOMAIN, PROP_ETHMAC, PROP_SERIALNO, PROP_WIFIMAC TO_REDACT = {CONF_UNIQUE_ID} # UniqueID contain MAC Address TO_REDACT_DEV = {ATTR_CONNECTIONS, ATTR_IDENTIFIERS} @@ -20,14 +20,13 @@ TO_REDACT_DEV_PROP = {PROP_ETHMAC, PROP_SERIALNO, PROP_WIFIMAC} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: AndroidTVConfigEntry ) -> dict[str, dict[str, Any]]: """Return diagnostics for a config entry.""" data = {"entry": async_redact_data(entry.as_dict(), TO_REDACT)} - hass_data = hass.data[DOMAIN][entry.entry_id] # Get information from AndroidTV library - aftv = hass_data[ANDROID_DEV] + aftv = entry.runtime_data.aftv data["device_properties"] = { **async_redact_data(aftv.device_properties, TO_REDACT_DEV_PROP), "device_class": aftv.DEVICE_CLASS, diff --git a/homeassistant/components/androidtv/entity.py b/homeassistant/components/androidtv/entity.py index 2185f6d151a..470a4950ebc 100644 --- a/homeassistant/components/androidtv/entity.py +++ b/homeassistant/components/androidtv/entity.py @@ -5,12 +5,10 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine import functools import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from androidtv.exceptions import LockNotAcquiredException -from androidtv.setup_async import AndroidTVAsync, FireTVAsync -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, @@ -20,10 +18,16 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, ) +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity -from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac +from . import ( + ADB_PYTHON_EXCEPTIONS, + ADB_TCP_EXCEPTIONS, + AndroidTVConfigEntry, + get_androidtv_mac, +) from .const import DEVICE_ANDROIDTV, DOMAIN PREFIX_ANDROIDTV = "Android TV" @@ -31,15 +35,13 @@ PREFIX_FIRETV = "Fire TV" _LOGGER = logging.getLogger(__name__) -_ADBDeviceT = TypeVar("_ADBDeviceT", bound="AndroidTVEntity") -_R = TypeVar("_R") -_P = ParamSpec("_P") - -_FuncType = Callable[Concatenate[_ADBDeviceT, _P], Awaitable[_R]] -_ReturnFuncType = Callable[Concatenate[_ADBDeviceT, _P], Coroutine[Any, Any, _R | None]] +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]] +type _ReturnFuncType[_T, **_P, _R] = Callable[ + Concatenate[_T, _P], Coroutine[Any, Any, _R | None] +] -def adb_decorator( +def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R]( override_available: bool = False, ) -> Callable[[_FuncType[_ADBDeviceT, _P, _R]], _ReturnFuncType[_ADBDeviceT, _P, _R]]: """Wrap ADB methods and catch exceptions. @@ -74,24 +76,36 @@ def adb_decorator( ) return None except self.exceptions as err: - _LOGGER.error( - ( - "Failed to execute an ADB command. ADB connection re-" - "establishing attempt in the next update. Error: %s" - ), - err, - ) + if self.available: + _LOGGER.error( + ( + "Failed to execute an ADB command. ADB connection re-" + "establishing attempt in the next update. Error: %s" + ), + err, + ) + await self.aftv.adb_close() - # pylint: disable-next=protected-access self._attr_available = False return None - except Exception: - # An unforeseen exception occurred. Close the ADB connection so that - # it doesn't happen over and over again, then raise the exception. - await self.aftv.adb_close() - # pylint: disable-next=protected-access - self._attr_available = False + except ServiceValidationError: + # Service validation error is thrown because raised by remote services raise + except Exception as err: # noqa: BLE001 + # An unforeseen exception occurred. Close the ADB connection so that + # it doesn't happen over and over again. + if self.available: + _LOGGER.error( + ( + "Unexpected exception executing an ADB command. ADB connection" + " re-establishing attempt in the next update. Error: %s" + ), + err, + ) + + await self.aftv.adb_close() + self._attr_available = False + return None return _adb_exception_catcher @@ -103,18 +117,13 @@ class AndroidTVEntity(Entity): _attr_has_entity_name = True - def __init__( - self, - aftv: AndroidTVAsync | FireTVAsync, - entry: ConfigEntry, - entry_data: dict[str, Any], - ) -> None: + def __init__(self, entry: AndroidTVConfigEntry) -> None: """Initialize the AndroidTV base entity.""" - self.aftv = aftv + self.aftv = entry.runtime_data.aftv self._attr_unique_id = entry.unique_id - self._entry_data = entry_data + self._entry_runtime_data = entry.runtime_data - device_class = aftv.DEVICE_CLASS + device_class = self.aftv.DEVICE_CLASS device_type = ( PREFIX_ANDROIDTV if device_class == DEVICE_ANDROIDTV else PREFIX_FIRETV ) @@ -122,7 +131,7 @@ class AndroidTVEntity(Entity): device_name = entry.data.get( CONF_NAME, f"{device_type} {entry.data[CONF_HOST]}" ) - info = aftv.device_properties + info = self.aftv.device_properties model = info.get(ATTR_MODEL) self._attr_device_info = DeviceInfo( model=f"{model} ({device_type})" if model else device_type, @@ -138,7 +147,7 @@ class AndroidTVEntity(Entity): self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)} # ADB exceptions to catch - if not aftv.adb_server_ip: + if not self.aftv.adb_server_ip: # Using "adb_shell" (Python ADB implementation) self.exceptions = ADB_PYTHON_EXCEPTIONS else: diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 016a7a5a7a2..884b5f60f57 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -18,7 +18,6 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform @@ -26,9 +25,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle +from . import AndroidTVConfigEntry from .const import ( - ANDROID_DEV, - ANDROID_DEV_OPT, CONF_APPS, CONF_EXCLUDE_UNNAMED_APPS, CONF_GET_SOURCES, @@ -39,7 +37,6 @@ from .const import ( DEFAULT_GET_SOURCES, DEFAULT_SCREENCAP, DEVICE_ANDROIDTV, - DOMAIN, SIGNAL_CONFIG_ENTITY, ) from .entity import AndroidTVEntity, adb_decorator @@ -70,20 +67,16 @@ ANDROIDTV_STATES = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AndroidTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Android Debug Bridge entity.""" - entry_data = hass.data[DOMAIN][entry.entry_id] - aftv: AndroidTVAsync | FireTVAsync = entry_data[ANDROID_DEV] - - device_class = aftv.DEVICE_CLASS - device_args = [aftv, entry, entry_data] + device_class = entry.runtime_data.aftv.DEVICE_CLASS async_add_entities( [ - AndroidTVDevice(*device_args) + AndroidTVDevice(entry) if device_class == DEVICE_ANDROIDTV - else FireTVDevice(*device_args) + else FireTVDevice(entry) ] ) @@ -120,14 +113,9 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity): _attr_device_class = MediaPlayerDeviceClass.TV _attr_name = None - def __init__( - self, - aftv: AndroidTVAsync | FireTVAsync, - entry: ConfigEntry, - entry_data: dict[str, Any], - ) -> None: + def __init__(self, entry: AndroidTVConfigEntry) -> None: """Initialize the Android / Fire TV device.""" - super().__init__(aftv, entry, entry_data) + super().__init__(entry) self._entry_id = entry.entry_id self._media_image: tuple[bytes | None, str | None] = None, None @@ -153,7 +141,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity): def _process_config(self) -> None: """Load the config options.""" _LOGGER.debug("Loading configuration options") - options = self._entry_data[ANDROID_DEV_OPT] + options = self._entry_runtime_data.dev_opt apps = options.get(CONF_APPS, {}) self._app_id_to_name = APPS.copy() diff --git a/homeassistant/components/androidtv/remote.py b/homeassistant/components/androidtv/remote.py new file mode 100644 index 00000000000..db48b0cf1b6 --- /dev/null +++ b/homeassistant/components/androidtv/remote.py @@ -0,0 +1,75 @@ +"""Support for the AndroidTV remote.""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import Any + +from androidtv.constants import KEYS + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_TURN_OFF_COMMAND, CONF_TURN_ON_COMMAND, DOMAIN +from .entity import AndroidTVEntity, adb_decorator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the AndroidTV remote from a config entry.""" + async_add_entities([AndroidTVRemote(entry)]) + + +class AndroidTVRemote(AndroidTVEntity, RemoteEntity): + """Device that sends commands to a AndroidTV.""" + + _attr_name = None + _attr_should_poll = False + + @adb_decorator() + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + options = self._entry_runtime_data.dev_opt + if turn_on_cmd := options.get(CONF_TURN_ON_COMMAND): + await self.aftv.adb_shell(turn_on_cmd) + else: + await self.aftv.turn_on() + + @adb_decorator() + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + options = self._entry_runtime_data.dev_opt + if turn_off_cmd := options.get(CONF_TURN_OFF_COMMAND): + await self.aftv.adb_shell(turn_off_cmd) + else: + await self.aftv.turn_off() + + @adb_decorator() + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to a device.""" + + num_repeats = kwargs[ATTR_NUM_REPEATS] + command_list = [] + for cmd in command: + if key := KEYS.get(cmd): + command_list.append(f"input keyevent {key}") + else: + command_list.append(cmd) + + for _ in range(num_repeats): + for cmd in command_list: + try: + await self.aftv.adb_shell(cmd) + except UnicodeDecodeError as ex: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="failed_send", + translation_placeholders={"cmd": cmd}, + ) from ex diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index 7949c066916..d6fdf78d1fb 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -103,5 +103,10 @@ "name": "Learn sendevent", "description": "Translates a key press on a remote into ADB 'sendevent' commands. You must press one button on the remote within 8 seconds of calling this service." } + }, + "exceptions": { + "failed_send": { + "message": "Failed to send command {cmd}" + } } } diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index dcd08cf6fc3..6a55e9971ac 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -30,6 +30,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry ) -> bool: """Set up Android TV Remote from a config entry.""" + _LOGGER.debug("async_setup_entry: %s", entry.data) api = create_api(hass, entry.data[CONF_HOST], get_enable_ime(entry)) @callback @@ -79,7 +80,7 @@ async def async_setup_entry( entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - entry.async_on_unload(entry.add_update_listener(update_listener)) + entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(api.disconnect) return True @@ -87,9 +88,13 @@ async def async_setup_entry( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + _LOGGER.debug("async_unload_entry: %s", entry.data) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" + _LOGGER.debug( + "async_update_options: data: %s options: %s", entry.data, entry.options + ) await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 2fd9f607218..813c0eda14b 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from androidtvremote2 import ( @@ -23,10 +24,22 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CONF_ENABLE_IME, DOMAIN +from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN from .helpers import create_api, get_enable_ime +_LOGGER = logging.getLogger(__name__) + +APPS_NEW_ID = "NewApp" +CONF_APP_DELETE = "app_delete" +CONF_APP_ID = "app_id" + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required("host"): str, @@ -139,6 +152,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" + _LOGGER.debug("Android TV device found via zeroconf: %s", discovery_info) self.host = discovery_info.host self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") self.mac = discovery_info.properties.get("bt") @@ -148,6 +162,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured( updates={CONF_HOST: self.host, CONF_NAME: self.name} ) + _LOGGER.debug("New Android TV device found via zeroconf: %s", self.name) self.context.update({"title_placeholders": {CONF_NAME: self.name}}) return await self.async_step_zeroconf_confirm() @@ -208,17 +223,46 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry): """Android TV Remote options flow.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + super().__init__(config_entry) + self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) + self._conf_app_id: str | None = None + + @callback + def _save_config(self, data: dict[str, Any]) -> ConfigFlowResult: + """Save the updated options.""" + new_data = {k: v for k, v in data.items() if k not in [CONF_APPS]} + if self._apps: + new_data[CONF_APPS] = self._apps + + return self.async_create_entry(title="", data=new_data) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + if sel_app := user_input.get(CONF_APPS): + return await self.async_step_apps(None, sel_app) + return self._save_config(user_input) + apps_list = { + k: f"{v[CONF_APP_NAME]} ({k})" if CONF_APP_NAME in v else k + for k, v in self._apps.items() + } + apps = [SelectOptionDict(value=APPS_NEW_ID, label="Add new")] + [ + SelectOptionDict(value=k, label=v) for k, v in apps_list.items() + ] return self.async_show_form( step_id="init", data_schema=vol.Schema( { + vol.Optional(CONF_APPS): SelectSelector( + SelectSelectorConfig( + options=apps, mode=SelectSelectorMode.DROPDOWN + ) + ), vol.Required( CONF_ENABLE_IME, default=get_enable_ime(self.config_entry), @@ -226,3 +270,61 @@ class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry): } ), ) + + async def async_step_apps( + self, user_input: dict[str, Any] | None = None, app_id: str | None = None + ) -> ConfigFlowResult: + """Handle options flow for apps list.""" + if app_id is not None: + self._conf_app_id = app_id if app_id != APPS_NEW_ID else None + return self._async_apps_form(app_id) + + if user_input is not None: + app_id = user_input.get(CONF_APP_ID, self._conf_app_id) + if app_id: + if user_input.get(CONF_APP_DELETE, False): + self._apps.pop(app_id) + else: + self._apps[app_id] = { + CONF_APP_NAME: user_input.get(CONF_APP_NAME, ""), + CONF_APP_ICON: user_input.get(CONF_APP_ICON, ""), + } + + return await self.async_step_init() + + @callback + def _async_apps_form(self, app_id: str) -> ConfigFlowResult: + """Return configuration form for apps.""" + + app_schema = { + vol.Optional( + CONF_APP_NAME, + description={ + "suggested_value": self._apps[app_id].get(CONF_APP_NAME, "") + if app_id in self._apps + else "" + }, + ): str, + vol.Optional( + CONF_APP_ICON, + description={ + "suggested_value": self._apps[app_id].get(CONF_APP_ICON, "") + if app_id in self._apps + else "" + }, + ): str, + } + if app_id == APPS_NEW_ID: + data_schema = vol.Schema({**app_schema, vol.Optional(CONF_APP_ID): str}) + else: + data_schema = vol.Schema( + {**app_schema, vol.Optional(CONF_APP_DELETE, default=False): bool} + ) + + return self.async_show_form( + step_id="apps", + data_schema=data_schema, + description_placeholders={ + "app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "", + }, + ) diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py index 9d2a7fcb240..540c8186e20 100644 --- a/homeassistant/components/androidtv_remote/const.py +++ b/homeassistant/components/androidtv_remote/const.py @@ -6,5 +6,8 @@ from typing import Final DOMAIN: Final = "androidtv_remote" +CONF_APPS = "apps" CONF_ENABLE_IME: Final = "enable_ime" CONF_ENABLE_IME_DEFAULT_VALUE: Final = True +CONF_APP_NAME = "app_name" +CONF_APP_ICON = "app_icon" diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index fa070e1ec18..44b2d2a5f20 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from androidtvremote2 import AndroidTVRemote, ConnectionClosed from homeassistant.config_entries import ConfigEntry @@ -11,7 +13,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from .const import CONF_APPS, DOMAIN class AndroidTVRemoteBaseEntity(Entity): @@ -26,6 +28,7 @@ class AndroidTVRemoteBaseEntity(Entity): self._api = api self._host = config_entry.data[CONF_HOST] self._name = config_entry.data[CONF_NAME] + self._apps: dict[str, Any] = config_entry.options.get(CONF_APPS, {}) self._attr_unique_id = config_entry.unique_id self._attr_is_on = api.is_on device_info = api.device_info diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 915586b3879..e24fcc5d653 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.15"], + "requirements": ["androidtvremote2==0.1.1"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 571eab4a15b..554aa2f2946 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -8,17 +8,20 @@ from typing import Any from androidtvremote2 import AndroidTVRemote, ConnectionClosed from homeassistant.components.media_player import ( + MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) +from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AndroidTVRemoteConfigEntry +from .const import CONF_APP_ICON, CONF_APP_NAME from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 @@ -50,6 +53,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA ) def __init__( @@ -65,7 +69,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt def _update_current_app(self, current_app: str) -> None: """Update current app info.""" self._attr_app_id = current_app - self._attr_app_name = current_app + self._attr_app_name = ( + self._apps[current_app].get(CONF_APP_NAME, current_app) + if current_app in self._apps + else current_app + ) def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None: """Update volume info.""" @@ -176,12 +184,41 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt await self._channel_set_task return - if media_type == MediaType.URL: + if media_type in [MediaType.URL, MediaType.APP]: self._send_launch_app_command(media_id) return raise ValueError(f"Invalid media type: {media_type}") + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse apps.""" + children = [ + BrowseMedia( + media_class=MediaClass.APP, + media_content_type=MediaType.APP, + media_content_id=app_id, + title=app.get(CONF_APP_NAME, ""), + thumbnail=app.get(CONF_APP_ICON, ""), + can_play=False, + can_expand=False, + ) + for app_id, app in self._apps.items() + ] + return BrowseMedia( + title="Applications", + media_class=MediaClass.DIRECTORY, + media_content_id="apps", + media_content_type=MediaType.APPS, + children_media_class=MediaClass.APP, + can_play=False, + can_expand=True, + children=children, + ) + async def _send_key_commands( self, key_codes: list[str], delay_secs: float = 0.1 ) -> None: diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 72387a54bf0..c9a261c8735 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AndroidTVRemoteConfigEntry +from .const import CONF_APP_NAME from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 @@ -41,17 +42,28 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): _attr_supported_features = RemoteEntityFeature.ACTIVITY + def _update_current_app(self, current_app: str) -> None: + """Update current app info.""" + self._attr_current_activity = ( + self._apps[current_app].get(CONF_APP_NAME, current_app) + if current_app in self._apps + else current_app + ) + @callback def _current_app_updated(self, current_app: str) -> None: """Update the state when the current app changes.""" - self._attr_current_activity = current_app + self._update_current_app(current_app) self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - self._attr_current_activity = self._api.current_app + self._attr_activity_list = [ + app.get(CONF_APP_NAME, "") for app in self._apps.values() + ] + self._update_current_app(self._api.current_app) self._api.add_current_app_updated_callback(self._current_app_updated) async def async_will_remove_from_hass(self) -> None: @@ -66,6 +78,14 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): self._send_key_command("POWER") activity = kwargs.get(ATTR_ACTIVITY, "") if activity: + activity = next( + ( + app_id + for app_id, app in self._apps.items() + if app.get(CONF_APP_NAME, "") == activity + ), + activity, + ) self._send_launch_app_command(activity) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index dbbf6a2d383..33970171d40 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -20,7 +20,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "You need to pair again with the Android TV ({name})." + "description": "You need to pair again with the Android TV ({name}). It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." } }, "error": { @@ -39,8 +39,19 @@ "step": { "init": { "data": { + "apps": "Configure applications list", "enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard." } + }, + "apps": { + "title": "Configure Android Apps", + "description": "Configure application id {app_id}", + "data": { + "app_name": "Application Name", + "app_id": "Application ID", + "app_icon": "Application Icon", + "app_delete": "Check to delete this application" + } } } } diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 9b0f649dad9..7503de8ea10 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -3,18 +3,25 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING -from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +from anova_wifi import ( + AnovaApi, + APCWifiDevice, + InvalidLogin, + NoDevicesFound, + WebsocketFailure, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from .const import DOMAIN from .coordinator import AnovaCoordinator from .models import AnovaData -from .util import serialize_device_list PLATFORMS = [Platform.SENSOR] @@ -36,36 +43,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False assert api.jwt - api.existing_devices = [ - AnovaPrecisionCooker( - aiohttp_client.async_get_clientsession(hass), - device[0], - device[1], - api.jwt, - ) - for device in entry.data[CONF_DEVICES] - ] try: - new_devices = await api.get_devices() - except NoDevicesFound: - # get_devices raises an exception if no devices are online - new_devices = [] - devices = api.existing_devices - if new_devices: - hass.config_entries.async_update_entry( - entry, - data={ - **entry.data, - CONF_DEVICES: serialize_device_list(devices), - }, - ) + await api.create_websocket() + except NoDevicesFound as err: + # Can later setup successfully and spawn a repair. + raise ConfigEntryNotReady( + "No devices were found on the websocket, perhaps you don't have any devices on this account?" + ) from err + except WebsocketFailure as err: + raise ConfigEntryNotReady("Failed connecting to the websocket.") from err + # Create a coordinator per device, if the device is offline, no data will be on the + # websocket, and the coordinator should auto mark as unavailable. But as long as + # the websocket successfully connected, config entry should setup. + devices: list[APCWifiDevice] = [] + if TYPE_CHECKING: + # api.websocket_handler can't be None after successfully creating the + # websocket client + assert api.websocket_handler is not None + devices = list(api.websocket_handler.devices.values()) coordinators = [AnovaCoordinator(hass, device) for device in devices] - for coordinator in coordinators: - await coordinator.async_config_entry_first_refresh() - firmware_version = coordinator.data.sensor.firmware_version - coordinator.async_setup(str(firmware_version)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData( - api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators + api_jwt=api.jwt, coordinators=coordinators, api=api ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -74,6 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - + anova_data: AnovaData = hass.data[DOMAIN].pop(entry.entry_id) + # Disconnect from WS + await anova_data.api.disconnect_websocket() return unload_ok diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py index 08a3d4e832f..6e331ccf4a2 100644 --- a/homeassistant/components/anova/config_flow.py +++ b/homeassistant/components/anova/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound +from anova_wifi import AnovaApi, InvalidLogin import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -10,7 +10,6 @@ from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .util import serialize_device_list class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): @@ -33,22 +32,18 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() try: await api.authenticate() - devices = await api.get_devices() except InvalidLogin: errors["base"] = "invalid_auth" - except NoDevicesFound: - errors["base"] = "no_devices_found" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" else: - # We store device list in config flow in order to persist found devices on restart, as the Anova api get_devices does not return any devices that are offline. - device_list = serialize_device_list(devices) return self.async_create_entry( title="Anova", data={ - CONF_USERNAME: api.username, - CONF_PASSWORD: api.password, - CONF_DEVICES: device_list, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + # this can be removed in a migration to 1.2 in 2024.11 + CONF_DEVICES: [], }, ) diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index c0261c139c1..93c6fdbf1c5 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -1,14 +1,13 @@ """Support for Anova Coordinators.""" -from asyncio import timeout -from datetime import timedelta import logging -from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate +from anova_wifi import APCUpdate, APCWifiDevice -from homeassistant.core import HomeAssistant, callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -18,37 +17,24 @@ _LOGGER = logging.getLogger(__name__) class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): """Anova custom coordinator.""" - def __init__( - self, - hass: HomeAssistant, - anova_device: AnovaPrecisionCooker, - ) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, anova_device: APCWifiDevice) -> None: """Set up Anova Coordinator.""" super().__init__( hass, name="Anova Precision Cooker", logger=_LOGGER, - update_interval=timedelta(seconds=30), ) - assert self.config_entry is not None - self.device_unique_id = anova_device.device_key + self.device_unique_id = anova_device.cooker_id self.anova_device = anova_device + self.anova_device.set_update_listener(self.async_set_updated_data) self.device_info: DeviceInfo | None = None - @callback - def async_setup(self, firmware_version: str) -> None: - """Set the firmware version info.""" self.device_info = DeviceInfo( identifiers={(DOMAIN, self.device_unique_id)}, name="Anova Precision Cooker", manufacturer="Anova", model="Precision Cooker", - sw_version=firmware_version, ) - - async def _async_update_data(self) -> APCUpdate: - try: - async with timeout(5): - return await self.anova_device.update() - except AnovaOffline as err: - raise UpdateFailed(err) from err + self.sensor_data_set: bool = False diff --git a/homeassistant/components/anova/entity.py b/homeassistant/components/anova/entity.py index a8e3ce0ae70..54492f3775e 100644 --- a/homeassistant/components/anova/entity.py +++ b/homeassistant/components/anova/entity.py @@ -19,6 +19,11 @@ class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity): self.device = coordinator.anova_device self._attr_device_info = coordinator.device_info + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data is not None and super().available + class AnovaDescriptionEntity(AnovaEntity): """Defines an Anova entity that uses a description.""" diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json index 7c4509e2f25..331a4f61118 100644 --- a/homeassistant/components/anova/manifest.json +++ b/homeassistant/components/anova/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@Lash-L"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/anova", - "iot_class": "cloud_polling", + "iot_class": "cloud_push", "loggers": ["anova_wifi"], - "requirements": ["anova-wifi==0.10.0"] + "requirements": ["anova-wifi==0.12.0"] } diff --git a/homeassistant/components/anova/models.py b/homeassistant/components/anova/models.py index 4a6338eb081..8caf16eeae1 100644 --- a/homeassistant/components/anova/models.py +++ b/homeassistant/components/anova/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from anova_wifi import AnovaPrecisionCooker +from anova_wifi import AnovaApi from .coordinator import AnovaCoordinator @@ -12,5 +12,5 @@ class AnovaData: """Data for the Anova integration.""" api_jwt: str - precision_cookers: list[AnovaPrecisionCooker] coordinators: list[AnovaCoordinator] + api: AnovaApi diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index 7e94f8f4b0b..e5fe9ededfd 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from anova_wifi import APCUpdateSensor +from anova_wifi import AnovaMode, AnovaState, APCUpdateSensor from homeassistant import config_entries from homeassistant.components.sensor import ( @@ -20,25 +20,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN +from .coordinator import AnovaCoordinator from .entity import AnovaDescriptionEntity from .models import AnovaData -@dataclass(frozen=True) -class AnovaSensorEntityDescriptionMixin: - """Describes the mixin variables for anova sensors.""" - - value_fn: Callable[[APCUpdateSensor], float | int | str] - - -@dataclass(frozen=True) -class AnovaSensorEntityDescription( - SensorEntityDescription, AnovaSensorEntityDescriptionMixin -): +@dataclass(frozen=True, kw_only=True) +class AnovaSensorEntityDescription(SensorEntityDescription): """Describes a Anova sensor.""" + value_fn: Callable[[APCUpdateSensor], StateType] -SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + +SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [ AnovaSensorEntityDescription( key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, @@ -50,11 +44,15 @@ SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ AnovaSensorEntityDescription( key="state", translation_key="state", + device_class=SensorDeviceClass.ENUM, + options=[state.name for state in AnovaState], value_fn=lambda data: data.state, ), AnovaSensorEntityDescription( key="mode", translation_key="mode", + device_class=SensorDeviceClass.ENUM, + options=[mode.name for mode in AnovaMode], value_fn=lambda data: data.mode, ), AnovaSensorEntityDescription( @@ -106,11 +104,34 @@ async def async_setup_entry( ) -> None: """Set up Anova device.""" anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - AnovaSensor(coordinator, description) - for coordinator in anova_data.coordinators - for description in SENSOR_DESCRIPTIONS - ) + + for coordinator in anova_data.coordinators: + setup_coordinator(coordinator, async_add_entities) + + +def setup_coordinator( + coordinator: AnovaCoordinator, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up an individual Anova Coordinator.""" + + def _async_sensor_listener() -> None: + """Listen for new sensor data and add sensors if they did not exist.""" + if not coordinator.sensor_data_set: + valid_entities: set[AnovaSensor] = set() + for description in SENSOR_DESCRIPTIONS: + if description.value_fn(coordinator.data.sensor) is not None: + valid_entities.add(AnovaSensor(coordinator, description)) + async_add_entities(valid_entities) + coordinator.sensor_data_set = True + + if coordinator.data is not None: + _async_sensor_listener() + # It is possible that we don't have any data, but the device exists, + # i.e. slow network, offline device, etc. + # We want to set up sensors after the fact as we don't know what sensors + # are valid until runtime. + coordinator.async_add_listener(_async_sensor_listener) class AnovaSensor(AnovaDescriptionEntity, SensorEntity): diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index b7762732303..bfe3a61282e 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -11,13 +11,9 @@ "description": "[%key:common::config_flow::description::confirm_setup%]" } }, - "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" - }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "no_devices_found": "No devices were found. Make sure you have at least one Anova device online." + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { @@ -26,10 +22,28 @@ "name": "Cook time" }, "state": { - "name": "State" + "name": "State", + "state": { + "preheating": "Preheating", + "cooking": "Cooking", + "maintaining": "Maintaining", + "timer_expired": "Timer expired", + "set_timer": "Set timer", + "no_state": "No state" + } }, "mode": { - "name": "[%key:common::config_flow::data::mode%]" + "name": "[%key:common::config_flow::data::mode%]", + "state": { + "startup": "Startup", + "idle": "[%key:common::state::idle%]", + "cook": "Cooking", + "low_water": "Low water", + "ota": "Ota", + "provisioning": "Provisioning", + "high_temp": "High temperature", + "device_failure": "Device failure" + } }, "target_temperature": { "name": "Target temperature" diff --git a/homeassistant/components/anova/util.py b/homeassistant/components/anova/util.py deleted file mode 100644 index 10e8fa0fef9..00000000000 --- a/homeassistant/components/anova/util.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Anova utilities.""" - -from anova_wifi import AnovaPrecisionCooker - - -def serialize_device_list(devices: list[AnovaPrecisionCooker]) -> list[tuple[str, str]]: - """Turn the device list into a serializable list that can be reconstructed.""" - return [(device.device_key, device.type) for device in devices] diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py index ec38460116d..6d74a9936ae 100644 --- a/homeassistant/components/aosmith/config_flow.py +++ b/homeassistant/components/aosmith/config_flow.py @@ -36,7 +36,7 @@ class AOSmithConfigFlow(ConfigFlow, domain=DOMAIN): await client.get_devices() except AOSmithInvalidCredentialsException: return "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return "unknown" diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py index d35b8b36410..711b0c8559c 100644 --- a/homeassistant/components/aosmith/entity.py +++ b/homeassistant/components/aosmith/entity.py @@ -1,7 +1,5 @@ """The base entity for the A. O. Smith integration.""" -from typing import TypeVar - from py_aosmith import AOSmithAPIClient from py_aosmith.models import Device as AOSmithDevice @@ -11,12 +9,10 @@ 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]): +class AOSmithEntity[ + _AOSmithCoordinatorT: AOSmithStatusCoordinator | AOSmithEnergyCoordinator +](CoordinatorEntity[_AOSmithCoordinatorT]): """Base entity for A. O. Smith.""" _attr_has_entity_name = True diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 6ac33072856..8d2c1ee2af1 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -87,7 +87,9 @@ SENSORS: dict[str, SensorEntityDescription] = { "cumonbatt": SensorEntityDescription( key="cumonbatt", translation_key="total_time_on_battery", + native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DURATION, ), "date": SensorEntityDescription( key="date", @@ -340,12 +342,16 @@ SENSORS: dict[str, SensorEntityDescription] = { "timeleft": SensorEntityDescription( key="timeleft", translation_key="time_left", + native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, ), "tonbatt": SensorEntityDescription( key="tonbatt", translation_key="time_on_battery", + native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DURATION, ), "upsmode": SensorEntityDescription( key="upsmode", diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index cd1a1c59127..4e5c8791acd 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -73,8 +73,10 @@ DEVICE_EXCEPTIONS = ( exceptions.DeviceIdMissingError, ) +type AppleTvConfigEntry = ConfigEntry[AppleTVManager] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool: """Set up a config entry for Apple TV.""" manager = AppleTVManager(hass, entry) @@ -95,7 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryNotReady(f"{address}: {ex}") from ex - hass.data.setdefault(DOMAIN, {})[entry.unique_id] = manager + entry.runtime_data = manager async def on_hass_stop(event: Event) -> None: """Stop push updates when hass stops.""" @@ -104,6 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) + entry.async_on_unload(manager.disconnect) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await manager.init() @@ -113,13 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an Apple TV config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - manager = hass.data[DOMAIN].pop(entry.unique_id) - await manager.disconnect() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class AppleTVEntity(Entity): @@ -246,7 +243,7 @@ class AppleTVManager(DeviceListener): if self._task: self._task.cancel() self._task = None - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("An error occurred while disconnecting") def _start_connect_loop(self) -> None: @@ -292,7 +289,7 @@ class AppleTVManager(DeviceListener): return except asyncio.CancelledError: pass - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Failed to connect") await self.disconnect() diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 1f2aa3b3b3a..71c26244203 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -184,7 +184,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "no_devices_found" except DeviceAlreadyConfigured: errors["base"] = "already_configured" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -329,7 +329,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="no_devices_found") except DeviceAlreadyConfigured: return self.async_abort(reason="already_configured") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -472,7 +472,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): except exceptions.PairingError: _LOGGER.exception("Authentication problem") abort_reason = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") abort_reason = "unknown" @@ -514,7 +514,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN): except exceptions.PairingError: _LOGGER.exception("Authentication problem") errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 3f64d10f9ac..9fb9dee46e1 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -37,15 +37,13 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import AppleTVEntity, AppleTVManager +from . import AppleTvConfigEntry, AppleTVEntity, AppleTVManager from .browse_media import build_app_list -from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -100,13 +98,13 @@ SUPPORT_FEATURE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AppleTvConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Load Apple TV media player based on a config entry.""" name: str = config_entry.data[CONF_NAME] assert config_entry.unique_id is not None - manager: AppleTVManager = hass.data[DOMAIN][config_entry.unique_id] + manager = config_entry.runtime_data async_add_entities([AppleTvMediaPlayer(name, config_entry.unique_id, manager)]) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index aed2c0ae3f0..8950a46388d 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -15,13 +15,11 @@ from homeassistant.components.remote import ( DEFAULT_HOLD_SECS, RemoteEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AppleTVEntity, AppleTVManager -from .const import DOMAIN +from . import AppleTvConfigEntry, AppleTVEntity _LOGGER = logging.getLogger(__name__) @@ -38,14 +36,14 @@ COMMAND_TO_ATTRIBUTE = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AppleTvConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Load Apple TV remote based on a config entry.""" name: str = config_entry.data[CONF_NAME] # apple_tv config entries always have a unique id assert config_entry.unique_id is not None - manager: AppleTVManager = hass.data[DOMAIN][config_entry.unique_id] + manager = config_entry.runtime_data async_add_entities([AppleTVRemote(name, config_entry.unique_id, manager)]) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 0c0e816f088..4e838a5e25b 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.7.4"] + "requirements": ["apprise==1.8.0"] } diff --git a/homeassistant/components/aprilaire/__init__.py b/homeassistant/components/aprilaire/__init__.py index 4fa5cdac68d..ba310615567 100644 --- a/homeassistant/components/aprilaire/__init__.py +++ b/homeassistant/components/aprilaire/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN from .coordinator import AprilaireCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aprilaire/sensor.py b/homeassistant/components/aprilaire/sensor.py new file mode 100644 index 00000000000..249c1b3850f --- /dev/null +++ b/homeassistant/components/aprilaire/sensor.py @@ -0,0 +1,308 @@ +"""The Aprilaire sensor component.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from pyaprilaire.const import Attribute + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import AprilaireCoordinator +from .entity import BaseAprilaireEntity + +DEHUMIDIFICATION_STATUS_MAP: dict[StateType, str] = { + 0: "idle", + 1: "idle", + 2: "on", + 3: "on", + 4: "off", +} + +HUMIDIFICATION_STATUS_MAP: dict[StateType, str] = { + 0: "idle", + 1: "idle", + 2: "on", + 3: "off", +} + +VENTILATION_STATUS_MAP: dict[StateType, str] = { + 0: "idle", + 1: "idle", + 2: "on", + 3: "idle", + 4: "idle", + 5: "idle", + 6: "off", +} + +AIR_CLEANING_STATUS_MAP: dict[StateType, str] = { + 0: "idle", + 1: "idle", + 2: "on", + 3: "off", +} + +FAN_STATUS_MAP: dict[StateType, str] = {0: "off", 1: "on"} + + +def get_entities( + entity_class: type[BaseAprilaireSensor], + coordinator: AprilaireCoordinator, + unique_id: str, + descriptions: tuple[AprilaireSensorDescription, ...], +) -> list[BaseAprilaireSensor]: + """Get the entities for a list of sensor descriptions.""" + + entities = ( + entity_class(coordinator, description, unique_id) + for description in descriptions + ) + + return [entity for entity in entities if entity.exists] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Aprilaire sensor devices.""" + + coordinator: AprilaireCoordinator = hass.data[DOMAIN][config_entry.unique_id] + + assert config_entry.unique_id is not None + + entities = ( + get_entities( + AprilaireHumiditySensor, + coordinator, + config_entry.unique_id, + HUMIDITY_SENSORS, + ) + + get_entities( + AprilaireTemperatureSensor, + coordinator, + config_entry.unique_id, + TEMPERATURE_SENSORS, + ) + + get_entities( + AprilaireStatusSensor, coordinator, config_entry.unique_id, STATUS_SENSORS + ) + ) + + async_add_entities(entities) + + +@dataclass(frozen=True, kw_only=True) +class AprilaireSensorDescription(SensorEntityDescription): + """Class describing Aprilaire sensor entities.""" + + status_key: str | None + value_key: str + + +@dataclass(frozen=True, kw_only=True) +class AprilaireStatusSensorDescription(AprilaireSensorDescription): + """Class describing Aprilaire status sensor entities.""" + + status_map: dict[StateType, str] + + +HUMIDITY_SENSORS: tuple[AprilaireSensorDescription, ...] = ( + AprilaireSensorDescription( + key="indoor_humidity_controlling_sensor", + translation_key="indoor_humidity_controlling_sensor", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + status_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_STATUS, + value_key=Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE, + ), + AprilaireSensorDescription( + key="outdoor_humidity_controlling_sensor", + translation_key="outdoor_humidity_controlling_sensor", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + status_key=Attribute.OUTDOOR_HUMIDITY_CONTROLLING_SENSOR_STATUS, + value_key=Attribute.OUTDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE, + ), +) + +TEMPERATURE_SENSORS: tuple[AprilaireSensorDescription, ...] = ( + AprilaireSensorDescription( + key="indoor_temperature_controlling_sensor", + translation_key="indoor_temperature_controlling_sensor", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + status_key=Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS, + value_key=Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_VALUE, + ), + AprilaireSensorDescription( + key="outdoor_temperature_controlling_sensor", + translation_key="outdoor_temperature_controlling_sensor", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + status_key=Attribute.OUTDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS, + value_key=Attribute.OUTDOOR_TEMPERATURE_CONTROLLING_SENSOR_VALUE, + ), +) + +STATUS_SENSORS: tuple[AprilaireSensorDescription, ...] = ( + AprilaireStatusSensorDescription( + key="dehumidification_status", + translation_key="dehumidification_status", + device_class=SensorDeviceClass.ENUM, + status_key=Attribute.DEHUMIDIFICATION_AVAILABLE, + value_key=Attribute.DEHUMIDIFICATION_STATUS, + status_map=DEHUMIDIFICATION_STATUS_MAP, + options=list(set(DEHUMIDIFICATION_STATUS_MAP.values())), + ), + AprilaireStatusSensorDescription( + key="humidification_status", + translation_key="humidification_status", + device_class=SensorDeviceClass.ENUM, + status_key=Attribute.HUMIDIFICATION_AVAILABLE, + value_key=Attribute.HUMIDIFICATION_STATUS, + status_map=HUMIDIFICATION_STATUS_MAP, + options=list(set(HUMIDIFICATION_STATUS_MAP.values())), + ), + AprilaireStatusSensorDescription( + key="ventilation_status", + translation_key="ventilation_status", + device_class=SensorDeviceClass.ENUM, + status_key=Attribute.VENTILATION_AVAILABLE, + value_key=Attribute.VENTILATION_STATUS, + status_map=VENTILATION_STATUS_MAP, + options=list(set(VENTILATION_STATUS_MAP.values())), + ), + AprilaireStatusSensorDescription( + key="air_cleaning_status", + translation_key="air_cleaning_status", + device_class=SensorDeviceClass.ENUM, + status_key=Attribute.AIR_CLEANING_AVAILABLE, + value_key=Attribute.AIR_CLEANING_STATUS, + status_map=AIR_CLEANING_STATUS_MAP, + options=list(set(AIR_CLEANING_STATUS_MAP.values())), + ), + AprilaireStatusSensorDescription( + key="fan_status", + translation_key="fan_status", + device_class=SensorDeviceClass.ENUM, + status_key=None, + value_key=Attribute.FAN_STATUS, + status_map=FAN_STATUS_MAP, + options=list(set(FAN_STATUS_MAP.values())), + ), +) + + +class BaseAprilaireSensor(BaseAprilaireEntity, SensorEntity): + """Base sensor entity for Aprilaire.""" + + entity_description: AprilaireSensorDescription + status_sensor_available_value: int | None = None + status_sensor_exists_values: list[int] + + def __init__( + self, + coordinator: AprilaireCoordinator, + description: AprilaireSensorDescription, + unique_id: str, + ) -> None: + """Initialize a sensor for an Aprilaire device.""" + + self.entity_description = description + + super().__init__(coordinator, unique_id) + + @property + def exists(self) -> bool: + """Return True if the sensor exists.""" + + if self.entity_description.status_key is None: + return True + + return ( + self.coordinator.data.get(self.entity_description.status_key) + in self.status_sensor_exists_values + ) + + @property + def available(self) -> bool: + """Return True if the sensor is available.""" + + if ( + self.entity_description.status_key is None + or self.status_sensor_available_value is None + ): + return True + + if not super().available: + return False + + return ( + self.coordinator.data.get(self.entity_description.status_key) + == self.status_sensor_available_value + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + + # Valid cast as pyaprilaire only provides str | int | float + return cast( + StateType, self.coordinator.data.get(self.entity_description.value_key) + ) + + +class AprilaireHumiditySensor(BaseAprilaireSensor): + """Humidity sensor entity for Aprilaire.""" + + status_sensor_available_value = 0 + status_sensor_exists_values = [0, 1, 2] + + +class AprilaireTemperatureSensor(BaseAprilaireSensor): + """Temperature sensor entity for Aprilaire.""" + + status_sensor_available_value = 0 + status_sensor_exists_values = [0, 1, 2] + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + if self.unit_of_measurement == UnitOfTemperature.CELSIUS: + return 1 + + return 0 + + +class AprilaireStatusSensor(BaseAprilaireSensor): + """Status sensor entity for Aprilaire.""" + + status_sensor_exists_values = [1, 2] + entity_description: AprilaireStatusSensorDescription + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor mapped to the status option.""" + + raw_value = super().native_value + + return self.entity_description.status_map.get(raw_value) diff --git a/homeassistant/components/aprilaire/strings.json b/homeassistant/components/aprilaire/strings.json index e996691f21f..72005e0215c 100644 --- a/homeassistant/components/aprilaire/strings.json +++ b/homeassistant/components/aprilaire/strings.json @@ -23,6 +23,59 @@ "thermostat": { "name": "Thermostat" } + }, + "sensor": { + "indoor_humidity_controlling_sensor": { + "name": "Indoor humidity controlling sensor" + }, + "outdoor_humidity_controlling_sensor": { + "name": "Outdoor humidity controlling sensor" + }, + "indoor_temperature_controlling_sensor": { + "name": "Indoor temperature controlling sensor" + }, + "outdoor_temperature_controlling_sensor": { + "name": "Outdoor temperature controlling sensor" + }, + "dehumidification_status": { + "name": "Dehumidification status", + "state": { + "idle": "[%key:common::state::idle%]", + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]" + } + }, + "humidification_status": { + "name": "Humidification status", + "state": { + "idle": "[%key:common::state::idle%]", + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]" + } + }, + "ventilation_status": { + "name": "Ventilation status", + "state": { + "idle": "[%key:common::state::idle%]", + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]" + } + }, + "air_cleaning_status": { + "name": "Air cleaning status", + "state": { + "idle": "[%key:common::state::idle%]", + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]" + } + }, + "fan_status": { + "name": "Fan status", + "state": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]" + } + } } } } diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index 0915643340b..e96494db930 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -39,6 +39,7 @@ ATTR_COURSE = "course" ATTR_COMMENT = "comment" ATTR_FROM = "from" ATTR_FORMAT = "format" +ATTR_OBJECT_NAME = "object_name" ATTR_POS_AMBIGUITY = "posambiguity" ATTR_SPEED = "speed" @@ -50,7 +51,7 @@ DEFAULT_TIMEOUT = 30.0 FILTER_PORT = 14580 -MSG_FORMATS = ["compressed", "uncompressed", "mic-e"] +MSG_FORMATS = ["compressed", "uncompressed", "mic-e", "object"] PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { @@ -181,7 +182,10 @@ class AprsListenerThread(threading.Thread): """Receive message and process if position.""" _LOGGER.debug("APRS message received: %s", str(msg)) if msg[ATTR_FORMAT] in MSG_FORMATS: - dev_id = slugify(msg[ATTR_FROM]) + if msg[ATTR_FORMAT] == "object": + dev_id = slugify(msg[ATTR_OBJECT_NAME]) + else: + dev_id = slugify(msg[ATTR_FROM]) lat = msg[ATTR_LATITUDE] lon = msg[ATTR_LONGITUDE] diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py new file mode 100644 index 00000000000..2df267dda0b --- /dev/null +++ b/homeassistant/components/apsystems/__init__.py @@ -0,0 +1,45 @@ +"""The APsystems local API integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from APsystemsEZ1 import APsystemsEZ1M + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import ApSystemsDataCoordinator + +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR] + + +@dataclass +class ApSystemsData: + """Store runtime data.""" + + coordinator: ApSystemsDataCoordinator + device_id: str + + +type ApSystemsConfigEntry = ConfigEntry[ApSystemsData] + + +async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool: + """Set up this integration using UI.""" + api = APsystemsEZ1M(ip_address=entry.data[CONF_IP_ADDRESS], timeout=8) + coordinator = ApSystemsDataCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + assert entry.unique_id + entry.runtime_data = ApSystemsData( + coordinator=coordinator, device_id=entry.unique_id + ) + 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/apsystems/config_flow.py b/homeassistant/components/apsystems/config_flow.py new file mode 100644 index 00000000000..f49237ce450 --- /dev/null +++ b/homeassistant/components/apsystems/config_flow.py @@ -0,0 +1,52 @@ +"""The config_flow for APsystems local API integration.""" + +from typing import Any + +from aiohttp.client_exceptions import ClientConnectionError +from APsystemsEZ1 import APsystemsEZ1M +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + } +) + + +class APsystemsLocalAPIFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Apsystems local.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + session = async_get_clientsession(self.hass, False) + api = APsystemsEZ1M(user_input[CONF_IP_ADDRESS], session=session) + try: + device_info = await api.get_device_info() + except (TimeoutError, ClientConnectionError): + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device_info.deviceId) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Solar", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/apsystems/const.py b/homeassistant/components/apsystems/const.py new file mode 100644 index 00000000000..857652aeae8 --- /dev/null +++ b/homeassistant/components/apsystems/const.py @@ -0,0 +1,6 @@ +"""Constants for the APsystems Local API integration.""" + +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) +DOMAIN = "apsystems" diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py new file mode 100644 index 00000000000..f2d076ce3fd --- /dev/null +++ b/homeassistant/components/apsystems/coordinator.py @@ -0,0 +1,29 @@ +"""The coordinator for APsystems local API integration.""" + +from __future__ import annotations + +from datetime import timedelta + +from APsystemsEZ1 import APsystemsEZ1M, ReturnOutputData + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + + +class ApSystemsDataCoordinator(DataUpdateCoordinator[ReturnOutputData]): + """Coordinator used for all sensors.""" + + def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + LOGGER, + name="APSystems Data", + update_interval=timedelta(seconds=12), + ) + self.api = api + + async def _async_update_data(self) -> ReturnOutputData: + return await self.api.get_output_data() diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py new file mode 100644 index 00000000000..519f4fffb61 --- /dev/null +++ b/homeassistant/components/apsystems/entity.py @@ -0,0 +1,27 @@ +"""APsystems base entity.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from . import ApSystemsData +from .const import DOMAIN + + +class ApSystemsEntity(Entity): + """Defines a base APsystems entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + data: ApSystemsData, + ) -> None: + """Initialize the APsystems entity.""" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, data.device_id)}, + serial_number=data.device_id, + manufacturer="APsystems", + model="EZ1-M", + ) diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json new file mode 100644 index 00000000000..8e0ac00796d --- /dev/null +++ b/homeassistant/components/apsystems/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "apsystems", + "name": "APsystems", + "codeowners": ["@mawoka-myblock", "@SonnenladenGmbH"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/apsystems", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["apsystems-ez1==1.3.1"] +} diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py new file mode 100644 index 00000000000..f9b535d7d6a --- /dev/null +++ b/homeassistant/components/apsystems/number.py @@ -0,0 +1,52 @@ +"""The output limit which can be set in the APsystems local API integration.""" + +from __future__ import annotations + +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType + +from . import ApSystemsConfigEntry, ApSystemsData +from .entity import ApSystemsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ApSystemsConfigEntry, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the sensor platform.""" + + add_entities([ApSystemsMaxOutputNumber(config_entry.runtime_data)]) + + +class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): + """Base sensor to be used with description.""" + + _attr_native_max_value = 800 + _attr_native_min_value = 30 + _attr_native_step = 1 + _attr_device_class = NumberDeviceClass.POWER + _attr_mode = NumberMode.BOX + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_translation_key = "max_output" + + def __init__( + self, + data: ApSystemsData, + ) -> None: + """Initialize the sensor.""" + super().__init__(data) + self._api = data.coordinator.api + self._attr_unique_id = f"{data.device_id}_output_limit" + + async def async_update(self) -> None: + """Set the state with the value fetched from the inverter.""" + self._attr_native_value = await self._api.get_max_power() + + async def async_set_native_value(self, value: float) -> None: + """Set the desired output power.""" + self._attr_native_value = await self._api.set_max_power(int(value)) diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py new file mode 100644 index 00000000000..637def4e418 --- /dev/null +++ b/homeassistant/components/apsystems/sensor.py @@ -0,0 +1,151 @@ +"""The read-only sensors for APsystems local API integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from APsystemsEZ1 import ReturnOutputData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ApSystemsConfigEntry, ApSystemsData +from .coordinator import ApSystemsDataCoordinator +from .entity import ApSystemsEntity + + +@dataclass(frozen=True, kw_only=True) +class ApsystemsLocalApiSensorDescription(SensorEntityDescription): + """Describes Apsystens Inverter sensor entity.""" + + value_fn: Callable[[ReturnOutputData], float | None] + + +SENSORS: tuple[ApsystemsLocalApiSensorDescription, ...] = ( + ApsystemsLocalApiSensorDescription( + key="total_power", + translation_key="total_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.p1 + c.p2, + ), + ApsystemsLocalApiSensorDescription( + key="total_power_p1", + translation_key="total_power_p1", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.p1, + ), + ApsystemsLocalApiSensorDescription( + key="total_power_p2", + translation_key="total_power_p2", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.p2, + ), + ApsystemsLocalApiSensorDescription( + key="lifetime_production", + translation_key="lifetime_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.te1 + c.te2, + ), + ApsystemsLocalApiSensorDescription( + key="lifetime_production_p1", + translation_key="lifetime_production_p1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.te1, + ), + ApsystemsLocalApiSensorDescription( + key="lifetime_production_p2", + translation_key="lifetime_production_p2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.te2, + ), + ApsystemsLocalApiSensorDescription( + key="today_production", + translation_key="today_production", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e1 + c.e2, + ), + ApsystemsLocalApiSensorDescription( + key="today_production_p1", + translation_key="today_production_p1", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e1, + ), + ApsystemsLocalApiSensorDescription( + key="today_production_p2", + translation_key="today_production_p2", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ApSystemsConfigEntry, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the sensor platform.""" + config = config_entry.runtime_data + + add_entities( + ApSystemsSensorWithDescription( + data=config, + entity_description=desc, + ) + for desc in SENSORS + ) + + +class ApSystemsSensorWithDescription( + CoordinatorEntity[ApSystemsDataCoordinator], ApSystemsEntity, SensorEntity +): + """Base sensor to be used with description.""" + + entity_description: ApsystemsLocalApiSensorDescription + _attr_has_entity_name = True + + def __init__( + self, + data: ApSystemsData, + entity_description: ApsystemsLocalApiSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(data.coordinator) + ApSystemsEntity.__init__(self, data) + self.entity_description = entity_description + self._attr_unique_id = f"{data.device_id}_{entity_description.key}" + + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/apsystems/strings.json b/homeassistant/components/apsystems/strings.json new file mode 100644 index 00000000000..cfd24675311 --- /dev/null +++ b/homeassistant/components/apsystems/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "total_power": { "name": "Total power" }, + "total_power_p1": { "name": "Power of P1" }, + "total_power_p2": { "name": "Power of P2" }, + "lifetime_production": { "name": "Total lifetime production" }, + "lifetime_production_p1": { "name": "Lifetime production of P1" }, + "lifetime_production_p2": { "name": "Lifetime production of P2" }, + "today_production": { "name": "Production of today" }, + "today_production_p1": { "name": "Production of today from P1" }, + "today_production_p2": { "name": "Production of today from P2" } + }, + "number": { + "max_output": { "name": "Max output" } + } + } +} diff --git a/homeassistant/components/aquacell/__init__.py b/homeassistant/components/aquacell/__init__.py new file mode 100644 index 00000000000..98cf5d7f0f0 --- /dev/null +++ b/homeassistant/components/aquacell/__init__.py @@ -0,0 +1,37 @@ +"""The Aquacell integration.""" + +from __future__ import annotations + +from aioaquacell import AquacellApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import AquacellCoordinator + +PLATFORMS = [Platform.SENSOR] + +type AquacellConfigEntry = ConfigEntry[AquacellCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: AquacellConfigEntry) -> bool: + """Set up Aquacell from a config entry.""" + session = async_get_clientsession(hass) + + aquacell_api = AquacellApi(session) + + coordinator = AquacellCoordinator(hass, aquacell_api) + + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py new file mode 100644 index 00000000000..a9c749e9e2d --- /dev/null +++ b/homeassistant/components/aquacell/config_flow.py @@ -0,0 +1,71 @@ +"""Config flow for Aquacell integration.""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from aioaquacell import ApiException, AquacellApi, AuthenticationFailed +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN_CREATION_TIME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aquacell.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + await self.async_set_unique_id( + user_input[CONF_EMAIL].lower(), raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + session = async_get_clientsession(self.hass) + api = AquacellApi(session) + try: + refresh_token = await api.authenticate( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except ApiException: + errors["base"] = "cannot_connect" + except AuthenticationFailed: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + **user_input, + CONF_REFRESH_TOKEN: refresh_token, + CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/aquacell/const.py b/homeassistant/components/aquacell/const.py new file mode 100644 index 00000000000..96568d2286b --- /dev/null +++ b/homeassistant/components/aquacell/const.py @@ -0,0 +1,12 @@ +"""Constants for the Aquacell integration.""" + +from datetime import timedelta + +DOMAIN = "aquacell" +DATA_AQUACELL = "DATA_AQUACELL" + +CONF_REFRESH_TOKEN = "refresh_token" +CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time" + +REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30) +UPDATE_INTERVAL = timedelta(days=1) diff --git a/homeassistant/components/aquacell/coordinator.py b/homeassistant/components/aquacell/coordinator.py new file mode 100644 index 00000000000..dd5dfcd2d0d --- /dev/null +++ b/homeassistant/components/aquacell/coordinator.py @@ -0,0 +1,90 @@ +"""Coordinator to update data from Aquacell API.""" + +import asyncio +from datetime import datetime +import logging + +from aioaquacell import ( + AquacellApi, + AquacellApiException, + AuthenticationFailed, + Softener, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_CREATION_TIME, + REFRESH_TOKEN_EXPIRY_TIME, + UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]): + """My aquacell coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, aquacell_api: AquacellApi) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Aquacell Coordinator", + update_interval=UPDATE_INTERVAL, + ) + + self.refresh_token = self.config_entry.data[CONF_REFRESH_TOKEN] + self.refresh_token_creation_time = self.config_entry.data[ + CONF_REFRESH_TOKEN_CREATION_TIME + ] + self.email = self.config_entry.data[CONF_EMAIL] + self.password = self.config_entry.data[CONF_PASSWORD] + self.aquacell_api = aquacell_api + + async def _async_update_data(self) -> dict[str, Softener]: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + + async with asyncio.timeout(10): + # Check if the refresh token is expired + expiry_time = ( + self.refresh_token_creation_time + + REFRESH_TOKEN_EXPIRY_TIME.total_seconds() + ) + try: + if datetime.now().timestamp() >= expiry_time: + await self._reauthenticate() + else: + await self.aquacell_api.authenticate_refresh(self.refresh_token) + _LOGGER.debug("Logged in using: %s", self.refresh_token) + + softeners = await self.aquacell_api.get_all_softeners() + except AuthenticationFailed as err: + raise ConfigEntryError from err + except AquacellApiException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + return {softener.dsn: softener for softener in softeners} + + async def _reauthenticate(self) -> None: + _LOGGER.debug("Attempting to renew refresh token") + refresh_token = await self.aquacell_api.authenticate(self.email, self.password) + self.refresh_token = refresh_token + data = { + **self.config_entry.data, + CONF_REFRESH_TOKEN: self.refresh_token, + CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), + } + + self.hass.config_entries.async_update_entry(self.config_entry, data=data) diff --git a/homeassistant/components/aquacell/entity.py b/homeassistant/components/aquacell/entity.py new file mode 100644 index 00000000000..6c746ded24c --- /dev/null +++ b/homeassistant/components/aquacell/entity.py @@ -0,0 +1,41 @@ +"""Aquacell entity.""" + +from aioaquacell import Softener + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AquacellCoordinator + + +class AquacellEntity(CoordinatorEntity[AquacellCoordinator]): + """Representation of an aquacell entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AquacellCoordinator, + softener_key: str, + entity_key: str, + ) -> None: + """Initialize the aquacell entity.""" + super().__init__(coordinator) + + self.softener_key = softener_key + + self._attr_unique_id = f"{softener_key}-{entity_key}" + self._attr_device_info = DeviceInfo( + name=self.softener.name, + hw_version=self.softener.fwVersion, + identifiers={(DOMAIN, str(softener_key))}, + manufacturer=self.softener.brand, + model=self.softener.ssn, + serial_number=softener_key, + ) + + @property + def softener(self) -> Softener: + """Handle updated data from the coordinator.""" + return self.coordinator.data[self.softener_key] diff --git a/homeassistant/components/aquacell/icons.json b/homeassistant/components/aquacell/icons.json new file mode 100644 index 00000000000..d7383f54d72 --- /dev/null +++ b/homeassistant/components/aquacell/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "sensor": { + "salt_left_side_percentage": { + "default": "mdi:basket-fill" + }, + "salt_right_side_percentage": { + "default": "mdi:basket-fill" + }, + "wi_fi_strength": { + "default": "mdi:wifi", + "state": { + "low": "mdi:wifi-strength-1", + "medium": "mdi:wifi-strength-2", + "high": "mdi:wifi-strength-4" + } + } + } + } +} diff --git a/homeassistant/components/aquacell/manifest.json b/homeassistant/components/aquacell/manifest.json new file mode 100644 index 00000000000..1f43fa214d3 --- /dev/null +++ b/homeassistant/components/aquacell/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "aquacell", + "name": "Aquacell", + "codeowners": ["@Jordi1990"], + "config_flow": true, + "dependencies": ["http", "network"], + "documentation": "https://www.home-assistant.io/integrations/aquacell", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["aioaquacell"], + "requirements": ["aioaquacell==0.1.7"] +} diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py new file mode 100644 index 00000000000..702d75a0215 --- /dev/null +++ b/homeassistant/components/aquacell/sensor.py @@ -0,0 +1,117 @@ +"""Sensors exposing properties of the softener device.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from aioaquacell import Softener + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import AquacellConfigEntry +from .coordinator import AquacellCoordinator +from .entity import AquacellEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SoftenerSensorEntityDescription(SensorEntityDescription): + """Describes Softener sensor entity.""" + + value_fn: Callable[[Softener], StateType] + + +SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( + SoftenerSensorEntityDescription( + key="salt_left_side_percentage", + translation_key="salt_left_side_percentage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda softener: softener.salt.leftPercent, + ), + SoftenerSensorEntityDescription( + key="salt_right_side_percentage", + translation_key="salt_right_side_percentage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda softener: softener.salt.rightPercent, + ), + SoftenerSensorEntityDescription( + key="salt_left_side_time_remaining", + translation_key="salt_left_side_time_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda softener: softener.salt.leftDays, + ), + SoftenerSensorEntityDescription( + key="salt_right_side_time_remaining", + translation_key="salt_right_side_time_remaining", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda softener: softener.salt.rightDays, + ), + SoftenerSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda softener: softener.battery, + ), + SoftenerSensorEntityDescription( + key="wi_fi_strength", + translation_key="wi_fi_strength", + value_fn=lambda softener: softener.wifiLevel, + device_class=SensorDeviceClass.ENUM, + options=[ + "high", + "medium", + "low", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AquacellConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensors.""" + softeners = config_entry.runtime_data.data + async_add_entities( + SoftenerSensor(config_entry.runtime_data, sensor, softener_key) + for sensor in SENSORS + for softener_key in softeners + ) + + +class SoftenerSensor(AquacellEntity, SensorEntity): + """Softener sensor.""" + + entity_description: SoftenerSensorEntityDescription + + def __init__( + self, + coordinator: AquacellCoordinator, + description: SoftenerSensorEntityDescription, + softener_key: str, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator, softener_key, description.key) + + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.softener) diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json new file mode 100644 index 00000000000..32b6bba943a --- /dev/null +++ b/homeassistant/components/aquacell/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "description": "Fill in your Aquacell mobile app credentials", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "salt_left_side_percentage": { + "name": "Salt left side percentage" + }, + "salt_right_side_percentage": { + "name": "Salt right side percentage" + }, + "salt_left_side_time_remaining": { + "name": "Salt left side time remaining" + }, + "salt_right_side_time_remaining": { + "name": "Salt right side time remaining" + }, + "wi_fi_strength": { + "name": "Wi-Fi strength", + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + } + } + } +} diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 7160810e0dc..64631ed1948 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate import sharp_aquos_rc import voluptuous as vol @@ -28,9 +28,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_SharpAquosTVDeviceT = TypeVar("_SharpAquosTVDeviceT", bound="SharpAquosTVDevice") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Sharp Aquos TV" @@ -85,7 +82,7 @@ def setup_platform( add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)]) -def _retry( +def _retry[_SharpAquosTVDeviceT: SharpAquosTVDevice, **_P]( func: Callable[Concatenate[_SharpAquosTVDeviceT, _P], Any], ) -> Callable[Concatenate[_SharpAquosTVDeviceT, _P], None]: """Handle query retries.""" diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index a1cd80cc3c7..3f74d480c17 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/aranet", "integration_type": "device", "iot_class": "local_push", - "requirements": ["aranet4==2.3.3"] + "requirements": ["aranet4==2.3.4"] } diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 4509aa66027..c0fe194e87b 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -143,7 +143,7 @@ def _sensor_device_info_to_hass( def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, -) -> PassiveBluetoothDataUpdate: +) -> PassiveBluetoothDataUpdate[Any]: """Convert a sensor update to a Bluetooth data update.""" data: dict[PassiveBluetoothEntityKey, Any] = {} names: dict[PassiveBluetoothEntityKey, str | None] = {} @@ -171,9 +171,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aranet sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: PassiveBluetoothProcessorCoordinator[Aranet4Advertisement] = hass.data[ + DOMAIN + ][entry.entry_id] processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( @@ -184,7 +184,9 @@ async def async_setup_entry( class Aranet4BluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, Aranet4Advertisement], + ], SensorEntity, ): """Representation of an Aranet sensor.""" diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index ff6bd872065..e4a0ae78920 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -86,6 +86,6 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N await asyncio.sleep(interval) except TimeoutError: continue - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception, aborting arcam client") return diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 2c9b64b00ce..39d289f9cb1 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.4.0"], + "requirements": ["arcam-fmj==1.5.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index ca08a2b4d16..9865b459497 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import functools import logging -from typing import Any, ParamSpec, TypeVar +from typing import Any from arcam.fmj import ConnectionFailed, SourceCodes from arcam.fmj.state import State @@ -36,9 +36,6 @@ from .const import ( SIGNAL_CLIENT_STOPPED, ) -_R = TypeVar("_R") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) @@ -64,7 +61,7 @@ async def async_setup_entry( ) -def convert_exception( +def convert_exception[**_P, _R]( func: Callable[_P, Coroutine[Any, Any, _R]], ) -> Callable[_P, Coroutine[Any, Any, _R]]: """Return decorator to convert a connection error into a home assistant error.""" diff --git a/homeassistant/components/aseko_pool_live/config_flow.py b/homeassistant/components/aseko_pool_live/config_flow.py index f4df44aa2d7..cd2f0e4ac7f 100644 --- a/homeassistant/components/aseko_pool_live/config_flow.py +++ b/homeassistant/components/aseko_pool_live/config_flow.py @@ -62,7 +62,7 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuthCredentials: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -126,7 +126,7 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuthCredentials: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 2251167466c..ff360676cf7 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -5,7 +5,7 @@ from __future__ import annotations import array import asyncio from collections import defaultdict, deque -from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable +from collections.abc import AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum import logging @@ -16,6 +16,7 @@ import time from typing import TYPE_CHECKING, Any, Final, Literal, cast import wave +from typing_extensions import AsyncGenerator import voluptuous as vol if TYPE_CHECKING: @@ -349,7 +350,7 @@ class PipelineEvent: timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat()) -PipelineEventCallback = Callable[[PipelineEvent], None] +type PipelineEventCallback = Callable[[PipelineEvent], None] @dataclass(frozen=True) @@ -922,7 +923,7 @@ class PipelineRun: stt_vad: VoiceCommandSegmenter | None, sample_rate: int = 16000, sample_width: int = 2, - ) -> AsyncGenerator[bytes, None]: + ) -> AsyncGenerator[bytes]: """Yield audio chunks until VAD detects silence or speech-to-text completes.""" chunk_seconds = AUDIO_PROCESSOR_SAMPLES / sample_rate sent_vad_start = False @@ -1185,7 +1186,7 @@ class PipelineRun: audio_stream: AsyncIterable[bytes], sample_rate: int = 16000, sample_width: int = 2, - ) -> AsyncGenerator[ProcessedAudioChunk, None]: + ) -> AsyncGenerator[ProcessedAudioChunk]: """Apply volume transformation only (no VAD/audio enhancements) with optional chunking.""" ms_per_sample = sample_rate // 1000 ms_per_chunk = (AUDIO_PROCESSOR_SAMPLES // sample_width) // ms_per_sample @@ -1220,7 +1221,7 @@ class PipelineRun: audio_stream: AsyncIterable[bytes], sample_rate: int = 16000, sample_width: int = 2, - ) -> AsyncGenerator[ProcessedAudioChunk, None]: + ) -> AsyncGenerator[ProcessedAudioChunk]: """Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation.""" assert self.audio_processor is not None @@ -1295,7 +1296,7 @@ def _pipeline_debug_recording_thread_proc( wav_writer.writeframes(message) except Empty: pass # occurs when pipeline has unexpected error - except Exception: # pylint: disable=broad-exception-caught + except Exception: _LOGGER.exception("Unexpected error in debug recording thread") finally: if wav_writer is not None: @@ -1386,7 +1387,7 @@ class PipelineInput: # Send audio in the buffer first to speech-to-text, then move on to stt_stream. # This is basically an async itertools.chain. async def buffer_then_audio_stream() -> ( - AsyncGenerator[ProcessedAudioChunk, None] + AsyncGenerator[ProcessedAudioChunk] ): # Buffered audio for chunk in stt_audio_buffer: @@ -1608,11 +1609,10 @@ class PipelineStorageCollectionWebsocket( self, hass: HomeAssistant, *, - create_list: bool = True, create_create: bool = True, ) -> None: """Set up the websocket commands.""" - super().async_setup(hass, create_list=create_list, create_create=create_create) + super().async_setup(hass, create_create=create_create) websocket_api.async_register_command( hass, @@ -1647,9 +1647,7 @@ class PipelineStorageCollectionWebsocket( try: await super().ws_delete_item(hass, connection, msg) except PipelinePreferred as exc: - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_ALLOWED, str(exc) - ) + connection.send_error(msg["id"], websocket_api.ERR_NOT_ALLOWED, str(exc)) @callback def ws_get_item( @@ -1663,7 +1661,7 @@ class PipelineStorageCollectionWebsocket( if item_id not in self.storage_collection.data: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"Unable to find {self.item_id_key} {item_id}", ) return @@ -1694,7 +1692,7 @@ class PipelineStorageCollectionWebsocket( self.storage_collection.async_set_preferred_item(msg[self.item_id_key]) except ItemNotFound: connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown item" + msg["id"], websocket_api.ERR_NOT_FOUND, "unknown item" ) return connection.send_result(msg["id"]) diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 43ed003f65d..5d011424e6e 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -109,7 +109,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): self.async_write_ha_state() async def _pipelines_updated( - self, change_sets: Iterable[collection.CollectionChangeSet] + self, change_set: Iterable[collection.CollectionChange] ) -> None: """Handle pipeline update.""" self._update_options() diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 3e8cdf6fa42..18464810525 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -5,12 +5,13 @@ import asyncio # Suppressing disable=deprecated-module is needed for Python 3.11 import audioop # pylint: disable=deprecated-module import base64 -from collections.abc import AsyncGenerator, Callable +from collections.abc import Callable import contextlib import logging import math from typing import Any, Final +from typing_extensions import AsyncGenerator import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api @@ -165,7 +166,7 @@ async def websocket_run( elif start_stage == PipelineStage.STT: wake_word_phrase = msg["input"].get("wake_word_phrase") - async def stt_stream() -> AsyncGenerator[bytes, None]: + async def stt_stream() -> AsyncGenerator[bytes]: state = None # Yield until we receive an empty chunk @@ -352,7 +353,7 @@ def websocket_get_run( if pipeline_id not in pipeline_data.pipeline_debug: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"pipeline_id {pipeline_id} not found", ) return @@ -362,7 +363,7 @@ def websocket_get_run( if pipeline_run_id not in pipeline_debug: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"pipeline_run_id {pipeline_run_id} not found", ) return diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index f3d12c3bd39..1148f5ef7df 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -4,13 +4,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant -from .const import DATA_ASUSWRT, DOMAIN from .router import AsusWrtRouter PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] +type AsusWrtConfigEntry = ConfigEntry[AsusWrtRouter] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: AsusWrtConfigEntry) -> bool: """Set up AsusWrt platform.""" router = AsusWrtRouter(hass, entry) @@ -26,26 +27,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_ASUSWRT: router} + entry.runtime_data = router 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, entry: AsusWrtConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + router = entry.runtime_data await router.close() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: AsusWrtConfigEntry) -> None: """Update when config_entry options update.""" - router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + router = entry.runtime_data if router.update_options(entry.options): await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 579f894ff61..b193787f500 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -7,7 +7,7 @@ from collections import namedtuple from collections.abc import Awaitable, Callable, Coroutine import functools import logging -from typing import Any, TypeVar, cast +from typing import Any, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aiohttp import ClientSession @@ -56,15 +56,11 @@ WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) _LOGGER = logging.getLogger(__name__) - -_AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") -_FuncType = Callable[ - [_AsusWrtBridgeT], Awaitable[list[Any] | tuple[Any] | dict[str, Any]] -] -_ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] +type _FuncType[_T] = Callable[[_T], Awaitable[list[Any] | tuple[Any] | dict[str, Any]]] +type _ReturnFuncType[_T] = Callable[[_T], Coroutine[Any, Any, dict[str, Any]]] -def handle_errors_and_zip( +def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge]( exceptions: type[Exception] | tuple[type[Exception], ...], keys: list[str] | None ) -> Callable[[_FuncType[_AsusWrtBridgeT]], _ReturnFuncType[_AsusWrtBridgeT]]: """Run library methods and zip results or manage exceptions.""" diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index e456b1c55ba..f5db3dfa3d8 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -195,7 +195,7 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): ) error = RESULT_CONN_ERROR - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Unknown error connecting with AsusWrt router at %s using protocol %s", host, diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index d31d986574e..5ce37207145 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -8,8 +8,6 @@ CONF_REQUIRE_IP = "require_ip" CONF_SSH_KEY = "ssh_key" CONF_TRACK_UNKNOWN = "track_unknown" -DATA_ASUSWRT = DOMAIN - DEFAULT_DNSMASQ = "/var/lib/misc" DEFAULT_INTERFACE = "eth0" DEFAULT_TRACK_UNKNOWN = False diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 059a0eeb3fb..d2330801bd5 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -3,12 +3,11 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity, SourceType -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 DATA_ASUSWRT, DOMAIN +from . import AsusWrtConfigEntry from .router import AsusWrtDevInfo, AsusWrtRouter ATTR_LAST_TIME_REACHABLE = "last_time_reachable" @@ -17,10 +16,12 @@ DEFAULT_DEVICE_NAME = "Unknown device" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AsusWrtConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for AsusWrt component.""" - router = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + router = entry.runtime_data tracked: set = set() @callback diff --git a/homeassistant/components/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index 47ad1f29363..bc537d523eb 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -7,7 +7,6 @@ from typing import Any import attr from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, @@ -18,20 +17,19 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DATA_ASUSWRT, DOMAIN -from .router import AsusWrtRouter +from . import AsusWrtConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME} TO_REDACT_DEV = {ATTR_CONNECTIONS, ATTR_IDENTIFIERS} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: AsusWrtConfigEntry ) -> dict[str, dict[str, Any]]: """Return diagnostics for a config entry.""" data = {"entry": async_redact_data(entry.as_dict(), TO_REDACT)} - router: AsusWrtRouter = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + router = entry.runtime_data # Gather information how this AsusWrt device is represented in Home Assistant device_registry = dr.async_get(hass) diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index ed97b1f6871..1244db34ed5 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging +from types import MappingProxyType from typing import Any from pyasuswrt import AsusWrtError @@ -362,7 +363,7 @@ class AsusWrtRouter: """Add a function to call when router is closed.""" self._on_close.append(func) - def update_options(self, new_options: dict[str, Any]) -> bool: + def update_options(self, new_options: MappingProxyType[str, Any]) -> bool: """Update router options.""" req_reload = False for name, new_opt in new_options.items(): diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 80da4b51f0a..69470882153 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfDataRate, @@ -25,9 +24,8 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import slugify +from . import AsusWrtConfigEntry from .const import ( - DATA_ASUSWRT, - DOMAIN, KEY_COORDINATOR, KEY_SENSORS, SENSORS_BYTES, @@ -173,10 +171,12 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AsusWrtConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensors.""" - router: AsusWrtRouter = hass.data[DOMAIN][entry.entry_id][DATA_ASUSWRT] + router = entry.runtime_data entities = [] for sensor_data in router.sensors_coordinator.values(): diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 40dc59ae90a..eec794896f6 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -2,502 +2,64 @@ from __future__ import annotations -import asyncio -from collections.abc import Callable, Coroutine, Iterable, ValuesView -from datetime import datetime -from itertools import chain -import logging -from typing import Any, ParamSpec, TypeVar +from typing import cast -from aiohttp import ClientError, ClientResponseError -from yalexs.activity import ActivityTypes -from yalexs.const import DEFAULT_BRAND -from yalexs.doorbell import Doorbell, DoorbellDetail +from aiohttp import ClientResponseError +from path import Path from yalexs.exceptions import AugustApiAIOHTTPError -from yalexs.lock import Lock, LockDetail -from yalexs.pubnub_activity import activities_from_pubnub_message -from yalexs.pubnub_async import AugustPubNub, async_create_pubnub -from yalexs_ble import YaleXSBLEDiscovery +from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation +from yalexs.manager.gateway import Config as YaleXSConfig -from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry -from homeassistant.const import CONF_PASSWORD -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .activity import ActivityStream -from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS -from .exceptions import CannotConnect, InvalidAuth, RequireValidation +from .const import DOMAIN, PLATFORMS +from .data import AugustData from .gateway import AugustGateway -from .subscriber import AugustSubscriberMixin from .util import async_create_august_clientsession -_R = TypeVar("_R") -_P = ParamSpec("_P") - -_LOGGER = logging.getLogger(__name__) - -API_CACHED_ATTRS = { - "door_state", - "door_state_datetime", - "lock_status", - "lock_status_datetime", -} -YALEXS_BLE_DOMAIN = "yalexs_ble" - -AugustConfigEntry = ConfigEntry["AugustData"] +type AugustConfigEntry = ConfigEntry[AugustData] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" session = async_create_august_clientsession(hass) - august_gateway = AugustGateway(hass, session) - + august_gateway = AugustGateway(Path(hass.config.config_dir), session) try: - await august_gateway.async_setup(entry.data) - return await async_setup_august(hass, entry, august_gateway) + await async_setup_august(hass, entry, august_gateway) except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to august api") from err except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err: raise ConfigEntryNotReady from err + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True async def async_unload_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool: """Unload a config entry.""" - entry.runtime_data.async_stop() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_setup_august( - hass: HomeAssistant, config_entry: AugustConfigEntry, august_gateway: AugustGateway -) -> bool: + hass: HomeAssistant, entry: AugustConfigEntry, august_gateway: AugustGateway +) -> None: """Set up the August component.""" - - if CONF_PASSWORD in config_entry.data: - # We no longer need to store passwords since we do not - # support YAML anymore - config_data = config_entry.data.copy() - del config_data[CONF_PASSWORD] - hass.config_entries.async_update_entry(config_entry, data=config_data) - + config = cast(YaleXSConfig, entry.data) + await august_gateway.async_setup(config) await august_gateway.async_authenticate() await august_gateway.async_refresh_access_token_if_needed() - - data = config_entry.runtime_data = AugustData(hass, config_entry, august_gateway) + data = entry.runtime_data = AugustData(hass, august_gateway) + entry.async_on_unload( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, data.async_stop) + ) + entry.async_on_unload(data.async_stop) await data.async_setup() - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - - return True - - -@callback -def _async_trigger_ble_lock_discovery( - hass: HomeAssistant, locks_with_offline_keys: list[LockDetail] -) -> None: - """Update keys for the yalexs-ble integration if available.""" - for lock_detail in locks_with_offline_keys: - discovery_flow.async_create_flow( - hass, - YALEXS_BLE_DOMAIN, - context={"source": SOURCE_INTEGRATION_DISCOVERY}, - data=YaleXSBLEDiscovery( - { - "name": lock_detail.device_name, - "address": lock_detail.mac_address, - "serial": lock_detail.serial_number, - "key": lock_detail.offline_key, - "slot": lock_detail.offline_slot, - } - ), - ) - - -class AugustData(AugustSubscriberMixin): - """August data object.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - august_gateway: AugustGateway, - ) -> None: - """Init August data object.""" - super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES) - self._config_entry = config_entry - self._hass = hass - self._august_gateway = august_gateway - self.activity_stream: ActivityStream = None # type: ignore[assignment] - self._api = august_gateway.api - self._device_detail_by_id: dict[str, LockDetail | DoorbellDetail] = {} - self._doorbells_by_id: dict[str, Doorbell] = {} - self._locks_by_id: dict[str, Lock] = {} - self._house_ids: set[str] = set() - self._pubnub_unsub: CALLBACK_TYPE | None = None - - @property - def brand(self) -> str: - """Brand of the device.""" - return self._config_entry.data.get(CONF_BRAND, DEFAULT_BRAND) - - async def async_setup(self) -> None: - """Async setup of august device data and activities.""" - token = self._august_gateway.access_token - # This used to be a gather but it was less reliable with august's recent api changes. - user_data = await self._api.async_get_user(token) - locks = await self._api.async_get_operable_locks(token) - doorbells = await self._api.async_get_doorbells(token) - if not doorbells: - doorbells = [] - if not locks: - locks = [] - - self._doorbells_by_id = {device.device_id: device for device in doorbells} - self._locks_by_id = {device.device_id: device for device in locks} - self._house_ids = {device.house_id for device in chain(locks, doorbells)} - - await self._async_refresh_device_detail_by_ids( - [device.device_id for device in chain(locks, doorbells)] - ) - - # We remove all devices that we are missing - # detail as we cannot determine if they are usable. - # This also allows us to avoid checking for - # detail being None all over the place - - # Currently we know how to feed data to yalexe_ble - # but we do not know how to send it to homekit_controller - # yet - _async_trigger_ble_lock_discovery( - self._hass, - [ - lock_detail - for lock_detail in self._device_detail_by_id.values() - if isinstance(lock_detail, LockDetail) and lock_detail.offline_key - ], - ) - - self._remove_inoperative_locks() - self._remove_inoperative_doorbells() - - pubnub = AugustPubNub() - for device in self._device_detail_by_id.values(): - pubnub.register_device(device) - - self.activity_stream = ActivityStream( - self._hass, self._api, self._august_gateway, self._house_ids, pubnub - ) - await self.activity_stream.async_setup() - pubnub.subscribe(self.async_pubnub_message) - self._pubnub_unsub = async_create_pubnub( - user_data["UserID"], - pubnub, - self.brand, - ) - - if self._locks_by_id: - # Do not prevent setup as the sync can timeout - # but it is not a fatal error as the lock - # will recover automatically when it comes back online. - self._config_entry.async_create_background_task( - self._hass, self._async_initial_sync(), "august-initial-sync" - ) - - async def _async_initial_sync(self) -> None: - """Attempt to request an initial sync.""" - # We don't care if this fails because we only want to wake - # locks that are actually online anyways and they will be - # awake when they come back online - for result in await asyncio.gather( - *[ - self.async_status_async( - device_id, bool(detail.bridge and detail.bridge.hyper_bridge) - ) - for device_id, detail in self._device_detail_by_id.items() - if device_id in self._locks_by_id - ], - return_exceptions=True, - ): - if isinstance(result, Exception) and not isinstance( - result, (TimeoutError, ClientResponseError, CannotConnect) - ): - _LOGGER.warning( - "Unexpected exception during initial sync: %s", - result, - exc_info=result, - ) - - @callback - def async_pubnub_message( - self, device_id: str, date_time: datetime, message: dict[str, Any] - ) -> None: - """Process a pubnub message.""" - device = self.get_device_detail(device_id) - activities = activities_from_pubnub_message(device, date_time, message) - activity_stream = self.activity_stream - if activities and activity_stream.async_process_newer_device_activities( - activities - ): - self.async_signal_device_id_update(device.device_id) - activity_stream.async_schedule_house_id_refresh(device.house_id) - - @callback - def async_stop(self) -> None: - """Stop the subscriptions.""" - if self._pubnub_unsub: - self._pubnub_unsub() - self.activity_stream.async_stop() - - @property - def doorbells(self) -> ValuesView[Doorbell]: - """Return a list of py-august Doorbell objects.""" - return self._doorbells_by_id.values() - - @property - def locks(self) -> ValuesView[Lock]: - """Return a list of py-august Lock objects.""" - return self._locks_by_id.values() - - def get_device_detail(self, device_id: str) -> DoorbellDetail | LockDetail: - """Return the py-august LockDetail or DoorbellDetail object for a device.""" - return self._device_detail_by_id[device_id] - - async def _async_refresh(self, time: datetime) -> None: - await self._async_refresh_device_detail_by_ids(self._subscriptions.keys()) - - async def _async_refresh_device_detail_by_ids( - self, device_ids_list: Iterable[str] - ) -> None: - """Refresh each device in sequence. - - This used to be a gather but it was less reliable with august's - recent api changes. - - The august api has been timing out for some devices so - we want the ones that it isn't timing out for to keep working. - """ - for device_id in device_ids_list: - try: - await self._async_refresh_device_detail_by_id(device_id) - except TimeoutError: - _LOGGER.warning( - "Timed out calling august api during refresh of device: %s", - device_id, - ) - except (ClientResponseError, CannotConnect) as err: - _LOGGER.warning( - "Error from august api during refresh of device: %s", - device_id, - exc_info=err, - ) - - async def refresh_camera_by_id(self, device_id: str) -> None: - """Re-fetch doorbell/camera data from API.""" - await self._async_update_device_detail( - self._doorbells_by_id[device_id], - self._api.async_get_doorbell_detail, - ) - - async def _async_refresh_device_detail_by_id(self, device_id: str) -> None: - if device_id in self._locks_by_id: - if self.activity_stream and self.activity_stream.pubnub.connected: - saved_attrs = _save_live_attrs(self._device_detail_by_id[device_id]) - await self._async_update_device_detail( - self._locks_by_id[device_id], self._api.async_get_lock_detail - ) - if self.activity_stream and self.activity_stream.pubnub.connected: - _restore_live_attrs(self._device_detail_by_id[device_id], saved_attrs) - # keypads are always attached to locks - if ( - device_id in self._device_detail_by_id - and self._device_detail_by_id[device_id].keypad is not None - ): - keypad = self._device_detail_by_id[device_id].keypad - self._device_detail_by_id[keypad.device_id] = keypad - elif device_id in self._doorbells_by_id: - await self._async_update_device_detail( - self._doorbells_by_id[device_id], - self._api.async_get_doorbell_detail, - ) - _LOGGER.debug( - "async_signal_device_id_update (from detail updates): %s", device_id - ) - self.async_signal_device_id_update(device_id) - - async def _async_update_device_detail( - self, - device: Doorbell | Lock, - api_call: Callable[ - [str, str], Coroutine[Any, Any, DoorbellDetail | LockDetail] - ], - ) -> None: - _LOGGER.debug( - "Started retrieving detail for %s (%s)", - device.device_name, - device.device_id, - ) - - try: - self._device_detail_by_id[device.device_id] = await api_call( - self._august_gateway.access_token, device.device_id - ) - except ClientError as ex: - _LOGGER.error( - "Request error trying to retrieve %s details for %s. %s", - device.device_id, - device.device_name, - ex, - ) - _LOGGER.debug( - "Completed retrieving detail for %s (%s)", - device.device_name, - device.device_id, - ) - - def get_device(self, device_id: str) -> Doorbell | Lock | None: - """Get a device by id.""" - return self._locks_by_id.get(device_id) or self._doorbells_by_id.get(device_id) - - def _get_device_name(self, device_id: str) -> str | None: - """Return doorbell or lock name as August has it stored.""" - if device := self.get_device(device_id): - return device.device_name - return None - - async def async_lock(self, device_id: str) -> list[ActivityTypes]: - """Lock the device.""" - return await self._async_call_api_op_requires_bridge( - device_id, - self._api.async_lock_return_activities, - self._august_gateway.access_token, - device_id, - ) - - async def async_status_async(self, device_id: str, hyper_bridge: bool) -> str: - """Request status of the device but do not wait for a response since it will come via pubnub.""" - return await self._async_call_api_op_requires_bridge( - device_id, - self._api.async_status_async, - self._august_gateway.access_token, - device_id, - hyper_bridge, - ) - - async def async_lock_async(self, device_id: str, hyper_bridge: bool) -> str: - """Lock the device but do not wait for a response since it will come via pubnub.""" - return await self._async_call_api_op_requires_bridge( - device_id, - self._api.async_lock_async, - self._august_gateway.access_token, - device_id, - hyper_bridge, - ) - - async def async_unlock(self, device_id: str) -> list[ActivityTypes]: - """Unlock the device.""" - return await self._async_call_api_op_requires_bridge( - device_id, - self._api.async_unlock_return_activities, - self._august_gateway.access_token, - device_id, - ) - - async def async_unlock_async(self, device_id: str, hyper_bridge: bool) -> str: - """Unlock the device but do not wait for a response since it will come via pubnub.""" - return await self._async_call_api_op_requires_bridge( - device_id, - self._api.async_unlock_async, - self._august_gateway.access_token, - device_id, - hyper_bridge, - ) - - async def _async_call_api_op_requires_bridge( - self, - device_id: str, - func: Callable[_P, Coroutine[Any, Any, _R]], - *args: _P.args, - **kwargs: _P.kwargs, - ) -> _R: - """Call an API that requires the bridge to be online and will change the device state.""" - try: - ret = await func(*args, **kwargs) - except AugustApiAIOHTTPError as err: - device_name = self._get_device_name(device_id) - if device_name is None: - device_name = f"DeviceID: {device_id}" - raise HomeAssistantError(f"{device_name}: {err}") from err - - return ret - - def _remove_inoperative_doorbells(self) -> None: - for doorbell in list(self.doorbells): - device_id = doorbell.device_id - if self._device_detail_by_id.get(device_id): - continue - _LOGGER.info( - ( - "The doorbell %s could not be setup because the system could not" - " fetch details about the doorbell" - ), - doorbell.device_name, - ) - del self._doorbells_by_id[device_id] - - def _remove_inoperative_locks(self) -> None: - # Remove non-operative locks as there must - # be a bridge (August Connect) for them to - # be usable - for lock in list(self.locks): - device_id = lock.device_id - lock_detail = self._device_detail_by_id.get(device_id) - if lock_detail is None: - _LOGGER.info( - ( - "The lock %s could not be setup because the system could not" - " fetch details about the lock" - ), - lock.device_name, - ) - elif lock_detail.bridge is None: - _LOGGER.info( - ( - "The lock %s could not be setup because it does not have a" - " bridge (Connect)" - ), - lock.device_name, - ) - del self._device_detail_by_id[device_id] - # Bridge may come back online later so we still add the device since we will - # have a pubnub subscription to tell use when it recovers - else: - continue - del self._locks_by_id[device_id] - - -def _save_live_attrs(lock_detail: DoorbellDetail | LockDetail) -> dict[str, Any]: - """Store the attributes that the lock detail api may have an invalid cache for. - - Since we are connected to pubnub we may have more current data - then the api so we want to restore the most current data after - updating battery state etc. - """ - return {attr: getattr(lock_detail, attr) for attr in API_CACHED_ATTRS} - - -def _restore_live_attrs( - lock_detail: DoorbellDetail | LockDetail, attrs: dict[str, Any] -) -> None: - """Restore the non-cache attributes after a cached update.""" - for attr, value in attrs.items(): - setattr(lock_detail, attr, value) - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: AugustConfigEntry, device_entry: dr.DeviceEntry diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py deleted file mode 100644 index ee180ab5480..00000000000 --- a/homeassistant/components/august/activity.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Consume the august activity stream.""" - -from __future__ import annotations - -from datetime import datetime -from functools import partial -import logging -from time import monotonic - -from aiohttp import ClientError -from yalexs.activity import Activity, ActivityType -from yalexs.api_async import ApiAsync -from yalexs.pubnub_async import AugustPubNub -from yalexs.util import get_latest_activity - -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.event import async_call_later -from homeassistant.util.dt import utcnow - -from .const import ACTIVITY_UPDATE_INTERVAL -from .gateway import AugustGateway -from .subscriber import AugustSubscriberMixin - -_LOGGER = logging.getLogger(__name__) - -ACTIVITY_STREAM_FETCH_LIMIT = 10 -ACTIVITY_CATCH_UP_FETCH_LIMIT = 2500 - -INITIAL_LOCK_RESYNC_TIME = 60 - -# If there is a storm of activity (ie lock, unlock, door open, door close, etc) -# we want to debounce the updates so we don't hammer the activity api too much. -ACTIVITY_DEBOUNCE_COOLDOWN = 4 - - -@callback -def _async_cancel_future_scheduled_updates(cancels: list[CALLBACK_TYPE]) -> None: - """Cancel future scheduled updates.""" - for cancel in cancels: - cancel() - cancels.clear() - - -class ActivityStream(AugustSubscriberMixin): - """August activity stream handler.""" - - def __init__( - self, - hass: HomeAssistant, - api: ApiAsync, - august_gateway: AugustGateway, - house_ids: set[str], - pubnub: AugustPubNub, - ) -> None: - """Init August activity stream object.""" - super().__init__(hass, ACTIVITY_UPDATE_INTERVAL) - self._hass = hass - self._schedule_updates: dict[str, list[CALLBACK_TYPE]] = {} - self._august_gateway = august_gateway - self._api = api - self._house_ids = house_ids - self._latest_activities: dict[str, dict[ActivityType, Activity]] = {} - self._did_first_update = False - self.pubnub = pubnub - self._update_debounce: dict[str, Debouncer] = {} - self._update_debounce_jobs: dict[str, HassJob] = {} - self._start_time: float | None = None - - @callback - def _async_update_house_id_later(self, debouncer: Debouncer, _: datetime) -> None: - """Call a debouncer from async_call_later.""" - debouncer.async_schedule_call() - - async def async_setup(self) -> None: - """Token refresh check and catch up the activity stream.""" - self._start_time = monotonic() - update_debounce = self._update_debounce - update_debounce_jobs = self._update_debounce_jobs - for house_id in self._house_ids: - debouncer = Debouncer( - self._hass, - _LOGGER, - cooldown=ACTIVITY_DEBOUNCE_COOLDOWN, - immediate=True, - function=partial(self._async_update_house_id, house_id), - background=True, - ) - update_debounce[house_id] = debouncer - update_debounce_jobs[house_id] = HassJob( - partial(self._async_update_house_id_later, debouncer), - f"debounced august activity update for {house_id}", - cancel_on_shutdown=True, - ) - - await self._async_refresh(utcnow()) - self._did_first_update = True - - @callback - def async_stop(self) -> None: - """Cleanup any debounces.""" - for debouncer in self._update_debounce.values(): - debouncer.async_cancel() - for cancels in self._schedule_updates.values(): - _async_cancel_future_scheduled_updates(cancels) - - def get_latest_device_activity( - self, device_id: str, activity_types: set[ActivityType] - ) -> Activity | None: - """Return latest activity that is one of the activity_types.""" - if not (latest_device_activities := self._latest_activities.get(device_id)): - return None - - latest_activity: Activity | None = None - - for activity_type in activity_types: - if activity := latest_device_activities.get(activity_type): - if ( - latest_activity - and activity.activity_start_time - <= latest_activity.activity_start_time - ): - continue - latest_activity = activity - - return latest_activity - - async def _async_refresh(self, time: datetime) -> None: - """Update the activity stream from August.""" - # This is the only place we refresh the api token - await self._august_gateway.async_refresh_access_token_if_needed() - if self.pubnub.connected: - _LOGGER.debug("Skipping update because pubnub is connected") - return - _LOGGER.debug("Start retrieving device activities") - # Await in sequence to avoid hammering the API - for debouncer in self._update_debounce.values(): - await debouncer.async_call() - - @callback - def async_schedule_house_id_refresh(self, house_id: str) -> None: - """Update for a house activities now and once in the future.""" - if future_updates := self._schedule_updates.setdefault(house_id, []): - _async_cancel_future_scheduled_updates(future_updates) - - debouncer = self._update_debounce[house_id] - debouncer.async_schedule_call() - - # Schedule two updates past the debounce time - # to ensure we catch the case where the activity - # api does not update right away and we need to poll - # it again. Sometimes the lock operator or a doorbell - # will not show up in the activity stream right away. - # Only do additional polls if we are past - # the initial lock resync time to avoid a storm - # of activity at setup. - if ( - not self._start_time - or monotonic() - self._start_time < INITIAL_LOCK_RESYNC_TIME - ): - _LOGGER.debug( - "Skipping additional updates due to ongoing initial lock resync time" - ) - return - - _LOGGER.debug("Scheduling additional updates for house id %s", house_id) - job = self._update_debounce_jobs[house_id] - for step in (1, 2): - future_updates.append( - async_call_later( - self._hass, - (step * ACTIVITY_DEBOUNCE_COOLDOWN) + 0.1, - job, - ) - ) - - async def _async_update_house_id(self, house_id: str) -> None: - """Update device activities for a house.""" - if self._did_first_update: - limit = ACTIVITY_STREAM_FETCH_LIMIT - else: - limit = ACTIVITY_CATCH_UP_FETCH_LIMIT - - _LOGGER.debug("Updating device activity for house id %s", house_id) - try: - activities = await self._api.async_get_house_activities( - self._august_gateway.access_token, house_id, limit=limit - ) - except ClientError as ex: - _LOGGER.error( - "Request error trying to retrieve activity for house id %s: %s", - house_id, - ex, - ) - # Make sure we process the next house if one of them fails - return - - _LOGGER.debug( - "Completed retrieving device activities for house id %s", house_id - ) - for device_id in self.async_process_newer_device_activities(activities): - _LOGGER.debug( - "async_signal_device_id_update (from activity stream): %s", - device_id, - ) - self.async_signal_device_id_update(device_id) - - def async_process_newer_device_activities( - self, activities: list[Activity] - ) -> set[str]: - """Process activities if they are newer than the last one.""" - updated_device_ids = set() - latest_activities = self._latest_activities - for activity in activities: - device_id = activity.device_id - activity_type = activity.activity_type - device_activities = latest_activities.setdefault(device_id, {}) - # Ignore activities that are older than the latest one unless it is a non - # locking or unlocking activity with the exact same start time. - last_activity = device_activities.get(activity_type) - # The activity stream can have duplicate activities. So we need - # to call get_latest_activity to figure out if if the activity - # is actually newer than the last one. - latest_activity = get_latest_activity(activity, last_activity) - if latest_activity != activity: - continue - - device_activities[activity_type] = activity - updated_device_ids.add(device_id) - - return updated_device_ids diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index baf78bbd445..415b77d3fe9 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -5,16 +5,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial import logging -from yalexs.activity import ( - ACTION_DOORBELL_CALL_MISSED, - SOURCE_PUBNUB, - Activity, - ActivityType, -) -from yalexs.doorbell import Doorbell, DoorbellDetail -from yalexs.lock import Lock, LockDetail, LockDoorStatus +from yalexs.activity import ACTION_DOORBELL_CALL_MISSED, Activity, ActivityType +from yalexs.doorbell import DoorbellDetail +from yalexs.lock import LockDetail, LockDoorStatus +from yalexs.manager.const import ACTIVITY_UPDATE_INTERVAL from yalexs.util import update_lock_detail_from_activity from homeassistant.components.binary_sensor import ( @@ -28,8 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from . import AugustConfigEntry, AugustData -from .const import ACTIVITY_UPDATE_INTERVAL -from .entity import AugustEntityMixin +from .entity import AugustDescriptionEntity _LOGGER = logging.getLogger(__name__) @@ -51,45 +47,26 @@ def _retrieve_online_state( return detail.bridge_is_online -def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: - assert data.activity_stream is not None - latest = data.activity_stream.get_latest_device_activity( - detail.device_id, {ActivityType.DOORBELL_MOTION} - ) - - if latest is None: - return False - - return _activity_time_based_state(latest) +def _retrieve_time_based_state( + activities: set[ActivityType], data: AugustData, detail: DoorbellDetail +) -> bool: + """Get the latest state of the sensor.""" + stream = data.activity_stream + if latest := stream.get_latest_device_activity(detail.device_id, activities): + return _activity_time_based_state(latest) + return False -def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> bool: - assert data.activity_stream is not None - latest = data.activity_stream.get_latest_device_activity( - detail.device_id, {ActivityType.DOORBELL_IMAGE_CAPTURE} - ) - - if latest is None: - return False - - return _activity_time_based_state(latest) +_RING_ACTIVITIES = {ActivityType.DOORBELL_DING} def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail | LockDetail) -> bool: - assert data.activity_stream is not None - latest = data.activity_stream.get_latest_device_activity( - detail.device_id, {ActivityType.DOORBELL_DING} - ) - - if latest is None: - return False - - if ( - data.activity_stream.pubnub.connected - and latest.action == ACTION_DOORBELL_CALL_MISSED + stream = data.activity_stream + latest = stream.get_latest_device_activity(detail.device_id, _RING_ACTIVITIES) + if latest is None or ( + data.push_updates_connected and latest.action == ACTION_DOORBELL_CALL_MISSED ): return False - return _activity_time_based_state(latest) @@ -122,13 +99,15 @@ SENSOR_TYPES_VIDEO_DOORBELL = ( AugustDoorbellBinarySensorEntityDescription( key="motion", device_class=BinarySensorDeviceClass.MOTION, - value_fn=_retrieve_motion_state, + value_fn=partial(_retrieve_time_based_state, {ActivityType.DOORBELL_MOTION}), is_time_based=True, ), AugustDoorbellBinarySensorEntityDescription( key="image capture", translation_key="image_capture", - value_fn=_retrieve_image_capture_state, + value_fn=partial( + _retrieve_time_based_state, {ActivityType.DOORBELL_IMAGE_CAPTURE} + ), is_time_based=True, ), AugustDoorbellBinarySensorEntityDescription( @@ -160,106 +139,51 @@ async def async_setup_entry( data = config_entry.runtime_data entities: list[BinarySensorEntity] = [] - for door in data.locks: - detail = data.get_device_detail(door.device_id) - if not detail.doorsense: - _LOGGER.debug( - ( - "Not adding sensor class door for lock %s because it does not have" - " doorsense" - ), - door.device_name, - ) - continue - - _LOGGER.debug("Adding sensor class door for %s", door.device_name) - entities.append(AugustDoorBinarySensor(data, door, SENSOR_TYPE_DOOR)) + for lock in data.locks: + detail = data.get_device_detail(lock.device_id) + if detail.doorsense: + entities.append(AugustDoorBinarySensor(data, lock, SENSOR_TYPE_DOOR)) if detail.doorbell: - for description in SENSOR_TYPES_DOORBELL: - _LOGGER.debug( - "Adding doorbell sensor class %s for %s", - description.device_class, - door.device_name, - ) - entities.append(AugustDoorbellBinarySensor(data, door, description)) + entities.extend( + AugustDoorbellBinarySensor(data, lock, description) + for description in SENSOR_TYPES_DOORBELL + ) for doorbell in data.doorbells: - for description in SENSOR_TYPES_DOORBELL + SENSOR_TYPES_VIDEO_DOORBELL: - _LOGGER.debug( - "Adding doorbell sensor class %s for %s", - description.device_class, - doorbell.device_name, - ) - entities.append(AugustDoorbellBinarySensor(data, doorbell, description)) + entities.extend( + AugustDoorbellBinarySensor(data, doorbell, description) + for description in SENSOR_TYPES_DOORBELL + SENSOR_TYPES_VIDEO_DOORBELL + ) async_add_entities(entities) -class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): +class AugustDoorBinarySensor(AugustDescriptionEntity, BinarySensorEntity): """Representation of an August Door binary sensor.""" _attr_device_class = BinarySensorDeviceClass.DOOR - - def __init__( - self, - data: AugustData, - device: Lock, - description: BinarySensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(data, device) - self.entity_description = description - self._data = data - self._device = device - self._attr_unique_id = f"{self._device_id}_{description.key}" + description: BinarySensorEntityDescription @callback def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" - assert self._data.activity_stream is not None - door_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, {ActivityType.DOOR_OPERATION} - ) - - if door_activity is not None: + if door_activity := self._get_latest({ActivityType.DOOR_OPERATION}): update_lock_detail_from_activity(self._detail, door_activity) - # If the source is pubnub the lock must be online since its a live update - if door_activity.source == SOURCE_PUBNUB: + if door_activity.was_pushed: self._detail.set_online(True) - bridge_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, {ActivityType.BRIDGE_OPERATION} - ) - - if bridge_activity is not None: + if bridge_activity := self._get_latest({ActivityType.BRIDGE_OPERATION}): update_lock_detail_from_activity(self._detail, bridge_activity) self._attr_available = self._detail.bridge_is_online self._attr_is_on = self._detail.door_state == LockDoorStatus.OPEN - async def async_added_to_hass(self) -> None: - """Set the initial state when adding to hass.""" - self._update_from_data() - await super().async_added_to_hass() - -class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): +class AugustDoorbellBinarySensor(AugustDescriptionEntity, BinarySensorEntity): """Representation of an August binary sensor.""" entity_description: AugustDoorbellBinarySensorEntityDescription - - def __init__( - self, - data: AugustData, - device: Doorbell | Lock, - description: AugustDoorbellBinarySensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(data, device) - self.entity_description = description - self._check_for_off_update_listener: Callable[[], None] | None = None - self._data = data - self._attr_unique_id = f"{self._device_id}_{description.key}" + _check_for_off_update_listener: Callable[[], None] | None = None @callback def _update_from_data(self) -> None: @@ -273,22 +197,21 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): else: self._attr_available = True + @callback + def _async_scheduled_update(self, now: datetime) -> None: + """Timer callback for sensor update.""" + self._check_for_off_update_listener = None + self._update_from_data() + if not self.is_on: + self.async_write_ha_state() + def _schedule_update_to_recheck_turn_off_sensor(self) -> None: """Schedule an update to recheck the sensor to see if it is ready to turn off.""" # If the sensor is already off there is nothing to do if not self.is_on: return - - @callback - def _scheduled_update(now: datetime) -> None: - """Timer callback for sensor update.""" - self._check_for_off_update_listener = None - self._update_from_data() - if not self.is_on: - self.async_write_ha_state() - self._check_for_off_update_listener = async_call_later( - self.hass, TIME_TO_RECHECK_DETECTION.total_seconds(), _scheduled_update + self.hass, TIME_TO_RECHECK_DETECTION, self._async_scheduled_update ) def _cancel_any_pending_updates(self) -> None: @@ -299,11 +222,6 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self._check_for_off_update_listener() self._check_for_off_update_listener = None - async def async_added_to_hass(self) -> None: - """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed.""" - self._update_from_data() - await super().async_added_to_hass() - async def async_will_remove_from_hass(self) -> None: """When removing cancel any scheduled updates.""" self._cancel_any_pending_updates() diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index d7aefca5d3c..406475db601 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -1,12 +1,10 @@ """Support for August buttons.""" -from yalexs.lock import Lock - from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AugustConfigEntry, AugustData +from . import AugustConfigEntry from .entity import AugustEntityMixin @@ -17,7 +15,7 @@ async def async_setup_entry( ) -> None: """Set up August lock wake buttons.""" data = config_entry.runtime_data - async_add_entities(AugustWakeLockButton(data, lock) for lock in data.locks) + async_add_entities(AugustWakeLockButton(data, lock, "wake") for lock in data.locks) class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): @@ -25,11 +23,6 @@ class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): _attr_translation_key = "wake" - def __init__(self, data: AugustData, device: Lock) -> None: - """Initialize the lock wake button.""" - super().__init__(data, device) - self._attr_unique_id = f"{self._device_id}_wake" - async def async_press(self) -> None: """Wake the device.""" await self._data.async_status_async(self._device_id, self._hyper_bridge) diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 4c56502e6c7..4e569e2a91e 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -6,8 +6,7 @@ import logging from aiohttp import ClientSession from yalexs.activity import ActivityType -from yalexs.const import Brand -from yalexs.doorbell import ContentTokenExpired, Doorbell +from yalexs.doorbell import Doorbell from yalexs.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera @@ -43,31 +42,25 @@ class AugustCamera(AugustEntityMixin, Camera): """An implementation of an August security camera.""" _attr_translation_key = "camera" + _attr_motion_detection_enabled = True + _attr_brand = DEFAULT_NAME + _image_url: str | None = None + _image_content: bytes | None = None def __init__( self, data: AugustData, device: Doorbell, session: ClientSession, timeout: int ) -> None: """Initialize an August security camera.""" - super().__init__(data, device) + super().__init__(data, device, "camera") self._timeout = timeout self._session = session - self._image_url = None - self._content_token = None - self._image_content = None - self._attr_unique_id = f"{self._device_id:s}_camera" - self._attr_motion_detection_enabled = True - self._attr_brand = DEFAULT_NAME + self._attr_model = self._detail.model @property def is_recording(self) -> bool: """Return true if the device is recording.""" return self._device.has_subscription - @property - def model(self) -> str | None: - """Return the camera model.""" - return self._detail.model - async def _async_update(self): """Update device.""" _LOGGER.debug("async_update called %s", self._detail.device_name) @@ -77,11 +70,9 @@ class AugustCamera(AugustEntityMixin, Camera): @callback def _update_from_data(self) -> None: """Get the latest state of the sensor.""" - doorbell_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, - {ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_IMAGE_CAPTURE}, - ) - if doorbell_activity is not None: + if doorbell_activity := self._get_latest( + {ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_IMAGE_CAPTURE} + ): update_doorbell_image_from_activity(self._detail, doorbell_activity) async def async_camera_image( @@ -91,24 +82,9 @@ class AugustCamera(AugustEntityMixin, Camera): self._update_from_data() if self._image_url is not self._detail.image_url: - self._image_url = self._detail.image_url - self._content_token = self._detail.content_token or self._content_token - _LOGGER.debug( - "calling doorbell async_get_doorbell_image, %s", - self._detail.device_name, + self._image_content = await self._data.async_get_doorbell_image( + self._device_id, self._session, timeout=self._timeout ) - try: - self._image_content = await self._detail.async_get_doorbell_image( - self._session, timeout=self._timeout - ) - except ContentTokenExpired: - if self._data.brand == Brand.YALE_HOME: - _LOGGER.debug( - "Error fetching camera image, updating content-token from api to retry" - ) - await self._async_update() - self._image_content = await self._detail.async_get_doorbell_image( - self._session, timeout=self._timeout - ) + self._image_url = self._detail.image_url return self._image_content diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index e6803da2ae0..18c15ad61a1 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -3,12 +3,14 @@ from collections.abc import Mapping from dataclasses import dataclass import logging +from pathlib import Path from typing import Any import aiohttp import voluptuous as vol from yalexs.authenticator import ValidationResult from yalexs.const import BRANDS, DEFAULT_BRAND +from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -23,7 +25,6 @@ from .const import ( LOGIN_METHODS, VERIFICATION_CODE_KEY, ) -from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway from .util import async_create_august_clientsession @@ -65,7 +66,7 @@ async def async_validate_input( } -@dataclass +@dataclass(slots=True) class ValidateResult: """Result from validation.""" @@ -164,7 +165,9 @@ class AugustConfigFlow(ConfigFlow, domain=DOMAIN): if self._august_gateway is not None: return self._august_gateway self._aiohttp_session = async_create_august_clientsession(self.hass) - self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) + self._august_gateway = AugustGateway( + Path(self.hass.config.config_dir), self._aiohttp_session + ) return self._august_gateway @callback @@ -254,7 +257,7 @@ class AugustConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except RequireValidation: validation_required = True - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: _LOGGER.exception("Unexpected exception") errors["base"] = "unhandled" description_placeholders = {"error": str(ex)} diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 6aa033c62b2..7d7ff1854ed 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -1,7 +1,5 @@ """Constants for August devices.""" -from datetime import timedelta - from homeassistant.const import Platform DEFAULT_TIMEOUT = 25 @@ -37,15 +35,6 @@ ATTR_OPERATION_KEYPAD = "keypad" ATTR_OPERATION_MANUAL = "manual" ATTR_OPERATION_TAG = "tag" -# Limit battery, online, and hardware updates to hourly -# in order to reduce the number of api requests and -# avoid hitting rate limits -MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=24) - -# Activity needs to be checked more frequently as the -# doorbell motion and rings are included here -ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10) - LOGIN_METHODS = ["phone", "email"] DEFAULT_LOGIN_METHOD = "email" diff --git a/homeassistant/components/august/data.py b/homeassistant/components/august/data.py new file mode 100644 index 00000000000..66ddfeedfde --- /dev/null +++ b/homeassistant/components/august/data.py @@ -0,0 +1,52 @@ +"""Support for August devices.""" + +from __future__ import annotations + +from yalexs.lock import LockDetail +from yalexs.manager.data import YaleXSData +from yalexs_ble import YaleXSBLEDiscovery + +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import discovery_flow + +from .gateway import AugustGateway + +YALEXS_BLE_DOMAIN = "yalexs_ble" + + +@callback +def _async_trigger_ble_lock_discovery( + hass: HomeAssistant, locks_with_offline_keys: list[LockDetail] +) -> None: + """Update keys for the yalexs-ble integration if available.""" + for lock_detail in locks_with_offline_keys: + discovery_flow.async_create_flow( + hass, + YALEXS_BLE_DOMAIN, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, + data=YaleXSBLEDiscovery( + { + "name": lock_detail.device_name, + "address": lock_detail.mac_address, + "serial": lock_detail.serial_number, + "key": lock_detail.offline_key, + "slot": lock_detail.offline_slot, + } + ), + ) + + +class AugustData(YaleXSData): + """August data object.""" + + def __init__(self, hass: HomeAssistant, august_gateway: AugustGateway) -> None: + """Init August data object.""" + self._hass = hass + super().__init__(august_gateway, HomeAssistantError) + + @callback + def async_offline_key_discovered(self, detail: LockDetail) -> None: + """Handle offline key discovery.""" + _async_trigger_ble_lock_discovery(self._hass, [detail]) diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 47cb966bdc1..babf5c587fb 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -2,7 +2,9 @@ from abc import abstractmethod +from yalexs.activity import Activity, ActivityType from yalexs.doorbell import Doorbell, DoorbellDetail +from yalexs.keypad import KeypadDetail from yalexs.lock import Lock, LockDetail from yalexs.util import get_configuration_url @@ -10,7 +12,7 @@ from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from . import DOMAIN, AugustData from .const import MANUFACTURER @@ -24,12 +26,17 @@ class AugustEntityMixin(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, data: AugustData, device: Doorbell | Lock) -> None: + def __init__( + self, data: AugustData, device: Doorbell | Lock | KeypadDetail, unique_id: str + ) -> None: """Initialize an August device.""" super().__init__() self._data = data + self._stream = data.activity_stream self._device = device detail = self._detail + self._device_id = device.device_id + self._attr_unique_id = f"{device.device_id}_{unique_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer=MANUFACTURER, @@ -42,10 +49,6 @@ class AugustEntityMixin(Entity): if isinstance(detail, LockDetail) and (mac := detail.mac_address): self._attr_device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_BLUETOOTH, mac)} - @property - def _device_id(self) -> str: - return self._device.device_id - @property def _detail(self) -> DoorbellDetail | LockDetail: return self._data.get_device_detail(self._device.device_id) @@ -55,6 +58,11 @@ class AugustEntityMixin(Entity): """Check if the lock has a paired hyper bridge.""" return bool(self._detail.bridge and self._detail.bridge.hyper_bridge) + @callback + def _get_latest(self, activity_types: set[ActivityType]) -> Activity | None: + """Get the latest activity for the device.""" + return self._stream.get_latest_device_activity(self._device_id, activity_types) + @callback def _update_from_data_and_write_state(self) -> None: self._update_from_data() @@ -72,10 +80,25 @@ class AugustEntityMixin(Entity): ) ) self.async_on_remove( - self._data.activity_stream.async_subscribe_device_id( + self._stream.async_subscribe_device_id( self._device_id, self._update_from_data_and_write_state ) ) + self._update_from_data() + + +class AugustDescriptionEntity(AugustEntityMixin): + """An August entity with a description.""" + + def __init__( + self, + data: AugustData, + device: Doorbell | Lock | KeypadDetail, + description: EntityDescription, + ) -> None: + """Initialize an August entity with a description.""" + super().__init__(data, device, description.key) + self.entity_description = description def _remove_device_types(name: str, device_types: list[str]) -> str: diff --git a/homeassistant/components/august/exceptions.py b/homeassistant/components/august/exceptions.py deleted file mode 100644 index edd418c9519..00000000000 --- a/homeassistant/components/august/exceptions.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Shared exceptions for the august integration.""" - -from homeassistant import exceptions - - -class RequireValidation(exceptions.HomeAssistantError): - """Error to indicate we require validation (2fa).""" - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 63bc085b811..2c6ad739bdc 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -1,56 +1,23 @@ """Handle August connection setup and authentication.""" -import asyncio -from collections.abc import Mapping -from http import HTTPStatus -import logging -import os from typing import Any -from aiohttp import ClientError, ClientResponseError, ClientSession -from yalexs.api_async import ApiAsync -from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync -from yalexs.authenticator_common import Authentication from yalexs.const import DEFAULT_BRAND -from yalexs.exceptions import AugustApiAIOHTTPError +from yalexs.manager.gateway import Gateway -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_USERNAME from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, CONF_BRAND, CONF_INSTALL_ID, CONF_LOGIN_METHOD, - DEFAULT_AUGUST_CONFIG_FILE, - DEFAULT_TIMEOUT, - VERIFICATION_CODE_KEY, ) -from .exceptions import CannotConnect, InvalidAuth, RequireValidation - -_LOGGER = logging.getLogger(__name__) -class AugustGateway: +class AugustGateway(Gateway): """Handle the connection to August.""" - api: ApiAsync - authenticator: AuthenticatorAsync - authentication: Authentication - _access_token_cache_file: str - - def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: - """Init the connection.""" - self._aiohttp_session = aiohttp_session - self._token_refresh_lock = asyncio.Lock() - self._hass: HomeAssistant = hass - self._config: Mapping[str, Any] | None = None - - @property - def access_token(self) -> str: - """Access token for the api.""" - return self.authentication.access_token - def config_entry(self) -> dict[str, Any]: """Config entry.""" assert self._config is not None @@ -61,101 +28,3 @@ class AugustGateway: CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file, } - - @callback - def async_configure_access_token_cache_file( - self, username: str, access_token_cache_file: str | None - ) -> str: - """Configure the access token cache file.""" - file = access_token_cache_file or f".{username}{DEFAULT_AUGUST_CONFIG_FILE}" - self._access_token_cache_file = file - return self._hass.config.path(file) - - async def async_setup(self, conf: Mapping[str, Any]) -> None: - """Create the api and authenticator objects.""" - if conf.get(VERIFICATION_CODE_KEY): - return - - access_token_cache_file_path = self.async_configure_access_token_cache_file( - conf[CONF_USERNAME], conf.get(CONF_ACCESS_TOKEN_CACHE_FILE) - ) - self._config = conf - - self.api = ApiAsync( - self._aiohttp_session, - timeout=self._config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - brand=self._config.get(CONF_BRAND, DEFAULT_BRAND), - ) - - self.authenticator = AuthenticatorAsync( - self.api, - self._config[CONF_LOGIN_METHOD], - self._config[CONF_USERNAME], - self._config.get(CONF_PASSWORD, ""), - install_id=self._config.get(CONF_INSTALL_ID), - access_token_cache_file=access_token_cache_file_path, - ) - - await self.authenticator.async_setup_authentication() - - async def async_authenticate(self) -> Authentication: - """Authenticate with the details provided to setup.""" - try: - self.authentication = await self.authenticator.async_authenticate() - if self.authentication.state == AuthenticationState.AUTHENTICATED: - # Call the locks api to verify we are actually - # authenticated because we can be authenticated - # by have no access - await self.api.async_get_operable_locks(self.access_token) - except AugustApiAIOHTTPError as ex: - if ex.auth_failed: - raise InvalidAuth from ex - raise CannotConnect from ex - except ClientResponseError as ex: - if ex.status == HTTPStatus.UNAUTHORIZED: - raise InvalidAuth from ex - - raise CannotConnect from ex - except ClientError as ex: - _LOGGER.error("Unable to connect to August service: %s", str(ex)) - raise CannotConnect from ex - - if self.authentication.state == AuthenticationState.BAD_PASSWORD: - raise InvalidAuth - - if self.authentication.state == AuthenticationState.REQUIRES_VALIDATION: - raise RequireValidation - - if self.authentication.state != AuthenticationState.AUTHENTICATED: - _LOGGER.error("Unknown authentication state: %s", self.authentication.state) - raise InvalidAuth - - return self.authentication - - async def async_reset_authentication(self) -> None: - """Remove the cache file.""" - await self._hass.async_add_executor_job(self._reset_authentication) - - def _reset_authentication(self) -> None: - """Remove the cache file.""" - path = self._hass.config.path(self._access_token_cache_file) - if os.path.exists(path): - os.unlink(path) - - async def async_refresh_access_token_if_needed(self) -> None: - """Refresh the august access token if needed.""" - if not self.authenticator.should_refresh(): - return - async with self._token_refresh_lock: - refreshed_authentication = ( - await self.authenticator.async_refresh_access_token(force=False) - ) - _LOGGER.info( - ( - "Refreshed august access token. The old token expired at %s, and" - " the new token expires at %s" - ), - self.authentication.access_token_expires, - refreshed_authentication.access_token_expires, - ) - self.authentication = refreshed_authentication diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 5a07a5de272..5382c710229 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -7,11 +7,11 @@ import logging from typing import Any from aiohttp import ClientResponseError -from yalexs.activity import SOURCE_PUBNUB, ActivityType, ActivityTypes +from yalexs.activity import ActivityType, ActivityTypes from yalexs.lock import Lock, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity -from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity +from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -40,26 +40,31 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): """Representation of an August lock.""" _attr_name = None + _lock_status: LockStatus | None = None def __init__(self, data: AugustData, device: Lock) -> None: """Initialize the lock.""" - super().__init__(data, device) - self._lock_status = None - self._attr_unique_id = f"{self._device_id:s}_lock" - self._update_from_data() + super().__init__(data, device, "lock") + if self._detail.unlatch_supported: + self._attr_supported_features = LockEntityFeature.OPEN async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - assert self._data.activity_stream is not None - if self._data.activity_stream.pubnub.connected: + if self._data.push_updates_connected: await self._data.async_lock_async(self._device_id, self._hyper_bridge) return await self._call_lock_operation(self._data.async_lock) + async def async_open(self, **kwargs: Any) -> None: + """Open/unlatch the device.""" + if self._data.push_updates_connected: + await self._data.async_unlatch_async(self._device_id, self._hyper_bridge) + return + await self._call_lock_operation(self._data.async_unlatch) + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - assert self._data.activity_stream is not None - if self._data.activity_stream.pubnub.connected: + if self._data.push_updates_connected: await self._data.async_unlock_async(self._device_id, self._hyper_bridge) return await self._call_lock_operation(self._data.async_unlock) @@ -97,53 +102,38 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): @callback def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" - activity_stream = self._data.activity_stream - device_id = self._device_id - if lock_activity := activity_stream.get_latest_device_activity( - device_id, - {ActivityType.LOCK_OPERATION}, - ): + detail = self._detail + if lock_activity := self._get_latest({ActivityType.LOCK_OPERATION}): self._attr_changed_by = lock_activity.operated_by - - lock_activity_without_operator = activity_stream.get_latest_device_activity( - device_id, - {ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR}, + lock_activity_without_operator = self._get_latest( + {ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR} ) - if latest_activity := get_latest_activity( lock_activity_without_operator, lock_activity ): - if latest_activity.source == SOURCE_PUBNUB: - # If the source is pubnub the lock must be online since its a live update + if latest_activity.was_pushed: self._detail.set_online(True) - update_lock_detail_from_activity(self._detail, latest_activity) + update_lock_detail_from_activity(detail, latest_activity) - bridge_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, {ActivityType.BRIDGE_OPERATION} - ) - - if bridge_activity is not None: - update_lock_detail_from_activity(self._detail, bridge_activity) + if bridge_activity := self._get_latest({ActivityType.BRIDGE_OPERATION}): + update_lock_detail_from_activity(detail, bridge_activity) self._update_lock_status_from_detail() - if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN: + lock_status = self._lock_status + if lock_status is None or lock_status is LockStatus.UNKNOWN: self._attr_is_locked = None else: - self._attr_is_locked = self._lock_status is LockStatus.LOCKED - - self._attr_is_jammed = self._lock_status is LockStatus.JAMMED - self._attr_is_locking = self._lock_status is LockStatus.LOCKING - self._attr_is_unlocking = self._lock_status in ( + self._attr_is_locked = lock_status is LockStatus.LOCKED + self._attr_is_jammed = lock_status is LockStatus.JAMMED + self._attr_is_locking = lock_status is LockStatus.LOCKING + self._attr_is_unlocking = lock_status in ( LockStatus.UNLOCKING, LockStatus.UNLATCHING, ) - - self._attr_extra_state_attributes = { - ATTR_BATTERY_LEVEL: self._detail.battery_level - } - if self._detail.keypad is not None: + self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: detail.battery_level} + if keypad := detail.keypad: self._attr_extra_state_attributes["keypad_battery_level"] = ( - self._detail.keypad.battery_level + keypad.battery_level ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index f85e75664eb..13658e7401d 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==3.1.0", "yalexs-ble==2.4.2"] + "requirements": ["yalexs==6.4.0", "yalexs-ble==2.4.2"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index c1dc6620f81..7a4c1a92358 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -4,13 +4,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -import logging from typing import Any, Generic, TypeVar, cast from yalexs.activity import ActivityType, LockOperationActivity from yalexs.doorbell import Doorbell from yalexs.keypad import KeypadDetail -from yalexs.lock import Lock, LockDetail +from yalexs.lock import LockDetail from homeassistant.components.sensor import ( RestoreSensor, @@ -26,10 +25,9 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AugustConfigEntry, AugustData +from . import AugustConfigEntry from .const import ( ATTR_OPERATION_AUTORELOCK, ATTR_OPERATION_KEYPAD, @@ -37,7 +35,6 @@ from .const import ( ATTR_OPERATION_METHOD, ATTR_OPERATION_REMOTE, ATTR_OPERATION_TAG, - DOMAIN, OPERATION_METHOD_AUTORELOCK, OPERATION_METHOD_KEYPAD, OPERATION_METHOD_MANUAL, @@ -45,9 +42,7 @@ from .const import ( OPERATION_METHOD_REMOTE, OPERATION_METHOD_TAG, ) -from .entity import AugustEntityMixin - -_LOGGER = logging.getLogger(__name__) +from .entity import AugustDescriptionEntity, AugustEntityMixin def _retrieve_device_battery_state(detail: LockDetail) -> int: @@ -63,20 +58,13 @@ def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: _T = TypeVar("_T", LockDetail, KeypadDetail) -@dataclass(frozen=True) -class AugustRequiredKeysMixin(Generic[_T]): +@dataclass(frozen=True, kw_only=True) +class AugustSensorEntityDescription(SensorEntityDescription, Generic[_T]): """Mixin for required keys.""" value_fn: Callable[[_T], int | None] -@dataclass(frozen=True) -class AugustSensorEntityDescription( - SensorEntityDescription, AugustRequiredKeysMixin[_T] -): - """Describes August sensor entity.""" - - SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( key="device_battery", entity_category=EntityCategory.DIAGNOSTIC, @@ -100,107 +88,47 @@ async def async_setup_entry( """Set up the August sensors.""" data = config_entry.runtime_data entities: list[SensorEntity] = [] - migrate_unique_id_devices = [] - operation_sensors = [] - batteries: dict[str, list[Doorbell | Lock]] = { - "device_battery": [], - "linked_keypad_battery": [], - } - for device in data.doorbells: - batteries["device_battery"].append(device) + for device in data.locks: - batteries["device_battery"].append(device) - batteries["linked_keypad_battery"].append(device) - operation_sensors.append(device) - - for device in batteries["device_battery"]: detail = data.get_device_detail(device.device_id) - if detail is None or SENSOR_TYPE_DEVICE_BATTERY.value_fn(detail) is None: - _LOGGER.debug( - "Not adding battery sensor for %s because it is not present", - device.device_name, + entities.append(AugustOperatorSensor(data, device, "lock_operator")) + if SENSOR_TYPE_DEVICE_BATTERY.value_fn(detail): + entities.append( + AugustBatterySensor[LockDetail]( + data, device, SENSOR_TYPE_DEVICE_BATTERY + ) ) - continue - _LOGGER.debug( - "Adding battery sensor for %s", - device.device_name, - ) - entities.append( - AugustBatterySensor[LockDetail]( - data, device, device, SENSOR_TYPE_DEVICE_BATTERY + if keypad := detail.keypad: + entities.append( + AugustBatterySensor[KeypadDetail]( + data, keypad, SENSOR_TYPE_KEYPAD_BATTERY + ) ) - ) - for device in batteries["linked_keypad_battery"]: - detail = data.get_device_detail(device.device_id) - - if detail.keypad is None: - _LOGGER.debug( - "Not adding keypad battery sensor for %s because it is not present", - device.device_name, - ) - continue - _LOGGER.debug( - "Adding keypad battery sensor for %s", - device.device_name, - ) - keypad_battery_sensor = AugustBatterySensor[KeypadDetail]( - data, detail.keypad, device, SENSOR_TYPE_KEYPAD_BATTERY - ) - entities.append(keypad_battery_sensor) - migrate_unique_id_devices.append(keypad_battery_sensor) - - entities.extend(AugustOperatorSensor(data, device) for device in operation_sensors) - - await _async_migrate_old_unique_ids(hass, migrate_unique_id_devices) + entities.extend( + AugustBatterySensor[Doorbell](data, device, SENSOR_TYPE_DEVICE_BATTERY) + for device in data.doorbells + if SENSOR_TYPE_DEVICE_BATTERY.value_fn(data.get_device_detail(device.device_id)) + ) async_add_entities(entities) -async def _async_migrate_old_unique_ids(hass: HomeAssistant, devices) -> None: - """Keypads now have their own serial number.""" - registry = er.async_get(hass) - for device in devices: - old_entity_id = registry.async_get_entity_id( - "sensor", DOMAIN, device.old_unique_id - ) - if old_entity_id is not None: - _LOGGER.debug( - "Migrating unique_id from [%s] to [%s]", - device.old_unique_id, - device.unique_id, - ) - registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) - - class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): """Representation of an August lock operation sensor.""" _attr_translation_key = "operator" - - def __init__(self, data: AugustData, device) -> None: - """Initialize the sensor.""" - super().__init__(data, device) - self._data = data - self._device = device - self._operated_remote: bool | None = None - self._operated_keypad: bool | None = None - self._operated_manual: bool | None = None - self._operated_tag: bool | None = None - self._operated_autorelock: bool | None = None - self._operated_time = None - self._attr_unique_id = f"{self._device_id}_lock_operator" - self._update_from_data() + _operated_remote: bool | None = None + _operated_keypad: bool | None = None + _operated_manual: bool | None = None + _operated_tag: bool | None = None + _operated_autorelock: bool | None = None @callback def _update_from_data(self) -> None: """Get the latest state of the sensor and update activity.""" - lock_activity = self._data.activity_stream.get_latest_device_activity( - self._device_id, {ActivityType.LOCK_OPERATION} - ) - self._attr_available = True - if lock_activity is not None: + if lock_activity := self._get_latest({ActivityType.LOCK_OPERATION}): lock_activity = cast(LockOperationActivity, lock_activity) self._attr_native_value = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote @@ -255,41 +183,28 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): return self._attr_native_value = last_sensor_state.native_value - if ATTR_ENTITY_PICTURE in last_state.attributes: - self._attr_entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] - if ATTR_OPERATION_REMOTE in last_state.attributes: - self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE] - if ATTR_OPERATION_KEYPAD in last_state.attributes: - self._operated_keypad = last_state.attributes[ATTR_OPERATION_KEYPAD] - if ATTR_OPERATION_MANUAL in last_state.attributes: - self._operated_manual = last_state.attributes[ATTR_OPERATION_MANUAL] - if ATTR_OPERATION_TAG in last_state.attributes: - self._operated_tag = last_state.attributes[ATTR_OPERATION_TAG] - if ATTR_OPERATION_AUTORELOCK in last_state.attributes: - self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK] + last_attrs = last_state.attributes + if ATTR_ENTITY_PICTURE in last_attrs: + self._attr_entity_picture = last_attrs[ATTR_ENTITY_PICTURE] + if ATTR_OPERATION_REMOTE in last_attrs: + self._operated_remote = last_attrs[ATTR_OPERATION_REMOTE] + if ATTR_OPERATION_KEYPAD in last_attrs: + self._operated_keypad = last_attrs[ATTR_OPERATION_KEYPAD] + if ATTR_OPERATION_MANUAL in last_attrs: + self._operated_manual = last_attrs[ATTR_OPERATION_MANUAL] + if ATTR_OPERATION_TAG in last_attrs: + self._operated_tag = last_attrs[ATTR_OPERATION_TAG] + if ATTR_OPERATION_AUTORELOCK in last_attrs: + self._operated_autorelock = last_attrs[ATTR_OPERATION_AUTORELOCK] -class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]): +class AugustBatterySensor(AugustDescriptionEntity, SensorEntity, Generic[_T]): """Representation of an August sensor.""" entity_description: AugustSensorEntityDescription[_T] _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE - def __init__( - self, - data: AugustData, - device, - old_device, - description: AugustSensorEntityDescription[_T], - ) -> None: - """Initialize the sensor.""" - super().__init__(data, device) - self.entity_description = description - self._attr_unique_id = f"{self._device_id}_{description.key}" - self.old_unique_id = f"{old_device.device_id}_{description.key}" - self._update_from_data() - @callback def _update_from_data(self) -> None: """Get the latest state of the sensor.""" diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py deleted file mode 100644 index bec8e2f0b97..00000000000 --- a/homeassistant/components/august/subscriber.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Base class for August entity.""" - -from __future__ import annotations - -from abc import abstractmethod -from datetime import datetime, timedelta - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.event import async_track_time_interval - - -class AugustSubscriberMixin: - """Base implementation for a subscriber.""" - - def __init__(self, hass: HomeAssistant, update_interval: timedelta) -> None: - """Initialize an subscriber.""" - super().__init__() - self._hass = hass - self._update_interval = update_interval - self._subscriptions: dict[str, list[CALLBACK_TYPE]] = {} - self._unsub_interval: CALLBACK_TYPE | None = None - self._stop_interval: CALLBACK_TYPE | None = None - - @callback - def async_subscribe_device_id( - self, device_id: str, update_callback: CALLBACK_TYPE - ) -> CALLBACK_TYPE: - """Add an callback subscriber. - - Returns a callable that can be used to unsubscribe. - """ - if not self._subscriptions: - self._async_setup_listeners() - - self._subscriptions.setdefault(device_id, []).append(update_callback) - - def _unsubscribe() -> None: - self.async_unsubscribe_device_id(device_id, update_callback) - - return _unsubscribe - - @abstractmethod - async def _async_refresh(self, time: datetime) -> None: - """Refresh data.""" - - @callback - def _async_scheduled_refresh(self, now: datetime) -> None: - """Call the refresh method.""" - self._hass.async_create_background_task( - self._async_refresh(now), name=f"{self} schedule refresh", eager_start=True - ) - - @callback - def _async_cancel_update_interval(self, _: Event | None = None) -> None: - """Cancel the scheduled update.""" - if self._unsub_interval: - self._unsub_interval() - self._unsub_interval = None - - @callback - def _async_setup_listeners(self) -> None: - """Create interval and stop listeners.""" - self._async_cancel_update_interval() - self._unsub_interval = async_track_time_interval( - self._hass, - self._async_scheduled_refresh, - self._update_interval, - name="august refresh", - ) - - if not self._stop_interval: - self._stop_interval = self._hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, - self._async_cancel_update_interval, - ) - - @callback - def async_unsubscribe_device_id( - self, device_id: str, update_callback: CALLBACK_TYPE - ) -> None: - """Remove a callback subscriber.""" - self._subscriptions[device_id].remove(update_callback) - if not self._subscriptions[device_id]: - del self._subscriptions[device_id] - - if self._subscriptions: - return - self._async_cancel_update_interval() - - @callback - def async_signal_device_id_update(self, device_id: str) -> None: - """Call the callbacks for a device_id.""" - if not self._subscriptions.get(device_id): - return - - for update_callback in self._subscriptions[device_id]: - update_callback() diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index cf7b48412a7..273f6c6fec2 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,61 +1,29 @@ """The aurora component.""" -import logging - -from auroranoaa import AuroraForecast - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from .const import AURORA_API, CONF_THRESHOLD, COORDINATOR, DEFAULT_THRESHOLD, DOMAIN from .coordinator import AuroraDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +type AuroraConfigEntry = ConfigEntry[AuroraDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: AuroraConfigEntry) -> bool: """Set up Aurora from a config entry.""" - - conf = entry.data - options = entry.options - - session = aiohttp_client.async_get_clientsession(hass) - api = AuroraForecast(session) - - longitude = conf[CONF_LONGITUDE] - latitude = conf[CONF_LATITUDE] - threshold = options.get(CONF_THRESHOLD, DEFAULT_THRESHOLD) - - coordinator = AuroraDataUpdateCoordinator( - hass=hass, - api=api, - latitude=latitude, - longitude=longitude, - threshold=threshold, - ) + coordinator = AuroraDataUpdateCoordinator(hass=hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: coordinator, - AURORA_API: api, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AuroraConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index 5c9166a0f60..b8fb5002ff5 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -3,27 +3,28 @@ from __future__ import annotations 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 .const import COORDINATOR, DOMAIN +from . import AuroraConfigEntry from .entity import AuroraEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entries: AddEntitiesCallback + hass: HomeAssistant, + entry: AuroraConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the binary_sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - - entity = AuroraSensor( - coordinator=coordinator, - translation_key="visibility_alert", + async_add_entities( + [ + AuroraSensor( + coordinator=entry.runtime_data, + translation_key="visibility_alert", + ) + ] ) - async_add_entries([entity]) - class AuroraSensor(AuroraEntity, BinarySensorEntity): """Implementation of an aurora sensor.""" diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 744624c2eb8..521af17b659 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -64,7 +64,7 @@ class AuroraConfigFlow(ConfigFlow, domain=DOMAIN): await api.get_forecast_data(longitude, latitude) except ClientError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index fef0b5e6352..7a13e85889d 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -1,8 +1,6 @@ """Constants for the Aurora integration.""" DOMAIN = "aurora" -COORDINATOR = "coordinator" -AURORA_API = "aurora_api" CONF_THRESHOLD = "forecast_threshold" DEFAULT_THRESHOLD = 75 ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration" diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index ae1101f8054..422dff83922 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -4,27 +4,30 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING from aiohttp import ClientError from auroranoaa import AuroraForecast +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE 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_THRESHOLD, DEFAULT_THRESHOLD + +if TYPE_CHECKING: + from . import AuroraConfigEntry + _LOGGER = logging.getLogger(__name__) class AuroraDataUpdateCoordinator(DataUpdateCoordinator[int]): """Class to manage fetching data from the NOAA Aurora API.""" - def __init__( - self, - hass: HomeAssistant, - api: AuroraForecast, - latitude: float, - longitude: float, - threshold: float, - ) -> None: + config_entry: AuroraConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: """Initialize the data updater.""" super().__init__( @@ -34,10 +37,12 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator[int]): update_interval=timedelta(minutes=5), ) - self.api = api - self.latitude = int(latitude) - self.longitude = int(longitude) - self.threshold = int(threshold) + self.api = AuroraForecast(async_get_clientsession(hass)) + self.latitude = int(self.config_entry.data[CONF_LATITUDE]) + self.longitude = int(self.config_entry.data[CONF_LONGITUDE]) + self.threshold = int( + self.config_entry.options.get(CONF_THRESHOLD, DEFAULT_THRESHOLD) + ) async def _async_update_data(self) -> int: """Fetch the data from the NOAA Aurora Forecast.""" diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 3aa917862fb..317b82aed5a 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -1,20 +1,17 @@ """The aurora component.""" -import logging - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN from .coordinator import AuroraDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): """Implementation of the base Aurora Entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index e3ae9f9cf1b..35d39289598 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -3,28 +3,30 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorStateClass -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 COORDINATOR, DOMAIN +from . import AuroraConfigEntry from .entity import AuroraEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entries: AddEntitiesCallback + hass: HomeAssistant, + entry: AuroraConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - entity = AuroraSensor( - coordinator=coordinator, - translation_key="visibility", + async_add_entities( + [ + AuroraSensor( + coordinator=entry.runtime_data, + translation_key="visibility", + ) + ] ) - async_add_entries([entity]) - class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" diff --git a/homeassistant/components/aurora_abb_powerone/coordinator.py b/homeassistant/components/aurora_abb_powerone/coordinator.py index d6e9b241b86..6a84869b2e5 100644 --- a/homeassistant/components/aurora_abb_powerone/coordinator.py +++ b/homeassistant/components/aurora_abb_powerone/coordinator.py @@ -14,7 +14,7 @@ from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): # pylint: disable=hass-enforce-coordinator-module +class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): """Class to manage fetching AuroraAbbPowerone data.""" def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None: diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json index 92994415ee2..8d33cc95d45 100644 --- a/homeassistant/components/aurora_abb_powerone/manifest.json +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@davet2001"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["aurorapy"], "requirements": ["aurorapy==0.2.7"] diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index b631c61a18d..cef7af4df92 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -125,6 +125,7 @@ as part of a config flow. from __future__ import annotations +import asyncio from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus @@ -162,13 +163,14 @@ from homeassistant.util import dt as dt_util from . import indieauth, login_flow, mfa_setup_flow DOMAIN = "auth" -STRICT_CONNECTION_URL = "/auth/strict_connection/temp_token" -StoreResultType = Callable[[str, Credentials], str] -RetrieveResultType = Callable[[str, str], Credentials | None] +type StoreResultType = Callable[[str, Credentials], str] +type RetrieveResultType = Callable[[str, str], Credentials | None] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +DELETE_CURRENT_TOKEN_DELAY = 2 + @bind_hass def create_auth_code( @@ -188,7 +190,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(RevokeTokenView()) hass.http.register_view(LinkUserView(retrieve_result)) hass.http.register_view(OAuth2AuthorizeCallbackView()) - hass.http.register_view(StrictConnectionTempTokenView()) websocket_api.async_register_command(hass, websocket_current_user) websocket_api.async_register_command(hass, websocket_create_long_lived_access_token) @@ -196,6 +197,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_delete_refresh_token) websocket_api.async_register_command(hass, websocket_delete_all_refresh_tokens) websocket_api.async_register_command(hass, websocket_sign_path) + websocket_api.async_register_command(hass, websocket_refresh_token_set_expiry) login_flow.async_setup(hass, store_result) mfa_setup_flow.async_setup(hass) @@ -323,7 +325,6 @@ class TokenView(HomeAssistantView): status_code=HTTPStatus.FORBIDDEN, ) - await hass.auth.session.async_create_session(request, refresh_token) return self.json( { "access_token": access_token, @@ -392,7 +393,6 @@ class TokenView(HomeAssistantView): status_code=HTTPStatus.FORBIDDEN, ) - await hass.auth.session.async_create_session(request, refresh_token) return self.json( { "access_token": access_token, @@ -441,20 +441,6 @@ class LinkUserView(HomeAssistantView): return self.json_message("User linked") -class StrictConnectionTempTokenView(HomeAssistantView): - """View to get temporary strict connection token.""" - - url = STRICT_CONNECTION_URL - name = "api:auth:strict_connection:temp_token" - requires_auth = False - - async def get(self, request: web.Request) -> web.Response: - """Get a temporary token and redirect to main page.""" - hass = request.app[KEY_HASS] - await hass.auth.session.async_create_temp_unauthorized_session(request) - raise web.HTTPSeeOther(location="/") - - @callback def _create_auth_code_store() -> tuple[StoreResultType, RetrieveResultType]: """Create an in memory store.""" @@ -558,7 +544,7 @@ async def websocket_create_long_lived_access_token( try: access_token = hass.auth.async_create_access_token(refresh_token) except InvalidAuthError as exc: - connection.send_error(msg["id"], websocket_api.const.ERR_UNAUTHORIZED, str(exc)) + connection.send_error(msg["id"], websocket_api.ERR_UNAUTHORIZED, str(exc)) return connection.send_result(msg["id"], access_token) @@ -580,18 +566,23 @@ def websocket_refresh_tokens( else: auth_provider_type = None + expire_at = None + if refresh.expire_at: + expire_at = dt_util.utc_from_timestamp(refresh.expire_at) + tokens.append( { - "id": refresh.id, + "auth_provider_type": auth_provider_type, + "client_icon": refresh.client_icon, "client_id": refresh.client_id, "client_name": refresh.client_name, - "client_icon": refresh.client_icon, - "type": refresh.token_type, "created_at": refresh.created_at, + "expire_at": expire_at, + "id": refresh.id, "is_current": refresh.id == current_id, "last_used_at": refresh.last_used_at, "last_used_ip": refresh.last_used_ip, - "auth_provider_type": auth_provider_type, + "type": refresh.token_type, } ) @@ -651,7 +642,7 @@ def websocket_delete_all_refresh_tokens( continue try: hass.auth.async_remove_refresh_token(token) - except Exception: # pylint: disable=broad-except + except Exception: getLogger(__name__).exception("Error during refresh token removal") remove_failed = True @@ -662,11 +653,34 @@ def websocket_delete_all_refresh_tokens( else: connection.send_result(msg["id"], {}) + async def _delete_current_token_soon() -> None: + """Delete the current token after a delay. + + We do not want to delete the current token immediately as it will + close the connection. + + This is implemented as a tracked task to ensure the token + is still deleted if Home Assistant is shut down during + the delay. + + It should not be refactored to use a call_later as that + would not be tracked and the token would not be deleted + if Home Assistant was shut down during the delay. + """ + try: + await asyncio.sleep(DELETE_CURRENT_TOKEN_DELAY) + finally: + # If the task is cancelled because we are shutting down, delete + # the token right away. + hass.auth.async_remove_refresh_token(current_refresh_token) + if delete_current_token and ( not limit_token_types or current_refresh_token.token_type == token_type ): - # This will close the connection so we need to send the result first. - hass.loop.call_soon(hass.auth.async_remove_refresh_token, current_refresh_token) + # Deleting the token will close the connection so we need + # to do it with a delay in a tracked task to ensure it still + # happens if Home Assistant is shutting down. + hass.async_create_task(_delete_current_token_soon()) @websocket_api.websocket_command( @@ -694,3 +708,26 @@ def websocket_sign_path( }, ) ) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/refresh_token_set_expiry", + vol.Required("refresh_token_id"): str, + vol.Required("enable_expiry"): bool, + } +) +@websocket_api.ws_require_user() +def websocket_refresh_token_set_expiry( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle a set expiry of a refresh token request.""" + refresh_token = connection.user.refresh_tokens.get(msg["refresh_token_id"]) + + if refresh_token is None: + connection.send_error(msg["id"], "invalid_token_id", "Received invalid token") + return + + hass.auth.async_set_expiry(refresh_token, enable_expiry=msg["enable_expiry"]) + connection.send_result(msg["id"], {}) diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index 0dd3ee64cdf..d386bb7a488 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -31,11 +31,5 @@ "invalid_code": "Invalid code, please try again." } } - }, - "issues": { - "deprecated_legacy_api_password": { - "title": "The legacy API password is deprecated", - "description": "The legacy API password authentication provider is deprecated and will be removed. Please remove it from your YAML configuration and use the default Home Assistant authentication provider instead." - } } } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fa242ac1557..5a53179cf2c 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -65,7 +65,11 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( ATTR_CUR, @@ -98,7 +102,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.dt import parse_datetime -from .config import AutomationConfig +from .config import AutomationConfig, ValidationStatus from .const import ( CONF_ACTION, CONF_INITIAL_STATE, @@ -426,11 +430,15 @@ class UnavailableAutomationEntity(BaseAutomationEntity): automation_id: str | None, name: str, raw_config: ConfigType | None, + validation_error: str, + validation_status: ValidationStatus, ) -> None: """Initialize an automation entity.""" self._attr_name = name self._attr_unique_id = automation_id self.raw_config = raw_config + self._validation_error = validation_error + self._validation_status = validation_status @cached_property def referenced_labels(self) -> set[str]: @@ -462,6 +470,30 @@ class UnavailableAutomationEntity(BaseAutomationEntity): """Return a set of referenced entities.""" return set() + async def async_added_to_hass(self) -> None: + """Create a repair issue to notify the user the automation has errors.""" + await super().async_added_to_hass() + async_create_issue( + self.hass, + DOMAIN, + f"{self.entity_id}_validation_{self._validation_status}", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key=f"validation_{self._validation_status}", + translation_placeholders={ + "edit": f"/config/automation/edit/{self.unique_id}", + "entity_id": self.entity_id, + "error": self._validation_error, + "name": self._attr_name or self.entity_id, + }, + ) + + async def async_will_remove_from_hass(self) -> None: + await super().async_will_remove_from_hass() + async_delete_issue( + self.hass, DOMAIN, f"{self.entity_id}_validation_{self._validation_status}" + ) + async def async_trigger( self, run_variables: dict[str, Any], @@ -747,7 +779,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): err, ) automation_trace.set_error(err) - except Exception as err: # pylint: disable=broad-except + except Exception as err: self._logger.exception("While executing automation %s", self.entity_id) automation_trace.set_error(err) @@ -864,7 +896,8 @@ class AutomationEntityConfig: list_no: int raw_blueprint_inputs: ConfigType | None raw_config: ConfigType | None - validation_failed: bool + validation_error: str | None + validation_status: ValidationStatus async def _prepare_automation_config( @@ -884,14 +917,16 @@ async def _prepare_automation_config( raw_config = cast(AutomationConfig, config_block).raw_config raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs - validation_failed = cast(AutomationConfig, config_block).validation_failed + validation_error = cast(AutomationConfig, config_block).validation_error + validation_status = cast(AutomationConfig, config_block).validation_status automation_configs.append( AutomationEntityConfig( config_block, list_no, raw_blueprint_inputs, raw_config, - validation_failed, + validation_error, + validation_status, ) ) @@ -917,12 +952,14 @@ async def _create_automation_entities( automation_id: str | None = config_block.get(CONF_ID) name = _automation_name(automation_config) - if automation_config.validation_failed: + if automation_config.validation_status != ValidationStatus.OK: entities.append( UnavailableAutomationEntity( automation_id, name, automation_config.raw_config, + cast(str, automation_config.validation_error), + automation_config.validation_status, ) ) continue @@ -1208,7 +1245,7 @@ def websocket_config( if automation is None: connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" ) return diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index 71b4b3c0c6a..676aba946f4 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Mapping from contextlib import suppress -from typing import Any +from enum import StrEnum +from typing import Any, cast import voluptuous as vol from voluptuous.humanize import humanize_error @@ -73,7 +74,7 @@ PLATFORM_SCHEMA = vol.All( ) -async def _async_validate_config_item( +async def _async_validate_config_item( # noqa: C901 hass: HomeAssistant, config: ConfigType, raise_on_errors: bool, @@ -86,6 +87,12 @@ async def _async_validate_config_item( with suppress(ValueError): raw_config = dict(config) + def _humanize(err: Exception, config: ConfigType) -> str: + """Humanize vol.Invalid, stringify other exceptions.""" + if isinstance(err, vol.Invalid): + return cast(str, humanize_error(config, err)) + return str(err) + def _log_invalid_automation( err: Exception, automation_name: str, @@ -101,7 +108,7 @@ async def _async_validate_config_item( "Blueprint '%s' generated invalid automation with inputs %s: %s", blueprint_inputs.blueprint.name, blueprint_inputs.inputs, - humanize_error(config, err) if isinstance(err, vol.Invalid) else err, + _humanize(err, config), ) return @@ -109,17 +116,35 @@ async def _async_validate_config_item( "%s %s and has been disabled: %s", automation_name, problem, - humanize_error(config, err) if isinstance(err, vol.Invalid) else err, + _humanize(err, config), ) return - def _minimal_config() -> AutomationConfig: + def _set_validation_status( + automation_config: AutomationConfig, + validation_status: ValidationStatus, + validation_error: Exception, + config: ConfigType, + ) -> None: + """Set validation status.""" + if uses_blueprint: + validation_status = ValidationStatus.FAILED_BLUEPRINT + automation_config.validation_status = validation_status + automation_config.validation_error = _humanize(validation_error, config) + + def _minimal_config( + validation_status: ValidationStatus, + validation_error: Exception, + config: ConfigType, + ) -> AutomationConfig: """Try validating id, alias and description.""" minimal_config = _MINIMAL_PLATFORM_SCHEMA(config) automation_config = AutomationConfig(minimal_config) automation_config.raw_blueprint_inputs = raw_blueprint_inputs automation_config.raw_config = raw_config - automation_config.validation_failed = True + _set_validation_status( + automation_config, validation_status, validation_error, config + ) return automation_config if blueprint.is_blueprint_instance_config(config): @@ -135,7 +160,7 @@ async def _async_validate_config_item( ) if raise_on_errors: raise - return _minimal_config() + return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config) raw_blueprint_inputs = blueprint_inputs.config_with_inputs @@ -152,7 +177,7 @@ async def _async_validate_config_item( ) if raise_on_errors: raise HomeAssistantError(err) from err - return _minimal_config() + return _minimal_config(ValidationStatus.FAILED_BLUEPRINT, err, config) automation_name = "Unnamed automation" if isinstance(config, Mapping): @@ -167,7 +192,7 @@ async def _async_validate_config_item( _log_invalid_automation(err, automation_name, "could not be validated", config) if raise_on_errors: raise - return _minimal_config() + return _minimal_config(ValidationStatus.FAILED_SCHEMA, err, config) automation_config = AutomationConfig(validated_config) automation_config.raw_blueprint_inputs = raw_blueprint_inputs @@ -186,7 +211,9 @@ async def _async_validate_config_item( ) if raise_on_errors: raise - automation_config.validation_failed = True + _set_validation_status( + automation_config, ValidationStatus.FAILED_TRIGGERS, err, validated_config + ) return automation_config if CONF_CONDITION in validated_config: @@ -203,7 +230,12 @@ async def _async_validate_config_item( ) if raise_on_errors: raise - automation_config.validation_failed = True + _set_validation_status( + automation_config, + ValidationStatus.FAILED_CONDITIONS, + err, + validated_config, + ) return automation_config try: @@ -219,18 +251,32 @@ async def _async_validate_config_item( ) if raise_on_errors: raise - automation_config.validation_failed = True + _set_validation_status( + automation_config, ValidationStatus.FAILED_ACTIONS, err, validated_config + ) return automation_config return automation_config +class ValidationStatus(StrEnum): + """What was changed in a config entry.""" + + FAILED_ACTIONS = "failed_actions" + FAILED_BLUEPRINT = "failed_blueprint" + FAILED_CONDITIONS = "failed_conditions" + FAILED_SCHEMA = "failed_schema" + FAILED_TRIGGERS = "failed_triggers" + OK = "ok" + + class AutomationConfig(dict): """Dummy class to allow adding attributes.""" raw_config: dict[str, Any] | None = None raw_blueprint_inputs: dict[str, Any] | None = None - validation_failed: bool = False + validation_status: ValidationStatus = ValidationStatus.OK + validation_error: str | None = None async def _try_async_validate_config_item( diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index 31bd812a947..c0750a38ca8 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -1,4 +1,7 @@ { + "common": { + "validation_failed_title": "Automation {name} failed to set up" + }, "title": "Automation", "entity_component": { "_": { @@ -43,6 +46,26 @@ } } } + }, + "validation_failed_actions": { + "title": "[%key:component::automation::common::validation_failed_title%]", + "description": "The automation \"{name}\" (`{entity_id}`) is not active because its actions could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration." + }, + "validation_failed_blueprint": { + "title": "[%key:component::automation::common::validation_failed_title%]", + "description": "The blueprinted automation \"{name}\" (`{entity_id}`) failed to set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration." + }, + "validation_failed_conditions": { + "title": "[%key:component::automation::common::validation_failed_title%]", + "description": "The automation \"{name}\" (`{entity_id}`) is not active because its conditions could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration." + }, + "validation_failed_schema": { + "title": "[%key:component::automation::common::validation_failed_title%]", + "description": "The automation \"{name}\" (`{entity_id}`) is not active because the configuration has errors.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration." + }, + "validation_failed_triggers": { + "title": "[%key:component::automation::common::validation_failed_title%]", + "description": "The automation \"{name}\" (`{entity_id}`) is not active because its triggers could not be set up.\n\nError:`{error}`.\n\nTo fix this error, [edit the automation]({edit}) to correct it, then save and reload the automation configuration." } }, "services": { diff --git a/homeassistant/components/automation/trace.py b/homeassistant/components/automation/trace.py index e7f671e6f05..08f42167ceb 100644 --- a/homeassistant/components/automation/trace.py +++ b/homeassistant/components/automation/trace.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from contextlib import contextmanager from typing import Any +from typing_extensions import Generator + from homeassistant.components.trace import ( CONF_STORED_TRACES, ActionTrace, @@ -55,7 +56,7 @@ def trace_automation( blueprint_inputs: ConfigType | None, context: Context, trace_config: ConfigType, -) -> Generator[AutomationTrace, None, None]: +) -> Generator[AutomationTrace]: """Trace action execution of automation with automation_id.""" trace = AutomationTrace(automation_id, config, blueprint_inputs, context) async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES]) diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 470ccc0e409..afc1b4c6c64 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aws", "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], - "requirements": ["aiobotocore==2.12.1"] + "requirements": ["aiobotocore==2.13.0"] } diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 8f197d8924d..94752182d10 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -13,7 +13,7 @@ from .hub import AxisHub, get_axis_api _LOGGER = logging.getLogger(__name__) -AxisConfigEntry = ConfigEntry[AxisHub] +type AxisConfigEntry = ConfigEntry[AxisHub] async def async_setup_entry(hass: HomeAssistant, config_entry: AxisConfigEntry) -> bool: diff --git a/homeassistant/components/azure_data_explorer/__init__.py b/homeassistant/components/azure_data_explorer/__init__.py new file mode 100644 index 00000000000..319f7e4389b --- /dev/null +++ b/homeassistant/components/azure_data_explorer/__init__.py @@ -0,0 +1,211 @@ +"""The Azure Data Explorer integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +import json +import logging + +from azure.kusto.data.exceptions import KustoAuthenticationError, KustoServiceError +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MATCH_ALL +from homeassistant.core import Event, HomeAssistant, State +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import utcnow + +from .client import AzureDataExplorerClient +from .const import ( + CONF_APP_REG_SECRET, + CONF_FILTER, + CONF_SEND_INTERVAL, + DATA_FILTER, + DATA_HUB, + DEFAULT_MAX_DELAY, + DOMAIN, + FILTER_STATES, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, + }, + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +# fixtures for both init and config flow tests +@dataclass +class FilterTest: + """Class for capturing a filter test.""" + + entity_id: str + expect_called: bool + + +async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: + """Activate ADX component from yaml. + + Adds an empty filter to hass data. + Tries to get a filter from yaml, if present set to hass data. + """ + + hass.data.setdefault(DOMAIN, {DATA_FILTER: FILTER_SCHEMA({})}) + if DOMAIN in yaml_config: + hass.data[DOMAIN][DATA_FILTER] = yaml_config[DOMAIN].pop(CONF_FILTER) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Do the setup based on the config entry and the filter from yaml.""" + adx = AzureDataExplorer(hass, entry) + try: + await adx.test_connection() + except KustoServiceError as exp: + raise ConfigEntryError( + "Could not find Azure Data Explorer database or table" + ) from exp + except KustoAuthenticationError: + return False + + hass.data[DOMAIN][DATA_HUB] = adx + await adx.async_start() + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + adx = hass.data[DOMAIN].pop(DATA_HUB) + await adx.async_stop() + return True + + +class AzureDataExplorer: + """A event handler class for Azure Data Explorer.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize the listener.""" + + self.hass = hass + self._entry = entry + self._entities_filter = hass.data[DOMAIN][DATA_FILTER] + + self._client = AzureDataExplorerClient(entry.data) + + self._send_interval = entry.options[CONF_SEND_INTERVAL] + self._client_secret = entry.data[CONF_APP_REG_SECRET] + self._max_delay = DEFAULT_MAX_DELAY + + self._shutdown = False + self._queue: asyncio.Queue[tuple[datetime, State]] = asyncio.Queue() + self._listener_remover: Callable[[], None] | None = None + self._next_send_remover: Callable[[], None] | None = None + + async def async_start(self) -> None: + """Start the component. + + This register the listener and + schedules the first send. + """ + + self._listener_remover = self.hass.bus.async_listen( + MATCH_ALL, self.async_listen + ) + self._schedule_next_send() + + async def async_stop(self) -> None: + """Shut down the ADX by queueing None, calling send, join queue.""" + if self._next_send_remover: + self._next_send_remover() + if self._listener_remover: + self._listener_remover() + self._shutdown = True + await self.async_send(None) + + async def test_connection(self) -> None: + """Test the connection to the Azure Data Explorer service.""" + await self.hass.async_add_executor_job(self._client.test_connection) + + def _schedule_next_send(self) -> None: + """Schedule the next send.""" + if not self._shutdown: + if self._next_send_remover: + self._next_send_remover() + self._next_send_remover = async_call_later( + self.hass, self._send_interval, self.async_send + ) + + async def async_listen(self, event: Event) -> None: + """Listen for new messages on the bus and queue them for ADX.""" + if state := event.data.get("new_state"): + await self._queue.put((event.time_fired, state)) + + async def async_send(self, _) -> None: + """Write preprocessed events to Azure Data Explorer.""" + + adx_events = [] + dropped = 0 + while not self._queue.empty(): + (time_fired, event) = self._queue.get_nowait() + adx_event, dropped = self._parse_event(time_fired, event, dropped) + self._queue.task_done() + if adx_event is not None: + adx_events.append(adx_event) + + if dropped: + _LOGGER.warning( + "Dropped %d old events, consider filtering messages", dropped + ) + + if adx_events: + event_string = "".join(adx_events) + + try: + await self.hass.async_add_executor_job( + self._client.ingest_data, event_string + ) + + except KustoServiceError as err: + _LOGGER.error("Could not find database or table: %s", err) + except KustoAuthenticationError as err: + _LOGGER.error("Could not authenticate to Azure Data Explorer: %s", err) + + self._schedule_next_send() + + def _parse_event( + self, + time_fired: datetime, + state: State, + dropped: int, + ) -> tuple[str | None, int]: + """Parse event by checking if it needs to be sent, and format it.""" + + if state.state in FILTER_STATES or not self._entities_filter(state.entity_id): + return None, dropped + if (utcnow() - time_fired).seconds > DEFAULT_MAX_DELAY + self._send_interval: + return None, dropped + 1 + if "\n" in state.state: + return None, dropped + 1 + + json_event = json.dumps(obj=state, cls=JSONEncoder) + + return (json_event, dropped) diff --git a/homeassistant/components/azure_data_explorer/client.py b/homeassistant/components/azure_data_explorer/client.py new file mode 100644 index 00000000000..88609ff8e10 --- /dev/null +++ b/homeassistant/components/azure_data_explorer/client.py @@ -0,0 +1,90 @@ +"""Setting up the Azure Data Explorer ingest client.""" + +from __future__ import annotations + +from collections.abc import Mapping +import io +import logging +from typing import Any + +from azure.kusto.data import KustoClient, KustoConnectionStringBuilder +from azure.kusto.data.data_format import DataFormat +from azure.kusto.ingest import ( + IngestionProperties, + ManagedStreamingIngestClient, + QueuedIngestClient, + StreamDescriptor, +) + +from .const import ( + CONF_ADX_CLUSTER_INGEST_URI, + CONF_ADX_DATABASE_NAME, + CONF_ADX_TABLE_NAME, + CONF_APP_REG_ID, + CONF_APP_REG_SECRET, + CONF_AUTHORITY_ID, + CONF_USE_QUEUED_CLIENT, +) + +_LOGGER = logging.getLogger(__name__) + + +class AzureDataExplorerClient: + """Class for Azure Data Explorer Client.""" + + def __init__(self, data: Mapping[str, Any]) -> None: + """Create the right class.""" + + self._database = data[CONF_ADX_DATABASE_NAME] + self._table = data[CONF_ADX_TABLE_NAME] + self._ingestion_properties = IngestionProperties( + database=self._database, + table=self._table, + data_format=DataFormat.MULTIJSON, + ingestion_mapping_reference="ha_json_mapping", + ) + + # Create client for ingesting data + kcsb_ingest = ( + KustoConnectionStringBuilder.with_aad_application_key_authentication( + data[CONF_ADX_CLUSTER_INGEST_URI], + data[CONF_APP_REG_ID], + data[CONF_APP_REG_SECRET], + data[CONF_AUTHORITY_ID], + ) + ) + + # Create client for querying data + kcsb_query = ( + KustoConnectionStringBuilder.with_aad_application_key_authentication( + data[CONF_ADX_CLUSTER_INGEST_URI].replace("ingest-", ""), + data[CONF_APP_REG_ID], + data[CONF_APP_REG_SECRET], + data[CONF_AUTHORITY_ID], + ) + ) + + if data[CONF_USE_QUEUED_CLIENT] is True: + # Queded is the only option supported on free tear of ADX + self.write_client = QueuedIngestClient(kcsb_ingest) + else: + self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb_ingest) + + self.query_client = KustoClient(kcsb_query) + + def test_connection(self) -> None: + """Test connection, will throw Exception if it cannot connect.""" + + query = f"{self._table} | take 1" + + self.query_client.execute_query(self._database, query) + + def ingest_data(self, adx_events: str) -> None: + """Send data to Axure Data Explorer.""" + + bytes_stream = io.StringIO(adx_events) + stream_descriptor = StreamDescriptor(bytes_stream) + + self.write_client.ingest_from_stream( + stream_descriptor, ingestion_properties=self._ingestion_properties + ) diff --git a/homeassistant/components/azure_data_explorer/config_flow.py b/homeassistant/components/azure_data_explorer/config_flow.py new file mode 100644 index 00000000000..4ffb5ea7cf7 --- /dev/null +++ b/homeassistant/components/azure_data_explorer/config_flow.py @@ -0,0 +1,89 @@ +"""Config flow for Azure Data Explorer integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from azure.kusto.data.exceptions import KustoAuthenticationError, KustoServiceError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers.selector import BooleanSelector + +from . import AzureDataExplorerClient +from .const import ( + CONF_ADX_CLUSTER_INGEST_URI, + CONF_ADX_DATABASE_NAME, + CONF_ADX_TABLE_NAME, + CONF_APP_REG_ID, + CONF_APP_REG_SECRET, + CONF_AUTHORITY_ID, + CONF_USE_QUEUED_CLIENT, + DEFAULT_OPTIONS, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ADX_CLUSTER_INGEST_URI): str, + vol.Required(CONF_ADX_DATABASE_NAME): str, + vol.Required(CONF_ADX_TABLE_NAME): str, + vol.Required(CONF_APP_REG_ID): str, + vol.Required(CONF_APP_REG_SECRET): str, + vol.Required(CONF_AUTHORITY_ID): str, + vol.Required(CONF_USE_QUEUED_CLIENT, default=False): BooleanSelector(), + } +) + + +class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Azure Data Explorer.""" + + VERSION = 1 + + async def validate_input(self, data: dict[str, Any]) -> 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. + """ + client = AzureDataExplorerClient(data) + + try: + await self.hass.async_add_executor_job(client.test_connection) + + except KustoAuthenticationError as exp: + _LOGGER.error(exp) + return {"base": "invalid_auth"} + + except KustoServiceError as exp: + _LOGGER.error(exp) + return {"base": "cannot_connect"} + + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + errors: dict = {} + if user_input: + errors = await self.validate_input(user_input) # type: ignore[assignment] + if not errors: + return self.async_create_entry( + data=user_input, + title=user_input[CONF_ADX_CLUSTER_INGEST_URI].replace( + "https://", "" + ), + options=DEFAULT_OPTIONS, + ) + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + last_step=True, + ) diff --git a/homeassistant/components/azure_data_explorer/const.py b/homeassistant/components/azure_data_explorer/const.py new file mode 100644 index 00000000000..a88a6b8b94f --- /dev/null +++ b/homeassistant/components/azure_data_explorer/const.py @@ -0,0 +1,30 @@ +"""Constants for the Azure Data Explorer integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN + +DOMAIN = "azure_data_explorer" + +CONF_ADX_CLUSTER_INGEST_URI = "cluster_ingest_uri" +CONF_ADX_DATABASE_NAME = "database" +CONF_ADX_TABLE_NAME = "table" +CONF_APP_REG_ID = "client_id" +CONF_APP_REG_SECRET = "client_secret" +CONF_AUTHORITY_ID = "authority_id" +CONF_SEND_INTERVAL = "send_interval" +CONF_MAX_DELAY = "max_delay" +CONF_FILTER = DATA_FILTER = "filter" +CONF_USE_QUEUED_CLIENT = "use_queued_ingestion" +DATA_HUB = "hub" +STEP_USER = "user" + + +DEFAULT_SEND_INTERVAL: int = 5 +DEFAULT_MAX_DELAY: int = 30 +DEFAULT_OPTIONS: dict[str, Any] = {CONF_SEND_INTERVAL: DEFAULT_SEND_INTERVAL} + +ADDITIONAL_ARGS: dict[str, Any] = {"logging_enable": False} +FILTER_STATES = (STATE_UNKNOWN, STATE_UNAVAILABLE) diff --git a/homeassistant/components/azure_data_explorer/manifest.json b/homeassistant/components/azure_data_explorer/manifest.json new file mode 100644 index 00000000000..feae53a5652 --- /dev/null +++ b/homeassistant/components/azure_data_explorer/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "azure_data_explorer", + "name": "Azure Data Explorer", + "codeowners": ["@kaareseras"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/azure_data_explorer", + "iot_class": "cloud_push", + "loggers": ["azure"], + "requirements": ["azure-kusto-ingest==3.1.0", "azure-kusto-data[aio]==3.1.0"] +} diff --git a/homeassistant/components/azure_data_explorer/strings.json b/homeassistant/components/azure_data_explorer/strings.json new file mode 100644 index 00000000000..c8ec158a844 --- /dev/null +++ b/homeassistant/components/azure_data_explorer/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your Azure Data Explorer integration", + "description": "Enter connection details", + "data": { + "cluster_ingest_uri": "Cluster Ingest URI", + "authority_id": "Authority ID", + "client_id": "Client ID", + "client_secret": "Client secret", + "database": "Database name", + "table": "Table name", + "use_queued_ingestion": "Use queued ingestion" + }, + "data_description": { + "cluster_ingest_uri": "Ingest-URI of the cluster", + "use_queued_ingestion": "Must be enabled when using ADX free cluster" + } + } + }, + "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%]" + } + } +} diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 537019fb9c1..9890d47fbb5 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -2,94 +2,46 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta import logging -from typing import Final - -from aioazuredevops.builds import DevOpsBuild -from aioazuredevops.client import DevOpsClient -from aioazuredevops.core import DevOpsProject -import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN +from .const import CONF_PAT, CONF_PROJECT +from .coordinator import AzureDevOpsDataUpdateCoordinator + +type AzureDevOpsConfigEntry = ConfigEntry[AzureDevOpsDataUpdateCoordinator] _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" - -@dataclass(frozen=True) -class AzureDevOpsEntityDescription(EntityDescription): - """Class describing Azure DevOps entities.""" - - organization: str = "" - project: DevOpsProject = None - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AzureDevOpsConfigEntry) -> bool: """Set up Azure DevOps from a config entry.""" - aiohttp_session = async_get_clientsession(hass) - client = DevOpsClient(session=aiohttp_session) - if entry.data.get(CONF_PAT) is not None: - await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) - if not client.authorized: - raise ConfigEntryAuthFailed( - "Could not authorize with Azure DevOps. You will need to update your" - " token" - ) - - project = await client.get_project( - entry.data[CONF_ORG], - entry.data[CONF_PROJECT], - ) - - async def async_update_data() -> list[DevOpsBuild]: - """Fetch data from Azure DevOps.""" - - try: - builds = await client.get_builds( - entry.data[CONF_ORG], - entry.data[CONF_PROJECT], - BUILDS_QUERY, - ) - except aiohttp.ClientError as exception: - raise UpdateFailed from exception - - if builds is None: - raise UpdateFailed("No builds found") - - return builds - - coordinator = DataUpdateCoordinator( + # Create the data update coordinator + coordinator = AzureDevOpsDataUpdateCoordinator( hass, _LOGGER, - name=f"{DOMAIN}_coordinator", - update_method=async_update_data, - update_interval=timedelta(seconds=300), + entry=entry, ) + # Store the coordinator in runtime data + entry.runtime_data = coordinator + + # If a personal access token is set, authorize the client + if entry.data.get(CONF_PAT) is not None: + await coordinator.authorize(entry.data[CONF_PAT]) + + # Set the project for the coordinator + coordinator.project = await coordinator.get_project(entry.data[CONF_PROJECT]) + + # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator, project - + # Set up platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -97,43 +49,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Azure DevOps config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok - - -class AzureDevOpsEntity(CoordinatorEntity[DataUpdateCoordinator[list[DevOpsBuild]]]): - """Defines a base Azure DevOps entity.""" - - _attr_has_entity_name = True - - entity_description: AzureDevOpsEntityDescription - - def __init__( - self, - coordinator: DataUpdateCoordinator[list[DevOpsBuild]], - entity_description: AzureDevOpsEntityDescription, - ) -> None: - """Initialize the Azure DevOps entity.""" - super().__init__(coordinator) - self.entity_description = entity_description - self._attr_unique_id: str = ( - f"{entity_description.organization}_{entity_description.key}" - ) - self._organization: str = entity_description.organization - self._project_name: str = entity_description.project.name - - -class AzureDevOpsDeviceEntity(AzureDevOpsEntity): - """Defines a Azure DevOps device entity.""" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this Azure DevOps instance.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._organization, self._project_name)}, # type: ignore[arg-type] - manufacturer=self._organization, - name=self._project_name, - ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/azure_devops/coordinator.py b/homeassistant/components/azure_devops/coordinator.py new file mode 100644 index 00000000000..d7531c130e9 --- /dev/null +++ b/homeassistant/components/azure_devops/coordinator.py @@ -0,0 +1,116 @@ +"""Define the Azure DevOps DataUpdateCoordinator.""" + +from collections.abc import Callable +from datetime import timedelta +import logging +from typing import Final + +from aioazuredevops.client import DevOpsClient +from aioazuredevops.models.builds import Build +from aioazuredevops.models.core import Project +import aiohttp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ORG, DOMAIN +from .data import AzureDevOpsData + +BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +def ado_exception_none_handler(func: Callable) -> Callable: + """Handle exceptions or None to always return a value or raise.""" + + async def handler(*args, **kwargs): + try: + response = await func(*args, **kwargs) + except aiohttp.ClientError as exception: + raise UpdateFailed from exception + + if response is None: + raise UpdateFailed("No data returned from Azure DevOps") + + return response + + return handler + + +class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]): + """Class to manage and fetch Azure DevOps data.""" + + client: DevOpsClient + organization: str + project: Project + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + entry: ConfigEntry, + ) -> None: + """Initialize global Azure DevOps data updater.""" + self.title = entry.title + + super().__init__( + hass=hass, + logger=logger, + name=DOMAIN, + update_interval=timedelta(seconds=300), + ) + + self.client = DevOpsClient(session=async_get_clientsession(hass)) + self.organization = entry.data[CONF_ORG] + + @ado_exception_none_handler + async def authorize( + self, + personal_access_token: str, + ) -> bool: + """Authorize with Azure DevOps.""" + await self.client.authorize( + personal_access_token, + self.organization, + ) + if not self.client.authorized: + raise ConfigEntryAuthFailed( + "Could not authorize with Azure DevOps. You will need to update your" + " token" + ) + + return True + + @ado_exception_none_handler + async def get_project( + self, + project: str, + ) -> Project | None: + """Get the project.""" + return await self.client.get_project( + self.organization, + project, + ) + + @ado_exception_none_handler + async def _get_builds(self, project_name: str) -> list[Build] | None: + """Get the builds.""" + return await self.client.get_builds( + self.organization, + project_name, + BUILDS_QUERY, + ) + + async def _async_update_data(self) -> AzureDevOpsData: + """Fetch data from Azure DevOps.""" + # Get the builds from the project + builds = await self._get_builds(self.project.name) + + return AzureDevOpsData( + organization=self.organization, + project=self.project, + builds=builds, + ) diff --git a/homeassistant/components/azure_devops/data.py b/homeassistant/components/azure_devops/data.py new file mode 100644 index 00000000000..6d9e2069b67 --- /dev/null +++ b/homeassistant/components/azure_devops/data.py @@ -0,0 +1,15 @@ +"""Data classes for Azure DevOps integration.""" + +from dataclasses import dataclass + +from aioazuredevops.models.builds import Build +from aioazuredevops.models.core import Project + + +@dataclass(frozen=True, kw_only=True) +class AzureDevOpsData: + """Class describing Azure DevOps data.""" + + organization: str + project: Project + builds: list[Build] diff --git a/homeassistant/components/azure_devops/entity.py b/homeassistant/components/azure_devops/entity.py new file mode 100644 index 00000000000..0a4a94d4b32 --- /dev/null +++ b/homeassistant/components/azure_devops/entity.py @@ -0,0 +1,28 @@ +"""Base entity for Azure DevOps.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AzureDevOpsDataUpdateCoordinator + + +class AzureDevOpsEntity(CoordinatorEntity[AzureDevOpsDataUpdateCoordinator]): + """Defines a base Azure DevOps entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AzureDevOpsDataUpdateCoordinator, + ) -> None: + """Initialize the Azure DevOps entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + (DOMAIN, coordinator.data.organization, coordinator.data.project.name) # type: ignore[arg-type] + }, + manufacturer=coordinator.data.organization, + name=coordinator.data.project.name, + ) diff --git a/homeassistant/components/azure_devops/manifest.json b/homeassistant/components/azure_devops/manifest.json index 0d5e5a1c685..48ceee5f9d8 100644 --- a/homeassistant/components/azure_devops/manifest.json +++ b/homeassistant/components/azure_devops/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/azure_devops", "iot_class": "cloud_polling", "loggers": ["aioazuredevops"], - "requirements": ["aioazuredevops==2.0.0"] + "requirements": ["aioazuredevops==2.1.1"] } diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 514db5462e9..029d3d875dc 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -2,89 +2,186 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass +from datetime import datetime +import logging from typing import Any -from aioazuredevops.builds import DevOpsBuild +from aioazuredevops.models.builds import Build -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util -from . import AzureDevOpsDeviceEntity, AzureDevOpsEntityDescription -from .const import CONF_ORG, DOMAIN +from . import AzureDevOpsConfigEntry +from .coordinator import AzureDevOpsDataUpdateCoordinator +from .entity import AzureDevOpsEntity + +_LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) -class AzureDevOpsSensorEntityDescription( - AzureDevOpsEntityDescription, SensorEntityDescription -): - """Class describing Azure DevOps sensor entities.""" +class AzureDevOpsBuildSensorEntityDescription(SensorEntityDescription): + """Class describing Azure DevOps base build sensor entities.""" - build_key: int - attrs: Callable[[DevOpsBuild], Any] - value: Callable[[DevOpsBuild], StateType] + attr_fn: Callable[[Build], dict[str, Any] | None] = lambda _: None + value_fn: Callable[[Build], datetime | StateType] + + +BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, ...] = ( + # Attributes are deprecated in 2024.7 and can be removed in 2025.1 + AzureDevOpsBuildSensorEntityDescription( + key="latest_build", + translation_key="latest_build", + attr_fn=lambda build: { + "definition_id": (build.definition.build_id if build.definition else None), + "definition_name": (build.definition.name if build.definition else None), + "id": build.build_id, + "reason": build.reason, + "result": build.result, + "source_branch": build.source_branch, + "source_version": build.source_version, + "status": build.status, + "url": build.links.web if build.links else None, + "queue_time": build.queue_time, + "start_time": build.start_time, + "finish_time": build.finish_time, + }, + value_fn=lambda build: build.build_number, + ), + AzureDevOpsBuildSensorEntityDescription( + key="build_id", + translation_key="build_id", + entity_registry_visible_default=False, + value_fn=lambda build: build.build_id, + ), + AzureDevOpsBuildSensorEntityDescription( + key="reason", + translation_key="reason", + entity_registry_visible_default=False, + value_fn=lambda build: build.reason, + ), + AzureDevOpsBuildSensorEntityDescription( + key="result", + translation_key="result", + entity_registry_visible_default=False, + value_fn=lambda build: build.result, + ), + AzureDevOpsBuildSensorEntityDescription( + key="source_branch", + translation_key="source_branch", + entity_registry_enabled_default=False, + entity_registry_visible_default=False, + value_fn=lambda build: build.source_branch, + ), + AzureDevOpsBuildSensorEntityDescription( + key="source_version", + translation_key="source_version", + entity_registry_visible_default=False, + value_fn=lambda build: build.source_version, + ), + AzureDevOpsBuildSensorEntityDescription( + key="queue_time", + translation_key="queue_time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + entity_registry_visible_default=False, + value_fn=lambda build: parse_datetime(build.queue_time), + ), + AzureDevOpsBuildSensorEntityDescription( + key="start_time", + translation_key="start_time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_visible_default=False, + value_fn=lambda build: parse_datetime(build.start_time), + ), + AzureDevOpsBuildSensorEntityDescription( + key="finish_time", + translation_key="finish_time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_visible_default=False, + value_fn=lambda build: parse_datetime(build.finish_time), + ), + AzureDevOpsBuildSensorEntityDescription( + key="url", + translation_key="url", + value_fn=lambda build: build.links.web if build.links else None, + ), +) + + +def parse_datetime(value: str | None) -> datetime | None: + """Parse datetime string.""" + if value is None: + return None + + return dt_util.parse_datetime(value) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AzureDevOpsConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Azure DevOps sensor based on a config entry.""" - coordinator, project = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data + initial_builds: list[Build] = coordinator.data.builds - sensors = [ - AzureDevOpsSensor( + async_add_entities( + AzureDevOpsBuildSensor( coordinator, - AzureDevOpsSensorEntityDescription( - key=f"{build.project.project_id}_{build.definition.build_id}_latest_build", - translation_key="latest_build", - translation_placeholders={"definition_name": build.definition.name}, - attrs=lambda build: { - "definition_id": ( - build.definition.build_id if build.definition else None - ), - "definition_name": ( - build.definition.name if build.definition else None - ), - "id": build.build_id, - "reason": build.reason, - "result": build.result, - "source_branch": build.source_branch, - "source_version": build.source_version, - "status": build.status, - "url": build.links.web if build.links else None, - "queue_time": build.queue_time, - "start_time": build.start_time, - "finish_time": build.finish_time, - }, - build_key=key, - organization=entry.data[CONF_ORG], - project=project, - value=lambda build: build.build_number, - ), + description, + key, ) - for key, build in enumerate(coordinator.data) - ] - - async_add_entities(sensors, True) + for description in BASE_BUILD_SENSOR_DESCRIPTIONS + for key, build in enumerate(initial_builds) + if build.project and build.definition + ) -class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): - """Define a Azure DevOps sensor.""" +class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): + """Define a Azure DevOps build sensor.""" - entity_description: AzureDevOpsSensorEntityDescription + entity_description: AzureDevOpsBuildSensorEntityDescription + + def __init__( + self, + coordinator: AzureDevOpsDataUpdateCoordinator, + description: AzureDevOpsBuildSensorEntityDescription, + item_key: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self.entity_description = description + self.item_key = item_key + self._attr_unique_id = ( + f"{self.coordinator.data.organization}_" + f"{self.build.project.id}_" + f"{self.build.definition.build_id}_" + f"{description.key}" + ) + self._attr_translation_placeholders = { + "definition_name": self.build.definition.name + } @property - def native_value(self) -> StateType: + def build(self) -> Build: + """Return the build.""" + return self.coordinator.data.builds[self.item_key] + + @property + def native_value(self) -> datetime | StateType: """Return the state.""" - build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] - return self.entity_description.value(build) + return self.entity_description.value_fn(self.build) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of the entity.""" - build: DevOpsBuild = self.coordinator.data[self.entity_description.build_key] - return self.entity_description.attrs(build) + return self.entity_description.attr_fn(self.build) diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index c163aee5b7f..7bd6d8af561 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -31,8 +31,35 @@ }, "entity": { "sensor": { + "build_id": { + "name": "{definition_name} latest build id" + }, + "finish_time": { + "name": "{definition_name} latest build finish time" + }, "latest_build": { "name": "{definition_name} latest build" + }, + "queue_time": { + "name": "{definition_name} latest build queue time" + }, + "reason": { + "name": "{definition_name} latest build reason" + }, + "result": { + "name": "{definition_name} latest build result" + }, + "source_branch": { + "name": "{definition_name} latest build source branch" + }, + "source_version": { + "name": "{definition_name} latest build source version" + }, + "start_time": { + "name": "{definition_name} latest build start time" + }, + "url": { + "name": "{definition_name} latest build url" } } } diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index c088b35a002..264daa683bc 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -73,7 +73,7 @@ async def validate_data(data: dict[str, Any]) -> dict[str, str] | None: await client.test_connection() except EventHubError: return {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") return {"base": "unknown"} return None diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 08d6fda3663..8deba33c8ba 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -92,7 +92,7 @@ async def handle_backup_start( try: await manager.pre_backup_actions() - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 connection.send_error(msg["id"], "pre_backup_actions_failed", str(err)) return @@ -114,7 +114,7 @@ async def handle_backup_end( try: await manager.post_backup_actions() - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 connection.send_error(msg["id"], "post_backup_actions_failed", str(err)) return diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py index d3b29b52e44..8d26e3bea43 100644 --- a/homeassistant/components/baf/__init__.py +++ b/homeassistant/components/baf/__init__.py @@ -10,11 +10,13 @@ from aiobafi6.exceptions import DeviceUUIDMismatchError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, QUERY_INTERVAL, RUN_TIMEOUT -from .models import BAFData +from .const import QUERY_INTERVAL, RUN_TIMEOUT + +type BAFConfigEntry = ConfigEntry[Device] + PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -27,7 +29,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: BAFConfigEntry) -> bool: """Set up Big Ass Fans from a config entry.""" ip_address = entry.data[CONF_IP_ADDRESS] @@ -46,16 +48,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: run_future.cancel() raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BAFData(device, run_future) + @callback + def _async_cancel_run() -> None: + run_future.cancel() + + entry.runtime_data = device + entry.async_on_unload(_async_cancel_run) 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, entry: BAFConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: BAFData = hass.data[DOMAIN].pop(entry.entry_id) - data.run_future.cancel() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index e95e197b8be..7c855711712 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -8,7 +8,6 @@ from typing import cast from aiobafi6 import Device -from homeassistant import config_entries from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -17,9 +16,8 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .entity import BAFEntity -from .models import BAFData +from . import BAFConfigEntry +from .entity import BAFDescriptionEntity @dataclass(frozen=True, kw_only=True) @@ -42,33 +40,23 @@ OCCUPANCY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: BAFConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up BAF binary sensors.""" - data: BAFData = hass.data[DOMAIN][entry.entry_id] - device = data.device - sensors_descriptions: list[BAFBinarySensorDescription] = [] + device = entry.runtime_data if device.has_occupancy: - sensors_descriptions.extend(OCCUPANCY_SENSORS) - async_add_entities( - BAFBinarySensor(device, description) for description in sensors_descriptions - ) + async_add_entities( + BAFBinarySensor(device, description) for description in OCCUPANCY_SENSORS + ) -class BAFBinarySensor(BAFEntity, BinarySensorEntity): +class BAFBinarySensor(BAFDescriptionEntity, BinarySensorEntity): """BAF binary sensor.""" entity_description: BAFBinarySensorDescription - def __init__(self, device: Device, description: BAFBinarySensorDescription) -> None: - """Initialize the entity.""" - self.entity_description = description - super().__init__(device) - self._attr_unique_id = f"{self._device.mac_address}-{description.key}" - @callback def _async_update_attrs(self) -> None: """Update attrs from device.""" - description = self.entity_description - self._attr_is_on = description.value_fn(self._device) + self._attr_is_on = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index f451c5e7a71..38407813d37 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any -from homeassistant import config_entries from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -15,20 +14,19 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import BAFConfigEntry from .entity import BAFEntity -from .models import BAFData async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: BAFConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up BAF fan auto comfort.""" - data: BAFData = hass.data[DOMAIN][entry.entry_id] - if data.device.has_fan and data.device.has_auto_comfort: - async_add_entities([BAFAutoComfort(data.device)]) + device = entry.runtime_data + if device.has_fan and device.has_auto_comfort: + async_add_entities([BAFAutoComfort(device)]) class BAFAutoComfort(BAFEntity, ClimateEntity): diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index d0a3a82b396..0d56699e1ce 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -92,7 +92,7 @@ class BAFFlowHandler(ConfigFlow, domain=DOMAIN): device = await async_try_connect(ip_address) except CannotConnect: errors[CONF_IP_ADDRESS] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Unknown exception during connection test to %s", ip_address ) diff --git a/homeassistant/components/baf/entity.py b/homeassistant/components/baf/entity.py index 487e601b542..6bb9dbfeca7 100644 --- a/homeassistant/components/baf/entity.py +++ b/homeassistant/components/baf/entity.py @@ -7,7 +7,7 @@ from aiobafi6 import Device from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo, format_mac -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription class BAFEntity(Entity): @@ -47,3 +47,13 @@ class BAFEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Remove data updated listener after this object has been initialized.""" self._device.remove_callback(self._async_update_from_device) + + +class BAFDescriptionEntity(BAFEntity): + """Base class for baf entities that use an entity description.""" + + def __init__(self, device: Device, description: EntityDescription) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(device) + self._attr_unique_id = f"{device.mac_address}-{description.key}" diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 6c90e2a53cb..d8c800ea512 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -7,7 +7,6 @@ from typing import Any from aiobafi6 import OffOnAuto -from homeassistant import config_entries from homeassistant.components.fan import ( DIRECTION_FORWARD, DIRECTION_REVERSE, @@ -21,20 +20,20 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DOMAIN, PRESET_MODE_AUTO, SPEED_COUNT, SPEED_RANGE +from . import BAFConfigEntry +from .const import PRESET_MODE_AUTO, SPEED_COUNT, SPEED_RANGE from .entity import BAFEntity -from .models import BAFData async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: BAFConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up SenseME fans.""" - data: BAFData = hass.data[DOMAIN][entry.entry_id] - if data.device.has_fan: - async_add_entities([BAFFan(data.device)]) + device = entry.runtime_data + if device.has_fan: + async_add_entities([BAFFan(device)]) class BAFFan(BAFEntity, FanEntity): diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index e203e12cf96..2fb36ed874f 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -6,7 +6,6 @@ from typing import Any from aiobafi6 import Device, OffOnAuto -from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -20,21 +19,20 @@ from homeassistant.util.color import ( color_temperature_mired_to_kelvin, ) -from .const import DOMAIN +from . import BAFConfigEntry from .entity import BAFEntity -from .models import BAFData async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: BAFConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up BAF lights.""" - data: BAFData = hass.data[DOMAIN][entry.entry_id] - if data.device.has_light: - klass = BAFFanLight if data.device.has_fan else BAFStandaloneLight - async_add_entities([klass(data.device)]) + device = entry.runtime_data + if device.has_light: + klass = BAFFanLight if device.has_fan else BAFStandaloneLight + async_add_entities([klass(device)]) class BAFLight(BAFEntity, LightEntity): diff --git a/homeassistant/components/baf/models.py b/homeassistant/components/baf/models.py index c94b73d9abd..3bb574d5a19 100644 --- a/homeassistant/components/baf/models.py +++ b/homeassistant/components/baf/models.py @@ -2,19 +2,8 @@ from __future__ import annotations -import asyncio from dataclasses import dataclass -from aiobafi6 import Device - - -@dataclass -class BAFData: - """Data for the baf integration.""" - - device: Device - run_future: asyncio.Future - @dataclass class BAFDiscovery: diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 43da381391c..a2e5e704e4d 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -8,7 +8,6 @@ from typing import cast from aiobafi6 import Device -from homeassistant import config_entries from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, @@ -18,9 +17,9 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, HALF_DAY_SECS, ONE_DAY_SECS, ONE_MIN_SECS, SPEED_RANGE -from .entity import BAFEntity -from .models import BAFData +from . import BAFConfigEntry +from .const import HALF_DAY_SECS, ONE_DAY_SECS, ONE_MIN_SECS, SPEED_RANGE +from .entity import BAFDescriptionEntity @dataclass(frozen=True, kw_only=True) @@ -116,12 +115,11 @@ LIGHT_NUMBER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: BAFConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up BAF numbers.""" - data: BAFData = hass.data[DOMAIN][entry.entry_id] - device = data.device + device = entry.runtime_data descriptions: list[BAFNumberDescription] = [] if device.has_fan: descriptions.extend(FAN_NUMBER_DESCRIPTIONS) @@ -132,17 +130,11 @@ async def async_setup_entry( async_add_entities(BAFNumber(device, description) for description in descriptions) -class BAFNumber(BAFEntity, NumberEntity): +class BAFNumber(BAFDescriptionEntity, NumberEntity): """BAF number.""" entity_description: BAFNumberDescription - def __init__(self, device: Device, description: BAFNumberDescription) -> None: - """Initialize the entity.""" - self.entity_description = description - super().__init__(device) - self._attr_unique_id = f"{self._device.mac_address}-{description.key}" - @callback def _async_update_attrs(self) -> None: """Update attrs from device.""" diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index fc052b1e48b..7e664254a38 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -8,7 +8,6 @@ from typing import cast from aiobafi6 import Device -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -24,9 +23,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .entity import BAFEntity -from .models import BAFData +from . import BAFConfigEntry +from .entity import BAFDescriptionEntity @dataclass(frozen=True, kw_only=True) @@ -94,12 +92,11 @@ FAN_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: BAFConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up BAF fan sensors.""" - data: BAFData = hass.data[DOMAIN][entry.entry_id] - device = data.device + device = entry.runtime_data sensors_descriptions: list[BAFSensorDescription] = [ description for description in DEFINED_ONLY_SENSORS @@ -114,19 +111,12 @@ async def async_setup_entry( ) -class BAFSensor(BAFEntity, SensorEntity): +class BAFSensor(BAFDescriptionEntity, SensorEntity): """BAF sensor.""" entity_description: BAFSensorDescription - def __init__(self, device: Device, description: BAFSensorDescription) -> None: - """Initialize the entity.""" - self.entity_description = description - super().__init__(device) - self._attr_unique_id = f"{self._device.mac_address}-{description.key}" - @callback def _async_update_attrs(self) -> None: """Update attrs from device.""" - description = self.entity_description - self._attr_native_value = description.value_fn(self._device) + self._attr_native_value = self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index 38248e48d09..e18e26ddcaa 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -8,15 +8,13 @@ from typing import Any, cast from aiobafi6 import Device -from homeassistant import config_entries from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .entity import BAFEntity -from .models import BAFData +from . import BAFConfigEntry +from .entity import BAFDescriptionEntity @dataclass(frozen=True, kw_only=True) @@ -104,12 +102,11 @@ LIGHT_SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: BAFConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up BAF fan switches.""" - data: BAFData = hass.data[DOMAIN][entry.entry_id] - device = data.device + device = entry.runtime_data descriptions: list[BAFSwitchDescription] = [] descriptions.extend(BASE_SWITCHES) if device.has_fan: @@ -121,17 +118,11 @@ async def async_setup_entry( async_add_entities(BAFSwitch(device, description) for description in descriptions) -class BAFSwitch(BAFEntity, SwitchEntity): +class BAFSwitch(BAFDescriptionEntity, SwitchEntity): """BAF switch component.""" entity_description: BAFSwitchDescription - def __init__(self, device: Device, description: BAFSwitchDescription) -> None: - """Initialize the entity.""" - self.entity_description = description - super().__init__(device) - self._attr_unique_id = f"{self._device.mac_address}-{description.key}" - @callback def _async_update_attrs(self) -> None: """Update attrs from device.""" diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 456fa0dd081..8cd9e93e539 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -54,7 +54,6 @@ async def async_setup_entry( class BalboaClimateEntity(BalboaEntity, ClimateEntity): """Representation of a Balboa spa climate entity.""" - _attr_icon = "mdi:hot-tub" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py index 2dc98fbcd69..fccfeceb331 100644 --- a/homeassistant/components/balboa/config_flow.py +++ b/homeassistant/components/balboa/config_flow.py @@ -74,7 +74,7 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN): info = await validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/balboa/icons.json b/homeassistant/components/balboa/icons.json index 7454366f692..40ed55a2725 100644 --- a/homeassistant/components/balboa/icons.json +++ b/homeassistant/components/balboa/icons.json @@ -20,6 +20,11 @@ } } }, + "climate": { + "balboa": { + "default": "mdi:hot-tub" + } + }, "fan": { "pump": { "default": "mdi:pump", diff --git a/homeassistant/components/balboa/select.py b/homeassistant/components/balboa/select.py index 3fdd8c4d014..9c3074350c5 100644 --- a/homeassistant/components/balboa/select.py +++ b/homeassistant/components/balboa/select.py @@ -23,9 +23,6 @@ async def async_setup_entry( class BalboaTempRangeSelectEntity(BalboaEntity, SelectEntity): """Representation of a Temperature Range select.""" - _attr_icon = "mdi:thermometer-lines" - _attr_name = "Temperature range" - _attr_unique_id = "temperature_range" _attr_translation_key = "temperature_range" _attr_options = [ LowHighRange.LOW.name.lower(), diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 4d53daeb510..25e7f8e15dc 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -54,7 +54,9 @@ class BangOlufsenMediaType(StrEnum): FAVOURITE = "favourite" DEEZER = "deezer" RADIO = "radio" + TIDAL = "tidal" TTS = "provider" + OVERLAY_TTS = "overlay_tts" class BangOlufsenModel(StrEnum): @@ -117,6 +119,8 @@ VALID_MEDIA_TYPES: Final[tuple] = ( BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.RADIO, BangOlufsenMediaType.TTS, + BangOlufsenMediaType.TIDAL, + BangOlufsenMediaType.OVERLAY_TTS, MediaType.MUSIC, MediaType.URL, MediaType.CHANNEL, diff --git a/homeassistant/components/bang_olufsen/entity.py b/homeassistant/components/bang_olufsen/entity.py index 4f8ff43e0a8..8ed68da1678 100644 --- a/homeassistant/components/bang_olufsen/entity.py +++ b/homeassistant/components/bang_olufsen/entity.py @@ -17,6 +17,7 @@ from mozart_api.mozart_client import MozartClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -62,7 +63,8 @@ class BangOlufsenEntity(Entity, BangOlufsenBase): self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, self._unique_id)}) - async def _update_connection_state(self, connection_state: bool) -> None: + @callback + def _async_update_connection_state(self, connection_state: bool) -> None: """Update entity connection state.""" self._attr_available = connection_state diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 935c057efc8..5c214a3fb17 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -12,6 +12,7 @@ from mozart_api.models import ( Action, Art, OverlayPlayRequest, + OverlayPlayRequestTextToSpeechTextToSpeech, PlaybackContentMetadata, PlaybackError, PlaybackProgress, @@ -43,7 +44,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL -from homeassistant.core import HomeAssistant +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 @@ -67,19 +68,20 @@ from .entity import BangOlufsenEntity _LOGGER = logging.getLogger(__name__) BANG_OLUFSEN_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.SEEK - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.PREVIOUS_TRACK + MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.CLEAR_PLAYLIST - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET ) @@ -138,7 +140,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): async_dispatcher_connect( self.hass, f"{self._unique_id}_{CONNECTION_STATUS}", - self._update_connection_state, + self._async_update_connection_state, ) ) @@ -146,7 +148,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.PLAYBACK_ERROR}", - self._update_playback_error, + self._async_update_playback_error, ) ) @@ -154,7 +156,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.PLAYBACK_METADATA}", - self._update_playback_metadata, + self._async_update_playback_metadata, ) ) @@ -162,35 +164,35 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.PLAYBACK_PROGRESS}", - self._update_playback_progress, + self._async_update_playback_progress, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.PLAYBACK_STATE}", - self._update_playback_state, + self._async_update_playback_state, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}", - self._update_sources, + self._async_update_sources, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.SOURCE_CHANGE}", - self._update_source_change, + self._async_update_source_change, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, f"{self._unique_id}_{WebsocketNotification.VOLUME}", - self._update_volume, + self._async_update_volume, ) ) @@ -235,12 +237,12 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self._media_image = get_highest_resolution_artwork(self._playback_metadata) # If the device has been updated with new sources, then the API will fail here. - await self._update_sources() + await self._async_update_sources() # Set the static entity attributes that needed more information. self._attr_source_list = list(self._sources.values()) - async def _update_sources(self) -> None: + async def _async_update_sources(self) -> None: """Get sources for the specific product.""" # Audio sources @@ -300,7 +302,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): if self.hass.is_running: self.async_write_ha_state() - async def _update_playback_metadata(self, data: PlaybackContentMetadata) -> None: + @callback + def _async_update_playback_metadata(self, data: PlaybackContentMetadata) -> None: """Update _playback_metadata and related.""" self._playback_metadata = data @@ -309,18 +312,21 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self.async_write_ha_state() - async def _update_playback_error(self, data: PlaybackError) -> None: + @callback + def _async_update_playback_error(self, data: PlaybackError) -> None: """Show playback error.""" _LOGGER.error(data.error) - async def _update_playback_progress(self, data: PlaybackProgress) -> None: + @callback + def _async_update_playback_progress(self, data: PlaybackProgress) -> None: """Update _playback_progress and last update.""" self._playback_progress = data self._attr_media_position_updated_at = utcnow() self.async_write_ha_state() - async def _update_playback_state(self, data: RenderingState) -> None: + @callback + def _async_update_playback_state(self, data: RenderingState) -> None: """Update _playback_state and related.""" self._playback_state = data @@ -330,7 +336,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self.async_write_ha_state() - async def _update_source_change(self, data: Source) -> None: + @callback + def _async_update_source_change(self, data: Source) -> None: """Update _source_change and related.""" self._source_change = data @@ -341,7 +348,10 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): ): self._playback_progress = PlaybackProgress(progress=0) - async def _update_volume(self, data: VolumeState) -> None: + self.async_write_ha_state() + + @callback + def _async_update_volume(self, data: VolumeState) -> None: """Update _volume.""" self._volume = data @@ -539,10 +549,10 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, + announce: bool | None = None, **kwargs: Any, ) -> None: """Play from: netradio station id, URI, favourite or Deezer.""" - # Convert audio/mpeg, audio/aac etc. to MediaType.MUSIC if media_type.startswith("audio/"): media_type = MediaType.MUSIC @@ -566,7 +576,42 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): if media_id.endswith(".m3u"): media_id = media_id.replace(".m3u", "") - if media_type in (MediaType.URL, MediaType.MUSIC): + if announce: + extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) + + absolute_volume = extra.get("overlay_absolute_volume", None) + offset_volume = extra.get("overlay_offset_volume", None) + tts_language = extra.get("overlay_tts_language", "en-us") + + # Construct request + overlay_play_request = OverlayPlayRequest() + + # Define volume level + if absolute_volume: + overlay_play_request.volume_absolute = absolute_volume + + elif offset_volume: + # Ensure that the volume is not above 100 + if not self._volume.level or not self._volume.level.level: + _LOGGER.warning("Error setting volume") + else: + overlay_play_request.volume_absolute = min( + self._volume.level.level + offset_volume, 100 + ) + + if media_type == BangOlufsenMediaType.OVERLAY_TTS: + # Bang & Olufsen cloud TTS + overlay_play_request.text_to_speech = ( + OverlayPlayRequestTextToSpeechTextToSpeech( + lang=tts_language, text=media_id + ) + ) + else: + overlay_play_request.uri = Uri(location=media_id) + + await self._client.post_overlay_play(overlay_play_request) + + elif media_type in (MediaType.URL, MediaType.MUSIC): await self._client.post_uri_source(uri=Uri(location=media_id)) # The "provider" media_type may not be suitable for overlay all the time. @@ -593,20 +638,20 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): elif media_type == BangOlufsenMediaType.FAVOURITE: await self._client.activate_preset(id=int(media_id)) - elif media_type == BangOlufsenMediaType.DEEZER: + elif media_type in (BangOlufsenMediaType.DEEZER, BangOlufsenMediaType.TIDAL): try: - if media_id == "flow": + # Play Deezer flow. + if media_id == "flow" and media_type == BangOlufsenMediaType.DEEZER: deezer_id = None if "id" in kwargs[ATTR_MEDIA_EXTRA]: deezer_id = kwargs[ATTR_MEDIA_EXTRA]["id"] - # Play Deezer flow. await self._client.start_deezer_flow( user_flow=UserFlow(user_id=deezer_id) ) - # Play a Deezer playlist or album. + # Play a playlist or album. elif any(match in media_id for match in ("playlist", "album")): start_from = 0 if "start_from" in kwargs[ATTR_MEDIA_EXTRA]: @@ -614,18 +659,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): await self._client.add_to_queue( play_queue_item=PlayQueueItem( - provider=PlayQueueItemType(value="deezer"), + provider=PlayQueueItemType(value=media_type), start_now_from_position=start_from, type="playlist", uri=media_id, ) ) - # Play a Deezer track. + # Play a track. else: await self._client.add_to_queue( play_queue_item=PlayQueueItem( - provider=PlayQueueItemType(value="deezer"), + provider=PlayQueueItemType(value=media_type), start_now_from_position=0, type="track", uri=media_id, diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 29e40c8b336..162cf139a1d 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -55,6 +55,9 @@ "is_on": "[%key:common::device_automation::condition_type::is_on%]", "is_off": "[%key:common::device_automation::condition_type::is_off%]" }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" + }, "trigger_type": { "bat_low": "{entity_name} battery low", "not_bat_low": "{entity_name} battery normal", diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index ce142101c3e..77b9618a5e3 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -1,7 +1,6 @@ """The BleBox devices integration.""" import logging -from typing import Generic, TypeVar from blebox_uniapi.box import Box from blebox_uniapi.error import Error @@ -38,8 +37,6 @@ PLATFORMS = [ PARALLEL_UPDATES = 0 -_FeatureT = TypeVar("_FeatureT", bound=Feature) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BleBox devices from a config entry.""" @@ -80,7 +77,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class BleBoxEntity(Entity, Generic[_FeatureT]): +class BleBoxEntity[_FeatureT: Feature](Entity): """Implements a common class for entities representing a BleBox feature.""" def __init__(self, feature: _FeatureT) -> None: diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 566935c405f..a2c6495cc56 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -1,11 +1,11 @@ { "domain": "blebox", "name": "BleBox devices", - "codeowners": ["@bbx-a", "@riokuu", "@swistakm"], + "codeowners": ["@bbx-a", "@swistakm"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.2.2"], + "requirements": ["blebox-uniapi==2.4.2"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index dbdf034faee..2642bfd0139 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -12,8 +12,15 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + LIGHT_LUX, PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, UnitOfSpeed, UnitOfTemperature, ) @@ -45,7 +52,7 @@ SENSOR_TYPES = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), SensorEntityDescription( - key="powerMeasurement", + key="powerConsumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -56,10 +63,55 @@ SENSOR_TYPES = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key="wind_speed", + key="wind", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, ), + SensorEntityDescription( + key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + ), + SensorEntityDescription( + key="forwardActiveEnergy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + SensorEntityDescription( + key="reverseActiveEnergy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + SensorEntityDescription( + key="reactivePower", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + ), + SensorEntityDescription( + key="activePower", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + ), + SensorEntityDescription( + key="apparentPower", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + ), + SensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + ), + SensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + ), + SensorEntityDescription( + key="frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + ), ) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index b7dc50a5c51..0ad15cf0d31 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -46,6 +46,7 @@ class BlinkSyncModuleHA( """Representation of a Blink Alarm Control Panel.""" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_code_arm_required = False _attr_has_entity_name = True _attr_name = None diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 7461d7b2a2b..fcf19adf71e 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -23,6 +23,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_BRAND, DOMAIN, + SERVICE_RECORD, SERVICE_SAVE_RECENT_CLIPS, SERVICE_SAVE_VIDEO, SERVICE_TRIGGER, @@ -50,6 +51,7 @@ async def async_setup_entry( async_add_entities(entities) platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service(SERVICE_RECORD, {}, "record") platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") platform.async_register_entity_service( SERVICE_SAVE_RECENT_CLIPS, @@ -94,7 +96,6 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Enable motion detection for the camera.""" try: await self._camera.async_arm(True) - except TimeoutError as er: raise HomeAssistantError( translation_domain=DOMAIN, @@ -127,6 +128,18 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Return the camera brand.""" return DEFAULT_BRAND + async def record(self) -> None: + """Trigger camera to record a clip.""" + try: + await self._camera.record() + except TimeoutError as er: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_clip", + ) from er + + self.async_write_ha_state() + async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" try: diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 1531728aa79..62f15bd6e10 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -69,7 +69,7 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_2fa() except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -96,7 +96,7 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): ) except BlinkSetupError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index a524d2c599a..0f24eec2178 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -20,7 +20,7 @@ TYPE_TEMPERATURE = "temperature" TYPE_BATTERY = "battery" TYPE_WIFI_STRENGTH = "wifi_strength" -SERVICE_REFRESH = "blink_update" +SERVICE_RECORD = "record" SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" diff --git a/homeassistant/components/blink/icons.json b/homeassistant/components/blink/icons.json index cd8a282737f..615a3c4c6dc 100644 --- a/homeassistant/components/blink/icons.json +++ b/homeassistant/components/blink/icons.json @@ -12,7 +12,7 @@ } }, "services": { - "blink_update": "mdi:update", + "record": "mdi:video-box", "trigger_camera": "mdi:image-refresh", "save_video": "mdi:file-video", "save_recent_clips": "mdi:file-video", diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 445a469b141..82f48a3c1ea 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.6"] + "requirements": ["blinkpy==0.23.0"] } diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index e01371c5c09..298ead00a45 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -8,13 +8,9 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, SERVICE_SEND_PIN +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN from .coordinator import BlinkUpdateCoordinator SERVICE_UPDATE_SCHEMA = vol.Schema( @@ -93,33 +89,9 @@ def setup_services(hass: HomeAssistant) -> None: call.data[CONF_PIN], ) - 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), - (send_pin, SERVICE_SEND_PIN, SERVICE_SEND_PIN_SCHEMA), - ] - - for service_handler, service_name, schema in service_mapping: - hass.services.async_register( - DOMAIN, - service_name, - service_handler, - schema=schema, - ) + hass.services.async_register( + DOMAIN, + SERVICE_SEND_PIN, + send_pin, + schema=SERVICE_SEND_PIN_SCHEMA, + ) diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 87083a990ef..244763d5535 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,12 +1,10 @@ # Describes the format for available Blink services -blink_update: - fields: - device_id: - required: true - selector: - device: - integration: blink +record: + target: + entity: + integration: blink + domain: camera trigger_camera: target: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 2c0be3d972c..bd0e7789816 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -55,15 +55,9 @@ } }, "services": { - "blink_update": { - "name": "Update", - "description": "Forces a refresh.", - "fields": { - "device_id": { - "name": "Device ID", - "description": "The Blink device id." - } - } + "record": { + "name": "Record", + "description": "Requests camera to record a clip." }, "trigger_camera": { "name": "Trigger camera", @@ -81,7 +75,7 @@ }, "save_recent_clips": { "name": "Save recent clips", - "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".", + "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_[camera name].mp4\".", "fields": { "file_path": { "name": "Output directory", @@ -123,6 +117,9 @@ "failed_disarm": { "message": "Blink failed to disarm camera." }, + "failed_clip": { + "message": "Blink failed to record a clip." + }, "failed_snap": { "message": "Blink failed to snap a picture." }, diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py index 66070094c29..a3aaf60cc39 100644 --- a/homeassistant/components/blue_current/config_flow.py +++ b/homeassistant/components/blue_current/config_flow.py @@ -48,7 +48,7 @@ class BlueCurrentConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "already_connected" except InvalidApiToken: errors["base"] = "invalid_token" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py index b544b69d2ff..4c590544984 100644 --- a/homeassistant/components/blue_current/sensor.py +++ b/homeassistant/components/blue_current/sensor.py @@ -23,8 +23,6 @@ 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", @@ -102,21 +100,6 @@ SENSORS = ( 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, @@ -168,6 +151,21 @@ SENSORS = ( ), ) +TIMESTAMP_SENSORS = ( + SensorEntityDescription( + key="start_datetime", + translation_key="start_datetime", + ), + SensorEntityDescription( + key="stop_datetime", + translation_key="stop_datetime", + ), + SensorEntityDescription( + key="offline_since", + translation_key="offline_since", + ), +) + GRID_SENSORS = ( SensorEntityDescription( key="grid_actual_p1", @@ -223,6 +221,14 @@ async def async_setup_entry( for sensor in SENSORS ] + sensor_list.extend( + [ + ChargePointTimestampSensor(connector, sensor, evse_id) + for evse_id in connector.charge_points + for sensor in TIMESTAMP_SENSORS + ] + ) + sensor_list.extend(GridSensor(connector, sensor) for sensor in GRID_SENSORS) async_add_entities(sensor_list) @@ -251,17 +257,31 @@ class ChargePointSensor(ChargepointEntity, SensorEntity): 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: + else: self.has_value = False +class ChargePointTimestampSensor(ChargePointSensor): + """Define a timestamp sensor.""" + + _attr_device_class = SensorDeviceClass.TIMESTAMP + + @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) + + # only update if the new_value is a newer timestamp. + if new_value is not None and ( + self.has_value is False or self._attr_native_value < new_value + ): + self.has_value = True + self._attr_native_value = new_value + + class GridSensor(BlueCurrentEntity, SensorEntity): """Define a grid sensor.""" diff --git a/homeassistant/components/bluemaestro/sensor.py b/homeassistant/components/bluemaestro/sensor.py index f8529a4103b..75d448c9b9d 100644 --- a/homeassistant/components/bluemaestro/sensor.py +++ b/homeassistant/components/bluemaestro/sensor.py @@ -134,7 +134,9 @@ async def async_setup_entry( class BlueMaestroBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a BlueMaestro sensor.""" diff --git a/homeassistant/components/blueprint/const.py b/homeassistant/components/blueprint/const.py index 18433aa6ba6..ccbcd7a9d80 100644 --- a/homeassistant/components/blueprint/const.py +++ b/homeassistant/components/blueprint/const.py @@ -9,5 +9,6 @@ CONF_SOURCE_URL = "source_url" CONF_HOMEASSISTANT = "homeassistant" CONF_MIN_VERSION = "min_version" CONF_AUTHOR = "author" +CONF_COLLAPSED = "collapsed" DOMAIN = "blueprint" diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 2475ccf8d14..414d4e55a9b 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -78,7 +78,7 @@ class Blueprint: self.domain = data_domain - missing = yaml.extract_inputs(data) - set(data[CONF_BLUEPRINT][CONF_INPUT]) + missing = yaml.extract_inputs(data) - set(self.inputs) if missing: raise InvalidBlueprint( @@ -95,8 +95,15 @@ class Blueprint: @property def inputs(self) -> dict[str, Any]: - """Return blueprint inputs.""" - return self.data[CONF_BLUEPRINT][CONF_INPUT] # type: ignore[no-any-return] + """Return flattened blueprint inputs.""" + inputs = {} + for key, value in self.data[CONF_BLUEPRINT][CONF_INPUT].items(): + if value and CONF_INPUT in value: + for key, value in value[CONF_INPUT].items(): + inputs[key] = value + else: + inputs[key] = value + return inputs @property def metadata(self) -> dict[str, Any]: diff --git a/homeassistant/components/blueprint/schemas.py b/homeassistant/components/blueprint/schemas.py index 390bb1ddc80..6aaa4091e07 100644 --- a/homeassistant/components/blueprint/schemas.py +++ b/homeassistant/components/blueprint/schemas.py @@ -8,6 +8,7 @@ from homeassistant.const import ( CONF_DEFAULT, CONF_DESCRIPTION, CONF_DOMAIN, + CONF_ICON, CONF_NAME, CONF_PATH, CONF_SELECTOR, @@ -18,6 +19,7 @@ from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_AUTHOR, CONF_BLUEPRINT, + CONF_COLLAPSED, CONF_HOMEASSISTANT, CONF_INPUT, CONF_MIN_VERSION, @@ -46,6 +48,23 @@ def version_validator(value: Any) -> str: return value +def unique_input_validator(inputs: Any) -> Any: + """Validate the inputs don't have duplicate keys under different sections.""" + all_inputs = set() + for key, value in inputs.items(): + if value and CONF_INPUT in value: + for key in value[CONF_INPUT]: + if key in all_inputs: + raise vol.Invalid(f"Duplicate use of input key {key} in blueprint.") + all_inputs.add(key) + else: + if key in all_inputs: + raise vol.Invalid(f"Duplicate use of input key {key} in blueprint.") + all_inputs.add(key) + + return inputs + + @callback def is_blueprint_config(config: Any) -> bool: """Return if it is a blueprint config.""" @@ -67,6 +86,21 @@ BLUEPRINT_INPUT_SCHEMA = vol.Schema( } ) +BLUEPRINT_INPUT_SECTION_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_ICON): str, + vol.Optional(CONF_DESCRIPTION): str, + vol.Optional(CONF_COLLAPSED): bool, + vol.Required(CONF_INPUT, default=dict): { + str: vol.Any( + None, + BLUEPRINT_INPUT_SCHEMA, + ) + }, + } +) + BLUEPRINT_SCHEMA = vol.Schema( { vol.Required(CONF_BLUEPRINT): vol.Schema( @@ -79,12 +113,16 @@ BLUEPRINT_SCHEMA = vol.Schema( vol.Optional(CONF_HOMEASSISTANT): { vol.Optional(CONF_MIN_VERSION): version_validator }, - vol.Optional(CONF_INPUT, default=dict): { - str: vol.Any( - None, - BLUEPRINT_INPUT_SCHEMA, - ) - }, + vol.Optional(CONF_INPUT, default=dict): vol.All( + { + str: vol.Any( + None, + BLUEPRINT_INPUT_SCHEMA, + BLUEPRINT_INPUT_SECTION_SCHEMA, + ) + }, + unique_input_validator, + ), } ), }, diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 6c63067a1c1..7be5a823bf8 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -344,7 +344,7 @@ class BluesoundPlayer(MediaPlayerEntity): ): """Send command to the player.""" if not self._is_online and not allow_offline: - return + return None if method[0] == "/": method = method[1:] @@ -468,7 +468,7 @@ class BluesoundPlayer(MediaPlayerEntity): """Update Capture sources.""" resp = await self.send_bluesound_command("RadioBrowse?service=Capture") if not resp: - return + return None self._capture_items = [] def _create_capture_item(item): @@ -496,7 +496,7 @@ class BluesoundPlayer(MediaPlayerEntity): """Update Presets.""" resp = await self.send_bluesound_command("Presets") if not resp: - return + return None self._preset_items = [] def _create_preset_item(item): @@ -526,7 +526,7 @@ class BluesoundPlayer(MediaPlayerEntity): """Update Services.""" resp = await self.send_bluesound_command("Services") if not resp: - return + return None self._services_items = [] def _create_service_item(item): @@ -603,7 +603,7 @@ class BluesoundPlayer(MediaPlayerEntity): return None if not (url := self._status.get("image")): - return + return None if url[0] == "/": url = f"http://{self.host}:{self.port}{url}" @@ -937,14 +937,14 @@ class BluesoundPlayer(MediaPlayerEntity): if selected_source.get("is_raw_url"): url = selected_source["url"] - return await self.send_bluesound_command(url) + await self.send_bluesound_command(url) async def async_clear_playlist(self) -> None: """Clear players playlist.""" if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command("Clear") + await self.send_bluesound_command("Clear") async def async_media_next_track(self) -> None: """Send media_next command to media player.""" @@ -957,7 +957,7 @@ class BluesoundPlayer(MediaPlayerEntity): if "@name" in action and "@url" in action and action["@name"] == "skip": cmd = action["@url"] - return await self.send_bluesound_command(cmd) + await self.send_bluesound_command(cmd) async def async_media_previous_track(self) -> None: """Send media_previous command to media player.""" @@ -970,35 +970,35 @@ class BluesoundPlayer(MediaPlayerEntity): if "@name" in action and "@url" in action and action["@name"] == "back": cmd = action["@url"] - return await self.send_bluesound_command(cmd) + await self.send_bluesound_command(cmd) async def async_media_play(self) -> None: """Send media_play command to media player.""" if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command("Play") + await self.send_bluesound_command("Play") async def async_media_pause(self) -> None: """Send media_pause command to media player.""" if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command("Pause") + await self.send_bluesound_command("Pause") async def async_media_stop(self) -> None: """Send stop command.""" if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command("Pause") + await self.send_bluesound_command("Pause") async def async_media_seek(self, position: float) -> None: """Send media_seek command to media player.""" if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command(f"Play?seek={float(position)}") + await self.send_bluesound_command(f"Play?seek={float(position)}") async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any @@ -1017,21 +1017,21 @@ class BluesoundPlayer(MediaPlayerEntity): url = f"Play?url={media_id}" - return await self.send_bluesound_command(url) + await self.send_bluesound_command(url) async def async_volume_up(self) -> None: """Volume up the media player.""" current_vol = self.volume_level if not current_vol or current_vol >= 1: return - return await self.async_set_volume_level(current_vol + 0.01) + await self.async_set_volume_level(current_vol + 0.01) async def async_volume_down(self) -> None: """Volume down the media player.""" current_vol = self.volume_level if not current_vol or current_vol <= 0: return - return await self.async_set_volume_level(current_vol - 0.01) + await self.async_set_volume_level(current_vol - 0.01) async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" @@ -1039,13 +1039,13 @@ class BluesoundPlayer(MediaPlayerEntity): volume = 0 elif volume > 1: volume = 1 - return await self.send_bluesound_command(f"Volume?level={float(volume) * 100}") + await self.send_bluesound_command(f"Volume?level={float(volume) * 100}") async def async_mute_volume(self, mute: bool) -> None: """Send mute command to media player.""" if mute: - return await self.send_bluesound_command("Volume?mute=1") - return await self.send_bluesound_command("Volume?mute=0") + await self.send_bluesound_command("Volume?mute=1") + await self.send_bluesound_command("Volume?mute=0") async def async_browse_media( self, diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 49fadd1892e..645adfdcd2d 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -339,7 +339,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_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)) entry.async_on_unload(scanner.async_stop) return True @@ -352,6 +351,4 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - scanner: HaScanner = hass.data[DOMAIN].pop(entry.entry_id) - await scanner.async_stop() return True diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index df5701a81a3..7c3d1bc3620 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import logging -from typing import Any, Generic, TypeVar +from typing import Any from bleak import BleakError from bluetooth_data_tools import monotonic_time_coarse @@ -21,12 +21,8 @@ from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator POLL_DEFAULT_COOLDOWN = 10 POLL_DEFAULT_IMMEDIATE = True -_T = TypeVar("_T") - -class ActiveBluetoothDataUpdateCoordinator( - PassiveBluetoothDataUpdateCoordinator, Generic[_T] -): +class ActiveBluetoothDataUpdateCoordinator[_T](PassiveBluetoothDataUpdateCoordinator): """A coordinator that receives passive data from advertisements but can also poll. Unlike the passive processor coordinator, this coordinator does call a parser @@ -136,7 +132,7 @@ class ActiveBluetoothDataUpdateCoordinator( ) self.last_poll_successful = False return - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index be4f6553738..e7b65067070 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -7,7 +7,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import logging -from typing import Any, Generic, TypeVar +from typing import Any from bleak import BleakError from bluetooth_data_tools import monotonic_time_coarse @@ -21,11 +21,9 @@ from .passive_update_processor import PassiveBluetoothProcessorCoordinator POLL_DEFAULT_COOLDOWN = 10 POLL_DEFAULT_IMMEDIATE = True -_T = TypeVar("_T") - -class ActiveBluetoothProcessorCoordinator( - Generic[_T], PassiveBluetoothProcessorCoordinator[_T] +class ActiveBluetoothProcessorCoordinator[_DataT]( + PassiveBluetoothProcessorCoordinator[_DataT] ): """A processor coordinator that parses passive data. @@ -63,11 +61,11 @@ class ActiveBluetoothProcessorCoordinator( *, address: str, mode: BluetoothScanningMode, - update_method: Callable[[BluetoothServiceInfoBleak], _T], + update_method: Callable[[BluetoothServiceInfoBleak], _DataT], needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool], poll_method: Callable[ [BluetoothServiceInfoBleak], - Coroutine[Any, Any, _T], + Coroutine[Any, Any, _DataT], ] | None = None, poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, @@ -110,7 +108,7 @@ class ActiveBluetoothProcessorCoordinator( async def _async_poll_data( self, last_service_info: BluetoothServiceInfoBleak - ) -> _T: + ) -> _DataT: """Fetch the latest data from the source.""" if self._poll_method is None: raise NotImplementedError("Poll method not implemented") @@ -129,7 +127,7 @@ class ActiveBluetoothProcessorCoordinator( ) self.last_poll_successful = False return - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 if self.last_poll_successful: self.logger.exception("%s: Failure while polling", self.address) self.last_poll_successful = False diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 789991cce9c..9355fca6cdc 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -107,7 +107,7 @@ class HomeAssistantBluetoothManager(BluetoothManager): callback = match[CALLBACK] try: callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error in bluetooth callback") for domain in matched_domains: @@ -182,7 +182,7 @@ class HomeAssistantBluetoothManager(BluetoothManager): if ble_device_matches(callback_matcher, service_info): try: callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error in bluetooth callback") return _async_remove_callback diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 9a0c84d6beb..095eeff7f30 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -14,12 +14,12 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.21.1", + "bleak==0.22.1", "bleak-retry-connector==3.5.0", "bluetooth-adapters==0.19.2", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.19.0", - "dbus-fast==2.21.1", - "habluetooth==3.0.1" + "dbus-fast==2.21.3", + "habluetooth==3.1.1" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index a5e1159e04e..06caf18c9f1 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from fnmatch import translate from functools import lru_cache import re -from typing import TYPE_CHECKING, Final, Generic, TypedDict, TypeVar +from typing import TYPE_CHECKING, Final, TypedDict from lru import LRU @@ -148,10 +148,9 @@ class IntegrationMatcher: return matched_domains -_T = TypeVar("_T", BluetoothMatcher, BluetoothCallbackMatcherWithCallback) - - -class BluetoothMatcherIndexBase(Generic[_T]): +class BluetoothMatcherIndexBase[ + _T: (BluetoothMatcher, BluetoothCallbackMatcherWithCallback) +]: """Bluetooth matcher base for the bluetooth integration. The indexer puts each matcher in the bucket that it is most diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index a97056e1f4b..deab0043097 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -8,5 +8,5 @@ from enum import Enum from home_assistant_bluetooth import BluetoothServiceInfoBleak BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") -BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] -ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] +type BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] +type ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 81a67f6caef..524faad510b 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -15,9 +15,11 @@ from homeassistant.helpers.update_coordinator import ( from .update_coordinator import BasePassiveBluetoothCoordinator if TYPE_CHECKING: - from collections.abc import Callable, Generator + from collections.abc import Callable import logging + from typing_extensions import Generator + from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak _PassiveBluetoothDataUpdateCoordinatorT = TypeVar( @@ -48,6 +50,11 @@ class PassiveBluetoothDataUpdateCoordinator( super().__init__(hass, logger, address, mode, connectable) self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + @property + def available(self) -> bool: + """Return if device is available.""" + return self._available + @callback def async_update_listeners(self) -> None: """Update all registered listeners.""" @@ -76,7 +83,7 @@ class PassiveBluetoothDataUpdateCoordinator( self._listeners[remove_listener] = (update_callback, context) return remove_listener - def async_contexts(self) -> Generator[Any, None, None]: + def async_contexts(self) -> Generator[Any]: """Return all registered contexts.""" yield from ( context for _, context in self._listeners.values() if context is not None diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 87f7c7a9b20..29ebda3488b 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -6,7 +6,7 @@ import dataclasses from datetime import timedelta from functools import cache import logging -from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypedDict, cast from habluetooth import BluetoothScanningMode @@ -42,7 +42,6 @@ STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 STORAGE_SAVE_INTERVAL = timedelta(minutes=15) PASSIVE_UPDATE_PROCESSOR = "passive_update_processor" -_T = TypeVar("_T") @dataclasses.dataclass(slots=True, frozen=True) @@ -73,7 +72,7 @@ class PassiveBluetoothEntityKey: class PassiveBluetoothProcessorData: """Data for the passive bluetooth processor.""" - coordinators: set[PassiveBluetoothProcessorCoordinator] + coordinators: set[PassiveBluetoothProcessorCoordinator[Any]] all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] @@ -95,10 +94,9 @@ 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] = {} if hasattr(descriptions_class, "_dataclass"): - descriptions_class = descriptions_class._dataclass + descriptions_class = descriptions_class._dataclass # noqa: SLF001 for field in cached_fields(descriptions_class): field_name = field.name # It would be nice if field.type returned the actual @@ -124,7 +122,7 @@ def serialize_entity_description(description: EntityDescription) -> dict[str, An @dataclasses.dataclass(slots=True, frozen=False) -class PassiveBluetoothDataUpdate(Generic[_T]): +class PassiveBluetoothDataUpdate[_T]: """Generic bluetooth data.""" devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict) @@ -221,7 +219,7 @@ class PassiveBluetoothDataUpdate(Generic[_T]): def async_register_coordinator_for_restore( - hass: HomeAssistant, coordinator: PassiveBluetoothProcessorCoordinator + hass: HomeAssistant, coordinator: PassiveBluetoothProcessorCoordinator[Any] ) -> CALLBACK_TYPE: """Register a coordinator to have its processors data restored.""" data: PassiveBluetoothProcessorData = hass.data[PASSIVE_UPDATE_PROCESSOR] @@ -243,7 +241,7 @@ async def async_setup(hass: HomeAssistant) -> None: storage: Store[dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]]] = Store( hass, STORAGE_VERSION, STORAGE_KEY ) - coordinators: set[PassiveBluetoothProcessorCoordinator] = set() + coordinators: set[PassiveBluetoothProcessorCoordinator[Any]] = set() all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] = ( await storage.async_load() or {} ) @@ -276,9 +274,7 @@ async def async_setup(hass: HomeAssistant) -> None: ) -class PassiveBluetoothProcessorCoordinator( - Generic[_T], BasePassiveBluetoothCoordinator -): +class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinator): """Passive bluetooth processor coordinator for bluetooth advertisements. The coordinator is responsible for dispatching the bluetooth data, @@ -295,12 +291,12 @@ class PassiveBluetoothProcessorCoordinator( logger: logging.Logger, address: str, mode: BluetoothScanningMode, - update_method: Callable[[BluetoothServiceInfoBleak], _T], + update_method: Callable[[BluetoothServiceInfoBleak], _DataT], connectable: bool = False, ) -> None: """Initialize the coordinator.""" super().__init__(hass, logger, address, mode, connectable) - self._processors: list[PassiveBluetoothDataProcessor] = [] + self._processors: list[PassiveBluetoothDataProcessor[Any, _DataT]] = [] self._update_method = update_method self.last_update_success = True self.restore_data: dict[str, RestoredPassiveBluetoothDataUpdate] = {} @@ -312,7 +308,7 @@ class PassiveBluetoothProcessorCoordinator( @property def available(self) -> bool: """Return if the device is available.""" - return super().available and self.last_update_success + return self._available and self.last_update_success @callback def async_get_restore_data( @@ -328,7 +324,7 @@ class PassiveBluetoothProcessorCoordinator( @callback def async_register_processor( self, - processor: PassiveBluetoothDataProcessor, + processor: PassiveBluetoothDataProcessor[Any, _DataT], entity_description_class: type[EntityDescription] | None = None, ) -> Callable[[], None]: """Register a processor that subscribes to updates.""" @@ -374,7 +370,7 @@ class PassiveBluetoothProcessorCoordinator( try: update = self._update_method(service_info) - except Exception: # pylint: disable=broad-except + except Exception: self.last_update_success = False self.logger.exception("Unexpected error updating %s data", self.name) return @@ -387,13 +383,7 @@ class PassiveBluetoothProcessorCoordinator( processor.async_handle_update(update, was_available) -_PassiveBluetoothDataProcessorT = TypeVar( - "_PassiveBluetoothDataProcessorT", - bound="PassiveBluetoothDataProcessor[Any]", -) - - -class PassiveBluetoothDataProcessor(Generic[_T]): +class PassiveBluetoothDataProcessor[_T, _DataT]: """Passive bluetooth data processor for bluetooth advertisements. The processor is responsible for keeping track of the bluetooth data @@ -414,7 +404,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): is available in the devices, entity_data, and entity_descriptions attributes. """ - coordinator: PassiveBluetoothProcessorCoordinator + coordinator: PassiveBluetoothProcessorCoordinator[_DataT] data: PassiveBluetoothDataUpdate[_T] entity_names: dict[PassiveBluetoothEntityKey, str | None] entity_data: dict[PassiveBluetoothEntityKey, _T] @@ -424,7 +414,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): def __init__( self, - update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]], + update_method: Callable[[_DataT], PassiveBluetoothDataUpdate[_T]], restore_key: str | None = None, ) -> None: """Initialize the coordinator.""" @@ -445,7 +435,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): @callback def async_register_coordinator( self, - coordinator: PassiveBluetoothProcessorCoordinator, + coordinator: PassiveBluetoothProcessorCoordinator[_DataT], entity_description_class: type[EntityDescription] | None, ) -> None: """Register a coordinator.""" @@ -483,7 +473,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): @callback def async_add_entities_listener( self, - entity_class: type[PassiveBluetoothProcessorEntity], + entity_class: type[PassiveBluetoothProcessorEntity[Self]], async_add_entities: AddEntitiesCallback, ) -> Callable[[], None]: """Add a listener for new entities.""" @@ -496,7 +486,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): """Listen for new entities.""" if data is None or created.issuperset(data.entity_descriptions): return - entities: list[PassiveBluetoothProcessorEntity] = [] + entities: list[PassiveBluetoothProcessorEntity[Self]] = [] for entity_key, description in data.entity_descriptions.items(): if entity_key not in created: entities.append(entity_class(self, entity_key, description)) @@ -579,12 +569,12 @@ class PassiveBluetoothDataProcessor(Generic[_T]): @callback def async_handle_update( - self, update: _T, was_available: bool | None = None + self, update: _DataT, was_available: bool | None = None ) -> None: """Handle a Bluetooth event.""" try: new_data = self.update_method(update) - except Exception: # pylint: disable=broad-except + except Exception: self.last_update_success = False self.coordinator.logger.exception( "Unexpected error updating %s data", self.coordinator.name @@ -608,7 +598,9 @@ class PassiveBluetoothDataProcessor(Generic[_T]): self.async_update_listeners(new_data, was_available, changed_entity_keys) -class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): +class PassiveBluetoothProcessorEntity[ + _PassiveBluetoothDataProcessorT: PassiveBluetoothDataProcessor[Any, Any] +](Entity): """A class for entities using PassiveBluetoothDataProcessor.""" _attr_has_entity_name = True @@ -667,7 +659,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce @callback def _handle_processor_update( - self, new_data: PassiveBluetoothDataUpdate | None + self, + new_data: PassiveBluetoothDataUpdate[_PassiveBluetoothDataProcessorT] | None, ) -> None: """Handle updated data from the processor.""" self.async_write_ha_state() diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index eb2f8c0cf82..880824aeccf 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -83,11 +83,6 @@ class BasePassiveBluetoothCoordinator(ABC): # was set when the unavailable callback was called. return self._last_unavailable_time - @property - def available(self) -> bool: - """Return if the device is available.""" - return self._available - @callback def _async_start(self) -> None: """Start the callbacks.""" diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 5374b52e684..49990977f71 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -28,10 +28,3 @@ SCAN_INTERVALS = { "north_america": 600, "rest_of_world": 300, } - -CLIMATE_ACTIVITY_STATE: list[str] = [ - "cooling", - "heating", - "inactive", - "standby", -] diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 14875c54719..6e0ed2ab670 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -50,6 +50,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): update_interval=timedelta(seconds=SCAN_INTERVALS[entry.data[CONF_REGION]]), ) + # Default to false on init so _async_update_data logic works + self.last_update_success = False + async def _async_update_data(self) -> None: """Fetch data from BMW.""" old_refresh_token = self.account.refresh_token diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index bbfadcef9db..e138f31ba24 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -65,11 +65,13 @@ class BMWLock(BMWBaseEntity, LockEntity): try: await self.vehicle.remote_services.trigger_remote_door_lock() except MyBMWAPIError as ex: - self._attr_is_locked = False + # Set the state to unknown if the command fails + self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError(ex) from ex - - self.coordinator.async_update_listeners() + finally: + # Always update the listeners to get the latest state + self.coordinator.async_update_listeners() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" @@ -83,11 +85,13 @@ class BMWLock(BMWBaseEntity, LockEntity): try: await self.vehicle.remote_services.trigger_remote_door_unlock() except MyBMWAPIError as ex: - self._attr_is_locked = True + # Set the state to unknown if the command fails + self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError(ex) from ex - - self.coordinator.async_update_listeners() + finally: + # Always update the listeners to get the latest state + self.coordinator.async_update_listeners() @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c6b180ca728..d90b35187aa 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.15.2"] + "requirements": ["bimmer-connected[china]==0.15.3"] } diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 409002b48e9..2522c6bf2a6 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -33,8 +33,8 @@ class BMWSelectEntityDescription(SelectEntityDescription): dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None -SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { - "ac_limit": BMWSelectEntityDescription( +SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = ( + BMWSelectEntityDescription( key="ac_limit", translation_key="ac_limit", is_available=lambda v: v.is_remote_set_ac_limit_enabled, @@ -48,17 +48,17 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { ), unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), - "charging_mode": BMWSelectEntityDescription( + BMWSelectEntityDescription( key="charging_mode", translation_key="charging_mode", is_available=lambda v: v.is_charging_plan_supported, - options=[c.value for c in ChargingMode if c != ChargingMode.UNKNOWN], - current_option=lambda v: str(v.charging_profile.charging_mode.value), # type: ignore[union-attr] + options=[c.value.lower() for c in ChargingMode if c != ChargingMode.UNKNOWN], + current_option=lambda v: v.charging_profile.charging_mode.value.lower(), # type: ignore[union-attr] remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( charging_mode=ChargingMode(o) ), ), -} +) async def async_setup_entry( @@ -76,7 +76,7 @@ async def async_setup_entry( entities.extend( [ BMWSelect(coordinator, vehicle, description) - for description in SELECT_TYPES.values() + for description in SELECT_TYPES if description.is_available(vehicle) ] ) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index d3366543c55..1d9737c7d5f 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -4,11 +4,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import datetime import logging -from typing import cast -from bimmer_connected.models import ValueWithUnit +from bimmer_connected.models import StrEnum, ValueWithUnit from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.climate import ClimateActivityState +from bimmer_connected.vehicle.fuel_and_battery import ChargingState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -17,13 +19,19 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent +from homeassistant.const import ( + PERCENTAGE, + STATE_UNKNOWN, + UnitOfElectricCurrent, + UnitOfLength, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from . import BMWBaseEntity -from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP +from .const import DOMAIN from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -34,34 +42,18 @@ class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" key_class: str | None = None - unit_type: str | None = None - value: Callable = lambda x, y: x is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled -def convert_and_round( - state: ValueWithUnit, - converter: Callable[[float | None, str], float], - precision: int, -) -> float | None: - """Safely convert and round a value from ValueWithUnit.""" - if state.value and state.unit: - return round( - converter(state.value, UNIT_MAP.get(state.unit, state.unit)), precision - ) - if state.value: - return state.value - return None - - SENSOR_TYPES: list[BMWSensorEntityDescription] = [ - # --- Generic --- BMWSensorEntityDescription( key="ac_current_limit", translation_key="ac_current_limit", key_class="charging_profile", - unit_type=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( @@ -83,74 +75,83 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ key="charging_status", translation_key="charging_status", key_class="fuel_and_battery", - value=lambda x, y: x.value, + device_class=SensorDeviceClass.ENUM, + options=[s.value.lower() for s in ChargingState if s != ChargingState.UNKNOWN], is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="charging_target", translation_key="charging_target", key_class="fuel_and_battery", - unit_type=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="remaining_battery_percent", translation_key="remaining_battery_percent", key_class="fuel_and_battery", - unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), - # --- Specific --- BMWSensorEntityDescription( key="mileage", translation_key="mileage", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), BMWSensorEntityDescription( key="remaining_range_total", translation_key="remaining_range_total", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), BMWSensorEntityDescription( key="remaining_range_electric", translation_key="remaining_range_electric", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="remaining_range_fuel", translation_key="remaining_range_fuel", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( key="remaining_fuel", translation_key="remaining_fuel", key_class="fuel_and_battery", - unit_type=VOLUME, - value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( key="remaining_fuel_percent", translation_key="remaining_fuel_percent", key_class="fuel_and_battery", - unit_type=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( @@ -158,8 +159,11 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ translation_key="climate_status", key_class="climate", device_class=SensorDeviceClass.ENUM, - options=CLIMATE_ACTIVITY_STATE, - value=lambda x, _: x.lower() if x != "UNKNOWN" else None, + options=[ + s.value.lower() + for s in ClimateActivityState + if s != ClimateActivityState.UNKNOWN + ], is_available=lambda v: v.is_remote_climate_stop_enabled, ), ] @@ -199,13 +203,6 @@ class BMWSensor(BMWBaseEntity, SensorEntity): self.entity_description = description self._attr_unique_id = f"{vehicle.vin}-{description.key}" - # Set the correct unit of measurement based on the unit_type - if description.unit_type: - self._attr_native_unit_of_measurement = ( - coordinator.hass.config.units.as_dict().get(description.unit_type) - or description.unit_type - ) - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -219,7 +216,18 @@ class BMWSensor(BMWBaseEntity, SensorEntity): getattr(self.vehicle, self.entity_description.key_class), self.entity_description.key, ) - self._attr_native_value = cast( - StateType, self.entity_description.value(state, self.hass) - ) + + # For datetime without tzinfo, we assume it to be the same timezone as the HA instance + if isinstance(state, datetime.datetime) and state.tzinfo is None: + state = state.replace(tzinfo=dt_util.get_default_time_zone()) + # For enum types, we only want the value + elif isinstance(state, ValueWithUnit): + state = state.value + # Get lowercase values from StrEnum + elif isinstance(state, StrEnum): + state = state.value.lower() + if state == STATE_UNKNOWN: + state = None + + self._attr_native_value = state super()._handle_coordinator_update() diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 539c281a1a5..587b13f084d 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -83,7 +83,11 @@ "name": "AC Charging Limit" }, "charging_mode": { - "name": "Charging Mode" + "name": "Charging Mode", + "state": { + "immediate_charging": "Immediate charging", + "delayed_charging": "Delayed charging" + } } }, "sensor": { @@ -97,7 +101,21 @@ "name": "Charging end time" }, "charging_status": { - "name": "Charging status" + "name": "Charging status", + "state": { + "default": "Default", + "charging": "Charging", + "error": "Error", + "complete": "Complete", + "fully_charged": "Fully charged", + "finished_fully_charged": "Finished, fully charged", + "finished_not_full": "Finished, not full", + "invalid": "Invalid", + "not_charging": "Not charging", + "plugged_in": "Plugged in", + "waiting_for_charging": "Waiting for charging", + "target_reached": "Target reached" + } }, "charging_target": { "name": "Charging target" diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index d534e10b023..eb28bebdb06 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -35,7 +35,7 @@ _API_TIMEOUT = SLOW_UPDATE_WARNING - 1 _LOGGER = logging.getLogger(__name__) -BondConfigEntry = ConfigEntry[BondData] +type BondConfigEntry = ConfigEntry[BondData] async def async_setup_entry(hass: HomeAssistant, entry: BondConfigEntry) -> bool: diff --git a/homeassistant/components/bond/diagnostics.py b/homeassistant/components/bond/diagnostics.py index 212df43a450..94361097362 100644 --- a/homeassistant/components/bond/diagnostics.py +++ b/homeassistant/components/bond/diagnostics.py @@ -24,14 +24,14 @@ async def async_get_config_entry_diagnostics( "data": async_redact_data(entry.data, TO_REDACT), }, "hub": { - "version": hub._version, # pylint: disable=protected-access + "version": hub._version, # noqa: SLF001 }, "devices": [ { "device_id": device.device_id, "props": device.props, - "attrs": device._attrs, # pylint: disable=protected-access - "supported_actions": device._supported_actions, # pylint: disable=protected-access + "attrs": device._attrs, # noqa: SLF001 + "supported_actions": device._supported_actions, # noqa: SLF001 } for device in hub.devices ], diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 5483c080f39..6279f3ca932 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -124,7 +124,7 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN): self.info = await self._get_info(self.host) except SHCConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -161,7 +161,7 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN): except SHCRegistrationError as err: _LOGGER.warning("Registration error: %s", err.message) errors["base"] = "pairing_failed" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index b7697191d27..06ce45cdb3a 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -5,7 +5,8 @@ from __future__ import annotations from boschshcpy import SHCDevice, SHCIntrusionSystem from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo, async_get as get_dev_reg +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -15,7 +16,7 @@ async def async_remove_devices( hass: HomeAssistant, entity: SHCBaseEntity, entry_id: str ) -> None: """Get item that is removed from session.""" - dev_registry = get_dev_reg(hass) + dev_registry = dr.async_get(hass) device = dev_registry.async_get_device(identifiers={(DOMAIN, entity.device_id)}) if device is not None: dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id) diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 15e6744ceb8..e08e88073f3 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from functools import wraps import logging from types import MappingProxyType -from typing import Any, Concatenate, Final, ParamSpec, TypeVar +from typing import Any, Concatenate, Final from pybravia import ( BraviaAuthError, @@ -35,14 +35,12 @@ from .const import ( SourceType, ) -_BraviaTVCoordinatorT = TypeVar("_BraviaTVCoordinatorT", bound="BraviaTVCoordinator") -_P = ParamSpec("_P") _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=10) -def catch_braviatv_errors( +def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P]( func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]], ) -> Callable[Concatenate[_BraviaTVCoordinatorT, _P], Coroutine[Any, Any, None]]: """Catch Bravia errors.""" diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 003daa64beb..30cbbbbbfa0 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -14,7 +14,7 @@ from bring_api.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -24,7 +24,7 @@ PLATFORMS: list[Platform] = [Platform.TODO] _LOGGER = logging.getLogger(__name__) -BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] +type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: @@ -38,7 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo try: await bring.login() - await bring.load_lists() except BringRequestException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -47,10 +46,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo except BringParseException as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, - translation_key="setup_request_exception", + translation_key="setup_parse_exception", ) from e except BringAuthException as e: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="setup_authentication_exception", translation_placeholders={CONF_EMAIL: email}, diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 1fbddeb7bfe..333837a20f2 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -2,15 +2,17 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from bring_api.bring import Bring from bring_api.exceptions import BringAuthException, BringRequestException +from bring_api.types import BringAuthResponse import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -18,6 +20,7 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) +from . import BringConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -42,33 +45,75 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Bring!.""" VERSION = 1 + reauth_entry: BringConfigEntry | None = None + info: BringAuthResponse async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} - if user_input is not None: - session = async_get_clientsession(self.hass) - bring = Bring(session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) - - try: - await bring.login() - await bring.load_lists() - except BringRequestException: - errors["base"] = "cannot_connect" - except BringAuthException: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(bring.uuid) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_EMAIL], data=user_input - ) + if user_input is not None and not ( + errors := await self.validate_input(user_input) + ): + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.info["name"] or user_input[CONF_EMAIL], data=user_input + ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + assert self.reauth_entry + + if user_input is not None: + if not (errors := await self.validate_input(user_input)): + return self.async_update_reload_and_abort( + self.reauth_entry, data=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values={CONF_EMAIL: self.reauth_entry.data[CONF_EMAIL]}, + ), + description_placeholders={CONF_NAME: self.reauth_entry.title}, + errors=errors, + ) + + async def validate_input(self, user_input: Mapping[str, Any]) -> dict[str, str]: + """Auth Helper.""" + + errors: dict[str, str] = {} + session = async_get_clientsession(self.hass) + bring = Bring(session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) + + try: + self.info = await bring.login() + except BringRequestException: + errors["base"] = "cannot_connect" + except BringAuthException: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(bring.uuid) + return errors diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 057e7549503..222c650e614 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -6,11 +6,17 @@ from datetime import timedelta import logging from bring_api.bring import Bring -from bring_api.exceptions import BringParseException, BringRequestException -from bring_api.types import BringList, BringPurchase +from bring_api.exceptions import ( + BringAuthException, + BringParseException, + BringRequestException, +) +from bring_api.types import BringItemsResponse, BringList from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -18,12 +24,9 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class BringData(BringList): +class BringData(BringList, BringItemsResponse): """Coordinator data class.""" - purchase_items: list[BringPurchase] - recently_items: list[BringPurchase] - class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): """A Bring Data Update Coordinator.""" @@ -47,8 +50,24 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e + except BringAuthException as e: + # try to recover by refreshing access token, otherwise + # initiate reauth flow + try: + await self.bring.retrieve_new_access_token() + except (BringRequestException, BringParseException) as exc: + raise UpdateFailed("Refreshing authentication token failed") from exc + except BringAuthException as exc: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.bring.mail}, + ) from exc + raise UpdateFailed( + "Authentication failed but re-authentication was successful, trying again later" + ) from e - list_dict = {} + list_dict: dict[str, BringData] = {} for lst in lists_response["lists"]: try: items = await self.bring.get_list(lst["listUuid"]) @@ -58,8 +77,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): ) from e except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e - lst["purchase_items"] = items["purchase"] - lst["recently_items"] = items["recently"] - list_dict[lst["listUuid"]] = lst + else: + list_dict[lst["listUuid"]] = BringData(**lst, **items) return list_dict diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index be2c5633362..1b781813203 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["bring-api==0.5.7"] + "requirements": ["bring-api==0.7.1"] } diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index e6df885cbbc..652958a1b1f 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -6,6 +6,14 @@ "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Bring! integration needs to re-authenticate your account", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -14,7 +22,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "exceptions": { @@ -60,8 +69,8 @@ "description": "Type of push notification to send to list members." }, "item": { - "name": "Item (Required if message type `Breaking news` selected)", - "description": "Item name to include in a breaking news message e.g. `Breaking news - Please get cilantro!`" + "name": "Article (Required if message type `Urgent Message` selected)", + "description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" } } } @@ -69,10 +78,10 @@ "selector": { "notification_type_selector": { "options": { - "going_shopping": "I'm going shopping! - Last chance for adjustments", - "changed_list": "List changed - Check it out", - "shopping_done": "Shopping done - you can relax", - "urgent_message": "Breaking news - Please get `item`!" + "going_shopping": "I'm going shopping! - Last chance to make changes", + "changed_list": "List updated - Take a look at the articles", + "shopping_done": "Shopping done - The fridge is well stocked", + "urgent_message": "Urgent Message - Please buy `Article name` urgently" } } } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 56527389dd5..f3ba70f6cc5 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -107,7 +107,7 @@ class BringTodoListEntity( description=item["specification"] or "", status=TodoItemStatus.NEEDS_ACTION, ) - for item in self.bring_list["purchase_items"] + for item in self.bring_list["purchase"] ), *( TodoItem( @@ -116,7 +116,7 @@ class BringTodoListEntity( description=item["specification"] or "", status=TodoItemStatus.COMPLETED, ) - for item in self.bring_list["recently_items"] + for item in self.bring_list["recently"] ), ] @@ -130,7 +130,7 @@ class BringTodoListEntity( try: await self.coordinator.bring.save_item( self.bring_list["listUuid"], - item.summary, + item.summary or "", item.description or "", str(uuid.uuid4()), ) @@ -165,12 +165,12 @@ class BringTodoListEntity( bring_list = self.bring_list bring_purchase_item = next( - (i for i in bring_list["purchase_items"] if i["uuid"] == item.uid), + (i for i in bring_list["purchase"] if i["uuid"] == item.uid), None, ) bring_recently_item = next( - (i for i in bring_list["recently_items"] if i["uuid"] == item.uid), + (i for i in bring_list["recently"] if i["uuid"] == item.uid), None, ) @@ -185,8 +185,8 @@ class BringTodoListEntity( await self.coordinator.bring.batch_update_list( bring_list["listUuid"], BringItem( - itemId=item.summary, - spec=item.description, + itemId=item.summary or "", + spec=item.description or "", uuid=item.uid, ), BringItemOperation.ADD @@ -206,13 +206,13 @@ class BringTodoListEntity( [ BringItem( itemId=current_item["itemId"], - spec=item.description, + spec=item.description or "", uuid=item.uid, operation=BringItemOperation.REMOVE, ), BringItem( - itemId=item.summary, - spec=item.description, + itemId=item.summary or "", + spec=item.description or "", uuid=str(uuid.uuid4()), operation=BringItemOperation.ADD if item.status == TodoItemStatus.NEEDS_ACTION diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 77c9ea0ff98..710b4a34a11 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -149,7 +149,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): try: codes = self._codes[device][cmd] except KeyError as err: - raise ValueError(f"Command not found: {repr(cmd)}") from err + raise ValueError(f"Command not found: {cmd!r}") from err if isinstance(codes, list): codes = codes[:] @@ -160,7 +160,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): try: codes[idx] = data_packet(code) except ValueError as err: - raise ValueError(f"Invalid code: {repr(code)}") from err + raise ValueError(f"Invalid code: {code!r}") from err code_list.append(codes) return code_list @@ -448,7 +448,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): try: codes = self._codes[subdevice] except KeyError as err: - err_msg = f"Device not found: {repr(subdevice)}" + err_msg = f"Device not found: {subdevice!r}" _LOGGER.error("Failed to call %s. %s", service, err_msg) raise ValueError(err_msg) from err @@ -461,9 +461,9 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): if cmds_not_found: if len(cmds_not_found) == 1: - err_msg = f"Command not found: {repr(cmds_not_found[0])}" + err_msg = f"Command not found: {cmds_not_found[0]!r}" else: - err_msg = f"Commands not found: {repr(cmds_not_found)}" + err_msg = f"Commands not found: {cmds_not_found!r}" if len(cmds_not_found) == len(commands): _LOGGER.error("Failed to call %s. %s", service, err_msg) diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 08376574dcf..e828d35f9c7 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -4,18 +4,17 @@ from __future__ import annotations from brother import Brother, SnmpError -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.components.snmp import async_get_snmp_engine +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 -from .const import DOMAIN, SNMP from .coordinator import BrotherDataUpdateCoordinator -from .utils import get_snmp_engine PLATFORMS = [Platform.SENSOR] -BrotherConfigEntry = ConfigEntry[BrotherDataUpdateCoordinator] +type BrotherConfigEntry = ConfigEntry[BrotherDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool: @@ -23,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b host = entry.data[CONF_HOST] printer_type = entry.data[CONF_TYPE] - snmp_engine = get_snmp_engine(hass) + snmp_engine = await async_get_snmp_engine(hass) try: brother = await Brother.create( host, printer_type=printer_type, snmp_engine=snmp_engine @@ -35,7 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - hass.data.setdefault(DOMAIN, {SNMP: snmp_engine}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -44,15 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> 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 - ] - # We only want to remove the SNMP engine when unloading the last config entry - if unload_ok and len(loaded_entries) == 1: - hass.data[DOMAIN].pop(SNMP) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index ca2f1ae5a39..4536cb9c4d5 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -2,26 +2,46 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from brother import Brother, SnmpError, UnsupportedModelError import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.components.snmp import async_get_snmp_engine +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util.network import is_host_valid from .const import DOMAIN, PRINTER_TYPES -from .utils import get_snmp_engine DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST, default=""): str, + vol.Required(CONF_HOST): str, vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES), } ) +RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any], expected_mac: str | None = None +) -> tuple[str, str]: + """Validate the user input.""" + if not is_host_valid(user_input[CONF_HOST]): + raise InvalidHost + + snmp_engine = await async_get_snmp_engine(hass) + + brother = await Brother.create(user_input[CONF_HOST], snmp_engine=snmp_engine) + await brother.async_update() + + if expected_mac is not None and brother.serial.lower() != expected_mac: + raise AnotherDevice + + return (brother.model, brother.serial) class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): @@ -33,6 +53,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize.""" self.brother: Brother self.host: str | None = None + self.entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -42,21 +63,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - if not is_host_valid(user_input[CONF_HOST]): - raise InvalidHost - - snmp_engine = get_snmp_engine(self.hass) - - brother = await Brother.create( - user_input[CONF_HOST], snmp_engine=snmp_engine - ) - await brother.async_update() - - await self.async_set_unique_id(brother.serial.lower()) - self._abort_if_unique_id_configured() - - title = f"{brother.model} {brother.serial}" - return self.async_create_entry(title=title, data=user_input) + model, serial = await validate_input(self.hass, user_input) except InvalidHost: errors[CONF_HOST] = "wrong_host" except (ConnectionError, TimeoutError): @@ -65,6 +72,12 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "snmp_error" except UnsupportedModelError: return self.async_abort(reason="unsupported_model") + else: + await self.async_set_unique_id(serial.lower()) + self._abort_if_unique_id_configured() + + title = f"{model} {serial}" + return self.async_create_entry(title=title, data=user_input) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors @@ -79,7 +92,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: self.host}) - snmp_engine = get_snmp_engine(self.hass) + snmp_engine = await async_get_snmp_engine(self.hass) model = discovery_info.properties.get("product") try: @@ -127,6 +140,61 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + if TYPE_CHECKING: + assert entry is not None + + self.entry = entry + + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors = {} + + if TYPE_CHECKING: + assert self.entry is not None + + if user_input is not None: + try: + await validate_input(self.hass, user_input, self.entry.unique_id) + except InvalidHost: + errors[CONF_HOST] = "wrong_host" + except (ConnectionError, TimeoutError): + errors["base"] = "cannot_connect" + except SnmpError: + errors["base"] = "snmp_error" + except AnotherDevice: + errors["base"] = "another_device" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data=self.entry.data | {CONF_HOST: user_input[CONF_HOST]}, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=RECONFIGURE_SCHEMA, + suggested_values=self.entry.data | (user_input or {}), + ), + description_placeholders={"printer_name": self.entry.title}, + errors=errors, + ) + class InvalidHost(HomeAssistantError): """Error to indicate that hostname/IP address is invalid.""" + + +class AnotherDevice(HomeAssistantError): + """Error to indicate that hostname/IP address belongs to another device.""" diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index f8d29363acd..c0ae7cf60b0 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -9,6 +9,4 @@ DOMAIN: Final = "brother" PRINTER_TYPES: Final = ["laser", "ink"] -SNMP: Final = "snmp" - UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 3bbaf40f686..5caaeb2f1a1 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -1,6 +1,7 @@ { "domain": "brother", "name": "Brother Printer", + "after_dependencies": ["snmp"], "codeowners": ["@bieniu"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brother", @@ -8,7 +9,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==4.1.0"], + "requirements": ["brother==4.2.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 0d8f4f4eedf..d7f8f4a1b89 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -17,16 +17,27 @@ "data": { "type": "[%key:component::brother::config::step::user::data::type%]" } + }, + "reconfigure_confirm": { + "description": "Update configuration for {printer_name}.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::brother::config::step::user::data_description::host%]" + } } }, "error": { "wrong_host": "Invalid hostname or IP address.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "snmp_error": "SNMP server turned off or printer not supported." + "snmp_error": "SNMP server turned off or printer not supported.", + "another_device": "The IP address or hostname of another Brother printer was used." }, "abort": { "unsupported_model": "This printer model is not supported.", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/homeassistant/components/brother/utils.py b/homeassistant/components/brother/utils.py deleted file mode 100644 index d7636cdd2e8..00000000000 --- a/homeassistant/components/brother/utils.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Brother helpers functions.""" - -from __future__ import annotations - -import logging - -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 -from homeassistant.helpers import singleton - -from .const import DOMAIN, SNMP - -_LOGGER = logging.getLogger(__name__) - - -@singleton.singleton("snmp_engine") -def get_snmp_engine(hass: HomeAssistant) -> hlapi.SnmpEngine: - """Get SNMP engine.""" - _LOGGER.debug("Creating SNMP engine") - snmp_engine = hlapi.SnmpEngine() - - @callback - def shutdown_listener(ev: Event) -> None: - if hass.data.get(DOMAIN): - _LOGGER.debug("Unconfiguring SNMP engine") - lcd.unconfigure(hass.data[DOMAIN][SNMP], None) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) - - return snmp_engine diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py index 65886c3081c..ecb2dd41d6f 100644 --- a/homeassistant/components/brunt/config_flow.py +++ b/homeassistant/components/brunt/config_flow.py @@ -43,7 +43,7 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str] | None: except ServerDisconnectedError: _LOGGER.warning("Cannot connect to Brunt") errors = {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error when trying to login to Brunt") errors = {"base": "unknown"} finally: diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index dab7a7db158..6f17adeeca7 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -15,11 +15,8 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import ( - CONNECTION_BLUETOOTH, - DeviceRegistry, - async_get, -) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.signal_type import SignalType @@ -130,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: kwargs[CONF_BINDKEY] = bytes.fromhex(bindkey) data = BTHomeBluetoothDeviceData(**kwargs) - device_registry = async_get(hass) + device_registry = dr.async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( BTHomePassiveBluetoothProcessorCoordinator( hass, diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py index 6de9506c54b..1a311f9f3a4 100644 --- a/homeassistant/components/bthome/binary_sensor.py +++ b/homeassistant/components/bthome/binary_sensor.py @@ -145,7 +145,7 @@ BINARY_SENSOR_DESCRIPTIONS = { def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, -) -> PassiveBluetoothDataUpdate: +) -> PassiveBluetoothDataUpdate[bool | None]: """Convert a binary sensor update to a bluetooth data update.""" return PassiveBluetoothDataUpdate( devices={ @@ -193,7 +193,7 @@ async def async_setup_entry( class BTHomeBluetoothBinarySensorEntity( - PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor[bool | None]], BinarySensorEntity, ): """Representation of a BTHome binary sensor.""" diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index 0abbf20d655..cb2abef6a43 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -2,9 +2,8 @@ from collections.abc import Callable from logging import Logger -from typing import Any -from bthome_ble import BTHomeBluetoothDeviceData +from bthome_ble import BTHomeBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -20,7 +19,9 @@ from homeassistant.core import HomeAssistant from .const import CONF_SLEEPY_DEVICE -class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordinator): +class BTHomePassiveBluetoothProcessorCoordinator( + PassiveBluetoothProcessorCoordinator[SensorUpdate] +): """Define a BTHome Bluetooth Passive Update Processor Coordinator.""" def __init__( @@ -29,7 +30,7 @@ class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordi logger: Logger, address: str, mode: BluetoothScanningMode, - update_method: Callable[[BluetoothServiceInfoBleak], Any], + update_method: Callable[[BluetoothServiceInfoBleak], SensorUpdate], device_data: BTHomeBluetoothDeviceData, discovered_event_classes: set[str], entry: ConfigEntry, @@ -47,7 +48,9 @@ class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordi return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) -class BTHomePassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): +class BTHomePassiveBluetoothDataProcessor[_T]( + PassiveBluetoothDataProcessor[_T, SensorUpdate] +): """Define a BTHome Bluetooth Passive Update Data Processor.""" coordinator: BTHomePassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py index 23976e368ad..be5e156e99c 100644 --- a/homeassistant/components/bthome/logbook.py +++ b/homeassistant/components/bthome/logbook.py @@ -6,7 +6,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers import device_registry as dr from .const import BTHOME_BLE_EVENT, DOMAIN, BTHomeBleEvent @@ -19,13 +19,13 @@ def async_describe_events( ], ) -> None: """Describe logbook events.""" - dr = async_get(hass) + dev_reg = dr.async_get(hass) @callback def async_describe_bthome_event(event: Event[BTHomeBleEvent]) -> dict[str, str]: """Describe bthome logbook event.""" data = event.data - device = dr.async_get(data["device_id"]) + device = dev_reg.async_get(data["device_id"]) name = device and device.name or f'BTHome {data["address"]}' if properties := data["event_properties"]: message = f"{data['event_class']} {data['event_type']}: {properties}" diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 7c90c6f3bbc..42fbe794918 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.8.1"] + "requirements": ["bthome-ble==3.9.1"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 179979707b2..2178481b21a 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdate, Units from bthome_ble.const import ( ExtendedSensorDeviceClass as BTHomeExtendedSensorDeviceClass, @@ -363,7 +365,7 @@ SENSOR_DESCRIPTIONS = { def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, -) -> PassiveBluetoothDataUpdate: +) -> PassiveBluetoothDataUpdate[float | None]: """Convert a sensor update to a bluetooth data update.""" return PassiveBluetoothDataUpdate( devices={ @@ -378,7 +380,9 @@ def sensor_update_to_bluetooth_data_update( if description.device_class }, entity_data={ - device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + device_key_to_bluetooth_entity_key(device_key): cast( + float | None, sensor_values.native_value + ) for device_key, sensor_values in sensor_update.entity_values.items() }, entity_names={ @@ -411,7 +415,7 @@ async def async_setup_entry( class BTHomeBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor[float | None]], SensorEntity, ): """Representation of a BTHome BLE sensor.""" diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index 4885f45032c..5b08f5c631a 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/buienradar", "iot_class": "cloud_polling", "loggers": ["buienradar", "vincenty"], - "requirements": ["buienradar==1.0.5"] + "requirements": ["buienradar==1.0.6"] } diff --git a/homeassistant/components/caldav/config_flow.py b/homeassistant/components/caldav/config_flow.py index 3710f7f1b4b..9e1d1098f45 100644 --- a/homeassistant/components/caldav/config_flow.py +++ b/homeassistant/components/caldav/config_flow.py @@ -82,7 +82,7 @@ class CalDavConfigFlow(ConfigFlow, domain=DOMAIN): except DAVError as err: _LOGGER.warning("CalDAV client error: %s", err) return "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return "unknown" return None diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index 380471284de..3a10b567167 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -196,7 +196,9 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Return a datetime.""" if isinstance(obj, datetime): return CalDavUpdateCoordinator.to_local(obj) - return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + return datetime.combine(obj, time.min).replace( + tzinfo=dt_util.get_default_time_zone() + ) @staticmethod def to_local(obj: datetime | date) -> datetime | date: diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 47ea10b71b6..621356f20e2 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -38,7 +38,6 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_time -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -268,8 +267,6 @@ CALENDAR_EVENT_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -LEGACY_SERVICE_LIST_EVENTS: Final = "list_events" -"""Deprecated: please use SERVICE_LIST_EVENTS.""" SERVICE_GET_EVENTS: Final = "get_events" SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( cv.has_at_least_one_key(EVENT_END_DATETIME, EVENT_DURATION), @@ -309,12 +306,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_create_event, required_features=[CalendarEntityFeature.CREATE_EVENT], ) - component.async_register_legacy_entity_service( - LEGACY_SERVICE_LIST_EVENTS, - SERVICE_GET_EVENTS_SCHEMA, - async_list_events_service, - supports_response=SupportsResponse.ONLY, - ) component.async_register_entity_service( SERVICE_GET_EVENTS, SERVICE_GET_EVENTS_SCHEMA, @@ -868,32 +859,6 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: await entity.async_create_event(**params) -async def async_list_events_service( - calendar: CalendarEntity, service_call: ServiceCall -) -> ServiceResponse: - """List events on a calendar during a time range. - - Deprecated: please use async_get_events_service. - """ - _LOGGER.warning( - "Detected use of service 'calendar.list_events'. " - "This is deprecated and will stop working in Home Assistant 2024.6. " - "Use 'calendar.get_events' instead which supports multiple entities", - ) - async_create_issue( - calendar.hass, - DOMAIN, - "deprecated_service_calendar_list_events", - breaks_in_ha_version="2024.6.0", - is_fixable=True, - is_persistent=False, - issue_domain=calendar.platform.platform_name, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service_calendar_list_events", - ) - return await async_get_events_service(calendar, service_call) - - async def async_get_events_service( calendar: CalendarEntity, service_call: ServiceCall ) -> ServiceResponse: diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index ad86ab1957d..523a634704c 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -88,8 +88,8 @@ class Timespan: return f"[{self.start}, {self.end})" -EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] -QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] +type EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] +type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity: diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 861b184975b..4d2ba00900f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -335,7 +335,7 @@ def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera: # stream_id: A unique id for the stream, used to update an existing source # The output is the SDP answer, or None if the source or offer is not eligible. # The Callable may throw HomeAssistantError on failure. -RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]] +type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]] def async_register_rtsp_to_web_rtc_provider( @@ -698,11 +698,11 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): await self.hass.async_add_executor_job(self.turn_off) def turn_on(self) -> None: - """Turn off camera.""" + """Turn on camera.""" raise NotImplementedError async def async_turn_on(self) -> None: - """Turn off camera.""" + """Turn on camera.""" await self.hass.async_add_executor_job(self.turn_on) def enable_motion_detection(self) -> None: diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index b9b607d5edf..bbe85bf82db 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -6,7 +6,7 @@ from contextlib import suppress import logging from typing import TYPE_CHECKING, Literal, cast -with suppress(Exception): # pylint: disable=broad-except +with suppress(Exception): # TurboJPEG imports numpy which may or may not work so # we have to guard the import here. We still want # to import it at top level so it gets loaded @@ -98,8 +98,14 @@ class TurboJPEGSingleton: """Try to create TurboJPEG only once.""" try: TurboJPEGSingleton.__instance = TurboJPEG() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error loading libturbojpeg; Camera snapshot performance will be sub-optimal" ) TurboJPEGSingleton.__instance = False + + +# TurboJPEG loads libraries that do blocking I/O. +# Initialize TurboJPEGSingleton in the executor to avoid +# blocking the event loop. +TurboJPEGSingleton.instance() diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 445579b9e4a..a7d5dc8ab98 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -53,6 +53,7 @@ class CanaryAlarm( | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False def __init__( self, coordinator: CanaryDataUpdateCoordinator, location: Location diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index f586a7e4e85..6ae7632a7e2 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -82,7 +82,7 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN): ) except (ConnectTimeout, HTTPError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json index e6bc52540d5..4d5adf4a32b 100644 --- a/homeassistant/components/canary/manifest.json +++ b/homeassistant/components/canary/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/canary", "iot_class": "cloud_polling", "loggers": ["canary"], - "requirements": ["py-canary==0.5.3"] + "requirements": ["py-canary==0.5.4"] } diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 905214e0d1d..9aab4698bf3 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -21,7 +21,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER from .coordinator import CanaryDataUpdateCoordinator -SensorTypeItem = tuple[str, str | None, str | None, SensorDeviceClass | None, list[str]] +type SensorTypeItem = tuple[ + str, str | None, str | None, SensorDeviceClass | None, list[str] +] SENSOR_VALUE_PRECISION: Final = 2 ATTR_AIR_QUALITY: Final = "air_quality" diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 2d4e1a9dbfa..137bc7ec3c0 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -162,7 +162,7 @@ class CastStatusListener( self._valid = True self._mz_mgr = mz_mgr - if cast_device._cast_info.is_audio_group: + if cast_device._cast_info.is_audio_group: # noqa: SLF001 self._mz_mgr.add_multizone(chromecast) if mz_only: return @@ -170,7 +170,7 @@ class CastStatusListener( chromecast.register_status_listener(self) chromecast.socket_client.media_controller.register_status_listener(self) chromecast.register_connection_listener(self) - if not cast_device._cast_info.is_audio_group: + if not cast_device._cast_info.is_audio_group: # noqa: SLF001 self._mz_mgr.register_listener(chromecast.uuid, self) def new_cast_status(self, status): @@ -214,8 +214,7 @@ class CastStatusListener( All following callbacks won't be forwarded. """ - # pylint: disable-next=protected-access - if self._cast_device._cast_info.is_audio_group: + if self._cast_device._cast_info.is_audio_group: # noqa: SLF001 self._mz_mgr.remove_multizone(self._uuid) else: self._mz_mgr.deregister_listener(self._uuid, self) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index eedbd0dd0b1..028a01e6f22 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -8,7 +8,7 @@ from datetime import datetime from functools import wraps import json import logging -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate import pychromecast from pychromecast.controllers.homeassistant import HomeAssistantController @@ -85,18 +85,12 @@ APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",) CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" - -_CastDeviceT = TypeVar("_CastDeviceT", bound="CastDevice") -_R = TypeVar("_R") -_P = ParamSpec("_P") - -_FuncType = Callable[Concatenate[_CastDeviceT, _P], _R] -_ReturnFuncType = Callable[Concatenate[_CastDeviceT, _P], _R] +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] -def api_error( +def api_error[_CastDeviceT: CastDevice, **_P, _R]( func: _FuncType[_CastDeviceT, _P, _R], -) -> _ReturnFuncType[_CastDeviceT, _P, _R]: +) -> _FuncType[_CastDeviceT, _P, _R]: """Handle PyChromecastError and reraise a HomeAssistantError.""" @wraps(func) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index b4038fbbf43..a6e5d2cab61 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -57,6 +57,7 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): HVACMode.HEAT, HVACMode.COOL, HVACMode.DRY, + HVACMode.FAN_ONLY, HVACMode.AUTO, ] _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py index f115aa8f6e1..0e49e0929e5 100644 --- a/homeassistant/components/ccm15/config_flow.py +++ b/homeassistant/components/ccm15/config_flow.py @@ -42,7 +42,7 @@ class CCM15ConfigFlow(ConfigFlow, domain=DOMAIN): try: if not await ccm15.async_test_connection(): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 2387c2a73c3..bc6ae29ee8e 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -11,7 +11,7 @@ from .coordinator import CertExpiryDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -CertExpiryConfigEntry = ConfigEntry[CertExpiryDataUpdateCoordinator] +type CertExpiryConfigEntry = ConfigEntry[CertExpiryDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: CertExpiryConfigEntry) -> bool: diff --git a/homeassistant/components/circuit/__init__.py b/homeassistant/components/circuit/__init__.py deleted file mode 100644 index 7e7d0eda76e..00000000000 --- a/homeassistant/components/circuit/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -"""The Unify Circuit component.""" - -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_URL, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery -import homeassistant.helpers.issue_registry as ir -from homeassistant.helpers.typing import ConfigType - -DOMAIN = "circuit" -CONF_WEBHOOK = "webhook" - -WEBHOOK_SCHEMA = vol.Schema( - {vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.string} -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_WEBHOOK): vol.All(cv.ensure_list, [WEBHOOK_SCHEMA])} - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Unify Circuit component.""" - ir.async_create_issue( - hass, - DOMAIN, - "service_removal", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_removal", - translation_placeholders={"integration": "Unify Circuit", "domain": DOMAIN}, - ) - webhooks = config[DOMAIN][CONF_WEBHOOK] - - for webhook_conf in webhooks: - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, webhook_conf, config - ) - ) - - return True diff --git a/homeassistant/components/circuit/manifest.json b/homeassistant/components/circuit/manifest.json deleted file mode 100644 index d982aef31ec..00000000000 --- a/homeassistant/components/circuit/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "circuit", - "name": "Unify Circuit", - "codeowners": ["@braam"], - "documentation": "https://www.home-assistant.io/integrations/circuit", - "iot_class": "cloud_push", - "loggers": ["circuit_webhook"], - "requirements": ["circuit-webhook==1.0.1"] -} diff --git a/homeassistant/components/circuit/notify.py b/homeassistant/components/circuit/notify.py deleted file mode 100644 index 23884ebd9be..00000000000 --- a/homeassistant/components/circuit/notify.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Unify Circuit platform for notify component.""" - -from __future__ import annotations - -import logging - -from circuit_webhook import Circuit - -from homeassistant.components.notify import BaseNotificationService -from homeassistant.const import CONF_URL -from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - - -def get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> CircuitNotificationService | None: - """Get the Unify Circuit notification service.""" - if discovery_info is None: - return None - - return CircuitNotificationService(discovery_info) - - -class CircuitNotificationService(BaseNotificationService): - """Implement the notification service for Unify Circuit.""" - - def __init__(self, config): - """Initialize the service.""" - self.webhook_url = config[CONF_URL] - - def send_message(self, message=None, **kwargs): - """Send a message to the webhook.""" - - webhook_url = self.webhook_url - - if webhook_url and message: - try: - circuit_message = Circuit(url=webhook_url) - circuit_message.post(text=message) - except RuntimeError as err: - _LOGGER.error("Could not send notification. Error: %s", err) diff --git a/homeassistant/components/circuit/strings.json b/homeassistant/components/circuit/strings.json deleted file mode 100644 index b9cb852d5b9..00000000000 --- a/homeassistant/components/circuit/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "service_removal": { - "title": "The {integration} integration is being removed", - "description": "The {integration} integration will be removed, as the service is no longer maintained.\n\n\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 9084a138350..ac6297dc5b6 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -45,7 +45,6 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue from homeassistant.util.unit_conversion import TemperatureConverter -from . import group as group_pre_import # noqa: F401 from .const import ( # noqa: F401 _DEPRECATED_HVAC_MODE_AUTO, _DEPRECATED_HVAC_MODE_COOL, diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 9702c97d0da..84651dd6d86 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -38,6 +38,7 @@ HVAC_MODE_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): "hvac_mode_changed", vol.Required(state_trigger.CONF_TO): vol.In(const.HVAC_MODES), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, } ) diff --git a/homeassistant/components/climate/group.py b/homeassistant/components/climate/group.py deleted file mode 100644 index 9ac4519ff0c..00000000000 --- a/homeassistant/components/climate/group.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN, HVACMode - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states( - DOMAIN, - { - STATE_ON, - HVACMode.HEAT, - HVACMode.COOL, - HVACMode.HEAT_COOL, - HVACMode.AUTO, - HVACMode.FAN_ONLY, - }, - STATE_ON, - STATE_OFF, - ) diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 3073d3e3c26..53d0891fcda 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -4,11 +4,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, ClimateEntity +from . import DOMAIN INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" @@ -22,79 +21,36 @@ class GetTemperatureIntent(intent.IntentHandler): """Handle GetTemperature intents.""" intent_type = INTENT_GET_TEMPERATURE - slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str} + description = "Gets the current temperature of a climate device or entity" + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + } + platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] - entities: list[ClimateEntity] = list(component.entities) - climate_entity: ClimateEntity | None = None - climate_state: State | None = None + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] - if not entities: - raise intent.IntentHandleError("No climate entities") + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] - name_slot = slots.get("name", {}) - entity_name: str | None = name_slot.get("value") - entity_text: str | None = name_slot.get("text") - - area_slot = slots.get("area", {}) - area_id = area_slot.get("value") - - if area_id: - # Filter by area and optionally name - area_name = area_slot.get("text") - - for maybe_climate in intent.async_match_states( - hass, name=entity_name, area_name=area_id, domains=[DOMAIN] - ): - climate_state = maybe_climate - break - - if climate_state is None: - raise intent.NoStatesMatchedError( - name=entity_text or entity_name, - area=area_name or area_id, - floor=None, - domains={DOMAIN}, - device_classes=None, - ) - - climate_entity = component.get_entity(climate_state.entity_id) - elif entity_name: - # Filter by name - for maybe_climate in intent.async_match_states( - hass, name=entity_name, domains=[DOMAIN] - ): - climate_state = maybe_climate - break - - if climate_state is None: - raise intent.NoStatesMatchedError( - name=entity_name, - area=None, - floor=None, - domains={DOMAIN}, - device_classes=None, - ) - - climate_entity = component.get_entity(climate_state.entity_id) - else: - # First entity - climate_entity = entities[0] - climate_state = hass.states.get(climate_entity.entity_id) - - assert climate_entity is not None - - if climate_state is None: - raise intent.IntentHandleError(f"No state for {climate_entity.name}") - - assert climate_state is not None + match_constraints = intent.MatchTargetsConstraints( + name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) response = intent_obj.create_response() response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=[climate_state]) + response.async_set_states(matched_states=match_result.states) return response diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index c31d22ccbeb..2a7fea9136c 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -13,6 +13,14 @@ "action_type": { "set_hvac_mode": "Change HVAC mode on {entity_name}", "set_preset_mode": "Change preset on {entity_name}" + }, + "extra_fields": { + "above": "[%key:common::device_automation::extra_fields::above%]", + "below": "[%key:common::device_automation::extra_fields::below%]", + "for": "[%key:common::device_automation::extra_fields::for%]", + "to": "[%key:common::device_automation::extra_fields::to%]", + "preset_mode": "Preset mode", + "hvac_mode": "HVAC mode" } }, "entity_component": { diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2552fe4bf5c..cd8e5101e73 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -7,14 +7,11 @@ from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from enum import Enum from typing import cast -from urllib.parse import quote_plus, urljoin from hass_nabucasa import Cloud import voluptuous as vol -from homeassistant.components import alexa, google_assistant, http -from homeassistant.components.auth import STRICT_CONNECTION_URL -from homeassistant.components.http.auth import async_sign_path +from homeassistant.components import alexa, google_assistant from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( CONF_DESCRIPTION, @@ -24,21 +21,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import ( - Event, - HassJob, - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) -from homeassistant.exceptions import ( - HomeAssistantError, - ServiceValidationError, - Unauthorized, - UnknownUser, -) +from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform @@ -47,7 +31,6 @@ 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 homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -418,50 +401,3 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None: async_register_admin_service( hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - - async def create_temporary_strict_connection_url( - call: ServiceCall, - ) -> ServiceResponse: - """Create a strict connection url and return it.""" - # Copied form homeassistant/helpers/service.py#_async_admin_handler - # as the helper supports no responses yet - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - if not user.is_admin: - raise Unauthorized(context=call.context) - - if prefs.strict_connection is http.const.StrictConnectionMode.DISABLED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="strict_connection_not_enabled", - ) - - try: - url = get_url(hass, require_cloud=True) - except NoURLAvailableError as ex: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_url_available", - ) from ex - - path = async_sign_path( - hass, - STRICT_CONNECTION_URL, - timedelta(hours=1), - use_content_user=True, - ) - url = urljoin(url, path) - - return { - "url": f"https://login.home-assistant.io?u={quote_plus(url)}", - "direct_url": url, - } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c4d1c1dec60..01c8de77156 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -250,7 +250,6 @@ class CloudClient(Interface): "enabled": self._prefs.remote_enabled, "instance_domain": self.cloud.remote.instance_domain, "alias": self.cloud.remote.alias, - "strict_connection": self._prefs.strict_connection, }, "version": HA_VERSION, "instance_id": self.prefs.instance_id, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 8b68eefc443..2c58dd57340 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -33,7 +33,6 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" PREF_GOOGLE_CONNECTED = "google_connected" PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" -PREF_STRICT_CONNECTION = "strict_connection" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 29185191a20..bd2860b19df 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -9,7 +9,7 @@ import dataclasses from functools import wraps from http import HTTPStatus import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate import aiohttp from aiohttp import web @@ -19,7 +19,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import TTS_VOICES import voluptuous as vol -from homeassistant.components import http, websocket_api +from homeassistant.components import websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, @@ -46,7 +46,6 @@ from .const import ( PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_REMOTE_ALLOW_REMOTE_ENABLE, - PREF_STRICT_CONNECTION, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) @@ -116,11 +115,7 @@ def async_setup(hass: HomeAssistant) -> None: ) -_HassViewT = TypeVar("_HassViewT", bound=HomeAssistantView) -_P = ParamSpec("_P") - - -def _handle_cloud_errors( +def _handle_cloud_errors[_HassViewT: HomeAssistantView, **_P]( handler: Callable[ Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response] ], @@ -136,7 +131,7 @@ def _handle_cloud_errors( """Handle exceptions that raise from the wrapped request handler.""" try: result = await handler(view, request, *args, **kwargs) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 status, msg = _process_cloud_exception(err, request.path) return view.json_message( msg, status_code=status, message_code=err.__class__.__name__.lower() @@ -167,7 +162,7 @@ def _ws_handle_cloud_errors( try: return await handler(hass, connection, msg) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 err_status, err_msg = _process_cloud_exception(err, msg["type"]) connection.send_error(msg["id"], str(err_status), err_msg) @@ -453,9 +448,6 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: vol.Coerce(tuple), validate_language_voice ), vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, - vol.Optional(PREF_STRICT_CONNECTION): vol.Coerce( - http.const.StrictConnectionMode - ), } ) @websocket_api.async_response @@ -650,7 +642,7 @@ async def google_assistant_get( if not state: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"{entity_id} unknown", ) return @@ -659,7 +651,7 @@ async def google_assistant_get( if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported(): connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_SUPPORTED, + websocket_api.ERR_NOT_SUPPORTED, f"{entity_id} not supported by Google assistant", ) return @@ -763,7 +755,7 @@ async def alexa_get( ): connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_SUPPORTED, + websocket_api.ERR_NOT_SUPPORTED, f"{entity_id} not supported by Alexa", ) return diff --git a/homeassistant/components/cloud/icons.json b/homeassistant/components/cloud/icons.json index 1a8593388b4..06ee7eb2f19 100644 --- a/homeassistant/components/cloud/icons.json +++ b/homeassistant/components/cloud/icons.json @@ -1,6 +1,5 @@ { "services": { - "create_temporary_strict_connection_url": "mdi:login-variant", "remote_connect": "mdi:cloud", "remote_disconnect": "mdi:cloud-off" } diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0d2ee546ad8..529f4fb9be9 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.78.0"] + "requirements": ["hass-nabucasa==0.81.1"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 72207513ca9..af4e68194d6 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -10,7 +10,7 @@ from hass_nabucasa.voice import MAP_VOICE from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User -from homeassistant.components import http, webhook +from homeassistant.components import webhook from homeassistant.components.google_assistant.http import ( async_get_users as async_get_google_assistant_users, ) @@ -44,7 +44,6 @@ from .const import ( PREF_INSTANCE_ID, PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_DOMAIN, - PREF_STRICT_CONNECTION, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, ) @@ -177,7 +176,6 @@ class CloudPreferences: google_settings_version: int | UndefinedType = UNDEFINED, google_connected: bool | UndefinedType = UNDEFINED, remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, - strict_connection: http.const.StrictConnectionMode | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -197,7 +195,6 @@ class CloudPreferences: (PREF_REMOTE_DOMAIN, remote_domain), (PREF_GOOGLE_CONNECTED, google_connected), (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), - (PREF_STRICT_CONNECTION, strict_connection), ): if value is not UNDEFINED: prefs[key] = value @@ -245,7 +242,6 @@ class CloudPreferences: PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, - PREF_STRICT_CONNECTION: self.strict_connection, } @property @@ -362,20 +358,6 @@ class CloudPreferences: """ return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] - @property - def strict_connection(self) -> http.const.StrictConnectionMode: - """Return the strict connection mode.""" - mode = self._prefs.get(PREF_STRICT_CONNECTION) - - if mode is None: - # Set to default value - # We store None in the store as the default value to detect if the user has changed the - # value or not. - mode = http.const.StrictConnectionMode.DISABLED - elif not isinstance(mode, http.const.StrictConnectionMode): - mode = http.const.StrictConnectionMode(mode) - return mode - async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" user = await self._load_cloud_user() @@ -433,5 +415,4 @@ class CloudPreferences: PREF_REMOTE_DOMAIN: None, PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, - PREF_STRICT_CONNECTION: None, } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 1fec87235da..b71ccc0dfa0 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -5,30 +5,22 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, - "exceptions": { - "strict_connection_not_enabled": { - "message": "Strict connection is not enabled for cloud requests" - }, - "no_url_available": { - "message": "No cloud URL available.\nPlease mark sure you have a working Remote UI." - } - }, "system_health": { "info": { - "can_reach_cert_server": "Reach Certificate Server", + "can_reach_cert_server": "Reach certificate server", "can_reach_cloud": "Reach Home Assistant Cloud", - "can_reach_cloud_auth": "Reach Authentication Server", - "certificate_status": "Certificate Status", - "relayer_connected": "Relayer Connected", - "relayer_region": "Relayer Region", - "remote_connected": "Remote Connected", - "remote_enabled": "Remote Enabled", - "remote_server": "Remote Server", - "alexa_enabled": "Alexa Enabled", - "google_enabled": "Google Enabled", + "can_reach_cloud_auth": "Reach authentication server", + "certificate_status": "Certificate status", + "relayer_connected": "Relayer connected", + "relayer_region": "Relayer region", + "remote_connected": "Remote connected", + "remote_enabled": "Remote enabled", + "remote_server": "Remote server", + "alexa_enabled": "Alexa enabled", + "google_enabled": "Google enabled", "logged_in": "Logged In", "instance_id": "Instance ID", - "subscription_expiration": "Subscription Expiration" + "subscription_expiration": "Subscription expiration" } }, "issues": { @@ -81,10 +73,6 @@ } }, "services": { - "create_temporary_strict_connection_url": { - "name": "Create a temporary strict connection URL", - "description": "Create a temporary strict connection URL, which can be used to login on another device." - }, "remote_connect": { "name": "Remote connect", "description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud." diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py deleted file mode 100644 index 3e055851fff..00000000000 --- a/homeassistant/components/cloud/util.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Cloud util functions.""" - -from hass_nabucasa import Cloud - -from homeassistant.components import http -from homeassistant.core import HomeAssistant - -from .client import CloudClient -from .const import DOMAIN - - -def get_strict_connection_mode(hass: HomeAssistant) -> http.const.StrictConnectionMode: - """Get the strict connection mode.""" - cloud: Cloud[CloudClient] = hass.data[DOMAIN] - return cloud.client.prefs.strict_connection diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index f4becf12067..704e4c0fd47 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -194,7 +194,7 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except pycfdns.AuthenticationException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 61cf6d4e0ce..1b69a06d12d 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -14,7 +14,7 @@ from .coordinator import CO2SignalCoordinator PLATFORMS = [Platform.SENSOR] -CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator] +type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: CO2SignalConfigEntry) -> bool: diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 71ebcec65ee..623d5cf6731 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -130,7 +130,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth_secret" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -188,7 +188,7 @@ class OptionsFlowHandler(OptionsFlow): errors["base"] = "currency_unavailable" except ExchangeRateUnavailable: errors["base"] = "exchange_rate_unavailable" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 3fc8158f970..f5c75e3f926 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -268,7 +268,7 @@ WALLETS = { "XTZ": "XTZ", "YER": "YER", "YFI": "YFI", - "ZAR": "ZAR", + "ZAR": "ZAR", # codespell:ignore zar "ZEC": "ZEC", "ZMW": "ZMW", "ZRX": "ZRX", @@ -550,7 +550,7 @@ RATES = { "TRAC": "TRAC", "TRB": "TRB", "TRIBE": "TRIBE", - "TRU": "TRU", + "TRU": "TRU", # codespell:ignore tru "TRY": "TRY", "TTD": "TTD", "TWD": "TWD", @@ -590,7 +590,7 @@ RATES = { "YER": "YER", "YFI": "YFI", "YFII": "YFII", - "ZAR": "ZAR", + "ZAR": "ZAR", # codespell:ignore zar "ZEC": "ZEC", "ZEN": "ZEN", "ZMW": "ZMW", diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 5d30387a9cb..770866aa319 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import CONF_NAME, CONF_OFFSET +from homeassistant.const import CONF_NAME, CONF_OFFSET, CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -36,12 +36,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CONF_FIVE_MINUTE, name="ComEd 5 Minute Price", - native_unit_of_measurement="c", + native_unit_of_measurement=f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}", ), SensorEntityDescription( key=CONF_CURRENT_HOUR_AVERAGE, name="ComEd Current Hour Average Price", - native_unit_of_measurement="c", + native_unit_of_measurement=f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}", ), ) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 53d08e0097c..4cd8b749031 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -92,7 +92,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -138,7 +138,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 0f217eb0ee1..0cd1e24da6f 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( SCAN_INTERVAL as BINARY_SENSOR_DEFAULT_SCAN_INTERVAL, ) from homeassistant.components.cover import ( + DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, DOMAIN as COVER_DOMAIN, SCAN_INTERVAL as COVER_DEFAULT_SCAN_INTERVAL, ) @@ -105,6 +106,7 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( cv.time_period, cv.positive_timedelta diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 12123a81a38..0256f5aab37 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -9,10 +9,11 @@ from concord232 import client as concord232_client import requests import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + AlarmControlPanelEntity, AlarmControlPanelEntityFeature, + CodeFormat, ) from homeassistant.const import ( CONF_CODE, @@ -70,10 +71,10 @@ def setup_platform( _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) -class Concord232Alarm(alarm.AlarmControlPanelEntity): +class Concord232Alarm(AlarmControlPanelEntity): """Representation of the Concord232-based alarm panel.""" - _attr_code_format = alarm.CodeFormat.NUMBER + _attr_code_format = CodeFormat.NUMBER _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME @@ -85,6 +86,7 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): self._attr_name = name self._code = code + self._alarm_control_panel_option_default_code = code self._mode = mode self._url = url self._alarm = concord232_client.Client(self._url) diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index a499ab84784..c8cc9242ea4 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.area_registry import AreaEntry, async_get +from homeassistant.helpers import area_registry as ar @callback @@ -29,10 +29,10 @@ def websocket_list_areas( msg: dict[str, Any], ) -> None: """Handle list areas command.""" - registry = async_get(hass) + registry = ar.async_get(hass) connection.send_result( msg["id"], - [_entry_dict(entry) for entry in registry.async_list_areas()], + [entry.json_fragment for entry in registry.async_list_areas()], ) @@ -55,7 +55,7 @@ def websocket_create_area( msg: dict[str, Any], ) -> None: """Create area command.""" - registry = async_get(hass) + registry = ar.async_get(hass) data = dict(msg) data.pop("type") @@ -74,7 +74,7 @@ def websocket_create_area( except ValueError as err: connection.send_error(msg["id"], "invalid_info", str(err)) else: - connection.send_result(msg["id"], _entry_dict(entry)) + connection.send_result(msg["id"], entry.json_fragment) @websocket_api.websocket_command( @@ -91,7 +91,7 @@ def websocket_delete_area( msg: dict[str, Any], ) -> None: """Delete area command.""" - registry = async_get(hass) + registry = ar.async_get(hass) try: registry.async_delete(msg["area_id"]) @@ -121,7 +121,7 @@ def websocket_update_area( msg: dict[str, Any], ) -> None: """Handle update area websocket command.""" - registry = async_get(hass) + registry = ar.async_get(hass) data = dict(msg) data.pop("type") @@ -140,18 +140,4 @@ def websocket_update_area( except ValueError as err: connection.send_error(msg["id"], "invalid_info", str(err)) else: - connection.send_result(msg["id"], _entry_dict(entry)) - - -@callback -def _entry_dict(entry: AreaEntry) -> dict[str, Any]: - """Convert entry to API format.""" - return { - "aliases": list(entry.aliases), - "area_id": entry.id, - "floor_id": entry.floor_id, - "icon": entry.icon, - "labels": list(entry.labels), - "name": entry.name, - "picture": entry.picture, - } + connection.send_result(msg["id"], entry.json_fragment) diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 266c06d6ee8..1b3fa71d7ea 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -121,7 +121,7 @@ async def websocket_update( if not (user := await hass.auth.async_get_user(msg.pop("user_id"))): connection.send_message( websocket_api.error_message( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "User not found" + msg["id"], websocket_api.ERR_NOT_FOUND, "User not found" ) ) return diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 8eb4eb22fb5..b16701f8bd0 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -311,9 +311,7 @@ def send_entry_not_found( connection: websocket_api.ActiveConnection, msg_id: int ) -> None: """Send Config entry not found error.""" - connection.send_error( - msg_id, websocket_api.const.ERR_NOT_FOUND, "Config entry not found" - ) + connection.send_error(msg_id, websocket_api.ERR_NOT_FOUND, "Config entry not found") def get_entry( diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 5c3e4cfe09b..6f788b1c9f2 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -61,6 +61,7 @@ class CheckConfigView(HomeAssistantView): vol.Optional("latitude"): cv.latitude, vol.Optional("location_name"): str, vol.Optional("longitude"): cv.longitude, + vol.Optional("radius"): cv.positive_int, vol.Optional("time_zone"): cv.time_zone, vol.Optional("update_units"): bool, vol.Optional("unit_system"): unit_system.validate_unit_system, @@ -109,11 +110,9 @@ async def websocket_detect_config( # We don't want any integrations to use the name of the unit system # so we are using the private attribute here if location_info.use_metric: - # pylint: disable-next=protected-access - info["unit_system"] = unit_system._CONF_UNIT_SYSTEM_METRIC + info["unit_system"] = unit_system._CONF_UNIT_SYSTEM_METRIC # noqa: SLF001 else: - # pylint: disable-next=protected-access - info["unit_system"] = unit_system._CONF_UNIT_SYSTEM_US_CUSTOMARY + info["unit_system"] = unit_system._CONF_UNIT_SYSTEM_US_CUSTOMARY # noqa: SLF001 if location_info.latitude: info["latitude"] = location_info.latitude diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index f2b0035d060..a5d506e5a8d 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -11,11 +11,8 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api.decorators import require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import ( - DeviceEntry, - DeviceEntryDisabler, - async_get, -) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry, DeviceEntryDisabler @callback @@ -42,10 +39,10 @@ def websocket_list_devices( msg: dict[str, Any], ) -> None: """Handle list devices command.""" - registry = async_get(hass) + registry = dr.async_get(hass) # Build start of response message msg_json_prefix = ( - f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' + f'{{"id":{msg["id"]},"type": "{websocket_api.TYPE_RESULT}",' f'"success":true,"result": [' ).encode() # Concatenate cached entity registry item JSON serializations @@ -80,7 +77,7 @@ def websocket_update_device( msg: dict[str, Any], ) -> None: """Handle update device websocket command.""" - registry = async_get(hass) + registry = dr.async_get(hass) msg.pop("type") msg_id = msg.pop("id") @@ -112,7 +109,7 @@ async def websocket_remove_config_entry_from_device( msg: dict[str, Any], ) -> None: """Remove config entry from a device.""" - registry = async_get(hass) + registry = dr.async_get(hass) config_entry_id = msg["config_entry_id"] device_id = msg["device_id"] diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 7cdec324340..bf7a9087d56 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -43,7 +43,7 @@ def websocket_list_entities( registry = er.async_get(hass) # Build start of response message msg_json_prefix = ( - f'{{"id":{msg["id"]},"type": "{websocket_api.const.TYPE_RESULT}",' + f'{{"id":{msg["id"]},"type": "{websocket_api.TYPE_RESULT}",' '"success":true,"result": [' ).encode() # Concatenate cached entity registry item JSON serializations @@ -74,7 +74,7 @@ def websocket_list_entities_for_display( registry = er.async_get(hass) # Build start of response message msg_json_prefix = ( - f'{{"id":{msg["id"]},"type":"{websocket_api.const.TYPE_RESULT}","success":true,' + f'{{"id":{msg["id"]},"type":"{websocket_api.TYPE_RESULT}","success":true,' f'"result":{{"entity_categories":{_ENTITY_CATEGORIES_JSON},"entities":[' ).encode() # Concatenate cached entity registry item JSON serializations diff --git a/homeassistant/components/config/floor_registry.py b/homeassistant/components/config/floor_registry.py index 986f772ac53..05d563325e8 100644 --- a/homeassistant/components/config/floor_registry.py +++ b/homeassistant/components/config/floor_registry.py @@ -7,7 +7,8 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.floor_registry import FloorEntry, async_get +from homeassistant.helpers import floor_registry as fr +from homeassistant.helpers.floor_registry import FloorEntry @callback @@ -30,7 +31,7 @@ def websocket_list_floors( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle list floors command.""" - registry = async_get(hass) + registry = fr.async_get(hass) connection.send_result( msg["id"], [_entry_dict(entry) for entry in registry.async_list_floors()], @@ -52,7 +53,7 @@ def websocket_create_floor( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Create floor command.""" - registry = async_get(hass) + registry = fr.async_get(hass) data = dict(msg) data.pop("type") @@ -82,7 +83,7 @@ def websocket_delete_floor( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Delete floor command.""" - registry = async_get(hass) + registry = fr.async_get(hass) try: registry.async_delete(msg["floor_id"]) @@ -108,7 +109,7 @@ def websocket_update_floor( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle update floor websocket command.""" - registry = async_get(hass) + registry = fr.async_get(hass) data = dict(msg) data.pop("type") diff --git a/homeassistant/components/config/label_registry.py b/homeassistant/components/config/label_registry.py index 1d5d526016d..07b2f1bbd2e 100644 --- a/homeassistant/components/config/label_registry.py +++ b/homeassistant/components/config/label_registry.py @@ -7,8 +7,8 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.label_registry import LabelEntry, async_get +from homeassistant.helpers import config_validation as cv, label_registry as lr +from homeassistant.helpers.label_registry import LabelEntry SUPPORTED_LABEL_THEME_COLORS = { "primary", @@ -60,7 +60,7 @@ def websocket_list_labels( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle list labels command.""" - registry = async_get(hass) + registry = lr.async_get(hass) connection.send_result( msg["id"], [_entry_dict(entry) for entry in registry.async_list_labels()], @@ -84,7 +84,7 @@ def websocket_create_label( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Create label command.""" - registry = async_get(hass) + registry = lr.async_get(hass) data = dict(msg) data.pop("type") @@ -110,7 +110,7 @@ def websocket_delete_label( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Delete label command.""" - registry = async_get(hass) + registry = lr.async_get(hass) try: registry.async_delete(msg["label_id"]) @@ -138,7 +138,7 @@ def websocket_update_label( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle update label websocket command.""" - registry = async_get(hass) + registry = lr.async_get(hass) data = dict(msg) data.pop("type") diff --git a/homeassistant/components/config/view.py b/homeassistant/components/config/view.py index 62459a83a7d..980c0f82dd1 100644 --- a/homeassistant/components/config/view.py +++ b/homeassistant/components/config/view.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Callable, Coroutine from http import HTTPStatus import os -from typing import Any, Generic, TypeVar, cast +from typing import Any, cast from aiohttp import web import voluptuous as vol @@ -21,10 +21,10 @@ from homeassistant.util.yaml.loader import JSON_TYPE from .const import ACTION_CREATE_UPDATE, ACTION_DELETE -_DataT = TypeVar("_DataT", dict[str, dict[str, Any]], list[dict[str, Any]]) - -class BaseEditConfigView(HomeAssistantView, Generic[_DataT]): +class BaseEditConfigView[_DataT: (dict[str, dict[str, Any]], list[dict[str, Any]])]( + HomeAssistantView +): """Configure a Group endpoint.""" def __init__( diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index b2cf9a136cc..d1ddcb6cd4b 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -49,7 +49,7 @@ SERVICE_CONFIGURE = "configure" STATE_CONFIGURE = "configure" STATE_CONFIGURED = "configured" -ConfiguratorCallback = Callable[[list[dict[str, str]]], None] +type ConfiguratorCallback = Callable[[list[dict[str, str]]], None] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 86a13de1ac8..c9a6eab5c62 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -120,7 +120,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: director_all_items = json.loads(director_all_items) entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items - entry_data[CONF_UI_CONFIGURATION] = json.loads(await director.getUiConfiguration()) + # Check if OS version is 3 or higher to get UI configuration + entry_data[CONF_UI_CONFIGURATION] = None + if int(entry_data[CONF_DIRECTOR_SW_VERSION].split(".")[0]) >= 3: + entry_data[CONF_UI_CONFIGURATION] = json.loads( + await director.getUiConfiguration() + ) # Load options from config entry entry_data[CONF_SCAN_INTERVAL] = entry.options.get( diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 4ecc1ebe3f5..f6d746c9cb4 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -112,7 +112,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 99d8c27face..72aa44faaed 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -81,11 +81,18 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Control4 rooms from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + ui_config = entry_data[CONF_UI_CONFIGURATION] + + # OS 2 will not have a ui_configuration + if not ui_config: + _LOGGER.debug("No UI Configuration found for Control4") + return + all_rooms = await get_rooms(hass, entry) if not all_rooms: return - entry_data = hass.data[DOMAIN][entry.entry_id] scan_interval = entry_data[CONF_SCAN_INTERVAL] _LOGGER.debug("Scan interval = %s", scan_interval) @@ -119,8 +126,6 @@ async def async_setup_entry( if "parentId" in item and k > 1 } - ui_config = entry_data[CONF_UI_CONFIGURATION] - entity_list = [] for room in all_rooms: room_id = room["id"] diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 333fb24498b..6441dcab4ca 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -30,7 +30,17 @@ from .agent_manager import ( async_get_agent, get_agent_manager, ) -from .const import HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT +from .const import ( + ATTR_AGENT_ID, + ATTR_CONVERSATION_ID, + ATTR_LANGUAGE, + ATTR_TEXT, + DOMAIN, + HOME_ASSISTANT_AGENT, + OLD_HOME_ASSISTANT_AGENT, + SERVICE_PROCESS, + SERVICE_RELOAD, +) from .default_agent import async_get_default_agent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http @@ -52,19 +62,8 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) -ATTR_TEXT = "text" -ATTR_LANGUAGE = "language" -ATTR_AGENT_ID = "agent_id" -ATTR_CONVERSATION_ID = "conversation_id" - -DOMAIN = "conversation" - REGEX_TYPE = type(re.compile("")) -SERVICE_PROCESS = "process" -SERVICE_RELOAD = "reload" - - SERVICE_PROCESS_SCHEMA = vol.Schema( { vol.Required(ATTR_TEXT): cv.string, @@ -128,7 +127,6 @@ async def async_get_conversation_languages( """ agent_manager = get_agent_manager(hass) entity_component: EntityComponent[ConversationEntity] = hass.data[DOMAIN] - languages: set[str] = set() agents: list[ConversationEntity | AbstractConversationAgent] if agent_id: @@ -137,6 +135,10 @@ async def async_get_conversation_languages( if agent is None: raise ValueError(f"Agent {agent_id} not found") + # Shortcut + if agent.supported_languages == MATCH_ALL: + return MATCH_ALL + agents = [agent] else: @@ -144,11 +146,16 @@ async def async_get_conversation_languages( for info in agent_manager.async_get_agent_info(): agent = agent_manager.async_get_agent(info.id) assert agent is not None + + # Shortcut + if agent.supported_languages == MATCH_ALL: + return MATCH_ALL + agents.append(agent) + languages: set[str] = set() + for agent in agents: - if agent.supported_languages == MATCH_ALL: - return MATCH_ALL for language_tag in agent.supported_languages: languages.add(language_tag) @@ -183,7 +190,10 @@ def async_get_agent_info( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" - entity_component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) + entity_component: EntityComponent[ConversationEntity] = EntityComponent( + _LOGGER, DOMAIN, hass + ) + hass.data[DOMAIN] = entity_component await async_setup_default_agent( hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 9f31ccd6c62..8202b9a0ed4 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses import logging from typing import Any @@ -20,6 +21,11 @@ from .models import ( ConversationInput, ConversationResult, ) +from .trace import ( + ConversationTraceEvent, + ConversationTraceEventType, + async_conversation_trace, +) _LOGGER = logging.getLogger(__name__) @@ -84,15 +90,24 @@ async def async_converse( language = hass.config.language _LOGGER.debug("Processing in %s: %s", language, text) - return await method( - ConversationInput( - text=text, - context=context, - conversation_id=conversation_id, - device_id=device_id, - language=language, - ) + conversation_input = ConversationInput( + text=text, + context=context, + conversation_id=conversation_id, + device_id=device_id, + language=language, + agent_id=agent_id, ) + with async_conversation_trace() as trace: + trace.add_event( + ConversationTraceEvent( + ConversationTraceEventType.ASYNC_PROCESS, + dataclasses.asdict(conversation_input), + ) + ) + result = await method(conversation_input) + trace.set_result(**result.as_dict()) + return result class AgentManager: diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index d20b6d96aa2..70a598e8b56 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -4,3 +4,11 @@ DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "conversation.home_assistant" OLD_HOME_ASSISTANT_AGENT = "homeassistant" + +ATTR_TEXT = "text" +ATTR_LANGUAGE = "language" +ATTR_AGENT_ID = "agent_id" +ATTR_CONVERSATION_ID = "conversation_id" + +SERVICE_PROCESS = "process" +SERVICE_RELOAD = "reload" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 121702115b9..7bb2c2182b3 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -126,10 +126,6 @@ async def async_setup_default_agent( await entity_component.async_add_entities([entity]) hass.data[DATA_DEFAULT_ENTITY] = entity - entity_registry = er.async_get(hass) - for entity_id in entity_registry.entities: - async_should_expose(hass, DOMAIN, entity_id) - @core.callback def async_entity_state_listener( event: core.Event[core.EventStateChangedData], @@ -339,8 +335,11 @@ class DefaultAgent(ConversationEntity): assert lang_intents is not None # Slot values to pass to the intent - slots = { - entity.name: {"value": entity.value, "text": entity.text or entity.value} + slots: dict[str, Any] = { + entity.name: { + "value": entity.value, + "text": entity.text or entity.value, + } for entity in result.entities_list } @@ -354,11 +353,13 @@ class DefaultAgent(ConversationEntity): user_input.context, language, assistant=DOMAIN, + device_id=user_input.device_id, + conversation_agent_id=user_input.agent_id, ) - except intent.NoStatesMatchedError as no_states_error: + except intent.MatchFailedError as match_error: # Intent was valid, but no entities matched the constraints. - error_response_type, error_response_args = _get_no_states_matched_response( - no_states_error + error_response_type, error_response_args = _get_match_error_response( + self.hass, match_error ) return _make_error_result( language, @@ -368,28 +369,16 @@ class DefaultAgent(ConversationEntity): ), conversation_id, ) - except intent.DuplicateNamesMatchedError as duplicate_names_error: - # Intent was valid, but two or more entities with the same name matched. - ( - error_response_type, - error_response_args, - ) = _get_duplicate_names_matched_response(duplicate_names_error) - return _make_error_result( - language, - intent.IntentResponseErrorCode.NO_VALID_TARGETS, - self._get_error_text( - error_response_type, lang_intents, **error_response_args - ), - conversation_id, - ) - except intent.IntentHandleError: + except intent.IntentHandleError as err: # Intent was valid and entities matched constraints, but an error # occurred during handling. _LOGGER.exception("Intent handling error") return _make_error_result( language, intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - self._get_error_text(ErrorKey.HANDLE_ERROR, lang_intents), + self._get_error_text( + err.response_key or ErrorKey.HANDLE_ERROR, lang_intents + ), conversation_id, ) except intent.IntentUnexpectedError: @@ -430,8 +419,9 @@ class DefaultAgent(ConversationEntity): language: str, ) -> RecognizeResult | None: """Search intents for a match to user input.""" - # Prioritize matches with entity names above area names - maybe_result: RecognizeResult | None = None + name_result: RecognizeResult | None = None + best_results: list[RecognizeResult] = [] + best_text_chunks_matched: int | None = None for result in recognize_all( user_input.text, lang_intents.intents, @@ -439,18 +429,40 @@ class DefaultAgent(ConversationEntity): intent_context=intent_context, language=language, ): - if "name" in result.entities: - return result + # Prioritize results with a "name" slot, but still prefer ones with + # more literal text matched. + if ( + ("name" in result.entities) + and (not result.entities["name"].is_wildcard) + and ( + (name_result is None) + or (result.text_chunks_matched > name_result.text_chunks_matched) + ) + ): + name_result = result - # Keep looking in case an entity has the same name - maybe_result = result + if (best_text_chunks_matched is None) or ( + result.text_chunks_matched > best_text_chunks_matched + ): + # Only overwrite if more literal text was matched. + # This causes wildcards to match last. + best_results = [result] + best_text_chunks_matched = result.text_chunks_matched + elif result.text_chunks_matched == best_text_chunks_matched: + # Accumulate results with the same number of literal text matched. + # We will resolve the ambiguity below. + best_results.append(result) - if maybe_result is not None: + if name_result is not None: + # Prioritize matches with entity names above area names + return name_result + + if best_results: # Successful strict match - return maybe_result + return best_results[0] # Try again with missing entities enabled - best_num_unmatched_entities = 0 + maybe_result: RecognizeResult | None = None for result in recognize_all( user_input.text, lang_intents.intents, @@ -536,13 +548,16 @@ class DefaultAgent(ConversationEntity): state1 = unmatched[0] # Render response template + speech_slots = { + entity_name: entity_value.text or entity_value.value + for entity_name, entity_value in recognize_result.entities.items() + } + speech_slots.update(intent_response.speech_slots) + speech = response_template.async_render( { - # Slots from intent recognizer - "slots": { - entity_name: entity_value.text or entity_value.value - for entity_name, entity_value in recognize_result.entities.items() - }, + # Slots from intent recognizer and response + "slots": speech_slots, # First matched or unmatched state "state": ( template.TemplateState(self.hass, state1) @@ -808,34 +823,34 @@ class DefaultAgent(ConversationEntity): _LOGGER.debug("Exposed entities: %s", entity_names) # Expose all areas. - # - # We pass in area id here with the expectation that no two areas will - # share the same name or alias. areas = ar.async_get(self.hass) area_names = [] for area in areas.async_list_areas(): - area_names.append((area.name, area.id)) - if area.aliases: - for alias in area.aliases: - if not alias.strip(): - continue + area_names.append((area.name, area.name)) + if not area.aliases: + continue - area_names.append((alias, area.id)) + for alias in area.aliases: + alias = alias.strip() + if not alias: + continue + + area_names.append((alias, alias)) # Expose all floors. - # - # We pass in floor id here with the expectation that no two floors will - # share the same name or alias. floors = fr.async_get(self.hass) floor_names = [] for floor in floors.async_list_floors(): - floor_names.append((floor.name, floor.floor_id)) - if floor.aliases: - for alias in floor.aliases: - if not alias.strip(): - continue + floor_names.append((floor.name, floor.name)) + if not floor.aliases: + continue - floor_names.append((alias, floor.floor_id)) + for alias in floor.aliases: + alias = alias.strip() + if not alias: + continue + + floor_names.append((alias, floor.name)) self._slot_lists = { "area": TextSlotList.from_tuples(area_names, allow_template=False), @@ -863,11 +878,11 @@ class DefaultAgent(ConversationEntity): if device_area is None: return None - return {"area": {"value": device_area.id, "text": device_area.name}} + return {"area": {"value": device_area.name, "text": device_area.name}} def _get_error_text( self, - error_key: ErrorKey, + error_key: ErrorKey | str, lang_intents: LanguageIntents | None, **response_args, ) -> str: @@ -875,7 +890,11 @@ class DefaultAgent(ConversationEntity): if lang_intents is None: return _DEFAULT_ERROR_TEXT - response_key = error_key.value + if isinstance(error_key, ErrorKey): + response_key = error_key.value + else: + response_key = error_key + response_str = ( lang_intents.error_responses.get(response_key) or _DEFAULT_ERROR_TEXT ) @@ -1025,61 +1044,95 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str return ErrorKey.NO_INTENT, {} -def _get_no_states_matched_response( - no_states_error: intent.NoStatesMatchedError, +def _get_match_error_response( + hass: core.HomeAssistant, + match_error: intent.MatchFailedError, ) -> tuple[ErrorKey, dict[str, Any]]: - """Return key and template arguments for error when intent returns no matching states.""" + """Return key and template arguments for error when target matching fails.""" - # Device classes should be checked before domains - if no_states_error.device_classes: - device_class = next(iter(no_states_error.device_classes)) # first device class - if no_states_error.area: + constraints, result = match_error.constraints, match_error.result + reason = result.no_match_reason + + if ( + reason + in (intent.MatchFailedReason.DEVICE_CLASS, intent.MatchFailedReason.DOMAIN) + ) and constraints.device_classes: + device_class = next(iter(constraints.device_classes)) # first device class + if constraints.area_name: # device_class in area return ErrorKey.NO_DEVICE_CLASS_IN_AREA, { "device_class": device_class, - "area": no_states_error.area, + "area": constraints.area_name, } # device_class only return ErrorKey.NO_DEVICE_CLASS, {"device_class": device_class} - if no_states_error.domains: - domain = next(iter(no_states_error.domains)) # first domain - if no_states_error.area: + if (reason == intent.MatchFailedReason.DOMAIN) and constraints.domains: + domain = next(iter(constraints.domains)) # first domain + if constraints.area_name: # domain in area return ErrorKey.NO_DOMAIN_IN_AREA, { "domain": domain, - "area": no_states_error.area, + "area": constraints.area_name, } - if no_states_error.floor: + if constraints.floor_name: # domain in floor return ErrorKey.NO_DOMAIN_IN_FLOOR, { "domain": domain, - "floor": no_states_error.floor, + "floor": constraints.floor_name, } # domain only return ErrorKey.NO_DOMAIN, {"domain": domain} + if reason == intent.MatchFailedReason.DUPLICATE_NAME: + if constraints.floor_name: + # duplicate on floor + return ErrorKey.DUPLICATE_ENTITIES_IN_FLOOR, { + "entity": result.no_match_name, + "floor": constraints.floor_name, + } + + if constraints.area_name: + # duplicate on area + return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, { + "entity": result.no_match_name, + "area": constraints.area_name, + } + + return ErrorKey.DUPLICATE_ENTITIES, {"entity": result.no_match_name} + + if reason == intent.MatchFailedReason.INVALID_AREA: + # Invalid area name + return ErrorKey.NO_AREA, {"area": result.no_match_name} + + if reason == intent.MatchFailedReason.INVALID_FLOOR: + # Invalid floor name + return ErrorKey.NO_FLOOR, {"floor": result.no_match_name} + + if reason == intent.MatchFailedReason.FEATURE: + # Feature not supported by entity + return ErrorKey.FEATURE_NOT_SUPPORTED, {} + + if reason == intent.MatchFailedReason.STATE: + # Entity is not in correct state + assert match_error.constraints.states + state = next(iter(match_error.constraints.states)) + if match_error.constraints.domains: + # Translate if domain is available + domain = next(iter(match_error.constraints.domains)) + state = translation.async_translate_state( + hass, state, domain, None, None, None + ) + + return ErrorKey.ENTITY_WRONG_STATE, {"state": state} + # Default error return ErrorKey.NO_INTENT, {} -def _get_duplicate_names_matched_response( - duplicate_names_error: intent.DuplicateNamesMatchedError, -) -> tuple[ErrorKey, dict[str, Any]]: - """Return key and template arguments for error when intent returns duplicate matches.""" - - if duplicate_names_error.area: - return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, { - "entity": duplicate_names_error.name, - "area": duplicate_names_error.area, - } - - return ErrorKey.DUPLICATE_ENTITIES, {"entity": duplicate_names_error.name} - - def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Sequence): diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index e582dacf284..591298cbac1 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -94,9 +94,7 @@ async def websocket_prepare( agent = async_get_agent(hass, msg.get("agent_id")) if agent is None: - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Agent not found" - ) + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Agent not found") return await agent.async_prepare(msg.get("language")) @@ -128,10 +126,14 @@ async def websocket_list_agents( language, supported_languages, country ) + name = entity.entity_id + if state := hass.states.get(entity.entity_id): + name = state.name + agents.append( { "id": entity.entity_id, - "name": entity.name or entity.entity_id, + "name": name, "supported_languages": supported_languages, } ) @@ -184,6 +186,7 @@ async def websocket_hass_agent_debug( conversation_id=None, device_id=msg.get("device_id"), language=msg.get("language", hass.config.language), + agent_id=None, ) ) for sentence in msg["sentences"] @@ -311,9 +314,9 @@ def _get_debug_targets( def _get_unmatched_slots( result: RecognizeResult, -) -> dict[str, str | int]: +) -> dict[str, str | int | float]: """Return a dict of unmatched text/range slot entities.""" - unmatched_slots: dict[str, str | int] = {} + unmatched_slots: dict[str, str | int | float] = {} for entity in result.unmatched_entities_list: if isinstance(entity, UnmatchedTextEntity): if entity.text == MISSING_ENTITY: diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 82e2adca680..ee0b29f22fc 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.4.24"] + "requirements": ["hassil==1.7.1", "home-assistant-intents==2024.6.21"] } diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 3fd24152698..902b52483e0 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -27,6 +27,7 @@ class ConversationInput: conversation_id: str | None device_id: str | None language: str + agent_id: str | None = None @dataclass(slots=True) diff --git a/homeassistant/components/conversation/trace.py b/homeassistant/components/conversation/trace.py new file mode 100644 index 00000000000..0bd2fe8ed5b --- /dev/null +++ b/homeassistant/components/conversation/trace.py @@ -0,0 +1,118 @@ +"""Debug traces for conversation.""" + +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import asdict, dataclass, field +import enum +from typing import Any + +from homeassistant.util import dt as dt_util, ulid as ulid_util +from homeassistant.util.limited_size_dict import LimitedSizeDict + +STORED_TRACES = 3 + + +class ConversationTraceEventType(enum.StrEnum): + """Type of an event emitted during a conversation.""" + + ASYNC_PROCESS = "async_process" + """The conversation is started from user input.""" + + AGENT_DETAIL = "agent_detail" + """Event detail added by a conversation agent.""" + + LLM_TOOL_CALL = "llm_tool_call" + """An LLM Tool call""" + + +@dataclass(frozen=True) +class ConversationTraceEvent: + """Event emitted during a conversation.""" + + event_type: ConversationTraceEventType + data: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat()) + + +class ConversationTrace: + """Stores debug data related to a conversation.""" + + def __init__(self) -> None: + """Initialize ConversationTrace.""" + self._trace_id = ulid_util.ulid_now() + self._events: list[ConversationTraceEvent] = [] + self._error: Exception | None = None + self._result: dict[str, Any] = {} + + @property + def trace_id(self) -> str: + """Identifier for this trace.""" + return self._trace_id + + def add_event(self, event: ConversationTraceEvent) -> None: + """Add an event to the trace.""" + self._events.append(event) + + def set_error(self, ex: Exception) -> None: + """Set error.""" + self._error = ex + + def set_result(self, **kwargs: Any) -> None: + """Set result.""" + self._result = {**kwargs} + + def as_dict(self) -> dict[str, Any]: + """Return dictionary version of this ConversationTrace.""" + result: dict[str, Any] = { + "id": self._trace_id, + "events": [asdict(event) for event in self._events], + } + if self._error is not None: + result["error"] = str(self._error) or self._error.__class__.__name__ + if self._result is not None: + result["result"] = self._result + return result + + +_current_trace: ContextVar[ConversationTrace | None] = ContextVar( + "current_trace", default=None +) +_recent_traces: LimitedSizeDict[str, ConversationTrace] = LimitedSizeDict( + size_limit=STORED_TRACES +) + + +def async_conversation_trace_append( + event_type: ConversationTraceEventType, event_data: dict[str, Any] +) -> None: + """Append a ConversationTraceEvent to the current active trace.""" + trace = _current_trace.get() + if not trace: + return + trace.add_event(ConversationTraceEvent(event_type, event_data)) + + +@contextmanager +def async_conversation_trace() -> Generator[ConversationTrace, None]: + """Create a new active ConversationTrace.""" + trace = ConversationTrace() + token = _current_trace.set(trace) + _recent_traces[trace.trace_id] = trace + try: + yield trace + except Exception as ex: + trace.set_error(ex) + raise + finally: + _current_trace.reset(token) + + +def async_get_traces() -> list[ConversationTrace]: + """Get the most recent traces.""" + return list(_recent_traces.values()) + + +def async_clear_traces() -> None: + """Clear all traces.""" + _recent_traces.clear() diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index a607a7bdebe..3d68d70e575 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, Self, TypeVar +from typing import Any, Self import voluptuous as vol @@ -23,8 +23,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -_T = TypeVar("_T") - _LOGGER = logging.getLogger(__name__) ATTR_INITIAL = "initial" @@ -62,7 +60,7 @@ STORAGE_FIELDS = { } -def _none_to_empty_dict(value: _T | None) -> _T | dict[str, Any]: +def _none_to_empty_dict[_T](value: _T | None) -> _T | dict[str, Any]: if value is None: return {} return value diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index ac9c0384dea..852c5fd9cae 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -8,7 +8,7 @@ from enum import IntFlag, StrEnum import functools as ft from functools import cached_property import logging -from typing import Any, ParamSpec, TypeVar, final +from typing import Any, final import voluptuous as vol @@ -45,7 +45,6 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import group as group_pre_import # noqa: F401 from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -54,9 +53,6 @@ SCAN_INTERVAL = timedelta(seconds=15) ENTITY_ID_FORMAT = DOMAIN + ".{}" -_P = ParamSpec("_P") -_R = TypeVar("_R") - class CoverDeviceClass(StrEnum): """Device class for cover.""" @@ -477,7 +473,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): else: await self.async_close_cover_tilt(**kwargs) - def _get_toggle_function( + def _get_toggle_function[**_P, _R]( self, fns: dict[str, Callable[_P, _R]] ) -> Callable[_P, _R]: # If we are opening or closing and we support stopping, then we should stop diff --git a/homeassistant/components/cover/group.py b/homeassistant/components/cover/group.py deleted file mode 100644 index 8beb0b6837c..00000000000 --- a/homeassistant/components/cover/group.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_CLOSED, STATE_OPEN -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - # On means open, Off means closed - registry.on_off_states(DOMAIN, {STATE_OPEN}, STATE_OPEN, STATE_CLOSED) diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index a77bfbcbd16..b38f698ac3d 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -15,12 +15,22 @@ async def async_setup_intents(hass: HomeAssistant) -> None: intent.async_register( hass, intent.ServiceIntentHandler( - INTENT_OPEN_COVER, DOMAIN, SERVICE_OPEN_COVER, "Opened {}" + INTENT_OPEN_COVER, + DOMAIN, + SERVICE_OPEN_COVER, + "Opening {}", + description="Opens a cover", + platforms={DOMAIN}, ), ) intent.async_register( hass, intent.ServiceIntentHandler( - INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER, "Closed {}" + INTENT_CLOSE_COVER, + DOMAIN, + SERVICE_CLOSE_COVER, + "Closing {}", + description="Closes a cover", + platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 979835fcfd2..0afef8a200f 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -18,6 +18,12 @@ "is_position": "Current {entity_name} position is", "is_tilt_position": "Current {entity_name} tilt position is" }, + "extra_fields": { + "above": "[%key:common::device_automation::extra_fields::above%]", + "below": "[%key:common::device_automation::extra_fields::below%]", + "for": "[%key:common::device_automation::extra_fields::for%]", + "position": "Position" + }, "trigger_type": { "opened": "{entity_name} opened", "closed": "{entity_name} closed", diff --git a/homeassistant/components/crownstone/manifest.json b/homeassistant/components/crownstone/manifest.json index 532fd859b4e..6168d483ab5 100644 --- a/homeassistant/components/crownstone/manifest.json +++ b/homeassistant/components/crownstone/manifest.json @@ -13,8 +13,8 @@ "crownstone_uart" ], "requirements": [ - "crownstone-cloud==1.4.9", - "crownstone-sse==2.0.4", + "crownstone-cloud==1.4.11", + "crownstone-sse==2.0.5", "crownstone-uart==2.1.0", "pyserial==3.5" ] diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 6f1196c7721..807b101dda5 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -87,13 +87,14 @@ async def daikin_api_setup( device = await Appliance.factory( host, session, key=key, uuid=uuid, password=password ) + _LOGGER.debug("Connection to %s successful", host) except TimeoutError as err: _LOGGER.debug("Connection to %s timed out", host) raise ConfigEntryNotReady from err except ClientConnectionError as err: _LOGGER.debug("ClientConnectionError to %s", host) raise ConfigEntryNotReady from err - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.error("Unexpected error creating device %s", host) return None diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 2acbe42264d..f8c0181d93b 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -109,7 +109,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): data_schema=self.schema, errors={"base": "unknown"}, ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error creating device") return self.async_show_form( step_id="user", diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 93ee636c726..64ec15cb093 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -51,6 +51,11 @@ "compressor_energy_consumption": { "name": "Compressor energy consumption" } + }, + "switch": { + "toggle": { + "name": "Power" + } } } } diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b1be0a0d08d..f2b8526ced6 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -36,9 +36,7 @@ async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> """Service call wrapper to set a new date/time.""" value: datetime = service_call.data[ATTR_DATETIME] if value.tzinfo is None: - value = value.replace( - tzinfo=dt_util.get_time_zone(entity.hass.config.time_zone) - ) + value = value.replace(tzinfo=dt_util.get_default_time_zone()) return await entity.async_set_value(value) diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index 21786a292f4..555b6f8ff00 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -152,7 +152,7 @@ class DdWrtDeviceScanner(DeviceScanner): ) except requests.exceptions.Timeout: _LOGGER.exception("Connection to the router timed out") - return + return None if response.status_code == HTTPStatus.OK: return _parse_ddwrt_response(response.text) if response.status_code == HTTPStatus.UNAUTHORIZED: @@ -160,7 +160,7 @@ class DdWrtDeviceScanner(DeviceScanner): _LOGGER.exception( "Failed to authenticate, check your username and password" ) - return + return None _LOGGER.error("Invalid response from DD-WRT: %s", response) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 4952cb3dafc..8007f3217d5 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -6,13 +6,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .config_flow import get_master_hub from .const import CONF_MASTER_GATEWAY, DOMAIN, PLATFORMS from .deconz_event import async_setup_events, async_unload_events from .errors import AuthenticationRequired, CannotConnect from .hub import DeconzHub, get_deconz_api -from .services import async_setup_services, async_unload_services +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up services.""" + async_setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -33,9 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationRequired as err: raise ConfigEntryAuthFailed from err - if not hass.data[DOMAIN]: - async_setup_services(hass) - hub = hass.data[DOMAIN][config_entry.entry_id] = DeconzHub(hass, config_entry, api) await hub.async_update_device_registry() @@ -58,10 +65,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hub: DeconzHub = hass.data[DOMAIN].pop(config_entry.entry_id) async_unload_events(hub) - if not hass.data[DOMAIN]: - async_unload_services(hass) - - elif hub.master: + if hass.data[DOMAIN] and hub.master: await async_update_master_hub(hass, config_entry) new_master_hub = next(iter(hass.data[DOMAIN].values())) await async_update_master_hub(hass, new_master_hub.config_entry) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 02f6ada8fc8..0b3461b7a12 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, TypeVar from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType @@ -48,29 +47,28 @@ PROVIDES_EXTRA_ATTRIBUTES = ( "water", ) -T = TypeVar( - "T", - Alarm, - CarbonMonoxide, - Fire, - GenericFlag, - OpenClose, - Presence, - Vibration, - Water, - PydeconzSensorBase, -) - @dataclass(frozen=True, kw_only=True) -class DeconzBinarySensorDescription(Generic[T], BinarySensorEntityDescription): +class DeconzBinarySensorDescription[ + _T: ( + Alarm, + CarbonMonoxide, + Fire, + GenericFlag, + OpenClose, + Presence, + Vibration, + Water, + PydeconzSensorBase, + ) +](BinarySensorEntityDescription): """Class describing deCONZ binary sensor entities.""" - instance_check: type[T] | None = None + instance_check: type[_T] | None = None name_suffix: str = "" old_unique_id_suffix: str = "" update_key: str - value_fn: Callable[[T], bool | None] + value_fn: Callable[[_T], bool | None] ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = ( diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 0ddabbcfccc..8551ad33cf5 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Generic, TypeVar - from pydeconz.models.deconz_device import DeconzDevice as PydeconzDevice from pydeconz.models.group import Group as PydeconzGroup from pydeconz.models.light import LightBase as PydeconzLightBase @@ -19,13 +17,12 @@ from .const import DOMAIN as DECONZ_DOMAIN from .hub import DeconzHub from .util import serial_from_unique_id -_DeviceT = TypeVar( - "_DeviceT", - bound=PydeconzGroup | PydeconzLightBase | PydeconzSensorBase | PydeconzScene, +type _DeviceType = ( + PydeconzGroup | PydeconzLightBase | PydeconzSensorBase | PydeconzScene ) -class DeconzBase(Generic[_DeviceT]): +class DeconzBase[_DeviceT: _DeviceType]: """Common base for deconz entities and events.""" unique_id_suffix: str | None = None @@ -71,7 +68,7 @@ class DeconzBase(Generic[_DeviceT]): ) -class DeconzDevice(DeconzBase[_DeviceT], Entity): +class DeconzDevice[_DeviceT: _DeviceType](DeconzBase[_DeviceT], Entity): """Representation of a deCONZ device.""" _attr_should_poll = False diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index 5e16d85ec4d..ec988feb3cf 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -347,7 +347,8 @@ AQARA_SINGLE_WALL_SWITCH = { (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, } -AQARA_MINI_SWITCH_MODEL = "lumi.remote.b1acn01" +AQARA_MINI_SWITCH_WXKG11LM_MODEL = "lumi.remote.b1acn01" +AQARA_MINI_SWITCH_WBR02D_MODEL = "lumi.remote.b1acn02" AQARA_MINI_SWITCH = { (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, @@ -615,7 +616,8 @@ REMOTES = { AQARA_SINGLE_WALL_SWITCH_QBKG11LM_MODEL: AQARA_SINGLE_WALL_SWITCH_QBKG11LM, AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH, AQARA_SINGLE_WALL_SWITCH_WXKG06LM_MODEL: AQARA_SINGLE_WALL_SWITCH, - AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH, + AQARA_MINI_SWITCH_WXKG11LM_MODEL: AQARA_MINI_SWITCH, + AQARA_MINI_SWITCH_WBR02D_MODEL: AQARA_MINI_SWITCH, AQARA_ROUND_SWITCH_MODEL: AQARA_ROUND_SWITCH, AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH, AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL: AQARA_SQUARE_SWITCH_WXKG11LM_2016, diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index fc5388d2b33..cb834f9eee7 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -2,13 +2,12 @@ from __future__ import annotations -from typing import Any, TypedDict, TypeVar +from typing import Any, TypedDict, cast from pydeconz.interfaces.groups import GroupHandler from pydeconz.interfaces.lights import LightHandler -from pydeconz.models import ResourceType from pydeconz.models.event import EventType -from pydeconz.models.group import Group +from pydeconz.models.group import Group, TypedGroupAction from pydeconz.models.light.light import Light, LightAlert, LightColorMode, LightEffect from homeassistant.components.light import ( @@ -29,7 +28,6 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hs_to_xy @@ -88,8 +86,6 @@ XMAS_LIGHT_EFFECTS = [ "waves", ] -_LightDeviceT = TypeVar("_LightDeviceT", bound=Group | Light) - class SetStateAttributes(TypedDict, total=False): """Attributes available with set state call.""" @@ -105,6 +101,23 @@ class SetStateAttributes(TypedDict, total=False): xy: tuple[float, float] +def update_color_state( + group: Group, lights: list[Light], override: bool = False +) -> None: + """Sync group color state with light.""" + data = { + attribute: light_attribute + for light in lights + for attribute in ("bri", "ct", "hue", "sat", "xy", "colormode", "effect") + if (light_attribute := light.raw["state"].get(attribute)) is not None + } + + if override: + group.raw["action"] = cast(TypedGroupAction, data) + else: + group.update(cast(dict[str, dict[str, Any]], {"action": data})) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -114,17 +127,6 @@ async def async_setup_entry( hub = DeconzHub.get_hub(hass, config_entry) hub.entities[DOMAIN] = set() - entity_registry = er.async_get(hass) - - # On/Off Output should be switch not light 2022.5 - for light in hub.api.lights.lights.values(): - if light.type == ResourceType.ON_OFF_OUTPUT.value and ( - entity_id := entity_registry.async_get_entity_id( - DOMAIN, DECONZ_DOMAIN, light.unique_id - ) - ): - entity_registry.async_remove(entity_id) - @callback def async_add_light(_: EventType, light_id: str) -> None: """Add light from deCONZ.""" @@ -148,11 +150,12 @@ async def async_setup_entry( if (group := hub.api.groups[group_id]) and not group.lights: return - first = True - for light_id in group.lights: - if (light := hub.api.lights.lights.get(light_id)) and light.reachable: - group.update_color_state(light, update_all_attributes=first) - first = False + lights = [ + light + for light_id in group.lights + if (light := hub.api.lights.lights.get(light_id)) and light.reachable + ] + update_color_state(group, lights, True) async_add_entities([DeconzGroup(group, hub)]) @@ -162,7 +165,9 @@ async def async_setup_entry( ) -class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): +class DeconzBaseLight[_LightDeviceT: Group | Light]( + DeconzDevice[_LightDeviceT], LightEntity +): """Representation of a deCONZ light.""" TYPE = DOMAIN @@ -326,7 +331,7 @@ class DeconzLight(DeconzBaseLight[Light]): if self._device.reachable and "attr" not in self._device.changed_keys: for group in self.hub.api.groups.values(): if self._device.resource_id in group.lights: - group.update_color_state(self._device) + update_color_state(group, [self._device]) class DeconzGroup(DeconzBaseLight[Group]): diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index ef2f4a73c1b..2f58cacfa2c 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==115"], + "requirements": ["pydeconz==116"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 03c25668820..f29caf97b52 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, Generic, TypeVar +from typing import Any from pydeconz.gateway import DeconzSession from pydeconz.interfaces.sensors import SensorResources @@ -25,18 +25,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .deconz_device import DeconzDevice from .hub import DeconzHub -T = TypeVar("T", Presence, PydeconzSensorBase) - @dataclass(frozen=True, kw_only=True) -class DeconzNumberDescription(Generic[T], NumberEntityDescription): +class DeconzNumberDescription[_T: (Presence, PydeconzSensorBase)]( + NumberEntityDescription +): """Class describing deCONZ number entities.""" - instance_check: type[T] + instance_check: type[_T] name_suffix: str set_fn: Callable[[DeconzSession, str, int], Coroutine[Any, Any, dict[str, Any]]] update_key: str - value_fn: Callable[[T], float | None] + value_fn: Callable[[_T], float | None] ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 750019dc680..e67c0129147 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -11,8 +11,10 @@ from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType from pydeconz.models.sensor import SensorBase as PydeconzSensorBase from pydeconz.models.sensor.air_quality import AirQuality +from pydeconz.models.sensor.carbon_dioxide import CarbonDioxide from pydeconz.models.sensor.consumption import Consumption from pydeconz.models.sensor.daylight import DAYLIGHT_STATUS, Daylight +from pydeconz.models.sensor.formaldehyde import Formaldehyde from pydeconz.models.sensor.generic_status import GenericStatus from pydeconz.models.sensor.humidity import Humidity from pydeconz.models.sensor.light_level import LightLevel @@ -76,8 +78,10 @@ ATTR_EVENT_ID = "event_id" T = TypeVar( "T", AirQuality, + CarbonDioxide, Consumption, Daylight, + Formaldehyde, GenericStatus, Humidity, LightLevel, @@ -155,6 +159,16 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), + DeconzSensorDescription[CarbonDioxide]( + key="carbon_dioxide", + supported_fn=lambda device: True, + update_key="measured_value", + value_fn=lambda device: device.carbon_dioxide, + instance_check=CarbonDioxide, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + ), DeconzSensorDescription[Consumption]( key="consumption", supported_fn=lambda device: device.consumption is not None, @@ -174,6 +188,16 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( icon="mdi:white-balance-sunny", entity_registry_enabled_default=False, ), + DeconzSensorDescription[Formaldehyde]( + key="formaldehyde", + supported_fn=lambda device: True, + update_key="measured_value", + value_fn=lambda device: device.formaldehyde, + instance_check=Formaldehyde, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + ), DeconzSensorDescription[GenericStatus]( key="status", supported_fn=lambda device: device.status is not None, diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 233f9c3f570..e10195d86bc 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -10,10 +10,6 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_entries_for_device, -) from homeassistant.util.read_only_dict import ReadOnlyDict from .config_flow import get_master_hub @@ -103,13 +99,6 @@ def async_setup_services(hass: HomeAssistant) -> None: ) -@callback -def async_unload_services(hass: HomeAssistant) -> None: - """Unload deCONZ services.""" - for service in SUPPORTED_SERVICES: - hass.services.async_remove(DOMAIN, service) - - async def async_configure_service(hub: DeconzHub, data: ReadOnlyDict) -> None: """Set attribute of device in deCONZ. @@ -153,7 +142,7 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: device_registry = dr.async_get(hub.hass) entity_registry = er.async_get(hub.hass) - entity_entries = async_entries_for_config_entry( + entity_entries = er.async_entries_for_config_entry( entity_registry, hub.config_entry.entry_id ) @@ -203,7 +192,7 @@ async def async_remove_orphaned_entries_service(hub: DeconzHub) -> None: for device_id in devices_to_be_removed: if ( len( - async_entries_for_device( + er.async_entries_for_device( entity_registry, device_id, include_disabled_entities=True ) ) diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index 237577872c9..3f8118a6e5d 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -7,7 +7,7 @@ import copy from functools import wraps import logging import time -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate from bluepy.btle import BTLEException import decora @@ -29,10 +29,6 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_DecoraLightT = TypeVar("_DecoraLightT", bound="DecoraLight") -_R = TypeVar("_R") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) @@ -60,7 +56,7 @@ PLATFORM_SCHEMA = vol.Schema( ) -def retry( +def retry[_DecoraLightT: DecoraLight, **_P, _R]( method: Callable[Concatenate[_DecoraLightT, _P], _R], ) -> Callable[Concatenate[_DecoraLightT, _P], _R | None]: """Retry bluetooth commands.""" @@ -82,8 +78,7 @@ def retry( "Decora connect error for device %s. Reconnecting", device.name, ) - # pylint: disable-next=protected-access - device._switch.connect() + device._switch.connect() # noqa: SLF001 return wrapper_retry diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index 6a313db2669..62367e81af4 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -26,9 +26,10 @@ from .coordinator import DelugeDataUpdateCoordinator PLATFORMS = [Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) +type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bool: """Set up Deluge from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] @@ -42,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError) as ex: raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex - except Exception as ex: # pylint:disable=broad-except + except Exception as ex: # noqa: BLE001 if type(ex).__name__ == "BadLoginError": raise ConfigEntryAuthFailed( "Credentials for Deluge client are not valid" @@ -51,18 +52,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = DelugeDataUpdateCoordinator(hass, api, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class DelugeEntity(CoordinatorEntity[DelugeDataUpdateCoordinator]): diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 8ebf56ceb5b..0a04a17a991 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -94,7 +94,7 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(api.connect) except (ConnectionRefusedError, TimeoutError, SSLError): return "cannot_connect" - except Exception as ex: # pylint:disable=broad-except + except Exception as ex: # noqa: BLE001 if type(ex).__name__ == "BadLoginError": return "invalid_auth" return "unknown" diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py index c3dd25609fe..11557561be8 100644 --- a/homeassistant/components/deluge/coordinator.py +++ b/homeassistant/components/deluge/coordinator.py @@ -4,11 +4,10 @@ from __future__ import annotations from datetime import timedelta from ssl import SSLError -from typing import Any +from typing import TYPE_CHECKING, Any from deluge_client.client import DelugeRPCClient, FailedToReconnectException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,16 +15,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DATA_KEYS, LOGGER +if TYPE_CHECKING: + from . import DelugeConfigEntry + class DelugeDataUpdateCoordinator( DataUpdateCoordinator[dict[Platform, dict[str, Any]]] ): """Data update coordinator for the Deluge integration.""" - config_entry: ConfigEntry + config_entry: DelugeConfigEntry def __init__( - self, hass: HomeAssistant, api: DelugeRPCClient, entry: ConfigEntry + self, hass: HomeAssistant, api: DelugeRPCClient, entry: DelugeConfigEntry ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 1b96c60ec45..fd4bf36889c 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -12,14 +12,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, Platform, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import DelugeEntity -from .const import CURRENT_STATUS, DATA_KEYS, DOMAIN, DOWNLOAD_SPEED, UPLOAD_SPEED +from . import DelugeConfigEntry, DelugeEntity +from .const import CURRENT_STATUS, DATA_KEYS, DOWNLOAD_SPEED, UPLOAD_SPEED from .coordinator import DelugeDataUpdateCoordinator @@ -74,12 +73,13 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DelugeConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Deluge sensor.""" async_add_entities( - DelugeSensor(hass.data[DOMAIN][entry.entry_id], description) - for description in SENSOR_TYPES + DelugeSensor(entry.runtime_data, description) for description in SENSOR_TYPES ) diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 866f7b4f25b..cfae0244ebd 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -5,21 +5,21 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DelugeEntity -from .const import DOMAIN +from . import DelugeConfigEntry, DelugeEntity from .coordinator import DelugeDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DelugeConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Deluge switch.""" - async_add_entities([DelugeSwitch(hass.data[DOMAIN][entry.entry_id])]) + async_add_entities([DelugeSwitch(entry.runtime_data)]) class DelugeSwitch(DelugeEntity, SwitchEntity): diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 738f6af38dd..371b783b653 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -65,12 +65,11 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} - ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} ) + ) if DOMAIN not in config: return True diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index 0b152f87c29..f95042f2cc7 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -30,7 +30,7 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - ManualAlarm( # type:ignore[no-untyped-call] + DemoAlarm( # type:ignore[no-untyped-call] hass, "Security", "1234", @@ -74,3 +74,9 @@ async def async_setup_entry( ) ] ) + + +class DemoAlarm(ManualAlarm): + """Demo Alarm Control Panel.""" + + _attr_unique_id = "demo_alarm_control_panel" diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index cc57ed9a460..468d9cb042b 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -39,6 +39,9 @@ class DemoConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + return self.async_create_entry(title="Demo", data=import_info) diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 8c10877482f..c17e10edd85 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -11,6 +11,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -76,6 +78,16 @@ class DemoLock(LockEntity): """Return true if lock is locked.""" return self._state == STATE_LOCKED + @property + def is_open(self) -> bool: + """Return true if lock is open.""" + return self._state == STATE_OPEN + + @property + def is_opening(self) -> bool: + """Return true if lock is opening.""" + return self._state == STATE_OPENING + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" self._state = STATE_LOCKING @@ -97,5 +109,8 @@ class DemoLock(LockEntity): async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._state = STATE_UNLOCKED + self._state = STATE_OPENING + self.async_write_ha_state() + await asyncio.sleep(LOCK_UNLOCK_DELAY) + self._state = STATE_OPEN self.async_write_ha_state() diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 25e4cc0119c..8d6df72a67e 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from datetime import timedelta from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from denonavr import DenonAVR from denonavr.const import ( @@ -100,11 +100,6 @@ TELNET_EVENTS = { "Z3", } -_DenonDeviceT = TypeVar("_DenonDeviceT", bound="DenonDevice") -_R = TypeVar("_R") -_P = ParamSpec("_P") - - DENON_STATE_MAPPING = { STATE_ON: MediaPlayerState.ON, STATE_OFF: MediaPlayerState.OFF, @@ -164,7 +159,7 @@ async def async_setup_entry( async_add_entities(entities, update_before_add=True) -def async_log_errors( +def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R]( func: Callable[Concatenate[_DenonDeviceT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_DenonDeviceT, _P], Coroutine[Any, Any, _R | None]]: """Log errors occurred when calling a Denon AVR receiver. @@ -177,7 +172,6 @@ def async_log_errors( async def wrapper( self: _DenonDeviceT, *args: _P.args, **kwargs: _P.kwargs ) -> _R | None: - # pylint: disable=protected-access available = True try: return await func(self, *args, **kwargs) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 2b365e96244..5117663f3c5 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -3,12 +3,20 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device import ( + async_remove_stale_devices_links_keep_entity_device, +) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" + + async_remove_stale_devices_links_keep_entity_device( + hass, entry.entry_id, entry.options[CONF_SOURCE] + ) + await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index e15741ce9cf..2ef2018eda8 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -10,11 +10,19 @@ import voluptuous as vol from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_NAME, CONF_SOURCE, UnitOfTime +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + CONF_SOURCE, + UnitOfTime, +) +from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, SchemaConfigFlowHandler, SchemaFlowFormStep, + SchemaOptionsFlowHandler, ) from .const import ( @@ -42,8 +50,43 @@ TIME_UNITS = [ UnitOfTime.DAYS, ] -OPTIONS_SCHEMA = vol.Schema( - { +ALLOWED_DOMAINS = [COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] + + +@callback +def entity_selector_compatible( + handler: SchemaOptionsFlowHandler, +) -> selector.EntitySelector: + """Return an entity selector which compatible entities.""" + current = handler.hass.states.get(handler.options[CONF_SOURCE]) + unit_of_measurement = ( + current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None + ) + + entities = [ + ent.entity_id + for ent in handler.hass.states.async_all(ALLOWED_DOMAINS) + if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement + and ent.domain in ALLOWED_DOMAINS + ] + + return selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=entities) + ) + + +async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict: + if handler is None or not isinstance( + handler.parent_handler, SchemaOptionsFlowHandler + ): + entity_selector = selector.EntitySelector( + selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS) + ) + else: + entity_selector = entity_selector_compatible(handler.parent_handler) + + return { + vol.Required(CONF_SOURCE): entity_selector, vol.Required(CONF_ROUND_DIGITS, default=2): selector.NumberSelector( selector.NumberSelectorConfig( min=0, @@ -62,25 +105,28 @@ OPTIONS_SCHEMA = vol.Schema( ), ), } -) -CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): selector.TextSelector(), - vol.Required(CONF_SOURCE): selector.EntitySelector( - selector.EntitySelectorConfig( - domain=[COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] - ), - ), - } -).extend(OPTIONS_SCHEMA.schema) + +async def _get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + return vol.Schema(await _get_options_dict(handler)) + + +async def _get_config_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + options = await _get_options_dict(handler) + return vol.Schema( + { + vol.Required(CONF_NAME): selector.TextSelector(), + **options, + } + ) + CONFIG_FLOW = { - "user": SchemaFlowFormStep(CONFIG_SCHEMA), + "user": SchemaFlowFormStep(_get_config_schema), } OPTIONS_FLOW = { - "init": SchemaFlowFormStep(OPTIONS_SCHEMA), + "init": SchemaFlowFormStep(_get_options_schema), } diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index d5a83035ed5..fd430c6ef4d 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -20,11 +20,8 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -90,27 +87,10 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE] ) - source_entity = registry.async_get(source_entity_id) - dev_reg = dr.async_get(hass) - # Resolve source entity device - if ( - (source_entity is not None) - and (source_entity.device_id is not None) - and ( - ( - device := dev_reg.async_get( - device_id=source_entity.device_id, - ) - ) - is not None - ) - ): - device_info = DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, - ) - else: - device_info = None + device_info = async_device_info_to_link_from_entity( + hass, + source_entity_id, + ) if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": # Before we had support for optional selectors, "none" was used for selecting nothing diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 6d95d18214e..567b8fcc2d2 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -9,7 +9,7 @@ from enum import Enum from functools import wraps import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, overload +from typing import TYPE_CHECKING, Any, Literal, overload import voluptuous as vol import voluptuous_serialize @@ -49,7 +49,7 @@ if TYPE_CHECKING: from .condition import DeviceAutomationConditionProtocol from .trigger import DeviceAutomationTriggerProtocol - DeviceAutomationPlatformType: TypeAlias = ( + type DeviceAutomationPlatformType = ( ModuleType | DeviceAutomationTriggerProtocol | DeviceAutomationConditionProtocol @@ -369,7 +369,7 @@ def handle_device_errors( await func(hass, connection, msg) except DeviceNotFound: connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Device not found" + msg["id"], websocket_api.ERR_NOT_FOUND, "Device not found" ) return with_error_handling diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index ca78b1cbdc5..92c961eb148 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -14,7 +14,6 @@ from homeassistant.helpers.deprecation import ( from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import group as group_pre_import # noqa: F401 from .config_entry import ( # noqa: F401 ScannerEntity, TrackerEntity, diff --git a/homeassistant/components/device_tracker/group.py b/homeassistant/components/device_tracker/group.py deleted file mode 100644 index 1c28887c2ca..00000000000 --- a/homeassistant/components/device_tracker/group.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states(DOMAIN, {STATE_HOME}, STATE_HOME, STATE_NOT_HOME) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index dfeed98f320..ac168c06fb1 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -365,7 +365,7 @@ class DeviceTrackerPlatform: hass.config.components.add(full_name) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception( "Error setting up platform %s %s", self.type, self.name ) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 44c43219b82..d6e36d92300 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -5,6 +5,9 @@ "is_home": "{entity_name} is home", "is_not_home": "{entity_name} is not home" }, + "extra_fields": { + "zone": "[%key:common::device_automation::extra_fields::zone%]" + }, "trigger_type": { "enters": "{entity_name} enters a zone", "leaves": "{entity_name} leaves a zone" diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index cbdc02e44c8..7755e0f22b4 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import CONF_MYDEVOLO, DEFAULT_MYDEVOLO, GATEWAY_SERIAL_PATTERN, PLATFORMS -DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] +type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] async def async_setup_entry( @@ -62,7 +62,7 @@ async def async_setup_entry( await hass.async_add_executor_job( partial( HomeControl, - gateway_id=gateway_id, + gateway_id=str(gateway_id), mydevolo_instance=mydevolo, zeroconf_instance=zeroconf_instance, ) diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index 662ce51daaf..0687a4a907f 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -125,13 +125,9 @@ class DevoloHomeControlFlowHandler(ConfigFlow, domain=DOMAIN): # The old user and the new user are not the same. This could mess-up everything as all unique IDs might change. raise UuidChanged - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self._reauth_entry, data=user_input, unique_id=uuid ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - ) - return self.async_abort(reason="reauth_successful") @callback def _show_form( diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index d96312be4e6..59aafb1eb9c 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any @@ -48,10 +49,21 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type DevoloHomeNetworkConfigEntry = ConfigEntry[DevoloHomeNetworkData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class DevoloHomeNetworkData: + """The devolo Home Network data.""" + + device: Device + coordinators: dict[str, DataUpdateCoordinator[Any]] + + +async def async_setup_entry( + hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry +) -> bool: """Set up devolo Home Network from a config entry.""" - hass.data.setdefault(DOMAIN, {}) zeroconf_instance = await zeroconf.async_get_async_instance(hass) async_client = get_async_client(hass) device_registry = dr.async_get(hass) @@ -73,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_placeholders={"ip_address": entry.data[CONF_IP_ADDRESS]}, ) from err - hass.data[DOMAIN][entry.entry_id] = {"device": device} + entry.runtime_data = DevoloHomeNetworkData(device=device, coordinators={}) async def async_update_firmware_available() -> UpdateFirmwareCheck: """Fetch data from API endpoint.""" @@ -188,7 +200,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id]["coordinators"] = coordinators + entry.runtime_data.coordinators = coordinators await hass.config_entries.async_forward_entry_setups(entry, platforms(device)) @@ -199,15 +211,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry +) -> bool: """Unload a config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + device = entry.runtime_data.device unload_ok = await hass.config_entries.async_unload_platforms( entry, platforms(device) ) if unload_ok: await device.async_disconnect() - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 6750fbc50d5..38d79951149 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -4,9 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any -from devolo_plc_api import Device from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.components.binary_sensor import ( @@ -14,13 +12,13 @@ from homeassistant.components.binary_sensor import ( 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER, DOMAIN +from . import DevoloHomeNetworkConfigEntry +from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER from .entity import DevoloCoordinatorEntity @@ -52,13 +50,12 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevoloHomeNetworkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] - coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinators"] + coordinators = entry.runtime_data.coordinators entities: list[BinarySensorEntity] = [] entities.append( @@ -66,7 +63,6 @@ async def async_setup_entry( entry, coordinators[CONNECTED_PLC_DEVICES], SENSOR_TYPES[CONNECTED_TO_ROUTER], - device, ) ) async_add_entities(entities) @@ -79,14 +75,13 @@ class DevoloBinarySensorEntity( def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[LogicalNetwork], description: DevoloBinarySensorEntityDescription, - device: Device, ) -> None: """Initialize entity.""" self.entity_description: DevoloBinarySensorEntityDescription = description - super().__init__(entry, coordinator, device) + super().__init__(entry, coordinator) @property def is_on(self) -> bool: diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 1dcdc007189..1f67912f020 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -13,12 +13,12 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS from .entity import DevoloEntity @@ -55,10 +55,12 @@ BUTTON_TYPES: dict[str, DevoloButtonEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevoloHomeNetworkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Get all devices and buttons and setup them via config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + device = entry.runtime_data.device entities: list[DevoloButtonEntity] = [] if device.plcnet: @@ -66,14 +68,12 @@ async def async_setup_entry( DevoloButtonEntity( entry, BUTTON_TYPES[IDENTIFY], - device, ) ) entities.append( DevoloButtonEntity( entry, BUTTON_TYPES[PAIRING], - device, ) ) if device.device and "restart" in device.device.features: @@ -81,7 +81,6 @@ async def async_setup_entry( DevoloButtonEntity( entry, BUTTON_TYPES[RESTART], - device, ) ) if device.device and "wifi1" in device.device.features: @@ -89,7 +88,6 @@ async def async_setup_entry( DevoloButtonEntity( entry, BUTTON_TYPES[START_WPS], - device, ) ) async_add_entities(entities) @@ -102,13 +100,12 @@ class DevoloButtonEntity(DevoloEntity, ButtonEntity): def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, description: DevoloButtonEntityDescription, - device: Device, ) -> None: """Initialize entity.""" self.entity_description = description - super().__init__(entry, device) + super().__init__(entry) async def async_press(self) -> None: """Handle the button press.""" diff --git a/homeassistant/components/devolo_home_network/config_flow.py b/homeassistant/components/devolo_home_network/config_flow.py index a53211aa479..63d86d46e8a 100644 --- a/homeassistant/components/devolo_home_network/config_flow.py +++ b/homeassistant/components/devolo_home_network/config_flow.py @@ -63,7 +63,7 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except DeviceNotFound: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -114,10 +114,11 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult: """Handle reauthentication.""" - self.context[CONF_HOST] = data[CONF_IP_ADDRESS] - self.context["title_placeholders"][PRODUCT] = self.hass.data[DOMAIN][ - self.context["entry_id"] - ]["device"].product + if entry := self.hass.config_entries.async_get_entry(self.context["entry_id"]): + self.context[CONF_HOST] = data[CONF_IP_ADDRESS] + self.context["title_placeholders"][PRODUCT] = ( + entry.runtime_data.device.product + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -139,11 +140,4 @@ class DevoloHomeNetworkConfigFlow(ConfigFlow, domain=DOMAIN): CONF_IP_ADDRESS: self.context[CONF_HOST], CONF_PASSWORD: user_input[CONF_PASSWORD], } - self.hass.config_entries.async_update_entry( - reauth_entry, - data=data, - ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(reauth_entry.entry_id) - ) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(reauth_entry, data=data) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index f97a4c36400..0a221779622 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -10,7 +10,6 @@ from homeassistant.components.device_tracker import ( ScannerEntity, SourceType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -20,16 +19,19 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) +from . import DevoloHomeNetworkConfigEntry from .const import CONNECTED_WIFI_CLIENTS, DOMAIN, WIFI_APTYPE, WIFI_BANDS async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevoloHomeNetworkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + device = entry.runtime_data.device coordinators: dict[str, DataUpdateCoordinator[list[ConnectedStationInfo]]] = ( - hass.data[DOMAIN][entry.entry_id]["coordinators"] + entry.runtime_data.coordinators ) registry = er.async_get(hass) tracked = set() diff --git a/homeassistant/components/devolo_home_network/diagnostics.py b/homeassistant/components/devolo_home_network/diagnostics.py index 17d65fd26b2..9cfc8a2c260 100644 --- a/homeassistant/components/devolo_home_network/diagnostics.py +++ b/homeassistant/components/devolo_home_network/diagnostics.py @@ -4,23 +4,20 @@ from __future__ import annotations from typing import Any -from devolo_plc_api import Device - from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import DevoloHomeNetworkConfigEntry TO_REDACT = {CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + device = entry.runtime_data.device diag_data = { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a6159d7b948..e77c3f60803 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -2,9 +2,6 @@ from __future__ import annotations -from typing import TypeVar - -from devolo_plc_api.device import Device from devolo_plc_api.device_api import ( ConnectedStationInfo, NeighborAPInfo, @@ -12,7 +9,6 @@ from devolo_plc_api.device_api import ( ) 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 from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import ( @@ -20,18 +16,16 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) +from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN -_DataT = TypeVar( - "_DataT", - bound=( - LogicalNetwork - | DataRate - | list[ConnectedStationInfo] - | list[NeighborAPInfo] - | WifiGuestAccessGet - | bool - ), +type _DataType = ( + LogicalNetwork + | DataRate + | list[ConnectedStationInfo] + | list[NeighborAPInfo] + | WifiGuestAccessGet + | bool ) @@ -42,37 +36,37 @@ class DevoloEntity(Entity): def __init__( self, - entry: ConfigEntry, - device: Device, + entry: DevoloHomeNetworkConfigEntry, ) -> None: """Initialize a devolo home network device.""" - self.device = device + self.device = entry.runtime_data.device self.entry = entry self._attr_device_info = DeviceInfo( - configuration_url=f"http://{device.ip}", - connections={(CONNECTION_NETWORK_MAC, device.mac)}, - identifiers={(DOMAIN, str(device.serial_number))}, + configuration_url=f"http://{self.device.ip}", + connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, + identifiers={(DOMAIN, str(self.device.serial_number))}, manufacturer="devolo", - model=device.product, - serial_number=device.serial_number, - sw_version=device.firmware_version, + model=self.device.product, + serial_number=self.device.serial_number, + sw_version=self.device.firmware_version, ) self._attr_translation_key = self.entity_description.key - self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" + self._attr_unique_id = ( + f"{self.device.serial_number}_{self.entity_description.key}" + ) -class DevoloCoordinatorEntity( +class DevoloCoordinatorEntity[_DataT: _DataType]( CoordinatorEntity[DataUpdateCoordinator[_DataT]], DevoloEntity ): """Representation of a coordinated devolo home network device.""" def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[_DataT], - device: Device, ) -> None: """Initialize a devolo home network device.""" super().__init__(coordinator) - DevoloEntity.__init__(self, entry, device) + DevoloEntity.__init__(self, entry) diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 71d27b18d0c..ee3b079da02 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -5,20 +5,19 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from functools import partial -from typing import Any -from devolo_plc_api import Device, wifi_qr_code +from devolo_plc_api import wifi_qr_code from devolo_plc_api.device_api import WifiGuestAccessGet from homeassistant.components.image import ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator import homeassistant.util.dt as dt_util -from .const import DOMAIN, IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI +from . import DevoloHomeNetworkConfigEntry +from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI from .entity import DevoloCoordinatorEntity @@ -39,13 +38,12 @@ IMAGE_TYPES: dict[str, DevoloImageEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevoloHomeNetworkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] - coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinators"] + coordinators = entry.runtime_data.coordinators entities: list[ImageEntity] = [] entities.append( @@ -53,7 +51,6 @@ async def async_setup_entry( entry, coordinators[SWITCH_GUEST_WIFI], IMAGE_TYPES[IMAGE_GUEST_WIFI], - device, ) ) async_add_entities(entities) @@ -66,14 +63,13 @@ class DevoloImageEntity(DevoloCoordinatorEntity[WifiGuestAccessGet], ImageEntity def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[WifiGuestAccessGet], description: DevoloImageEntityDescription, - device: Device, ) -> None: """Initialize entity.""" self.entity_description: DevoloImageEntityDescription = description - super().__init__(entry, coordinator, device) + super().__init__(entry, coordinator) ImageEntity.__init__(self, coordinator.hass) self._attr_image_last_updated = dt_util.utcnow() self._data = self.coordinator.data diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index cc682d8f694..ffd40acf42a 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -7,7 +7,6 @@ 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 REMOTE, DataRate, LogicalNetwork @@ -17,16 +16,15 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry 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 +from . import DevoloHomeNetworkConfigEntry from .const import ( CONNECTED_PLC_DEVICES, CONNECTED_WIFI_CLIENTS, - DOMAIN, NEIGHBORING_WIFI_NETWORKS, PLC_RX_RATE, PLC_TX_RATE, @@ -101,13 +99,13 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevoloHomeNetworkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] - coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinators"] + device = entry.runtime_data.device + coordinators = entry.runtime_data.coordinators entities: list[BaseDevoloSensorEntity[Any, Any]] = [] if device.plcnet: @@ -116,7 +114,6 @@ async def async_setup_entry( entry, coordinators[CONNECTED_PLC_DEVICES], SENSOR_TYPES[CONNECTED_PLC_DEVICES], - device, ) ) network = await device.plcnet.async_get_network_overview() @@ -129,7 +126,6 @@ async def async_setup_entry( entry, coordinators[CONNECTED_PLC_DEVICES], SENSOR_TYPES[PLC_TX_RATE], - device, peer, ) ) @@ -138,7 +134,6 @@ async def async_setup_entry( entry, coordinators[CONNECTED_PLC_DEVICES], SENSOR_TYPES[PLC_RX_RATE], - device, peer, ) ) @@ -148,7 +143,6 @@ async def async_setup_entry( entry, coordinators[CONNECTED_WIFI_CLIENTS], SENSOR_TYPES[CONNECTED_WIFI_CLIENTS], - device, ) ) entities.append( @@ -156,7 +150,6 @@ async def async_setup_entry( entry, coordinators[NEIGHBORING_WIFI_NETWORKS], SENSOR_TYPES[NEIGHBORING_WIFI_NETWORKS], - device, ) ) async_add_entities(entities) @@ -171,14 +164,13 @@ class BaseDevoloSensorEntity( def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[_CoordinatorDataT], description: DevoloSensorEntityDescription[_ValueDataT], - device: Device, ) -> None: """Initialize entity.""" self.entity_description = description - super().__init__(entry, coordinator, device) + super().__init__(entry, coordinator) class DevoloSensorEntity(BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT]): @@ -199,14 +191,13 @@ class DevoloPlcDataRateSensorEntity(BaseDevoloSensorEntity[LogicalNetwork, DataR def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[LogicalNetwork], description: DevoloSensorEntityDescription[DataRate], - device: Device, peer: str, ) -> None: """Initialize entity.""" - super().__init__(entry, coordinator, description, device) + super().__init__(entry, coordinator, description) self._peer = peer peer_device = next( device diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 2a9775257a8..3df67287f3b 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -11,13 +11,13 @@ from devolo_plc_api.device_api import WifiGuestAccessGet from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS from .entity import DevoloCoordinatorEntity @@ -51,13 +51,13 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevoloHomeNetworkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] - coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinators"] + device = entry.runtime_data.device + coordinators = entry.runtime_data.coordinators entities: list[DevoloSwitchEntity[Any]] = [] if device.device and "led" in device.device.features: @@ -66,7 +66,6 @@ async def async_setup_entry( entry, coordinators[SWITCH_LEDS], SWITCH_TYPES[SWITCH_LEDS], - device, ) ) if device.device and "wifi1" in device.device.features: @@ -75,7 +74,6 @@ async def async_setup_entry( entry, coordinators[SWITCH_GUEST_WIFI], SWITCH_TYPES[SWITCH_GUEST_WIFI], - device, ) ) async_add_entities(entities) @@ -88,14 +86,13 @@ class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity): def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[_DataT], description: DevoloSwitchEntityDescription[_DataT], - device: Device, ) -> None: """Initialize entity.""" self.entity_description = description - super().__init__(entry, coordinator, device) + super().__init__(entry, coordinator) @property def is_on(self) -> bool: diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 75fc1b7b99c..92f5cb0f094 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -16,13 +16,13 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, REGULAR_FIRMWARE from .entity import DevoloCoordinatorEntity @@ -47,13 +47,12 @@ UPDATE_TYPES: dict[str, DevoloUpdateEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DevoloHomeNetworkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" - device: Device = hass.data[DOMAIN][entry.entry_id]["device"] - coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinators"] + coordinators = entry.runtime_data.coordinators async_add_entities( [ @@ -61,7 +60,6 @@ async def async_setup_entry( entry, coordinators[REGULAR_FIRMWARE], UPDATE_TYPES[REGULAR_FIRMWARE], - device, ) ] ) @@ -78,14 +76,13 @@ class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity): def __init__( self, - entry: ConfigEntry, + entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator, description: DevoloUpdateEntityDescription, - device: Device, ) -> None: """Initialize entity.""" self.entity_description = description - super().__init__(entry, coordinator, device) + super().__init__(entry, coordinator) self._in_progress_old_version: str | None = None @property diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 48cdcd99439..19b35c2b03d 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -40,7 +40,7 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AccountError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" if "base" not in errors: diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index b4d06b6e276..e830de39f29 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -45,13 +45,12 @@ from homeassistant.core import ( callback, ) from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import config_validation as cv, discovery_flow -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceRegistry, - async_get, - format_mac, +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery_flow, ) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( async_track_state_added_domain, @@ -243,7 +242,7 @@ class WatcherBase: matchers = self._integration_matchers registered_devices_domains = matchers.registered_devices_domains - dev_reg: DeviceRegistry = async_get(self.hass) + dev_reg = dr.async_get(self.hass) if device := dev_reg.async_get_device( connections={(CONNECTION_NETWORK_MAC, formatted_mac)} ): diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 6c70e0dc110..b23b7cef2bd 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -15,15 +15,24 @@ import voluptuous as vol from homeassistant.components import http, websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, integration_platform -from homeassistant.helpers.device_registry import DeviceEntry, async_get +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + integration_platform, +) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.json import ( ExtendedJSONEncoder, find_paths_unserializable_data, ) from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_custom_components, async_get_integration +from homeassistant.loader import ( + Manifest, + async_get_custom_components, + async_get_integration, +) +from homeassistant.setup import async_get_domain_setup_times from homeassistant.util.json import format_unserializable_data from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType @@ -156,6 +165,23 @@ def handle_get( ) +@callback +def async_format_manifest(manifest: Manifest) -> Manifest: + """Format manifest for diagnostics. + + Remove the @ from codeowners so that + when users download the diagnostics and paste + the codeowners into the repository, it will + not notify the users in the codeowners file. + """ + manifest_copy = manifest.copy() + if "codeowners" in manifest_copy: + manifest_copy["codeowners"] = [ + codeowner.lstrip("@") for codeowner in manifest_copy["codeowners"] + ] + return manifest_copy + + async def _async_get_json_file_response( hass: HomeAssistant, data: Mapping[str, Any], @@ -178,17 +204,15 @@ async def _async_get_json_file_response( "version": cc_obj.version, "requirements": cc_obj.requirements, } + payload = { + "home_assistant": hass_sys_info, + "custom_components": custom_components, + "integration_manifest": async_format_manifest(integration.manifest), + "setup_times": async_get_domain_setup_times(hass, domain), + "data": data, + } try: - json_data = json.dumps( - { - "home_assistant": hass_sys_info, - "custom_components": custom_components, - "integration_manifest": integration.manifest, - "data": data, - }, - indent=2, - cls=ExtendedJSONEncoder, - ) + json_data = json.dumps(payload, indent=2, cls=ExtendedJSONEncoder) except TypeError: _LOGGER.error( "Failed to serialize to JSON: %s/%s%s. Bad data at %s", @@ -197,7 +221,7 @@ async def _async_get_json_file_response( f"/{DiagnosticsSubType.DEVICE.value}/{sub_id}" if sub_id is not None else "", - format_unserializable_data(find_paths_unserializable_data(data)), + format_unserializable_data(find_paths_unserializable_data(payload)), ) return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) @@ -260,7 +284,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): ) # Device diagnostics - dev_reg = async_get(hass) + dev_reg = dr.async_get(hass) if sub_id is None: return web.Response(status=HTTPStatus.BAD_REQUEST) diff --git a/homeassistant/components/diagnostics/util.py b/homeassistant/components/diagnostics/util.py index 9b33b33f1ed..0ca85c9a584 100644 --- a/homeassistant/components/diagnostics/util.py +++ b/homeassistant/components/diagnostics/util.py @@ -3,26 +3,23 @@ from __future__ import annotations from collections.abc import Iterable, Mapping -from typing import Any, TypeVar, cast, overload +from typing import Any, cast, overload from homeassistant.core import callback from .const import REDACTED -_T = TypeVar("_T") + +@overload +def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict: ... @overload -def async_redact_data(data: Mapping, to_redact: Iterable[Any]) -> dict: # type: ignore[overload-overlap] - ... - - -@overload -def async_redact_data(data: _T, to_redact: Iterable[Any]) -> _T: ... +def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T: ... @callback -def async_redact_data(data: _T, to_redact: Iterable[Any]) -> _T: +def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T: """Redact sensitive data in a dict.""" if not isinstance(data, (Mapping, list)): return data diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py index 95c8861d665..db7739bc34d 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -112,12 +112,12 @@ async def async_handle_message(hass, message): ) req = message.get("result") if req.get("actionIncomplete", True): - return + return None elif _api_version is V2: req = message.get("queryResult") if req.get("allRequiredParamsPresent", False) is False: - return + return None action = req.get("action", "") parameters = req.get("parameters").copy() diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index f1289119f2b..7cdfd5c07c9 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -55,7 +55,7 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except DIRECTVError: return self._show_setup_form({"base": ERROR_CANNOT_CONNECT}) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason=ERROR_UNKNOWN) @@ -88,7 +88,7 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, self.discovery_info) except DIRECTVError: return self.async_abort(reason=ERROR_CANNOT_CONNECT) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason=ERROR_UNKNOWN) diff --git a/homeassistant/components/discord/config_flow.py b/homeassistant/components/discord/config_flow.py index a25a86cab3a..f86c597fb57 100644 --- a/homeassistant/components/discord/config_flow.py +++ b/homeassistant/components/discord/config_flow.py @@ -89,7 +89,7 @@ async def _async_try_connect(token: str) -> tuple[str | None, nextcord.AppInfo | return "invalid_auth", None except (ClientConnectorError, nextcord.HTTPException, nextcord.NotFound): return "cannot_connect", None - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return "unknown", None await discord_bot.close() diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 974441f3899..72aa6c19a21 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -16,7 +16,7 @@ from .coordinator import DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] -DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] +type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -> bool: diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index e47935764a8..5e17f0764b7 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -91,7 +91,7 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except discovergyError.InvalidLogin: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error occurred while getting meters") errors["base"] = "unknown" else: diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py index 39ff7a7cd4b..80c3c23a8fa 100644 --- a/homeassistant/components/discovergy/const.py +++ b/homeassistant/components/discovergy/const.py @@ -3,4 +3,4 @@ from __future__ import annotations DOMAIN = "discovergy" -MANUFACTURER = "Discovergy" +MANUFACTURER = "inexogy" diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index da9fb117353..1061766a64c 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -1,10 +1,10 @@ { "domain": "discovergy", - "name": "Discovergy", + "name": "inexogy", "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==3.0.0"] + "requirements": ["pydiscovergy==3.0.1"] } diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 5147440e1b7..34c21bc1cfe 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -26,7 +26,7 @@ }, "system_health": { "info": { - "api_endpoint_reachable": "Discovergy API endpoint reachable" + "api_endpoint_reachable": "inexogy API endpoint reachable" } }, "entity": { diff --git a/homeassistant/components/dlink/__init__.py b/homeassistant/components/dlink/__init__.py index 80260643223..212fe2e9e21 100644 --- a/homeassistant/components/dlink/__init__.py +++ b/homeassistant/components/dlink/__init__.py @@ -9,13 +9,15 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platfor from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_USE_LEGACY_PROTOCOL, DOMAIN +from .const import CONF_USE_LEGACY_PROTOCOL from .data import SmartPlugData +type DLinkConfigEntry = ConfigEntry[SmartPlugData] + PLATFORMS = [Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: DLinkConfigEntry) -> bool: """Set up D-Link Power Plug from a config entry.""" smartplug = await hass.async_add_executor_job( SmartPlug, @@ -27,14 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not smartplug.authenticated and smartplug.use_legacy_protocol: raise ConfigEntryNotReady("Cannot connect/authenticate") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SmartPlugData(smartplug) + entry.runtime_data = SmartPlugData(smartplug) 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, entry: DLinkConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 4613aeb9cef..4452a2958fc 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -121,7 +121,7 @@ class DLinkFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_USERNAME], user_input[CONF_USE_LEGACY_PROTOCOL], ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return "unknown" if not smartplug.authenticated and smartplug.use_legacy_protocol: diff --git a/homeassistant/components/dlink/entity.py b/homeassistant/components/dlink/entity.py index 2a9ac0e6c12..228dfd168a5 100644 --- a/homeassistant/components/dlink/entity.py +++ b/homeassistant/components/dlink/entity.py @@ -2,14 +2,13 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from . import DLinkConfigEntry from .const import ATTRIBUTION, DOMAIN, MANUFACTURER -from .data import SmartPlugData class DLinkEntity(Entity): @@ -20,18 +19,17 @@ class DLinkEntity(Entity): def __init__( self, - config_entry: ConfigEntry, - data: SmartPlugData, + config_entry: DLinkConfigEntry, description: EntityDescription, ) -> None: """Initialize a D-Link Power Plug entity.""" - self.data = data + self.data = config_entry.runtime_data self.entity_description = description self._attr_unique_id = f"{config_entry.entry_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer=MANUFACTURER, - model=data.smartplug.model_name, + model=self.data.smartplug.model_name, name=config_entry.title, ) if config_entry.unique_id: diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index 36bfe4fb391..54322cc6875 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -6,12 +6,12 @@ from datetime import timedelta from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_TOTAL_CONSUMPTION, DOMAIN +from . import DLinkConfigEntry +from .const import ATTR_TOTAL_CONSUMPTION from .entity import DLinkEntity SCAN_INTERVAL = timedelta(minutes=2) @@ -22,13 +22,12 @@ SWITCH_TYPE = SwitchEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DLinkConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the D-Link Power Plug switch.""" - async_add_entities( - [SmartPlugSwitch(entry, hass.data[DOMAIN][entry.entry_id], SWITCH_TYPE)], - True, - ) + async_add_entities([SmartPlugSwitch(entry, SWITCH_TYPE)], True) class SmartPlugSwitch(DLinkEntity, SwitchEntity): diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 837bfc456d8..6b551f0e999 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -40,7 +40,7 @@ from .data import get_domain_data LOGGER = logging.getLogger(__name__) -FlowInput = Mapping[str, Any] | None +type FlowInput = Mapping[str, Any] | None class ConnectError(IntegrationError): @@ -149,7 +149,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): # case the device doesn't have a static and unique UDN (breaking the # UPnP spec). for entry in self._async_current_entries(include_ignore=True): - if self._location == entry.data[CONF_URL]: + if self._location == entry.data.get(CONF_URL): return self.async_abort(reason="already_configured") if self._mac and self._mac == entry.data.get(CONF_MAC): return self.async_abort(reason="already_configured") diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 69b9c0ffdb7..443c2101302 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Sequence import contextlib from datetime import datetime, timedelta import functools -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from async_upnp_client.client import UpnpService, UpnpStateVariable from async_upnp_client.const import NotificationSubType @@ -52,11 +52,6 @@ from .data import EventListenAddr, get_domain_data PARALLEL_UPDATES = 0 -_DlnaDmrEntityT = TypeVar("_DlnaDmrEntityT", bound="DlnaDmrEntity") -_R = TypeVar("_R") -_P = ParamSpec("_P") - - _TRANSPORT_STATE_TO_MEDIA_PLAYER_STATE = { TransportState.PLAYING: MediaPlayerState.PLAYING, TransportState.TRANSITIONING: MediaPlayerState.PLAYING, @@ -68,7 +63,7 @@ _TRANSPORT_STATE_TO_MEDIA_PLAYER_STATE = { } -def catch_request_errors( +def catch_request_errors[_DlnaDmrEntityT: DlnaDmrEntity, **_P, _R]( func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_DlnaDmrEntityT, _P], Coroutine[Any, Any, _R | None]]: """Catch UpnpError errors.""" @@ -530,8 +525,12 @@ class DlnaDmrEntity(MediaPlayerEntity): TransportState.PAUSED_PLAYBACK, ): force_refresh = True + break - self.async_schedule_update_ha_state(force_refresh) + if force_refresh: + self.async_schedule_update_ha_state(force_refresh) + else: + self.async_write_ha_state() @property def available(self) -> bool: diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 2312c7d2e3d..afff1152cca 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from enum import StrEnum import functools from functools import cached_property -from typing import Any, TypeVar, cast +from typing import Any, cast from async_upnp_client.aiohttp import AiohttpSessionRequester from async_upnp_client.client import UpnpRequester @@ -43,9 +43,6 @@ from .const import ( STREAMABLE_PROTOCOLS, ) -_DlnaDmsDeviceMethod = TypeVar("_DlnaDmsDeviceMethod", bound="DmsDeviceSource") -_R = TypeVar("_R") - class DlnaDmsData: """Storage class for domain global data.""" @@ -124,7 +121,7 @@ class ActionError(DlnaDmsDeviceError): """Error when calling a UPnP Action on the device.""" -def catch_request_errors( +def catch_request_errors[_DlnaDmsDeviceMethod: DmsDeviceSource, _R]( func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]], ) -> Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]]: """Catch UpnpError errors.""" diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 78309b5f2bf..37e0f60849f 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -3,9 +3,10 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_PORT +from homeassistant.core import _LOGGER, HomeAssistant -from .const import PLATFORMS +from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -25,3 +26,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload dnsip config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry to a newer version.""" + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version < 2 and config_entry.minor_version < 2: + version = config_entry.version + minor_version = config_entry.minor_version + _LOGGER.debug( + "Migrating configuration from version %s.%s", + version, + minor_version, + ) + + new_options = {**config_entry.options} + new_options[CONF_PORT] = DEFAULT_PORT + new_options[CONF_PORT_IPV6] = DEFAULT_PORT + + hass.config_entries.async_update_entry( + config_entry, options=new_options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + 1, + 2, + ) + + return True diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index f07971d5db5..6dda0c03910 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlowWithConfigEntry, ) -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -25,10 +25,12 @@ from .const import ( CONF_IPV4, CONF_IPV6, CONF_IPV6_V4, + CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, DEFAULT_HOSTNAME, DEFAULT_NAME, + DEFAULT_PORT, DEFAULT_RESOLVER, DEFAULT_RESOLVER_IPV6, DOMAIN, @@ -42,32 +44,42 @@ DATA_SCHEMA = vol.Schema( DATA_SCHEMA_ADV = vol.Schema( { vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string, - vol.Optional(CONF_RESOLVER, default=DEFAULT_RESOLVER): cv.string, - vol.Optional(CONF_RESOLVER_IPV6, default=DEFAULT_RESOLVER_IPV6): cv.string, + vol.Optional(CONF_RESOLVER): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_RESOLVER_IPV6): cv.string, + vol.Optional(CONF_PORT_IPV6): cv.port, } ) async def async_validate_hostname( - hostname: str, resolver_ipv4: str, resolver_ipv6: str + hostname: str, + resolver_ipv4: str, + resolver_ipv6: str, + port: int, + port_ipv6: int, ) -> dict[str, bool]: """Validate hostname.""" - async def async_check(hostname: str, resolver: str, qtype: str) -> bool: + async def async_check( + hostname: str, resolver: str, qtype: str, port: int = 53 + ) -> bool: """Return if able to resolve hostname.""" result = False with contextlib.suppress(DNSError): result = bool( - await aiodns.DNSResolver(nameservers=[resolver]).query(hostname, qtype) + await aiodns.DNSResolver( + nameservers=[resolver], udp_port=port, tcp_port=port + ).query(hostname, qtype) ) return result result: dict[str, bool] = {} tasks = await asyncio.gather( - async_check(hostname, resolver_ipv4, "A"), - async_check(hostname, resolver_ipv6, "AAAA"), - async_check(hostname, resolver_ipv4, "AAAA"), + async_check(hostname, resolver_ipv4, "A", port=port), + async_check(hostname, resolver_ipv6, "AAAA", port=port_ipv6), + async_check(hostname, resolver_ipv4, "AAAA", port=port), ) result[CONF_IPV4] = tasks[0] @@ -81,6 +93,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for dnsip integration.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -102,8 +115,12 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER) resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6) + port = user_input.get(CONF_PORT, DEFAULT_PORT) + port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT) - validate = await async_validate_hostname(hostname, resolver, resolver_ipv6) + validate = await async_validate_hostname( + hostname, resolver, resolver_ipv6, port, port_ipv6 + ) set_resolver = resolver if validate[CONF_IPV6]: @@ -129,7 +146,9 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): }, options={ CONF_RESOLVER: resolver, + CONF_PORT: port, CONF_RESOLVER_IPV6: set_resolver, + CONF_PORT_IPV6: port_ipv6, }, ) @@ -156,11 +175,15 @@ class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry): errors = {} if user_input is not None: resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER) + port = user_input.get(CONF_PORT, DEFAULT_PORT) resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6) + port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT) validate = await async_validate_hostname( self.config_entry.data[CONF_HOSTNAME], resolver, resolver_ipv6, + port, + port_ipv6, ) if ( @@ -176,14 +199,21 @@ class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry): else: return self.async_create_entry( title=self.config_entry.title, - data={CONF_RESOLVER: resolver, CONF_RESOLVER_IPV6: resolver_ipv6}, + data={ + CONF_RESOLVER: resolver, + CONF_PORT: port, + CONF_RESOLVER_IPV6: resolver_ipv6, + CONF_PORT_IPV6: port_ipv6, + }, ) schema = self.add_suggested_values_to_schema( vol.Schema( { vol.Optional(CONF_RESOLVER): cv.string, + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_RESOLVER_IPV6): cv.string, + vol.Optional(CONF_PORT_IPV6): cv.port, } ), self.config_entry.options, diff --git a/homeassistant/components/dnsip/const.py b/homeassistant/components/dnsip/const.py index 41116bde61a..2e81099df34 100644 --- a/homeassistant/components/dnsip/const.py +++ b/homeassistant/components/dnsip/const.py @@ -8,6 +8,7 @@ PLATFORMS = [Platform.SENSOR] CONF_HOSTNAME = "hostname" CONF_RESOLVER = "resolver" CONF_RESOLVER_IPV6 = "resolver_ipv6" +CONF_PORT_IPV6 = "port_ipv6" CONF_IPV4 = "ipv4" CONF_IPV6 = "ipv6" CONF_IPV6_V4 = "ipv6_v4" @@ -16,4 +17,5 @@ DEFAULT_HOSTNAME = "myip.opendns.com" DEFAULT_IPV6 = False DEFAULT_NAME = "myip" DEFAULT_RESOLVER = "208.67.222.222" +DEFAULT_PORT = 53 DEFAULT_RESOLVER_IPV6 = "2620:119:53::53" diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 529de6f2b1b..34730e934a0 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +from ipaddress import IPv4Address, IPv6Address import logging import aiodns @@ -10,7 +11,7 @@ from aiodns.error import DNSError from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,18 +20,30 @@ from .const import ( CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, + CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, DOMAIN, ) DEFAULT_RETRIES = 2 +MAX_RESULTS = 10 _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=120) +def sort_ips(ips: list, querytype: str) -> list: + """Join IPs into a single string.""" + + if querytype == "AAAA": + ips = [IPv6Address(ip) for ip in ips] + else: + ips = [IPv4Address(ip) for ip in ips] + return [str(ip) for ip in sorted(ips)][:MAX_RESULTS] + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -41,11 +54,14 @@ async def async_setup_entry( resolver_ipv4 = entry.options[CONF_RESOLVER] resolver_ipv6 = entry.options[CONF_RESOLVER_IPV6] + port_ipv4 = entry.options[CONF_PORT] + port_ipv6 = entry.options[CONF_PORT_IPV6] + entities = [] if entry.data[CONF_IPV4]: - entities.append(WanIpSensor(name, hostname, resolver_ipv4, False)) + entities.append(WanIpSensor(name, hostname, resolver_ipv4, False, port_ipv4)) if entry.data[CONF_IPV6]: - entities.append(WanIpSensor(name, hostname, resolver_ipv6, True)) + entities.append(WanIpSensor(name, hostname, resolver_ipv6, True, port_ipv6)) async_add_entities(entities, update_before_add=True) @@ -55,6 +71,7 @@ class WanIpSensor(SensorEntity): _attr_has_entity_name = True _attr_translation_key = "dnsip" + _unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"}) def __init__( self, @@ -62,18 +79,19 @@ class WanIpSensor(SensorEntity): hostname: str, resolver: str, ipv6: bool, + port: int, ) -> None: """Initialize the DNS IP sensor.""" self._attr_name = "IPv6" if ipv6 else None self._attr_unique_id = f"{hostname}_{ipv6}" self.hostname = hostname - self.resolver = aiodns.DNSResolver() + self.resolver = aiodns.DNSResolver(tcp_port=port, udp_port=port) self.resolver.nameservers = [resolver] self.querytype = "AAAA" if ipv6 else "A" self._retries = DEFAULT_RETRIES self._attr_extra_state_attributes = { - "Resolver": resolver, - "Querytype": self.querytype, + "resolver": resolver, + "querytype": self.querytype, } self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -92,7 +110,11 @@ class WanIpSensor(SensorEntity): response = None if response: - self._attr_native_value = response[0].host + sorted_ips = sort_ips( + [res.host for res in response], querytype=self.querytype + ) + self._attr_native_value = sorted_ips[0] + self._attr_extra_state_attributes["ip_addresses"] = sorted_ips self._attr_available = True self._retries = DEFAULT_RETRIES elif self._retries > 0: diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index d402e27287c..bc502776cc6 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -5,7 +5,9 @@ "data": { "hostname": "The hostname for which to perform the DNS query", "resolver": "Resolver for IPV4 lookup", - "resolver_ipv6": "Resolver for IPV6 lookup" + "port": "Port for IPV4 lookup", + "resolver_ipv6": "Resolver for IPV6 lookup", + "port_ipv6": "Port for IPV6 lookup" } } }, @@ -18,7 +20,9 @@ "init": { "data": { "resolver": "[%key:component::dnsip::config::step::user::data::resolver%]", - "resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]" + "port": "[%key:component::dnsip::config::step::user::data::port%]", + "resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]", + "port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]" } } }, @@ -26,7 +30,24 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { - "invalid_resolver": "Invalid IP address for resolver" + "invalid_resolver": "Invalid IP address or port for resolver" + } + }, + "entity": { + "sensor": { + "dnsip": { + "state_attributes": { + "resolver": { + "name": "Resolver" + }, + "querytype": { + "name": "Query type" + }, + "ip_addresses": { + "name": "IP addresses" + } + } + } } } } diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 8bb069bab88..b59c03ac565 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -148,7 +148,7 @@ class DoorBirdConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return info, errors diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index d4cd19644c1..5f90e7e663a 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -175,7 +175,7 @@ class DormkabaConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_code" except dkey_errors.WrongActivationCode: errors["base"] = "wrong_code" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py index 76cd63a3a1d..632c42d9b54 100644 --- a/homeassistant/components/dremel_3d_printer/__init__.py +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -5,18 +5,19 @@ from __future__ import annotations from dremel3dpy import Dremel3DPrinter from requests.exceptions import ConnectTimeout, HTTPError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CAMERA_MODEL, DOMAIN -from .coordinator import Dremel3DPrinterDataUpdateCoordinator +from .const import CAMERA_MODEL +from .coordinator import Dremel3DPrinterDataUpdateCoordinator, DremelConfigEntry PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: DremelConfigEntry +) -> bool: """Set up Dremel 3D Printer from a config entry.""" try: api = await hass.async_add_executor_job( @@ -30,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator platforms = list(PLATFORMS) if api.get_model() != CAMERA_MODEL: platforms.remove(Platform.CAMERA) @@ -38,12 +39,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: DremelConfigEntry) -> bool: """Unload Dremel config entry.""" platforms = list(PLATFORMS) - api: Dremel3DPrinter = hass.data[DOMAIN][entry.entry_id].api - if api.get_model() != CAMERA_MODEL: + if entry.runtime_data.api.get_model() != CAMERA_MODEL: platforms.remove(Platform.CAMERA) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, platforms) diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py index e6df0ebcf6e..972945a84bb 100644 --- a/homeassistant/components/dremel_3d_printer/binary_sensor.py +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -12,11 +12,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -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 DremelConfigEntry from .entity import Dremel3DPrinterEntity @@ -43,14 +42,12 @@ BINARY_SENSOR_TYPES: tuple[Dremel3DPrinterBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DremelConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the available Dremel binary sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - Dremel3DPrinterBinarySensor(coordinator, description) + Dremel3DPrinterBinarySensor(config_entry.runtime_data, description) for description in BINARY_SENSOR_TYPES ) diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py index d92263b6a15..f91c1b0ea51 100644 --- a/homeassistant/components/dremel_3d_printer/button.py +++ b/homeassistant/components/dremel_3d_printer/button.py @@ -8,12 +8,11 @@ from dataclasses import dataclass from dremel3dpy import Dremel3DPrinter from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .coordinator import DremelConfigEntry from .entity import Dremel3DPrinterEntity @@ -45,13 +44,12 @@ BUTTON_TYPES: tuple[Dremel3DPrinterButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DremelConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Dremel 3D Printer control buttons.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - Dremel3DPrinterButtonEntity(coordinator, description) + Dremel3DPrinterButtonEntity(config_entry.runtime_data, description) for description in BUTTON_TYPES ) diff --git a/homeassistant/components/dremel_3d_printer/camera.py b/homeassistant/components/dremel_3d_printer/camera.py index dc663844c9c..f4293915a25 100644 --- a/homeassistant/components/dremel_3d_printer/camera.py +++ b/homeassistant/components/dremel_3d_printer/camera.py @@ -4,12 +4,10 @@ from __future__ import annotations from homeassistant.components.camera import CameraEntityDescription from homeassistant.components.mjpeg import MjpegCamera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Dremel3DPrinterDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import Dremel3DPrinterDataUpdateCoordinator, DremelConfigEntry from .entity import Dremel3DPrinterEntity CAMERA_TYPE = CameraEntityDescription( @@ -20,12 +18,11 @@ CAMERA_TYPE = CameraEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DremelConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a MJPEG IP Camera for the 3D45 Model. The 3D20 and 3D40 models don't have built in cameras.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([Dremel3D45Camera(coordinator, CAMERA_TYPE)]) + async_add_entities([Dremel3D45Camera(config_entry.runtime_data, CAMERA_TYPE)]) class Dremel3D45Camera(Dremel3DPrinterEntity, MjpegCamera): diff --git a/homeassistant/components/dremel_3d_printer/config_flow.py b/homeassistant/components/dremel_3d_printer/config_flow.py index aa4cdb045e7..913180db0f7 100644 --- a/homeassistant/components/dremel_3d_printer/config_flow.py +++ b/homeassistant/components/dremel_3d_printer/config_flow.py @@ -42,7 +42,7 @@ class Dremel3DPrinterConfigFlow(ConfigFlow, domain=DOMAIN): api = await self.hass.async_add_executor_job(Dremel3DPrinter, host) except (ConnectTimeout, HTTPError, JSONDecodeError): errors = {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("An unknown error has occurred") errors = {"base": "unknown"} diff --git a/homeassistant/components/dremel_3d_printer/coordinator.py b/homeassistant/components/dremel_3d_printer/coordinator.py index 81e0053fd77..3323569c05f 100644 --- a/homeassistant/components/dremel_3d_printer/coordinator.py +++ b/homeassistant/components/dremel_3d_printer/coordinator.py @@ -10,11 +10,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER +type DremelConfigEntry = ConfigEntry[Dremel3DPrinterDataUpdateCoordinator] + class Dremel3DPrinterDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Dremel 3D Printer data.""" - config_entry: ConfigEntry + config_entry: DremelConfigEntry def __init__(self, hass: HomeAssistant, api: Dremel3DPrinter) -> None: """Initialize Dremel 3D Printer data update coordinator.""" diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index bda2bb537fd..002a5fc4adb 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -28,7 +27,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance -from .const import ATTR_EXTRUDER, ATTR_PLATFORM, DOMAIN +from .const import ATTR_EXTRUDER, ATTR_PLATFORM +from .coordinator import DremelConfigEntry from .entity import Dremel3DPrinterEntity @@ -234,14 +234,13 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: DremelConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the available Dremel 3D Printer sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - Dremel3DPrinterSensor(coordinator, description) for description in SENSOR_TYPES + Dremel3DPrinterSensor(config_entry.runtime_data, description) + for description in SENSOR_TYPES ) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 901dfc047f5..9003c4d4334 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -25,14 +25,14 @@ PRICE_EUR_KWH: Final = f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}" PRICE_EUR_M3: Final = f"EUR/{UnitOfVolume.CUBIC_METERS}" -def dsmr_transform(value): +def dsmr_transform(value: str) -> float | str: """Transform DSMR version value to right format.""" if value.isdigit(): return float(value) / 10 return value -def tariff_transform(value): +def tariff_transform(value: str) -> str: """Transform tariff from number to description.""" if value == "1": return "low" @@ -141,7 +141,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/reading/extra_device_delivered", translation_key="gas_meter_usage", entity_registry_enabled_default=False, - icon="mdi:fire", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, @@ -266,81 +265,68 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity1_cost", translation_key="daily_low_tariff_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity2_cost", translation_key="daily_high_tariff_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/electricity_cost_merged", translation_key="daily_power_total_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas", translation_key="daily_gas_usage", - icon="mdi:counter", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/gas_cost", translation_key="gas_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/total_cost", translation_key="total_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", translation_key="low_tariff_delivered_price", - icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", translation_key="high_tariff_delivered_price", - icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", translation_key="low_tariff_returned_price", - icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", translation_key="high_tariff_returned_price", - icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_KWH, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/energy_supplier_price_gas", translation_key="gas_price", - icon="mdi:currency-eur", native_unit_of_measurement=PRICE_EUR_M3, ), DSMRReaderSensorEntityDescription( key="dsmr/day-consumption/fixed_cost", translation_key="current_day_fixed_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/dsmr_version", translation_key="dsmr_version", entity_registry_enabled_default=False, - icon="mdi:alert-circle", state=dsmr_transform, ), DSMRReaderSensorEntityDescription( @@ -348,62 +334,52 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( translation_key="electricity_tariff", device_class=SensorDeviceClass.ENUM, options=["low", "high"], - icon="mdi:flash", state=tariff_transform, ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/power_failure_count", translation_key="power_failure_count", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/long_power_failure_count", translation_key="long_power_failure_count", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l1", translation_key="voltage_sag_l1", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l2", translation_key="voltage_sag_l2", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_sag_count_l3", translation_key="voltage_sag_l3", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l1", translation_key="voltage_swell_l1", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l2", translation_key="voltage_swell_l2", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/voltage_swell_count_l3", translation_key="voltage_swell_l3", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/meter-stats/rejected_telegrams", translation_key="rejected_telegrams", entity_registry_enabled_default=False, - icon="mdi:flash", ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1", @@ -444,44 +420,37 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity1_cost", translation_key="current_month_low_tariff_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity2_cost", translation_key="current_month_high_tariff_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/electricity_cost_merged", translation_key="current_month_power_total_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas", translation_key="current_month_gas_usage", - icon="mdi:counter", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/gas_cost", translation_key="current_month_gas_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/fixed_cost", translation_key="current_month_fixed_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-month/total_cost", translation_key="current_month_total_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( @@ -523,44 +492,37 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity1_cost", translation_key="current_year_low_tariff_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity2_cost", translation_key="current_year_high_tariff_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/electricity_cost_merged", translation_key="current_year_power_total_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas", translation_key="current_year_gas_usage", - icon="mdi:counter", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/gas_cost", translation_key="current_year_gas_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/fixed_cost", translation_key="current_year_fixed_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( key="dsmr/current-year/total_cost", translation_key="current_year_total_cost", - icon="mdi:currency-eur", native_unit_of_measurement=CURRENCY_EURO, ), DSMRReaderSensorEntityDescription( diff --git a/homeassistant/components/dsmr_reader/icons.json b/homeassistant/components/dsmr_reader/icons.json new file mode 100644 index 00000000000..aa58ddf43de --- /dev/null +++ b/homeassistant/components/dsmr_reader/icons.json @@ -0,0 +1,249 @@ +{ + "entity": { + "sensor": { + "low_tariff_usage": { + "default": "mdi:flash" + }, + "low_tariff_returned": { + "default": "mdi:flash" + }, + "high_tariff_usage": { + "default": "mdi:flash" + }, + "high_tariff_returned": { + "default": "mdi:flash" + }, + "current_power_usage": { + "default": "mdi:flash" + }, + "current_power_return": { + "default": "mdi:flash" + }, + "current_power_usage_l1": { + "default": "mdi:flash" + }, + "current_power_usage_l2": { + "default": "mdi:flash" + }, + "current_power_usage_l3": { + "default": "mdi:flash" + }, + "current_power_return_l1": { + "default": "mdi:flash" + }, + "current_power_return_l2": { + "default": "mdi:flash" + }, + "current_power_return_l3": { + "default": "mdi:flash" + }, + "gas_meter_usage": { + "default": "mdi:fire" + }, + "current_voltage_l1": { + "default": "mdi:flash" + }, + "current_voltage_l2": { + "default": "mdi:flash" + }, + "current_voltage_l3": { + "default": "mdi:flash" + }, + "phase_power_current_l1": { + "default": "mdi:flash" + }, + "phase_power_current_l2": { + "default": "mdi:flash" + }, + "phase_power_current_l3": { + "default": "mdi:flash" + }, + "telegram_timestamp": { + "default": "mdi:clock" + }, + "gas_usage": { + "default": "mdi:counter" + }, + "current_gas_usage": { + "default": "mdi:counter" + }, + "gas_meter_read": { + "default": "mdi:clock" + }, + "daily_low_tariff_usage": { + "default": "mdi:flash" + }, + "daily_high_tariff_usage": { + "default": "mdi:flash" + }, + "daily_low_tariff_return": { + "default": "mdi:flash" + }, + "daily_high_tariff_return": { + "default": "mdi:flash" + }, + "daily_power_usage_total": { + "default": "mdi:flash" + }, + "daily_power_return_total": { + "default": "mdi:flash" + }, + "daily_low_tariff_cost": { + "default": "mdi:currency-eur" + }, + "daily_high_tariff_cost": { + "default": "mdi:currency-eur" + }, + "daily_power_total_cost": { + "default": "mdi:currency-eur" + }, + "daily_gas_usage": { + "default": "mdi:counter" + }, + "gas_cost": { + "default": "mdi:currency-eur" + }, + "total_cost": { + "default": "mdi:currency-eur" + }, + "low_tariff_delivered_price": { + "default": "mdi:currency-eur" + }, + "high_tariff_delivered_price": { + "default": "mdi:currency-eur" + }, + "low_tariff_returned_price": { + "default": "mdi:currency-eur" + }, + "high_tariff_returned_price": { + "default": "mdi:currency-eur" + }, + "gas_price": { + "default": "mdi:currency-eur" + }, + "current_day_fixed_cost": { + "default": "mdi:currency-eur" + }, + "dsmr_version": { + "default": "mdi:alert-circle" + }, + "electricity_tariff": { + "default": "mdi:flash" + }, + "power_failure_count": { + "default": "mdi:flash" + }, + "long_power_failure_count": { + "default": "mdi:flash" + }, + "voltage_sag_l1": { + "default": "mdi:flash" + }, + "voltage_sag_l2": { + "default": "mdi:flash" + }, + "voltage_sag_l3": { + "default": "mdi:flash" + }, + "voltage_swell_l1": { + "default": "mdi:flash" + }, + "voltage_swell_l2": { + "default": "mdi:flash" + }, + "voltage_swell_l3": { + "default": "mdi:flash" + }, + "rejected_telegrams": { + "default": "mdi:flash" + }, + "current_month_low_tariff_usage": { + "default": "mdi:flash" + }, + "current_month_high_tariff_usage": { + "default": "mdi:flash" + }, + "current_month_low_tariff_returned": { + "default": "mdi:flash" + }, + "current_month_high_tariff_returned": { + "default": "mdi:flash" + }, + "current_month_power_usage_total": { + "default": "mdi:flash" + }, + "current_month_power_return_total": { + "default": "mdi:flash" + }, + "current_month_low_tariff_cost": { + "default": "mdi:currency-eur" + }, + "current_month_high_tariff_cost": { + "default": "mdi:currency-eur" + }, + "current_month_power_total_cost": { + "default": "mdi:currency-eur" + }, + "current_month_gas_usage": { + "default": "mdi:counter" + }, + "current_month_gas_cost": { + "default": "mdi:currency-eur" + }, + "current_month_fixed_cost": { + "default": "mdi:currency-eur" + }, + "current_month_total_cost": { + "default": "mdi:currency-eur" + }, + "current_year_low_tariff_usage": { + "default": "mdi:flash" + }, + "current_year_high_tariff_usage": { + "default": "mdi:flash" + }, + "current_year_low_tariff_returned": { + "default": "mdi:flash" + }, + "current_year_high_tariff_returned": { + "default": "mdi:flash" + }, + "current_year_power_usage_total": { + "default": "mdi:flash" + }, + "current_year_power_returned_total": { + "default": "mdi:flash" + }, + "current_year_low_tariff_cost": { + "default": "mdi:currency-eur" + }, + "current_year_high_tariff_cost": { + "default": "mdi:currency-eur" + }, + "current_year_power_total_cost": { + "default": "mdi:currency-eur" + }, + "current_year_gas_usage": { + "default": "mdi:counter" + }, + "current_year_gas_cost": { + "default": "mdi:currency-eur" + }, + "current_year_fixed_cost": { + "default": "mdi:currency-eur" + }, + "current_year_total_cost": { + "default": "mdi:currency-eur" + }, + "previous_quarter_hour_peak_usage": { + "default": "mdi:flash" + }, + "quarter_hour_peak_start_time": { + "default": "mdi:clock" + }, + "quarter_hour_peak_end_time": { + "default": "mdi:clock" + } + } + } +} diff --git a/homeassistant/components/dsmr_reader/manifest.json b/homeassistant/components/dsmr_reader/manifest.json index 35dc21384bd..9c0e6da2c46 100644 --- a/homeassistant/components/dsmr_reader/manifest.json +++ b/homeassistant/components/dsmr_reader/manifest.json @@ -1,7 +1,7 @@ { "domain": "dsmr_reader", "name": "DSMR Reader", - "codeowners": ["@sorted-bits", "@glodenox"], + "codeowners": ["@sorted-bits", "@glodenox", "@erwindouna"], "config_flow": true, "dependencies": ["mqtt"], "documentation": "https://www.home-assistant.io/integrations/dsmr_reader", diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 3c07ad65de6..784a4cdec51 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -6,9 +6,12 @@ from homeassistant.components import mqtt from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import slugify +from .const import DOMAIN from .definitions import SENSORS, DSMRReaderSensorEntityDescription @@ -53,6 +56,20 @@ class DSMRSensor(SensorEntity): self.async_write_ha_state() - await mqtt.async_subscribe( - self.hass, self.entity_description.key, message_received, 1 - ) + try: + await mqtt.async_subscribe( + self.hass, self.entity_description.key, message_received, 1 + ) + except HomeAssistantError: + async_create_issue( + self.hass, + DOMAIN, + f"cannot_subscribe_mqtt_topic_{self.entity_description.key}", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="cannot_subscribe_mqtt_topic", + translation_placeholders={ + "topic": self.entity_description.key, + "topic_title": self.entity_description.key.split("/")[-1], + }, + ) diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index fce274e8917..90cf0533a72 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -259,5 +259,11 @@ "name": "Quarter-hour peak end time" } } + }, + "issues": { + "cannot_subscribe_mqtt_topic": { + "title": "Cannot subscribe to MQTT topic {topic_title}", + "description": "The DSMR Reader integration cannot subscribe to the MQTT topic: `{topic}`. Please check the configuration of the MQTT broker and the topic.\nDSMR Reader needs to be running, before starting this integration." + } } } diff --git a/homeassistant/components/duotecno/config_flow.py b/homeassistant/components/duotecno/config_flow.py index 44675d6bbde..ca95726542f 100644 --- a/homeassistant/components/duotecno/config_flow.py +++ b/homeassistant/components/duotecno/config_flow.py @@ -51,7 +51,7 @@ class DuoTecnoConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidPassword: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index 86f61c8a73c..3908440a182 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from duotecno.unit import BaseUnit @@ -41,12 +41,13 @@ class DuotecnoEntity(Entity): """When a unit has an update.""" self.async_write_ha_state() - -_T = TypeVar("_T", bound="DuotecnoEntity") -_P = ParamSpec("_P") + @property + def available(self) -> bool: + """Available state for the unit.""" + return self._unit.is_available() -def api_call( +def api_call[_T: DuotecnoEntity, **_P]( 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 0c8eab8f0a0..1adb9e874e5 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==2024.3.2"] + "requirements": ["pyDuotecno==2024.5.1"] } diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index f71b81d862b..7a56299a35b 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -3,8 +3,9 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import DwdWeatherWarningsConfigEntry, DwdWeatherWarningsCoordinator @@ -12,6 +13,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: DwdWeatherWarningsConfigEntry ) -> bool: """Set up a config entry.""" + device_registry = dr.async_get(hass) + if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): + device_registry.async_clear_config_entry(entry.entry_id) coordinator = DwdWeatherWarningsCoordinator(hass) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py index 7f0afe352db..55705625685 100644 --- a/homeassistant/components/dwd_weather_warnings/coordinator.py +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -19,7 +19,7 @@ from .const import ( from .exceptions import EntityNotFoundError from .util import get_position_data -DwdWeatherWarningsConfigEntry = ConfigEntry["DwdWeatherWarningsCoordinator"] +type DwdWeatherWarningsConfigEntry = ConfigEntry[DwdWeatherWarningsCoordinator] class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): @@ -56,7 +56,7 @@ class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): try: position = get_position_data(self.hass, self._device_tracker) except (EntityNotFoundError, AttributeError) as err: - raise UpdateFailed(f"Error fetching position: {repr(err)}") from err + raise UpdateFailed(f"Error fetching position: {err!r}") from err distance = None if self._previous_position is not None: diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index cef665ffb10..c6aa5727b74 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -3,9 +3,9 @@ Data is fetched from DWD: https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html -Warnungen vor extremem Unwetter (Stufe 4) +Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor Unwetterwarnungen (Stufe 3) -Warnungen vor markantem Wetter (Stufe 2) +Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor Wetterwarnungen (Stufe 1) """ @@ -36,7 +36,6 @@ from .const import ( ATTR_REGION_NAME, ATTR_WARNING_COUNT, CURRENT_WARNING_SENSOR, - DEFAULT_NAME, DOMAIN, ) from .coordinator import DwdWeatherWarningsConfigEntry, DwdWeatherWarningsCoordinator @@ -61,12 +60,12 @@ async def async_setup_entry( """Set up entities from config entry.""" coordinator = entry.runtime_data + unique_id = entry.unique_id + assert unique_id + async_add_entities( - [ - DwdWeatherWarningsSensor(coordinator, entry, description) - for description in SENSOR_TYPES - ], - True, + DwdWeatherWarningsSensor(coordinator, description, unique_id) + for description in SENSOR_TYPES ) @@ -81,18 +80,18 @@ class DwdWeatherWarningsSensor( def __init__( self, coordinator: DwdWeatherWarningsCoordinator, - entry: DwdWeatherWarningsConfigEntry, description: SensorEntityDescription, + unique_id: str, ) -> None: """Initialize a DWD-Weather-Warnings sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{entry.unique_id}-{description.key}" + self._attr_unique_id = f"{unique_id}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=f"{DEFAULT_NAME} {entry.title}", + identifiers={(DOMAIN, unique_id)}, + name=coordinator.api.warncell_name, entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 46fcfb267d0..59b8e464bb0 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -106,6 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), ) + await async_register_dynalite_frontend(hass) + return True @@ -131,9 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - await async_register_dynalite_frontend(hass) - return True diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py index b7020367f74..b62944f63fe 100644 --- a/homeassistant/components/dynalite/panel.py +++ b/homeassistant/components/dynalite/panel.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.components import panel_custom, websocket_api from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.components.http import StaticPathConfig from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -98,19 +99,18 @@ async def async_register_dynalite_frontend(hass: HomeAssistant): """Register the Dynalite frontend configuration panel.""" websocket_api.async_register_command(hass, get_dynalite_config) websocket_api.async_register_command(hass, save_dynalite_config) - if DOMAIN not in hass.data.get("frontend_panels", {}): - path = locate_dir() - build_id = get_build_id() - hass.http.register_static_path( - URL_BASE, path, cache_headers=(build_id != "dev") - ) + path = locate_dir() + build_id = get_build_id() + await hass.http.async_register_static_paths( + [StaticPathConfig(URL_BASE, path, cache_headers=(build_id != "dev"))] + ) - await panel_custom.async_register_panel( - hass=hass, - frontend_url_path=DOMAIN, - config_panel_domain=DOMAIN, - webcomponent_name="dynalite-panel", - module_url=f"{URL_BASE}/entrypoint-{build_id}.js", - embed_iframe=True, - require_admin=True, - ) + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + config_panel_domain=DOMAIN, + webcomponent_name="dynalite-panel", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 18e09178581..4286f2cf757 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -42,7 +42,7 @@ class EcobeeBinarySensor(BinarySensorEntity): def __init__(self, data, sensor_name, sensor_index): """Initialize the Ecobee sensor.""" self.data = data - self.sensor_name = sensor_name.rstrip() + self.sensor_name = sensor_name self.index = sensor_index @property diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 11675c0bf61..8dcc7285590 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -36,10 +36,17 @@ 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.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.unit_conversion import TemperatureConverter from . import EcobeeData -from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER +from .const import ( + _LOGGER, + DOMAIN, + ECOBEE_AUX_HEAT_ONLY, + ECOBEE_MODEL_TO_NAME, + MANUFACTURER, +) from .util import ecobee_date, ecobee_time, is_indefinite_hold ATTR_COOL_TEMP = "cool_temp" @@ -69,9 +76,6 @@ DEFAULT_MIN_HUMIDITY = 15 DEFAULT_MAX_HUMIDITY = 50 HUMIDIFIER_MANUAL_MODE = "manual" -ECOBEE_AUX_HEAT_ONLY = "auxHeatOnly" - - # Order matters, because for reverse mapping we don't want to map HEAT to AUX ECOBEE_HVAC_TO_HASS = collections.OrderedDict( [ @@ -79,9 +83,13 @@ ECOBEE_HVAC_TO_HASS = collections.OrderedDict( ("cool", HVACMode.COOL), ("auto", HVACMode.HEAT_COOL), ("off", HVACMode.OFF), - ("auxHeatOnly", HVACMode.HEAT), + (ECOBEE_AUX_HEAT_ONLY, HVACMode.HEAT), ] ) +# Reverse key/value pair, drop auxHeatOnly as it doesn't map to specific HASS mode +HASS_TO_ECOBEE_HVAC = { + v: k for k, v in ECOBEE_HVAC_TO_HASS.items() if k != ECOBEE_AUX_HEAT_ONLY +} ECOBEE_HVAC_ACTION_TO_HASS = { # Map to None if we do not know how to represent. @@ -570,17 +578,39 @@ class Thermostat(ClimateEntity): """Return true if aux heater.""" return self.settings["hvacMode"] == ECOBEE_AUX_HEAT_ONLY - def turn_aux_heat_on(self) -> None: + async def async_turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2024.10.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") self._last_hvac_mode_before_aux_heat = self.hvac_mode - self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) + await self.hass.async_add_executor_job( + self.data.ecobee.set_hvac_mode, self.thermostat_index, ECOBEE_AUX_HEAT_ONLY + ) self.update_without_throttle = True - def turn_aux_heat_off(self) -> None: + async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2024.10.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") - self.set_hvac_mode(self._last_hvac_mode_before_aux_heat) + await self.async_set_hvac_mode(self._last_hvac_mode_before_aux_heat) self.update_without_throttle = True def set_preset_mode(self, preset_mode: str) -> None: @@ -740,9 +770,7 @@ class Thermostat(ClimateEntity): def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" - ecobee_value = next( - (k for k, v in ECOBEE_HVAC_TO_HASS.items() if v == hvac_mode), None - ) + ecobee_value = HASS_TO_ECOBEE_HVAC.get(hvac_mode) if ecobee_value is None: _LOGGER.error("Invalid mode for set_hvac_mode: %s", hvac_mode) return diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 8adc7f9638b..85a332f3c87 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -55,6 +55,8 @@ PLATFORMS = [ MANUFACTURER = "ecobee" +ECOBEE_AUX_HEAT_ONLY = "auxHeatOnly" + # Translates ecobee API weatherSymbol to Home Assistant usable names # https://www.ecobee.com/home/developer/api/documentation/v1/objects/WeatherForecast.shtml ECOBEE_WEATHER_SYMBOL_TO_HASS = { diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index b11bdf8afb0..22dfcb2a428 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -3,7 +3,6 @@ "name": "ecobee", "codeowners": [], "config_flow": true, - "dependencies": ["http", "repairs"], "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { "models": ["EB", "ecobee*"] diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index f7e2f1549d1..167233e4071 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -9,6 +9,7 @@ from homeassistant.components.notify import ( ATTR_TARGET, BaseNotificationService, NotifyEntity, + migrate_notify_issue, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -18,7 +19,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import Ecobee, EcobeeData from .const import DOMAIN from .entity import EcobeeBaseEntity -from .repairs import migrate_notify_issue def get_service( @@ -43,7 +43,9 @@ class EcobeeNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message and raise issue.""" - migrate_notify_issue(self.hass) + migrate_notify_issue( + self.hass, DOMAIN, "Ecobee", "2024.11.0", service_name=self._service_name + ) await self.hass.async_add_executor_job( partial(self.send_message, message, **kwargs) ) diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 4c3dd801c41..ab09407903d 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -88,10 +88,15 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): super().__init__(data, thermostat_index) self.entity_description = description self._attr_unique_id = f"{self.base_unique_id}_ventilator_{description.key}" + self.update_without_throttle = False async def async_update(self) -> None: """Get the latest state from the thermostat.""" - await self.data.update() + if self.update_without_throttle: + await self.data.update(no_throttle=True) + self.update_without_throttle = False + else: + await self.data.update() self._attr_native_value = self.thermostat["settings"][ self.entity_description.ecobee_setting_key ] @@ -99,3 +104,4 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): def set_native_value(self, value: float) -> None: """Set new ventilator Min On Time value.""" self.entity_description.set_fn(self.data, self.thermostat_index, int(value)) + self.update_without_throttle = True diff --git a/homeassistant/components/ecobee/repairs.py b/homeassistant/components/ecobee/repairs.py deleted file mode 100644 index 66474730b2f..00000000000 --- a/homeassistant/components/ecobee/repairs.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Repairs support for Ecobee.""" - -from __future__ import annotations - -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.components.repairs import RepairsFlow -from homeassistant.components.repairs.issue_handler import ConfirmRepairFlow -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir - -from .const import DOMAIN - - -@callback -def migrate_notify_issue(hass: HomeAssistant) -> None: - """Ensure an issue is registered.""" - ir.async_create_issue( - hass, - DOMAIN, - "migrate_notify", - breaks_in_ha_version="2024.11.0", - issue_domain=NOTIFY_DOMAIN, - is_fixable=True, - is_persistent=True, - translation_key="migrate_notify", - severity=ir.IssueSeverity.WARNING, - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None] | None, -) -> RepairsFlow: - """Create flow.""" - assert issue_id == "migrate_notify" - return ConfirmRepairFlow() diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 1d64b6d6b94..56cf6e9ebf0 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -38,6 +38,11 @@ "ventilator_min_type_away": { "name": "Ventilator min time away" } + }, + "switch": { + "aux_heat_only": { + "name": "Aux heat only" + } } }, "services": { @@ -165,13 +170,13 @@ } }, "issues": { - "migrate_notify": { - "title": "Migration of Ecobee notify service", + "migrate_aux_heat": { + "title": "Migration of Ecobee set_aux_heat service", "fix_flow": { "step": { "confirm": { - "description": "The Ecobee `notify` service has been migrated. A new `notify` entity per Thermostat is available now.\n\nUpdate any automations to use the new `notify.send_message` exposed by these new entities. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy Ecobee notify service" + "description": "The Ecobee `set_aux_heat` service has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy Ecobee set_aux_heat service" } } } diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index 44528a5f421..67be78fb21d 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -2,9 +2,11 @@ from __future__ import annotations +from datetime import tzinfo import logging from typing import Any +from homeassistant.components.climate import HVACMode from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -12,7 +14,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from . import EcobeeData -from .const import DOMAIN +from .climate import HASS_TO_ECOBEE_HVAC +from .const import DOMAIN, ECOBEE_AUX_HEAT_ONLY from .entity import EcobeeBaseEntity _LOGGER = logging.getLogger(__name__) @@ -29,12 +32,23 @@ async def async_setup_entry( data: EcobeeData = hass.data[DOMAIN] async_add_entities( - ( - EcobeeVentilator20MinSwitch(data, index) + [ + EcobeeVentilator20MinSwitch( + data, + index, + (await dt_util.async_get_time_zone(thermostat["location"]["timeZone"])) + or dt_util.get_default_time_zone(), + ) for index, thermostat in enumerate(data.ecobee.thermostats) if thermostat["settings"]["ventilatorType"] != "none" - ), - True, + ], + update_before_add=True, + ) + + async_add_entities( + EcobeeSwitchAuxHeatOnly(data, index) + for index, thermostat in enumerate(data.ecobee.thermostats) + if thermostat["settings"]["hasHeatPump"] ) @@ -48,15 +62,14 @@ class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): self, data: EcobeeData, thermostat_index: int, + operating_timezone: tzinfo, ) -> None: """Initialize ecobee ventilator platform.""" super().__init__(data, thermostat_index) self._attr_unique_id = f"{self.base_unique_id}_ventilator_20m_timer" self._attr_is_on = False self.update_without_throttle = False - self._operating_timezone = dt_util.get_time_zone( - self.thermostat["location"]["timeZone"] - ) + self._operating_timezone = operating_timezone async def async_update(self) -> None: """Get the latest state from the thermostat.""" @@ -88,3 +101,39 @@ class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): self.data.ecobee.set_ventilator_timer, self.thermostat_index, False ) self.update_without_throttle = True + + +class EcobeeSwitchAuxHeatOnly(EcobeeBaseEntity, SwitchEntity): + """Representation of a aux_heat_only ecobee switch.""" + + _attr_has_entity_name = True + _attr_translation_key = "aux_heat_only" + + def __init__( + self, + data: EcobeeData, + thermostat_index: int, + ) -> None: + """Initialize ecobee ventilator platform.""" + super().__init__(data, thermostat_index) + self._attr_unique_id = f"{self.base_unique_id}_aux_heat_only" + + self._last_hvac_mode_before_aux_heat = HASS_TO_ECOBEE_HVAC.get( + HVACMode.HEAT_COOL + ) + + def turn_on(self, **kwargs: Any) -> None: + """Set the hvacMode to auxHeatOnly.""" + self._last_hvac_mode_before_aux_heat = self.thermostat["settings"]["hvacMode"] + self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) + + def turn_off(self, **kwargs: Any) -> None: + """Set the hvacMode back to the prior setting.""" + self.data.ecobee.set_hvac_mode( + self.thermostat_index, self._last_hvac_mode_before_aux_heat + ) + + @property + def is_on(self) -> bool: + """Return true if auxHeatOnly mode is active.""" + return self.thermostat["settings"]["hvacMode"] == ECOBEE_AUX_HEAT_ONLY diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index b7961f956eb..b6378504c65 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -59,7 +59,7 @@ class EcobeeWeather(WeatherEntity): _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_native_visibility_unit = UnitOfLength.METERS - _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR _attr_has_entity_name = True _attr_name = None _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY diff --git a/homeassistant/components/ecoforest/config_flow.py b/homeassistant/components/ecoforest/config_flow.py index 91260f0811e..9c0f15f390b 100644 --- a/homeassistant/components/ecoforest/config_flow.py +++ b/homeassistant/components/ecoforest/config_flow.py @@ -46,7 +46,7 @@ class EcoForestConfigFlow(ConfigFlow, domain=DOMAIN): device = await api.get() except EcoforestAuthenticationRequired: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "cannot_connect" else: diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index e4924b57641..b2f40acc2f8 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -37,7 +37,7 @@ PLATFORMS = [ Platform.SWITCH, Platform.VACUUM, ] -EcovacsConfigEntry = ConfigEntry[EcovacsController] +type EcovacsConfigEntry = ConfigEntry[EcovacsController] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index f6e3e34aaa4..d755d01a4ae 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic -from deebot_client.capabilities import CapabilityEvent, VacuumCapabilities +from deebot_client.capabilities import CapabilityEvent from deebot_client.events.water_info import WaterInfoEvent from homeassistant.components.binary_sensor import ( @@ -16,12 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcovacsConfigEntry -from .entity import ( - CapabilityDevice, - EcovacsCapabilityEntityDescription, - EcovacsDescriptionEntity, - EventT, -) +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT from .util import get_supported_entitites @@ -38,7 +33,6 @@ class EcovacsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( EcovacsBinarySensorEntityDescription[WaterInfoEvent]( - device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.water, value_fn=lambda e: e.mop_attached, key="water_mop_attached", @@ -62,7 +56,7 @@ async def async_setup_entry( class EcovacsBinarySensor( - EcovacsDescriptionEntity[CapabilityDevice, CapabilityEvent[EventT]], + EcovacsDescriptionEntity[CapabilityEvent[EventT]], BinarySensorEntity, ): """Ecovacs binary sensor.""" diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index 14fd54df5a0..5d76b38bed8 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -2,12 +2,7 @@ from dataclasses import dataclass -from deebot_client.capabilities import ( - Capabilities, - CapabilityExecute, - CapabilityLifeSpan, - VacuumCapabilities, -) +from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan from deebot_client.events import LifeSpan from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -18,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcovacsConfigEntry from .const import SUPPORTED_LIFESPANS from .entity import ( - CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -43,7 +37,6 @@ class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription): ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( EcovacsButtonEntityDescription( - device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.map.relocation if caps.map else None, key="relocate", translation_key="relocate", @@ -77,7 +70,7 @@ async def async_setup_entry( EcovacsResetLifespanButtonEntity( device, device.capabilities.life_span, description ) - for device in controller.devices(Capabilities) + for device in controller.devices for description in LIFESPAN_ENTITY_DESCRIPTIONS if description.component in device.capabilities.life_span.types ) @@ -85,7 +78,7 @@ async def async_setup_entry( class EcovacsButtonEntity( - EcovacsDescriptionEntity[CapabilityDevice, CapabilityExecute], + EcovacsDescriptionEntity[CapabilityExecute], ButtonEntity, ): """Ecovacs button entity.""" @@ -98,7 +91,7 @@ class EcovacsButtonEntity( class EcovacsResetLifespanButtonEntity( - EcovacsDescriptionEntity[Capabilities, CapabilityLifeSpan], + EcovacsDescriptionEntity[CapabilityLifeSpan], ButtonEntity, ): """Ecovacs reset lifespan button entity.""" diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 4a421113f5f..7e4bfbe5597 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -93,7 +93,7 @@ async def _validate_input( errors["base"] = "cannot_connect" except InvalidAuthenticationError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception during login") errors["base"] = "unknown" @@ -121,7 +121,7 @@ async def _validate_input( errors[cannot_connect_field] = "cannot_connect" except InvalidAuthenticationError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception during mqtt connection verification") errors["base"] = "unknown" diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index 6b77404e935..65044c016f9 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -17,6 +17,8 @@ SUPPORTED_LIFESPANS = ( LifeSpan.FILTER, LifeSpan.LENS_BRUSH, LifeSpan.SIDE_BRUSH, + LifeSpan.UNIT_CARE, + LifeSpan.ROUND_MOP, ) diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 690f4e56cc9..0bef2e8fdd7 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -2,14 +2,13 @@ from __future__ import annotations -from collections.abc import Generator, Mapping +from collections.abc import Mapping import logging import ssl from typing import Any from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator, create_rest_config -from deebot_client.capabilities import Capabilities from deebot_client.const import UNDEFINED, UndefinedType from deebot_client.device import Device from deebot_client.exceptions import DeebotError, InvalidAuthenticationError @@ -20,7 +19,7 @@ from deebot_client.util.continents import get_continent from sucks import EcoVacsAPI, VacBot from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.util.ssl import get_default_no_verify_context @@ -118,12 +117,10 @@ class EcovacsController: await self._mqtt.disconnect() await self._authenticator.teardown() - @callback - def devices(self, capability: type[Capabilities]) -> Generator[Device, None, None]: - """Return generator for devices with a specific capability.""" - for device in self._devices: - if isinstance(device.capabilities, capability): - yield device + @property + def devices(self) -> list[Device]: + """Return devices.""" + return self._devices @property def legacy_devices(self) -> list[VacBot]: diff --git a/homeassistant/components/ecovacs/diagnostics.py b/homeassistant/components/ecovacs/diagnostics.py index 50b59b90860..22a55d9c6ab 100644 --- a/homeassistant/components/ecovacs/diagnostics.py +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any -from deebot_client.capabilities import Capabilities - from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -34,7 +32,7 @@ async def async_get_config_entry_diagnostics( diag["devices"] = [ async_redact_data(device.device_info, REDACT_DEVICE) - for device in controller.devices(Capabilities) + for device in controller.devices ] diag["legacy_devices"] = [ async_redact_data(device.vacuum, REDACT_DEVICE) diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index 4497f82d964..c038c54497a 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -18,11 +18,10 @@ from homeassistant.helpers.entity import Entity, EntityDescription from .const import DOMAIN CapabilityEntity = TypeVar("CapabilityEntity") -CapabilityDevice = TypeVar("CapabilityDevice", bound=Capabilities) EventT = TypeVar("EventT", bound=Event) -class EcovacsEntity(Entity, Generic[CapabilityDevice, CapabilityEntity]): +class EcovacsEntity(Entity, Generic[CapabilityEntity]): """Ecovacs entity.""" _attr_should_poll = False @@ -31,7 +30,7 @@ class EcovacsEntity(Entity, Generic[CapabilityDevice, CapabilityEntity]): def __init__( self, - device: Device[CapabilityDevice], + device: Device, capability: CapabilityEntity, **kwargs: Any, ) -> None: @@ -97,12 +96,12 @@ class EcovacsEntity(Entity, Generic[CapabilityDevice, CapabilityEntity]): self._device.events.request_refresh(event_type) -class EcovacsDescriptionEntity(EcovacsEntity[CapabilityDevice, CapabilityEntity]): +class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]): """Ecovacs entity.""" def __init__( self, - device: Device[CapabilityDevice], + device: Device, capability: CapabilityEntity, entity_description: EntityDescription, **kwargs: Any, @@ -115,9 +114,8 @@ class EcovacsDescriptionEntity(EcovacsEntity[CapabilityDevice, CapabilityEntity] @dataclass(kw_only=True, frozen=True) class EcovacsCapabilityEntityDescription( EntityDescription, - Generic[CapabilityDevice, CapabilityEntity], + Generic[CapabilityEntity], ): """Ecovacs entity description.""" - device_capabilities: type[CapabilityDevice] - capability_fn: Callable[[CapabilityDevice], CapabilityEntity | None] + capability_fn: Callable[[Capabilities], CapabilityEntity | None] diff --git a/homeassistant/components/ecovacs/event.py b/homeassistant/components/ecovacs/event.py index 9e4dde00b54..3249b466c77 100644 --- a/homeassistant/components/ecovacs/event.py +++ b/homeassistant/components/ecovacs/event.py @@ -1,6 +1,6 @@ """Event module.""" -from deebot_client.capabilities import Capabilities, CapabilityEvent +from deebot_client.capabilities import CapabilityEvent from deebot_client.device import Device from deebot_client.events import CleanJobStatus, ReportStatsEvent @@ -22,12 +22,12 @@ async def async_setup_entry( """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data async_add_entities( - EcovacsLastJobEventEntity(device) for device in controller.devices(Capabilities) + EcovacsLastJobEventEntity(device) for device in controller.devices ) class EcovacsLastJobEventEntity( - EcovacsEntity[Capabilities, CapabilityEvent[ReportStatsEvent]], + EcovacsEntity[CapabilityEvent[ReportStatsEvent]], EventEntity, ): """Ecovacs last job event entity.""" @@ -39,7 +39,7 @@ class EcovacsLastJobEventEntity( event_types=["finished", "finished_with_warnings", "manually_stopped"], ) - def __init__(self, device: Device[Capabilities]) -> None: + def __init__(self, device: Device) -> None: """Initialize entity.""" super().__init__(device, device.capabilities.stats.report) diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index 44c577104dd..d129273e891 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -26,6 +26,12 @@ }, "reset_lifespan_side_brush": { "default": "mdi:broom" + }, + "reset_lifespan_unit_care": { + "default": "mdi:robot-vacuum" + }, + "reset_lifespan_round_mop": { + "default": "mdi:broom" } }, "event": { @@ -63,6 +69,12 @@ "lifespan_side_brush": { "default": "mdi:broom" }, + "lifespan_unit_care": { + "default": "mdi:robot-vacuum" + }, + "lifespan_round_mop": { + "default": "mdi:broom" + }, "network_ip": { "default": "mdi:ip-network-outline" }, @@ -128,5 +140,8 @@ "default": "mdi:laser-pointer" } } + }, + "services": { + "raw_get_positions": "mdi:map-marker-radius-outline" } } diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index 1e94dc856ee..d8b69084cec 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -1,6 +1,6 @@ """Ecovacs image entities.""" -from deebot_client.capabilities import CapabilityMap, VacuumCapabilities +from deebot_client.capabilities import CapabilityMap from deebot_client.device import Device from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent @@ -20,18 +20,18 @@ async def async_setup_entry( ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data - entities = [] - for device in controller.devices(VacuumCapabilities): - capabilities: VacuumCapabilities = device.capabilities - if caps := capabilities.map: - entities.append(EcovacsMap(device, caps, hass)) + entities = [ + EcovacsMap(device, caps, hass) + for device in controller.devices + if (caps := device.capabilities.map) + ] if entities: async_add_entities(entities) class EcovacsMap( - EcovacsEntity[VacuumCapabilities, CapabilityMap], + EcovacsEntity[CapabilityMap], ImageEntity, ): """Ecovacs map.""" diff --git a/homeassistant/components/ecovacs/lawn_mower.py b/homeassistant/components/ecovacs/lawn_mower.py index 2561fe22217..a1dc8acf3a2 100644 --- a/homeassistant/components/ecovacs/lawn_mower.py +++ b/homeassistant/components/ecovacs/lawn_mower.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from deebot_client.capabilities import MowerCapabilities +from deebot_client.capabilities import Capabilities, DeviceType from deebot_client.device import Device from deebot_client.events import StateEvent from deebot_client.models import CleanAction, State @@ -42,14 +42,16 @@ async def async_setup_entry( """Set up the Ecovacs mowers.""" controller = config_entry.runtime_data mowers: list[EcovacsMower] = [ - EcovacsMower(device) for device in controller.devices(MowerCapabilities) + EcovacsMower(device) + for device in controller.devices + if device.capabilities.device_type is DeviceType.MOWER ] _LOGGER.debug("Adding Ecovacs Mowers to Home Assistant: %s", mowers) async_add_entities(mowers) class EcovacsMower( - EcovacsEntity[MowerCapabilities, MowerCapabilities], + EcovacsEntity[Capabilities], LawnMowerEntity, ): """Ecovacs Mower.""" @@ -62,10 +64,9 @@ class EcovacsMower( entity_description = LawnMowerEntityEntityDescription(key="mower", name=None) - def __init__(self, device: Device[MowerCapabilities]) -> None: + def __init__(self, device: Device) -> None: """Initialize the mower.""" - capabilities = device.capabilities - super().__init__(device, capabilities) + super().__init__(device, device.capabilities) async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index aad04d9ec87..d14291576ff 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.9", "deebot-client==7.1.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==8.0.0"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index bd8ce50aadb..bfe840dad42 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic -from deebot_client.capabilities import Capabilities, CapabilitySet, VacuumCapabilities +from deebot_client.capabilities import CapabilitySet from deebot_client.events import CleanCountEvent, VolumeEvent from homeassistant.components.number import NumberEntity, NumberEntityDescription @@ -16,7 +16,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcovacsConfigEntry from .entity import ( - CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -39,7 +38,6 @@ class EcovacsNumberEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( EcovacsNumberEntityDescription[VolumeEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.settings.volume, value_fn=lambda e: e.volume, native_max_value_fn=lambda e: e.maximum, @@ -52,7 +50,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( native_step=1.0, ), EcovacsNumberEntityDescription[CleanCountEvent]( - device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.clean.count, value_fn=lambda e: e.count, key="clean_count", @@ -81,7 +78,7 @@ async def async_setup_entry( class EcovacsNumberEntity( - EcovacsDescriptionEntity[CapabilityDevice, CapabilitySet[EventT, int]], + EcovacsDescriptionEntity[CapabilitySet[EventT, int]], NumberEntity, ): """Ecovacs number entity.""" diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index 4caa6327bb3..c8b01a0f83a 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Generic -from deebot_client.capabilities import CapabilitySetTypes, VacuumCapabilities +from deebot_client.capabilities import CapabilitySetTypes from deebot_client.device import Device from deebot_client.events import WaterInfoEvent, WorkModeEvent @@ -14,12 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcovacsConfigEntry -from .entity import ( - CapabilityDevice, - EcovacsCapabilityEntityDescription, - EcovacsDescriptionEntity, - EventT, -) +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT from .util import get_name_key, get_supported_entitites @@ -37,7 +32,6 @@ class EcovacsSelectEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( EcovacsSelectEntityDescription[WaterInfoEvent]( - device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.water, current_option_fn=lambda e: get_name_key(e.amount), options_fn=lambda water: [get_name_key(amount) for amount in water.types], @@ -46,7 +40,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ), EcovacsSelectEntityDescription[WorkModeEvent]( - device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.clean.work_mode, current_option_fn=lambda e: get_name_key(e.mode), options_fn=lambda cap: [get_name_key(mode) for mode in cap.types], @@ -73,7 +66,7 @@ async def async_setup_entry( class EcovacsSelectEntity( - EcovacsDescriptionEntity[CapabilityDevice, CapabilitySetTypes[EventT, str]], + EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]], SelectEntity, ): """Ecovacs select entity.""" diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index e9229781827..256198693fb 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic -from deebot_client.capabilities import Capabilities, CapabilityEvent, CapabilityLifeSpan +from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan from deebot_client.events import ( BatteryEvent, ErrorEvent, @@ -39,7 +39,6 @@ from homeassistant.helpers.typing import StateType from . import EcovacsConfigEntry from .const import SUPPORTED_LIFESPANS from .entity import ( - CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -63,7 +62,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( # Stats EcovacsSensorEntityDescription[StatsEvent]( key="stats_area", - device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.clean, value_fn=lambda e: e.area, translation_key="stats_area", @@ -71,7 +69,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( ), EcovacsSensorEntityDescription[StatsEvent]( key="stats_time", - device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.clean, value_fn=lambda e: e.time, translation_key="stats_time", @@ -81,7 +78,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( ), # TotalStats EcovacsSensorEntityDescription[TotalStatsEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.total, value_fn=lambda e: e.area, key="total_stats_area", @@ -90,7 +86,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[TotalStatsEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.total, value_fn=lambda e: e.time, key="total_stats_time", @@ -101,7 +96,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[TotalStatsEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.total, value_fn=lambda e: e.cleanings, key="total_stats_cleanings", @@ -109,7 +103,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[BatteryEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.battery, value_fn=lambda e: e.value, key=ATTR_BATTERY_LEVEL, @@ -118,7 +111,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), EcovacsSensorEntityDescription[NetworkInfoEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.network, value_fn=lambda e: e.ip, key="network_ip", @@ -127,7 +119,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), EcovacsSensorEntityDescription[NetworkInfoEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.network, value_fn=lambda e: e.rssi, key="network_rssi", @@ -136,7 +127,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), EcovacsSensorEntityDescription[NetworkInfoEvent]( - device_capabilities=Capabilities, capability_fn=lambda caps: caps.network, value_fn=lambda e: e.ssid, key="network_ssid", @@ -181,13 +171,13 @@ async def async_setup_entry( ) entities.extend( EcovacsLifespanSensor(device, device.capabilities.life_span, description) - for device in controller.devices(Capabilities) + for device in controller.devices for description in LIFESPAN_ENTITY_DESCRIPTIONS if description.component in device.capabilities.life_span.types ) entities.extend( EcovacsErrorSensor(device, capability) - for device in controller.devices(Capabilities) + for device in controller.devices if (capability := device.capabilities.error) ) @@ -195,7 +185,7 @@ async def async_setup_entry( class EcovacsSensor( - EcovacsDescriptionEntity[CapabilityDevice, CapabilityEvent], + EcovacsDescriptionEntity[CapabilityEvent], SensorEntity, ): """Ecovacs sensor.""" @@ -218,7 +208,7 @@ class EcovacsSensor( class EcovacsLifespanSensor( - EcovacsDescriptionEntity[Capabilities, CapabilityLifeSpan], + EcovacsDescriptionEntity[CapabilityLifeSpan], SensorEntity, ): """Lifespan sensor.""" @@ -238,7 +228,7 @@ class EcovacsLifespanSensor( class EcovacsErrorSensor( - EcovacsEntity[Capabilities, CapabilityEvent[ErrorEvent]], + EcovacsEntity[CapabilityEvent[ErrorEvent]], SensorEntity, ): """Error sensor.""" diff --git a/homeassistant/components/ecovacs/services.yaml b/homeassistant/components/ecovacs/services.yaml new file mode 100644 index 00000000000..0d884a24feb --- /dev/null +++ b/homeassistant/components/ecovacs/services.yaml @@ -0,0 +1,4 @@ +raw_get_positions: + target: + entity: + domain: vacuum diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index bb27bd6941d..68218e63d4e 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -58,6 +58,12 @@ "reset_lifespan_lens_brush": { "name": "Reset lens brush lifespan" }, + "reset_lifespan_round_mop": { + "name": "Reset round mop lifespan" + }, + "reset_lifespan_unit_care": { + "name": "Reset unit care lifespan" + }, "reset_lifespan_side_brush": { "name": "Reset side brushes lifespan" } @@ -113,6 +119,12 @@ "lifespan_side_brush": { "name": "Side brushes lifespan" }, + "lifespan_unit_care": { + "name": "Unit care lifespan" + }, + "lifespan_round_mop": { + "name": "Round mop lifespan" + }, "network_ip": { "name": "IP address" }, @@ -214,6 +226,9 @@ }, "vacuum_send_command_params_required": { "message": "Params are required for the command: {command}" + }, + "vacuum_raw_get_positions_not_supported": { + "message": "Getting the positions of the chargers and the device itself is not supported" } }, "issues": { @@ -249,5 +264,11 @@ "self_hosted": "Self-hosted" } } + }, + "services": { + "raw_get_positions": { + "name": "Get raw positions", + "description": "Get the raw response for the positions of the chargers and the device itself." + } } } diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py index 25ecb53e278..872981b5c28 100644 --- a/homeassistant/components/ecovacs/switch.py +++ b/homeassistant/components/ecovacs/switch.py @@ -3,11 +3,7 @@ from dataclasses import dataclass from typing import Any -from deebot_client.capabilities import ( - Capabilities, - CapabilitySetEnable, - VacuumCapabilities, -) +from deebot_client.capabilities import CapabilitySetEnable from deebot_client.events import EnableEvent from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -17,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EcovacsConfigEntry from .entity import ( - CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -28,86 +23,76 @@ from .util import get_supported_entitites @dataclass(kw_only=True, frozen=True) class EcovacsSwitchEntityDescription( SwitchEntityDescription, - EcovacsCapabilityEntityDescription[CapabilityDevice, CapabilitySetEnable], + EcovacsCapabilityEntityDescription[CapabilitySetEnable], ): """Ecovacs switch entity description.""" ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = ( - EcovacsSwitchEntityDescription[Capabilities]( - device_capabilities=Capabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.advanced_mode, key="advanced_mode", translation_key="advanced_mode", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[VacuumCapabilities]( - device_capabilities=VacuumCapabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.clean.continuous, key="continuous_cleaning", translation_key="continuous_cleaning", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[VacuumCapabilities]( - device_capabilities=VacuumCapabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.carpet_auto_fan_boost, key="carpet_auto_fan_boost", translation_key="carpet_auto_fan_boost", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[VacuumCapabilities]( - device_capabilities=VacuumCapabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.clean.preference, key="clean_preference", translation_key="clean_preference", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[Capabilities]( - device_capabilities=Capabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.true_detect, key="true_detect", translation_key="true_detect", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[Capabilities]( - device_capabilities=Capabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.border_switch, key="border_switch", translation_key="border_switch", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[Capabilities]( - device_capabilities=Capabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.child_lock, key="child_lock", translation_key="child_lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[Capabilities]( - device_capabilities=Capabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.moveup_warning, key="move_up_warning", translation_key="move_up_warning", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[Capabilities]( - device_capabilities=Capabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.cross_map_border_warning, key="cross_map_border_warning", translation_key="cross_map_border_warning", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription[Capabilities]( - device_capabilities=Capabilities, + EcovacsSwitchEntityDescription( capability_fn=lambda c: c.settings.safe_protect, key="safe_protect", translation_key="safe_protect", @@ -132,7 +117,7 @@ async def async_setup_entry( class EcovacsSwitchEntity( - EcovacsDescriptionEntity[CapabilityDevice, CapabilitySetEnable], + EcovacsDescriptionEntity[CapabilitySetEnable], SwitchEntity, ): """Ecovacs switch entity.""" diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index 9d692bbbb8f..a4894de8968 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -7,8 +7,6 @@ import random import string from typing import TYPE_CHECKING -from deebot_client.capabilities import Capabilities - from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -40,9 +38,8 @@ def get_supported_entitites( """Return all supported entities for all devices.""" return [ entity_class(device, capability, description) - for device in controller.devices(Capabilities) + for device in controller.devices for description in descriptions - if isinstance(device.capabilities, description.device_capabilities) if (capability := description.capability_fn(device.capabilities)) ] diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 5c898694cbb..401274609d8 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from deebot_client.capabilities import VacuumCapabilities +from deebot_client.capabilities import Capabilities, DeviceType from deebot_client.device import Device from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent from deebot_client.models import CleanAction, CleanMode, Room, State @@ -23,8 +23,9 @@ from homeassistant.components.vacuum import ( StateVacuumEntityDescription, VacuumEntityFeature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify @@ -39,6 +40,8 @@ _LOGGER = logging.getLogger(__name__) ATTR_ERROR = "error" ATTR_COMPONENT_PREFIX = "component_" +SERVICE_RAW_GET_POSITIONS = "raw_get_positions" + async def async_setup_entry( hass: HomeAssistant, @@ -46,9 +49,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecovacs vacuums.""" + controller = config_entry.runtime_data vacuums: list[EcovacsVacuum | EcovacsLegacyVacuum] = [ - EcovacsVacuum(device) for device in controller.devices(VacuumCapabilities) + EcovacsVacuum(device) + for device in controller.devices + if device.capabilities.device_type is DeviceType.VACUUM ] for device in controller.legacy_devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) @@ -56,6 +62,14 @@ async def async_setup_entry( _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) async_add_entities(vacuums) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_RAW_GET_POSITIONS, + {}, + "async_raw_get_positions", + supports_response=SupportsResponse.ONLY, + ) + class EcovacsLegacyVacuum(StateVacuumEntity): """Legacy Ecovacs vacuums.""" @@ -197,6 +211,15 @@ class EcovacsLegacyVacuum(StateVacuumEntity): """Send a command to a vacuum cleaner.""" self.device.run(sucks.VacBotCommand(command, params)) + async def async_raw_get_positions( + self, + ) -> None: + """Get bot and chargers positions.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="vacuum_raw_get_positions_not_supported", + ) + _STATE_TO_VACUUM_STATE = { State.IDLE: STATE_IDLE, @@ -211,7 +234,7 @@ _ATTR_ROOMS = "rooms" class EcovacsVacuum( - EcovacsEntity[VacuumCapabilities, VacuumCapabilities], + EcovacsEntity[Capabilities], StateVacuumEntity, ): """Ecovacs vacuum.""" @@ -222,7 +245,6 @@ class EcovacsVacuum( VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.BATTERY | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE @@ -234,16 +256,17 @@ class EcovacsVacuum( key="vacuum", translation_key="vacuum", name=None ) - def __init__(self, device: Device[VacuumCapabilities]) -> None: + def __init__(self, device: Device) -> None: """Initialize the vacuum.""" - capabilities = device.capabilities - super().__init__(device, capabilities) + super().__init__(device, device.capabilities) self._rooms: list[Room] = [] - self._attr_fan_speed_list = [ - get_name_key(level) for level in capabilities.fan_speed.types - ] + if fan_speed := self._capability.fan_speed: + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + self._attr_fan_speed_list = [ + get_name_key(level) for level in fan_speed.types + ] async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" @@ -253,10 +276,6 @@ class EcovacsVacuum( self._attr_battery_level = event.value self.async_write_ha_state() - async def on_fan_speed(event: FanSpeedEvent) -> None: - self._attr_fan_speed = get_name_key(event.speed) - self.async_write_ha_state() - async def on_rooms(event: RoomsEvent) -> None: self._rooms = event.rooms self.async_write_ha_state() @@ -266,9 +285,16 @@ class EcovacsVacuum( self.async_write_ha_state() self._subscribe(self._capability.battery.event, on_battery) - self._subscribe(self._capability.fan_speed.event, on_fan_speed) self._subscribe(self._capability.state.event, on_status) + if self._capability.fan_speed: + + async def on_fan_speed(event: FanSpeedEvent) -> None: + self._attr_fan_speed = get_name_key(event.speed) + self.async_write_ha_state() + + self._subscribe(self._capability.fan_speed.event, on_fan_speed) + if map_caps := self._capability.map: self._subscribe(map_caps.rooms.event, on_rooms) @@ -298,6 +324,8 @@ class EcovacsVacuum( async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" + if TYPE_CHECKING: + assert self._capability.fan_speed await self._device.execute_command(self._capability.fan_speed.set(fan_speed)) async def async_return_to_base(self, **kwargs: Any) -> None: @@ -377,3 +405,19 @@ class EcovacsVacuum( await self._device.execute_command( self._capability.custom.set(command, params) ) + + async def async_raw_get_positions( + self, + ) -> dict[str, Any]: + """Get bot and chargers positions.""" + _LOGGER.debug("async_raw_get_positions") + + if not (map_cap := self._capability.map) or not ( + position_commands := map_cap.position.get + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="vacuum_raw_get_positions_not_supported", + ) + + return await self._device.execute_command(position_commands[0]) diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py index f73467288a2..1ef2956d84b 100644 --- a/homeassistant/components/ecowitt/binary_sensor.py +++ b/homeassistant/components/ecowitt/binary_sensor.py @@ -11,6 +11,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 @@ -22,7 +23,9 @@ ECOWITT_BINARYSENSORS_MAPPING: Final = { key="LEAK", device_class=BinarySensorDeviceClass.MOISTURE ), EcoWittSensorTypes.BATTERY_BINARY: BinarySensorEntityDescription( - key="BATTERY", device_class=BinarySensorDeviceClass.BATTERY + key="BATTERY", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/ecowitt/diagnostics.py b/homeassistant/components/ecowitt/diagnostics.py index db7d2e0989d..a21d11e8126 100644 --- a/homeassistant/components/ecowitt/diagnostics.py +++ b/homeassistant/components/ecowitt/diagnostics.py @@ -26,7 +26,7 @@ async def async_get_device_diagnostics( "device": { "name": station.station, "model": station.model, - "frequency": station.frequence, + "frequency": station.frequence, # codespell:ignore frequence "version": station.version, }, "raw": ecowitt.last_values[station_id], diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 5f2f08f2519..6845fb64d4c 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -125,6 +125,7 @@ ECOWITT_SENSORS_MAPPING: Final = { device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), EcoWittSensorTypes.LIGHTNING_COUNT: SensorEntityDescription( key="LIGHTNING_COUNT", @@ -241,7 +242,12 @@ async def async_setup_entry( ) # Hourly rain doesn't reset to fixed hours, it must be measurement state classes - if sensor.key in ("hrain_piezomm", "hrain_piezo"): + if sensor.key in ( + "hrain_piezomm", + "hrain_piezo", + "hourlyrainmm", + "hourlyrainin", + ): description = dataclasses.replace( description, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py index 3bfd37392ad..52979e50552 100644 --- a/homeassistant/components/efergy/__init__.py +++ b/homeassistant/components/efergy/__init__.py @@ -16,9 +16,10 @@ from homeassistant.helpers.entity import Entity from .const import DEFAULT_NAME, DOMAIN PLATFORMS = [Platform.SENSOR] +type EfergyConfigEntry = ConfigEntry[Efergy] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EfergyConfigEntry) -> bool: """Set up Efergy from a config entry.""" api = Efergy( entry.data[CONF_API_KEY], @@ -36,17 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "API Key is no longer valid. Please reauthenticate" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + entry.runtime_data = api + 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, entry: EfergyConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class EfergyEntity(Entity): diff --git a/homeassistant/components/efergy/config_flow.py b/homeassistant/components/efergy/config_flow.py index 8e23925d193..b17c19693d6 100644 --- a/homeassistant/components/efergy/config_flow.py +++ b/homeassistant/components/efergy/config_flow.py @@ -61,14 +61,18 @@ class EfergyFlowHandler(ConfigFlow, domain=DOMAIN): async def _async_try_connect(self, api_key: str) -> tuple[str | None, str | None]: """Try connecting to Efergy servers.""" - api = Efergy(api_key, session=async_get_clientsession(self.hass)) + api = Efergy( + api_key, + session=async_get_clientsession(self.hass), + utc_offset=self.hass.config.time_zone, + ) try: await api.async_status() except exceptions.ConnectError: return None, "cannot_connect" except exceptions.InvalidAuth: return None, "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") return None, "unknown" return api.info["hid"], None diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json index 1147248b254..15d3a0798cd 100644 --- a/homeassistant/components/efergy/manifest.json +++ b/homeassistant/components/efergy/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["iso4217", "pyefergy"], - "requirements": ["pyefergy==22.1.1"] + "requirements": ["pyefergy==22.5.0"] } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 59b2799d37b..a03f8f7d012 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -15,14 +15,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import EfergyEntity -from .const import CONF_CURRENT_VALUES, DOMAIN, LOGGER +from . import EfergyConfigEntry, EfergyEntity +from .const import CONF_CURRENT_VALUES, LOGGER SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -106,10 +105,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: EfergyConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Efergy sensors.""" - api: Efergy = hass.data[DOMAIN][entry.entry_id] + api = entry.runtime_data sensors = [] for description in SENSOR_TYPES: if description.key != CONF_CURRENT_VALUES: diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index dec4750d219..706ba0db719 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -6,8 +6,10 @@ import logging import requests -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -61,10 +63,11 @@ def setup_platform( add_entities([device], True) -class EgardiaAlarm(alarm.AlarmControlPanelEntity): +class EgardiaAlarm(AlarmControlPanelEntity): """Representation of a Egardia alarm.""" _attr_state: str | None + _attr_code_arm_required = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json index 405d9ee688a..f19aeb3d947 100644 --- a/homeassistant/components/electrasmart/manifest.json +++ b/homeassistant/components/electrasmart/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/electrasmart", "iot_class": "cloud_polling", - "requirements": ["pyElectra==1.2.0"] + "requirements": ["pyElectra==1.2.3"] } diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index 90b31aa7511..a3f073b8ca2 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -54,8 +54,8 @@ class ElectricKiwiSelectHOPEntity( """Initialise the HOP selection entity.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" - f"_{coordinator._ek_api.connection_id}_{description.key}" + f"{coordinator._ek_api.customer_number}" # noqa: SLF001 + f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 ) self.entity_description = description self.values_dict = coordinator.get_hop_options() diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 308201a9458..7672466106b 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -91,13 +91,13 @@ def _check_and_move_time(hop: Hop, time: str) -> datetime: date_time = datetime.combine( dt_util.start_of_local_day(), datetime.strptime(time, "%I:%M %p").time(), - dt_util.DEFAULT_TIME_ZONE, + dt_util.get_default_time_zone(), ) end_time = datetime.combine( dt_util.start_of_local_day(), datetime.strptime(hop.end.end_time, "%I:%M %p").time(), - dt_util.DEFAULT_TIME_ZONE, + dt_util.get_default_time_zone(), ) if end_time < dt_util.now(): @@ -167,8 +167,8 @@ class ElectricKiwiAccountEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" - f"_{coordinator._ek_api.connection_id}_{description.key}" + f"{coordinator._ek_api.customer_number}" # noqa: SLF001 + f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 ) self.entity_description = description @@ -196,8 +196,8 @@ class ElectricKiwiHOPEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" - f"_{coordinator._ek_api.connection_id}_{description.key}" + f"{coordinator._ek_api.customer_number}" # noqa: SLF001 + f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 ) self.entity_description = description diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 8d6af325213..2d8446c3b76 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -4,25 +4,24 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import ElgatoDataUpdateCoordinator PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +type ElgatorConfigEntry = ConfigEntry[ElgatoDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool: """Set up Elgato Light from a config entry.""" coordinator = ElgatoDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElgatorConfigEntry) -> bool: """Unload Elgato Light config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 47e24ca245a..aefff0b750b 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -13,13 +13,12 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import ElgatorConfigEntry from .coordinator import ElgatoDataUpdateCoordinator from .entity import ElgatoEntity @@ -49,11 +48,11 @@ BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ElgatorConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato button based on a config entry.""" - coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ElgatoButtonEntity( coordinator=coordinator, diff --git a/homeassistant/components/elgato/diagnostics.py b/homeassistant/components/elgato/diagnostics.py index 91f5c9a8319..ac3ea0a155d 100644 --- a/homeassistant/components/elgato/diagnostics.py +++ b/homeassistant/components/elgato/diagnostics.py @@ -4,18 +4,16 @@ 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 ElgatoDataUpdateCoordinator +from . import ElgatorConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ElgatorConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "info": coordinator.data.info.to_dict(), "state": coordinator.data.state.to_dict(), diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 100a04fb6fb..339bed97f6f 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -13,7 +13,6 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import ( @@ -21,7 +20,8 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) -from .const import DOMAIN, SERVICE_IDENTIFY +from . import ElgatorConfigEntry +from .const import SERVICE_IDENTIFY from .coordinator import ElgatoDataUpdateCoordinator from .entity import ElgatoEntity @@ -30,11 +30,11 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ElgatorConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato Light based on a config entry.""" - coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities([ElgatoLight(coordinator)]) platform = async_get_current_platform() @@ -59,7 +59,15 @@ class ElgatoLight(ElgatoEntity, LightEntity): self._attr_unique_id = coordinator.data.info.serial_number # Elgato Light supporting color, have a different temperature range - if self.coordinator.data.settings.power_on_hue is not None: + if ( + self.coordinator.data.info.product_name + in ( + "Elgato Light Strip", + "Elgato Light Strip Pro", + ) + or self.coordinator.data.settings.power_on_hue + or self.coordinator.data.state.hue is not None + ): self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} self._attr_min_mireds = 153 self._attr_max_mireds = 285 diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 76d88df3fb9..f794d26cf7f 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -22,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import ElgatorConfigEntry from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity @@ -102,11 +101,11 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ElgatorConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato sensor based on a config entry.""" - coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ElgatoSensorEntity( diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 0d20ae95e03..fe177616034 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -9,13 +9,12 @@ from typing import Any from elgato import Elgato, ElgatoError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import ElgatorConfigEntry from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity @@ -53,11 +52,11 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ElgatorConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Elgato switches based on a config entry.""" - coordinator: ElgatoDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ElgatoSwitchEntity( diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 3b0c5f02f97..fff40b6ad73 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -69,6 +69,8 @@ from .discovery import ( ) from .models import ELKM1Data +type ElkM1ConfigEntry = ConfigEntry[ELKM1Data] + SYNC_TIMEOUT = 120 _LOGGER = logging.getLogger(__name__) @@ -181,7 +183,6 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: """Set up the Elk M1 platform.""" - hass.data.setdefault(DOMAIN, {}) _create_elk_services(hass) async def _async_discovery(*_: Any) -> None: @@ -235,7 +236,7 @@ def _async_find_matching_config_entry( return None -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool: """Set up Elk-M1 Control from a config entry.""" conf: MappingProxyType[str, Any] = entry.data @@ -308,7 +309,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config["temperature_unit"] = temperature_unit prefix: str = conf[CONF_PREFIX] auto_configure: bool = conf[CONF_AUTO_CONFIGURE] - hass.data[DOMAIN][entry.entry_id] = ELKM1Data( + entry.runtime_data = ELKM1Data( elk=elk, prefix=prefix, mac=entry.unique_id, @@ -331,24 +332,20 @@ def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) - def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None: """Search all config entries for a given prefix.""" - all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] - for elk_data in all_elk.values(): + for entry in hass.config_entries.async_entries(DOMAIN): + if not entry.runtime_data: + continue + elk_data: ELKM1Data = entry.runtime_data if elk_data.prefix == prefix: return elk_data.elk return None -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] - # disconnect cleanly - all_elk[entry.entry_id].elk.disconnect() - - if unload_ok: - all_elk.pop(entry.entry_id) - + entry.runtime_data.elk.disconnect() return unload_ok diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 5752bf82436..eb8d7360ce2 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -17,7 +17,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -33,12 +32,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import ElkAttachedEntity, ElkEntity, create_elk_entities +from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities from .const import ( ATTR_CHANGED_BY_ID, ATTR_CHANGED_BY_KEYPAD, ATTR_CHANGED_BY_TIME, - DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA, ) from .models import ELKM1Data @@ -63,12 +61,11 @@ SERVICE_ALARM_CLEAR_BYPASS = "alarm_clear_bypass" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElkM1ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ElkM1 alarm platform.""" - - elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk_data = config_entry.runtime_data elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities) diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index c04a9d17830..171e9968ce6 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -9,22 +9,19 @@ from elkm1_lib.elements import Element from elkm1_lib.zones import Zone 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 . import ElkAttachedEntity, ElkEntity -from .const import DOMAIN -from .models import ELKM1Data +from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElkM1ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk_data = config_entry.runtime_data elk = elk_data.elk auto_configure = elk_data.auto_configure diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 76ede0bbdf1..6281cca8592 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -17,14 +17,11 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_WHOLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkEntity, create_elk_entities -from .const import DOMAIN -from .models import ELKM1Data +from . import ElkEntity, ElkM1ConfigEntry, create_elk_entities SUPPORT_HVAC = [ HVACMode.OFF, @@ -59,11 +56,11 @@ ELK_TO_HASS_FAN_MODES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElkM1ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 thermostat platform.""" - elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk_data = config_entry.runtime_data elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities( diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index 9a71c86478b..972b38d2ae9 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -248,7 +248,7 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "cannot_connect"}, None except InvalidAuth: return {CONF_PASSWORD: "invalid_auth"}, None - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return {"base": "unknown"}, None diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index 432d6683de4..17d525f6ddc 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -9,22 +9,20 @@ from elkm1_lib.elk import Elk from elkm1_lib.lights import Light from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkEntity, create_elk_entities -from .const import DOMAIN +from . import ElkEntity, ElkM1ConfigEntry, create_elk_entities from .models import ELKM1Data async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElkM1ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elk light platform.""" - elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk_data = config_entry.runtime_data elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities) diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index 9658052f3e5..e4b738c9dbd 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -7,22 +7,19 @@ from typing import Any from elkm1_lib.tasks import Task from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkAttachedEntity, ElkEntity, create_elk_entities -from .const import DOMAIN -from .models import ELKM1Data +from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElkM1ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 scene platform.""" - elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk_data = config_entry.runtime_data elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities) diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 27a6c1596eb..801a09b76eb 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -15,16 +15,14 @@ from elkm1_lib.zones import Zone import voluptuous as vol from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfElectricPotential from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkAttachedEntity, ElkEntity, create_elk_entities -from .const import ATTR_VALUE, DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA -from .models import ELKM1Data +from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities +from .const import ATTR_VALUE, ELK_USER_CODE_SERVICE_SCHEMA SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh" SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set" @@ -39,11 +37,11 @@ ELK_SET_COUNTER_SERVICE_SCHEMA = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElkM1ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk_data = config_entry.runtime_data elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities) diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index 3224f9affcf..f4820f57b3d 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -7,22 +7,19 @@ from typing import Any from elkm1_lib.outputs import Output from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkAttachedEntity, ElkEntity, create_elk_entities -from .const import DOMAIN -from .models import ELKM1Data +from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ElkM1ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 switch platform.""" - elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk_data = config_entry.runtime_data elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities) diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py index 518bf1e932b..b30d7a260a3 100644 --- a/homeassistant/components/elmax/__init__.py +++ b/homeassistant/components/elmax/__init__.py @@ -13,12 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .common import ( - DirectPanel, - ElmaxCoordinator, - build_direct_ssl_context, - get_direct_api_url, -) +from .common import DirectPanel, build_direct_ssl_context, get_direct_api_url from .const import ( CONF_ELMAX_MODE, CONF_ELMAX_MODE_CLOUD, @@ -35,6 +30,7 @@ from .const import ( ELMAX_PLATFORMS, POLLING_SECONDS, ) +from .coordinator import ElmaxCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index b9a895f6967..fd4f23a394e 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -17,9 +17,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import InvalidStateError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElmaxCoordinator from .common import ElmaxEntity from .const import DOMAIN +from .coordinator import ElmaxCoordinator async def async_setup_entry( diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index b3bdc174246..e477ab6c2a4 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -12,9 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElmaxCoordinator from .common import ElmaxEntity from .const import DOMAIN +from .coordinator import ElmaxCoordinator async def async_setup_entry( diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 39b6797fc58..965e30235ff 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -2,45 +2,17 @@ from __future__ import annotations -from asyncio import timeout -from datetime import timedelta -import logging -from logging import Logger import ssl -from elmax_api.exceptions import ( - ElmaxApiError, - ElmaxBadLoginError, - ElmaxBadPinError, - ElmaxNetworkError, - ElmaxPanelBusyError, -) -from elmax_api.http import Elmax, GenericElmax -from elmax_api.model.actuator import Actuator -from elmax_api.model.area import Area -from elmax_api.model.cover import Cover from elmax_api.model.endpoint import DeviceEndpoint -from elmax_api.model.panel import PanelEntry, PanelStatus -from httpx import ConnectError, ConnectTimeout +from elmax_api.model.panel import PanelEntry from packaging import version -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DEFAULT_TIMEOUT, - DOMAIN, - ELMAX_LOCAL_API_PATH, - MIN_APIV2_SUPPORTED_VERSION, -) - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, ELMAX_LOCAL_API_PATH, MIN_APIV2_SUPPORTED_VERSION +from .coordinator import ElmaxCoordinator def get_direct_api_url(host: str, port: int, use_ssl: bool) -> str: @@ -77,103 +49,6 @@ class DirectPanel(PanelEntry): return f"Direct Panel {self.hash}" -class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): # pylint: disable=hass-enforce-coordinator-module - """Coordinator helper to handle Elmax API polling.""" - - def __init__( - self, - hass: HomeAssistant, - logger: Logger, - elmax_api_client: GenericElmax, - panel: PanelEntry, - name: str, - update_interval: timedelta, - ) -> None: - """Instantiate the object.""" - self._client = elmax_api_client - self._panel_entry = panel - self._state_by_endpoint = None - super().__init__( - hass=hass, logger=logger, name=name, update_interval=update_interval - ) - - @property - def panel_entry(self) -> PanelEntry: - """Return the panel entry.""" - return self._panel_entry - - def get_actuator_state(self, actuator_id: str) -> Actuator: - """Return state of a specific actuator.""" - if self._state_by_endpoint is not None: - return self._state_by_endpoint[actuator_id] - raise HomeAssistantError("Unknown actuator") - - def get_zone_state(self, zone_id: str) -> Actuator: - """Return state of a specific zone.""" - if self._state_by_endpoint is not None: - return self._state_by_endpoint[zone_id] - raise HomeAssistantError("Unknown zone") - - def get_area_state(self, area_id: str) -> Area: - """Return state of a specific area.""" - if self._state_by_endpoint is not None and area_id: - return self._state_by_endpoint[area_id] - raise HomeAssistantError("Unknown area") - - def get_cover_state(self, cover_id: str) -> Cover: - """Return state of a specific cover.""" - if self._state_by_endpoint is not None: - return self._state_by_endpoint[cover_id] - raise HomeAssistantError("Unknown cover") - - @property - def http_client(self): - """Return the current http client being used by this instance.""" - return self._client - - @http_client.setter - def http_client(self, client: GenericElmax): - """Set the client library instance for Elmax API.""" - self._client = client - - async def _async_update_data(self): - try: - async with timeout(DEFAULT_TIMEOUT): - # The following command might fail in case of the panel is offline. - # We handle this case in the following exception blocks. - status = await self._client.get_current_panel_status() - - # Store a dictionary for fast endpoint state access - self._state_by_endpoint = { - k.endpoint_id: k for k in status.all_endpoints - } - return status - - except ElmaxBadPinError as err: - raise ConfigEntryAuthFailed("Control panel pin was refused") from err - except ElmaxBadLoginError as err: - raise ConfigEntryAuthFailed("Refused username/password/pin") from err - except ElmaxApiError as err: - raise UpdateFailed(f"Error communicating with ELMAX API: {err}") from err - except ElmaxPanelBusyError as err: - raise UpdateFailed( - "Communication with the panel failed, as it is currently busy" - ) from err - except (ConnectError, ConnectTimeout, ElmaxNetworkError) as err: - if isinstance(self._client, Elmax): - raise UpdateFailed( - "A communication error has occurred. " - "Make sure HA can reach the internet and that " - "your firewall allows communication with the Meross Cloud." - ) from err - - raise UpdateFailed( - "A communication error has occurred. " - "Make sure the panel is online and that " - "your firewall allows communication with it." - ) from err - - class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]): """Wrapper for Elmax entities.""" diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 666f4e75fcd..2971a425663 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -370,7 +370,7 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): ) except ElmaxBadPinError: errors["base"] = "invalid_pin" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error occurred") errors["base"] = "unknown" diff --git a/homeassistant/components/elmax/coordinator.py b/homeassistant/components/elmax/coordinator.py new file mode 100644 index 00000000000..baf9d568a82 --- /dev/null +++ b/homeassistant/components/elmax/coordinator.py @@ -0,0 +1,124 @@ +"""Coordinator for the elmax-cloud integration.""" + +from __future__ import annotations + +from asyncio import timeout +from datetime import timedelta +from logging import Logger + +from elmax_api.exceptions import ( + ElmaxApiError, + ElmaxBadLoginError, + ElmaxBadPinError, + ElmaxNetworkError, + ElmaxPanelBusyError, +) +from elmax_api.http import Elmax, GenericElmax +from elmax_api.model.actuator import Actuator +from elmax_api.model.area import Area +from elmax_api.model.cover import Cover +from elmax_api.model.panel import PanelEntry, PanelStatus +from httpx import ConnectError, ConnectTimeout + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_TIMEOUT + + +class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): + """Coordinator helper to handle Elmax API polling.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + elmax_api_client: GenericElmax, + panel: PanelEntry, + name: str, + update_interval: timedelta, + ) -> None: + """Instantiate the object.""" + self._client = elmax_api_client + self._panel_entry = panel + self._state_by_endpoint = None + super().__init__( + hass=hass, logger=logger, name=name, update_interval=update_interval + ) + + @property + def panel_entry(self) -> PanelEntry: + """Return the panel entry.""" + return self._panel_entry + + def get_actuator_state(self, actuator_id: str) -> Actuator: + """Return state of a specific actuator.""" + if self._state_by_endpoint is not None: + return self._state_by_endpoint[actuator_id] + raise HomeAssistantError("Unknown actuator") + + def get_zone_state(self, zone_id: str) -> Actuator: + """Return state of a specific zone.""" + if self._state_by_endpoint is not None: + return self._state_by_endpoint[zone_id] + raise HomeAssistantError("Unknown zone") + + def get_area_state(self, area_id: str) -> Area: + """Return state of a specific area.""" + if self._state_by_endpoint is not None and area_id: + return self._state_by_endpoint[area_id] + raise HomeAssistantError("Unknown area") + + def get_cover_state(self, cover_id: str) -> Cover: + """Return state of a specific cover.""" + if self._state_by_endpoint is not None: + return self._state_by_endpoint[cover_id] + raise HomeAssistantError("Unknown cover") + + @property + def http_client(self): + """Return the current http client being used by this instance.""" + return self._client + + @http_client.setter + def http_client(self, client: GenericElmax): + """Set the client library instance for Elmax API.""" + self._client = client + + async def _async_update_data(self): + try: + async with timeout(DEFAULT_TIMEOUT): + # The following command might fail in case of the panel is offline. + # We handle this case in the following exception blocks. + status = await self._client.get_current_panel_status() + + # Store a dictionary for fast endpoint state access + self._state_by_endpoint = { + k.endpoint_id: k for k in status.all_endpoints + } + return status + + except ElmaxBadPinError as err: + raise ConfigEntryAuthFailed("Control panel pin was refused") from err + except ElmaxBadLoginError as err: + raise ConfigEntryAuthFailed("Refused username/password/pin") from err + except ElmaxApiError as err: + raise UpdateFailed(f"Error communicating with ELMAX API: {err}") from err + except ElmaxPanelBusyError as err: + raise UpdateFailed( + "Communication with the panel failed, as it is currently busy" + ) from err + except (ConnectError, ConnectTimeout, ElmaxNetworkError) as err: + if isinstance(self._client, Elmax): + raise UpdateFailed( + "A communication error has occurred. " + "Make sure HA can reach the internet and that " + "your firewall allows communication with the Meross Cloud." + ) from err + + raise UpdateFailed( + "A communication error has occurred. " + "Make sure the panel is online and that " + "your firewall allows communication with it." + ) from err diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 6113ccd7997..528b2e6dead 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -13,9 +13,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElmaxCoordinator from .common import ElmaxEntity from .const import DOMAIN +from .coordinator import ElmaxCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/elmax/manifest.json b/homeassistant/components/elmax/manifest.json index 181b1c8a882..c57b707906b 100644 --- a/homeassistant/components/elmax/manifest.json +++ b/homeassistant/components/elmax/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/elmax", "iot_class": "cloud_polling", "loggers": ["elmax_api"], - "requirements": ["elmax-api==0.0.4"], + "requirements": ["elmax-api==0.0.5"], "zeroconf": [ { "type": "_elmax-ssl._tcp.local." diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index 911ad864b50..6ecbc70a8c5 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -12,9 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElmaxCoordinator from .common import ElmaxEntity from .const import DOMAIN +from .coordinator import ElmaxCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/emoncms/coordinator.py b/homeassistant/components/emoncms/coordinator.py new file mode 100644 index 00000000000..16258a11f4d --- /dev/null +++ b/homeassistant/components/emoncms/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the emoncms integration.""" + +from datetime import timedelta +import logging +from typing import Any + +from pyemoncms import EmoncmsClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]): + """Emoncms Data Update Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + emoncms_client: EmoncmsClient, + scan_interval: timedelta, + ) -> None: + """Initialize the emoncms data coordinator.""" + super().__init__( + hass, + _LOGGER, + name="emoncms_coordinator", + update_method=emoncms_client.async_list_feeds, + update_interval=scan_interval, + ) diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index 21f625acb4a..09229d0419a 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -1,7 +1,8 @@ { "domain": "emoncms", "name": "Emoncms", - "codeowners": ["@borpin"], + "codeowners": ["@borpin", "@alexandrecuer"], "documentation": "https://www.home-assistant.io/integrations/emoncms", - "iot_class": "local_polling" + "iot_class": "local_polling", + "requirements": ["pyemoncms==0.0.7"] } diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 746877c4e5f..97c69619fa9 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -3,10 +3,10 @@ from __future__ import annotations from datetime import timedelta -from http import HTTPStatus import logging +from typing import Any -import requests +from pyemoncms import EmoncmsClient import voluptuous as vol from homeassistant.components.sensor import ( @@ -25,12 +25,15 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfPower, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import template +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import EmoncmsCoordinator _LOGGER = logging.getLogger(__name__) @@ -48,7 +51,6 @@ CONF_SENSOR_NAMES = "sensor_names" DECIMALS = 2 DEFAULT_UNIT = UnitOfPower.WATT -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none" @@ -72,41 +74,36 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def get_id(sensorid, feedtag, feedname, feedid, feeduserid): - """Return unique identifier for feed / sensor.""" - return f"emoncms{sensorid}_{feedtag}_{feedname}_{feedid}_{feeduserid}" - - -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 Emoncms sensor.""" - apikey = config.get(CONF_API_KEY) - url = config.get(CONF_URL) - sensorid = config.get(CONF_ID) + apikey = config[CONF_API_KEY] + url = config[CONF_URL] + sensorid = config[CONF_ID] value_template = config.get(CONF_VALUE_TEMPLATE) config_unit = config.get(CONF_UNIT_OF_MEASUREMENT) exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) sensor_names = config.get(CONF_SENSOR_NAMES) - interval = config.get(CONF_SCAN_INTERVAL) + scan_interval = config.get(CONF_SCAN_INTERVAL, timedelta(seconds=30)) if value_template is not None: value_template.hass = hass - data = EmonCmsData(hass, url, apikey, interval) - - data.update() - - if data.data is None: + emoncms_client = EmoncmsClient(url, apikey, session=async_get_clientsession(hass)) + coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval) + await coordinator.async_refresh() + elems = coordinator.data + if elems is None: return - sensors = [] + sensors: list[EmonCmsSensor] = [] - for elem in data.data: + for idx, elem in enumerate(elems): if exclude_feeds is not None and int(elem["id"]) in exclude_feeds: continue @@ -124,44 +121,48 @@ def setup_platform( sensors.append( EmonCmsSensor( - hass, - data, + coordinator, name, value_template, unit_of_measurement, str(sensorid), - elem, + idx, ) ) - add_entities(sensors) + async_add_entities(sensors) -class EmonCmsSensor(SensorEntity): +class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): """Implementation of an Emoncms sensor.""" def __init__( - self, hass, data, name, value_template, unit_of_measurement, sensorid, elem - ): + self, + coordinator: EmoncmsCoordinator, + name: str | None, + value_template: template.Template | None, + unit_of_measurement: str | None, + sensorid: str, + idx: int, + ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) + self.idx = idx + elem = {} + if self.coordinator.data: + elem = self.coordinator.data[self.idx] if name is None: # Suppress ID in sensor name if it's 1, since most people won't # have more than one EmonCMS source and it's redundant to show the # ID if there's only one. id_for_name = "" if str(sensorid) == "1" else sensorid # Use the feed name assigned in EmonCMS or fall back to the feed ID - feed_name = elem.get("name") or f"Feed {elem['id']}" - self._name = f"EmonCMS{id_for_name} {feed_name}" + feed_name = elem.get("name", f"Feed {elem.get('id')}") + self._attr_name = f"EmonCMS{id_for_name} {feed_name}" else: - self._name = name - self._identifier = get_id( - sensorid, elem["tag"], elem["name"], elem["id"], elem["userid"] - ) - self._hass = hass - self._data = data + self._attr_name = name self._value_template = value_template - self._unit_of_measurement = unit_of_measurement + self._attr_native_unit_of_measurement = unit_of_measurement self._sensorid = sensorid - self._elem = elem if unit_of_measurement in ("kWh", "Wh"): self._attr_device_class = SensorDeviceClass.ENERGY @@ -187,113 +188,37 @@ class EmonCmsSensor(SensorEntity): elif unit_of_measurement == "hPa": self._attr_device_class = SensorDeviceClass.PRESSURE self._attr_state_class = SensorStateClass.MEASUREMENT + self._update_attributes(elem) - if self._value_template is not None: - self._state = self._value_template.render_with_possible_json_value( - elem["value"], STATE_UNKNOWN - ) - elif elem["value"] is not None: - self._state = round(float(elem["value"]), DECIMALS) - else: - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the attributes of the sensor.""" - return { - ATTR_FEEDID: self._elem["id"], - ATTR_TAG: self._elem["tag"], - ATTR_FEEDNAME: self._elem["name"], - ATTR_SIZE: self._elem["size"], - ATTR_USERID: self._elem["userid"], - ATTR_LASTUPDATETIME: self._elem["time"], - ATTR_LASTUPDATETIMESTR: template.timestamp_local(float(self._elem["time"])), + def _update_attributes(self, elem: dict[str, Any]) -> None: + """Update entity attributes.""" + self._attr_extra_state_attributes = { + ATTR_FEEDID: elem["id"], + ATTR_TAG: elem["tag"], + ATTR_FEEDNAME: elem["name"], } + if elem["value"] is not None: + self._attr_extra_state_attributes[ATTR_SIZE] = elem["size"] + self._attr_extra_state_attributes[ATTR_USERID] = elem["userid"] + self._attr_extra_state_attributes[ATTR_LASTUPDATETIME] = elem["time"] + self._attr_extra_state_attributes[ATTR_LASTUPDATETIMESTR] = ( + template.timestamp_local(float(elem["time"])) + ) - def update(self) -> None: - """Get the latest data and updates the state.""" - self._data.update() - - if self._data.data is None: - return - - elem = next( - ( - elem - for elem in self._data.data - if get_id( - self._sensorid, - elem["tag"], - elem["name"], - elem["id"], - elem["userid"], - ) - == self._identifier - ), - None, - ) - - if elem is None: - return - - self._elem = elem - + self._attr_native_value = None if self._value_template is not None: - self._state = self._value_template.render_with_possible_json_value( - elem["value"], STATE_UNKNOWN + self._attr_native_value = ( + self._value_template.render_with_possible_json_value( + elem["value"], STATE_UNKNOWN + ) ) elif elem["value"] is not None: - self._state = round(float(elem["value"]), DECIMALS) - else: - self._state = None + self._attr_native_value = round(float(elem["value"]), DECIMALS) - -class EmonCmsData: - """The class for handling the data retrieval.""" - - def __init__(self, hass, url, apikey, interval): - """Initialize the data object.""" - self._apikey = apikey - self._url = f"{url}/feed/list.json" - self._interval = interval - self._hass = hass - self.data = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from Emoncms.""" - try: - parameters = {"apikey": self._apikey} - req = requests.get( - self._url, params=parameters, allow_redirects=True, timeout=5 - ) - except requests.exceptions.RequestException as exception: - _LOGGER.error(exception) - return - - if req.status_code == HTTPStatus.OK: - self.data = req.json() - else: - _LOGGER.error( - ( - "Please verify if the specified configuration value " - "'%s' is correct! (HTTP Status_code = %d)" - ), - CONF_URL, - req.status_code, - ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + data = self.coordinator.data + if data: + self._update_attributes(data[self.idx]) + super()._handle_coordinator_update() diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index 70bd58e4cc0..9909ddff19c 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -46,7 +46,7 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN): info = await fetch_mac_and_title(self.hass, user_input[CONF_HOST]) except aiohttp.ClientError: errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -77,7 +77,7 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN): self.discovered_info = await fetch_mac_and_title( self.hass, self.discovered_ip ) - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 _LOGGER.debug( "Unable to fetch status, falling back to manual entry", exc_info=ex ) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 9a7ce8369aa..3e229d07b6c 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -136,8 +136,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: # We misunderstood the startup signal. You're not allowed to change # anything during startup. Temp workaround. - # pylint: disable-next=protected-access - app._on_startup.freeze() + app._on_startup.freeze() # noqa: SLF001 await app.startup() DescriptionXmlView(config).register(hass, app, app.router) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index d0da07da37c..9c5a9fbacd1 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -121,7 +121,7 @@ class WaterSourceType(TypedDict): number_energy_price: float | None # Price for energy ($/m³) -SourceType = ( +type SourceType = ( GridSourceType | SolarSourceType | BatterySourceType diff --git a/homeassistant/components/energy/types.py b/homeassistant/components/energy/types.py index d52a15a60c8..96b122da839 100644 --- a/homeassistant/components/energy/types.py +++ b/homeassistant/components/energy/types.py @@ -14,8 +14,8 @@ class SolarForecastType(TypedDict): wh_hours: dict[str, float | int] -GetSolarForecastType = Callable[ - [HomeAssistant, str], Awaitable["SolarForecastType | None"] +type GetSolarForecastType = Callable[ + [HomeAssistant, str], Awaitable[SolarForecastType | None] ] diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 2d34f606653..cfacbe48b97 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -20,7 +20,7 @@ from . import data from .const import DOMAIN ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) -ENERGY_USAGE_UNITS = { +ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { sensor.SensorDeviceClass.ENERGY: ( UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.KILO_WATT_HOUR, @@ -38,7 +38,7 @@ GAS_USAGE_DEVICE_CLASSES = ( sensor.SensorDeviceClass.ENERGY, sensor.SensorDeviceClass.GAS, ) -GAS_USAGE_UNITS = { +GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { sensor.SensorDeviceClass.ENERGY: ( UnitOfEnergy.GIGA_JOULE, UnitOfEnergy.KILO_WATT_HOUR, @@ -58,7 +58,7 @@ GAS_PRICE_UNITS = tuple( GAS_UNIT_ERROR = "entity_unexpected_unit_gas" GAS_PRICE_UNIT_ERROR = "entity_unexpected_unit_gas_price" WATER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.WATER,) -WATER_USAGE_UNITS = { +WATER_USAGE_UNITS: dict[str, tuple[UnitOfVolume, ...]] = { sensor.SensorDeviceClass.WATER: ( UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, @@ -360,12 +360,14 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: source_result, ) ) - elif flow.get("entity_energy_price") is not None: + elif ( + entity_energy_price := flow.get("entity_energy_price") + ) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, hass, - flow["entity_energy_price"], + entity_energy_price, source_result, ENERGY_PRICE_UNITS, ENERGY_PRICE_UNIT_ERROR, @@ -411,12 +413,14 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: source_result, ) ) - elif flow.get("entity_energy_price") is not None: + elif ( + entity_energy_price := flow.get("entity_energy_price") + ) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, hass, - flow["entity_energy_price"], + entity_energy_price, source_result, ENERGY_PRICE_UNITS, ENERGY_PRICE_UNIT_ERROR, @@ -462,12 +466,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: source_result, ) ) - elif source.get("entity_energy_price") is not None: + elif (entity_energy_price := source.get("entity_energy_price")) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, hass, - source["entity_energy_price"], + entity_energy_price, source_result, GAS_PRICE_UNITS, GAS_PRICE_UNIT_ERROR, @@ -513,12 +517,12 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: source_result, ) ) - elif source.get("entity_energy_price") is not None: + elif (entity_energy_price := source.get("entity_energy_price")) is not None: validate_calls.append( functools.partial( _async_validate_price_entity, hass, - source["entity_energy_price"], + entity_energy_price, source_result, WATER_PRICE_UNITS, WATER_PRICE_UNIT_ERROR, diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 2b5b71d3e2f..5f48a99133d 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -4,11 +4,10 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from datetime import timedelta import functools from itertools import chain -from types import ModuleType from typing import Any, cast import voluptuous as vol @@ -34,13 +33,13 @@ from .data import ( from .types import EnergyPlatform, GetSolarForecastType, SolarForecastType from .validate import async_validate -EnergyWebSocketCommandHandler = Callable[ - [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], +type EnergyWebSocketCommandHandler = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any], EnergyManager], None, ] -AsyncEnergyWebSocketCommandHandler = Callable[ - [HomeAssistant, websocket_api.ActiveConnection, "dict[str, Any]", "EnergyManager"], - Awaitable[None], +type AsyncEnergyWebSocketCommandHandler = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any], EnergyManager], + Coroutine[Any, Any, None], ] @@ -64,13 +63,15 @@ async def async_get_energy_platforms( @callback def _process_energy_platform( - hass: HomeAssistant, domain: str, platform: ModuleType + hass: HomeAssistant, + domain: str, + platform: EnergyPlatform, ) -> None: """Process energy platforms.""" if not hasattr(platform, "async_get_solar_forecast"): return - platforms[domain] = cast(EnergyPlatform, platform).async_get_solar_forecast + platforms[domain] = platform.async_get_solar_forecast await async_process_integration_platforms( hass, DOMAIN, _process_energy_platform, wait_for_platforms=True @@ -80,11 +81,10 @@ async def async_get_energy_platforms( def _ws_with_manager( - func: Any, -) -> websocket_api.WebSocketCommandHandler: + func: AsyncEnergyWebSocketCommandHandler | EnergyWebSocketCommandHandler, +) -> websocket_api.AsyncWebSocketCommandHandler: """Decorate a function to pass in a manager.""" - @websocket_api.async_response @functools.wraps(func) async def with_manager( hass: HomeAssistant, @@ -106,12 +106,13 @@ def _ws_with_manager( vol.Required("type"): "energy/get_prefs", } ) +@websocket_api.async_response @_ws_with_manager @callback def ws_get_prefs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], manager: EnergyManager, ) -> None: """Handle get prefs command.""" @@ -130,11 +131,12 @@ def ws_get_prefs( vol.Optional("device_consumption"): [DEVICE_CONSUMPTION_SCHEMA], } ) +@websocket_api.async_response @_ws_with_manager async def ws_save_prefs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, - msg: dict, + msg: dict[str, Any], manager: EnergyManager, ) -> None: """Handle get prefs command.""" @@ -186,6 +188,7 @@ async def ws_validate( vol.Required("type"): "energy/solar_forecast", } ) +@websocket_api.async_response @_ws_with_manager async def ws_solar_forecast( hass: HomeAssistant, diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py index 241ca7444fb..4e4f8bdb687 100644 --- a/homeassistant/components/enigma2/__init__.py +++ b/homeassistant/components/enigma2/__init__.py @@ -16,12 +16,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import DOMAIN +type Enigma2ConfigEntry = ConfigEntry[OpenWebIfDevice] PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Enigma2ConfigEntry) -> bool: """Set up Enigma2 from a config entry.""" base_url = URL.build( scheme="http" if not entry.data[CONF_SSL] else "https", @@ -35,14 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, verify_ssl=entry.data[CONF_VERIFY_SSL], base_url=base_url ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = OpenWebIfDevice(session) + entry.runtime_data = OpenWebIfDevice(session) 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py index ac57bd9d0fa..0d640d0a478 100644 --- a/homeassistant/components/enigma2/config_flow.py +++ b/homeassistant/components/enigma2/config_flow.py @@ -1,6 +1,6 @@ """Config flow for Enigma2.""" -from typing import Any +from typing import Any, cast from aiohttp.client_exceptions import ClientError from openwebif.api import OpenWebIfDevice @@ -8,7 +8,12 @@ from openwebif.error import InvalidAuthError import voluptuous as vol from yarl import URL -from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_USER, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -17,10 +22,15 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, callback from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaFlowFormStep, + SchemaOptionsFlowHandler, +) from .const import ( CONF_DEEP_STANDBY, @@ -55,6 +65,31 @@ CONFIG_SCHEMA = vol.Schema( ) +async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get the options schema.""" + entry = cast(SchemaOptionsFlowHandler, handler.parent_handler).config_entry + device: OpenWebIfDevice = entry.runtime_data + bouquets = [b[1] for b in (await device.get_all_bouquets())["bouquets"]] + + return vol.Schema( + { + vol.Optional(CONF_DEEP_STANDBY): selector.BooleanSelector(), + vol.Optional(CONF_SOURCE_BOUQUET): selector.SelectSelector( + selector.SelectSelectorConfig( + options=bouquets, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_USE_CHANNEL_ICON): selector.BooleanSelector(), + } + ) + + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(get_options_schema), +} + + class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Enigma2.""" @@ -95,7 +130,7 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): errors = {"base": "invalid_auth"} except ClientError: errors = {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors = {"base": "unknown"} else: await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"]) @@ -163,3 +198,9 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry( data=data, title=data[CONF_HOST], options=options ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: + """Get the options flow for this handler.""" + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 037d82cd6c0..8e090e7cecb 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -32,6 +32,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import Enigma2ConfigEntry from .const import ( CONF_DEEP_STANDBY, CONF_MAC_ADDRESS, @@ -102,12 +103,12 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: Enigma2ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Enigma2 media player platform.""" - device: OpenWebIfDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data about = await device.get_about() device.mac_address = about["info"]["ifaces"][0]["mac"] entity = Enigma2Device(entry, device, about) @@ -141,10 +142,10 @@ class Enigma2Device(MediaPlayerEntity): self._device: OpenWebIfDevice = device self._entry = entry - self._attr_unique_id = device.mac_address + self._attr_unique_id = device.mac_address or entry.entry_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.mac_address)}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=about["info"]["brand"], model=about["info"]["model"], configuration_url=device.base, diff --git a/homeassistant/components/enigma2/strings.json b/homeassistant/components/enigma2/strings.json index ddeb59ea6d5..f74806b60a2 100644 --- a/homeassistant/components/enigma2/strings.json +++ b/homeassistant/components/enigma2/strings.json @@ -26,6 +26,20 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "options": { + "step": { + "init": { + "data": { + "deep_standby": "Turn off to Deep Standby", + "source_bouquet": "Bouquet to use as media source", + "use_channel_icon": "Show channel icon as media image" + }, + "data_description": { + "deep_standby": "Turn off the device to Deep Standby (shutdown) instead of standby mode." + } + } + } + }, "issues": { "deprecated_yaml_import_issue_unknown": { "title": "The Enigma2 YAML configuration import failed", diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 5f859d16142..e115f0c6ea8 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Mapping import logging +from types import MappingProxyType from typing import Any from awesomeversion import AwesomeVersion @@ -169,7 +170,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): except EnvoyError as e: errors["base"] = "cannot_connect" description_placeholders = {"reason": str(e)} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -213,3 +214,71 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders=description_placeholders, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Add reconfigure step to allow to manually reconfigure a config entry.""" + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + + suggested_values: dict[str, Any] | MappingProxyType[str, Any] = ( + user_input or entry.data + ) + + host: Any = suggested_values.get(CONF_HOST) + username: Any = suggested_values.get(CONF_USERNAME) + password: Any = suggested_values.get(CONF_PASSWORD) + + if user_input is not None: + try: + envoy = await validate_input( + self.hass, + host, + username, + password, + ) + except INVALID_AUTH_ERRORS as e: + errors["base"] = "invalid_auth" + description_placeholders = {"reason": str(e)} + except EnvoyError as e: + errors["base"] = "cannot_connect" + description_placeholders = {"reason": str(e)} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.unique_id != envoy.serial_number: + errors["base"] = "unexpected_envoy" + description_placeholders = { + "reason": f"target: {self.unique_id}, actual: {envoy.serial_number}" + } + else: + # If envoy exists in configuration update fields and exit + self._abort_if_unique_id_configured( + { + CONF_HOST: host, + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + error="reconfigure_successful", + ) + if not self.unique_id: + await self.async_set_unique_id(entry.unique_id) + + self.context["title_placeholders"] = { + CONF_SERIAL: self.unique_id, + CONF_HOST: host, + } + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), suggested_values + ), + description_placeholders=description_placeholders, + errors=errors, + ) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 597d326968d..b3c117556bf 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.20.1"], + "requirements": ["pyenphase==1.20.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 38bb18ad768..63c5879cfe8 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -89,6 +89,7 @@ async def async_setup_entry( envoy_data.tariff and envoy_data.tariff.storage_settings and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + and coordinator.envoy.supported_features & SupportedFeatures.ENPOWER ): entities.append( EnvoyStorageSettingsNumberEntity(coordinator, STORAGE_RESERVE_SOC_ENTITY) diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 98374d16394..0971c7b5715 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -144,6 +144,7 @@ async def async_setup_entry( envoy_data.tariff and envoy_data.tariff.storage_settings and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + and coordinator.envoy.supported_features & SupportedFeatures.ENPOWER ): entities.append( EnvoyStorageSettingsSelectEntity(coordinator, STORAGE_MODE_ENTITY) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 22112228a37..295aa1948f8 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -12,11 +12,23 @@ "data_description": { "host": "The hostname or IP address of your Enphase Envoy gateway." } + }, + "reconfigure": { + "description": "[%key:component::enphase_envoy::config::step::user::description%]", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "[%key:component::enphase_envoy::config::step::user::data_description::host%]" + } } }, "error": { "cannot_connect": "Cannot connect: {reason}", "invalid_auth": "Invalid authentication: {reason}", + "unexpected_envoy": "Unexpected Envoy: {reason}", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 6f47d057e81..0b6eadf6d13 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -2,18 +2,17 @@ from datetime import timedelta import logging -import xml.etree.ElementTree as et -from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc +from env_canada import ECAirQuality, ECRadar, ECWeather from homeassistant.config_entries import ConfigEntry 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_STATION, DOMAIN +from .coordinator import ECDataUpdateCoordinator DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) @@ -98,23 +97,3 @@ def device_info(config_entry: ConfigEntry) -> DeviceInfo: name=config_entry.title, configuration_url="https://weather.gc.ca/", ) - - -class ECDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching EC data.""" - - def __init__(self, hass, ec_data, name, update_interval): - """Initialize global EC data updater.""" - super().__init__( - hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval - ) - self.ec_data = ec_data - self.last_update_success = False - - async def _async_update_data(self): - """Fetch data from EC.""" - try: - await self.ec_data.update() - except (et.ParseError, ec_exc.UnknownStationId) as ex: - raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex - return self.ec_data diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index 369a419f2a6..a351bb0ef06 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -61,7 +61,7 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "bad_station_id" else: errors["base"] = "error_response" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py new file mode 100644 index 00000000000..e17c360e3fb --- /dev/null +++ b/homeassistant/components/environment_canada/coordinator.py @@ -0,0 +1,32 @@ +"""Coordinator for the Environment Canada (EC) component.""" + +import logging +import xml.etree.ElementTree as et + +from env_canada import ec_exc + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ECDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching EC data.""" + + def __init__(self, hass, ec_data, name, update_interval): + """Initialize global EC data updater.""" + super().__init__( + hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval + ) + self.ec_data = ec_data + self.last_update_success = False + + async def _async_update_data(self): + """Fetch data from EC.""" + try: + await self.ec_data.update() + except (et.ParseError, ec_exc.UnknownStationId) as ex: + raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex + return self.ec_data diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index f29c8177dfd..a0bdd5d4919 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.6.2"] + "requirements": ["env-canada==0.6.3"] } diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 119608bbb2a..d4bbe174f20 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -17,6 +17,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, @@ -116,8 +117,9 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): ): """Initialize the alarm panel.""" self._partition_number = partition_number - self._code = code self._panic_type = panic_type + self._alarm_control_panel_option_default_code = code + self._attr_code_format = CodeFormat.NUMBER _LOGGER.debug("Setting up alarm: %s", alarm_name) super().__init__(alarm_name, info, controller) @@ -141,13 +143,6 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): if partition is None or int(partition) == self._partition_number: self.async_write_ha_state() - @property - def code_format(self) -> CodeFormat | None: - """Regex for code format or None if no code is required.""" - if self._code: - return None - return CodeFormat.NUMBER - @property def state(self) -> str: """Return the state of the device.""" @@ -161,7 +156,9 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): state = STATE_ALARM_ARMED_AWAY elif self._info["status"]["armed_stay"]: state = STATE_ALARM_ARMED_HOME - elif self._info["status"]["exit_delay"] or self._info["status"]["entry_delay"]: + elif self._info["status"]["exit_delay"]: + state = STATE_ALARM_ARMING + elif self._info["status"]["entry_delay"]: state = STATE_ALARM_PENDING elif self._info["status"]["alpha"]: state = STATE_ALARM_DISARMED @@ -169,34 +166,15 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if code: - self.hass.data[DATA_EVL].disarm_partition(str(code), self._partition_number) - else: - self.hass.data[DATA_EVL].disarm_partition( - str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].disarm_partition(code, self._partition_number) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if code: - self.hass.data[DATA_EVL].arm_stay_partition( - str(code), self._partition_number - ) - else: - self.hass.data[DATA_EVL].arm_stay_partition( - str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].arm_stay_partition(code, self._partition_number) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if code: - self.hass.data[DATA_EVL].arm_away_partition( - str(code), self._partition_number - ) - else: - self.hass.data[DATA_EVL].arm_away_partition( - str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].arm_away_partition(code, self._partition_number) async def async_alarm_trigger(self, code: str | None = None) -> None: """Alarm trigger command. Will be used to trigger a panic alarm.""" @@ -204,9 +182,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - self.hass.data[DATA_EVL].arm_night_partition( - str(code) if code else str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].arm_night_partition(code, self._partition_number) @callback def async_alarm_keypress(self, keypress=None): diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index 093ebf77eba..0cf9f165aa2 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/envisalink", "iot_class": "local_push", "loggers": ["pyenvisalink"], - "requirements": ["pyenvisalink==4.6"] + "requirements": ["pyenvisalink==4.7"] } diff --git a/homeassistant/components/epic_games_store/config_flow.py b/homeassistant/components/epic_games_store/config_flow.py index 2ae86060ba2..9e65c93c334 100644 --- a/homeassistant/components/epic_games_store/config_flow.py +++ b/homeassistant/components/epic_games_store/config_flow.py @@ -82,7 +82,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await validate_input(self.hass, user_input) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/epic_games_store/helper.py b/homeassistant/components/epic_games_store/helper.py index 2510c7699e5..0eb6f0b0049 100644 --- a/homeassistant/components/epic_games_store/helper.py +++ b/homeassistant/components/epic_games_store/helper.py @@ -60,12 +60,12 @@ def get_game_url(raw_game_data: dict[str, Any], language: str) -> str: url_slug: str | None = None try: url_slug = raw_game_data["offerMappings"][0]["pageSlug"] - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 with contextlib.suppress(Exception): url_slug = raw_game_data["catalogNs"]["mappings"][0]["pageSlug"] if not url_slug: - url_slug = raw_game_data["urlSlug"] + url_slug = raw_game_data["productSlug"] return f"https://store.epicgames.com/{language}/{url_bundle_or_product}/{url_slug}" diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index a962b94b5e0..a901e9df216 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -36,14 +36,14 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import ( - DeviceInfo, - async_get as async_get_device_registry, +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, + entity_registry as er, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE @@ -110,13 +110,13 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity): return False if uid := await self._projector.get_serial_number(): self.hass.config_entries.async_update_entry(self._entry, unique_id=uid) - ent_reg = async_get_entity_registry(self.hass) + ent_reg = er.async_get(self.hass) old_entity_id = ent_reg.async_get_entity_id( "media_player", DOMAIN, self._entry.entry_id ) if old_entity_id is not None: ent_reg.async_update_entity(old_entity_id, new_unique_id=uid) - dev_reg = async_get_device_registry(self.hass) + dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device({(DOMAIN, self._entry.entry_id)}) if device is not None: dev_reg.async_update_device(device.id, new_identifiers={(DOMAIN, uid)}) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 326655d4e59..7b8ccb6c990 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -19,12 +19,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.device_registry import ( - CONNECTION_BLUETOOTH, - DeviceInfo, - async_get, - format_mac, -) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -88,7 +84,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): """Initialize the climate entity.""" super().__init__(eq3_config, thermostat) - self._attr_unique_id = format_mac(eq3_config.mac_address) + self._attr_unique_id = dr.format_mac(eq3_config.mac_address) self._attr_device_info = DeviceInfo( name=slugify(self._eq3_config.mac_address), manufacturer=MANUFACTURER, @@ -158,7 +154,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): def _async_on_device_updated(self) -> None: """Handle updated device data from the thermostat.""" - device_registry = async_get(self.hass) + device_registry = dr.async_get(self.hass) if device := device_registry.async_get_device( connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, ): diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 6c4a59962ff..bf5489531bc 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -23,5 +23,5 @@ "iot_class": "local_polling", "loggers": ["eq3btsmart"], "quality_scale": "silver", - "requirements": ["eq3btsmart==1.1.6", "bleak-esphome==1.0.0"] + "requirements": ["eq3btsmart==1.1.8", "bleak-esphome==1.0.0"] } diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 67e94121e1d..d1948df0690 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -6,7 +6,7 @@ from collections import OrderedDict from collections.abc import Mapping import json import logging -from typing import Any +from typing import Any, cast from aioesphomeapi import ( APIClient, @@ -31,6 +31,8 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo +from homeassistant.util.json import json_loads_object from .const import ( CONF_ALLOW_SERVICE_CALLS, @@ -250,6 +252,42 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle MQTT discovery.""" + device_info = json_loads_object(discovery_info.payload) + if "mac" not in device_info: + return self.async_abort(reason="mqtt_missing_mac") + + # there will be no port if the API is not enabled + if "port" not in device_info: + return self.async_abort(reason="mqtt_missing_api") + + if "ip" not in device_info: + return self.async_abort(reason="mqtt_missing_ip") + + # mac address is lowercase and without :, normalize it + unformatted_mac = cast(str, device_info["mac"]) + mac_address = format_mac(unformatted_mac) + + device_name = cast(str, device_info["name"]) + + self._device_name = device_name + self._name = cast(str, device_info.get("friendly_name", device_name)) + self._host = cast(str, device_info["ip"]) + self._port = cast(int, device_info["port"]) + + self._noise_required = "api_encryption" in device_info + + # Check if already configured + await self.async_set_unique_id(mac_address) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._host, CONF_PORT: self._port} + ) + + return await self.async_step_discovery_confirm() + async def async_step_dhcp( self, discovery_info: dhcp.DhcpServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/esphome/coordinator.py b/homeassistant/components/esphome/coordinator.py new file mode 100644 index 00000000000..284e17fd183 --- /dev/null +++ b/homeassistant/components/esphome/coordinator.py @@ -0,0 +1,57 @@ +"""Coordinator to interact with an ESPHome dashboard.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import aiohttp +from awesomeversion import AwesomeVersion +from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") + + +class ESPHomeDashboardCoordinator(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): + """Class to interact with the ESPHome dashboard.""" + + def __init__( + self, + hass: HomeAssistant, + addon_slug: str, + url: str, + session: aiohttp.ClientSession, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name="ESPHome Dashboard", + update_interval=timedelta(minutes=5), + always_update=False, + ) + self.addon_slug = addon_slug + self.url = url + self.api = ESPHomeDashboardAPI(url, session) + self.supports_update: bool | None = None + + async def _async_update_data(self) -> dict: + """Fetch device data.""" + devices = await self.api.get_devices() + configured_devices = devices["configured"] + + if ( + self.supports_update is None + and configured_devices + and (current_version := configured_devices[0].get("current_version")) + ): + self.supports_update = ( + AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE + ) + + return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 54a593fe0cc..b2d0487df9c 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -1,25 +1,20 @@ -"""Files to interact with a the ESPHome dashboard.""" +"""Files to interact with an ESPHome dashboard.""" from __future__ import annotations import asyncio -from datetime import timedelta import logging from typing import Any -import aiohttp -from awesomeversion import AwesomeVersion -from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI - from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import ESPHomeDashboardCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,8 +24,6 @@ KEY_DASHBOARD_MANAGER = "esphome_dashboard_manager" STORAGE_KEY = "esphome.dashboard" STORAGE_VERSION = 1 -MIN_VERSION_SUPPORTS_UPDATE = AwesomeVersion("2023.1.0") - async def async_setup(hass: HomeAssistant) -> None: """Set up the ESPHome dashboard.""" @@ -58,7 +51,7 @@ class ESPHomeDashboardManager: self._hass = hass self._store: Store[dict[str, Any]] = Store(hass, STORAGE_VERSION, STORAGE_KEY) self._data: dict[str, Any] | None = None - self._current_dashboard: ESPHomeDashboard | None = None + self._current_dashboard: ESPHomeDashboardCoordinator | None = None self._cancel_shutdown: CALLBACK_TYPE | None = None async def async_setup(self) -> None: @@ -70,7 +63,7 @@ class ESPHomeDashboardManager: ) @callback - def async_get(self) -> ESPHomeDashboard | None: + def async_get(self) -> ESPHomeDashboardCoordinator | None: """Get the current dashboard.""" return self._current_dashboard @@ -92,7 +85,7 @@ class ESPHomeDashboardManager: self._cancel_shutdown = None self._current_dashboard = None - dashboard = ESPHomeDashboard( + dashboard = ESPHomeDashboardCoordinator( hass, addon_slug, url, async_get_clientsession(hass) ) await dashboard.async_request_refresh() @@ -138,7 +131,7 @@ class ESPHomeDashboardManager: @callback -def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboard | None: +def async_get_dashboard(hass: HomeAssistant) -> ESPHomeDashboardCoordinator | None: """Get an instance of the dashboard if set. This is only safe to call after `async_setup` has been completed. @@ -157,43 +150,3 @@ async def async_set_dashboard_info( """Set the dashboard info.""" manager = await async_get_or_create_dashboard_manager(hass) await manager.async_set_dashboard_info(addon_slug, host, port) - - -class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): # pylint: disable=hass-enforce-coordinator-module - """Class to interact with the ESPHome dashboard.""" - - def __init__( - self, - hass: HomeAssistant, - addon_slug: str, - url: str, - session: aiohttp.ClientSession, - ) -> None: - """Initialize.""" - super().__init__( - hass, - _LOGGER, - name="ESPHome Dashboard", - update_interval=timedelta(minutes=5), - always_update=False, - ) - self.addon_slug = addon_slug - self.url = url - self.api = ESPHomeDashboardAPI(url, session) - self.supports_update: bool | None = None - - async def _async_update_data(self) -> dict: - """Fetch device data.""" - devices = await self.api.get_devices() - configured_devices = devices["configured"] - - if ( - self.supports_update is None - and configured_devices - and (current_version := configured_devices[0].get("current_version")) - ): - self.supports_update = ( - AwesomeVersion(current_version) > MIN_VERSION_SUPPORTS_UPDATE - ) - - return {dev["name"]: dev for dev in configured_devices} diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 4f32f62ee62..374c22eef72 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -130,7 +130,6 @@ def esphome_state_property( @functools.wraps(func) def _wrapper(self: _EntityT) -> _R | None: - # pylint: disable-next=protected-access if not self._has_state: return None val = func(self) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 41b18c9b88c..7a491d1863b 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -38,6 +38,7 @@ from aioesphomeapi import ( TextInfo, TextSensorInfo, TimeInfo, + UpdateInfo, UserService, ValveInfo, build_unique_id, @@ -82,6 +83,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, TimeInfo: Platform.TIME, + UpdateInfo: Platform.UPDATE, ValveInfo: Platform.VALVE, } @@ -244,7 +246,10 @@ class RuntimeEntryData: callback_(static_info) async def _ensure_platforms_loaded( - self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[Platform] + self, + hass: HomeAssistant, + entry: ConfigEntry, + platforms: set[Platform], ) -> None: async with self.platform_load_lock: if needed := platforms - self.loaded_platforms: @@ -252,7 +257,11 @@ class RuntimeEntryData: self.loaded_platforms |= needed async def async_update_static_infos( - self, hass: HomeAssistant, entry: ConfigEntry, infos: list[EntityInfo], mac: str + self, + hass: HomeAssistant, + entry: ConfigEntry, + infos: list[EntityInfo], + mac: str, ) -> None: """Distribute an update of static infos to all platforms.""" # First, load all platforms @@ -374,7 +383,7 @@ class RuntimeEntryData: if subscription := self.state_subscriptions.get(subscription_key): try: subscription() - except Exception: # pylint: disable=broad-except + except Exception: # If we allow this exception to raise it will # make it all the way to data_received in aioesphomeapi # which will cause the connection to be closed. diff --git a/homeassistant/components/esphome/enum_mapper.py b/homeassistant/components/esphome/enum_mapper.py index 0e59cde8a7e..f59af1a8a44 100644 --- a/homeassistant/components/esphome/enum_mapper.py +++ b/homeassistant/components/esphome/enum_mapper.py @@ -1,14 +1,11 @@ """Helper class to convert between Home Assistant and ESPHome enum values.""" -from typing import Generic, TypeVar, overload +from typing import overload from aioesphomeapi import APIIntEnum -_EnumT = TypeVar("_EnumT", bound=APIIntEnum) -_ValT = TypeVar("_ValT") - -class EsphomeEnumMapper(Generic[_EnumT, _ValT]): +class EsphomeEnumMapper[_EnumT: APIIntEnum, _ValT]: """Helper class to convert between hass and esphome enum values.""" def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index ef56f3a2164..f191c36c574 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -27,6 +27,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.components import tag, zeroconf +from homeassistant.components.intent import async_register_timer_handler from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, @@ -77,6 +78,7 @@ from .voice_assistant import ( VoiceAssistantAPIPipeline, VoiceAssistantPipeline, VoiceAssistantUDPPipeline, + handle_timer_event, ) _LOGGER = logging.getLogger(__name__) @@ -517,6 +519,12 @@ class ESPHomeManager: handle_stop=self._handle_pipeline_stop, ) ) + if flags & VoiceAssistantFeature.TIMERS: + entry_data.disconnect_callbacks.add( + async_register_timer_handler( + hass, self.device_id, partial(handle_timer_event, cli) + ) + ) cli.subscribe_states(entry_data.async_update_state) cli.subscribe_service_calls(self.async_on_service_call) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index cde44fa3231..de855e15d4c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, - "dependencies": ["assist_pipeline", "bluetooth"], + "dependencies": ["assist_pipeline", "bluetooth", "intent"], "dhcp": [ { "registered_devices": true @@ -14,8 +14,9 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], + "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==24.3.0", + "aioesphomeapi==24.6.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index c2bfdc5850d..8caad0f939d 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -14,6 +14,7 @@ from aioesphomeapi import ( from homeassistant.components import media_source from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEntity, @@ -77,6 +78,7 @@ class EsphomeMediaPlayer( | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE ) if self._static_info.supports_pause: flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY @@ -112,10 +114,10 @@ class EsphomeMediaPlayer( media_id = sourced_media.url media_id = async_process_play_media_url(self.hass, media_id) + announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE) self._client.media_player_command( - self._key, - media_url=media_id, + self._key, media_url=media_id, announcement=announcement ) async def async_browse_media( diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index e38e8e1a2c4..205b0b10744 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -5,7 +5,10 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "mdns_missing_mac": "Missing MAC address in MDNS properties.", - "service_received": "Service received" + "service_received": "Service received", + "mqtt_missing_mac": "Missing MAC address in MQTT properties.", + "mqtt_missing_api": "Missing API port in MQTT properties.", + "mqtt_missing_ip": "Missing IP address in MQTT properties." }, "error": { "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index b16a6e798b7..cb3d36dab9d 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -5,7 +5,12 @@ from __future__ import annotations import asyncio from typing import Any -from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo +from aioesphomeapi import ( + DeviceInfo as ESPHomeDeviceInfo, + EntityInfo, + UpdateInfo, + UpdateState, +) from homeassistant.components.update import ( UpdateDeviceClass, @@ -19,9 +24,17 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.enum import try_parse_enum -from .dashboard import ESPHomeDashboard, async_get_dashboard +from .coordinator import ESPHomeDashboardCoordinator +from .dashboard import async_get_dashboard from .domain_data import DomainData +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) from .entry_data import RuntimeEntryData KEY_UPDATE_LOCK = "esphome_update_lock" @@ -35,6 +48,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ESPHome update based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=UpdateInfo, + entity_type=ESPHomeUpdateEntity, + state_type=UpdateState, + ) + if (dashboard := async_get_dashboard(hass)) is None: return entry_data = DomainData.get(hass).get_entry_data(entry) @@ -53,7 +75,7 @@ async def async_setup_entry( unsub() unsubs.clear() - async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)]) + async_add_entities([ESPHomeDashboardUpdateEntity(entry_data, dashboard)]) if entry_data.available and dashboard.last_update_success: _async_setup_update_entity() @@ -65,7 +87,9 @@ async def async_setup_entry( ] -class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): +class ESPHomeDashboardUpdateEntity( + CoordinatorEntity[ESPHomeDashboardCoordinator], UpdateEntity +): """Defines an ESPHome update entity.""" _attr_has_entity_name = True @@ -75,7 +99,7 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): _attr_release_url = "https://esphome.io/changelog/" def __init__( - self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard + self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboardCoordinator ) -> None: """Initialize the update entity.""" super().__init__(coordinator=coordinator) @@ -178,3 +202,65 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): ) finally: await self.coordinator.async_request_refresh() + + +class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): + """A update implementation for esphome.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_device_class = try_parse_enum( + UpdateDeviceClass, static_info.device_class + ) + + @property + @esphome_state_property + def installed_version(self) -> str | None: + """Return the installed version.""" + return self._state.current_version + + @property + @esphome_state_property + def in_progress(self) -> bool | int | None: + """Return if the update is in progress.""" + if self._state.has_progress: + return int(self._state.progress) + return self._state.in_progress + + @property + @esphome_state_property + def latest_version(self) -> str | None: + """Return the latest version.""" + return self._state.latest_version + + @property + @esphome_state_property + def release_summary(self) -> str | None: + """Return the release summary.""" + return self._state.release_summary + + @property + @esphome_state_property + def release_url(self) -> str | None: + """Return the release URL.""" + return self._state.release_url + + @property + @esphome_state_property + def title(self) -> str | None: + """Return the title of the update.""" + return self._state.title + + @convert_api_error_ha_error + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Update the current value.""" + self._client.update_command(key=self._key, install=True) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index f9f753389ed..10358d871ca 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -16,6 +16,7 @@ from aioesphomeapi import ( VoiceAssistantCommandFlag, VoiceAssistantEventType, VoiceAssistantFeature, + VoiceAssistantTimerEventType, ) from homeassistant.components import stt, tts @@ -33,6 +34,7 @@ from homeassistant.components.assist_pipeline.error import ( WakeWordDetectionAborted, WakeWordDetectionError, ) +from homeassistant.components.intent.timers import TimerEventType, TimerInfo from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -65,6 +67,17 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ } ) +_TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventType] = ( + EsphomeEnumMapper( + { + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: TimerEventType.STARTED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: TimerEventType.UPDATED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: TimerEventType.CANCELLED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: TimerEventType.FINISHED, + } + ) +) + class VoiceAssistantPipeline: """Base abstract pipeline class.""" @@ -237,12 +250,12 @@ class VoiceAssistantPipeline: await self._tts_done.wait() _LOGGER.debug("Pipeline finished") - except PipelineNotFound: + except PipelineNotFound as e: self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, { - "code": "pipeline not found", - "message": "Selected pipeline not found", + "code": e.code, + "message": e.message, }, ) _LOGGER.warning("Pipeline not found") @@ -438,3 +451,23 @@ class VoiceAssistantAPIPipeline(VoiceAssistantPipeline): self.started = False self.stop_requested = True + + +def handle_timer_event( + api_client: APIClient, event_type: TimerEventType, timer_info: TimerInfo +) -> None: + """Handle timer events.""" + try: + native_event_type = _TIMER_EVENT_TYPES.from_hass(event_type) + except KeyError: + _LOGGER.debug("Received unknown timer event type: %s", event_type) + return + + api_client.send_voice_assistant_timer_event( + native_event_type, + timer_info.id, + timer_info.name, + timer_info.seconds, + timer_info.seconds_left, + timer_info.is_active, + ) diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index fe91e58d839..afc6fecd9a4 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -2,12 +2,6 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -import logging -from typing import cast - -from aiohttp import ContentTypeError import pyevilgenius from homeassistant.config_entries import ConfigEntry @@ -15,12 +9,10 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import EvilGeniusUpdateCoordinator PLATFORMS = [Platform.LIGHT] @@ -51,56 +43,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): # pylint: disable=hass-enforce-coordinator-module - """Update coordinator for Evil Genius data.""" - - info: dict - - product: dict | None - - def __init__( - self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice - ) -> None: - """Initialize the data update coordinator.""" - self.client = client - super().__init__( - hass, - logging.getLogger(__name__), - name=name, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - - @property - def device_name(self) -> str: - """Return the device name.""" - return cast(str, self.data["name"]["value"]) - - @property - def product_name(self) -> str | None: - """Return the product name.""" - if self.product is None: - return None - - return cast(str, self.product["productName"]) - - async def _async_update_data(self) -> dict: - """Update Evil Genius data.""" - if not hasattr(self, "info"): - async with asyncio.timeout(5): - self.info = await self.client.get_info() - - if not hasattr(self, "product"): - async with asyncio.timeout(5): - try: - self.product = await self.client.get_product() - except ContentTypeError: - # Older versions of the API don't support this - self.product = None - - async with asyncio.timeout(5): - return cast(dict, await self.client.get_all()) - - class EvilGeniusEntity(CoordinatorEntity[EvilGeniusUpdateCoordinator]): """Base entity for Evil Genius.""" diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py index 283b3d36beb..67bbd7faf54 100644 --- a/homeassistant/components/evil_genius_labs/config_flow.py +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -67,7 +67,7 @@ class EvilGeniusLabsConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "timeout" except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/evil_genius_labs/coordinator.py b/homeassistant/components/evil_genius_labs/coordinator.py new file mode 100644 index 00000000000..9f0f0df02af --- /dev/null +++ b/homeassistant/components/evil_genius_labs/coordinator.py @@ -0,0 +1,66 @@ +"""Coordinator for the Evil Genius Labs integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import cast + +from aiohttp import ContentTypeError +import pyevilgenius + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +UPDATE_INTERVAL = 10 + + +class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): + """Update coordinator for Evil Genius data.""" + + info: dict + + product: dict | None + + def __init__( + self, hass: HomeAssistant, name: str, client: pyevilgenius.EvilGeniusDevice + ) -> None: + """Initialize the data update coordinator.""" + self.client = client + super().__init__( + hass, + logging.getLogger(__name__), + name=name, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + @property + def device_name(self) -> str: + """Return the device name.""" + return cast(str, self.data["name"]["value"]) + + @property + def product_name(self) -> str | None: + """Return the product name.""" + if self.product is None: + return None + + return cast(str, self.product["productName"]) + + async def _async_update_data(self) -> dict: + """Update Evil Genius data.""" + if not hasattr(self, "info"): + async with asyncio.timeout(5): + self.info = await self.client.get_info() + + if not hasattr(self, "product"): + async with asyncio.timeout(5): + try: + self.product = await self.client.get_product() + except ContentTypeError: + # Older versions of the API don't support this + self.product = None + + async with asyncio.timeout(5): + return cast(dict, await self.client.get_all()) diff --git a/homeassistant/components/evil_genius_labs/diagnostics.py b/homeassistant/components/evil_genius_labs/diagnostics.py index 2249e1269b0..c9c79acc1bb 100644 --- a/homeassistant/components/evil_genius_labs/diagnostics.py +++ b/homeassistant/components/evil_genius_labs/diagnostics.py @@ -8,8 +8,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import EvilGeniusUpdateCoordinator from .const import DOMAIN +from .coordinator import EvilGeniusUpdateCoordinator TO_REDACT = {"wiFiSsidDefault", "wiFiSSID"} diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index c64a22d28cd..89bdcae9ef7 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -11,8 +11,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EvilGeniusEntity, EvilGeniusUpdateCoordinator +from . import EvilGeniusEntity from .const import DOMAIN +from .coordinator import EvilGeniusUpdateCoordinator from .util import update_when_done HA_NO_EFFECT = "None" diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py index db07cf46918..f3c86f2666f 100644 --- a/homeassistant/components/evil_genius_labs/util.py +++ b/homeassistant/components/evil_genius_labs/util.py @@ -4,16 +4,12 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from . import EvilGeniusEntity -_EvilGeniusEntityT = TypeVar("_EvilGeniusEntityT", bound=EvilGeniusEntity) -_R = TypeVar("_R") -_P = ParamSpec("_P") - -def update_when_done( +def update_when_done[_EvilGeniusEntityT: EvilGeniusEntity, **_P, _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 4564e863e42..13673caebb3 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -1,19 +1,17 @@ -"""Support for (EMEA/EU-based) Honeywell TCC climate systems. +"""Support for (EMEA/EU-based) Honeywell TCC systems. -Such systems include evohome, Round Thermostat, and others. +Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and +others. """ from __future__ import annotations -from collections.abc import Awaitable -from datetime import datetime, timedelta -from http import HTTPStatus +from datetime import datetime, timedelta, timezone import logging -import re -from typing import Any +from typing import Any, Final import evohomeasync as ev1 -from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP +from evohomeasync.schema import SZ_SESSION_ID import evohomeasync2 as evo from evohomeasync2.schema.const import ( SZ_ALLOWED_SYSTEM_MODES, @@ -52,27 +50,41 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -from .const import DOMAIN, GWS, STORAGE_KEY, STORAGE_VER, TCS, UTC_OFFSET +from .const import ( + ACCESS_TOKEN_EXPIRES, + ATTR_DURATION_DAYS, + ATTR_DURATION_HOURS, + ATTR_DURATION_UNTIL, + ATTR_SYSTEM_MODE, + ATTR_ZONE_TEMP, + CONF_LOCATION_IDX, + DOMAIN, + GWS, + SCAN_INTERVAL_DEFAULT, + SCAN_INTERVAL_MINIMUM, + STORAGE_KEY, + STORAGE_VER, + TCS, + USER_DATA, + EvoService, +) +from .coordinator import EvoBroker +from .helpers import ( + convert_dict, + convert_until, + dt_aware_to_naive, + handle_evo_exception, +) _LOGGER = logging.getLogger(__name__) -ACCESS_TOKEN = "access_token" -ACCESS_TOKEN_EXPIRES = "access_token_expires" -REFRESH_TOKEN = "refresh_token" -USER_DATA = "user_data" - -CONF_LOCATION_IDX = "location_idx" - -SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) -SCAN_INTERVAL_MINIMUM = timedelta(seconds=60) - -CONFIG_SCHEMA = vol.Schema( +CONFIG_SCHEMA: Final = vol.Schema( { DOMAIN: vol.Schema( { @@ -88,22 +100,12 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -ATTR_SYSTEM_MODE = "mode" -ATTR_DURATION_DAYS = "period" -ATTR_DURATION_HOURS = "duration" +# system mode schemas are built dynamically when the services are regiatered -ATTR_ZONE_TEMP = "setpoint" -ATTR_DURATION_UNTIL = "duration" - -SVC_REFRESH_SYSTEM = "refresh_system" -SVC_SET_SYSTEM_MODE = "set_system_mode" -SVC_RESET_SYSTEM = "reset_system" -SVC_SET_ZONE_OVERRIDE = "set_zone_override" -SVC_RESET_ZONE_OVERRIDE = "clear_zone_override" - - -RESET_ZONE_OVERRIDE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) -SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( +RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): cv.entity_id} +) +SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.Required(ATTR_ZONE_TEMP): vol.All( @@ -114,99 +116,6 @@ SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( ), } ) -# system mode schemas are built dynamically, below - - -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: 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) - - -def convert_until(status_dict: dict, until_key: str) -> None: - """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" - if until_key in status_dict and ( # only present for certain modes - dt_utc_naive := dt_util.parse_datetime(status_dict[until_key]) - ): - status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() - - -def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: - """Recursively convert a dict's keys to snake_case.""" - - def convert_key(key: str) -> str: - """Convert a string to snake_case.""" - string = re.sub(r"[\-\.\s]", "_", str(key)) - return ( - (string[0]).lower() - + re.sub( - r"[A-Z]", - lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] - string[1:], - ) - ) - - return { - (convert_key(k) if isinstance(k, str) else k): ( - convert_dict(v) if isinstance(v, dict) else v - ) - for k, v in dictionary.items() - } - - -def _handle_exception(err: evo.RequestFailed) -> None: - """Return False if the exception can't be ignored.""" - - try: - raise err - - except evo.AuthenticationFailed: - _LOGGER.error( - ( - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: %s" - ), - err, - ) - - except evo.RequestFailed: - if err.status is None: - _LOGGER.warning( - ( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: %s" - ), - err, - ) - - elif err.status == HTTPStatus.SERVICE_UNAVAILABLE: - _LOGGER.warning( - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page" - ) - - elif err.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.warning( - ( - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the %s" - ), - CONF_SCAN_INTERVAL, - ) - - else: - raise # we don't expect/handle any other Exceptions async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -225,7 +134,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and ( expires := dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES]) ): - tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(expires) + tokens[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires) user_data = tokens.pop(USER_DATA, {}) return (tokens, user_data) @@ -243,11 +152,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await client_v2.login() except evo.AuthenticationFailed as err: - _handle_exception(err) + handle_evo_exception(err) return False finally: config[DOMAIN][CONF_PASSWORD] = "REDACTED" + assert isinstance(client_v2.installation_info, list) # mypy + loc_idx = config[DOMAIN][CONF_LOCATION_IDX] try: loc_config = client_v2.installation_info[loc_idx] @@ -274,7 +185,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } _config = { SZ_LOCATION_INFO: loc_info, - GWS: [{SZ_GATEWAY_INFO: gwy_info, TCS: loc_config[GWS][0][TCS]}], + GWS: [{SZ_GATEWAY_INFO: gwy_info}], } _LOGGER.debug("Config = %s", _config) @@ -358,14 +269,14 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: async_dispatcher_send(hass, DOMAIN, payload) - hass.services.async_register(DOMAIN, SVC_REFRESH_SYSTEM, force_refresh) + hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) # Enumerate which operating modes are supported by this system modes = broker.config[SZ_ALLOWED_SYSTEM_MODES] # Not all systems support "AutoWithReset": register this handler only if required 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) + hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode) system_mode_schemas = [] modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET] @@ -409,7 +320,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: if system_mode_schemas: hass.services.async_register( DOMAIN, - SVC_SET_SYSTEM_MODE, + EvoService.SET_SYSTEM_MODE, set_system_mode, schema=vol.Schema(vol.Any(*system_mode_schemas)), ) @@ -417,173 +328,18 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: # The zone modes are consistent across all systems and use the same schema hass.services.async_register( DOMAIN, - SVC_RESET_ZONE_OVERRIDE, + EvoService.RESET_ZONE_OVERRIDE, set_zone_override, schema=RESET_ZONE_OVERRIDE_SCHEMA, ) hass.services.async_register( DOMAIN, - SVC_SET_ZONE_OVERRIDE, + EvoService.SET_ZONE_OVERRIDE, set_zone_override, schema=SET_ZONE_OVERRIDE_SCHEMA, ) -class EvoBroker: - """Container for evohome client and data.""" - - def __init__( - self, - hass: HomeAssistant, - client: evo.EvohomeClient, - client_v1: ev1.EvohomeClient | None, - store: Store[dict[str, Any]], - params: ConfigType, - ) -> None: - """Initialize the evohome client and its data structure.""" - self.hass = hass - self.client = client - self.client_v1 = client_v1 - self._store = store - 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: 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.""" - # evohomeasync2 uses naive/local datetimes - access_token_expires = _dt_local_to_aware( - self.client.access_token_expires # type: ignore[arg-type] - ) - - app_storage: dict[str, Any] = { - CONF_USERNAME: self.client.username, - REFRESH_TOKEN: self.client.refresh_token, - ACCESS_TOKEN: self.client.access_token, - ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), - } - - if self.client_v1: - app_storage[USER_DATA] = { - SZ_SESSION_ID: self.client_v1.broker.session_id, - } # this is the schema for STORAGE_VER == 1 - else: - app_storage[USER_DATA] = {} - - await self._store.async_save(app_storage) - - async def call_client_api( - self, - client_api: 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 client_api - except evo.RequestFailed as err: - _handle_exception(err) - 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) -> None: - """Get the latest high-precision temperatures of the default Location.""" - - assert self.client_v1 is not None # mypy check - - 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(SZ_SESSION_ID) if user_data else None # type: ignore[return-value] - - session_id = get_session_id(self.client_v1) - - try: - temps = await self.client_v1.get_temperatures() - - except ev1.InvalidSchema as err: - _LOGGER.warning( - ( - "Unable to obtain high-precision temperatures. " - "It appears the JSON schema is not as expected, " - "so the high-precision feature will be disabled until next restart." - "Message is: %s" - ), - err, - ) - self.client_v1 = None - - except ev1.RequestFailed as err: - _LOGGER.warning( - ( - "Unable to obtain the latest high-precision temperatures. " - "Check your network and the vendor's service status page. " - "Proceeding without high-precision temperatures for now. " - "Message is: %s" - ), - err, - ) - self.temps = {} # high-precision temps now considered stale - - except Exception: - self.temps = {} # high-precision temps now considered stale - raise - - else: - 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.client_v1 = None - else: - self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} - - finally: - 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: Any) -> None: - """Get the latest modes, temperatures, setpoints of a Location.""" - - access_token = self.client.access_token # maybe receive a new token? - - try: - 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() - - 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 - operating mode of the Controller and the current temp of its children (e.g. - Zones, DHW controller). - """ - await self._update_v2_api_state() - - if self.client_v1: - await self._update_v1_api_temps() - - class EvoDevice(Entity): """Base for any evohome device. @@ -612,7 +368,10 @@ class EvoDevice(Entity): return if payload["unique_id"] != self._attr_unique_id: return - if payload["service"] in (SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE): + if payload["service"] in ( + EvoService.SET_ZONE_OVERRIDE, + EvoService.RESET_ZONE_OVERRIDE, + ): await self.async_zone_svc_request(payload["service"], payload["data"]) return await self.async_tcs_svc_request(payload["service"], payload["data"]) @@ -685,7 +444,8 @@ class EvoChild(EvoDevice): if not (schedule := self._schedule.get("DailySchedules")): return {} # no scheduled setpoints when {'DailySchedules': []} - day_time = dt_util.now() + # get dt in the same TZ as the TCS location, so we can compare schedule times + day_time = dt_util.now().astimezone(timezone(self._evo_broker.loc_utc_offset)) day_of_week = day_time.weekday() # for evohome, 0 is Monday time_of_day = day_time.strftime("%H:%M:%S") @@ -699,7 +459,7 @@ class EvoChild(EvoDevice): else: break - # Did the current SP start yesterday? Does the next start SP tomorrow? + # Did this setpoint start yesterday? Does the next setpoint start tomorrow? this_sp_day = -1 if sp_idx == -1 else 0 next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 @@ -716,7 +476,7 @@ class EvoChild(EvoDevice): ) 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 + switchpoint_time_of_day, self._evo_broker.loc_utc_offset ) self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() @@ -740,16 +500,18 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check try: - self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] + schedule = await self._evo_broker.call_client_api( self._evo_device.get_schedule(), update_state=False ) except evo.InvalidSchedule as err: _LOGGER.warning( - "%s: Unable to retrieve the schedule: %s", + "%s: Unable to retrieve a valid schedule: %s", self._evo_device, err, ) self._schedule = {} + else: + self._schedule = schedule or {} _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 2d462b5c525..8b3e8a46e2c 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,4 +1,4 @@ -"""Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" +"""Support for Climate entities of the Evohome integration.""" from __future__ import annotations @@ -37,19 +37,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import ( +from . import EvoChild, EvoDevice +from .const import ( ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, ATTR_DURATION_UNTIL, ATTR_SYSTEM_MODE, ATTR_ZONE_TEMP, CONF_LOCATION_IDX, - SVC_RESET_ZONE_OVERRIDE, - SVC_SET_SYSTEM_MODE, - EvoChild, - EvoDevice, -) -from .const import ( DOMAIN, EVO_AUTO, EVO_AUTOECO, @@ -61,6 +56,7 @@ from .const import ( EVO_PERMOVER, EVO_RESET, EVO_TEMPOVER, + EvoService, ) if TYPE_CHECKING: @@ -200,11 +196,11 @@ class EvoZone(EvoChild, EvoClimateEntity): async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (setpoint override) for a zone.""" - if service == SVC_RESET_ZONE_OVERRIDE: + if service == EvoService.RESET_ZONE_OVERRIDE: await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return - # otherwise it is SVC_SET_ZONE_OVERRIDE + # otherwise it is EvoService.SET_ZONE_OVERRIDE temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: @@ -386,9 +382,9 @@ class EvoController(EvoClimateEntity): Data validation is not required, it will have been done upstream. """ - if service == SVC_SET_SYSTEM_MODE: + if service == EvoService.SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] - else: # otherwise it is SVC_RESET_SYSTEM + else: # otherwise it is EvoService.RESET_SYSTEM mode = EVO_RESET if ATTR_DURATION_DAYS in data: diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 1347c1f797c..15949bc3c37 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -1,26 +1,60 @@ -"""Support for (EMEA/EU-based) Honeywell TCC climate systems.""" +"""The constants of the Evohome integration.""" -DOMAIN = "evohome" +from __future__ import annotations -STORAGE_VER = 1 -STORAGE_KEY = DOMAIN +from datetime import timedelta +from enum import StrEnum, unique +from typing import Final -# The Parent's (i.e. TCS, Controller's) operating mode is one of: -EVO_RESET = "AutoWithReset" -EVO_AUTO = "Auto" -EVO_AUTOECO = "AutoWithEco" -EVO_AWAY = "Away" -EVO_DAYOFF = "DayOff" -EVO_CUSTOM = "Custom" -EVO_HEATOFF = "HeatingOff" +DOMAIN: Final = "evohome" -# The Children's operating mode is one of: -EVO_FOLLOW = "FollowSchedule" # the operating mode is 'inherited' from the TCS -EVO_TEMPOVER = "TemporaryOverride" -EVO_PERMOVER = "PermanentOverride" +STORAGE_VER: Final = 1 +STORAGE_KEY: Final = DOMAIN -# These are used only to help prevent E501 (line too long) violations -GWS = "gateways" -TCS = "temperatureControlSystems" +# The Parent's (i.e. TCS, Controller) operating mode is one of: +EVO_RESET: Final = "AutoWithReset" +EVO_AUTO: Final = "Auto" +EVO_AUTOECO: Final = "AutoWithEco" +EVO_AWAY: Final = "Away" +EVO_DAYOFF: Final = "DayOff" +EVO_CUSTOM: Final = "Custom" +EVO_HEATOFF: Final = "HeatingOff" -UTC_OFFSET = "currentOffsetMinutes" +# The Children's (i.e. Dhw, Zone) operating mode is one of: +EVO_FOLLOW: Final = "FollowSchedule" # the operating mode is 'inherited' from the TCS +EVO_TEMPOVER: Final = "TemporaryOverride" +EVO_PERMOVER: Final = "PermanentOverride" + +# These two are used only to help prevent E501 (line too long) violations +GWS: Final = "gateways" +TCS: Final = "temperatureControlSystems" + +UTC_OFFSET: Final = "currentOffsetMinutes" + +CONF_LOCATION_IDX: Final = "location_idx" + +ACCESS_TOKEN: Final = "access_token" +ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" +REFRESH_TOKEN: Final = "refresh_token" +USER_DATA: Final = "user_data" + +SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) +SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) + +ATTR_SYSTEM_MODE: Final = "mode" +ATTR_DURATION_DAYS: Final = "period" +ATTR_DURATION_HOURS: Final = "duration" + +ATTR_ZONE_TEMP: Final = "setpoint" +ATTR_DURATION_UNTIL: Final = "duration" + + +@unique +class EvoService(StrEnum): + """The Evohome services.""" + + REFRESH_SYSTEM: Final = "refresh_system" + SET_SYSTEM_MODE: Final = "set_system_mode" + RESET_SYSTEM: Final = "reset_system" + SET_ZONE_OVERRIDE: Final = "set_zone_override" + RESET_ZONE_OVERRIDE: Final = "clear_zone_override" diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py new file mode 100644 index 00000000000..6b54c5f4640 --- /dev/null +++ b/homeassistant/components/evohome/coordinator.py @@ -0,0 +1,191 @@ +"""Support for (EMEA/EU-based) Honeywell TCC systems.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from datetime import timedelta +import logging +from typing import Any + +import evohomeasync as ev1 +from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP +import evohomeasync2 as evo + +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ACCESS_TOKEN, + ACCESS_TOKEN_EXPIRES, + CONF_LOCATION_IDX, + DOMAIN, + GWS, + REFRESH_TOKEN, + TCS, + USER_DATA, + UTC_OFFSET, +) +from .helpers import dt_local_to_aware, handle_evo_exception + +_LOGGER = logging.getLogger(__name__.rpartition(".")[0]) + + +class EvoBroker: + """Container for evohome client and data.""" + + def __init__( + self, + hass: HomeAssistant, + client: evo.EvohomeClient, + client_v1: ev1.EvohomeClient | None, + store: Store[dict[str, Any]], + params: ConfigType, + ) -> None: + """Initialize the evohome client and its data structure.""" + self.hass = hass + self.client = client + self.client_v1 = client_v1 + self._store = store + self.params = params + + loc_idx = params[CONF_LOCATION_IDX] + self._location: evo.Location = client.locations[loc_idx] + + assert isinstance(client.installation_info, list) # mypy + + self.config = client.installation_info[loc_idx][GWS][0][TCS][0] + self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] # noqa: SLF001 + self.loc_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.""" + # evohomeasync2 uses naive/local datetimes + access_token_expires = dt_local_to_aware( + self.client.access_token_expires # type: ignore[arg-type] + ) + + app_storage: dict[str, Any] = { + CONF_USERNAME: self.client.username, + REFRESH_TOKEN: self.client.refresh_token, + ACCESS_TOKEN: self.client.access_token, + ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), + } + + if self.client_v1: + app_storage[USER_DATA] = { + SZ_SESSION_ID: self.client_v1.broker.session_id, + } # this is the schema for STORAGE_VER == 1 + else: + app_storage[USER_DATA] = {} + + await self._store.async_save(app_storage) + + async def call_client_api( + self, + client_api: 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 client_api + except evo.RequestFailed as err: + handle_evo_exception(err) + 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) -> None: + """Get the latest high-precision temperatures of the default Location.""" + + assert self.client_v1 is not None # mypy check + + 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(SZ_SESSION_ID) if user_data else None # type: ignore[return-value] + + session_id = get_session_id(self.client_v1) + + try: + temps = await self.client_v1.get_temperatures() + + except ev1.InvalidSchema as err: + _LOGGER.warning( + ( + "Unable to obtain high-precision temperatures. " + "It appears the JSON schema is not as expected, " + "so the high-precision feature will be disabled until next restart." + "Message is: %s" + ), + err, + ) + self.client_v1 = None + + except ev1.RequestFailed as err: + _LOGGER.warning( + ( + "Unable to obtain the latest high-precision temperatures. " + "Check your network and the vendor's service status page. " + "Proceeding without high-precision temperatures for now. " + "Message is: %s" + ), + err, + ) + self.temps = {} # high-precision temps now considered stale + + except Exception: + self.temps = {} # high-precision temps now considered stale + raise + + else: + 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.client_v1 = None + else: + self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} + + finally: + 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: Any) -> None: + """Get the latest modes, temperatures, setpoints of a Location.""" + + access_token = self.client.access_token # maybe receive a new token? + + try: + status = await self._location.refresh_status() + except evo.RequestFailed as err: + handle_evo_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() + + 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 + operating mode of the Controller and the current temp of its children (e.g. + Zones, DHW controller). + """ + await self._update_v2_api_state() + + if self.client_v1: + await self._update_v1_api_temps() diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py new file mode 100644 index 00000000000..f84d2945779 --- /dev/null +++ b/homeassistant/components/evohome/helpers.py @@ -0,0 +1,110 @@ +"""Support for (EMEA/EU-based) Honeywell TCC systems.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from http import HTTPStatus +import logging +import re +from typing import Any + +import evohomeasync2 as evo + +from homeassistant.const import CONF_SCAN_INTERVAL +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + + +def dt_local_to_aware(dt_naive: datetime) -> datetime: + """Convert a local/naive datetime to TZ-aware.""" + 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: datetime) -> datetime: + """Convert a TZ-aware datetime to naive/local.""" + 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) + + +def convert_until(status_dict: dict, until_key: str) -> None: + """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" + if until_key in status_dict and ( # only present for certain modes + dt_utc_naive := dt_util.parse_datetime(status_dict[until_key]) + ): + status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() + + +def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: + """Recursively convert a dict's keys to snake_case.""" + + def convert_key(key: str) -> str: + """Convert a string to snake_case.""" + string = re.sub(r"[\-\.\s]", "_", str(key)) + return ( + (string[0]).lower() + + re.sub( + r"[A-Z]", + lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] + string[1:], + ) + ) + + return { + (convert_key(k) if isinstance(k, str) else k): ( + convert_dict(v) if isinstance(v, dict) else v + ) + for k, v in dictionary.items() + } + + +def handle_evo_exception(err: evo.RequestFailed) -> None: + """Return False if the exception can't be ignored.""" + + try: + raise err + + except evo.AuthenticationFailed: + _LOGGER.error( + ( + "Failed to authenticate with the vendor's server. Check your username" + " and password. NB: Some special password characters that work" + " correctly via the website will not work via the web API. Message" + " is: %s" + ), + err, + ) + + except evo.RequestFailed: + if err.status is None: + _LOGGER.warning( + ( + "Unable to connect with the vendor's server. " + "Check your network and the vendor's service status page. " + "Message is: %s" + ), + err, + ) + + elif err.status == HTTPStatus.SERVICE_UNAVAILABLE: + _LOGGER.warning( + "The vendor says their server is currently unavailable. " + "Check the vendor's service status page" + ) + + elif err.status == HTTPStatus.TOO_MANY_REQUESTS: + _LOGGER.warning( + ( + "The vendor's API rate limit has been exceeded. " + "If this message persists, consider increasing the %s" + ), + CONF_SCAN_INTERVAL, + ) + + else: + raise # we don't expect/handle any other Exceptions diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 26be4b47a36..66ba7f46a70 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -1,4 +1,4 @@ -"""Support for WaterHeater devices of (EMEA/EU) Honeywell TCC systems.""" +"""Support for WaterHeater entities of the Evohome integration.""" from __future__ import annotations diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index a17d8312700..2b47b120cf8 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -189,7 +189,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except PyEzvizError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -242,7 +242,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except PyEzvizError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -297,7 +297,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -358,7 +358,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 0c362f8cbe7..0fbc5cc6a68 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,8 +4,12 @@ from __future__ import annotations import logging +from pyezviz.exceptions import PyEzvizError +from pyezviz.utils import decrypt_image + from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -51,12 +55,28 @@ class EzvizLastMotion(EzvizEntity, ImageEntity): self._attr_image_last_updated = dt_util.parse_datetime( str(self.data["last_alarm_time"]) ) + camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) + self.alarm_image_password = ( + camera.data[CONF_PASSWORD] if camera is not None else None + ) async def _async_load_image_from_url(self, url: str) -> Image | None: """Load an image by url.""" if response := await self._fetch_url(url): + image_data = response.content + if self.data["encrypted"] and self.alarm_image_password is not None: + try: + image_data = decrypt_image( + response.content, self.alarm_image_password + ) + except PyEzvizError: + _LOGGER.warning( + "%s: Can't decrypt last alarm picture, looks like it was encrypted with other password", + self.entity_id, + ) + image_data = response.content return Image( - content=response.content, + content=image_data, content_type="image/jpeg", # Actually returns binary/octet-stream ) return None diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index 935831c467d..c5b90812f2d 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -43,7 +43,7 @@ class FAADelaysConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error connecting to FAA API") errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 12bd355b82b..b9593ec907f 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -4,49 +4,15 @@ from __future__ import annotations import logging -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.typing import ConfigType -from .const import CONF_MANUAL, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import FastdotcomDataUpdateCoordinator -from .services import async_setup_services _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_MANUAL, default=False): cv.boolean, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Fastdotcom component.""" - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - async_setup_services(hass) - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Fast.com from a config entry.""" diff --git a/homeassistant/components/fastdotcom/config_flow.py b/homeassistant/components/fastdotcom/config_flow.py index 36b6f81ae5b..b84c30cf58d 100644 --- a/homeassistant/components/fastdotcom/config_flow.py +++ b/homeassistant/components/fastdotcom/config_flow.py @@ -5,8 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DEFAULT_NAME, DOMAIN @@ -24,24 +22,3 @@ class FastdotcomConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data={}) return self.async_show_form(step_id="user") - - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow initiated by configuration file.""" - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Fast.com", - }, - ) - - return await self.async_step_user(user_input) diff --git a/homeassistant/components/fastdotcom/icons.json b/homeassistant/components/fastdotcom/icons.json index 5c61065d257..d3679448b81 100644 --- a/homeassistant/components/fastdotcom/icons.json +++ b/homeassistant/components/fastdotcom/icons.json @@ -5,8 +5,5 @@ "default": "mdi:speedometer" } } - }, - "services": { - "speedtest": "mdi:speedometer" } } diff --git a/homeassistant/components/fastdotcom/services.py b/homeassistant/components/fastdotcom/services.py deleted file mode 100644 index 5939a667342..00000000000 --- a/homeassistant/components/fastdotcom/services.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Services for the Fastdotcom integration.""" - -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import issue_registry as ir - -from .const import DOMAIN, SERVICE_NAME -from .coordinator import FastdotcomDataUpdateCoordinator - - -def async_setup_services(hass: HomeAssistant) -> None: - """Set up the service for the Fastdotcom integration.""" - - @callback - def collect_coordinator() -> FastdotcomDataUpdateCoordinator: - """Collect the coordinator Fastdotcom.""" - config_entries = hass.config_entries.async_entries(DOMAIN) - if not config_entries: - raise HomeAssistantError("No Fast.com config entries found") - - for config_entry in config_entries: - if config_entry.state != ConfigEntryState.LOADED: - raise HomeAssistantError(f"{config_entry.title} is not loaded") - coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] - break - return coordinator - - async def async_perform_service(call: ServiceCall) -> None: - """Perform a service call to manually run Fastdotcom.""" - 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", - ) - coordinator = collect_coordinator() - await coordinator.async_request_refresh() - - hass.services.async_register( - DOMAIN, - SERVICE_NAME, - async_perform_service, - ) diff --git a/homeassistant/components/fastdotcom/services.yaml b/homeassistant/components/fastdotcom/services.yaml deleted file mode 100644 index 002b28b4e4d..00000000000 --- a/homeassistant/components/fastdotcom/services.yaml +++ /dev/null @@ -1 +0,0 @@ -speedtest: diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index 61a1f686747..36863f1a0a3 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -15,24 +15,5 @@ "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 2b0c6b77559..36ffe545996 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -2,296 +2,119 @@ from __future__ import annotations -from calendar import timegm -from datetime import datetime, timedelta -from logging import getLogger -import os -import pickle -from time import gmtime, struct_time - -import feedparser import voluptuous as vol -from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.storage import Store +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey -_LOGGER = getLogger(__name__) +from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import FeedReaderCoordinator, StoredData + +type FeedReaderConfigEntry = ConfigEntry[FeedReaderCoordinator] CONF_URLS = "urls" -CONF_MAX_ENTRIES = "max_entries" -DEFAULT_MAX_ENTRIES = 20 -DEFAULT_SCAN_INTERVAL = timedelta(hours=1) -DELAY_SAVE = 30 - -DOMAIN = "feedreader" - -EVENT_FEEDREADER = "feedreader" -STORAGE_VERSION = 1 +MY_KEY: HassKey[StoredData] = HassKey(DOMAIN) CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: { - vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional( - CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES - ): cv.positive_int, - } - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional( + CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES + ): cv.positive_int, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Feedreader component.""" - urls: list[str] = config[DOMAIN][CONF_URLS] - if not urls: - return False + if DOMAIN in config: + for url in config[DOMAIN][CONF_URLS]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_URL: url, + CONF_MAX_ENTRIES: config[DOMAIN][CONF_MAX_ENTRIES], + }, + ) + ) - scan_interval: timedelta = config[DOMAIN][CONF_SCAN_INTERVAL] - max_entries: int = config[DOMAIN][CONF_MAX_ENTRIES] - old_data_file = hass.config.path(f"{DOMAIN}.pickle") - storage = StoredData(hass, old_data_file) - await storage.async_setup() - feeds = [ - FeedManager(hass, url, scan_interval, max_entries, storage) for url in urls - ] - - for feed in feeds: - feed.async_setup() + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.1.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Feedreader", + }, + ) return True -class FeedManager: - """Abstraction over Feedparser module.""" +async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool: + """Set up Feedreader from a config entry.""" + storage = hass.data.setdefault(MY_KEY, StoredData(hass)) + if not storage.is_initialized: + await storage.async_setup() - def __init__( - self, - hass: HomeAssistant, - url: str, - scan_interval: timedelta, - max_entries: int, - storage: StoredData, - ) -> None: - """Initialize the FeedManager object, poll as per scan interval.""" - self._hass = hass - self._url = url - self._scan_interval = scan_interval - self._max_entries = max_entries - self._feed: feedparser.FeedParserDict | None = None - self._firstrun = True - self._storage = storage - self._last_entry_timestamp: struct_time | None = None - self._has_published_parsed = False - self._has_updated_parsed = False - self._event_type = EVENT_FEEDREADER - self._feed_id = url + coordinator = FeedReaderCoordinator( + hass, + entry.data[CONF_URL], + entry.options[CONF_MAX_ENTRIES], + storage, + ) - @callback - def async_setup(self) -> None: - """Set up the feed manager.""" - self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, self._async_update) - async_track_time_interval( - self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True - ) + await coordinator.async_config_entry_first_refresh() - def _log_no_entries(self) -> None: - """Send no entries log at debug level.""" - _LOGGER.debug("No new entries to be published in feed %s", self._url) + # workaround because coordinators without listeners won't update + # can be removed when we have entities to update + coordinator.async_add_listener(lambda: None) - async def _async_update(self, _: datetime | Event) -> None: - """Update the feed and publish new entries to the event bus.""" - last_entry_timestamp = await self._hass.async_add_executor_job(self._update) - if last_entry_timestamp: - self._storage.async_put_timestamp(self._feed_id, last_entry_timestamp) + entry.runtime_data = coordinator - def _update(self) -> struct_time | None: - """Update the feed and publish new entries to the event bus.""" - _LOGGER.debug("Fetching new data from feed %s", self._url) - self._feed = feedparser.parse( - self._url, - etag=None if not self._feed else self._feed.get("etag"), - modified=None if not self._feed else self._feed.get("modified"), - ) - if not self._feed: - _LOGGER.error("Error fetching feed data from %s", self._url) - return None - # The 'bozo' flag really only indicates that there was an issue - # during the initial parsing of the XML, but it doesn't indicate - # whether this is an unrecoverable error. In this case the - # feedparser lib is trying a less strict parsing approach. - # If an error is detected here, log warning message but continue - # processing the feed entries if present. - if self._feed.bozo != 0: - _LOGGER.warning( - "Possible issue parsing feed %s: %s", - self._url, - self._feed.bozo_exception, - ) - # Using etag and modified, if there's no new data available, - # the entries list will be empty - _LOGGER.debug( - "%s entri(es) available in feed %s", - len(self._feed.entries), - self._url, - ) - if not self._feed.entries: - self._log_no_entries() - return None + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - self._filter_entries() - self._publish_new_entries() - - _LOGGER.debug("Fetch from feed %s completed", self._url) - - if ( - self._has_published_parsed or self._has_updated_parsed - ) and self._last_entry_timestamp: - return self._last_entry_timestamp - - return None - - def _filter_entries(self) -> None: - """Filter the entries provided and return the ones to keep.""" - assert self._feed is not None - if len(self._feed.entries) > self._max_entries: - _LOGGER.debug( - "Processing only the first %s entries in feed %s", - self._max_entries, - self._url, - ) - self._feed.entries = self._feed.entries[0 : self._max_entries] - - def _update_and_fire_entry(self, entry: feedparser.FeedParserDict) -> None: - """Update last_entry_timestamp and fire entry.""" - # Check if the entry has a updated or published date. - # Start from a updated date because generally `updated` > `published`. - if "updated_parsed" in entry and entry.updated_parsed: - # We are lucky, `updated_parsed` data available, let's make use of - # it to publish only new available entries since the last run - self._has_updated_parsed = True - self._last_entry_timestamp = max( - entry.updated_parsed, self._last_entry_timestamp - ) - elif "published_parsed" in entry and entry.published_parsed: - # We are lucky, `published_parsed` data available, let's make use of - # it to publish only new available entries since the last run - self._has_published_parsed = True - self._last_entry_timestamp = max( - entry.published_parsed, self._last_entry_timestamp - ) - else: - self._has_updated_parsed = False - self._has_published_parsed = False - _LOGGER.debug( - "No updated_parsed or published_parsed info available for entry %s", - entry, - ) - 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_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 > last_entry_timestamp - ) - or ( - "updated_parsed" in entry - and entry.updated_parsed > last_entry_timestamp - ) - ): - self._update_and_fire_entry(entry) - new_entry_count += 1 - else: - _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 + return True -class StoredData: - """Represent a data storage.""" +async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool: + """Unload a config entry.""" + entries = hass.config_entries.async_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) + # if this is the last entry, remove the storage + if len(entries) == 1: + hass.data.pop(MY_KEY) + return True - def __init__(self, hass: HomeAssistant, legacy_data_file: str) -> None: - """Initialize data storage.""" - self._legacy_data_file = legacy_data_file - self._data: dict[str, struct_time] = {} - self._hass = hass - self._store: Store[dict[str, str]] = Store(hass, STORAGE_VERSION, DOMAIN) - async def async_setup(self) -> None: - """Set up storage.""" - if not os.path.exists(self._store.path): - # Remove the legacy store loading after deprecation period. - data = await self._hass.async_add_executor_job(self._legacy_fetch_data) - else: - if (store_data := await self._store.async_load()) is None: - return - # Make sure that dst is set to 0, by using gmtime() on the timestamp. - data = { - feed_id: gmtime(datetime.fromisoformat(timestamp_string).timestamp()) - for feed_id, timestamp_string in store_data.items() - } - - self._data = data - - def _legacy_fetch_data(self) -> dict[str, struct_time]: - """Fetch data stored in pickle file.""" - _LOGGER.debug("Fetching data from legacy file %s", self._legacy_data_file) - try: - with open(self._legacy_data_file, "rb") as myfile: - return pickle.load(myfile) or {} - except FileNotFoundError: - pass - except (OSError, pickle.PickleError) as err: - _LOGGER.error( - "Error loading data from pickled file %s: %s", - self._legacy_data_file, - err, - ) - - return {} - - def get_timestamp(self, feed_id: str) -> struct_time | None: - """Return stored timestamp for given feed id.""" - return self._data.get(feed_id) - - @callback - def async_put_timestamp(self, feed_id: str, timestamp: struct_time) -> None: - """Update timestamp for given feed id.""" - self._data[feed_id] = timestamp - self._store.async_delay_save(self._async_save_data, DELAY_SAVE) - - @callback - def _async_save_data(self) -> dict[str, str]: - """Save feed data to storage.""" - return { - feed_id: dt_util.utc_from_timestamp(timegm(struct_utc)).isoformat() - for feed_id, struct_utc in self._data.items() - } +async def _async_update_listener( + hass: HomeAssistant, entry: FeedReaderConfigEntry +) -> None: + """Handle reconfiguration.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py new file mode 100644 index 00000000000..6fa153b8177 --- /dev/null +++ b/homeassistant/components/feedreader/config_flow.py @@ -0,0 +1,195 @@ +"""Config flow for RSS/Atom feeds.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any +import urllib.error + +import feedparser +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_IMPORT, + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from homeassistant.util import slugify + +from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN + +LOGGER = logging.getLogger(__name__) + + +async def async_fetch_feed(hass: HomeAssistant, url: str) -> feedparser.FeedParserDict: + """Fetch the feed.""" + return await hass.async_add_executor_job(feedparser.parse, url) + + +class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + _config_entry: ConfigEntry + _max_entries: int | None = None + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return FeedReaderOptionsFlowHandler(config_entry) + + def show_user_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + description_placeholders: dict[str, str] | None = None, + step_id: str = "user", + ) -> ConfigFlowResult: + """Show the user form.""" + if user_input is None: + user_input = {} + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema( + { + vol.Required( + CONF_URL, default=user_input.get(CONF_URL, "") + ): TextSelector(TextSelectorConfig(type=TextSelectorType.URL)) + } + ), + description_placeholders=description_placeholders, + errors=errors, + ) + + def abort_on_import_error(self, url: str, error: str) -> ConfigFlowResult: + """Abort import flow on error.""" + async_create_issue( + self.hass, + DOMAIN, + f"import_yaml_error_{DOMAIN}_{error}_{slugify(url)}", + breaks_in_ha_version="2025.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"import_yaml_error_{error}", + translation_placeholders={"url": url}, + ) + return self.async_abort(reason=error) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + if not user_input: + return self.show_user_form() + + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + + feed = await async_fetch_feed(self.hass, user_input[CONF_URL]) + + if feed.bozo: + LOGGER.debug("feed bozo_exception: %s", feed.bozo_exception) + if isinstance(feed.bozo_exception, urllib.error.URLError): + if self.context["source"] == SOURCE_IMPORT: + return self.abort_on_import_error(user_input[CONF_URL], "url_error") + return self.show_user_form(user_input, {"base": "url_error"}) + + if not feed.entries: + if self.context["source"] == SOURCE_IMPORT: + return self.abort_on_import_error( + user_input[CONF_URL], "no_feed_entries" + ) + return self.show_user_form(user_input, {"base": "no_feed_entries"}) + + feed_title = feed["feed"]["title"] + + return self.async_create_entry( + title=feed_title, + data=user_input, + options={CONF_MAX_ENTRIES: self._max_entries or DEFAULT_MAX_ENTRIES}, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle an import flow.""" + self._max_entries = user_input[CONF_MAX_ENTRIES] + return await self.async_step_user({CONF_URL: user_input[CONF_URL]}) + + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + config_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + if TYPE_CHECKING: + assert config_entry is not None + self._config_entry = config_entry + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + if not user_input: + return self.show_user_form( + user_input={**self._config_entry.data}, + description_placeholders={"name": self._config_entry.title}, + step_id="reconfigure_confirm", + ) + + feed = await async_fetch_feed(self.hass, user_input[CONF_URL]) + + if feed.bozo: + LOGGER.debug("feed bozo_exception: %s", feed.bozo_exception) + if isinstance(feed.bozo_exception, urllib.error.URLError): + return self.show_user_form( + user_input=user_input, + description_placeholders={"name": self._config_entry.title}, + step_id="reconfigure_confirm", + errors={"base": "url_error"}, + ) + if not feed.entries: + return self.show_user_form( + user_input=user_input, + description_placeholders={"name": self._config_entry.title}, + step_id="reconfigure_confirm", + errors={"base": "no_feed_entries"}, + ) + + self.hass.config_entries.async_update_entry(self._config_entry, data=user_input) + return self.async_abort(reason="reconfigure_successful") + + +class FeedReaderOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle an options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle options flow.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_MAX_ENTRIES, + default=self.options.get(CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES), + ): cv.positive_int, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/feedreader/const.py b/homeassistant/components/feedreader/const.py new file mode 100644 index 00000000000..c0aa6633669 --- /dev/null +++ b/homeassistant/components/feedreader/const.py @@ -0,0 +1,9 @@ +"""Constants for RSS/Atom feeds.""" + +from datetime import timedelta + +DOMAIN = "feedreader" + +CONF_MAX_ENTRIES = "max_entries" +DEFAULT_MAX_ENTRIES = 20 +DEFAULT_SCAN_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py new file mode 100644 index 00000000000..e116d804b3d --- /dev/null +++ b/homeassistant/components/feedreader/coordinator.py @@ -0,0 +1,206 @@ +"""Data update coordinator for RSS/Atom feeds.""" + +from __future__ import annotations + +from calendar import timegm +from datetime import datetime +from logging import getLogger +from time import gmtime, struct_time +from urllib.error import URLError + +import feedparser + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +DELAY_SAVE = 30 +EVENT_FEEDREADER = "feedreader" +STORAGE_VERSION = 1 + + +_LOGGER = getLogger(__name__) + + +class FeedReaderCoordinator(DataUpdateCoordinator[None]): + """Abstraction over Feedparser module.""" + + def __init__( + self, + hass: HomeAssistant, + url: str, + max_entries: int, + storage: StoredData, + ) -> None: + """Initialize the FeedManager object, poll as per scan interval.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{DOMAIN} {url}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._url = url + self._max_entries = max_entries + self._feed: feedparser.FeedParserDict | None = None + self._storage = storage + self._last_entry_timestamp: struct_time | None = None + self._event_type = EVENT_FEEDREADER + self._feed_id = url + + @callback + def _log_no_entries(self) -> None: + """Send no entries log at debug level.""" + _LOGGER.debug("No new entries to be published in feed %s", self._url) + + def _fetch_feed(self) -> feedparser.FeedParserDict: + """Fetch the feed data.""" + return feedparser.parse( + self._url, + etag=None if not self._feed else self._feed.get("etag"), + modified=None if not self._feed else self._feed.get("modified"), + ) + + async def _async_update_data(self) -> None: + """Update the feed and publish new entries to the event bus.""" + _LOGGER.debug("Fetching new data from feed %s", self._url) + self._feed = await self.hass.async_add_executor_job(self._fetch_feed) + + if not self._feed: + raise UpdateFailed(f"Error fetching feed data from {self._url}") + + # The 'bozo' flag really only indicates that there was an issue + # during the initial parsing of the XML, but it doesn't indicate + # whether this is an unrecoverable error. In this case the + # feedparser lib is trying a less strict parsing approach. + # If an error is detected here, log warning message but continue + # processing the feed entries if present. + if self._feed.bozo != 0: + if isinstance(self._feed.bozo_exception, URLError): + raise UpdateFailed( + f"Error fetching feed data from {self._url}: {self._feed.bozo_exception}" + ) + + # no connection issue, but parsing issue + _LOGGER.warning( + "Possible issue parsing feed %s: %s", + self._url, + self._feed.bozo_exception, + ) + # Using etag and modified, if there's no new data available, + # the entries list will be empty + _LOGGER.debug( + "%s entri(es) available in feed %s", + len(self._feed.entries), + self._url, + ) + if not self._feed.entries: + self._log_no_entries() + return None + + self._filter_entries() + self._publish_new_entries() + + _LOGGER.debug("Fetch from feed %s completed", self._url) + + if self._last_entry_timestamp: + self._storage.async_put_timestamp(self._feed_id, self._last_entry_timestamp) + + @callback + def _filter_entries(self) -> None: + """Filter the entries provided and return the ones to keep.""" + assert self._feed is not None + if len(self._feed.entries) > self._max_entries: + _LOGGER.debug( + "Processing only the first %s entries in feed %s", + self._max_entries, + self._url, + ) + self._feed.entries = self._feed.entries[0 : self._max_entries] + + @callback + def _update_and_fire_entry(self, entry: feedparser.FeedParserDict) -> None: + """Update last_entry_timestamp and fire entry.""" + # Check if the entry has a updated or published date. + # Start from a updated date because generally `updated` > `published`. + if time_stamp := entry.get("updated_parsed") or entry.get("published_parsed"): + self._last_entry_timestamp = time_stamp + else: + _LOGGER.debug( + "No updated_parsed or published_parsed info available for entry %s", + entry, + ) + entry["feed_url"] = self._url + self.hass.bus.async_fire(self._event_type, entry) + _LOGGER.debug("New event fired for entry %s", entry.get("link")) + + @callback + def _publish_new_entries(self) -> None: + """Publish new entries to the event bus.""" + assert self._feed is not None + new_entry_count = 0 + firstrun = False + self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) + if not self._last_entry_timestamp: + firstrun = True + # 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 firstrun or ( + ( + time_stamp := entry.get("updated_parsed") + or entry.get("published_parsed") + ) + and time_stamp > last_entry_timestamp + ): + self._update_and_fire_entry(entry) + new_entry_count += 1 + else: + _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) + + +class StoredData: + """Represent a data storage.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize data storage.""" + self._data: dict[str, struct_time] = {} + self.hass = hass + self._store: Store[dict[str, str]] = Store(hass, STORAGE_VERSION, DOMAIN) + self.is_initialized = False + + async def async_setup(self) -> None: + """Set up storage.""" + if (store_data := await self._store.async_load()) is not None: + # Make sure that dst is set to 0, by using gmtime() on the timestamp. + self._data = { + feed_id: gmtime(datetime.fromisoformat(timestamp_string).timestamp()) + for feed_id, timestamp_string in store_data.items() + } + self.is_initialized = True + + def get_timestamp(self, feed_id: str) -> struct_time | None: + """Return stored timestamp for given feed id.""" + return self._data.get(feed_id) + + @callback + def async_put_timestamp(self, feed_id: str, timestamp: struct_time) -> None: + """Update timestamp for given feed id.""" + self._data[feed_id] = timestamp + self._store.async_delay_save(self._async_save_data, DELAY_SAVE) + + @callback + def _async_save_data(self) -> dict[str, str]: + """Save feed data to storage.""" + return { + feed_id: dt_util.utc_from_timestamp(timegm(struct_utc)).isoformat() + for feed_id, struct_utc in self._data.items() + } diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json index fe52dc4d4c2..5103e1e807c 100644 --- a/homeassistant/components/feedreader/manifest.json +++ b/homeassistant/components/feedreader/manifest.json @@ -2,6 +2,7 @@ "domain": "feedreader", "name": "Feedreader", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/feedreader", "iot_class": "cloud_polling", "loggers": ["feedparser", "sgmllib3k"], diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json new file mode 100644 index 00000000000..31881b4112a --- /dev/null +++ b/homeassistant/components/feedreader/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + }, + "reconfigure_confirm": { + "description": "Update your configuration information for {name}.", + "data": { + "url": "[%key:common::config_flow::data::url%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "url_error": "The URL could not be opened.", + "no_feed_entries": "The URL seems not to serve any feed entries." + } + }, + "options": { + "step": { + "init": { + "data": { + "max_entries": "Maximum feed entries" + }, + "data_description": { + "max_entries": "The maximum number of entries to extract from each feed." + } + } + } + }, + "issues": { + "import_yaml_error_url_error": { + "title": "The Feedreader YAML configuration import failed", + "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessable for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." + }, + "import_yaml_error_no_feed_entries": { + "title": "[%key:component::feedreader::issues::import_yaml_error_url_error::title%]", + "description": "Configuring the Feedreader using YAML is being removed but when trying to import the YAML configuration for `{url}` no feed entries were found.\n\nPlease verify that url serves any feed entries and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." + } + } +} diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index e5086166ff5..5e1be36f398 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio from functools import cached_property import re -from typing import Generic, TypeVar from haffmpeg.core import HAFFmpeg from haffmpeg.tools import IMAGE_JPEG, FFVersion, ImageFrame @@ -29,8 +28,6 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.signal_type import SignalType -_HAFFmpegT = TypeVar("_HAFFmpegT", bound=HAFFmpeg) - DOMAIN = "ffmpeg" SERVICE_START = "start" @@ -179,7 +176,7 @@ class FFmpegManager: return CONTENT_TYPE_MULTIPART.format("ffserver") -class FFmpegBase(Entity, Generic[_HAFFmpegT]): +class FFmpegBase[_HAFFmpegT: HAFFmpeg](Entity): """Interface object for FFmpeg.""" _attr_should_poll = False diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index d5030d4530e..a9e1de2ea05 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, TypeVar +from typing import Any from haffmpeg.core import HAFFmpeg import haffmpeg.sensor as ffmpeg_sensor @@ -27,8 +27,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_HAFFmpegT = TypeVar("_HAFFmpegT", bound=HAFFmpeg) - CONF_RESET = "reset" CONF_CHANGES = "changes" CONF_REPEAT_TIME = "repeat_time" @@ -70,7 +68,9 @@ async def async_setup_platform( async_add_entities([entity]) -class FFmpegBinarySensor(FFmpegBase[_HAFFmpegT], BinarySensorEntity): +class FFmpegBinarySensor[_HAFFmpegT: HAFFmpeg]( + FFmpegBase[_HAFFmpegT], BinarySensorEntity +): """A binary sensor which use FFmpeg for noise detection.""" def __init__(self, ffmpeg: _HAFFmpegT, config: dict[str, Any]) -> None: diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 271e3981b71..faa82815b8d 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -44,7 +44,7 @@ class FibaroLock(FibaroDevice, LockEntity): def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - self.action("unsecure") + self.action("unsecure") # codespell:ignore unsecure self._attr_is_locked = False def update(self) -> None: diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index ed31fa957dd..aa3e241cc81 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -1 +1,123 @@ """The file component.""" + +from homeassistant.components.notify import migrate_notify_issue +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_FILE_PATH, + CONF_NAME, + CONF_PLATFORM, + CONF_SCAN_INTERVAL, + Platform, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + config_validation as cv, + discovery, + issue_registry as ir, +) +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA +from .sensor import PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA + +IMPORT_SCHEMA = { + Platform.SENSOR: SENSOR_PLATFORM_SCHEMA, + Platform.NOTIFY: NOTIFY_PLATFORM_SCHEMA, +} + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the file integration.""" + + hass.data[DOMAIN] = config + if hass.config_entries.async_entries(DOMAIN): + # We skip import in case we already have config entries + return True + # The use of the legacy notify service was deprecated with HA Core 2024.6.0 + # and will be removed with HA Core 2024.12 + migrate_notify_issue(hass, DOMAIN, "File", "2024.12.0") + # The YAML config was imported with HA Core 2024.6.0 and will be removed with + # HA Core 2024.12 + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + learn_more_url="https://www.home-assistant.io/integrations/file/", + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "File", + }, + ) + + # Import the YAML config into separate config entries + platforms_config: dict[Platform, list[ConfigType]] = { + domain: config[domain] for domain in PLATFORMS if domain in config + } + for domain, items in platforms_config.items(): + for item in items: + if item[CONF_PLATFORM] == DOMAIN: + file_config_item = IMPORT_SCHEMA[domain](item) + file_config_item[CONF_PLATFORM] = domain + if CONF_SCAN_INTERVAL in file_config_item: + del file_config_item[CONF_SCAN_INTERVAL] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=file_config_item, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a file component entry.""" + config = dict(entry.data) + filepath: str = config[CONF_FILE_PATH] + if filepath and not await hass.async_add_executor_job( + hass.config.is_allowed_path, filepath + ): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="dir_not_allowed", + translation_placeholders={"filename": filepath}, + ) + + await hass.config_entries.async_forward_entry_setups( + entry, [Platform(entry.data[CONF_PLATFORM])] + ) + if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data: + # New notify entities are being setup through the config entry, + # but during the deprecation period we want to keep the legacy notify platform, + # so we forward the setup config through discovery. + # Only the entities from yaml will still be available as legacy service. + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + config, + hass.data[DOMAIN], + ) + ) + + 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, [entry.data[CONF_PLATFORM]] + ) diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py new file mode 100644 index 00000000000..2d729473929 --- /dev/null +++ b/homeassistant/components/file/config_flow.py @@ -0,0 +1,117 @@ +"""Config flow for file integration.""" + +import os +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_FILE_PATH, + CONF_FILENAME, + CONF_NAME, + CONF_PLATFORM, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, + Platform, +) +from homeassistant.helpers.selector import ( + BooleanSelector, + BooleanSelectorConfig, + TemplateSelector, + TemplateSelectorConfig, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN + +BOOLEAN_SELECTOR = BooleanSelector(BooleanSelectorConfig()) +TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) +TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) + +FILE_FLOW_SCHEMAS = { + Platform.SENSOR.value: vol.Schema( + { + vol.Required(CONF_FILE_PATH): TEXT_SELECTOR, + vol.Optional(CONF_VALUE_TEMPLATE): TEMPLATE_SELECTOR, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): TEXT_SELECTOR, + } + ), + Platform.NOTIFY.value: vol.Schema( + { + vol.Required(CONF_FILE_PATH): TEXT_SELECTOR, + vol.Optional(CONF_TIMESTAMP, default=False): BOOLEAN_SELECTOR, + } + ), +} + + +class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a file config flow.""" + + VERSION = 1 + + async def validate_file_path(self, file_path: str) -> bool: + """Ensure the file path is valid.""" + return await self.hass.async_add_executor_job( + self.hass.config.is_allowed_path, file_path + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + return self.async_show_menu( + step_id="user", + menu_options=["notify", "sensor"], + ) + + async def _async_handle_step( + self, platform: str, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle file config flow step.""" + errors: dict[str, str] = {} + if user_input: + user_input[CONF_PLATFORM] = platform + self._async_abort_entries_match(user_input) + if not await self.validate_file_path(user_input[CONF_FILE_PATH]): + errors[CONF_FILE_PATH] = "not_allowed" + else: + title = f"{DEFAULT_NAME} [{user_input[CONF_FILE_PATH]}]" + return self.async_create_entry(data=user_input, title=title) + + return self.async_show_form( + step_id=platform, data_schema=FILE_FLOW_SCHEMAS[platform], errors=errors + ) + + async def async_step_notify( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle file notifier config flow.""" + return await self._async_handle_step(Platform.NOTIFY.value, user_input) + + async def async_step_sensor( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle file sensor config flow.""" + return await self._async_handle_step(Platform.SENSOR.value, user_input) + + async def async_step_import( + self, import_data: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Import `file`` config from configuration.yaml.""" + assert import_data is not None + self._async_abort_entries_match(import_data) + platform = import_data[CONF_PLATFORM] + name: str = import_data.get(CONF_NAME, DEFAULT_NAME) + file_name: str + if platform == Platform.NOTIFY: + file_name = import_data.pop(CONF_FILENAME) + file_path: str = os.path.join(self.hass.config.config_dir, file_name) + import_data[CONF_FILE_PATH] = file_path + else: + file_path = import_data[CONF_FILE_PATH] + title = f"{name} [{file_path}]" + return self.async_create_entry(title=title, data=import_data) diff --git a/homeassistant/components/file/const.py b/homeassistant/components/file/const.py new file mode 100644 index 00000000000..0fa9f8a421b --- /dev/null +++ b/homeassistant/components/file/const.py @@ -0,0 +1,8 @@ +"""Constants for the file integration.""" + +DOMAIN = "file" + +CONF_TIMESTAMP = "timestamp" + +DEFAULT_NAME = "File" +FILE_ICON = "mdi:file" diff --git a/homeassistant/components/file/manifest.json b/homeassistant/components/file/manifest.json index fb09e5151f2..37bb108e1d5 100644 --- a/homeassistant/components/file/manifest.json +++ b/homeassistant/components/file/manifest.json @@ -2,6 +2,7 @@ "domain": "file", "name": "File", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/file", "iot_class": "local_polling", "requirements": ["file-read-backwards==2.0.0"] diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 50e6cec09a8..244bd69aa32 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -2,7 +2,10 @@ from __future__ import annotations +from functools import partial +import logging import os +from types import MappingProxyType from typing import Any, TextIO import voluptuous as vol @@ -12,15 +15,25 @@ from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService, + NotifyEntity, + NotifyEntityFeature, + migrate_notify_issue, ) -from homeassistant.const import CONF_FILENAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -CONF_TIMESTAMP = "timestamp" +from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON +_LOGGER = logging.getLogger(__name__) + +# The legacy platform schema uses a filename, after import +# The full file path is stored in the config entry PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_FILENAME): cv.string, @@ -29,40 +42,111 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, -) -> FileNotificationService: +) -> FileNotificationService | None: """Get the file notification service.""" - filename: str = config[CONF_FILENAME] - timestamp: bool = config[CONF_TIMESTAMP] + if discovery_info is None: + # We only set up through discovery + return None + file_path: str = discovery_info[CONF_FILE_PATH] + timestamp: bool = discovery_info[CONF_TIMESTAMP] - return FileNotificationService(filename, timestamp) + return FileNotificationService(file_path, timestamp) class FileNotificationService(BaseNotificationService): """Implement the notification service for the File service.""" - def __init__(self, filename: str, add_timestamp: bool) -> None: + def __init__(self, file_path: str, add_timestamp: bool) -> None: """Initialize the service.""" - self.filename = filename + self._file_path = file_path self.add_timestamp = add_timestamp + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: + """Send a message to a file.""" + # The use of the legacy notify service was deprecated with HA Core 2024.6.0 + # and will be removed with HA Core 2024.12 + migrate_notify_issue( + self.hass, DOMAIN, "File", "2024.12.0", service_name=self._service_name + ) + await self.hass.async_add_executor_job( + partial(self.send_message, message, **kwargs) + ) + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a file.""" file: TextIO - filepath: str = os.path.join(self.hass.config.config_dir, self.filename) - with open(filepath, "a", encoding="utf8") as file: - if os.stat(filepath).st_size == 0: - title = ( - f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log" - f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" - ) - file.write(title) + filepath = self._file_path + try: + with open(filepath, "a", encoding="utf8") as file: + if os.stat(filepath).st_size == 0: + title = ( + f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log" + f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" + ) + file.write(title) - if self.add_timestamp: - text = f"{dt_util.utcnow().isoformat()} {message}\n" - else: - text = f"{message}\n" - file.write(text) + if self.add_timestamp: + text = f"{dt_util.utcnow().isoformat()} {message}\n" + else: + text = f"{message}\n" + file.write(text) + except OSError as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="write_access_failed", + translation_placeholders={"filename": filepath, "exc": f"{exc!r}"}, + ) from exc + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up notify entity.""" + unique_id = entry.entry_id + async_add_entities([FileNotifyEntity(unique_id, entry.data)]) + + +class FileNotifyEntity(NotifyEntity): + """Implement the notification entity platform for the File service.""" + + _attr_icon = FILE_ICON + _attr_supported_features = NotifyEntityFeature.TITLE + + def __init__(self, unique_id: str, config: MappingProxyType[str, Any]) -> None: + """Initialize the service.""" + self._file_path: str = config[CONF_FILE_PATH] + self._add_timestamp: bool = config.get(CONF_TIMESTAMP, False) + # Only import a name from an imported entity + self._attr_name = config.get(CONF_NAME, DEFAULT_NAME) + self._attr_unique_id = unique_id + + def send_message(self, message: str, title: str | None = None) -> None: + """Send a message to a file.""" + file: TextIO + filepath = self._file_path + try: + with open(filepath, "a", encoding="utf8") as file: + if os.stat(filepath).st_size == 0: + title = ( + f"{title or ATTR_TITLE_DEFAULT} notifications (Log" + f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" + ) + file.write(title) + + if self._add_timestamp: + text = f"{dt_util.utcnow().isoformat()} {message}\n" + else: + text = f"{message}\n" + file.write(text) + except OSError as exc: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="write_access_failed", + translation_placeholders={"filename": filepath, "exc": f"{exc!r}"}, + ) from exc diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index f70b0bce701..fa04ae7c62a 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -9,6 +9,7 @@ from file_read_backwards import FileReadBackwards import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_FILE_PATH, CONF_NAME, @@ -16,22 +17,20 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_NAME, FILE_ICON + _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "File" - -ICON = "mdi:file" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_FILE_PATH): cv.isfile, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } ) @@ -42,29 +41,44 @@ async def async_setup_platform( config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the file sensor from YAML. + + The YAML platform config is automatically + imported to a config entry, this method can be removed + when YAML support is removed. + """ + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the file sensor.""" + config = dict(entry.data) file_path: str = config[CONF_FILE_PATH] - name: str = config[CONF_NAME] + unique_id: str = entry.entry_id + name: str = config.get(CONF_NAME, DEFAULT_NAME) unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + value_template: Template | None = None - if value_template is not None: - value_template.hass = hass + if CONF_VALUE_TEMPLATE in config: + value_template = Template(config[CONF_VALUE_TEMPLATE], hass) - if hass.config.is_allowed_path(file_path): - async_add_entities([FileSensor(name, file_path, unit, value_template)], True) - else: - _LOGGER.error("'%s' is not an allowed directory", file_path) + async_add_entities( + [FileSensor(unique_id, name, file_path, unit, value_template)], True + ) class FileSensor(SensorEntity): """Implementation of a file sensor.""" - _attr_icon = ICON + _attr_icon = FILE_ICON def __init__( self, + unique_id: str, name: str, file_path: str, unit_of_measurement: str | None, @@ -75,6 +89,7 @@ class FileSensor(SensorEntity): self._file_path = file_path self._attr_native_unit_of_measurement = unit_of_measurement self._val_tpl = value_template + self._attr_unique_id = unique_id def update(self) -> None: """Get the latest entry from a file and updates the state.""" diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json new file mode 100644 index 00000000000..9d49e6300e9 --- /dev/null +++ b/homeassistant/components/file/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "description": "Make a choice", + "menu_options": { + "sensor": "Set up a file based sensor", + "notify": "Set up a notification service" + } + }, + "sensor": { + "title": "File sensor", + "description": "Set up a file based sensor", + "data": { + "file_path": "File path", + "value_template": "Value template", + "unit_of_measurement": "Unit of measurement" + }, + "data_description": { + "file_path": "The local file path to retrieve the sensor value from", + "value_template": "A template to render the the sensors value based on the file content", + "unit_of_measurement": "Unit of measurement for the sensor" + } + }, + "notify": { + "title": "Notification to file service", + "description": "Set up a service that allows to write notification to a file.", + "data": { + "file_path": "[%key:component::file::config::step::sensor::data::file_path%]", + "timestamp": "Timestamp" + }, + "data_description": { + "file_path": "A local file path to write the notification to", + "timestamp": "Add a timestamp to the notification" + } + } + }, + "error": { + "not_allowed": "Access to the selected file path is not allowed" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "dir_not_allowed": { + "message": "Access to {filename} is not allowed." + }, + "write_access_failed": { + "message": "Write access to {filename} failed: {exc}." + } + } +} diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 60caf0ef7f3..97b3f83d5bc 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -128,7 +128,7 @@ class FileUploadView(HomeAssistantView): async def _upload_file(self, request: web.Request) -> web.Response: """Handle uploaded file.""" # Increase max payload - request._client_max_size = MAX_SIZE # pylint: disable=protected-access + request._client_max_size = MAX_SIZE # noqa: SLF001 reader = await request.multipart() file_field_reader = await reader.next() diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 90d2af5d52a..602eac1f24d 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -9,11 +9,14 @@ from homeassistant.core import HomeAssistant from .const import PLATFORMS from .coordinator import FileSizeCoordinator +type FileSizeConfigEntry = ConfigEntry[FileSizeCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: FileSizeConfigEntry) -> bool: """Set up from a config entry.""" coordinator = FileSizeCoordinator(hass, entry.data[CONF_FILE_PATH]) await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index 2e59e922801..37fba19fb4e 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -19,6 +19,8 @@ _LOGGER = logging.getLogger(__name__) class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime]]): """Filesize coordinator.""" + path: pathlib.Path + def __init__(self, hass: HomeAssistant, unresolved_path: str) -> None: """Initialize filesize coordinator.""" super().__init__( @@ -29,7 +31,6 @@ class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime always_update=False, ) self._unresolved_path = unresolved_path - self._path: pathlib.Path | None = None def _get_full_path(self) -> pathlib.Path: """Check if path is valid, allowed and return full path.""" @@ -45,11 +46,11 @@ class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime def _update(self) -> os.stat_result: """Fetch file information.""" - if not self._path: - self._path = self._get_full_path() + if not hasattr(self, "path"): + self.path = self._get_full_path() try: - return self._path.stat() + return self.path.stat() except OSError as error: raise UpdateFailed(f"Can not retrieve file statistics {error}") from error diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 761513b1f48..71a4e50edfe 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from datetime import datetime import logging -import pathlib from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,13 +11,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FILE_PATH, EntityCategory, UnitOfInformation +from homeassistant.const import EntityCategory, UnitOfInformation 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 FileSizeConfigEntry from .const import DOMAIN from .coordinator import FileSizeCoordinator @@ -53,20 +52,12 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FileSizeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the platform from config entry.""" - - path = entry.data[CONF_FILE_PATH] - get_path = await hass.async_add_executor_job(pathlib.Path, path) - fullpath = str(get_path.absolute()) - - coordinator = FileSizeCoordinator(hass, fullpath) - await coordinator.async_config_entry_first_refresh() - async_add_entities( - FilesizeEntity(description, fullpath, entry.entry_id, coordinator) + FilesizeEntity(description, entry.entry_id, entry.runtime_data) for description in SENSOR_TYPES ) @@ -79,13 +70,12 @@ class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity): def __init__( self, description: SensorEntityDescription, - path: str, entry_id: str, coordinator: FileSizeCoordinator, ) -> None: """Initialize the Filesize sensor.""" super().__init__(coordinator) - base_name = path.split("/")[-1] + base_name = str(coordinator.path.absolute()).rsplit("/", maxsplit=1)[-1] self._attr_unique_id = ( entry_id if description.key == "file" else f"{entry_id}-{description.key}" ) diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index c3ee594e47d..9173a2b3392 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -184,7 +184,7 @@ class FireServiceRotaClient: async def update_call(self, func, *args): """Perform update call and return data.""" if self.token_refresh_failure: - return + return None try: return await self._hass.async_add_executor_job(func, *args) diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py index 283fd585d35..26fbe596aa8 100644 --- a/homeassistant/components/firmata/__init__.py +++ b/homeassistant/components/firmata/__init__.py @@ -1,6 +1,5 @@ """Support for Arduino-compatible Microcontrollers through Firmata.""" -import asyncio from copy import copy import logging @@ -212,16 +211,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Shutdown and close a Firmata board for a config entry.""" _LOGGER.debug("Closing Firmata board %s", config_entry.data[CONF_NAME]) - - unload_entries = [] - for conf, platform in CONF_PLATFORM_MAP.items(): - if conf in config_entry.data: - unload_entries.append( - hass.config_entries.async_forward_entry_unload(config_entry, platform) - ) - results = [] - if unload_entries: - results = await asyncio.gather(*unload_entries) + results: list[bool] = [] + if platforms := [ + platform + for conf, platform in CONF_PLATFORM_MAP.items() + if conf in config_entry.data + ]: + results.append( + await hass.config_entries.async_unload_platforms(config_entry, platforms) + ) results.append(await hass.data[DOMAIN].pop(config_entry.entry_id).async_reset()) return False not in results diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index 9573627e130..641a0a74fa7 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -30,7 +30,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -FirmataPinType = int | str +type FirmataPinType = int | str class FirmataBoard: diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 0f49c0858f5..1eed5acbcca 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable import logging -from typing import Any, TypeVar, cast +from typing import Any, cast from fitbit import Fitbit from fitbit.exceptions import HTTPException, HTTPUnauthorized @@ -24,9 +24,6 @@ CONF_REFRESH_TOKEN = "refresh_token" CONF_EXPIRES_AT = "expires_at" -_T = TypeVar("_T") - - class FitbitApi(ABC): """Fitbit client library wrapper base class. @@ -129,7 +126,7 @@ class FitbitApi(ABC): dated_results: list[dict[str, Any]] = response[key] return dated_results[-1] - async def _run(self, func: Callable[[], _T]) -> _T: + async def _run[_T](self, func: Callable[[], _T]) -> _T: """Run client command.""" try: return await self._hass.async_add_executor_job(func) diff --git a/homeassistant/components/fivem/config_flow.py b/homeassistant/components/fivem/config_flow.py index 7cc553a6a72..b5ced70b846 100644 --- a/homeassistant/components/fivem/config_flow.py +++ b/homeassistant/components/fivem/config_flow.py @@ -59,7 +59,7 @@ class FiveMConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidGameNameError: errors["base"] = "invalid_game_name" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py index 087f70869bb..db1918d3f13 100644 --- a/homeassistant/components/flexit_bacnet/config_flow.py +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -46,7 +46,7 @@ class FlexitBacnetConfigFlow(ConfigFlow, domain=DOMAIN): await device.update() except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 41b58431977..7fe5fda3f4e 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -65,7 +65,7 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py index 0b0230f536e..3d616feb37f 100644 --- a/homeassistant/components/flipr/config_flow.py +++ b/homeassistant/components/flipr/config_flow.py @@ -44,7 +44,7 @@ class FliprConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except (Timeout, ConnectionError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: errors["base"] = "unknown" _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py index 0d65e12a2a3..b619df91d59 100644 --- a/homeassistant/components/flo/__init__.py +++ b/homeassistant/components/flo/__init__.py @@ -13,7 +13,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLIENT, DOMAIN -from .device import FloDeviceDataUpdateCoordinator +from .coordinator import FloDeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py index 84ce9d2bb7b..20f5d7822d2 100644 --- a/homeassistant/components/flo/binary_sensor.py +++ b/homeassistant/components/flo/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as FLO_DOMAIN -from .device import FloDeviceDataUpdateCoordinator +from .coordinator import FloDeviceDataUpdateCoordinator from .entity import FloEntity diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/coordinator.py similarity index 98% rename from homeassistant/components/flo/device.py rename to homeassistant/components/flo/coordinator.py index 2d99b8ac7a7..0edb80004fd 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/coordinator.py @@ -17,7 +17,7 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as FLO_DOMAIN, LOGGER -class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module +class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): """Flo device object.""" _failure_count: int = 0 diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index 62090d67194..b0cf8d04313 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -6,7 +6,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity import Entity from .const import DOMAIN as FLO_DOMAIN -from .device import FloDeviceDataUpdateCoordinator +from .coordinator import FloDeviceDataUpdateCoordinator class FloEntity(Entity): diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 9b85f3a855b..7419b0a1c3b 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as FLO_DOMAIN -from .device import FloDeviceDataUpdateCoordinator +from .coordinator import FloDeviceDataUpdateCoordinator from .entity import FloEntity diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 41690c28ae4..ab201dfb906 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -14,7 +14,7 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as FLO_DOMAIN -from .device import FloDeviceDataUpdateCoordinator +from .coordinator import FloDeviceDataUpdateCoordinator from .entity import FloEntity ATTR_REVERT_TO_MODE = "revert_to_mode" diff --git a/homeassistant/components/flume/entity.py b/homeassistant/components/flume/entity.py index 139094e9ae3..2698a319220 100644 --- a/homeassistant/components/flume/entity.py +++ b/homeassistant/components/flume/entity.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import TypeVar - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -15,17 +13,12 @@ from .coordinator import ( FlumeNotificationDataUpdateCoordinator, ) -_FlumeCoordinatorT = TypeVar( - "_FlumeCoordinatorT", - bound=( - FlumeDeviceDataUpdateCoordinator - | FlumeDeviceConnectionUpdateCoordinator - | FlumeNotificationDataUpdateCoordinator - ), -) - -class FlumeEntity(CoordinatorEntity[_FlumeCoordinatorT]): +class FlumeEntity[ + _FlumeCoordinatorT: FlumeDeviceDataUpdateCoordinator + | FlumeDeviceConnectionUpdateCoordinator + | FlumeNotificationDataUpdateCoordinator +](CoordinatorEntity[_FlumeCoordinatorT]): """Base entity class.""" _attr_attribution = "Data provided by Flume API" diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 203c9094b2e..96395e5403f 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -1,6 +1,9 @@ """Sensor for displaying the number of result from Flume.""" -from pyflume import FlumeData +from typing import Any + +from pyflume import FlumeAuth, FlumeData +from requests import Session from homeassistant.components.sensor import ( SensorDeviceClass, @@ -87,6 +90,26 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = ( ) +def make_flume_datas( + http_session: Session, flume_auth: FlumeAuth, flume_devices: list[dict[str, Any]] +) -> dict[str, FlumeData]: + """Create FlumeData objects for each device.""" + flume_datas: dict[str, FlumeData] = {} + for device in flume_devices: + device_id = device[KEY_DEVICE_ID] + device_timezone = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_TIMEZONE] + flume_data = FlumeData( + flume_auth, + device_id, + device_timezone, + scan_interval=DEVICE_SCAN_INTERVAL, + update_on_init=False, + http_session=http_session, + ) + flume_datas[device_id] = flume_data + return flume_datas + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -96,27 +119,22 @@ async def async_setup_entry( flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] flume_devices = flume_domain_data[FLUME_DEVICES] - flume_auth = flume_domain_data[FLUME_AUTH] - http_session = flume_domain_data[FLUME_HTTP_SESSION] + flume_auth: FlumeAuth = flume_domain_data[FLUME_AUTH] + http_session: Session = flume_domain_data[FLUME_HTTP_SESSION] flume_devices = [ device for device in get_valid_flume_devices(flume_devices) if device[KEY_DEVICE_TYPE] == FLUME_TYPE_SENSOR ] - flume_entity_list = [] - for device in flume_devices: - device_id = device[KEY_DEVICE_ID] - device_timezone = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_TIMEZONE] - device_location_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] + flume_entity_list: list[FlumeSensor] = [] + flume_datas = await hass.async_add_executor_job( + make_flume_datas, http_session, flume_auth, flume_devices + ) - flume_device = FlumeData( - flume_auth, - device_id, - device_timezone, - scan_interval=DEVICE_SCAN_INTERVAL, - update_on_init=False, - http_session=http_session, - ) + for device in flume_devices: + device_id: str = device[KEY_DEVICE_ID] + device_location_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] + flume_device = flume_datas[device_id] coordinator = FlumeDeviceDataUpdateCoordinator( hass=hass, flume_device=flume_device diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index 63f58ff64c4..fac31d445cc 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -50,6 +50,8 @@ from homeassistant.util.dt import as_local, utcnow as dt_utcnow _LOGGER = logging.getLogger(__name__) +ATTR_UNIQUE_ID = "unique_id" + CONF_START_TIME = "start_time" CONF_STOP_TIME = "stop_time" CONF_START_CT = "start_colortemp" @@ -88,6 +90,7 @@ PLATFORM_SCHEMA = vol.Schema( ), vol.Optional(CONF_INTERVAL, default=30): cv.positive_int, vol.Optional(ATTR_TRANSITION, default=30): VALID_TRANSITION, + vol.Optional(ATTR_UNIQUE_ID): cv.string, } ) @@ -151,6 +154,7 @@ async def async_setup_platform( mode = config.get(CONF_MODE) interval = config.get(CONF_INTERVAL) transition = config.get(ATTR_TRANSITION) + unique_id = config.get(ATTR_UNIQUE_ID) flux = FluxSwitch( name, hass, @@ -165,6 +169,7 @@ async def async_setup_platform( mode, interval, transition, + unique_id, ) async_add_entities([flux]) @@ -194,6 +199,7 @@ class FluxSwitch(SwitchEntity, RestoreEntity): mode, interval, transition, + unique_id, ): """Initialize the Flux switch.""" self._name = name @@ -209,6 +215,7 @@ class FluxSwitch(SwitchEntity, RestoreEntity): self._mode = mode self._interval = interval self._transition = transition + self._attr_unique_id = unique_id self.unsub_tracker = None @property diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index 3f0b9e8f6da..800a95509c2 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -23,10 +23,11 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN +from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -103,23 +104,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: learn_more_url="https://www.home-assistant.io/docs/configuration/basic/#allowlist_external_dirs", ) return False - await hass.async_add_executor_job(Watcher, path, patterns, hass) + await hass.async_add_executor_job(Watcher, path, patterns, hass, entry.entry_id) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -def create_event_handler(patterns: list[str], hass: HomeAssistant) -> EventHandler: +def create_event_handler( + patterns: list[str], hass: HomeAssistant, entry_id: str +) -> EventHandler: """Return the Watchdog EventHandler object.""" - - return EventHandler(patterns, hass) + return EventHandler(patterns, hass, entry_id) class EventHandler(PatternMatchingEventHandler): """Class for handling Watcher events.""" - def __init__(self, patterns: list[str], hass: HomeAssistant) -> None: + def __init__(self, patterns: list[str], hass: HomeAssistant, entry_id: str) -> None: """Initialise the EventHandler.""" super().__init__(patterns) self.hass = hass + self.entry_id = entry_id def process(self, event: FileSystemEvent, moved: bool = False) -> None: """On Watcher event, fire HA event.""" @@ -133,20 +137,22 @@ class EventHandler(PatternMatchingEventHandler): "folder": folder, } + _extra = {} if moved: event = cast(FileSystemMovedEvent, event) dest_folder, dest_file_name = os.path.split(event.dest_path) - fireable.update( - { - "dest_path": event.dest_path, - "dest_file": dest_file_name, - "dest_folder": dest_folder, - } - ) + _extra = { + "dest_path": event.dest_path, + "dest_file": dest_file_name, + "dest_folder": dest_folder, + } + fireable.update(_extra) self.hass.bus.fire( DOMAIN, fireable, ) + signal = f"folder_watcher-{self.entry_id}" + dispatcher_send(self.hass, signal, event.event_type, fireable) def on_modified(self, event: FileModifiedEvent) -> None: """File modified.""" @@ -172,20 +178,25 @@ class EventHandler(PatternMatchingEventHandler): class Watcher: """Class for starting Watchdog.""" - def __init__(self, path: str, patterns: list[str], hass: HomeAssistant) -> None: + def __init__( + self, path: str, patterns: list[str], hass: HomeAssistant, entry_id: str + ) -> None: """Initialise the watchdog observer.""" self._observer = Observer() self._observer.schedule( - create_event_handler(patterns, hass), path, recursive=True + create_event_handler(patterns, hass, entry_id), path, recursive=True ) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) + if not hass.is_running: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) + else: + self.startup(None) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) - def startup(self, event: Event) -> None: + def startup(self, event: Event | None) -> None: """Start the watcher.""" self._observer.start() - def shutdown(self, event: Event) -> None: + def shutdown(self, event: Event | None) -> None: """Shutdown the watcher.""" self._observer.stop() self._observer.join() diff --git a/homeassistant/components/folder_watcher/config_flow.py b/homeassistant/components/folder_watcher/config_flow.py index 50d198df3c3..fe43cd1c725 100644 --- a/homeassistant/components/folder_watcher/config_flow.py +++ b/homeassistant/components/folder_watcher/config_flow.py @@ -34,7 +34,7 @@ async def validate_setup( """Check path is a folder.""" value: str = user_input[CONF_FOLDER] dir_in = os.path.expanduser(str(value)) - handler.parent_handler._async_abort_entries_match({CONF_FOLDER: value}) # pylint: disable=protected-access + handler.parent_handler._async_abort_entries_match({CONF_FOLDER: value}) # noqa: SLF001 if not os.path.isdir(dir_in): raise SchemaFlowError("not_dir") diff --git a/homeassistant/components/folder_watcher/const.py b/homeassistant/components/folder_watcher/const.py index 22dae3b9164..c95f35a1bc1 100644 --- a/homeassistant/components/folder_watcher/const.py +++ b/homeassistant/components/folder_watcher/const.py @@ -1,6 +1,10 @@ """Constants for Folder watcher.""" +from homeassistant.const import Platform + CONF_FOLDER = "folder" CONF_PATTERNS = "patterns" DEFAULT_PATTERN = "*" DOMAIN = "folder_watcher" + +PLATFORMS = [Platform.EVENT] diff --git a/homeassistant/components/folder_watcher/event.py b/homeassistant/components/folder_watcher/event.py new file mode 100644 index 00000000000..7158930e116 --- /dev/null +++ b/homeassistant/components/folder_watcher/event.py @@ -0,0 +1,75 @@ +"""Support for Folder watcher event entities.""" + +from __future__ import annotations + +from typing import Any + +from watchdog.events import ( + EVENT_TYPE_CLOSED, + EVENT_TYPE_CREATED, + EVENT_TYPE_DELETED, + EVENT_TYPE_MODIFIED, + EVENT_TYPE_MOVED, +) + +from homeassistant.components.event import EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Folder Watcher event.""" + + async_add_entities([FolderWatcherEventEntity(entry)]) + + +class FolderWatcherEventEntity(EventEntity): + """Representation of a Folder watcher event entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_event_types = [ + EVENT_TYPE_CLOSED, + EVENT_TYPE_CREATED, + EVENT_TYPE_DELETED, + EVENT_TYPE_MODIFIED, + EVENT_TYPE_MOVED, + ] + _attr_name = None + _attr_translation_key = DOMAIN + + def __init__( + self, + entry: ConfigEntry, + ) -> None: + """Initialise a Folder watcher event entity.""" + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Folder watcher", + ) + self._attr_unique_id = entry.entry_id + self._entry = entry + + @callback + def _async_handle_event(self, event: str, _extra: dict[str, Any]) -> None: + """Handle the event.""" + self._trigger_event(event, _extra) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + signal = f"folder_watcher-{self._entry.entry_id}" + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._async_handle_event) + ) diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json index bd1742b8ce3..da1e3c1962a 100644 --- a/homeassistant/components/folder_watcher/strings.json +++ b/homeassistant/components/folder_watcher/strings.json @@ -42,5 +42,20 @@ "title": "The Folder Watcher configuration for {path} could not start", "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue." } + }, + "entity": { + "sensor": { + "folder_watcher": { + "state_attributes": { + "event_type": { "name": "Event type" }, + "path": { "name": "Path" }, + "file": { "name": "File" }, + "folder": { "name": "Folder" }, + "dest_path": { "name": "Destination path" }, + "dest_file": { "name": "Destination file" }, + "dest_folder": { "name": "Destination folder" } + } + } + } } } diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index f4cb1d0a631..00be13f1235 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -11,12 +11,13 @@ from .const import ( CONF_DAMPING_EVENING, CONF_DAMPING_MORNING, CONF_MODULES_POWER, - DOMAIN, ) from .coordinator import ForecastSolarDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] + async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old config entry.""" @@ -36,12 +37,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> bool: """Set up Forecast.Solar from a config entry.""" coordinator = ForecastSolarDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -52,11 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index a9bcebdb3cd..cb33ac5dc5a 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -4,15 +4,11 @@ from __future__ import annotations from typing import Any -from forecast_solar import Estimate - 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 from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from . import ForecastSolarConfigEntry TO_REDACT = { CONF_API_KEY, @@ -22,10 +18,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ForecastSolarConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[Estimate] = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": { diff --git a/homeassistant/components/forecast_solar/energy.py b/homeassistant/components/forecast_solar/energy.py index f4d03f26299..9031e5c1e1d 100644 --- a/homeassistant/components/forecast_solar/energy.py +++ b/homeassistant/components/forecast_solar/energy.py @@ -4,19 +4,21 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .coordinator import ForecastSolarDataUpdateCoordinator async def async_get_solar_forecast( hass: HomeAssistant, config_entry_id: str ) -> dict[str, dict[str, float | int]] | None: """Get solar forecast for a config entry ID.""" - if (coordinator := hass.data[DOMAIN].get(config_entry_id)) is None: + if ( + entry := hass.config_entries.async_get_entry(config_entry_id) + ) is None or not isinstance(entry.runtime_data, ForecastSolarDataUpdateCoordinator): return None return { "wh_hours": { timestamp.isoformat(): val - for timestamp, val in coordinator.data.wh_period.items() + for timestamp, val in entry.runtime_data.data.wh_period.items() } } diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 8d35b38765a..c1fa971a89d 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -24,6 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import ForecastSolarConfigEntry from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator @@ -133,10 +133,12 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ForecastSolarConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator: ForecastSolarDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ForecastSolarSensorEntity( diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 44596a448fc..98ad2f28caf 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -699,7 +699,8 @@ class ForkedDaapdMaster(MediaPlayerEntity): return if kwargs.get(ATTR_MEDIA_ANNOUNCE): - return await self._async_announce(media_id) + await self._async_announce(media_id) + return # if kwargs[ATTR_MEDIA_ENQUEUE] is None, we assume MediaPlayerEnqueue.REPLACE # if kwargs[ATTR_MEDIA_ENQUEUE] is True, we assume MediaPlayerEnqueue.ADD @@ -709,11 +710,12 @@ class ForkedDaapdMaster(MediaPlayerEntity): ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE ) if enqueue in {True, MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE}: - return await self.api.add_to_queue( + await self.api.add_to_queue( uris=media_id, playback="start", clear=enqueue == MediaPlayerEnqueue.REPLACE, ) + return current_position = next( ( @@ -724,13 +726,14 @@ class ForkedDaapdMaster(MediaPlayerEntity): 0, ) if enqueue == MediaPlayerEnqueue.NEXT: - return await self.api.add_to_queue( + await self.api.add_to_queue( uris=media_id, playback="start", position=current_position + 1, ) + return # enqueue == MediaPlayerEnqueue.PLAY - return await self.api.add_to_queue( + await self.api.add_to_queue( uris=media_id, playback="start", position=current_position, diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 3169e9a842f..7cc5bab7d16 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -48,7 +48,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> FortiOSDeviceScanner except ConnectionError as ex: _LOGGER.error("ConnectionError to FortiOS API: %s", ex) return None - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 _LOGGER.error("Failed to login to FortiOS API: %s", ex) return None diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index ab9bc32c6b0..8a005f19f09 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -110,7 +110,7 @@ class FoscamConfigFlow(ConfigFlow, domain=DOMAIN): except AbortFlow: raise - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index 4c62b928dff..da5983f9374 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -52,6 +52,8 @@ async def async_setup_entry( class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): """Representation of a Freebox alarm.""" + _attr_code_arm_required = False + def __init__( self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] ) -> None: diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index b790556b8e3..88e2165defd 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -89,7 +89,7 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN): ) errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Unknown error connecting with Freebox router at %s", self._data[CONF_HOST], diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index 3ffa80429e8..96c3bcc2496 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -72,5 +72,5 @@ class FreeboxSwitch(SwitchEntity): async def async_update(self) -> None: """Get the state and update it.""" - datas = await self._router.wifi.get_global_config() - self._attr_is_on = bool(datas["enabled"]) + data = await self._router.wifi.get_global_config() + self._attr_is_on = bool(data["enabled"]) diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index bab97569eda..1e1830ca1c1 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -13,7 +13,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .common import AvmWrapper, FritzData from .const import ( DATA_FRITZ, DEFAULT_SSL, @@ -22,6 +21,7 @@ from .const import ( FRITZ_EXCEPTIONS, PLATFORMS, ) +from .coordinator import AvmWrapper, FritzData from .services import async_setup_services, async_unload_services _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index adca977e179..cb1f698bdca 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -16,18 +16,14 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - AvmWrapper, - ConnectionInfo, - FritzBoxBaseCoordinatorEntity, - FritzEntityDescription, -) from .const import DOMAIN +from .coordinator import AvmWrapper, ConnectionInfo +from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class FritzBinarySensorEntityDescription( BinarySensorEntityDescription, FritzEntityDescription ): diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index cfd0e09412d..263521d23f4 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Final +from typing import Any, Final from homeassistant.components.button import ( ButtonDeviceClass, @@ -19,8 +19,9 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, FritzData, FritzDevice, FritzDeviceBase, _is_tracked from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DATA_FRITZ, DOMAIN, MeshRoles +from .coordinator import AvmWrapper, FritzData, FritzDevice, _is_tracked +from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) @@ -29,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) class FritzButtonDescription(ButtonEntityDescription): """Class to describe a Button entity.""" - press_action: Callable + press_action: Callable[[AvmWrapper], Any] BUTTONS: Final = [ diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index fdafd486b29..4cdd4c19c1b 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -91,7 +91,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return ERROR_AUTH_INVALID except FritzConnectionException: return ERROR_CANNOT_CONNECT - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return ERROR_UNKNOWN diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 3794a83dd7f..9a266507c25 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -57,9 +57,6 @@ ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured" ERROR_UNKNOWN = "unknown_error" FRITZ_SERVICES = "fritz_services" -SERVICE_REBOOT = "reboot" -SERVICE_RECONNECT = "reconnect" -SERVICE_CLEANUP = "cleanup" SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password" SWITCH_TYPE_DEFLECTION = "CallDeflection" diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/coordinator.py similarity index 79% rename from homeassistant/components/fritz/common.py rename to homeassistant/components/fritz/coordinator.py index ec893e99ab1..8a55084d7ef 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/coordinator.py @@ -28,33 +28,24 @@ from homeassistant.components.device_tracker import ( DEFAULT_CONSIDER_HOME, DOMAIN as DEVICE_TRACKER_DOMAIN, ) -from homeassistant.components.switch import DOMAIN as DEVICE_SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - update_coordinator, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ( CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY, - DEFAULT_DEVICE_NAME, DEFAULT_HOST, DEFAULT_SSL, DEFAULT_USERNAME, DOMAIN, FRITZ_EXCEPTIONS, - SERVICE_CLEANUP, - SERVICE_REBOOT, - SERVICE_RECONNECT, SERVICE_SET_GUEST_WIFI_PW, MeshRoles, ) @@ -86,13 +77,6 @@ def device_filter_out_from_trackers( return bool(reason) -def _cleanup_entity_filter(device: er.RegistryEntry) -> bool: - """Filter only relevant entities.""" - return device.domain == DEVICE_TRACKER_DOMAIN or ( - device.domain == DEVICE_SWITCH_DOMAIN and "_internet_access" in device.entity_id - ) - - def _ha_is_stopping(activity: str) -> None: """Inform that HA is stopping.""" _LOGGER.info("Cannot execute %s: HomeAssistant is shutting down", activity) @@ -175,11 +159,11 @@ class UpdateCoordinatorDataType(TypedDict): entity_states: dict[str, StateType | bool] -class FritzBoxTools( - update_coordinator.DataUpdateCoordinator[UpdateCoordinatorDataType] -): # pylint: disable=hass-enforce-coordinator-module +class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """FritzBoxTools class.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -342,7 +326,7 @@ class FritzBoxTools( "call_deflections" ] = await self.async_update_call_deflections() except FRITZ_EXCEPTIONS as ex: - raise update_coordinator.UpdateFailed(ex) from ex + raise UpdateFailed(ex) from ex _LOGGER.debug("enity_data: %s", entity_data) return entity_data @@ -441,7 +425,7 @@ class FritzBoxTools( hosts_info = await self.hass.async_add_executor_job( self.fritz_hosts.get_hosts_info ) - except Exception as ex: # pylint: disable=[broad-except] + except Exception as ex: # noqa: BLE001 if not self.hass.is_stopping: raise HomeAssistantError( translation_domain=DOMAIN, @@ -660,71 +644,37 @@ class FritzBoxTools( self.fritz_guest_wifi.set_password, password, length ) - async def async_trigger_cleanup( - self, config_entry: ConfigEntry | None = None - ) -> None: + async def async_trigger_cleanup(self) -> None: """Trigger device trackers cleanup.""" device_hosts = await self._async_update_hosts_info() entity_reg: er.EntityRegistry = er.async_get(self.hass) + config_entry = self.config_entry - if config_entry is None: - if self.config_entry is None: - return - config_entry = self.config_entry - - ha_entity_reg_list: list[er.RegistryEntry] = er.async_entries_for_config_entry( + entities: list[er.RegistryEntry] = er.async_entries_for_config_entry( entity_reg, config_entry.entry_id ) - entities_removed: bool = False - device_hosts_macs = set() - device_hosts_names = set() - for mac, device in device_hosts.items(): - device_hosts_macs.add(mac) - device_hosts_names.add(device.name) - - for entry in ha_entity_reg_list: - if entry.original_name is None: - continue - entry_name = entry.name or entry.original_name - entry_host = entry_name.split(" ")[0] - entry_mac = entry.unique_id.split("_")[0] - - if not _cleanup_entity_filter(entry) or ( - entry_mac in device_hosts_macs and entry_host in device_hosts_names - ): - _LOGGER.debug( - "Skipping entity %s [mac=%s, host=%s]", - entry_name, - entry_mac, - entry_host, - ) - continue - _LOGGER.info("Removing entity: %s", entry_name) - entity_reg.async_remove(entry.entity_id) - entities_removed = True - - if entities_removed: - self._async_remove_empty_devices(entity_reg, config_entry) - - @callback - def _async_remove_empty_devices( - self, entity_reg: er.EntityRegistry, config_entry: ConfigEntry - ) -> None: - """Remove devices with no entities.""" + orphan_macs: set[str] = set() + for entity in entities: + entry_mac = entity.unique_id.split("_")[0] + if ( + entity.domain == DEVICE_TRACKER_DOMAIN + or "_internet_access" in entity.unique_id + ) and entry_mac not in device_hosts: + _LOGGER.info("Removing orphan entity entry %s", entity.entity_id) + orphan_macs.add(entry_mac) + entity_reg.async_remove(entity.entity_id) device_reg = dr.async_get(self.hass) - device_list = dr.async_entries_for_config_entry( + orphan_connections = {(CONNECTION_NETWORK_MAC, mac) for mac in orphan_macs} + for device in dr.async_entries_for_config_entry( device_reg, config_entry.entry_id - ) - for device_entry in device_list: - if not er.async_entries_for_device( - entity_reg, - device_entry.id, - include_disabled_entities=True, - ): - _LOGGER.info("Removing device: %s", device_entry.name) - device_reg.async_remove_device(device_entry.id) + ): + if any(con in device.connections for con in orphan_connections): + _LOGGER.debug("Removing obsolete device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry.entry_id + ) async def service_fritzbox( self, service_call: ServiceCall, config_entry: ConfigEntry @@ -738,30 +688,6 @@ class FritzBoxTools( ) try: - if service_call.service == SERVICE_REBOOT: - _LOGGER.warning( - 'Service "fritz.reboot" is deprecated, please use the corresponding' - " button entity instead" - ) - await self.async_trigger_reboot() - return - - if service_call.service == SERVICE_RECONNECT: - _LOGGER.warning( - 'Service "fritz.reconnect" is deprecated, please use the' - " corresponding button entity instead" - ) - await self.async_trigger_reconnect() - return - - if service_call.service == SERVICE_CLEANUP: - _LOGGER.warning( - 'Service "fritz.cleanup" is deprecated, please use the' - " corresponding button entity instead" - ) - await self.async_trigger_cleanup(config_entry) - return - if service_call.service == SERVICE_SET_GUEST_WIFI_PW: await self.async_trigger_set_guest_password( service_call.data.get("password"), @@ -779,7 +705,7 @@ class FritzBoxTools( ) from ex -class AvmWrapper(FritzBoxTools): # pylint: disable=hass-enforce-coordinator-module +class AvmWrapper(FritzBoxTools): """Setup AVM wrapper for API calls.""" async def _async_service_call( @@ -961,50 +887,6 @@ class FritzData: wol_buttons: dict = field(default_factory=dict) -class FritzDeviceBase(update_coordinator.CoordinatorEntity[AvmWrapper]): - """Entity base class for a device connected to a FRITZ!Box device.""" - - def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None: - """Initialize a FRITZ!Box device.""" - super().__init__(avm_wrapper) - self._avm_wrapper = avm_wrapper - self._mac: str = device.mac_address - self._name: str = device.hostname or DEFAULT_DEVICE_NAME - - @property - def name(self) -> str: - """Return device name.""" - return self._name - - @property - def ip_address(self) -> str | None: - """Return the primary ip address of the device.""" - if self._mac: - return self._avm_wrapper.devices[self._mac].ip_address - return None - - @property - def mac_address(self) -> str: - """Return the mac address of the device.""" - return self._mac - - @property - def hostname(self) -> str | None: - """Return hostname of the device.""" - if self._mac: - return self._avm_wrapper.devices[self._mac].hostname - return None - - async def async_process_update(self) -> None: - """Update device.""" - raise NotImplementedError - - async def async_on_demand_update(self) -> None: - """Update state.""" - await self.async_process_update() - self.async_write_ha_state() - - class FritzDevice: """Representation of a device connected to the FRITZ!Box.""" @@ -1103,87 +985,6 @@ class SwitchInfo(TypedDict): init_state: bool -class FritzBoxBaseEntity: - """Fritz host entity base class.""" - - def __init__(self, avm_wrapper: AvmWrapper, device_name: str) -> None: - """Init device info class.""" - self._avm_wrapper = avm_wrapper - self._device_name = device_name - - @property - def mac_address(self) -> str: - """Return the mac address of the main device.""" - return self._avm_wrapper.mac - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - configuration_url=f"http://{self._avm_wrapper.host}", - connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, - identifiers={(DOMAIN, self._avm_wrapper.unique_id)}, - manufacturer="AVM", - model=self._avm_wrapper.model, - name=self._device_name, - sw_version=self._avm_wrapper.current_firmware, - ) - - -@dataclass(frozen=True) -class FritzRequireKeysMixin: - """Fritz entity description mix in.""" - - value_fn: Callable[[FritzStatus, Any], Any] | None - - -@dataclass(frozen=True) -class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): - """Fritz entity base description.""" - - -class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity[AvmWrapper]): - """Fritz host coordinator entity base class.""" - - entity_description: FritzEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - avm_wrapper: AvmWrapper, - device_name: str, - description: FritzEntityDescription, - ) -> None: - """Init device info class.""" - super().__init__(avm_wrapper) - self.entity_description = description - self._device_name = device_name - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - if self.entity_description.value_fn is not None: - self.async_on_remove( - await self.coordinator.async_register_entity_updates( - self.entity_description.key, self.entity_description.value_fn - ) - ) - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - configuration_url=f"http://{self.coordinator.host}", - connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)}, - identifiers={(DOMAIN, self.coordinator.unique_id)}, - manufacturer="AVM", - model=self.coordinator.model, - name=self._device_name, - sw_version=self.coordinator.current_firmware, - ) - - @dataclass class ConnectionInfo: """Fritz sensor connection information class.""" diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 89ba6c1cad8..6bf182458e0 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -11,14 +11,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( +from .const import DATA_FRITZ, DOMAIN +from .coordinator import ( AvmWrapper, FritzData, FritzDevice, - FritzDeviceBase, device_filter_out_from_trackers, ) -from .const import DATA_FRITZ, DOMAIN +from .entity import FritzDeviceBase _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index c4725b99e43..8823d55baa9 100644 --- a/homeassistant/components/fritz/diagnostics.py +++ b/homeassistant/components/fritz/diagnostics.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .common import AvmWrapper from .const import DOMAIN +from .coordinator import AvmWrapper TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py new file mode 100644 index 00000000000..45665c786d4 --- /dev/null +++ b/homeassistant/components/fritz/entity.py @@ -0,0 +1,137 @@ +"""AVM FRITZ!Tools entities.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from fritzconnection.lib.fritzstatus import FritzStatus + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_DEVICE_NAME, DOMAIN +from .coordinator import AvmWrapper, FritzDevice + + +class FritzDeviceBase(CoordinatorEntity[AvmWrapper]): + """Entity base class for a device connected to a FRITZ!Box device.""" + + def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None: + """Initialize a FRITZ!Box device.""" + super().__init__(avm_wrapper) + self._avm_wrapper = avm_wrapper + self._mac: str = device.mac_address + self._name: str = device.hostname or DEFAULT_DEVICE_NAME + + @property + def name(self) -> str: + """Return device name.""" + return self._name + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + if self._mac: + return self._avm_wrapper.devices[self._mac].ip_address + return None + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._mac + + @property + def hostname(self) -> str | None: + """Return hostname of the device.""" + if self._mac: + return self._avm_wrapper.devices[self._mac].hostname + return None + + async def async_process_update(self) -> None: + """Update device.""" + raise NotImplementedError + + async def async_on_demand_update(self) -> None: + """Update state.""" + await self.async_process_update() + self.async_write_ha_state() + + +class FritzBoxBaseEntity: + """Fritz host entity base class.""" + + def __init__(self, avm_wrapper: AvmWrapper, device_name: str) -> None: + """Init device info class.""" + self._avm_wrapper = avm_wrapper + self._device_name = device_name + + @property + def mac_address(self) -> str: + """Return the mac address of the main device.""" + return self._avm_wrapper.mac + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self._avm_wrapper.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, + identifiers={(DOMAIN, self._avm_wrapper.unique_id)}, + manufacturer="AVM", + model=self._avm_wrapper.model, + name=self._device_name, + sw_version=self._avm_wrapper.current_firmware, + ) + + +@dataclass(frozen=True, kw_only=True) +class FritzEntityDescription(EntityDescription): + """Fritz entity base description.""" + + value_fn: Callable[[FritzStatus, Any], Any] | None + + +class FritzBoxBaseCoordinatorEntity(CoordinatorEntity[AvmWrapper]): + """Fritz host coordinator entity base class.""" + + entity_description: FritzEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: FritzEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.entity_description = description + self._device_name = device_name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.entity_description.value_fn is not None: + self.async_on_remove( + await self.coordinator.async_register_entity_updates( + self.entity_description.key, self.entity_description.value_fn + ) + ) + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index aa1ede5a185..19c98446ccd 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -14,8 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util, slugify -from .common import AvmWrapper, FritzBoxBaseEntity from .const import DOMAIN +from .coordinator import AvmWrapper +from .entity import FritzBoxBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index aa9c410a545..11ee0ad5510 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -27,13 +27,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .common import ( - AvmWrapper, - ConnectionInfo, - FritzBoxBaseCoordinatorEntity, - FritzEntityDescription, -) from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION +from .coordinator import AvmWrapper, ConnectionInfo +from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) @@ -143,7 +139,7 @@ def _retrieve_link_attenuation_received_state( return status.attenuation[1] / 10 # type: ignore[no-any-return] -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index f0131c6bae2..bace7480ba5 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -11,15 +11,8 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service import async_extract_config_entry_ids -from .common import AvmWrapper -from .const import ( - DOMAIN, - FRITZ_SERVICES, - SERVICE_CLEANUP, - SERVICE_REBOOT, - SERVICE_RECONNECT, - SERVICE_SET_GUEST_WIFI_PW, -) +from .const import DOMAIN, FRITZ_SERVICES, SERVICE_SET_GUEST_WIFI_PW +from .coordinator import AvmWrapper _LOGGER = logging.getLogger(__name__) @@ -32,9 +25,6 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema( ) SERVICE_LIST: list[tuple[str, vol.Schema | None]] = [ - (SERVICE_CLEANUP, None), - (SERVICE_REBOOT, None), - (SERVICE_RECONNECT, None), (SERVICE_SET_GUEST_WIFI_PW, SERVICE_SCHEMA_SET_GUEST_WIFI_PW), ] diff --git a/homeassistant/components/fritz/services.yaml b/homeassistant/components/fritz/services.yaml index b9828280aa2..0ac7ca20c3d 100644 --- a/homeassistant/components/fritz/services.yaml +++ b/homeassistant/components/fritz/services.yaml @@ -1,31 +1,3 @@ -reconnect: - fields: - device_id: - required: true - selector: - device: - integration: fritz - entity: - device_class: connectivity -reboot: - fields: - device_id: - required: true - selector: - device: - integration: fritz - entity: - device_class: connectivity - -cleanup: - fields: - device_id: - required: true - selector: - device: - integration: fritz - entity: - device_class: connectivity set_guest_wifi_password: fields: device_id: diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 30603ca9032..eb47f76f27e 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -144,42 +144,12 @@ } }, "services": { - "reconnect": { - "name": "[%key:component::fritz::entity::button::reconnect::name%]", - "description": "Reconnects your FRITZ!Box internet connection.", - "fields": { - "device_id": { - "name": "Fritz!Box Device", - "description": "Select the Fritz!Box to reconnect." - } - } - }, - "reboot": { - "name": "Reboot", - "description": "Reboots your FRITZ!Box.", - "fields": { - "device_id": { - "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", - "description": "Select the Fritz!Box to reboot." - } - } - }, - "cleanup": { - "name": "Remove stale device tracker entities", - "description": "Remove FRITZ!Box stale device_tracker entities.", - "fields": { - "device_id": { - "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", - "description": "Select the Fritz!Box to check." - } - } - }, "set_guest_wifi_password": { "name": "Set guest Wi-Fi password", "description": "Sets a new password for the guest Wi-Fi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters.", "fields": { "device_id": { - "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", + "name": "Fritz!Box Device", "description": "Select the Fritz!Box to configure." }, "password": { diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 913d0165247..8af5b8ba529 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -17,15 +17,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from .common import ( - AvmWrapper, - FritzBoxBaseEntity, - FritzData, - FritzDevice, - FritzDeviceBase, - SwitchInfo, - device_filter_out_from_trackers, -) from .const import ( DATA_FRITZ, DOMAIN, @@ -36,6 +27,14 @@ from .const import ( WIFI_STANDARD, MeshRoles, ) +from .coordinator import ( + AvmWrapper, + FritzData, + FritzDevice, + SwitchInfo, + device_filter_out_from_trackers, +) +from .entity import FritzBoxBaseEntity, FritzDeviceBase _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 1a24a8dd152..6969f201f27 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -16,13 +16,14 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, FritzBoxBaseCoordinatorEntity, FritzEntityDescription from .const import DOMAIN +from .coordinator import AvmWrapper +from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): """Describes Fritz update entity.""" diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index cfaa7a298ad..5288682c388 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -19,6 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity @@ -27,18 +28,26 @@ from .const import ( ATTR_STATE_HOLIDAY_MODE, ATTR_STATE_SUMMER_MODE, ATTR_STATE_WINDOW_OPEN, + DOMAIN, LOGGER, ) -from .coordinator import FritzboxConfigEntry +from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator from .model import ClimateExtraAttributes -OPERATION_LIST = [HVACMode.HEAT, HVACMode.OFF] +HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] +PRESET_HOLIDAY = "holiday" +PRESET_SUMMER = "summer" +PRESET_MODES = [PRESET_ECO, PRESET_COMFORT] +SUPPORTED_FEATURES = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON +) MIN_TEMPERATURE = 8 MAX_TEMPERATURE = 28 -PRESET_MANUAL = "manual" - # special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) ON_API_TEMPERATURE = 127.0 OFF_API_TEMPERATURE = 126.5 @@ -76,15 +85,38 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): """The thermostat class for FRITZ!SmartHome thermostats.""" _attr_precision = PRECISION_HALVES - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "thermostat" _enable_turn_on_off_backwards_compatibility = False + def __init__( + self, + coordinator: FritzboxDataUpdateCoordinator, + ain: str, + ) -> None: + """Initialize the thermostat.""" + self._attr_supported_features = SUPPORTED_FEATURES + self._attr_hvac_modes = HVAC_MODES + self._attr_preset_modes = PRESET_MODES + super().__init__(coordinator, ain) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the HASS state machine.""" + if self.data.holiday_active: + self._attr_supported_features = ClimateEntityFeature.PRESET_MODE + self._attr_hvac_modes = [HVACMode.HEAT] + self._attr_preset_modes = [PRESET_HOLIDAY] + elif self.data.summer_active: + self._attr_supported_features = ClimateEntityFeature.PRESET_MODE + self._attr_hvac_modes = [HVACMode.OFF] + self._attr_preset_modes = [PRESET_SUMMER] + else: + self._attr_supported_features = SUPPORTED_FEATURES + self._attr_hvac_modes = HVAC_MODES + self._attr_preset_modes = PRESET_MODES + return super().async_write_ha_state() + @property def current_temperature(self) -> float: """Return the current temperature.""" @@ -116,6 +148,10 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return the current operation mode.""" + if self.data.holiday_active: + return HVACMode.HEAT + if self.data.summer_active: + return HVACMode.OFF if self.data.target_temperature in ( OFF_REPORT_SET_TEMPERATURE, OFF_API_TEMPERATURE, @@ -124,13 +160,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): return HVACMode.HEAT - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return OPERATION_LIST - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" + if self.data.holiday_active or self.data.summer_active: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_hvac_while_active_mode", + ) if self.hvac_mode == hvac_mode: LOGGER.debug( "%s is already in requested hvac mode %s", self.name, hvac_mode @@ -144,19 +180,23 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): @property def preset_mode(self) -> str | None: """Return current preset mode.""" + if self.data.holiday_active: + return PRESET_HOLIDAY + if self.data.summer_active: + return PRESET_SUMMER if self.data.target_temperature == self.data.comfort_temperature: return PRESET_COMFORT if self.data.target_temperature == self.data.eco_temperature: return PRESET_ECO return None - @property - def preset_modes(self) -> list[str]: - """Return supported preset modes.""" - return [PRESET_ECO, PRESET_COMFORT] - async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" + if self.data.holiday_active or self.data.summer_active: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="change_preset_while_active_mode", + ) if preset_mode == PRESET_COMFORT: await self.async_set_temperature(temperature=self.data.comfort_temperature) elif preset_mode == PRESET_ECO: diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index abe1d2553f1..52fa3ba1a12 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -18,7 +18,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -FritzboxConfigEntry = ConfigEntry["FritzboxDataUpdateCoordinator"] +type FritzboxConfigEntry = ConfigEntry[FritzboxDataUpdateCoordinator] @dataclass diff --git a/homeassistant/components/fritzbox/icons.json b/homeassistant/components/fritzbox/icons.json new file mode 100644 index 00000000000..5eb819cdde8 --- /dev/null +++ b/homeassistant/components/fritzbox/icons.json @@ -0,0 +1,16 @@ +{ + "entity": { + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "holiday": "mdi:bag-suitcase-outline", + "summer": "mdi:radiator-off" + } + } + } + } + } + } +} diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index 755cc97d7d8..cee0afa26c1 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -56,6 +56,21 @@ "device_lock": { "name": "Button lock via UI" }, "lock": { "name": "Button lock on device" } }, + "climate": { + "thermostat": { + "state_attributes": { + "preset_mode": { + "name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]", + "state": { + "eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", + "holiday": "Holiday", + "summer": "Summer" + } + } + } + } + }, "sensor": { "comfort_temperature": { "name": "Comfort temperature" }, "eco_temperature": { "name": "Eco temperature" }, @@ -64,5 +79,13 @@ "nextchange_time": { "name": "Next scheduled change time" }, "scheduled_preset": { "name": "Current scheduled preset" } } + }, + "exceptions": { + "change_preset_while_active_mode": { + "message": "Can't change preset while holiday or summer mode is active on the device." + }, + "change_hvac_while_active_mode": { + "message": "Can't change hvac mode while holiday or summer mode is active on the device." + } } } diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index 061017f420c..b33ba94cf16 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -15,7 +15,7 @@ from .const import CONF_PHONEBOOK, CONF_PREFIXES, PLATFORMS _LOGGER = logging.getLogger(__name__) -FritzBoxCallMonitorConfigEntry = ConfigEntry[FritzBoxPhonebook] +type FritzBoxCallMonitorConfigEntry = ConfigEntry[FritzBoxPhonebook] async def async_setup_entry( diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index c4d1c02ee74..07271b91f28 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta import logging -from typing import Final, TypeVar +from typing import Final from pyfronius import Fronius, FroniusError @@ -39,9 +39,7 @@ from .coordinator import ( _LOGGER: Final = logging.getLogger(__name__) PLATFORMS: Final = [Platform.SENSOR] -_FroniusCoordinatorT = TypeVar("_FroniusCoordinatorT", bound=FroniusCoordinatorBase) - -FroniusConfigEntry = ConfigEntry["FroniusSolarNet"] +type FroniusConfigEntry = ConfigEntry[FroniusSolarNet] async def async_setup_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> bool: @@ -255,7 +253,7 @@ class FroniusSolarNet: return inverter_infos @staticmethod - async def _init_optional_coordinator( + async def _init_optional_coordinator[_FroniusCoordinatorT: FroniusCoordinatorBase]( coordinator: _FroniusCoordinatorT, ) -> _FroniusCoordinatorT | None: """Initialize an update coordinator and return it if devices are found.""" diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py index 2b46d226b7a..b16f43d58e8 100644 --- a/homeassistant/components/fronius/config_flow.py +++ b/homeassistant/components/fronius/config_flow.py @@ -10,7 +10,7 @@ from pyfronius import Fronius, FroniusError import voluptuous as vol from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -22,12 +22,6 @@ _LOGGER: Final = logging.getLogger(__name__) DHCP_REQUEST_DELAY: Final = 60 -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - } -) - def create_title(info: FroniusConfigEntryData) -> str: """Return the title of the config flow.""" @@ -40,10 +34,7 @@ def create_title(info: FroniusConfigEntryData) -> str: async def validate_host( hass: HomeAssistant, host: str ) -> tuple[str, FroniusConfigEntryData]: - """Validate the user input allows us to connect. - - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ + """Validate the user input allows us to connect.""" fronius = Fronius(async_get_clientsession(hass), host) try: @@ -81,33 +72,32 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize flow.""" self.info: FroniusConfigEntryData + self._entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - errors = {} - try: - unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) - except CannotConnect: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured(updates=dict(info)) + if user_input is not None: + try: + unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured(updates=dict(info)) - return self.async_create_entry(title=create_title(info), data=info) + return self.async_create_entry(title=create_title(info), data=info) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, ) async def async_step_dhcp( @@ -150,6 +140,51 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Add reconfigure step to allow to reconfigure a config entry.""" + errors = {} + + if user_input is not None: + try: + unique_id, info = await validate_host(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Config didn't change or is already configured in another entry + self._async_abort_entries_match(dict(info)) + + existing_entry = await self.async_set_unique_id( + unique_id, raise_on_progress=False + ) + assert self._entry is not None + if existing_entry and existing_entry.entry_id != self._entry.entry_id: + # Uid of device is already configured in another entry (but with different host) + self._abort_if_unique_id_configured() + + return self.async_update_reload_and_abort( + self._entry, + data=info, + reason="reconfigure_successful", + ) + + if self._entry is None: + self._entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert self._entry is not None + host = self._entry.data[CONF_HOST] + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), + description_placeholders={"device": self._entry.title}, + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index 8702339ef03..083085270e0 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -8,7 +8,7 @@ from homeassistant.helpers.typing import StateType DOMAIN: Final = "fronius" -SolarNetId = str +type SolarNetId = str SOLAR_NET_DISCOVERY_NEW: Final = "fronius_discovery_new" SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow" SOLAR_NET_ID_SYSTEM: SolarNetId = "system" diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 71ecb4e762e..c3dea123a77 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import timedelta -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any from pyfronius import BadStatusError, FroniusError @@ -32,8 +32,6 @@ if TYPE_CHECKING: from . import FroniusSolarNet from .sensor import _FroniusSensorEntity - _FroniusEntityT = TypeVar("_FroniusEntityT", bound=_FroniusSensorEntity) - class FroniusCoordinatorBase( ABC, DataUpdateCoordinator[dict[SolarNetId, dict[str, Any]]] @@ -84,7 +82,7 @@ class FroniusCoordinatorBase( return data @callback - def add_entities_for_seen_keys( + def add_entities_for_seen_keys[_FroniusEntityT: _FroniusSensorEntity]( self, async_add_entities: AddEntitiesCallback, entity_constructor: type[_FroniusEntityT], diff --git a/homeassistant/components/fronius/diagnostics.py b/homeassistant/components/fronius/diagnostics.py new file mode 100644 index 00000000000..17737ba31f8 --- /dev/null +++ b/homeassistant/components/fronius/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for Fronius.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from . import FroniusConfigEntry + +TO_REDACT = {"unique_id", "unique_identifier", "serial"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: FroniusConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + diag: dict[str, Any] = {} + solar_net = config_entry.runtime_data + fronius = solar_net.fronius + + diag["config_entry"] = config_entry.as_dict() + diag["inverter_info"] = await fronius.inverter_info() + + diag["coordinators"] = {"inverters": {}} + for inv in solar_net.inverter_coordinators: + diag["coordinators"]["inverters"] |= inv.data + + diag["coordinators"]["logger"] = ( + solar_net.logger_coordinator.data if solar_net.logger_coordinator else None + ) + diag["coordinators"]["meter"] = ( + solar_net.meter_coordinator.data if solar_net.meter_coordinator else None + ) + diag["coordinators"]["ohmpilot"] = ( + solar_net.ohmpilot_coordinator.data if solar_net.ohmpilot_coordinator else None + ) + diag["coordinators"]["power_flow"] = ( + solar_net.power_flow_coordinator.data + if solar_net.power_flow_coordinator + else None + ) + diag["coordinators"]["storage"] = ( + solar_net.storage_coordinator.data if solar_net.storage_coordinator else None + ) + + return async_redact_data(diag, TO_REDACT) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 3b283c33326..31f080c1f51 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -549,6 +549,25 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + FroniusSensorEntityDescription( + key="power_battery_discharge", + response_key="power_battery", + default_value=0, + value_fn=lambda value: max(value, 0), # type: ignore[type-var] + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + FroniusSensorEntityDescription( + key="power_battery_charge", + response_key="power_battery", + default_value=0, + value_fn=lambda value: max(0 - value, 0), # type: ignore[operator] + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="power_grid", @@ -556,6 +575,25 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + FroniusSensorEntityDescription( + key="power_grid_import", + response_key="power_grid", + default_value=0, + value_fn=lambda value: max(value, 0), # type: ignore[type-var] + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + FroniusSensorEntityDescription( + key="power_grid_export", + response_key="power_grid", + default_value=0, + value_fn=lambda value: max(0 - value, 0), # type: ignore[operator] + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="power_load", @@ -563,6 +601,26 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + FroniusSensorEntityDescription( + key="power_load_generated", + response_key="power_load", + default_value=0, + value_fn=lambda value: max(value, 0), # type: ignore[type-var] + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + FroniusSensorEntityDescription( + key="power_load_consumed", + response_key="power_load", + default_value=0, + value_fn=lambda value: max(0 - value, 0), # type: ignore[operator] + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), FroniusSensorEntityDescription( key="power_photovoltaics", @@ -670,7 +728,7 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn if self.entity_description.invalid_when_falsy and not new_value: return None if self.entity_description.value_fn is not None: - return self.entity_description.value_fn(new_value) + new_value = self.entity_description.value_fn(new_value) if isinstance(new_value, float): return round(new_value, 4) return new_value diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index de066704644..ccfb88852a8 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -11,6 +11,12 @@ }, "confirm_discovery": { "description": "Do you want to add {device} to Home Assistant?" + }, + "reconfigure": { + "description": "Update your configuration information for {device}.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } } }, "error": { @@ -19,7 +25,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { @@ -234,12 +241,30 @@ "power_battery": { "name": "Power battery" }, + "power_battery_discharge": { + "name": "Power battery discharge" + }, + "power_battery_charge": { + "name": "Power battery charge" + }, "power_grid": { "name": "Power grid" }, + "power_grid_import": { + "name": "Power grid import" + }, + "power_grid_export": { + "name": "Power grid export" + }, "power_load": { "name": "Power load" }, + "power_load_generated": { + "name": "Power load generated" + }, + "power_load_consumed": { + "name": "Power load consumed" + }, "power_photovoltaics": { "name": "Power photovoltaics" }, diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 2f64a019c19..dac0f51f608 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Iterator -from functools import lru_cache +from collections.abc import Callable, Iterator +from functools import lru_cache, partial import logging import os import pathlib @@ -15,7 +15,7 @@ import voluptuous as vol from yarl import URL from homeassistant.components import onboarding, websocket_api -from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_HASS, HomeAssistantView, StaticPathConfig from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config import async_hass_config_yaml from homeassistant.const import ( @@ -33,6 +33,7 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.util.hass_dict import HassKey from .storage import async_setup_frontend_storage @@ -56,6 +57,10 @@ DATA_JS_VERSION = "frontend_js_version" DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" +DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = HassKey( + "frontend_ws_subscribers" +) + THEMES_STORAGE_KEY = f"{DOMAIN}_theme" THEMES_STORAGE_VERSION = 1 THEMES_SAVE_DELAY = 60 @@ -204,17 +209,24 @@ class UrlManager: on hass.data """ - def __init__(self, urls: list[str]) -> None: + def __init__( + self, + on_change: Callable[[str, str], None], + urls: list[str], + ) -> None: """Init the url manager.""" + self._on_change = on_change self.urls = frozenset(urls) def add(self, url: str) -> None: """Add a url to the set.""" self.urls = frozenset([*self.urls, url]) + self._on_change("added", url) def remove(self, url: str) -> None: """Remove a url from the set.""" self.urls = self.urls - {url} + self._on_change("removed", url) class Panel: @@ -311,22 +323,38 @@ def async_register_built_in_panel( @bind_hass @callback -def async_remove_panel(hass: HomeAssistant, frontend_url_path: str) -> None: +def async_remove_panel( + hass: HomeAssistant, frontend_url_path: str, *, warn_if_unknown: bool = True +) -> None: """Remove a built-in panel.""" panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None) if panel is None: - _LOGGER.warning("Removing unknown panel %s", frontend_url_path) + if warn_if_unknown: + _LOGGER.warning("Removing unknown panel %s", frontend_url_path) + return hass.bus.async_fire(EVENT_PANELS_UPDATED) def add_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None: - """Register extra js or module url to load.""" + """Register extra js or module url to load. + + This function allows custom integrations to register extra js or module. + """ key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL hass.data[key].add(url) +def remove_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None: + """Remove extra js or module url to load. + + This function allows custom integrations to remove extra js or module. + """ + key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_URL + hass.data[key].remove(url) + + def add_manifest_json_key(key: str, val: Any) -> None: """Add a keyval to the manifest.json.""" MANIFEST_JSON.update_key(key, val) @@ -351,6 +379,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_get_themes) websocket_api.async_register_command(hass, websocket_get_translations) websocket_api.async_register_command(hass, websocket_get_version) + websocket_api.async_register_command(hass, websocket_subscribe_extra_js) hass.http.register_view(ManifestJSONView()) conf = config.get(DOMAIN, {}) @@ -366,6 +395,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: is_dev = repo_path is not None root_path = _frontend_root(repo_path) + static_paths_configs: list[StaticPathConfig] = [] + for path, should_cache in ( ("service_worker.js", False), ("robots.txt", False), @@ -374,10 +405,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ("frontend_latest", not is_dev), ("frontend_es5", not is_dev), ): - hass.http.register_static_path(f"/{path}", str(root_path / path), should_cache) + static_paths_configs.append( + StaticPathConfig(f"/{path}", str(root_path / path), should_cache) + ) - hass.http.register_static_path( - "/auth/authorize", str(root_path / "authorize.html"), False + static_paths_configs.append( + StaticPathConfig("/auth/authorize", str(root_path / "authorize.html"), False) ) # https://wicg.github.io/change-password-url/ hass.http.register_redirect( @@ -385,9 +418,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) local = hass.config.path("www") - if os.path.isdir(local): - hass.http.register_static_path("/local", local, not is_dev) + if await hass.async_add_executor_job(os.path.isdir, local): + static_paths_configs.append(StaticPathConfig("/local", local, not is_dev)) + await hass.http.async_register_static_paths(static_paths_configs) # Shopping list panel was replaced by todo panel in 2023.11 hass.http.register_redirect("/shopping-list", "/todo") @@ -403,8 +437,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: sidebar_icon="hass:hammer", ) - hass.data[DATA_EXTRA_MODULE_URL] = UrlManager(conf.get(CONF_EXTRA_MODULE_URL, [])) - hass.data[DATA_EXTRA_JS_URL_ES5] = UrlManager(conf.get(CONF_EXTRA_JS_URL_ES5, [])) + @callback + def async_change_listener( + resource_type: str, + change_type: str, + url: str, + ) -> None: + subscribers = hass.data[DATA_WS_SUBSCRIBERS] + json_msg = { + "change_type": change_type, + "item": {"type": resource_type, "url": url}, + } + for connection, msg_id in subscribers: + connection.send_message(websocket_api.event_message(msg_id, json_msg)) + + hass.data[DATA_EXTRA_MODULE_URL] = UrlManager( + partial(async_change_listener, "module"), conf.get(CONF_EXTRA_MODULE_URL, []) + ) + hass.data[DATA_EXTRA_JS_URL_ES5] = UrlManager( + partial(async_change_listener, "es5"), conf.get(CONF_EXTRA_JS_URL_ES5, []) + ) + hass.data[DATA_WS_SUBSCRIBERS] = set() await _async_setup_themes(hass, conf.get(CONF_THEMES)) @@ -766,6 +819,24 @@ async def websocket_get_version( connection.send_result(msg["id"], {"version": frontend}) +@callback +@websocket_api.websocket_command({"type": "frontend/subscribe_extra_js"}) +def websocket_subscribe_extra_js( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to URL manager updates.""" + + subscribers = hass.data[DATA_WS_SUBSCRIBERS] + subscribers.add((connection, msg["id"])) + + @callback + def cancel_subscription() -> None: + subscribers.remove((connection, msg["id"])) + + connection.subscriptions[msg["id"]] = cancel_subscription + connection.send_message(websocket_api.result_message(msg["id"])) + + class PanelRespons(TypedDict): """Represent the panel response type.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6abe8df1d7c..1b17601a2f6 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==20240501.0"] + "requirements": ["home-assistant-frontend==20240610.1"] } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index cf775b15138..103323ff575 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -74,7 +74,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) except FSConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -108,7 +108,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) except FSConnectionError: return self.async_abort(reason="cannot_connect") - except Exception as exception: # pylint: disable=broad-except + except Exception as exception: # noqa: BLE001 _LOGGER.debug(exception) return self.async_abort(reason="unknown") @@ -206,7 +206,7 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidPinException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index ac72df67014..cb02d430230 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -308,10 +308,9 @@ class AFSAPIDevice(MediaPlayerEntity): # Keys of presets are 0-based, while the list shown on the device starts from 1 preset = int(keys[0]) - 1 - result = await self.fs_device.select_preset(preset) + await self.fs_device.select_preset(preset) else: - result = await self.fs_device.nav_select_item_via_path(keys) + await self.fs_device.nav_select_item_via_path(keys) await self.async_update() self._attr_media_content_id = media_id - return result diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index a0ed0cb4fa0..99b477c2989 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -13,7 +13,10 @@ from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CAMERA, + Platform.IMAGE, Platform.MEDIA_PLAYER, + Platform.NOTIFY, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/fully_kiosk/camera.py b/homeassistant/components/fully_kiosk/camera.py new file mode 100644 index 00000000000..99419271c26 --- /dev/null +++ b/homeassistant/components/fully_kiosk/camera.py @@ -0,0 +1,56 @@ +"""Support for Fully Kiosk Browser camera.""" + +from __future__ import annotations + +from homeassistant.components.camera import Camera, CameraEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import FullyKioskDataUpdateCoordinator +from .entity import FullyKioskEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the cameras.""" + coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([FullyCameraEntity(coordinator)]) + + +class FullyCameraEntity(FullyKioskEntity, Camera): + """Fully Kiosk Browser camera entity.""" + + _attr_name = None + _attr_supported_features = CameraEntityFeature.ON_OFF + + def __init__(self, coordinator: FullyKioskDataUpdateCoordinator) -> None: + """Initialize the camera.""" + FullyKioskEntity.__init__(self, coordinator) + Camera.__init__(self) + self._attr_unique_id = f"{coordinator.data['deviceID']}-camera" + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + image_bytes: bytes = await self.coordinator.fully.getCamshot() + return image_bytes + + async def async_turn_on(self) -> None: + """Turn on camera.""" + await self.coordinator.fully.enableMotionDetection() + await self.coordinator.async_refresh() + + async def async_turn_off(self) -> None: + """Turn off camera.""" + await self.coordinator.fully.disableMotionDetection() + await self.coordinator.async_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self.coordinator.data["settings"].get("motionDetection") + self.async_write_ha_state() diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 8fd0d4ee4cc..98cf96f637e 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -64,7 +64,7 @@ class FullyKioskConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" description_placeholders["error_detail"] = str(error.args) return None - except Exception as error: # pylint: disable=broad-except + except Exception as error: # noqa: BLE001 LOGGER.exception("Unexpected exception: %s", error) errors["base"] = "unknown" description_placeholders["error_detail"] = str(error.args) diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py new file mode 100644 index 00000000000..fbf3481e38b --- /dev/null +++ b/homeassistant/components/fully_kiosk/image.py @@ -0,0 +1,74 @@ +"""Support for Fully Kiosk Browser image.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from fullykiosk import FullyKiosk, FullyKioskError + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .coordinator import FullyKioskDataUpdateCoordinator +from .entity import FullyKioskEntity + + +@dataclass(frozen=True, kw_only=True) +class FullyImageEntityDescription(ImageEntityDescription): + """Fully Kiosk Browser image entity description.""" + + image_fn: Callable[[FullyKiosk], Coroutine[Any, Any, bytes]] + + +IMAGES: tuple[FullyImageEntityDescription, ...] = ( + FullyImageEntityDescription( + key="screenshot", + translation_key="screenshot", + image_fn=lambda fully: fully.getScreenshot(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Fully Kiosk Browser image entities.""" + coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + FullyImageEntity(coordinator, description) for description in IMAGES + ) + + +class FullyImageEntity(FullyKioskEntity, ImageEntity): + """Implement the image entity for Fully Kiosk Browser.""" + + entity_description: FullyImageEntityDescription + _attr_content_type = "image/png" + + def __init__( + self, + coordinator: FullyKioskDataUpdateCoordinator, + description: FullyImageEntityDescription, + ) -> None: + """Initialize the entity.""" + FullyKioskEntity.__init__(self, coordinator) + ImageEntity.__init__(self, coordinator.hass) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + try: + image_bytes = await self.entity_description.image_fn(self.coordinator.fully) + except FullyKioskError as err: + raise HomeAssistantError(err) from err + else: + self._attr_image_last_updated = dt_util.utcnow() + return image_bytes diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index b5dadf14184..8d9ba85a058 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/fully_kiosk", "iot_class": "local_polling", "mqtt": ["fully/deviceInfo/+"], - "requirements": ["python-fullykiosk==0.0.12"] + "requirements": ["python-fullykiosk==0.0.13"] } diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 1e258c928e7..ae61a39bb81 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -14,6 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import AUDIOMANAGER_STREAM_MUSIC, DOMAIN, MEDIA_SUPPORT_FULLYKIOSK @@ -54,13 +55,33 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): ) media_id = async_process_play_media_url(self.hass, play_item.url) - await self.coordinator.fully.playSound(media_id, AUDIOMANAGER_STREAM_MUSIC) + if media_type.startswith("audio/"): + media_type = MediaType.MUSIC + elif media_type.startswith("video/"): + media_type = MediaType.VIDEO + if media_type == MediaType.MUSIC: + self._attr_media_content_type = MediaType.MUSIC + await self.coordinator.fully.playSound(media_id, AUDIOMANAGER_STREAM_MUSIC) + elif media_type == MediaType.VIDEO: + self._attr_media_content_type = MediaType.VIDEO + await self.coordinator.fully.sendCommand( + "playVideo", + url=media_id, + stream=AUDIOMANAGER_STREAM_MUSIC, + showControls=1, + exitOnCompletion=1, + ) + else: + raise HomeAssistantError(f"Unsupported media type {media_type}") self._attr_state = MediaPlayerState.PLAYING self.async_write_ha_state() async def async_media_stop(self) -> None: """Stop playing media.""" - await self.coordinator.fully.stopSound() + if self._attr_media_content_type == MediaType.VIDEO: + await self.coordinator.fully.sendCommand("stopVideo") + else: + await self.coordinator.fully.stopSound() self._attr_state = MediaPlayerState.IDLE self.async_write_ha_state() @@ -81,7 +102,8 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): return await media_source.async_browse_media( self.hass, media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), + content_filter=lambda item: item.media_content_type.startswith("audio/") + or item.media_content_type.startswith("video/"), ) @callback diff --git a/homeassistant/components/fully_kiosk/notify.py b/homeassistant/components/fully_kiosk/notify.py new file mode 100644 index 00000000000..aa47c178f03 --- /dev/null +++ b/homeassistant/components/fully_kiosk/notify.py @@ -0,0 +1,74 @@ +"""Support for Fully Kiosk Browser notifications.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from fullykiosk import FullyKioskError + +from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription +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 FullyKioskDataUpdateCoordinator +from .entity import FullyKioskEntity + + +@dataclass(frozen=True, kw_only=True) +class FullyNotifyEntityDescription(NotifyEntityDescription): + """Fully Kiosk Browser notify entity description.""" + + cmd: str + + +NOTIFIERS: tuple[FullyNotifyEntityDescription, ...] = ( + FullyNotifyEntityDescription( + key="overlay_message", + translation_key="overlay_message", + cmd="setOverlayMessage", + ), + FullyNotifyEntityDescription( + key="tts", + translation_key="tts", + cmd="textToSpeech", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Fully Kiosk Browser notify entities.""" + coordinator: FullyKioskDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + FullyNotifyEntity(coordinator, description) for description in NOTIFIERS + ) + + +class FullyNotifyEntity(FullyKioskEntity, NotifyEntity): + """Implement the notify entity for Fully Kiosk Browser.""" + + entity_description: FullyNotifyEntityDescription + + def __init__( + self, + coordinator: FullyKioskDataUpdateCoordinator, + description: FullyNotifyEntityDescription, + ) -> None: + """Initialize the entity.""" + FullyKioskEntity.__init__(self, coordinator) + NotifyEntity.__init__(self) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data['deviceID']}-{description.key}" + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + try: + await self.coordinator.fully.sendCommand( + self.entity_description.cmd, text=message + ) + except FullyKioskError as err: + raise HomeAssistantError(err) from err diff --git a/homeassistant/components/fully_kiosk/services.py b/homeassistant/components/fully_kiosk/services.py index c1e0d89f7a1..b9369198940 100644 --- a/homeassistant/components/fully_kiosk/services.py +++ b/homeassistant/components/fully_kiosk/services.py @@ -69,18 +69,21 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def async_set_config(call: ServiceCall) -> None: """Set a Fully Kiosk Browser config value on the device.""" for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + # Fully API has different methods for setting string and bool values. # check if call.data[ATTR_VALUE] is a bool - if isinstance(call.data[ATTR_VALUE], bool) or call.data[ - ATTR_VALUE - ].lower() in ("true", "false"): - await coordinator.fully.setConfigurationBool( - call.data[ATTR_KEY], call.data[ATTR_VALUE] - ) + if isinstance(value, bool) or ( + isinstance(value, str) and value.lower() in ("true", "false") + ): + await coordinator.fully.setConfigurationBool(key, value) else: - await coordinator.fully.setConfigurationString( - call.data[ATTR_KEY], call.data[ATTR_VALUE] - ) + # Convert any int values to string + if isinstance(value, int): + value = str(value) + + await coordinator.fully.setConfigurationString(key, value) # Register all the above services service_mapping = [ @@ -111,7 +114,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: { vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Required(ATTR_KEY): cv.string, - vol.Required(ATTR_VALUE): vol.Any(str, bool), + vol.Required(ATTR_VALUE): vol.Any(str, bool, int), } ) ), diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index c1a1ef1fcf0..9c0049d3e5f 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -56,6 +56,19 @@ "name": "Load start URL" } }, + "image": { + "screenshot": { + "name": "Screenshot" + } + }, + "notify": { + "overlay_message": { + "name": "Overlay message" + }, + "tts": { + "name": "Text to speech" + } + }, "number": { "screensaver_time": { "name": "Screensaver timer" diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index a62d6435a82..2e35b88b18a 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations from datetime import datetime import logging from typing import Any -from zoneinfo import ZoneInfo from fyta_cli.fyta_connector import FytaConnector @@ -17,6 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.util.dt import async_get_time_zone from .const import CONF_EXPIRATION, DOMAIN from .coordinator import FytaCoordinator @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: access_token: str = entry.data[CONF_ACCESS_TOKEN] expiration: datetime = datetime.fromisoformat( entry.data[CONF_EXPIRATION] - ).astimezone(ZoneInfo(tz)) + ).astimezone(await async_get_time_zone(tz)) fyta = FytaConnector(username, password, access_token, expiration, tz) diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index 3d83c099ac3..c09aac1b966 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -50,7 +50,7 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): return {"base": "invalid_auth"} except FytaPasswordError: return {"base": "invalid_auth", CONF_PASSWORD: "password_error"} - except Exception as e: # pylint: disable=broad-except + except Exception as e: # noqa: BLE001 _LOGGER.error(e) return {"base": "unknown"} finally: diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 021bddf2cf8..db79f21eb53 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -9,13 +9,14 @@ from fyta_cli.fyta_exceptions import ( FytaAuthentificationError, FytaConnectionError, FytaPasswordError, + FytaPlantError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_EXPIRATION @@ -48,7 +49,10 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, dict[str, Any]]]): ): await self.renew_authentication() - return await self.fyta.update_all_plants() + try: + return await self.fyta.update_all_plants() + except (FytaConnectionError, FytaPlantError) as err: + raise UpdateFailed(err) from err async def renew_authentication(self) -> bool: """Renew access token for FYTA API.""" diff --git a/homeassistant/components/fyta/diagnostics.py b/homeassistant/components/fyta/diagnostics.py new file mode 100644 index 00000000000..83f2a38dcae --- /dev/null +++ b/homeassistant/components/fyta/diagnostics.py @@ -0,0 +1,30 @@ +"""Provides diagnostics for Fyta.""" + +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_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = [ + CONF_PASSWORD, + CONF_USERNAME, + CONF_ACCESS_TOKEN, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = hass.data[DOMAIN][config_entry.entry_id].data + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "plant_data": data, + } diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 020ab330152..f0953dd2a33 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/fyta", "integration_type": "hub", "iot_class": "cloud_polling", + "quality_scale": "platinum", "requirements": ["fyta_cli==0.4.1"] } diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index c3e90cef28e..574b4e7b18e 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -16,7 +16,12 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfConductivity, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -93,7 +98,7 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ FytaSensorEntityDescription( key="light", translation_key="light", - native_unit_of_measurement="mol/d", + native_unit_of_measurement="μmol/s⋅m²", state_class=SensorStateClass.MEASUREMENT, ), FytaSensorEntityDescription( @@ -105,7 +110,8 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ FytaSensorEntityDescription( key="salinity", translation_key="salinity", - native_unit_of_measurement="mS/cm", + native_unit_of_measurement=UnitOfConductivity.MILLISIEMENS, + device_class=SensorDeviceClass.CONDUCTIVITY, state_class=SensorStateClass.MEASUREMENT, ), FytaSensorEntityDescription( diff --git a/homeassistant/components/garages_amsterdam/config_flow.py b/homeassistant/components/garages_amsterdam/config_flow.py index 6623ad5bd18..0f4f277ed61 100644 --- a/homeassistant/components/garages_amsterdam/config_flow.py +++ b/homeassistant/components/garages_amsterdam/config_flow.py @@ -36,7 +36,7 @@ class GaragesAmsterdamConfigFlow(ConfigFlow, domain=DOMAIN): except ClientResponseError: _LOGGER.error("Unexpected response from server") return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index c2b3ae6732b..ed5b1c14ba3 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -26,6 +26,7 @@ PLATFORMS: list[Platform] = [ Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] LOGGER = logging.getLogger(__name__) TIMEOUT = 20.0 diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 6598aeaafd8..4812def7dde 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena-bluetooth==1.4.1"] + "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], + "requirements": ["gardena-bluetooth==1.4.2"] } diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index f2bddd3a91a..3e6ddf9a2df 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -120,9 +120,7 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity): def _handle_coordinator_update(self) -> None: value = self.coordinator.get_cached(self.entity_description.char) if isinstance(value, datetime): - value = value.replace( - tzinfo=dt_util.get_time_zone(self.hass.config.time_zone) - ) + value = value.replace(tzinfo=dt_util.get_default_time_zone()) self._attr_native_value = value if char := self.entity_description.connected_state: diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index a57130c3acf..d010665e427 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -50,6 +50,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}" self._attr_translation_key = "state" self._attr_is_on = None + self._attr_entity_registry_enabled_default = False def _handle_coordinator_update(self) -> None: self._attr_is_on = self.coordinator.get_cached(Valve.state) diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py new file mode 100644 index 00000000000..3faf758f7e9 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -0,0 +1,74 @@ +"""Support for switch entities.""" + +from __future__ import annotations + +from typing import Any + +from gardena_bluetooth.const import Valve + +from homeassistant.components.valve import ValveEntity, ValveEntityFeature +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 Coordinator, GardenaBluetoothEntity + +FALLBACK_WATERING_TIME_IN_SECONDS = 60 * 60 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up switch based on a config entry.""" + coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] + if GardenaBluetoothValve.characteristics.issubset(coordinator.characteristics): + entities.append(GardenaBluetoothValve(coordinator)) + + async_add_entities(entities) + + +class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): + """Representation of a valve switch.""" + + _attr_name = None + _attr_is_closed: bool | None = None + _attr_reports_position = False + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + characteristics = { + Valve.state.uuid, + Valve.manual_watering_time.uuid, + Valve.remaining_open_time.uuid, + } + + def __init__( + self, + coordinator: Coordinator, + ) -> None: + """Initialize the switch.""" + super().__init__( + coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} + ) + self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}" + + def _handle_coordinator_update(self) -> None: + self._attr_is_closed = not self.coordinator.get_cached(Valve.state) + super()._handle_coordinator_update() + + async def async_open_valve(self, **kwargs: Any) -> None: + """Turn the entity on.""" + value = ( + self.coordinator.get_cached(Valve.manual_watering_time) + or FALLBACK_WATERING_TIME_IN_SECONDS + ) + await self.coordinator.write(Valve.remaining_open_time, value) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.coordinator.write(Valve.remaining_open_time, 0) + self._attr_is_closed = True + self.async_write_ha_state() diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index af33ae3b36f..6e287c424b9 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -361,6 +361,10 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): self.user_input = user_input self.title = name + if still_url is None: + return self.async_create_entry( + title=self.title, data={}, options=self.user_input + ) # temporary preview for user to check the image self.context["preview_cam"] = user_input return await self.async_step_user_confirm_still() diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 65f6aa751ca..34f8025737f 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", + "integration_type": "device", "iot_class": "local_push", "requirements": ["ha-av==10.1.1", "Pillow==10.3.0"] } diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index 75f69bbe88c..6a59e24ebd2 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -1,6 +1,25 @@ """The generic_thermostat component.""" +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform +from homeassistant.core import HomeAssistant DOMAIN = "generic_thermostat" PLATFORMS = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up 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: + """Update listener, called when the config entry options are changed.""" + 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/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 4c660bd03e9..91ff1af122d 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from datetime import datetime, timedelta import logging import math @@ -25,6 +26,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -51,8 +53,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import ConditionError -from homeassistant.helpers import condition -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_state_change_event, @@ -95,7 +96,7 @@ CONF_PRESETS = { ) } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA_COMMON = vol.Schema( { vol.Required(CONF_HEATER): cv.entity_id, vol.Required(CONF_SENSOR): cv.entity_id, @@ -111,15 +112,34 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In( [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] ), - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + vol.Optional(CONF_PRECISION): vol.All( + vol.Coerce(float), + vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]), ), - vol.Optional(CONF_TEMP_STEP): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + vol.Optional(CONF_TEMP_STEP): vol.All( + vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]) ), vol.Optional(CONF_UNIQUE_ID): cv.string, + **{vol.Optional(v): vol.Coerce(float) for v in CONF_PRESETS.values()}, } -).extend({vol.Optional(v): vol.Coerce(float) for (k, v) in CONF_PRESETS.items()}) +) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(PLATFORM_SCHEMA_COMMON.schema) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + await _async_setup_config( + hass, + PLATFORM_SCHEMA_COMMON(dict(config_entry.options)), + config_entry.entry_id, + async_add_entities, + ) async def async_setup_platform( @@ -131,6 +151,18 @@ async def async_setup_platform( """Set up the generic thermostat platform.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + await _async_setup_config( + hass, config, config.get(CONF_UNIQUE_ID), async_add_entities + ) + + +async def _async_setup_config( + hass: HomeAssistant, + config: Mapping[str, Any], + unique_id: str | None, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the generic thermostat platform.""" name: str = config[CONF_NAME] heater_entity_id: str = config[CONF_HEATER] @@ -150,7 +182,6 @@ async def async_setup_platform( precision: float | None = config.get(CONF_PRECISION) target_temperature_step: float | None = config.get(CONF_TEMP_STEP) unit = hass.config.units.temperature_unit - unique_id: str | None = config.get(CONF_UNIQUE_ID) async_add_entities( [ diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py new file mode 100644 index 00000000000..f1fe1ecfe25 --- /dev/null +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for Generic hygrostat.""" + +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, SensorDeviceClass +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import CONF_NAME, DEGREE +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) + +from .climate import ( + CONF_AC_MODE, + CONF_COLD_TOLERANCE, + CONF_HEATER, + CONF_HOT_TOLERANCE, + CONF_MIN_DUR, + CONF_PRESETS, + CONF_SENSOR, + DEFAULT_TOLERANCE, + DOMAIN, +) + +OPTIONS_SCHEMA = { + vol.Required(CONF_AC_MODE): selector.BooleanSelector( + selector.BooleanSelectorConfig(), + ), + vol.Required(CONF_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=SENSOR_DOMAIN, device_class=SensorDeviceClass.TEMPERATURE + ) + ), + vol.Required(CONF_HEATER): selector.EntitySelector( + selector.EntitySelectorConfig(domain=SWITCH_DOMAIN) + ), + vol.Required( + CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE + ): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1 + ) + ), + vol.Required( + CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE + ): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1 + ) + ), + vol.Optional(CONF_MIN_DUR): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), +} + +PRESETS_SCHEMA = { + vol.Optional(v): selector.NumberSelector( + selector.NumberSelectorConfig( + mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE + ) + ) + for v in CONF_PRESETS.values() +} + +CONFIG_SCHEMA = { + vol.Required(CONF_NAME): selector.TextSelector(), + **OPTIONS_SCHEMA, +} + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"), + "presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)), +} + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"), + "presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)), +} + + +class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) diff --git a/homeassistant/components/generic_thermostat/manifest.json b/homeassistant/components/generic_thermostat/manifest.json index 7bfa1000845..320de2aeb3e 100644 --- a/homeassistant/components/generic_thermostat/manifest.json +++ b/homeassistant/components/generic_thermostat/manifest.json @@ -2,7 +2,9 @@ "domain": "generic_thermostat", "name": "Generic Thermostat", "codeowners": [], + "config_flow": true, "dependencies": ["sensor", "switch"], "documentation": "https://www.home-assistant.io/integrations/generic_thermostat", + "integration_type": "helper", "iot_class": "local_polling" } diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 8834892b7ab..27a563a9d8d 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -1,4 +1,74 @@ { + "title": "Generic thermostat", + "config": { + "step": { + "user": { + "title": "Add generic thermostat helper", + "description": "Create a climate entity that controls the temperature via a switch and sensor.", + "data": { + "ac_mode": "Cooling mode", + "heater": "Actuator switch", + "target_sensor": "Temperature sensor", + "min_cycle_duration": "Minimum cycle duration", + "name": "[%key:common::config_flow::data::name%]", + "cold_tolerance": "Cold tolerance", + "hot_tolerance": "Hot tolerance" + }, + "data_description": { + "ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.", + "heater": "Switch entity used to cool or heat depending on A/C mode.", + "target_sensor": "Temperature sensor that reflect the current temperature.", + "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.", + "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", + "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." + } + }, + "presets": { + "title": "Temperature presets", + "data": { + "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", + "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "home_temp": "[%common::state::home%]", + "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", + "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]", + "heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]", + "target_sensor": "[%key:component::generic_thermostat::config::step::user::data::target_sensor%]", + "min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]", + "cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]", + "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]" + }, + "data_description": { + "heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]", + "target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]", + "ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]", + "min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]", + "cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]", + "hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]" + } + }, + "presets": { + "title": "[%key:component::generic_thermostat::config::step::presets::title%]", + "data": { + "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", + "comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]", + "eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]", + "home_temp": "[%key:common::state::home%]", + "sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", + "activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]" + } + } + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 5fc21a3e5b4..05afb121d44 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -215,8 +215,8 @@ class GeniusBroker: """Make any useful debug log entries.""" _LOGGER.debug( "Raw JSON: \n\nclient._zones = %s \n\nclient._devices = %s", - self.client._zones, # pylint: disable=protected-access - self.client._devices, # pylint: disable=protected-access + self.client._zones, # noqa: SLF001 + self.client._devices, # noqa: SLF001 ) @@ -309,8 +309,7 @@ class GeniusZone(GeniusEntity): mode = payload["data"][ATTR_ZONE_MODE] - # pylint: disable-next=protected-access - if mode == "footprint" and not self._zone._has_pir: + if mode == "footprint" and not self._zone._has_pir: # noqa: SLF001 raise TypeError( f"'{self.entity_id}' cannot support footprint mode (it has no PIR)" ) diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py index a50f7e432d9..d55fe6e3ee6 100644 --- a/homeassistant/components/geo_json_events/__init__.py +++ b/homeassistant/components/geo_json_events/__init__.py @@ -7,10 +7,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get, -) +from homeassistant.helpers import entity_registry as er from .const import DOMAIN, PLATFORMS from .manager import GeoJsonFeedEntityManager @@ -40,8 +37,8 @@ async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None: has no previous data to compare against, and thus all entities managed by this integration are removed after startup. """ - entity_registry = async_get(hass) - orphaned_entries = async_entries_for_config_entry(entity_registry, entry_id) + entity_registry = er.async_get(hass) + orphaned_entries = er.async_entries_for_config_entry(entity_registry, entry_id) if orphaned_entries is not None: for entry in orphaned_entries: if entry.domain == Platform.GEO_LOCATION: diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 6c49ddd9020..b5a0e9d5371 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -2,31 +2,24 @@ from __future__ import annotations -import asyncio from dataclasses import dataclass import logging -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError -from gios import Gios -from gios.exceptions import GiosError -from gios.model import GiosSensors - from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN, SCAN_INTERVAL +from .const import CONF_STATION_ID, DOMAIN +from .coordinator import GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -GiosConfigEntry = ConfigEntry["GiosData"] +type GiosConfigEntry = ConfigEntry[GiosData] @dataclass @@ -77,23 +70,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): # pylint: disable=hass-enforce-coordinator-module - """Define an object to hold GIOS data.""" - - def __init__( - self, hass: HomeAssistant, session: ClientSession, station_id: int - ) -> None: - """Class to manage fetching GIOS data API.""" - self.gios = Gios(station_id, session) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - - async def _async_update_data(self) -> GiosSensors: - """Update data via library.""" - try: - async with asyncio.timeout(API_TIMEOUT): - return await self.gios.async_update() - except (GiosError, ClientConnectorError) as error: - raise UpdateFailed(error) from error diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py new file mode 100644 index 00000000000..17b4b89174f --- /dev/null +++ b/homeassistant/components/gios/coordinator.py @@ -0,0 +1,39 @@ +"""The GIOS component.""" + +from __future__ import annotations + +import asyncio +import logging + +from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientConnectorError +from gios import Gios +from gios.exceptions import GiosError +from gios.model import GiosSensors + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_TIMEOUT, DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): + """Define an object to hold GIOS data.""" + + def __init__( + self, hass: HomeAssistant, session: ClientSession, station_id: int + ) -> None: + """Class to manage fetching GIOS data API.""" + self.gios = Gios(station_id, session) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> GiosSensors: + """Update data via library.""" + try: + async with asyncio.timeout(API_TIMEOUT): + return await self.gios.async_update() + except (GiosError, ClientConnectorError) as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 244e741a086..69e198d34df 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GiosConfigEntry, GiosDataUpdateCoordinator +from . import GiosConfigEntry from .const import ( ATTR_AQI, ATTR_C6H6, @@ -38,6 +38,7 @@ from .const import ( MANUFACTURER, URL, ) +from .coordinator import GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 1f0fbc71efe..25d8782618f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -148,9 +148,7 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="could_not_register") if self.login_task is None: - self.login_task = self.hass.async_create_task( - _wait_for_login(), eager_start=False - ) + self.login_task = self.hass.async_create_task(_wait_for_login()) if self.login_task.done(): if self.login_task.exception(): diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index b6c4f477b46..f83b39d1cf9 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -40,8 +40,12 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) _LOGGER = logging.getLogger(__name__) +type GlancesConfigEntry = ConfigEntry[GlancesDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: GlancesConfigEntry +) -> bool: """Set up Glances from config entry.""" try: api = await get_api(hass, dict(config_entry.data)) @@ -54,26 +58,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = GlancesDataUpdateCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GlancesConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: """Return the api from glances_api.""" httpx_client = get_async_client(hass, verify_ssl=entry_data[CONF_VERIFY_SSL]) - for version in (3, 2): + for version in (4, 3, 2): api = Glances( host=entry_data[CONF_HOST], port=entry_data[CONF_PORT], @@ -100,7 +100,7 @@ async def get_api(hass: HomeAssistant, entry_data: dict[str, Any]) -> Glances: ) _LOGGER.debug("Connected to Glances API v%s", version) return api - raise ServerVersionMismatch("Could not connect to Glances API version 2 or 3") + raise ServerVersionMismatch("Could not connect to Glances API version 2, 3 or 4") class ServerVersionMismatch(HomeAssistantError): diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index 2fb5cf16996..e129a375df2 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.6.0"] + "requirements": ["glances-api==0.8.0"] } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index c5706757725..a1cb8e47b9d 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -23,7 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GlancesDataUpdateCoordinator +from . import GlancesConfigEntry, GlancesDataUpdateCoordinator from .const import CPU_ICON, DOMAIN @@ -288,12 +287,12 @@ SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GlancesConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Glances sensors.""" - coordinator: GlancesDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities: list[GlancesSensor] = [] for sensor_type, sensors in coordinator.data.items(): diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 60b0338c258..6698d1efc99 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -6,20 +6,18 @@ from typing import TYPE_CHECKING from goalzero import Yeti, exceptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac -from .const import DOMAIN -from .coordinator import GoalZeroDataUpdateCoordinator +from .coordinator import GoalZeroConfigEntry, GoalZeroDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoalZeroConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" mac = entry.unique_id @@ -38,16 +36,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except exceptions.ConnectError as ex: raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex - coordinator = GoalZeroDataUpdateCoordinator(hass, api) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = GoalZeroDataUpdateCoordinator(hass, api) + await entry.runtime_data.async_config_entry_first_refresh() 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, entry: GoalZeroConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index eec8773db30..6bd061879eb 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -9,12 +9,11 @@ from homeassistant.components.binary_sensor import ( 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 GoalZeroConfigEntry from .entity import GoalZeroEntity PARALLEL_UPDATES = 0 @@ -43,14 +42,13 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: GoalZeroConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Goal Zero Yeti sensor.""" async_add_entities( - GoalZeroBinarySensor( - hass.data[DOMAIN][entry.entry_id], - description, - ) + GoalZeroBinarySensor(entry.runtime_data, description) for description in BINARY_SENSOR_TYPES ) diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index c276db135fa..eb38e8fa154 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -111,7 +111,7 @@ class GoalZeroFlowHandler(ConfigFlow, domain=DOMAIN): return None, "cannot_connect" except exceptions.InvalidHost: return None, "invalid_host" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return None, "unknown" return str(api.sysdata["macAddress"]), None diff --git a/homeassistant/components/goalzero/coordinator.py b/homeassistant/components/goalzero/coordinator.py index 61c3a8dba29..3c7cd967482 100644 --- a/homeassistant/components/goalzero/coordinator.py +++ b/homeassistant/components/goalzero/coordinator.py @@ -10,11 +10,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER +type GoalZeroConfigEntry = ConfigEntry[GoalZeroDataUpdateCoordinator] + class GoalZeroDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for the Goal zero integration.""" - config_entry: ConfigEntry + config_entry: GoalZeroConfigEntry def __init__(self, hass: HomeAssistant, api: Yeti) -> None: """Initialize the coordinator.""" diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 86f8bc9455b..f565c216745 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -26,7 +25,7 @@ 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 GoalZeroConfigEntry from .entity import GoalZeroEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -130,15 +129,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: GoalZeroConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Goal Zero Yeti sensor.""" async_add_entities( - GoalZeroSensor( - hass.data[DOMAIN][entry.entry_id], - description, - ) - for description in SENSOR_TYPES + GoalZeroSensor(entry.runtime_data, description) for description in SENSOR_TYPES ) diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index 9c0aee03b83..daff4ee5fec 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -5,11 +5,10 @@ from __future__ import annotations from typing import Any, cast 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 DOMAIN +from .coordinator import GoalZeroConfigEntry from .entity import GoalZeroEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( @@ -29,15 +28,13 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: GoalZeroConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Goal Zero Yeti switch.""" async_add_entities( - GoalZeroSwitch( - hass.data[DOMAIN][entry.entry_id], - description, - ) - for description in SWITCH_TYPES + GoalZeroSwitch(entry.runtime_data, description) for description in SWITCH_TYPES ) diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 01834187c70..3052e9041ac 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Mapping from datetime import timedelta import logging from typing import Any, NamedTuple @@ -24,16 +24,12 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed from .const import DATA_UPDATE_COORDINATOR, DEVICE_TYPE_ISMARTGATE, DOMAIN, MANUFACTURER +from .coordinator import DeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -46,38 +42,6 @@ class StateData(NamedTuple): door: AbstractDoor | None -class DeviceDataUpdateCoordinator( - DataUpdateCoordinator[GogoGate2InfoResponse | ISmartGateInfoResponse] -): # pylint: disable=hass-enforce-coordinator-module - """Manages polling for state changes from the device.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - api: AbstractGateApi, - *, - name: str, - update_interval: timedelta, - update_method: Callable[ - [], Awaitable[GogoGate2InfoResponse | ISmartGateInfoResponse] - ] - | None = None, - request_refresh_debouncer: Debouncer | None = None, - ) -> None: - """Initialize the data update coordinator.""" - DataUpdateCoordinator.__init__( - self, - hass, - logger, - name=name, - update_interval=update_interval, - update_method=update_method, - request_refresh_debouncer=request_refresh_debouncer, - ) - self.api = api - - class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Base class for gogogate2 entities.""" diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index 96ab97f5ba5..cd9ca21b063 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -111,7 +111,7 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): else: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "cannot_connect" if self._ip_address and self._device_type: diff --git a/homeassistant/components/gogogate2/coordinator.py b/homeassistant/components/gogogate2/coordinator.py new file mode 100644 index 00000000000..7c15e8b1c32 --- /dev/null +++ b/homeassistant/components/gogogate2/coordinator.py @@ -0,0 +1,45 @@ +"""Coordinator for GogoGate2 component.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import timedelta +import logging + +from ismartgate import AbstractGateApi, GogoGate2InfoResponse, ISmartGateInfoResponse + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +class DeviceDataUpdateCoordinator( + DataUpdateCoordinator[GogoGate2InfoResponse | ISmartGateInfoResponse] +): + """Manages polling for state changes from the device.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + api: AbstractGateApi, + *, + name: str, + update_interval: timedelta, + update_method: Callable[ + [], Awaitable[GogoGate2InfoResponse | ISmartGateInfoResponse] + ] + | None = None, + request_refresh_debouncer: Debouncer | None = None, + ) -> None: + """Initialize the data update coordinator.""" + DataUpdateCoordinator.__init__( + self, + hass, + logger, + name=name, + update_interval=update_interval, + update_method=update_method, + request_refresh_debouncer=request_refresh_debouncer, + ) + self.api = api diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 17cfebe4a70..e807f1acd3f 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -20,12 +20,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - DeviceDataUpdateCoordinator, - GoGoGate2Entity, - cover_unique_id, - get_data_update_coordinator, -) +from .common import GoGoGate2Entity, cover_unique_id, get_data_update_coordinator +from .coordinator import DeviceDataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index c67b7f371e2..1dd0a57f7ed 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -16,12 +16,8 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - DeviceDataUpdateCoordinator, - GoGoGate2Entity, - get_data_update_coordinator, - sensor_unique_id, -) +from .common import GoGoGate2Entity, get_data_update_coordinator, sensor_unique_id +from .coordinator import DeviceDataUpdateCoordinator SENSOR_ID_WIRED = "WIRE" diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 6f1bdd2b449..41e0ed91f6a 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.3.2"] + "requirements": ["goodwe==0.3.6"] } diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index fc8b3864ae9..ce36bb35bf9 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -63,7 +63,7 @@ NUMBERS = ( native_unit_of_measurement=PERCENTAGE, native_step=1, native_min_value=0, - native_max_value=100, + native_max_value=200, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", @@ -131,6 +131,11 @@ class InverterNumberEntity(NumberEntity): self._attr_native_value = float(current_value) self._inverter: Inverter = inverter + async def async_update(self) -> None: + """Get the current value from inverter.""" + value = await self.entity_description.getter(self._inverter) + self._attr_native_value = float(value) + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.setter(self._inverter, int(value)) diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index f42f50c93fc..4fa84c8401f 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -89,6 +89,11 @@ class InverterOperationModeEntity(SelectEntity): self._attr_current_option = current_mode self._inverter: Inverter = inverter + async def async_update(self) -> None: + """Get the current value from inverter.""" + value = await self._inverter.get_operation_mode() + self._attr_current_option = _MODE_TO_OPTION[value] + async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._inverter.set_operation_mode(_OPTION_TO_MODE[option]) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index eb77eb27106..f51bf64d400 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -2,24 +2,15 @@ from __future__ import annotations -from collections.abc import Iterable from datetime import datetime, timedelta -import itertools import logging from typing import Any, cast -from gcal_sync.api import ( - GoogleCalendarService, - ListEventsRequest, - Range, - SyncEventsRequest, -) +from gcal_sync.api import Range, SyncEventsRequest from gcal_sync.exceptions import ApiException from gcal_sync.model import AccessRole, DateOrDatetime, Event from gcal_sync.store import ScopedCalendarStore from gcal_sync.sync import CalendarEventSyncManager -from gcal_sync.timeline import Timeline -from ical.iter import SortableItemValue from homeassistant.components.calendar import ( CREATE_EVENT_SCHEMA, @@ -43,11 +34,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from . import ( @@ -74,14 +61,10 @@ from .const import ( EVENT_START_DATETIME, FeatureAccess, ) +from .coordinator import CalendarQueryUpdateCoordinator, CalendarSyncUpdateCoordinator _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -# Maximum number of upcoming events to consider for state changes between -# coordinator updates. -MAX_UPCOMING_EVENTS = 20 - # Avoid syncing super old data on initial syncs. Note that old but active # recurring events are still included. SYNC_EVENT_MIN_TIME = timedelta(days=-90) @@ -249,140 +232,6 @@ async def async_setup_entry( ) -def _truncate_timeline(timeline: Timeline, max_events: int) -> Timeline: - """Truncate the timeline to a maximum number of events. - - This is used to avoid repeated expansion of recurring events during - state machine updates. - """ - upcoming = timeline.active_after(dt_util.now()) - truncated = list(itertools.islice(upcoming, max_events)) - return Timeline( - [ - SortableItemValue(event.timespan_of(dt_util.DEFAULT_TIME_ZONE), event) - for event in truncated - ] - ) - - -class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): # pylint: disable=hass-enforce-coordinator-module - """Coordinator for calendar RPC calls that use an efficient sync.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - sync: CalendarEventSyncManager, - name: str, - ) -> None: - """Create the CalendarSyncUpdateCoordinator.""" - super().__init__( - hass, - _LOGGER, - name=name, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) - self.sync = sync - self._upcoming_timeline: Timeline | None = None - - async def _async_update_data(self) -> Timeline: - """Fetch data from API endpoint.""" - try: - await self.sync.run() - except ApiException as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - timeline = await self.sync.store_service.async_get_timeline( - dt_util.DEFAULT_TIME_ZONE - ) - self._upcoming_timeline = _truncate_timeline(timeline, MAX_UPCOMING_EVENTS) - return timeline - - async def async_get_events( - self, start_date: datetime, end_date: datetime - ) -> Iterable[Event]: - """Get all events in a specific time frame.""" - if not self.data: - raise HomeAssistantError( - "Unable to get events: Sync from server has not completed" - ) - return self.data.overlapping( - start_date, - end_date, - ) - - @property - def upcoming(self) -> Iterable[Event] | None: - """Return upcoming events if any.""" - if self._upcoming_timeline: - return self._upcoming_timeline.active_after(dt_util.now()) - return None - - -class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): # pylint: disable=hass-enforce-coordinator-module - """Coordinator for calendar RPC calls. - - This sends a polling RPC, not using sync, as a workaround - for limitations in the calendar API for supporting search. - """ - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - calendar_service: GoogleCalendarService, - name: str, - calendar_id: str, - search: str | None, - ) -> None: - """Create the CalendarQueryUpdateCoordinator.""" - super().__init__( - hass, - _LOGGER, - name=name, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) - self.calendar_service = calendar_service - self.calendar_id = calendar_id - self._search = search - - async def async_get_events( - self, start_date: datetime, end_date: datetime - ) -> Iterable[Event]: - """Get all events in a specific time frame.""" - request = ListEventsRequest( - calendar_id=self.calendar_id, - start_time=start_date, - end_time=end_date, - search=self._search, - ) - result_items = [] - try: - result = await self.calendar_service.async_list_events(request) - async for result_page in result: - result_items.extend(result_page.items) - except ApiException as err: - self.async_set_update_error(err) - raise HomeAssistantError(str(err)) from err - return result_items - - async def _async_update_data(self) -> list[Event]: - """Fetch data from API endpoint.""" - request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search) - try: - result = await self.calendar_service.async_list_events(request) - except ApiException as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - return result.items - - @property - def upcoming(self) -> Iterable[Event] | None: - """Return the next upcoming event if any.""" - return self.data - - class GoogleCalendarEntity( CoordinatorEntity[CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator], CalendarEntity, @@ -492,11 +341,11 @@ class GoogleCalendarEntity( if isinstance(dtstart, datetime): start = DateOrDatetime( date_time=dt_util.as_local(dtstart), - timezone=str(dt_util.DEFAULT_TIME_ZONE), + timezone=str(dt_util.get_default_time_zone()), ) end = DateOrDatetime( date_time=dt_util.as_local(dtend), - timezone=str(dt_util.DEFAULT_TIME_ZONE), + timezone=str(dt_util.get_default_time_zone()), ) else: start = DateOrDatetime(date=dtstart) @@ -519,7 +368,7 @@ class GoogleCalendarEntity( CalendarSyncUpdateCoordinator, self.coordinator ).sync.store_service.async_add_event(event) except ApiException as err: - raise HomeAssistantError(f"Error while creating event: {str(err)}") from err + raise HomeAssistantError(f"Error while creating event: {err!s}") from err await self.coordinator.async_refresh() async def async_delete_event( diff --git a/homeassistant/components/google/coordinator.py b/homeassistant/components/google/coordinator.py new file mode 100644 index 00000000000..19198041c05 --- /dev/null +++ b/homeassistant/components/google/coordinator.py @@ -0,0 +1,162 @@ +"""Support for Google Calendar Search binary sensors.""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime, timedelta +import itertools +import logging + +from gcal_sync.api import GoogleCalendarService, ListEventsRequest +from gcal_sync.exceptions import ApiException +from gcal_sync.model import Event +from gcal_sync.sync import CalendarEventSyncManager +from gcal_sync.timeline import Timeline +from ical.iter import SortableItemValue + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +# Maximum number of upcoming events to consider for state changes between +# coordinator updates. +MAX_UPCOMING_EVENTS = 20 + + +def _truncate_timeline(timeline: Timeline, max_events: int) -> Timeline: + """Truncate the timeline to a maximum number of events. + + This is used to avoid repeated expansion of recurring events during + state machine updates. + """ + upcoming = timeline.active_after(dt_util.now()) + truncated = list(itertools.islice(upcoming, max_events)) + return Timeline( + [ + SortableItemValue(event.timespan_of(dt_util.get_default_time_zone()), event) + for event in truncated + ] + ) + + +class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): + """Coordinator for calendar RPC calls that use an efficient sync.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + sync: CalendarEventSyncManager, + name: str, + ) -> None: + """Create the CalendarSyncUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + self.sync = sync + self._upcoming_timeline: Timeline | None = None + + async def _async_update_data(self) -> Timeline: + """Fetch data from API endpoint.""" + try: + await self.sync.run() + except ApiException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + timeline = await self.sync.store_service.async_get_timeline( + dt_util.get_default_time_zone() + ) + self._upcoming_timeline = _truncate_timeline(timeline, MAX_UPCOMING_EVENTS) + return timeline + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> Iterable[Event]: + """Get all events in a specific time frame.""" + if not self.data: + raise HomeAssistantError( + "Unable to get events: Sync from server has not completed" + ) + return self.data.overlapping( + start_date, + end_date, + ) + + @property + def upcoming(self) -> Iterable[Event] | None: + """Return upcoming events if any.""" + if self._upcoming_timeline: + return self._upcoming_timeline.active_after(dt_util.now()) + return None + + +class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): + """Coordinator for calendar RPC calls. + + This sends a polling RPC, not using sync, as a workaround + for limitations in the calendar API for supporting search. + """ + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + calendar_service: GoogleCalendarService, + name: str, + calendar_id: str, + search: str | None, + ) -> None: + """Create the CalendarQueryUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + self.calendar_service = calendar_service + self.calendar_id = calendar_id + self._search = search + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> Iterable[Event]: + """Get all events in a specific time frame.""" + request = ListEventsRequest( + calendar_id=self.calendar_id, + start_time=start_date, + end_time=end_date, + search=self._search, + ) + result_items = [] + try: + result = await self.calendar_service.async_list_events(request) + async for result_page in result: + result_items.extend(result_page.items) + except ApiException as err: + self.async_set_update_error(err) + raise HomeAssistantError(str(err)) from err + return result_items + + async def _async_update_data(self) -> list[Event]: + """Fetch data from API endpoint.""" + request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search) + try: + result = await self.calendar_service.async_list_events(request) + except ApiException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + return result.items + + @property + def upcoming(self) -> Iterable[Event] | None: + """Return the next upcoming event if any.""" + return self.data diff --git a/homeassistant/components/google/diagnostics.py b/homeassistant/components/google/diagnostics.py index 0313e61bc8e..1a6f498b4cd 100644 --- a/homeassistant/components/google/diagnostics.py +++ b/homeassistant/components/google/diagnostics.py @@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" payload: dict[str, Any] = { "now": dt_util.now().isoformat(), - "timezone": str(dt_util.DEFAULT_TIME_ZONE), + "timezone": str(dt_util.get_default_time_zone()), "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index ac43dc58953..062bf58d2f5 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.0.4", "oauth2client==4.1.3", "ical==8.0.0"] + "requirements": ["gcal-sync==6.0.4", "oauth2client==4.1.3", "ical==8.0.1"] } diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index e97d8108965..04c85639e07 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -83,6 +83,7 @@ TYPE_DOOR = f"{PREFIX_TYPES}DOOR" TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL" TYPE_FAN = f"{PREFIX_TYPES}FAN" TYPE_GARAGE = f"{PREFIX_TYPES}GARAGE" +TYPE_GATE = f"{PREFIX_TYPES}GATE" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT" TYPE_LOCK = f"{PREFIX_TYPES}LOCK" @@ -171,7 +172,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (cover.DOMAIN, cover.CoverDeviceClass.CURTAIN): TYPE_CURTAIN, (cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR, (cover.DOMAIN, cover.CoverDeviceClass.GARAGE): TYPE_GARAGE, - (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, + (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GATE, (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, (cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW, (event.DOMAIN, event.EventDeviceClass.DOORBELL): TYPE_DOORBELL, diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 95c5bafc2cc..e47679e038f 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -396,8 +396,7 @@ async def async_get_users(hass: HomeAssistant) -> list[str]: This is called by the cloud integration to import from the previously shared store. """ - # pylint: disable-next=protected-access - path = hass.config.path(STORAGE_DIR, GoogleConfigStore._STORAGE_KEY) + path = hass.config.path(STORAGE_DIR, GoogleConfigStore._STORAGE_KEY) # noqa: SLF001 try: store_data = await hass.async_add_executor_job(json_util.load_json, path) except HomeAssistantError: diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index a03d7c397cc..e362d1121c2 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -90,7 +90,7 @@ async def _process(hass, data, message): result = await handler(hass, data, inputs[0].get("payload")) except SmartHomeError as err: return {"requestId": data.request_id, "payload": {"errorCode": err.code}} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error") return { "requestId": data.request_id, @@ -115,7 +115,7 @@ async def async_devices_sync_response(hass, config, agent_user_id): try: devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error serializing %s", entity.entity_id) return devices @@ -179,7 +179,7 @@ async def async_devices_query_response(hass, config, payload_devices): entity = GoogleEntity(hass, config, state) try: devices[devid] = entity.query_serialize() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error serializing query for %s", state) devices[devid] = {"online": False} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 3efeabfa778..05d18f1e45b 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -5,7 +5,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging -from typing import Any, TypeVar +from typing import Any from homeassistant.components import ( alarm_control_panel, @@ -242,10 +242,8 @@ COVER_VALVE_DOMAINS = {cover.DOMAIN, valve.DOMAIN} FRIENDLY_DOMAIN = {cover.DOMAIN: "Cover", valve.DOMAIN: "Valve"} -_TraitT = TypeVar("_TraitT", bound="_Trait") - -def register_trait(trait: type[_TraitT]) -> type[_TraitT]: +def register_trait[_TraitT: _Trait](trait: type[_TraitT]) -> type[_TraitT]: """Decorate a class to register a trait.""" TRAITS.append(trait) return trait @@ -259,7 +257,7 @@ def _google_temp_unit(units): def _next_selected(items: list[str], selected: str | None) -> str | None: - """Return the next item in a item list starting at given value. + """Return the next item in an item list starting at given value. If selected is missing in items, None is returned """ @@ -1555,19 +1553,20 @@ class ArmDisArmTrait(_Trait): state_to_service = { STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, - STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, + STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS: SERVICE_ALARM_ARM_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED: SERVICE_ALARM_TRIGGER, } state_to_support = { STATE_ALARM_ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME, - STATE_ALARM_ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY, STATE_ALARM_ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT, + STATE_ALARM_ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED: AlarmControlPanelEntityFeature.TRIGGER, } + """The list of states to support in increasing security state.""" @staticmethod def supported(domain, features, device_class, _): @@ -1588,6 +1587,17 @@ class ArmDisArmTrait(_Trait): if features & required_feature != 0 ] + def _default_arm_state(self): + states = self._supported_states() + + if STATE_ALARM_TRIGGERED in states: + states.remove(STATE_ALARM_TRIGGERED) + + if not states: + raise SmartHomeError(ERR_NOT_SUPPORTED, "ArmLevel missing") + + return states[0] + def sync_attributes(self): """Return ArmDisarm attributes for a sync request.""" response = {} @@ -1605,32 +1615,27 @@ class ArmDisArmTrait(_Trait): } levels.append(level) - response["availableArmLevels"] = {"levels": levels, "ordered": False} + response["availableArmLevels"] = {"levels": levels, "ordered": True} return response def query_attributes(self): """Return ArmDisarm query attributes.""" armed_state = self.state.attributes.get("next_state", self.state.state) - response = {"isArmed": armed_state in self.state_to_service} - if response["isArmed"]: - response.update({"currentArmLevel": armed_state}) - return response + + if armed_state in self.state_to_service: + return {"isArmed": True, "currentArmLevel": armed_state} + return { + "isArmed": False, + "currentArmLevel": self._default_arm_state(), + } async def execute(self, command, data, params, challenge): """Execute an ArmDisarm command.""" if params["arm"] and not params.get("cancel"): - # If no arm level given, we can only arm it if there is - # only one supported arm type. We never default to triggered. + # If no arm level given, we we arm the first supported + # level in state_to_support. if not (arm_level := params.get("armLevel")): - states = self._supported_states() - - if STATE_ALARM_TRIGGERED in states: - states.remove(STATE_ALARM_TRIGGERED) - - if len(states) != 1: - raise SmartHomeError(ERR_NOT_SUPPORTED, "ArmLevel missing") - - arm_level = states[0] + arm_level = self._default_arm_state() if self.state.state == arm_level: raise SmartHomeError(ERR_ALREADY_ARMED, "System is already armed") diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 7d8653b509d..4ea496f2824 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -26,11 +26,18 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.typing import ConfigType -from .const import DATA_MEM_STORAGE, DATA_SESSION, DOMAIN, SUPPORTED_LANGUAGE_CODES +from .const import ( + CONF_LANGUAGE_CODE, + DATA_MEM_STORAGE, + DATA_SESSION, + DOMAIN, + SUPPORTED_LANGUAGE_CODES, +) from .helpers import ( GoogleAssistantSDKAudioView, InMemoryStorage, async_send_text_commands, + best_matching_language_code, ) SERVICE_SEND_TEXT_COMMAND = "send_text_command" @@ -164,15 +171,24 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): if not session.valid_token: await session.async_ensure_token_valid() self.assistant = None - if not self.assistant or user_input.language != self.language: - credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) - self.language = user_input.language + + language = best_matching_language_code( + self.hass, + user_input.language, + self.entry.options.get(CONF_LANGUAGE_CODE), + ) + + if not self.assistant or language != self.language: + credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] + self.language = language self.assistant = TextAssistant(credentials, self.language) - resp = self.assistant.assist(user_input.text) + resp = await self.hass.async_add_executor_job( + self.assistant.assist, user_input.text + ) text_response = resp[0] or "" - intent_response = intent.IntentResponse(language=user_input.language) + intent_response = intent.IntentResponse(language=language) intent_response.async_set_speech(text_response) return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id diff --git a/homeassistant/components/google_assistant_sdk/diagnostics.py b/homeassistant/components/google_assistant_sdk/diagnostics.py new file mode 100644 index 00000000000..eacded4e2e6 --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics support for Google Assistant SDK.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +TO_REDACT = {"access_token", "refresh_token"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "data": entry.data, + "options": entry.options, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index ccd0fe765ac..f9d332cd735 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -72,14 +72,14 @@ async def async_send_text_commands( entry.async_start_reauth(hass) raise - credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) + credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass)) with TextAssistant( credentials, language_code, audio_out=bool(media_players) ) as assistant: command_response_list = [] for command in commands: - resp = assistant.assist(command) + resp = await hass.async_add_executor_job(assistant.assist, command) text_response = resp[0] _LOGGER.debug("command: %s\nresponse: %s", command, text_response) audio_response = resp[2] @@ -113,6 +113,33 @@ def default_language_code(hass: HomeAssistant) -> str: return DEFAULT_LANGUAGE_CODES.get(hass.config.language, "en-US") +def best_matching_language_code( + hass: HomeAssistant, assist_language: str, agent_language: str | None = None +) -> str: + """Get the best matching language, based on the preferred assist language and the configured agent language.""" + + # Use the assist language if supported + if assist_language in SUPPORTED_LANGUAGE_CODES: + return assist_language + language = assist_language.split("-")[0] + + # Use the agent language if assist and agent start with the same language part + if agent_language is not None and agent_language.startswith(language): + return best_matching_language_code(hass, agent_language) + + # If assist and agent are not matching, try to find the default language + default_language = DEFAULT_LANGUAGE_CODES.get(language) + if default_language is not None: + return default_language + + # If no default agent is available, use the agent language + if agent_language is not None: + return best_matching_language_code(hass, agent_language) + + # Fallback to the system default language + return default_language_code(hass) + + class InMemoryStorage: """Temporarily store and retrieve data from in memory storage.""" diff --git a/homeassistant/components/google_assistant_sdk/notify.py b/homeassistant/components/google_assistant_sdk/notify.py index 3f01cef2ebc..8ea3d37d5b6 100644 --- a/homeassistant/components/google_assistant_sdk/notify.py +++ b/homeassistant/components/google_assistant_sdk/notify.py @@ -15,7 +15,10 @@ from .helpers import async_send_text_commands, default_language_code # https://support.google.com/assistant/answer/9071582?hl=en LANG_TO_BROADCAST_COMMAND = { "en": ("broadcast {0}", "broadcast to {1} {0}"), - "de": ("Nachricht an alle {0}", "Nachricht an alle an {1} {0}"), + "de": ( + "Nachricht an alle {0}", # codespell:ignore alle + "Nachricht an alle an {1} {0}", # codespell:ignore alle + ), "es": ("Anuncia {0}", "Anuncia en {1} {0}"), "fr": ("Diffuse {0}", "Diffuse dans {1} {0}"), "it": ("Trasmetti {0}", "Trasmetti in {1} {0}"), diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index cd5c53b5fd7..c5eeaa7d924 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -295,7 +295,7 @@ class GoogleCloudTTSProvider(Provider): except TimeoutError as ex: _LOGGER.error("Timeout for Google Cloud TTS call: %s", ex) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error occurred during Google Cloud TTS call") return None, None diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e956c288b53..f115f3923b6 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,20 +2,18 @@ from __future__ import annotations -from functools import partial -import logging import mimetypes from pathlib import Path -from typing import Literal -from google.api_core.exceptions import ClientError +from google.ai import generativelanguage_v1beta +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import ClientError, DeadlineExceeded, GoogleAPICallError import google.generativeai as genai import google.generativeai.types as genai_types import voluptuous as vol -from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, MATCH_ALL +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -23,35 +21,21 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, ConfigEntryNotReady, HomeAssistantError, - TemplateError, ) -from homeassistant.helpers import config_validation as cv, intent, template +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from homeassistant.util import ulid -from .const import ( - CONF_CHAT_MODEL, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_TEMPERATURE, - CONF_TOP_K, - CONF_TOP_P, - DEFAULT_CHAT_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_PROMPT, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_K, - DEFAULT_TOP_P, - DOMAIN, -) +from .const import CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, RECOMMENDED_CHAT_MODEL -_LOGGER = logging.getLogger(__name__) SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +PLATFORMS = (Platform.CONVERSATION,) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -82,19 +66,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } ) - model_name = "gemini-pro-vision" if image_filenames else "gemini-pro" - model = genai.GenerativeModel(model_name=model_name) + model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL) try: response = await model.generate_content_async(prompt_parts) except ( - ClientError, + GoogleAPICallError, ValueError, genai_types.BlockedPromptException, genai_types.StopCandidateException, ) as err: raise HomeAssistantError(f"Error generating content: {err}") from err + if not response.parts: + raise HomeAssistantError("Error generating content") + return {"text": response.text} hass.services.async_register( @@ -119,120 +105,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: genai.configure(api_key=entry.data[CONF_API_KEY]) try: - await hass.async_add_executor_job( - partial( - genai.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - ) + client = generativelanguage_v1beta.ModelServiceAsyncClient( + client_options=ClientOptions(api_key=entry.data[CONF_API_KEY]) ) - except ClientError as err: - if err.reason == "API_KEY_INVALID": - _LOGGER.error("Invalid API key: %s", err) - return False - raise ConfigEntryNotReady(err) from err + await client.get_model( + name=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), timeout=5.0 + ) + except (GoogleAPICallError, ValueError) as err: + if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID": + raise ConfigEntryAuthFailed(err) from err + if isinstance(err, DeadlineExceeded): + raise ConfigEntryNotReady(err) from err + raise ConfigEntryError(err) from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - conversation.async_set_agent(hass, entry, GoogleGenerativeAIAgent(hass, entry)) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload GoogleGenerativeAI.""" - genai.configure(api_key=None) - conversation.async_unset_agent(hass, entry) + if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + return False + return True - - -class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): - """Google Generative AI conversation agent.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the agent.""" - self.hass = hass - self.entry = entry - self.history: dict[str, list[genai_types.ContentType]] = {} - - @property - def supported_languages(self) -> list[str] | Literal["*"]: - """Return a list of supported languages.""" - return MATCH_ALL - - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = genai.GenerativeModel( - model_name=self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL), - generation_config={ - "temperature": self.entry.options.get( - CONF_TEMPERATURE, DEFAULT_TEMPERATURE - ), - "top_p": self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P), - "top_k": self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K), - "max_output_tokens": self.entry.options.get( - CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS - ), - }, - ) - _LOGGER.debug("Model: %s", model) - - if user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - messages = self.history[conversation_id] - else: - conversation_id = ulid.ulid_now() - messages = [{}, {}] - - try: - prompt = self._async_generate_prompt(raw_prompt) - except TemplateError as err: - _LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - messages[0] = {"role": "user", "parts": prompt} - messages[1] = {"role": "model", "parts": "Ok"} - - _LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) - - chat = model.start_chat(history=messages) - try: - chat_response = await chat.send_message_async(user_input.text) - except ( - ClientError, - ValueError, - genai_types.BlockedPromptException, - genai_types.StopCandidateException, - ) as err: - _LOGGER.error("Error sending message: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to Google Generative AI: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - _LOGGER.debug("Response: %s", chat_response.parts) - self.history[conversation_id] = chat.history - - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(chat_response.text) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - def _async_generate_prompt(self, raw_prompt: str) -> str: - """Generate a prompt for the user.""" - return template.Template(raw_prompt, self.hass).async_render( - { - "ha_name": self.hass.config.location_name, - }, - parse_result=False, - ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index dde82db91cc..543deb926a0 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -2,13 +2,15 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial import logging -import types from types import MappingProxyType from typing import Any -from google.api_core.exceptions import ClientError +from google.ai import generativelanguage_v1beta +from google.api_core.client_options import ClientOptions +from google.api_core.exceptions import ClientError, GoogleAPICallError import google.generativeai as genai import voluptuous as vol @@ -18,48 +20,53 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, TemplateSelector, ) from .const import ( CONF_CHAT_MODEL, + CONF_DANGEROUS_BLOCK_THRESHOLD, + CONF_HARASSMENT_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_RECOMMENDED, + CONF_SEXUAL_BLOCK_THRESHOLD, CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, - DEFAULT_CHAT_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_PROMPT, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_K, - DEFAULT_TOP_P, DOMAIN, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, ) _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +STEP_API_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): str, } ) -DEFAULT_OPTIONS = types.MappingProxyType( - { - CONF_PROMPT: DEFAULT_PROMPT, - CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, - CONF_TEMPERATURE: DEFAULT_TEMPERATURE, - CONF_TOP_P: DEFAULT_TOP_P, - CONF_TOP_K: DEFAULT_TOP_K, - CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, - } -) +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: @@ -67,8 +74,10 @@ 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. """ - genai.configure(api_key=data[CONF_API_KEY]) - await hass.async_add_executor_job(partial(genai.list_models)) + client = generativelanguage_v1beta.ModelServiceAsyncClient( + client_options=ClientOptions(api_key=data[CONF_API_KEY]) + ) + await client.list_models(timeout=5.0) class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): @@ -76,34 +85,74 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize a new GoogleGenerativeAIConfigFlow.""" + self.reauth_entry: ConfigEntry | None = None + + async def async_step_api( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + except GoogleAPICallError as err: + if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.reauth_entry: + return self.async_update_reload_and_abort( + self.reauth_entry, + data=user_input, + ) + return self.async_create_entry( + title="Google Generative AI", + data=user_input, + options=RECOMMENDED_OPTIONS, + ) + return self.async_show_form( + step_id="api", + data_schema=STEP_API_DATA_SCHEMA, + description_placeholders={ + "api_key_url": "https://aistudio.google.com/app/apikey" + }, + errors=errors, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return await self.async_step_api() - errors = {} - - try: - await validate_input(self.hass, user_input) - except ClientError as err: - if err.reason == "API_KEY_INVALID": - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry( - title="Google Generative AI Conversation", data=user_input - ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is not None: + return await self.async_step_api() + assert self.reauth_entry return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="reauth_confirm", + description_placeholders={ + CONF_NAME: self.reauth_entry.title, + CONF_API_KEY: self.reauth_entry.data.get(CONF_API_KEY, ""), + }, ) @staticmethod @@ -120,59 +169,173 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry + self.last_rendered_recommended = config_entry.options.get( + CONF_RECOMMENDED, False + ) async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + if user_input is not None: - return self.async_create_entry( - title="Google Generative AI Conversation", data=user_input - ) - schema = google_generative_ai_config_option_schema(self.config_entry.options) + if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if user_input[CONF_LLM_HASS_API] == "none": + user_input.pop(CONF_LLM_HASS_API) + return self.async_create_entry(title="", data=user_input) + + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } + + schema = await google_generative_ai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", data_schema=vol.Schema(schema), ) -def google_generative_ai_config_option_schema( - options: MappingProxyType[str, Any], +async def google_generative_ai_config_option_schema( + hass: HomeAssistant, + options: dict[str, Any] | MappingProxyType[str, Any], ) -> dict: """Return a schema for Google Generative AI completion options.""" - if not options: - options = DEFAULT_OPTIONS - return { + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label="No control", + value="none", + ) + ] + hass_apis.extend( + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ) + + schema = { vol.Optional( CONF_PROMPT, - description={"suggested_value": options[CONF_PROMPT]}, - default=DEFAULT_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, ): TemplateSelector(), vol.Optional( - CONF_CHAT_MODEL, - description={ - "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - }, - default=DEFAULT_CHAT_MODEL, - ): str, - vol.Optional( - CONF_TEMPERATURE, - description={"suggested_value": options[CONF_TEMPERATURE]}, - default=DEFAULT_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), - vol.Optional( - CONF_TOP_P, - description={"suggested_value": options[CONF_TOP_P]}, - default=DEFAULT_TOP_P, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), - vol.Optional( - CONF_TOP_K, - description={"suggested_value": options[CONF_TOP_K]}, - default=DEFAULT_TOP_K, - ): int, - vol.Optional( - CONF_MAX_TOKENS, - description={"suggested_value": options[CONF_MAX_TOKENS]}, - default=DEFAULT_MAX_TOKENS, - ): int, + CONF_LLM_HASS_API, + description={"suggested_value": options.get(CONF_LLM_HASS_API)}, + default="none", + ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, } + + if options.get(CONF_RECOMMENDED): + return schema + + api_models = await hass.async_add_executor_job(partial(genai.list_models)) + + models = [ + SelectOptionDict( + label=api_model.display_name, + value=api_model.name, + ) + for api_model in sorted(api_models, key=lambda x: x.display_name) + if ( + api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro + and "vision" not in api_model.name + and "generateContent" in api_model.supported_generation_methods + ) + ] + + harm_block_thresholds: list[SelectOptionDict] = [ + SelectOptionDict( + label="Block none", + value="BLOCK_NONE", + ), + SelectOptionDict( + label="Block few", + value="BLOCK_ONLY_HIGH", + ), + SelectOptionDict( + label="Block some", + value="BLOCK_MEDIUM_AND_ABOVE", + ), + SelectOptionDict( + label="Block most", + value="BLOCK_LOW_AND_ABOVE", + ), + ] + harm_block_thresholds_selector = SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, options=harm_block_thresholds + ) + ) + + schema.update( + { + vol.Optional( + CONF_CHAT_MODEL, + description={"suggested_value": options.get(CONF_CHAT_MODEL)}, + default=RECOMMENDED_CHAT_MODEL, + ): SelectSelector( + SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models) + ), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options.get(CONF_TEMPERATURE)}, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options.get(CONF_TOP_P)}, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TOP_K, + description={"suggested_value": options.get(CONF_TOP_K)}, + default=RECOMMENDED_TOP_K, + ): int, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_HARASSMENT_BLOCK_THRESHOLD, + description={ + "suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD) + }, + default=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ): harm_block_thresholds_selector, + vol.Optional( + CONF_HATE_BLOCK_THRESHOLD, + description={"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)}, + default=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ): harm_block_thresholds_selector, + vol.Optional( + CONF_SEXUAL_BLOCK_THRESHOLD, + description={ + "suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD) + }, + default=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ): harm_block_thresholds_selector, + vol.Optional( + CONF_DANGEROUS_BLOCK_THRESHOLD, + description={ + "suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD) + }, + default=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ): harm_block_thresholds_selector, + } + ) + return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 2798b85f308..bd60e8d94c1 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -1,35 +1,24 @@ """Constants for the Google Generative AI Conversation integration.""" +import logging + DOMAIN = "google_generative_ai_conversation" +LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" -DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. -An overview of the areas and the devices in this smart home: -{%- for area in areas() %} - {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area) -%} - {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} - {%- if not area_info.printed %} - -{{ area_name(area) }}: - {%- set area_info.printed = true %} - {%- endif %} -- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} - {%- endif %} - {%- endfor %} -{%- endfor %} - -Answer the user's questions about the world truthfully. - -If the user wants to control a device, reject the request and suggest using the Home Assistant app. -""" +CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" -DEFAULT_CHAT_MODEL = "models/gemini-pro" +RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest" CONF_TEMPERATURE = "temperature" -DEFAULT_TEMPERATURE = 0.9 +RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" -DEFAULT_TOP_P = 1.0 +RECOMMENDED_TOP_P = 0.95 CONF_TOP_K = "top_k" -DEFAULT_TOP_K = 1 +RECOMMENDED_TOP_K = 64 CONF_MAX_TOKENS = "max_tokens" -DEFAULT_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 150 +CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" +CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" +CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" +CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" +RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py new file mode 100644 index 00000000000..b9f0006dbff --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -0,0 +1,375 @@ +"""Conversation support for the Google Generative AI Conversation integration.""" + +from __future__ import annotations + +import codecs +from typing import Any, Literal + +from google.api_core.exceptions import GoogleAPICallError +import google.generativeai as genai +from google.generativeai import protos +import google.generativeai.types as genai_types +from google.protobuf.json_format import MessageToDict +import voluptuous as vol +from voluptuous_openapi import convert + +from homeassistant.components import assist_pipeline, conversation +from homeassistant.components.conversation import trace +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import ulid + +from .const import ( + CONF_CHAT_MODEL, + CONF_DANGEROUS_BLOCK_THRESHOLD, + CONF_HARASSMENT_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD, + CONF_MAX_TOKENS, + CONF_PROMPT, + CONF_SEXUAL_BLOCK_THRESHOLD, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, +) + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up conversation entities.""" + agent = GoogleGenerativeAIConversationEntity(config_entry) + async_add_entities([agent]) + + +SUPPORTED_SCHEMA_KEYS = { + "type", + "format", + "description", + "nullable", + "enum", + "items", + "properties", + "required", +} + + +def _format_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Format the schema to protobuf.""" + result = {} + for key, val in schema.items(): + if key not in SUPPORTED_SCHEMA_KEYS: + continue + if key == "type": + key = "type_" + val = val.upper() + elif key == "format": + key = "format_" + elif key == "items": + val = _format_schema(val) + elif key == "properties": + val = {k: _format_schema(v) for k, v in val.items()} + result[key] = val + return result + + +def _format_tool(tool: llm.Tool) -> dict[str, Any]: + """Format tool specification.""" + + parameters = _format_schema(convert(tool.parameters)) + + return protos.Tool( + { + "function_declarations": [ + { + "name": tool.name, + "description": tool.description, + "parameters": parameters, + } + ] + } + ) + + +def _escape_decode(value: Any) -> Any: + """Recursively call codecs.escape_decode on all values.""" + if isinstance(value, str): + return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] + if isinstance(value, list): + return [_escape_decode(item) for item in value] + if isinstance(value, dict): + return {k: _escape_decode(v) for k, v in value.items()} + return value + + +class GoogleGenerativeAIConversationEntity( + conversation.ConversationEntity, conversation.AbstractConversationAgent +): + """Google Generative AI conversation agent.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.entry = entry + self.history: dict[str, list[genai_types.ContentType]] = {} + self._attr_unique_id = entry.entry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + assist_pipeline.async_migrate_engine( + self.hass, "conversation", self.entry.entry_id, self.entity_id + ) + conversation.async_set_agent(self.hass, self.entry, self) + + async def async_will_remove_from_hass(self) -> None: + """When entity will be removed from Home Assistant.""" + conversation.async_unset_agent(self.hass, self.entry) + await super().async_will_remove_from_hass() + + async def async_process( + self, user_input: conversation.ConversationInput + ) -> conversation.ConversationResult: + """Process a sentence.""" + result = conversation.ConversationResult( + response=intent.IntentResponse(language=user_input.language), + conversation_id=user_input.conversation_id + if user_input.conversation_id in self.history + else ulid.ulid_now(), + ) + assert result.conversation_id + + llm_context = llm.LLMContext( + platform=DOMAIN, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=conversation.DOMAIN, + device_id=user_input.device_id, + ) + llm_api: llm.APIInstance | None = None + tools: list[dict[str, Any]] | None = None + if self.entry.options.get(CONF_LLM_HASS_API): + try: + llm_api = await llm.async_get_api( + self.hass, + self.entry.options[CONF_LLM_HASS_API], + llm_context, + ) + except HomeAssistantError as err: + LOGGER.error("Error getting LLM API: %s", err) + result.response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Error preparing LLM API: {err}", + ) + return result + tools = [_format_tool(tool) for tool in llm_api.tools] + + try: + prompt = await self._async_render_prompt(user_input, llm_api, llm_context) + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + result.response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem with my template: {err}", + ) + return result + + model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Gemini 1.0 doesn't support system_instruction while 1.5 does. + # Assume future versions will support it (if not, the request fails with a + # clear message at which point we can fix). + supports_system_instruction = ( + "gemini-1.0" not in model_name and "gemini-pro" not in model_name + ) + + model = genai.GenerativeModel( + model_name=model_name, + generation_config={ + "temperature": self.entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "top_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), + "max_output_tokens": self.entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + }, + safety_settings={ + "HARASSMENT": self.entry.options.get( + CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + "HATE": self.entry.options.get( + CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + "SEXUAL": self.entry.options.get( + CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + "DANGEROUS": self.entry.options.get( + CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + }, + tools=tools or None, + system_instruction=prompt if supports_system_instruction else None, + ) + + messages = self.history.get(result.conversation_id, []) + if not supports_system_instruction: + if not messages: + messages = [{}, {"role": "model", "parts": "Ok"}] + messages[0] = {"role": "user", "parts": prompt} + + LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + { + # Make a copy to attach it to the trace event. + "messages": messages[:] + if supports_system_instruction + else messages[2:], + "prompt": prompt, + }, + ) + + chat = model.start_chat(history=messages) + chat_request = user_input.text + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + chat_response = await chat.send_message_async(chat_request) + except ( + GoogleAPICallError, + ValueError, + genai_types.BlockedPromptException, + genai_types.StopCandidateException, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + + if isinstance( + err, genai_types.StopCandidateException + ) and "finish_reason: SAFETY\n" in str(err): + error = "The message got blocked by your safety settings" + else: + error = ( + f"Sorry, I had a problem talking to Google Generative AI: {err}" + ) + + result.response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + error, + ) + return result + + LOGGER.debug("Response: %s", chat_response.parts) + if not chat_response.parts: + result.response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + "Sorry, I had a problem getting a response from Google Generative AI.", + ) + return result + self.history[result.conversation_id] = chat.history + function_calls = [ + part.function_call for part in chat_response.parts if part.function_call + ] + if not function_calls or not llm_api: + break + + tool_responses = [] + for function_call in function_calls: + tool_call = MessageToDict(function_call._pb) # noqa: SLF001 + tool_name = tool_call["name"] + tool_args = _escape_decode(tool_call["args"]) + LOGGER.debug("Tool call: %s(%s)", tool_name, tool_args) + tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + try: + function_response = await llm_api.async_call_tool(tool_input) + except (HomeAssistantError, vol.Invalid) as e: + function_response = {"error": type(e).__name__} + if str(e): + function_response["error_text"] = str(e) + + LOGGER.debug("Tool response: %s", function_response) + tool_responses.append( + protos.Part( + function_response=protos.FunctionResponse( + name=tool_name, response=function_response + ) + ) + ) + chat_request = protos.Content(parts=tool_responses) + + result.response.async_set_speech( + " ".join([part.text.strip() for part in chat_response.parts if part.text]) + ) + return result + + async def _async_render_prompt( + self, + user_input: conversation.ConversationInput, + llm_api: llm.APIInstance | None, + llm_context: llm.LLMContext, + ) -> str: + user_name: str | None = None + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name + + if llm_api: + api_prompt = llm_api.api_prompt + else: + api_prompt = llm.async_render_no_api_prompt(self.hass) + + return "\n".join( + ( + template.Template( + llm.BASE_PROMPT + + self.entry.options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ), + api_prompt, + ) + ) diff --git a/homeassistant/components/google_generative_ai_conversation/diagnostics.py b/homeassistant/components/google_generative_ai_conversation/diagnostics.py new file mode 100644 index 00000000000..13643da7e00 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Google Generative AI Conversation.""" + +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 +from homeassistant.core import HomeAssistant + +TO_REDACT = {CONF_API_KEY} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return async_redact_data( + { + "title": entry.title, + "data": entry.data, + "options": entry.options, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 5bafa9c43de..168fee105a0 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -1,11 +1,13 @@ { "domain": "google_generative_ai_conversation", - "name": "Google Generative AI Conversation", + "name": "Google Generative AI", + "after_dependencies": ["assist_pipeline", "intent"], "codeowners": ["@tronikos"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-generativeai==0.3.1"] + "quality_scale": "platinum", + "requirements": ["google-generativeai==0.6.0", "voluptuous-openapi==0.0.4"] } diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 306072f33a8..9fea4805d38 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -1,28 +1,45 @@ { "config": { "step": { - "user": { + "api": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" - } + }, + "description": "Get your API key from [here]({api_key_url})." + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Your current API key: {api_key} is no longer valid. Please enter a new valid 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": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { "step": { "init": { "data": { - "prompt": "Prompt Template", - "model": "[%key:common::generic::model%]", + "recommended": "Recommended model settings", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", "top_k": "Top K", - "max_tokens": "Maximum tokens to return in response" + "max_tokens": "Maximum tokens to return in response", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", + "hate_block_threshold": "Content that is rude, disrespectful, or profane", + "sexual_block_threshold": "Contains references to sexual acts or other lewd content", + "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template." } } } diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 1ac963b430a..7fae5f18da5 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -16,6 +16,8 @@ from .api import AsyncConfigEntryAuth from .const import DATA_AUTH, DATA_HASS_CONFIG, DOMAIN from .services import async_setup_services +type GoogleMailConfigEntry = ConfigEntry[AsyncConfigEntryAuth] + PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -28,13 +30,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool: """Set up Google Mail from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - auth = AsyncConfigEntryAuth(session) + auth = AsyncConfigEntryAuth(hass, session) await auth.check_and_refresh_token() - hass.data[DOMAIN][entry.entry_id] = auth + entry.runtime_data = auth hass.async_create_task( discovery.async_load_platform( @@ -55,10 +57,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) loaded_entries = [ entry for entry in hass.config_entries.async_entries(DOMAIN) @@ -68,4 +68,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index e824e4b3ddd..485d640a04d 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,5 +1,7 @@ """API for Google Mail bound to Home Assistant OAuth.""" +from functools import partial + from aiohttp.client_exceptions import ClientError, ClientResponseError from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials @@ -7,6 +9,7 @@ from googleapiclient.discovery import Resource, build from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -20,9 +23,11 @@ class AsyncConfigEntryAuth: def __init__( self, + hass: HomeAssistant, oauth2_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Google Mail Auth.""" + self._hass = hass self.oauth_session = oauth2_session @property @@ -58,4 +63,6 @@ class AsyncConfigEntryAuth: async def get_resource(self) -> Resource: """Get current resource.""" credentials = Credentials(await self.check_and_refresh_token()) - return build("gmail", "v1", credentials=credentials) + return await self._hass.async_add_executor_job( + partial(build, "gmail", "v1", credentials=credentials) + ) diff --git a/homeassistant/components/google_mail/config_flow.py b/homeassistant/components/google_mail/config_flow.py index 5b5c760628b..5c81f7d49f5 100644 --- a/homeassistant/components/google_mail/config_flow.py +++ b/homeassistant/components/google_mail/config_flow.py @@ -9,10 +9,11 @@ from typing import Any, cast from google.oauth2.credentials import Credentials from googleapiclient.discovery import build -from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import config_entry_oauth2_flow +from . import GoogleMailConfigEntry from .const import DEFAULT_ACCESS, DOMAIN @@ -23,7 +24,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN - reauth_entry: ConfigEntry | None = None + reauth_entry: GoogleMailConfigEntry | None = None @property def logger(self) -> logging.Logger: diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index 1de72632de1..c832104d719 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -11,11 +11,10 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import GoogleMailConfigEntry from .entity import GoogleMailEntity SCAN_INTERVAL = timedelta(minutes=15) @@ -28,12 +27,12 @@ SENSOR_TYPE = SensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: GoogleMailConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Google Mail sensor.""" - async_add_entities( - [GoogleMailSensor(hass.data[DOMAIN][entry.entry_id], SENSOR_TYPE)], True - ) + async_add_entities([GoogleMailSensor(entry.runtime_data, SENSOR_TYPE)], True) class GoogleMailSensor(GoogleMailEntity, SensorEntity): diff --git a/homeassistant/components/google_mail/services.py b/homeassistant/components/google_mail/services.py index e07e2be2101..2a81f7e6c51 100644 --- a/homeassistant/components/google_mail/services.py +++ b/homeassistant/components/google_mail/services.py @@ -3,16 +3,15 @@ from __future__ import annotations from datetime import datetime, timedelta +from typing import TYPE_CHECKING from googleapiclient.http import HttpRequest import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_config_entry_ids -from .api import AsyncConfigEntryAuth from .const import ( ATTR_ENABLED, ATTR_END, @@ -26,6 +25,9 @@ from .const import ( DOMAIN, ) +if TYPE_CHECKING: + from . import GoogleMailConfigEntry + SERVICE_SET_VACATION = "set_vacation" SERVICE_VACATION_SCHEMA = vol.All( @@ -47,7 +49,9 @@ SERVICE_VACATION_SCHEMA = vol.All( async def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Google Mail integration.""" - async def extract_gmail_config_entries(call: ServiceCall) -> list[ConfigEntry]: + async def extract_gmail_config_entries( + call: ServiceCall, + ) -> list[GoogleMailConfigEntry]: return [ entry for entry_id in await async_extract_config_entry_ids(hass, call) @@ -57,10 +61,11 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def gmail_service(call: ServiceCall) -> None: """Call Google Mail service.""" - auth: AsyncConfigEntryAuth for entry in await extract_gmail_config_entries(call): - if not (auth := hass.data[DOMAIN].get(entry.entry_id)): - raise ValueError(f"Config entry not loaded: {entry.entry_id}") + try: + auth = entry.runtime_data + except AttributeError as ex: + raise ValueError(f"Config entry not loaded: {entry.entry_id}") from ex service = await auth.get_resource() _settings = { diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index f346f913e0c..fc104cc5c22 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -29,6 +29,8 @@ from homeassistant.helpers.selector import ConfigEntrySelector from .const import DEFAULT_ACCESS, DOMAIN +type GoogleSheetsConfigEntry = ConfigEntry[OAuth2Session] + DATA = "data" DATA_CONFIG_ENTRY = "config_entry" WORKSHEET = "worksheet" @@ -44,7 +46,9 @@ SHEET_SERVICE_SCHEMA = vol.All( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: GoogleSheetsConfigEntry +) -> bool: """Set up Google Sheets from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) @@ -61,21 +65,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not async_entry_has_scopes(hass, entry): raise ConfigEntryAuthFailed("Required scopes are not present, reauth required") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = session + entry.runtime_data = session await async_setup_service(hass) return True -def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: +def async_entry_has_scopes(hass: HomeAssistant, entry: GoogleSheetsConfigEntry) -> bool: """Verify that the config entry desired scope is present in the oauth token.""" return DEFAULT_ACCESS in entry.data.get(CONF_TOKEN, {}).get("scope", "").split(" ") -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: GoogleSheetsConfigEntry +) -> bool: """Unload a config entry.""" - hass.data[DOMAIN].pop(entry.entry_id) loaded_entries = [ entry for entry in hass.config_entries.async_entries(DOMAIN) @@ -91,9 +96,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_service(hass: HomeAssistant) -> None: """Add the services for Google Sheets.""" - def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None: + def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None: """Run append in the executor.""" - service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call] try: sheet = service.open_by_key(entry.unique_id) except RefreshError: @@ -115,14 +120,12 @@ async def async_setup_service(hass: HomeAssistant) -> None: async def append_to_sheet(call: ServiceCall) -> None: """Append new line of data to a Google Sheets document.""" - entry: ConfigEntry | None = hass.config_entries.async_get_entry( + entry: GoogleSheetsConfigEntry | None = hass.config_entries.async_get_entry( call.data[DATA_CONFIG_ENTRY] ) - if not entry: + if not entry or not hasattr(entry, "runtime_data"): raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}") - if not (session := hass.data[DOMAIN].get(entry.entry_id)): - raise ValueError(f"Config entry not loaded: {call.data[DATA_CONFIG_ENTRY]}") - await session.async_ensure_token_valid() + await entry.runtime_data.async_ensure_token_valid() await hass.async_add_executor_job(_append_to_sheet, call, entry) hass.services.async_register( diff --git a/homeassistant/components/google_sheets/config_flow.py b/homeassistant/components/google_sheets/config_flow.py index a0a99742249..4008d42f52d 100644 --- a/homeassistant/components/google_sheets/config_flow.py +++ b/homeassistant/components/google_sheets/config_flow.py @@ -9,10 +9,11 @@ from typing import Any from google.oauth2.credentials import Credentials from gspread import Client, GSpreadException -from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import config_entry_oauth2_flow +from . import GoogleSheetsConfigEntry from .const import DEFAULT_ACCESS, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -25,7 +26,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN - reauth_entry: ConfigEntry | None = None + reauth_entry: GoogleSheetsConfigEntry | None = None @property def logger(self) -> logging.Logger: @@ -61,7 +62,9 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow, or update existing entry.""" - service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + service = Client( + Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] + ) if self.reauth_entry: _LOGGER.debug("service.open_by_key") diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index ed70f2f6f44..22e5e80229a 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -1,5 +1,6 @@ """API for Google Tasks bound to Home Assistant OAuth.""" +from functools import partial import json import logging from typing import Any @@ -52,7 +53,9 @@ class AsyncConfigEntryAuth: async def _get_service(self) -> Resource: """Get current resource.""" token = await self.async_get_access_token() - return build("tasks", "v1", credentials=Credentials(token=token)) + return await self._hass.async_add_executor_job( + partial(build, "tasks", "v1", credentials=Credentials(token=token)) + ) async def list_task_lists(self) -> list[dict[str, Any]]: """Get all TaskList resources.""" diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py index a9ef5c7ff23..965c215ee4d 100644 --- a/homeassistant/components/google_tasks/config_flow.py +++ b/homeassistant/components/google_tasks/config_flow.py @@ -66,7 +66,7 @@ class OAuth2FlowHandler( reason="access_not_configured", description_placeholders={"message": error}, ) - except Exception: # pylint: disable=broad-except + except Exception: self.logger.exception("Unknown error occurred") return self.async_abort(reason="unknown") user_id = user_resource_info["id"] diff --git a/homeassistant/components/google_translate/const.py b/homeassistant/components/google_translate/const.py index 68d8208f26b..ed9709d2811 100644 --- a/homeassistant/components/google_translate/const.py +++ b/homeassistant/components/google_translate/const.py @@ -81,7 +81,7 @@ SUPPORT_LANGUAGES = [ "sv", "sw", "ta", - "te", + "te", # codespell:ignore te "th", "tl", "tr", diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 424ad56b9d4..0b493d7eeeb 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Any + import voluptuous as vol from homeassistant.config_entries import ( @@ -49,6 +51,20 @@ from .const import ( ) from .helpers import InvalidApiKeyException, UnknownException, validate_config_entry +RECONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_ORIGIN): cv.string, + } +) + +CONFIG_SCHEMA = RECONFIGURE_SCHEMA.extend( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + OPTIONS_SCHEMA = vol.Schema( { vol.Required(CONF_MODE): SelectSelector( @@ -164,6 +180,28 @@ class GoogleOptionsFlow(OptionsFlow): ) +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, str] | None: + """Validate the user input allows us to connect.""" + try: + await hass.async_add_executor_job( + validate_config_entry, + hass, + user_input[CONF_API_KEY], + user_input[CONF_ORIGIN], + user_input[CONF_DESTINATION], + ) + except InvalidApiKeyException: + return {"base": "invalid_auth"} + except TimeoutError: + return {"base": "timeout_connect"} + except UnknownException: + return {"base": "cannot_connect"} + + return None + + class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Maps Travel Time.""" @@ -179,40 +217,46 @@ class GoogleTravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] | None = None user_input = user_input or {} if user_input: - try: - await self.hass.async_add_executor_job( - validate_config_entry, - self.hass, - user_input[CONF_API_KEY], - user_input[CONF_ORIGIN], - user_input[CONF_DESTINATION], - ) + errors = await validate_input(self.hass, user_input) + if not errors: return self.async_create_entry( title=user_input.get(CONF_NAME, DEFAULT_NAME), data=user_input, options=default_options(self.hass), ) - except InvalidApiKeyException: - errors["base"] = "invalid_auth" - except TimeoutError: - errors["base"] = "timeout_connect" - except UnknownException: - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_DESTINATION): cv.string, - vol.Required(CONF_ORIGIN): cv.string, - } + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + if TYPE_CHECKING: + assert entry + + errors: dict[str, str] | None = None + user_input = user_input or {} + if user_input: + errors = await validate_input(self.hass, user_input) + if not errors: + return self.async_update_reload_and_abort( + entry, + data=user_input, + reason="reconfigure_successful", + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + RECONFIGURE_SCHEMA, entry.data.copy() ), errors=errors, ) diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 7e086640e2b..046e52095c0 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -67,7 +67,7 @@ ALL_LANGUAGES = [ "sr", "sv", "ta", - "te", + "te", # codespell:ignore te "th", "tl", "tr", diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 2c7840b23d8..765cfc9c4b6 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -10,6 +10,14 @@ "origin": "Origin", "destination": "Destination" } + }, + "reconfigure": { + "description": "[%key:component::google_travel_time::config::step::user::description%]", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "origin": "[%key:component::google_travel_time::config::step::user::data::origin%]", + "destination": "[%key:component::google_travel_time::config::step::user::data::destination%]" + } } }, "error": { @@ -18,7 +26,8 @@ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { diff --git a/homeassistant/components/govee_ble/__init__.py b/homeassistant/components/govee_ble/__init__.py index 8d074b6f997..a79f1e522b4 100644 --- a/homeassistant/components/govee_ble/__init__.py +++ b/homeassistant/components/govee_ble/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from govee_ble import GoveeBluetoothDeviceData +from govee_ble import GoveeBluetoothDeviceData, SensorUpdate from homeassistant.components.bluetooth import BluetoothScanningMode from homeassistant.components.bluetooth.passive_update_processor import ( @@ -14,37 +14,32 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +GoveeBLEConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator[SensorUpdate]] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: GoveeBLEConfigEntry) -> bool: """Set up Govee BLE device from a config entry.""" address = entry.unique_id assert address is not None data = GoveeBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload( - coordinator.async_start() - ) # only start after all platforms have had a chance to subscribe + # only start after all platforms have had a chance to subscribe + entry.async_on_unload(coordinator.async_start()) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GoveeBLEConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index 33f4761d02a..61d2a971810 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -5,12 +5,10 @@ from __future__ import annotations from govee_ble import DeviceClass, DeviceKey, SensorUpdate, Units from govee_ble.parser import ERROR -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -29,7 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import GoveeBLEConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( @@ -108,13 +106,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: GoveeBLEConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Govee BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( @@ -128,7 +124,7 @@ async def async_setup_entry( class GoveeBluetoothSensorEntity( PassiveBluetoothProcessorEntity[ - PassiveBluetoothDataProcessor[float | int | str | None] + PassiveBluetoothDataProcessor[float | int | str | None, SensorUpdate] ], SensorEntity, ): diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index d2537fb5c9b..088f9bae22b 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -3,6 +3,11 @@ from __future__ import annotations import asyncio +from contextlib import suppress +from errno import EADDRINUSE +import logging + +from govee_local_api.controller import LISTENING_PORT from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -14,14 +19,29 @@ from .coordinator import GoveeLocalApiCoordinator PLATFORMS: list[Platform] = [Platform.LIGHT] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Govee light local from a config entry.""" coordinator: GoveeLocalApiCoordinator = GoveeLocalApiCoordinator(hass=hass) - entry.async_on_unload(coordinator.cleanup) - await coordinator.start() + async def await_cleanup(): + cleanup_complete: asyncio.Event = coordinator.cleanup() + with suppress(TimeoutError): + await asyncio.wait_for(cleanup_complete.wait(), 1) + + entry.async_on_unload(await_cleanup) + + try: + await coordinator.start() + except OSError as ex: + if ex.errno != EADDRINUSE: + _LOGGER.error("Start failed, errno: %d", ex.errno) + return False + _LOGGER.error("Port %s already in use", LISTENING_PORT) + raise ConfigEntryNotReady from ex await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py index d31bfed0579..da70d44688b 100644 --- a/homeassistant/components/govee_light_local/config_flow.py +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from contextlib import suppress import logging from govee_local_api import GoveeController @@ -39,7 +40,11 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: update_enabled=False, ) - await controller.start() + try: + await controller.start() + except OSError as ex: + _LOGGER.error("Start failed, errno: %d", ex.errno) + return False try: async with asyncio.timeout(delay=DISCOVERY_TIMEOUT): @@ -49,7 +54,9 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: _LOGGER.debug("No devices found") devices_count = len(controller.devices) - controller.cleanup() + cleanup_complete: asyncio.Event = controller.cleanup() + with suppress(TimeoutError): + await asyncio.wait_for(cleanup_complete.wait(), 1) return devices_count > 0 diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index 79b572e89ae..64119f1871c 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -1,5 +1,6 @@ """Coordinator for Govee light local.""" +import asyncio from collections.abc import Callable import logging @@ -54,9 +55,9 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): """Set discovery callback for automatic Govee light discovery.""" self._controller.set_device_discovered_callback(callback) - def cleanup(self) -> None: + def cleanup(self) -> asyncio.Event: """Stop and cleanup the cooridinator.""" - self._controller.cleanup() + return self._controller.cleanup() async def turn_on(self, device: GoveeDevice) -> None: """Turn on the light.""" diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index df72a082190..93a19408182 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==1.4.5"] + "requirements": ["govee-local-api==1.5.0"] } diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py index 17dd140aef7..b0672e1f853 100644 --- a/homeassistant/components/graphite/__init__.py +++ b/homeassistant/components/graphite/__init__.py @@ -177,7 +177,7 @@ class GraphiteFeeder(threading.Thread): self._report_attributes( event.data["entity_id"], event.data["new_state"] ) - except Exception: # pylint: disable=broad-except + except Exception: # Catch this so we can avoid the thread dying and # make it visible. _LOGGER.exception("Failed to process STATE_CHANGED event") diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index 5b2e95b15e2..0a2e2852e34 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -9,7 +9,6 @@ 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, @@ -17,6 +16,7 @@ from .const import ( DISPATCHERS, DOMAIN, ) +from .coordinator import DiscoveryService _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 66b025d52b5..20d5d405591 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -42,7 +42,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .bridge import DeviceDataUpdateCoordinator from .const import ( COORDINATORS, DISPATCH_DEVICE_DISCOVERED, @@ -51,6 +50,7 @@ from .const import ( FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) +from .coordinator import DeviceDataUpdateCoordinator from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/gree/bridge.py b/homeassistant/components/gree/coordinator.py similarity index 97% rename from homeassistant/components/gree/bridge.py rename to homeassistant/components/gree/coordinator.py index 867f742e821..1bccf3bbc48 100644 --- a/homeassistant/components/gree/bridge.py +++ b/homeassistant/components/gree/coordinator.py @@ -24,7 +24,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class DeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module +class DeviceDataUpdateCoordinator(DataUpdateCoordinator): """Manages polling for state changes from the device.""" def __init__(self, hass: HomeAssistant, device: Device) -> None: diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index 4eb4a0cbaeb..7bdef0abd5d 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -3,8 +3,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .bridge import DeviceDataUpdateCoordinator from .const import DOMAIN +from .coordinator import DeviceDataUpdateCoordinator class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index d9ab6b16960..04464fe2567 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -115,7 +115,7 @@ async def async_setup_platform( on_new_monitor(monitor) -UnderlyingSensorType = ( +type UnderlyingSensorType = ( greeneye.monitor.Channel | greeneye.monitor.PulseCounter | greeneye.monitor.TemperatureSensor diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index a0f8d2b9a39..f89bf67861d 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -265,16 +265,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if ATTR_ADD_ENTITIES in service.data: delta = service.data[ATTR_ADD_ENTITIES] entity_ids = set(group.tracking) | set(delta) - await group.async_update_tracked_entity_ids(entity_ids) + group.async_update_tracked_entity_ids(entity_ids) if ATTR_REMOVE_ENTITIES in service.data: delta = service.data[ATTR_REMOVE_ENTITIES] entity_ids = set(group.tracking) - set(delta) - await group.async_update_tracked_entity_ids(entity_ids) + group.async_update_tracked_entity_ids(entity_ids) if ATTR_ENTITIES in service.data: entity_ids = service.data[ATTR_ENTITIES] - await group.async_update_tracked_entity_ids(entity_ids) + group.async_update_tracked_entity_ids(entity_ids) if ATTR_NAME in service.data: group.set_name(service.data[ATTR_NAME]) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index f3e2405d86a..b7341aff59a 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -35,14 +35,15 @@ from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch _STATISTIC_MEASURES = [ - "min", + "last", "max", "mean", "median", - "last", - "range", - "sum", + "min", "product", + "range", + "stdev", + "sum", ] diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 489226742ae..1b2db35531f 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import abstractmethod -import asyncio from collections.abc import Callable, Collection, Mapping import logging from typing import Any @@ -134,6 +133,7 @@ class Group(Entity): tracking: tuple[str, ...] trackable: tuple[str, ...] single_state_type_key: SingleStateType | None + _registry: GroupIntegrationRegistry def __init__( self, @@ -262,13 +262,8 @@ class Group(Entity): """Test if any member has an assumed state.""" return self._assumed_state - def update_tracked_entity_ids(self, entity_ids: Collection[str] | None) -> None: - """Update the member entity IDs.""" - asyncio.run_coroutine_threadsafe( - self.async_update_tracked_entity_ids(entity_ids), self.hass.loop - ).result() - - async def async_update_tracked_entity_ids( + @callback + def async_update_tracked_entity_ids( self, entity_ids: Collection[str] | None ) -> None: """Update the member entity IDs. @@ -291,7 +286,7 @@ class Group(Entity): self.single_state_type_key = None return - registry: GroupIntegrationRegistry = self.hass.data[REG_KEY] + registry = self._registry excluded_domains = registry.exclude_domains tracking: list[str] = [] @@ -320,7 +315,6 @@ class Group(Entity): registry.state_group_mapping[self.entity_id] = self.single_state_type_key else: self.single_state_type_key = None - self.async_on_remove(self._async_deregister) self.trackable = tuple(trackable) self.tracking = tuple(tracking) @@ -328,7 +322,7 @@ class Group(Entity): @callback def _async_deregister(self) -> None: """Deregister group entity from the registry.""" - registry: GroupIntegrationRegistry = self.hass.data[REG_KEY] + registry = self._registry if self.entity_id in registry.state_group_mapping: registry.state_group_mapping.pop(self.entity_id) @@ -370,8 +364,10 @@ class Group(Entity): async def async_added_to_hass(self) -> None: """Handle addition to Home Assistant.""" + self._registry = self.hass.data[REG_KEY] self._set_tracked(self._entity_ids) self.async_on_remove(start.async_at_start(self.hass, self._async_start)) + self.async_on_remove(self._async_deregister) async def async_will_remove_from_hass(self) -> None: """Handle removal from Home Assistant.""" @@ -412,7 +408,7 @@ class Group(Entity): entity_id = new_state.entity_id domain = new_state.domain state = new_state.state - registry: GroupIntegrationRegistry = self.hass.data[REG_KEY] + registry = self._registry self._assumed[entity_id] = bool(new_state.attributes.get(ATTR_ASSUMED_STATE)) if domain not in registry.on_states_by_domain: diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index b0cf36bd6b1..4da5829634b 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -25,6 +25,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKING, @@ -175,12 +177,16 @@ class LockGroup(GroupEntity, LockEntity): # Set as unknown if any member is unknown or unavailable self._attr_is_jammed = None self._attr_is_locking = None + self._attr_is_opening = None + self._attr_is_open = None self._attr_is_unlocking = None self._attr_is_locked = None else: # Set attributes based on member states and let the lock entity sort out the correct state self._attr_is_jammed = STATE_JAMMED in states self._attr_is_locking = STATE_LOCKING in states + self._attr_is_opening = STATE_OPENING in states + self._attr_is_open = STATE_OPEN in states self._attr_is_unlocking = STATE_UNLOCKING in states self._attr_is_locked = all(state == STATE_LOCKED for state in states) diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index 4ce89a4c725..aba1b299ced 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -8,7 +8,41 @@ from __future__ import annotations from dataclasses import dataclass from typing import Protocol -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.climate import HVACMode +from homeassistant.components.vacuum import STATE_CLEANING, STATE_ERROR, STATE_RETURNING +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, +) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_TRIGGERED, + STATE_CLOSED, + STATE_HOME, + STATE_IDLE, + STATE_LOCKED, + STATE_LOCKING, + STATE_NOT_HOME, + STATE_OFF, + STATE_OK, + STATE_ON, + STATE_OPEN, + STATE_OPENING, + STATE_PAUSED, + STATE_PLAYING, + STATE_PROBLEM, + STATE_UNLOCKED, + STATE_UNLOCKING, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -16,6 +50,92 @@ from homeassistant.helpers.integration_platform import ( from .const import DOMAIN, REG_KEY +# EXCLUDED_DOMAINS and ON_OFF_STATES are considered immutable +# in respect that new platforms should not be added. +# The only maintenance allowed here is +# if existing platforms add new ON or OFF states. +EXCLUDED_DOMAINS: set[Platform | str] = { + Platform.AIR_QUALITY, + Platform.SENSOR, + Platform.WEATHER, +} + +ON_OFF_STATES: dict[Platform | str, tuple[set[str], str, str]] = { + Platform.ALARM_CONTROL_PANEL: ( + { + STATE_ON, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_TRIGGERED, + }, + STATE_ON, + STATE_OFF, + ), + Platform.CLIMATE: ( + { + STATE_ON, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.FAN_ONLY, + }, + STATE_ON, + STATE_OFF, + ), + Platform.COVER: ({STATE_OPEN}, STATE_OPEN, STATE_CLOSED), + Platform.DEVICE_TRACKER: ({STATE_HOME}, STATE_HOME, STATE_NOT_HOME), + Platform.LOCK: ( + { + STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, + STATE_UNLOCKED, + STATE_UNLOCKING, + }, + STATE_UNLOCKED, + STATE_LOCKED, + ), + Platform.MEDIA_PLAYER: ( + { + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, + STATE_IDLE, + }, + STATE_ON, + STATE_OFF, + ), + "person": ({STATE_HOME}, STATE_HOME, STATE_NOT_HOME), + "plant": ({STATE_PROBLEM}, STATE_PROBLEM, STATE_OK), + Platform.VACUUM: ( + { + STATE_ON, + STATE_CLEANING, + STATE_RETURNING, + STATE_ERROR, + }, + STATE_ON, + STATE_OFF, + ), + Platform.WATER_HEATER: ( + { + STATE_ON, + STATE_ECO, + STATE_ELECTRIC, + STATE_PERFORMANCE, + STATE_HIGH_DEMAND, + STATE_HEAT_PUMP, + STATE_GAS, + }, + STATE_ON, + STATE_OFF, + ), +} + async def async_setup(hass: HomeAssistant) -> None: """Set up the Group integration registry of integration platforms.""" @@ -61,8 +181,10 @@ class GroupIntegrationRegistry: self.on_off_mapping: dict[str, str] = {STATE_ON: STATE_OFF} self.off_on_mapping: dict[str, str] = {STATE_OFF: STATE_ON} self.on_states_by_domain: dict[str, set[str]] = {} - self.exclude_domains: set[str] = set() + self.exclude_domains = EXCLUDED_DOMAINS.copy() self.state_group_mapping: dict[str, SingleStateType] = {} + for domain, on_off_states in ON_OFF_STATES.items(): + self.on_off_states(domain, *on_off_states) @callback def exclude_domain(self, domain: str) -> None: @@ -71,7 +193,11 @@ class GroupIntegrationRegistry: @callback def on_off_states( - self, domain: str, on_states: set[str], default_on_state: str, off_state: str + self, + domain: Platform | str, + on_states: set[str], + default_on_state: str, + off_state: str, ) -> None: """Register on and off states for the current domain. diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 5de668c7bb0..2e6c321be1e 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -36,7 +36,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import ( @@ -45,6 +52,7 @@ from homeassistant.helpers.entity import ( get_unit_of_measurement, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -66,6 +74,7 @@ ATTR_MEDIAN = "median" ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_RANGE = "range" +ATTR_STDEV = "stdev" ATTR_SUM = "sum" ATTR_PRODUCT = "product" SENSOR_TYPES = { @@ -75,6 +84,7 @@ SENSOR_TYPES = { ATTR_MEDIAN: "median", ATTR_LAST: "last", ATTR_RANGE: "range", + ATTR_STDEV: "stdev", ATTR_SUM: "sum", ATTR_PRODUCT: "product", } @@ -250,6 +260,16 @@ def calc_range( return {}, value +def calc_stdev( + sensor_values: list[tuple[str, float, State]], +) -> tuple[dict[str, str | None], float]: + """Calculate standard deviation value.""" + result = (sensor_value for _, sensor_value, _ in sensor_values) + + value: float = statistics.stdev(result) + return {}, value + + def calc_sum( sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: @@ -284,6 +304,7 @@ CALC_TYPES: dict[ "median": calc_median, "last": calc_last, "range": calc_range, + "stdev": calc_stdev, "sum": calc_sum, "product": calc_product, } @@ -316,6 +337,7 @@ class SensorGroup(GroupEntity, SensorEntity): self._native_unit_of_measurement = unit_of_measurement self._valid_units: set[str | None] = set() self._can_convert: bool = False + self.calculate_attributes_later: CALLBACK_TYPE | None = None self._attr_name = name if name == DEFAULT_NAME: self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() @@ -332,13 +354,32 @@ class SensorGroup(GroupEntity, SensorEntity): async def async_added_to_hass(self) -> None: """When added to hass.""" + for entity_id in self._entity_ids: + if self.hass.states.get(entity_id) is None: + self.calculate_attributes_later = async_track_state_change_event( + self.hass, self._entity_ids, self.calculate_state_attributes + ) + break + if not self.calculate_attributes_later: + await self.calculate_state_attributes() + await super().async_added_to_hass() + + async def calculate_state_attributes( + self, event: Event[EventStateChangedData] | None = None + ) -> None: + """Calculate state attributes.""" + for entity_id in self._entity_ids: + if self.hass.states.get(entity_id) is None: + return + if self.calculate_attributes_later: + self.calculate_attributes_later() + self.calculate_attributes_later = None self._attr_state_class = self._calculate_state_class(self._state_class) self._attr_device_class = self._calculate_device_class(self._device_class) self._attr_native_unit_of_measurement = self._calculate_unit_of_measurement( self._native_unit_of_measurement ) self._valid_units = self._get_valid_units() - await super().async_added_to_hass() @callback def async_update_group_state(self) -> None: diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index f9039fb896e..bff1f1e22ec 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -189,14 +189,15 @@ "selector": { "type": { "options": { - "min": "Minimum", + "last": "Most recently updated", "max": "Maximum", "mean": "Arithmetic mean", "median": "Median", - "last": "Most recently updated", + "min": "Minimum", + "product": "Product", "range": "Statistical range", - "sum": "Sum", - "product": "Product" + "stdev": "Standard deviation", + "sum": "Sum" } } }, diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index c41d3ac486f..9c680b5d4f8 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -239,7 +239,7 @@ class GrowattData: date_now = dt_util.now().date() last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt_util.DEFAULT_TIME_ZONE + date_now, last_updated_time, dt_util.get_default_time_zone() ) # Dashboard data is largely inaccurate for mix system but it is the only diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py index 819fda8bdc7..849cec8063c 100644 --- a/homeassistant/components/guardian/coordinator.py +++ b/homeassistant/components/guardian/coordinator.py @@ -34,7 +34,7 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): entry: ConfigEntry, client: Client, api_name: str, - api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], + api_coro: Callable[[], Coroutine[Any, Any, dict[str, Any]]], api_lock: asyncio.Lock, valve_controller_uid: str, ) -> None: diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 6d407f9c7cc..4b9a2835474 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from datetime import timedelta from functools import wraps -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate from aioguardian.errors import GuardianError @@ -20,14 +20,10 @@ from .const import LOGGER if TYPE_CHECKING: from . import GuardianEntity - _GuardianEntityT = TypeVar("_GuardianEntityT", bound=GuardianEntity) - DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" -_P = ParamSpec("_P") - @dataclass class EntityDomainReplacementStrategy: @@ -64,7 +60,7 @@ def async_finish_entity_domain_replacements( @callback -def convert_exceptions_to_homeassistant_error( +def convert_exceptions_to_homeassistant_error[_GuardianEntityT: GuardianEntity, **_P]( func: Callable[Concatenate[_GuardianEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_GuardianEntityT, _P], Coroutine[Any, Any, None]]: """Decorate to handle exceptions from the Guardian API.""" diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index f5997b4a963..e8c0af8f97f 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -37,7 +37,7 @@ from .coordinator import HabiticaDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] +type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] SENSORS_TYPES = ["name", "hp", "maxHealth", "mp", "maxMP", "exp", "toNextLevel", "lvl"] @@ -151,12 +151,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HabiticaConfigEntry) -> username = entry.data[CONF_API_USER] password = entry.data[CONF_API_KEY] - api = HAHabitipyAsync( + api = await hass.async_add_executor_job( + HAHabitipyAsync, { "url": url, "login": username, "password": password, - } + }, ) try: user = await api.user.get(userFields="profile") diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 9a8852b731d..5dd9fb2aa22 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -33,12 +33,13 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, """Validate the user input allows us to connect.""" websession = async_get_clientsession(hass) - api = HabitipyAsync( - conf={ + api = await hass.async_add_executor_job( + HabitipyAsync, + { "login": data[CONF_API_USER], "password": data[CONF_API_KEY], "url": data[CONF_URL] or DEFAULT_URL, - } + }, ) try: await api.user.get(session=websession) @@ -64,7 +65,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except InvalidAuth: errors = {"base": "invalid_credentials"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors = {"base": "unknown"} else: diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 385652f710a..d190cd41d4e 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -47,9 +47,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): try: user_response = await self.api.user.get(userFields=",".join(user_fields)) - tasks_response = [] - for task_type in ("todos", "dailys", "habits", "rewards"): - tasks_response.extend(await self.api.tasks.user.get(type=task_type)) + tasks_response = await self.api.tasks.user.get() except ClientResponseError as error: raise UpdateFailed(f"Error communicating with API: {error}") from error diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 1250e6d223f..16a4ef959a8 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habitipy", "plumbum"], - "requirements": ["habitipy==0.2.0"] + "requirements": ["habitipy==0.3.1"] } diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index b579e7659f4..629c54a3571 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -76,7 +76,7 @@ class HarmonyConfigFlow(ConfigFlow, domain=DOMAIN): validated = await validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 69ef2cb66c9..f474783b736 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -5,7 +5,7 @@ from homeassistant.const import Platform DOMAIN = "harmony" SERVICE_SYNC = "sync" SERVICE_CHANGE_CHANNEL = "change_channel" -PLATFORMS = [Platform.REMOTE, Platform.SELECT, Platform.SWITCH] +PLATFORMS = [Platform.REMOTE, Platform.SELECT] UNIQUE_ID = "unique_id" ACTIVITY_POWER_OFF = "PowerOff" HARMONY_OPTIONS_UPDATE = "harmony_options_update" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 8acc4307d1f..d37801376ec 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -3,7 +3,7 @@ "name": "Logitech Harmony Hub", "codeowners": ["@ehendrix23", "@bdraco", "@mkeesey", "@Aohzan"], "config_flow": true, - "dependencies": ["remote", "switch"], + "dependencies": ["remote"], "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index 444097395c9..e13573a9ea3 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -46,16 +46,6 @@ } } }, - "issues": { - "deprecated_switches": { - "title": "The Logitech Harmony switch platform is being removed", - "description": "Using the switch platform to change the current activity is now deprecated and will be removed in a future version of Home Assistant.\n\nPlease adjust any automations or scripts that use switch entities to instead use the select entity." - }, - "deprecated_switches_entity": { - "title": "Deprecated Harmony entity detected in {info}", - "description": "Your Harmony entity `{entity}` is being used in `{info}`. A select entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." - } - }, "services": { "sync": { "name": "Sync", diff --git a/homeassistant/components/harmony/subscriber.py b/homeassistant/components/harmony/subscriber.py index e923df82843..ec42c47f9ff 100644 --- a/homeassistant/components/harmony/subscriber.py +++ b/homeassistant/components/harmony/subscriber.py @@ -10,8 +10,8 @@ from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback _LOGGER = logging.getLogger(__name__) -NoParamCallback = HassJob[[], Any] | None -ActivityCallback = HassJob[[tuple], Any] | None +type NoParamCallback = HassJob[[], Any] | None +type ActivityCallback = HassJob[[tuple], Any] | None class HarmonyCallback(NamedTuple): diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py deleted file mode 100644 index 0cb07e5cb1e..00000000000 --- a/homeassistant/components/harmony/switch.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Support for Harmony Hub activities.""" - -import logging -from typing import Any, cast - -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 -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue - -from .const import DOMAIN, HARMONY_DATA -from .data import HarmonyData -from .entity import HarmonyEntity -from .subscriber import HarmonyCallback - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up harmony activity switches.""" - data: HarmonyData = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] - - async_add_entities( - (HarmonyActivitySwitch(activity, data) for activity in data.activities), True - ) - - -class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): - """Switch representation of a Harmony activity.""" - - def __init__(self, activity: dict, data: HarmonyData) -> None: - """Initialize HarmonyActivitySwitch class.""" - super().__init__(data=data) - self._activity_name = self._attr_name = activity["label"] - self._activity_id = activity["id"] - self._attr_entity_registry_enabled_default = False - self._attr_unique_id = f"activity_{self._activity_id}" - self._attr_device_info = self._data.device_info(DOMAIN) - - @property - def is_on(self) -> bool: - """Return if the current activity is the one for this switch.""" - _, activity_name = self._data.current_activity - return activity_name == self._activity_name - - async def async_turn_on(self, **kwargs: Any) -> None: - """Start this activity.""" - async_create_issue( - self.hass, - DOMAIN, - "deprecated_switches", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches", - ) - await self._data.async_start_activity(self._activity_name) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Stop this activity.""" - async_create_issue( - self.hass, - DOMAIN, - "deprecated_switches", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches", - ) - await self._data.async_power_off() - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - activity_update_job = HassJob(self._async_activity_update) - self.async_on_remove( - self._data.async_subscribe( - HarmonyCallback( - connected=HassJob(self.async_got_connected), - disconnected=HassJob(self.async_got_disconnected), - activity_starting=activity_update_job, - activity_started=activity_update_job, - config_updated=None, - ) - ) - ) - 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_switches_{self.entity_id}_{item}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches_entity", - translation_placeholders={ - "entity": f"{SWITCH_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", - "info": item, - }, - ) - - @callback - def _async_activity_update(self, activity_info: tuple) -> None: - self.async_write_ha_state() diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 972942caf52..647c2248d56 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -15,6 +15,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import panel_custom from homeassistant.components.homeassistant import async_set_stop_handler +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( ATTR_NAME, @@ -27,10 +28,9 @@ from homeassistant.core import ( HassJob, HomeAssistant, ServiceCall, - async_get_hass, + async_get_hass_or_none, callback, ) -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -74,13 +74,14 @@ from .const import ( DATA_HOST_INFO, DATA_INFO, DATA_KEY_SUPERVISOR_ISSUES, + DATA_NETWORK_INFO, DATA_OS_INFO, DATA_STORE, DATA_SUPERVISOR_INFO, DOMAIN, HASSIO_UPDATE_INTERVAL, ) -from .data import ( +from .coordinator import ( HassioDataUpdateCoordinator, get_addons_changelogs, # noqa: F401 get_addons_info, @@ -160,10 +161,7 @@ VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" value = VALID_ADDON_SLUG(value) - - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() + hass = async_get_hass_or_none() if hass and (addons := get_addons_info(hass)) is not None and value not in addons: raise vol.Invalid("Not a valid add-on slug") @@ -353,8 +351,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # This overrides the normal API call that would be forwarded development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) if development_repo is not None: - hass.http.register_static_path( - "/api/hassio/app", os.path.join(development_repo, "hassio/build"), False + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + "/api/hassio/app", + os.path.join(development_repo, "hassio/build"), + False, + ) + ] ) hass.http.register_view(HassIOView(host, websession)) @@ -433,6 +437,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DATA_CORE_INFO], hass.data[DATA_SUPERVISOR_INFO], hass.data[DATA_OS_INFO], + hass.data[DATA_NETWORK_INFO], ) = await asyncio.gather( create_eager_task(hassio.get_info()), create_eager_task(hassio.get_host_info()), @@ -440,6 +445,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: create_eager_task(hassio.get_core_info()), create_eager_task(hassio.get_supervisor_info()), create_eager_task(hassio.get_os_info()), + create_eager_task(hassio.get_network_info()), ) except HassioAPIError as err: diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index 674a828c3b8..b3c43f16be1 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from enum import Enum from functools import partial, wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -28,15 +28,13 @@ from .handler import ( async_update_addon, ) -_AddonManagerT = TypeVar("_AddonManagerT", bound="AddonManager") -_R = TypeVar("_R") -_P = ParamSpec("_P") - -_FuncType = Callable[Concatenate[_AddonManagerT, _P], Awaitable[_R]] -_ReturnFuncType = Callable[Concatenate[_AddonManagerT, _P], Coroutine[Any, Any, _R]] +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]] +type _ReturnFuncType[_T, **_P, _R] = Callable[ + Concatenate[_T, _P], Coroutine[Any, Any, _R] +] -def api_error( +def api_error[_AddonManagerT: AddonManager, **_P, _R]( error_message: str, ) -> Callable[ [_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R] @@ -185,13 +183,18 @@ class AddonManager: options = {"options": config} await async_set_addon_options(self._hass, self.addon_slug, options) + def _check_addon_available(self, addon_info: AddonInfo) -> None: + """Check if the managed add-on is available.""" + + if not addon_info.available: + raise AddonError(f"{self.addon_name} add-on is not available") + @api_error("Failed to install the {addon_name} add-on") async def async_install_addon(self) -> None: """Install the managed add-on.""" addon_info = await self.async_get_addon_info() - if not addon_info.available: - raise AddonError(f"{self.addon_name} add-on is not available anymore") + self._check_addon_available(addon_info) await async_install_addon(self._hass, self.addon_slug) @@ -205,8 +208,7 @@ class AddonManager: """Update the managed add-on if needed.""" addon_info = await self.async_get_addon_info() - if not addon_info.available: - raise AddonError(f"{self.addon_name} add-on is not available anymore") + self._check_addon_available(addon_info) if addon_info.state is AddonState.NOT_INSTALLED: raise AddonError(f"{self.addon_name} add-on is not installed") diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 0845a98f832..6e6c9006fca 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -70,6 +70,7 @@ DATA_HOST_INFO = "hassio_host_info" DATA_STORE = "hassio_store" DATA_INFO = "hassio_info" DATA_OS_INFO = "hassio_os_info" +DATA_NETWORK_INFO = "hassio_network_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" @@ -97,10 +98,14 @@ DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" +PLACEHOLDER_KEY_ADDON = "addon" +PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" +ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" +ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" CORE_CONTAINER = "homeassistant" SUPERVISOR_CONTAINER = "hassio_supervisor" diff --git a/homeassistant/components/hassio/data.py b/homeassistant/components/hassio/coordinator.py similarity index 97% rename from homeassistant/components/hassio/data.py rename to homeassistant/components/hassio/coordinator.py index 3d684d6cd7c..024128f4ef8 100644 --- a/homeassistant/components/hassio/data.py +++ b/homeassistant/components/hassio/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections import defaultdict import logging -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME @@ -42,6 +42,7 @@ from .const import ( DATA_KEY_OS, DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, + DATA_NETWORK_INFO, DATA_OS_INFO, DATA_STORE, DATA_SUPERVISOR_INFO, @@ -53,7 +54,9 @@ from .const import ( SupervisorEntityModel, ) from .handler import HassIO, HassioAPIError -from .issues import SupervisorIssues + +if TYPE_CHECKING: + from .issues import SupervisorIssues _LOGGER = logging.getLogger(__name__) @@ -98,6 +101,16 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None: return hass.data.get(DATA_SUPERVISOR_INFO) +@callback +@bind_hass +def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None: + """Return Host Network information. + + Async friendly. + """ + return hass.data.get(DATA_NETWORK_INFO) + + @callback @bind_hass def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: @@ -275,7 +288,7 @@ def async_remove_addons_from_dev_reg( dev_reg.async_remove_device(dev.id) -class HassioDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module +class HassioDataUpdateCoordinator(DataUpdateCoordinator): """Class to retrieve Hass.io status.""" def __init__( diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index ae8b8b3b740..0ef50cedc5a 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import ADDONS_COORDINATOR -from .data import HassioDataUpdateCoordinator +from .coordinator import HassioDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 11259c65d24..3e08a622fe4 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -21,7 +21,7 @@ from .const import ( KEY_TO_UPDATE_TYPES, SUPERVISOR_CONTAINER, ) -from .data import HassioDataUpdateCoordinator +from .coordinator import HassioDataUpdateCoordinator class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index ff34aa06cf3..305b9d4961b 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Coroutine from http import HTTPStatus import logging import os -from typing import Any, ParamSpec +from typing import Any import aiohttp from yarl import URL @@ -24,8 +24,6 @@ from homeassistant.loader import bind_hass from .const import ATTR_DISCOVERY, ATTR_MESSAGE, ATTR_RESULT, DOMAIN, X_HASS_SOURCE -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) @@ -33,7 +31,7 @@ class HassioAPIError(RuntimeError): """Return if a API trow a error.""" -def _api_bool( +def _api_bool[**_P]( funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]], ) -> Callable[_P, Coroutine[Any, Any, bool]]: """Return a boolean.""" @@ -49,7 +47,7 @@ def _api_bool( return _wrapper -def api_data( +def api_data[**_P]( funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]], ) -> Callable[_P, Coroutine[Any, Any, Any]]: """Return data of an api.""" @@ -384,6 +382,14 @@ class HassIO: """ return self.send_command("/supervisor/info", method="get") + @api_data + def get_network_info(self) -> Coroutine: + """Return data for the Host Network. + + This method returns a coroutine. + """ + return self.send_command("/network/info", method="get") + @api_data def get_addon_info(self, addon: str) -> Coroutine: """Return data for a Add-on. diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 826c7a27b98..8c1fb11973e 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -158,10 +158,8 @@ class HassIOView(HomeAssistantView): if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary if TYPE_CHECKING: - # pylint: disable-next=protected-access - assert isinstance(request._stored_content_type, str) - # pylint: disable-next=protected-access - headers[CONTENT_TYPE] = request._stored_content_type + assert isinstance(request._stored_content_type, str) # noqa: SLF001 + headers[CONTENT_TYPE] = request._stored_content_type # noqa: SLF001 try: client = await self._websession.request( diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 0bb28a3ceef..9c2152489d6 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -36,12 +36,17 @@ from .const import ( EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + PLACEHOLDER_KEY_ADDON, + PLACEHOLDER_KEY_ADDON_URL, PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, UPDATE_KEY_SUPERVISOR, SupervisorIssueContext, ) +from .coordinator import get_addons_info from .handler import HassIO, HassioAPIError ISSUE_KEY_UNHEALTHY = "unhealthy" @@ -93,6 +98,8 @@ ISSUE_KEYS_FOR_REPAIRS = { "issue_system_multiple_data_disks", "issue_system_reboot_required", ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, } _LOGGER = logging.getLogger(__name__) @@ -258,6 +265,19 @@ class SupervisorIssues: placeholders: dict[str, str] | None = None if issue.reference: placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference} + + if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING: + placeholders[PLACEHOLDER_KEY_ADDON_URL] = ( + f"/hassio/addon/{issue.reference}" + ) + addons = get_addons_info(self._hass) + if addons and issue.reference in addons: + placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][ + "name" + ] + else: + placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference + async_create_issue( self._hass, DOMAIN, diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 63ed3d5c8a3..082dbe38bee 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -14,7 +14,9 @@ from homeassistant.data_entry_flow import FlowResult from . import get_addons_info, get_issues_info from .const import ( + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_REFERENCE, SupervisorIssueContext, @@ -22,12 +24,23 @@ from .const import ( from .handler import async_apply_suggestion from .issues import Issue, Suggestion -SUGGESTION_CONFIRMATION_REQUIRED = {"system_adopt_data_disk", "system_execute_reboot"} +HELP_URLS = { + "help_url": "https://www.home-assistant.io/help/", + "community_url": "https://community.home-assistant.io/", +} + +SUGGESTION_CONFIRMATION_REQUIRED = { + "addon_execute_remove", + "system_adopt_data_disk", + "system_execute_reboot", +} + EXTRA_PLACEHOLDERS = { "issue_mount_mount_failed": { "storage_url": "/config/storage", - } + }, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS, } @@ -127,7 +140,6 @@ class SupervisorIssueRepairFlow(RepairsFlow): self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle a flow step for a suggestion.""" - # pylint: disable-next=protected-access return await self._async_step_apply_suggestion( suggestion, confirmed=user_input is not None ) @@ -169,6 +181,25 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): return placeholders +class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for detached addon issue fixing flows.""" + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + placeholders: dict[str, str] = super().description_placeholders or {} + if self.issue and self.issue.reference: + addons = get_addons_info(self.hass) + if addons and self.issue.reference in addons: + placeholders[PLACEHOLDER_KEY_ADDON] = addons[self.issue.reference][ + "name" + ] + else: + placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference + + return placeholders or None + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -179,5 +210,7 @@ async def async_create_fix_flow( issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: return DockerConfigIssueRepairFlow(issue_id) + if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: + return DetachedAddonIssueRepairFlow(issue_id) return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 6abf9ca6334..6b81b87e195 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -1,22 +1,39 @@ { "system_health": { "info": { - "agent_version": "Agent Version", + "agent_version": "Agent version", "board": "Board", - "disk_total": "Disk Total", - "disk_used": "Disk Used", - "docker_version": "Docker Version", + "disk_total": "Disk total", + "disk_used": "Disk used", + "docker_version": "Docker version", "healthy": "Healthy", - "host_os": "Host Operating System", - "installed_addons": "Installed Add-ons", + "host_os": "Host operating system", + "installed_addons": "Installed add-ons", "supervisor_api": "Supervisor API", - "supervisor_version": "Supervisor Version", + "supervisor_version": "Supervisor version", "supported": "Supported", - "update_channel": "Update Channel", + "update_channel": "Update channel", "version_api": "Version API" } }, "issues": { + "issue_addon_detached_addon_missing": { + "title": "Missing repository for an installed add-on", + "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." + }, + "issue_addon_detached_addon_removed": { + "title": "Installed add-on has been removed from repository", + "fix_flow": { + "step": { + "addon_execute_remove": { + "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nClicking submit will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + } + }, + "abort": { + "apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details." + } + } + }, "issue_mount_mount_failed": { "title": "Network storage device failed", "fix_flow": { diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index b77187718bb..bc8da2a2a92 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -8,7 +8,13 @@ from typing import Any from homeassistant.components import system_health from homeassistant.core import HomeAssistant, callback -from .data import get_host_info, get_info, get_os_info, get_supervisor_info +from .coordinator import ( + get_host_info, + get_info, + get_network_info, + get_os_info, + get_supervisor_info, +) SUPERVISOR_PING = "http://{ip_address}/supervisor/ping" OBSERVER_URL = "http://{ip_address}:4357" @@ -28,6 +34,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: info = get_info(hass) or {} host_info = get_host_info(hass) or {} supervisor_info = get_supervisor_info(hass) + network_info = get_network_info(hass) or {} healthy: bool | dict[str, str] if supervisor_info is not None and supervisor_info.get("healthy"): @@ -57,6 +64,10 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: "disk_used": f"{host_info.get('disk_used')} GB", "healthy": healthy, "supported": supported, + "host_connectivity": network_info.get("host_internet"), + "supervisor_connectivity": network_info.get("supervisor_internet"), + "ntp_synchronized": host_info.get("dt_synchronized"), + "virtualization": host_info.get("virtualization"), } if info.get("hassos") is not None: diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 564b764bc2e..820bcb2fb2b 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import reduce, wraps import logging from operator import ior -from typing import Any, ParamSpec +from typing import Any from pyheos import HeosError, const as heos_const @@ -41,8 +41,6 @@ from .const import ( SIGNAL_HEOS_UPDATED, ) -_P = ParamSpec("_P") - BASE_SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET @@ -90,11 +88,13 @@ async def async_setup_entry( async_add_entities(devices, True) -_FuncType = Callable[_P, Awaitable[Any]] -_ReturnFuncType = Callable[_P, Coroutine[Any, Any, None]] +type _FuncType[**_P] = Callable[_P, Awaitable[Any]] +type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]] -def log_command_error(command: str) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: +def log_command_error[**_P]( + command: str, +) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]: """Return decorator that logs command failure.""" def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]: diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 0134f4682a5..0b02ddb2a8e 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import abstractmethod import datetime -from typing import Any, TypeVar +from typing import Any import voluptuous as vol @@ -55,10 +55,8 @@ UNITS: dict[str, str] = { } ICON = "mdi:chart-line" -_T = TypeVar("_T", bound=dict[str, Any]) - -def exactly_two_period_keys(conf: _T) -> _T: +def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T: """Ensure exactly 2 of CONF_PERIOD_KEYS are provided.""" if sum(param in conf for param in CONF_PERIOD_KEYS) != 2: raise vol.Invalid( diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index fb2733223eb..4001215d90e 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from aiohttp.web_exceptions import HTTPException from apyhiveapi import Auth, Hive @@ -28,9 +28,6 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS -_HiveEntityT = TypeVar("_HiveEntityT", bound="HiveEntity") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( @@ -131,7 +128,7 @@ async def async_remove_config_entry_device( return True -def refresh_system( +def refresh_system[_HiveEntityT: HiveEntity, **_P]( 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/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index 78e8606a43c..06383784a3f 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -51,6 +51,7 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.TRIGGER ) + _attr_code_arm_required = False async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" diff --git a/homeassistant/components/hko/config_flow.py b/homeassistant/components/hko/config_flow.py index aeee7d4aff8..8548bb4767d 100644 --- a/homeassistant/components/hko/config_flow.py +++ b/homeassistant/components/hko/config_flow.py @@ -54,7 +54,7 @@ class HKOConfigFlow(ConfigFlow, domain=DOMAIN): except HKOError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" else: await self.async_set_unique_id( diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index 57503b340d9..f56f4f90831 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -2,31 +2,26 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta 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.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_point_in_utc_time 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 - +def _get_obj_holidays_and_language( + country: str, province: str | None, language: str +) -> tuple[HolidayBase, str]: + """Get the object for the requested country and year.""" obj_holidays = country_holidays( country, subdiv=province, @@ -57,6 +52,23 @@ async def async_setup_entry( ) language = default_language + return (obj_holidays, language) + + +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, language = await hass.async_add_executor_job( + _get_obj_holidays_and_language, country, province, language + ) + async_add_entities( [ HolidayCalendarEntity( @@ -77,6 +89,9 @@ class HolidayCalendarEntity(CalendarEntity): _attr_has_entity_name = True _attr_name = None + _attr_event: CalendarEvent | None = None + _attr_should_poll = False + unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -100,14 +115,36 @@ class HolidayCalendarEntity(CalendarEntity): ) self._obj_holidays = obj_holidays - @property - def event(self) -> CalendarEvent | None: + def get_next_interval(self, now: datetime) -> datetime: + """Compute next time an update should occur.""" + tomorrow = dt_util.as_local(now) + timedelta(days=1) + return dt_util.start_of_local_day(tomorrow) + + def _update_state_and_setup_listener(self) -> None: + """Update state and setup listener for next interval.""" + now = dt_util.utcnow() + self._attr_event = self.update_event(now) + self.unsub = async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval(now) + ) + + @callback + def point_in_time_listener(self, time_date: datetime) -> None: + """Get the latest data and update state.""" + self._update_state_and_setup_listener() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Set up first update.""" + self._update_state_and_setup_listener() + + def update_event(self, now: datetime) -> 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(): + if holiday_date >= now.date(): next_holiday = (holiday_date, holiday_name) break @@ -121,6 +158,11 @@ class HolidayCalendarEntity(CalendarEntity): location=self._location, ) + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return self._attr_event + async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 3494798b50b..cb67039f374 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.47", "babel==2.13.1"] + "requirements": ["holidays==0.51", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 5b0a9e3e9d8..b54637bb524 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -12,6 +12,8 @@ BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" +BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock" + BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 1239395af2b..8c7ef2eb11a 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_VALUE, BSH_ACTIVE_PROGRAM, + BSH_CHILD_LOCK_STATE, BSH_OPERATION_STATE, BSH_POWER_ON, BSH_POWER_STATE, @@ -39,6 +40,7 @@ async def async_setup_entry( entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("switch", []) entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts] entity_list += [HomeConnectPowerSwitch(device_dict[CONF_DEVICE])] + entity_list += [HomeConnectChildLockSwitch(device_dict[CONF_DEVICE])] entities += entity_list return entities @@ -153,3 +155,44 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): else: self._attr_is_on = None _LOGGER.debug("Updated, new state: %s", self._attr_is_on) + + +class HomeConnectChildLockSwitch(HomeConnectEntity, SwitchEntity): + """Child lock switch class for Home Connect.""" + + def __init__(self, device) -> None: + """Initialize the entity.""" + super().__init__(device, "ChildLock") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Switch child lock on.""" + _LOGGER.debug("Tried to switch child lock on device: %s", self.name) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, BSH_CHILD_LOCK_STATE, True + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to turn on child lock on device: %s", err) + self._attr_is_on = False + self.async_entity_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Switch child lock off.""" + _LOGGER.debug("Tried to switch off child lock on device: %s", self.name) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, BSH_CHILD_LOCK_STATE, False + ) + except HomeConnectError as err: + _LOGGER.error( + "Error while trying to turn off child lock on device: %s", err + ) + self._attr_is_on = True + self.async_entity_update() + + async def async_update(self) -> None: + """Update the switch's status.""" + self._attr_is_on = False + if self.device.appliance.status.get(BSH_CHILD_LOCK_STATE, {}).get(ATTR_VALUE): + self._attr_is_on = True + _LOGGER.debug("Updated child lock, new state: %s", self._attr_is_on) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 6d32f175a8a..cc948fcc663 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -32,6 +32,7 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) +from homeassistant.helpers.signal import KEY_HA_STOP from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType @@ -386,7 +387,7 @@ async def _async_stop(hass: ha.HomeAssistant, restart: bool) -> None: """Stop home assistant.""" exit_code = RESTART_EXIT_CODE if restart else 0 # Track trask in hass.data. No need to cleanup, we're stopping. - hass.data["homeassistant_stop"] = asyncio.create_task(hass.async_stop(exit_code)) + hass.data[KEY_HA_STOP] = asyncio.create_task(hass.async_stop(exit_code)) @ha.callback diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 4d6d9724ecb..68632223045 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -35,9 +35,8 @@ DEFAULT_EXPOSED_DOMAINS = { "fan", "humidifier", "light", - "lock", + "media_player", "scene", - "script", "switch", "todo", "vacuum", @@ -151,9 +150,8 @@ class ExposedEntities: """ entity_registry = er.async_get(self._hass) if not (registry_entry := entity_registry.async_get(entity_id)): - return self._async_set_legacy_assistant_option( - assistant, entity_id, key, value - ) + self._async_set_legacy_assistant_option(assistant, entity_id, key, value) + return assistant_options: ReadOnlyDict[str, Any] | dict[str, Any] if ( @@ -259,7 +257,7 @@ class ExposedEntities: if assistant in registry_entry.options: if "should_expose" in registry_entry.options[assistant]: should_expose = registry_entry.options[assistant]["should_expose"] - return should_expose # noqa: RET504 + return should_expose if self.async_get_expose_new_entities(assistant): should_expose = self._is_default_exposed(entity_id, registry_entry) @@ -286,7 +284,7 @@ class ExposedEntities: ) and assistant in exposed_entity.assistants: if "should_expose" in exposed_entity.assistants[assistant]: should_expose = exposed_entity.assistants[assistant]["should_expose"] - return should_expose # noqa: RET504 + return should_expose if self.async_get_expose_new_entities(assistant): should_expose = self._is_default_exposed(entity_id, None) @@ -419,7 +417,7 @@ def ws_expose_entity( None, ): connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" + msg["id"], websocket_api.ERR_NOT_ALLOWED, f"can't expose '{blocked}'" ) return diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 09b2f17c947..2acd772b94e 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -60,19 +60,19 @@ }, "system_health": { "info": { - "arch": "CPU Architecture", - "config_dir": "Configuration Directory", + "arch": "CPU architecture", + "config_dir": "Configuration directory", "dev": "Development", "docker": "Docker", "hassio": "Supervisor", - "installation_type": "Installation Type", - "os_name": "Operating System Family", - "os_version": "Operating System Version", - "python_version": "Python Version", + "installation_type": "Installation type", + "os_name": "Operating system family", + "os_version": "Operating system version", + "python_version": "Python version", "timezone": "Timezone", "user": "User", "version": "Version", - "virtualenv": "Virtual Environment" + "virtualenv": "Virtual environment" } }, "services": { diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index d29baf342ab..0a15585586e 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -143,15 +143,13 @@ async def async_attach_trigger( if event_context_items: # Fast path for simple items comparison # This is safe because we do not mutate the event context - # pylint: disable-next=protected-access - if not (event.context._as_dict.items() >= event_context_items): + if not (event.context._as_dict.items() >= event_context_items): # noqa: SLF001 return elif event_context_schema: try: # Slow path for schema validation # This is safe because we make a copy of the event context - # pylint: disable-next=protected-access - event_context_schema(dict(event.context._as_dict)) + event_context_schema(dict(event.context._as_dict)) # noqa: SLF001 except vol.Invalid: # If event doesn't match, skip event return diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 43cc3d0918e..bc2c95675ad 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, TypeVar +from typing import Any import voluptuous as vol @@ -41,10 +41,8 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -_T = TypeVar("_T", bound=dict[str, Any]) - -def validate_above_below(value: _T) -> _T: +def validate_above_below[_T: dict[str, Any]](value: _T) -> _T: """Validate that above and below can co-exist.""" above = value.get(CONF_ABOVE) below = value.get(CONF_BELOW) diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 6d035683f71..5441683b86f 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -119,7 +119,7 @@ async def async_attach_trigger( hour, minute, second, - tzinfo=dt_util.DEFAULT_TIME_ZONE, + tzinfo=dt_util.get_default_time_zone(), ) # Only set up listener if time is now or in the future. if trigger_dt >= dt_util.now(): diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index b33bfe5ed1e..4a268901ca2 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -2,15 +2,9 @@ from __future__ import annotations -import dataclasses -from datetime import timedelta import logging -import aiohttp -from awesomeversion import AwesomeVersion, AwesomeVersionStrategy - -from homeassistant.components.hassio import get_supervisor_info, is_hassio -from homeassistant.const import EVENT_COMPONENT_LOADED, __version__ +from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,15 +16,12 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import EventComponentLoaded -COMPONENT_LOADED_COOLDOWN = 30 -DOMAIN = "homeassistant_alerts" -UPDATE_INTERVAL = timedelta(hours=3) -_LOGGER = logging.getLogger(__name__) +from .const import COMPONENT_LOADED_COOLDOWN, DOMAIN, REQUEST_TIMEOUT +from .coordinator import AlertUpdateCoordinator -REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30) +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -114,98 +105,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_at_started(hass, initial_refresh) return True - - -@dataclasses.dataclass(slots=True, frozen=True) -class IntegrationAlert: - """Issue Registry Entry.""" - - alert_id: str - integration: str - filename: str - date_updated: str | None - - @property - def issue_id(self) -> str: - """Return the issue id.""" - return f"{self.filename}_{self.integration}" - - -class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]): # pylint: disable=hass-enforce-coordinator-module - """Data fetcher for HA Alerts.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the data updater.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=UPDATE_INTERVAL, - ) - self.ha_version = AwesomeVersion( - __version__, - ensure_strategy=AwesomeVersionStrategy.CALVER, - ) - self.supervisor = is_hassio(self.hass) - - async def _async_update_data(self) -> dict[str, IntegrationAlert]: - response = await async_get_clientsession(self.hass).get( - "https://alerts.home-assistant.io/alerts.json", - timeout=REQUEST_TIMEOUT, - ) - alerts = await response.json() - - result = {} - - for alert in alerts: - if "integrations" not in alert: - continue - - if "homeassistant" in alert: - if "affected_from_version" in alert["homeassistant"]: - affected_from_version = AwesomeVersion( - alert["homeassistant"]["affected_from_version"], - ) - if self.ha_version < affected_from_version: - continue - if "resolved_in_version" in alert["homeassistant"]: - resolved_in_version = AwesomeVersion( - alert["homeassistant"]["resolved_in_version"], - ) - if self.ha_version >= resolved_in_version: - continue - - if self.supervisor and "supervisor" in alert: - if (supervisor_info := get_supervisor_info(self.hass)) is None: - continue - - if "affected_from_version" in alert["supervisor"]: - affected_from_version = AwesomeVersion( - alert["supervisor"]["affected_from_version"], - ) - if supervisor_info["version"] < affected_from_version: - continue - if "resolved_in_version" in alert["supervisor"]: - resolved_in_version = AwesomeVersion( - alert["supervisor"]["resolved_in_version"], - ) - if supervisor_info["version"] >= resolved_in_version: - continue - - for integration in alert["integrations"]: - if "package" not in integration: - continue - - if integration["package"] not in self.hass.config.components: - continue - - integration_alert = IntegrationAlert( - alert_id=alert["id"], - integration=integration["package"], - filename=alert["filename"], - date_updated=alert.get("updated"), - ) - - result[integration_alert.issue_id] = integration_alert - - return result diff --git a/homeassistant/components/homeassistant_alerts/const.py b/homeassistant/components/homeassistant_alerts/const.py new file mode 100644 index 00000000000..bc4a3cc2336 --- /dev/null +++ b/homeassistant/components/homeassistant_alerts/const.py @@ -0,0 +1,11 @@ +"""Constants for the Home Assistant alerts integration.""" + +from datetime import timedelta + +import aiohttp + +COMPONENT_LOADED_COOLDOWN = 30 +DOMAIN = "homeassistant_alerts" +UPDATE_INTERVAL = timedelta(hours=3) + +REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30) diff --git a/homeassistant/components/homeassistant_alerts/coordinator.py b/homeassistant/components/homeassistant_alerts/coordinator.py new file mode 100644 index 00000000000..5d99e1c980f --- /dev/null +++ b/homeassistant/components/homeassistant_alerts/coordinator.py @@ -0,0 +1,111 @@ +"""Coordinator for the Home Assistant alerts integration.""" + +import dataclasses +import logging + +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy + +from homeassistant.components.hassio import get_supervisor_info, is_hassio +from homeassistant.const import __version__ +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, REQUEST_TIMEOUT, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass(slots=True, frozen=True) +class IntegrationAlert: + """Issue Registry Entry.""" + + alert_id: str + integration: str + filename: str + date_updated: str | None + + @property + def issue_id(self) -> str: + """Return the issue id.""" + return f"{self.filename}_{self.integration}" + + +class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]): + """Data fetcher for HA Alerts.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + self.ha_version = AwesomeVersion( + __version__, + ensure_strategy=AwesomeVersionStrategy.CALVER, + ) + self.supervisor = is_hassio(self.hass) + + async def _async_update_data(self) -> dict[str, IntegrationAlert]: + response = await async_get_clientsession(self.hass).get( + "https://alerts.home-assistant.io/alerts.json", + timeout=REQUEST_TIMEOUT, + ) + alerts = await response.json() + + result = {} + + for alert in alerts: + if "integrations" not in alert: + continue + + if "homeassistant" in alert: + if "affected_from_version" in alert["homeassistant"]: + affected_from_version = AwesomeVersion( + alert["homeassistant"]["affected_from_version"], + ) + if self.ha_version < affected_from_version: + continue + if "resolved_in_version" in alert["homeassistant"]: + resolved_in_version = AwesomeVersion( + alert["homeassistant"]["resolved_in_version"], + ) + if self.ha_version >= resolved_in_version: + continue + + if self.supervisor and "supervisor" in alert: + if (supervisor_info := get_supervisor_info(self.hass)) is None: + continue + + if "affected_from_version" in alert["supervisor"]: + affected_from_version = AwesomeVersion( + alert["supervisor"]["affected_from_version"], + ) + if supervisor_info["version"] < affected_from_version: + continue + if "resolved_in_version" in alert["supervisor"]: + resolved_in_version = AwesomeVersion( + alert["supervisor"]["resolved_in_version"], + ) + if supervisor_info["version"] >= resolved_in_version: + continue + + for integration in alert["integrations"]: + if "package" not in integration: + continue + + if integration["package"] not in self.hass.config.components: + continue + + integration_alert = IntegrationAlert( + alert_id=alert["id"], + integration=integration["package"], + filename=alert["filename"], + date_updated=alert.get("updated"), + ) + + result[integration_alert.issue_id] = integration_alert + + return result diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 9d0aa902cc4..8eeb703248a 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -95,7 +95,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): _LOGGER.error(err) raise AbortFlow( "addon_set_config_failed", - description_placeholders=self._get_translation_placeholders(), + description_placeholders={ + **self._get_translation_placeholders(), + "addon_name": addon_manager.addon_name, + }, ) from err async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo: @@ -118,6 +121,17 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Pick Thread or Zigbee firmware.""" + return self.async_show_menu( + step_id="pick_firmware", + menu_options=[ + STEP_PICK_FIRMWARE_ZIGBEE, + STEP_PICK_FIRMWARE_THREAD, + ], + description_placeholders=self._get_translation_placeholders(), + ) + + async def _probe_firmware_type(self) -> bool: + """Probe the firmware currently on the device.""" assert self._usb_info is not None self._probed_firmware_type = await probe_silabs_firmware_type( @@ -131,29 +145,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ), ) - if self._probed_firmware_type not in ( + return self._probed_firmware_type in ( ApplicationType.EZSP, ApplicationType.SPINEL, ApplicationType.CPC, - ): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - - return self.async_show_menu( - step_id="pick_firmware", - menu_options=[ - STEP_PICK_FIRMWARE_THREAD, - STEP_PICK_FIRMWARE_ZIGBEE, - ], - description_placeholders=self._get_translation_placeholders(), ) async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Pick Zigbee firmware.""" + if not await self._probe_firmware_type(): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + # Allow the stick to be used with ZHA without flashing if self._probed_firmware_type == ApplicationType.EZSP: return await self.async_step_confirm_zigbee() @@ -369,6 +376,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Pick Thread firmware.""" + if not await self._probe_firmware_type(): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + # We install the OTBR addon no matter what, since it is required to use Thread if not is_hassio(self.hass): return self.async_abort( @@ -525,17 +538,7 @@ class HomeAssistantSkyConnectConfigFlow( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm a discovery.""" - self._set_confirm_only() - - # Without confirmation, discovery can automatically progress into parts of the - # config flow logic that interacts with hardware. - if user_input is not None: - return await self.async_step_pick_firmware() - - return self.async_show_form( - step_id="confirm", - description_placeholders=self._get_translation_placeholders(), - ) + return await self.async_step_pick_firmware() def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" @@ -638,15 +641,7 @@ class HomeAssistantSkyConnectOptionsFlowHandler( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options flow.""" - # Don't probe the running firmware, we load it from the config entry - return self.async_show_menu( - step_id="pick_firmware", - menu_options=[ - STEP_PICK_FIRMWARE_THREAD, - STEP_PICK_FIRMWARE_ZIGBEE, - ], - description_placeholders=self._get_translation_placeholders(), - ) + return await self.async_step_pick_firmware() async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None @@ -675,17 +670,16 @@ class HomeAssistantSkyConnectOptionsFlowHandler( """Pick Thread firmware.""" assert self._usb_info is not None - zha_entries = self.hass.config_entries.async_entries( + for zha_entry in self.hass.config_entries.async_entries( ZHA_DOMAIN, include_ignore=False, include_disabled=True, - ) - - if zha_entries and get_zha_device_path(zha_entries[0]) == self._usb_info.device: - raise AbortFlow( - "zha_still_using_stick", - description_placeholders=self._get_translation_placeholders(), - ) + ): + if get_zha_device_path(zha_entry) == self._usb_info.device: + raise AbortFlow( + "zha_still_using_stick", + description_placeholders=self._get_translation_placeholders(), + ) return await super().async_step_pick_firmware_thread(user_input) diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 792406dcb02..59bcb6e606a 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -58,10 +58,6 @@ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]" }, - "confirm": { - "title": "[%key:component::homeassistant_sky_connect::config::step::confirm::title%]", - "description": "[%key:component::homeassistant_sky_connect::config::step::confirm::description%]" - }, "pick_firmware": { "title": "[%key:component::homeassistant_sky_connect::config::step::pick_firmware::title%]", "description": "[%key:component::homeassistant_sky_connect::config::step::pick_firmware::description%]", @@ -131,16 +127,12 @@ "config": { "flow_title": "{model}", "step": { - "confirm": { - "title": "Set up the {model}", - "description": "The {model} can be used as either a Thread border router or a Zigbee coordinator. In the next step, you will choose which firmware will be configured." - }, "pick_firmware": { "title": "Pick your firmware", - "description": "The {model} can be used as a Thread border router or a Zigbee coordinator.", + "description": "Let's get started with setting up your {model}. Do you want to use it to set up a Zigbee or Thread network?", "menu_options": { - "pick_firmware_thread": "Use as a Thread border router", - "pick_firmware_zigbee": "Use as a Zigbee coordinator" + "pick_firmware_zigbee": "Zigbee", + "pick_firmware_thread": "Thread" } }, "install_zigbee_flasher_addon": { diff --git a/homeassistant/components/homeassistant_sky_connect/util.py b/homeassistant/components/homeassistant_sky_connect/util.py index f242416fa9a..864d6bfd9dc 100644 --- a/homeassistant/components/homeassistant_sky_connect/util.py +++ b/homeassistant/components/homeassistant_sky_connect/util.py @@ -50,9 +50,9 @@ def get_hardware_variant(config_entry: ConfigEntry) -> HardwareVariant: return HardwareVariant.from_usb_product_name(config_entry.data["product"]) -def get_zha_device_path(config_entry: ConfigEntry) -> str: +def get_zha_device_path(config_entry: ConfigEntry) -> str | None: """Get the device path from a ZHA config entry.""" - return cast(str, config_entry.data["device"]["path"]) + return cast(str | None, config_entry.data.get("device", {}).get("path", None)) @singleton(OTBR_ADDON_MANAGER_DATA) @@ -94,13 +94,15 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN): zha_path = get_zha_device_path(zha_config_entry) - device_guesses[zha_path].append( - FirmwareGuess( - is_running=(zha_config_entry.state == ConfigEntryState.LOADED), - firmware_type=ApplicationType.EZSP, - source="zha", + + if zha_path is not None: + device_guesses[zha_path].append( + FirmwareGuess( + is_running=(zha_config_entry.state == ConfigEntryState.LOADED), + firmware_type=ApplicationType.EZSP, + source="zha", + ) ) - ) if is_hassio(hass): otbr_addon_manager = get_otbr_addon_manager(hass) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index f9f91ec162b..828f8bf94d6 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -740,7 +740,7 @@ class HomeKit: if acc is not None: self.bridge.add_accessory(acc) return acc - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Failed to create a HomeKit accessory for %s", state.entity_id ) diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 36df47e8a93..8049c4fd5e2 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -11,10 +11,10 @@ This module generates and stores them in a HA storage. from __future__ import annotations -from collections.abc import Generator import random from fnv_hash_fast import fnv1a_32 +from typing_extensions import Generator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -39,7 +39,7 @@ def get_system_unique_id(entity: er.RegistryEntry, entity_unique_id: str) -> str return f"{entity.platform}.{entity.domain}.{entity_unique_id}" -def _generate_aids(unique_id: str | None, entity_id: str) -> Generator[int, None, None]: +def _generate_aids(unique_id: str | None, entity_id: str) -> Generator[int]: """Generate accessory aid.""" if unique_id: diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 9f44e2ab616..00b3de49169 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -59,6 +59,8 @@ CONF_MAX_WIDTH = "max_width" CONF_STREAM_ADDRESS = "stream_address" CONF_STREAM_SOURCE = "stream_source" CONF_SUPPORT_AUDIO = "support_audio" +CONF_THRESHOLD_CO = "co_threshold" +CONF_THRESHOLD_CO2 = "co2_threshold" CONF_VIDEO_CODEC = "video_codec" CONF_VIDEO_PROFILE_NAMES = "video_profile_names" CONF_VIDEO_MAP = "video_map" diff --git a/homeassistant/components/homekit/models.py b/homeassistant/components/homekit/models.py index fee081c9e51..f3fa8b7504c 100644 --- a/homeassistant/components/homekit/models.py +++ b/homeassistant/components/homekit/models.py @@ -1,5 +1,7 @@ """Models for the HomeKit component.""" +from __future__ import annotations + from dataclasses import dataclass from typing import TYPE_CHECKING @@ -11,6 +13,6 @@ if TYPE_CHECKING: class HomeKitEntryData: """Class to hold HomeKit data.""" - homekit: "HomeKit" + homekit: HomeKit pairing_qr: bytes | None = None pairing_qr_secret: str | None = None diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4f05bfbd687..b5764520b61 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -356,7 +356,7 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] stream_source = await camera.async_get_stream_source( self.hass, self.entity_id ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Failed to get stream source - this could be a transient error or your" " camera might not be compatible with HomeKit yet" @@ -503,7 +503,7 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] _LOGGER.info("[%s] %s stream", session_id, shutdown_method) try: await getattr(stream, shutdown_method)() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "[%s] Failed to %s stream", session_id, shutdown_method ) diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index bfa97756bb4..48327910be6 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -41,6 +41,8 @@ from .const import ( CHAR_PM25_DENSITY, CHAR_SMOKE_DETECTED, CHAR_VOC_DENSITY, + CONF_THRESHOLD_CO, + CONF_THRESHOLD_CO2, PROP_CELSIUS, PROP_MAX_VALUE, PROP_MIN_VALUE, @@ -335,6 +337,10 @@ class CarbonMonoxideSensor(HomeAccessory): SERV_CARBON_MONOXIDE_SENSOR, [CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL], ) + + self.threshold_co = self.config.get(CONF_THRESHOLD_CO, THRESHOLD_CO) + _LOGGER.debug("%s: Set CO threshold to %d", self.entity_id, self.threshold_co) + self.char_level = serv_co.configure_char(CHAR_CARBON_MONOXIDE_LEVEL, value=0) self.char_peak = serv_co.configure_char( CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0 @@ -353,7 +359,7 @@ class CarbonMonoxideSensor(HomeAccessory): self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) - co_detected = value > THRESHOLD_CO + co_detected = value > self.threshold_co self.char_detected.set_value(co_detected) _LOGGER.debug("%s: Set to %d", self.entity_id, value) @@ -371,6 +377,10 @@ class CarbonDioxideSensor(HomeAccessory): SERV_CARBON_DIOXIDE_SENSOR, [CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL], ) + + self.threshold_co2 = self.config.get(CONF_THRESHOLD_CO2, THRESHOLD_CO2) + _LOGGER.debug("%s: Set CO2 threshold to %d", self.entity_id, self.threshold_co2) + self.char_level = serv_co2.configure_char(CHAR_CARBON_DIOXIDE_LEVEL, value=0) self.char_peak = serv_co2.configure_char( CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0 @@ -389,7 +399,7 @@ class CarbonDioxideSensor(HomeAccessory): self.char_level.set_value(value) if value > self.char_peak.value: self.char_peak.set_value(value) - co2_detected = value > THRESHOLD_CO2 + co2_detected = value > self.threshold_co2 self.char_detected.set_value(co2_detected) _LOGGER.debug("%s: Set to %d", self.entity_id, value) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index dec7fe8eba7..8fbd7c6b13b 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -72,6 +72,8 @@ from .const import ( CONF_STREAM_COUNT, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, + CONF_THRESHOLD_CO, + CONF_THRESHOLD_CO2, CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, @@ -223,6 +225,13 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( } ) +SENSOR_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_THRESHOLD_CO): vol.Any(None, cv.positive_int), + vol.Optional(CONF_THRESHOLD_CO2): vol.Any(None, cv.positive_int), + } +) + HOMEKIT_CHAR_TRANSLATIONS = { 0: " ", # nul @@ -297,6 +306,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "cover": config = COVER_SCHEMA(config) + elif domain == "sensor": + config = SENSOR_SCHEMA(config) + else: config = BASIC_INFO_SCHEMA(config) diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index abd00f02aa0..ac2133f61ca 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -44,14 +44,14 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = { name="Setup", translation_key="setup", entity_category=EntityCategory.CONFIG, - write_value="#HAA@trcmd", + write_value="#HAA@trcmd", # codespell:ignore haa ), CharacteristicsTypes.VENDOR_HAA_UPDATE: HomeKitButtonEntityDescription( key=CharacteristicsTypes.VENDOR_HAA_UPDATE, name="Update", device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.CONFIG, - write_value="#HAA@trcmd", + write_value="#HAA@trcmd", # codespell:ignore haa ), CharacteristicsTypes.IDENTIFY: HomeKitButtonEntityDescription( key=CharacteristicsTypes.IDENTIFY, diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index e48cb069dfe..48aa3fc2bc7 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -476,7 +476,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="accessory_not_found_error") except InsecureSetupCode: errors["pairing_code"] = "insecure_setup_code" - except Exception as err: # pylint: disable=broad-except + except Exception as err: _LOGGER.exception("Pairing attempt failed with an unhandled exception") self.finish_pairing = None errors["pairing_code"] = "pairing_failed" @@ -508,7 +508,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): # TLV error, usually not in pairing mode _LOGGER.exception("Pairing communication failed") return await self.async_step_protocol_error() - except Exception as err: # pylint: disable=broad-except + except Exception as err: _LOGGER.exception("Pairing attempt failed with an unhandled exception") errors["pairing_code"] = "pairing_failed" description_placeholders["error"] = str(err) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 78190634aff..8c513805641 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -57,9 +57,9 @@ BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds _LOGGER = logging.getLogger(__name__) -AddAccessoryCb = Callable[[Accessory], bool] -AddServiceCb = Callable[[Service], bool] -AddCharacteristicCb = Callable[[Characteristic], bool] +type AddAccessoryCb = Callable[[Accessory], bool] +type AddServiceCb = Callable[[Service], bool] +type AddCharacteristicCb = Callable[[Characteristic], bool] def valid_serial_number(serial: str) -> bool: @@ -110,7 +110,7 @@ class HKDevice: # A list of callbacks that turn HK characteristics into entities self.char_factories: list[AddCharacteristicCb] = [] - # The platorms we have forwarded the config entry so far. If a new + # The platforms we have forwarded the config entry so far. If a new # accessory is added to a bridge we may have to load additional # platforms. We don't want to load all platforms up front if its just # a lightbulb. And we don't want to forward a config entry twice diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index ca041d49e11..d0944db38f8 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -212,13 +212,15 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): ) @property - def current_cover_tilt_position(self) -> int: + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt.""" tilt_position = self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT) if not tilt_position: tilt_position = self.service.value( CharacteristicsTypes.HORIZONTAL_TILT_CURRENT ) + if tilt_position is None: + return None # Recalculate to convert from arcdegree scale to percentage scale. if self.is_vertical_tilt: scale = 0.9 diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index a68241d7fc0..631ba43116a 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -2,13 +2,14 @@ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import Callable from typing import TYPE_CHECKING, Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics.const import InputEventValues from aiohomekit.model.services import Service, ServicesTypes from aiohomekit.utils import clamp_enum_to_char +from typing_extensions import Generator import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -88,7 +89,7 @@ class TriggerSource: for event_handler in self._callbacks.get(trigger_key, []): event_handler(ev) - def async_get_triggers(self) -> Generator[tuple[str, str], None, None]: + def async_get_triggers(self) -> Generator[tuple[str, str]]: """List device triggers for HomeKit devices.""" yield from self._triggers diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index 2f94f5bac92..ac436ce27a4 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -12,7 +12,7 @@ from homeassistant.core import Event, HomeAssistant from .const import CONTROLLER from .storage import async_get_entity_storage -IidTuple = tuple[int, int | None, int | None] +type IidTuple = tuple[int, int | None, int | None] def unique_id_to_iids(unique_id: str) -> IidTuple | None: diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index b728e85f959..ac0a05d24c1 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -116,7 +116,7 @@ class HMDevice(Entity): # Link events from pyhomematic self._available = not self._hmdevice.UNREACH - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 self._connected = False _LOGGER.error("Exception while linking %s: %s", self._address, str(err)) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 2b2ddb64700..08002bc551a 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -6,9 +6,11 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_registry import async_entries_for_config_entry +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -129,7 +131,7 @@ def _async_remove_obsolete_entities( return entity_registry = er.async_get(hass) - er_entries = async_entries_for_config_entry(entity_registry, entry.entry_id) + er_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) for er_entry in er_entries: if er_entry.unique_id.startswith("HomematicipAccesspointStatus"): entity_registry.async_remove(er_entry.entity_id) diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 2913896d511..1f294a8cade 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -47,6 +47,7 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_code_arm_required = False def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 7825999900e..2384426dc82 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -100,7 +100,7 @@ class HomematicipHAP: ) except HmipcConnectionError as err: raise ConfigEntryNotReady from err - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.error("Error connecting with HomematicIP Cloud: %s", err) return False diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 43edca4774a..4ac9af48ee1 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -6,17 +6,12 @@ from collections.abc import Callable, Coroutine from functools import wraps import json import logging -from typing import Any, Concatenate, ParamSpec, TypeGuard, TypeVar +from typing import Any, Concatenate, TypeGuard from homeassistant.exceptions import HomeAssistantError from . import HomematicipGenericEntity -_HomematicipGenericEntityT = TypeVar( - "_HomematicipGenericEntityT", bound=HomematicipGenericEntity -) -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) @@ -28,7 +23,7 @@ def is_error_response(response: Any) -> TypeGuard[dict[str, Any]]: return False -def handle_errors( +def handle_errors[_HomematicipGenericEntityT: HomematicipGenericEntity, **_P]( func: Callable[ Concatenate[_HomematicipGenericEntityT, _P], Coroutine[Any, Any, Any] ], diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 9da4e1bee05..024cb2d9f21 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.1.0"] + "requirements": ["homematicip==1.1.1"] } diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index a3eda4ad565..c4160b0bbb0 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from homewizard_energy.errors import DisabledError, RequestError @@ -12,11 +12,8 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN from .entity import HomeWizardEntity -_HomeWizardEntityT = TypeVar("_HomeWizardEntityT", bound=HomeWizardEntity) -_P = ParamSpec("_P") - -def homewizard_exception_handler( +def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P]( 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/manifest.json b/homeassistant/components/homewizard/manifest.json index 7355d9405df..02ba264d99e 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==v5.0.0"], + "requirements": ["python-homewizard-energy==v6.0.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 19102e5b985..86f1034fdff 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -625,6 +625,8 @@ async def async_setup_entry( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] # Migrate original gas meter sensor to ExternalDevice + # This is sensor that was directly linked to the P1 Meter + # Migration can be removed after 2024.8.0 ent_reg = er.async_get(hass) if ( @@ -634,7 +636,7 @@ async def async_setup_entry( ) and coordinator.data.data.gas_unique_id is not None: ent_reg.async_update_entity( entity_id, - new_unique_id=f"{DOMAIN}_{coordinator.data.data.gas_unique_id}", + new_unique_id=f"{DOMAIN}_gas_meter_{coordinator.data.data.gas_unique_id}", ) # Remove old gas_unique_id sensor @@ -654,6 +656,18 @@ async def async_setup_entry( if coordinator.data.data.external_devices is not None: for unique_id, device in coordinator.data.data.external_devices.items(): if description := EXTERNAL_SENSORS.get(device.meter_type): + # Migrate external devices to new unique_id + # This is to ensure that devices with same id but different type are unique + # Migration can be removed after 2024.11.0 + if entity_id := ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"{DOMAIN}_{device.unique_id}" + ): + ent_reg.async_update_entity( + entity_id, + new_unique_id=f"{DOMAIN}_{unique_id}", + ) + + # Add external device entities.append( HomeWizardExternalSensorEntity(coordinator, description, unique_id) ) diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index fc787d98eea..e30778f7f15 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -11,7 +11,7 @@ from typing import Any from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED, Homeworks import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -29,14 +29,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify -from .const import ( - CONF_ADDR, - CONF_CONTROLLER_ID, - CONF_DIMMERS, - CONF_KEYPADS, - CONF_RATE, - DOMAIN, -) +from .const import CONF_ADDR, CONF_CONTROLLER_ID, CONF_KEYPADS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -51,35 +44,7 @@ DEFAULT_FADE_RATE = 1.0 KEYPAD_LEDSTATE_POLL_COOLDOWN = 1.0 -CV_FADE_RATE = vol.All(vol.Coerce(float), vol.Range(min=0, max=20)) - -DIMMER_SCHEMA = vol.Schema( - { - vol.Required(CONF_ADDR): cv.string, - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_RATE, default=DEFAULT_FADE_RATE): CV_FADE_RATE, - } -) - -KEYPAD_SCHEMA = vol.Schema( - {vol.Required(CONF_ADDR): cv.string, vol.Required(CONF_NAME): cv.string} -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.port, - vol.Required(CONF_DIMMERS): vol.All(cv.ensure_list, [DIMMER_SCHEMA]), - vol.Optional(CONF_KEYPADS, default=[]): vol.All( - cv.ensure_list, [KEYPAD_SCHEMA] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) SERVICE_SEND_COMMAND_SCHEMA = vol.Schema( { @@ -150,22 +115,13 @@ async def async_send_command(hass: HomeAssistant, data: Mapping[str, Any]) -> No else: _LOGGER.debug("Sending command '%s'", command) await hass.async_add_executor_job( - # pylint: disable-next=protected-access - homeworks_data.controller._send, + homeworks_data.controller._send, # noqa: SLF001 command, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Start Homeworks controller.""" - - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - async_setup_services(hass) return True @@ -312,8 +268,7 @@ class HomeworksKeypad: def _request_keypad_led_states(self) -> None: """Query keypad led state.""" - # pylint: disable-next=protected-access - self._controller._send(f"RKLS, {self._addr}") + self._controller._send(f"RKLS, {self._addr}") # noqa: SLF001 async def request_keypad_led_states(self) -> None: """Query keypad led state. diff --git a/homeassistant/components/homeworks/button.py b/homeassistant/components/homeworks/button.py index 2f3ba482717..f071b05b492 100644 --- a/homeassistant/components/homeworks/button.py +++ b/homeassistant/components/homeworks/button.py @@ -71,16 +71,13 @@ class HomeworksButton(HomeworksEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" await self.hass.async_add_executor_job( - # pylint: disable-next=protected-access - self._controller._send, + self._controller._send, # noqa: SLF001 f"KBP, {self._addr}, {self._idx}", ) if not self._release_delay: return await asyncio.sleep(self._release_delay) - # pylint: disable-next=protected-access await self.hass.async_add_executor_job( - # pylint: disable-next=protected-access - self._controller._send, + self._controller._send, # noqa: SLF001 f"KBR, {self._addr}, {self._idx}", ) diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index f447860c53f..4b91018036a 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -14,17 +14,11 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - async_get_hass, - callback, -) +from homeassistant.core import async_get_hass, callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import ( config_validation as cv, entity_registry as er, - issue_registry as ir, selector, ) from homeassistant.helpers.schema_config_entry_flow import ( @@ -103,14 +97,14 @@ async def validate_add_controller( user_input[CONF_CONTROLLER_ID] = slugify(user_input[CONF_NAME]) user_input[CONF_PORT] = int(user_input[CONF_PORT]) try: - handler._async_abort_entries_match( # pylint: disable=protected-access + handler._async_abort_entries_match( # noqa: SLF001 {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} ) except AbortFlow as err: raise SchemaFlowError("duplicated_host_port") from err try: - handler._async_abort_entries_match( # pylint: disable=protected-access + handler._async_abort_entries_match( # noqa: SLF001 {CONF_CONTROLLER_ID: user_input[CONF_CONTROLLER_ID]} ) except AbortFlow as err: @@ -148,24 +142,6 @@ async def _try_connection(user_input: dict[str, Any]) -> None: raise SchemaFlowError("unknown_error") from err -def _create_import_issue(hass: HomeAssistant) -> None: - """Create a repair issue asking the user to remove YAML.""" - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Lutron Homeworks", - }, - ) - - def _validate_address(handler: SchemaCommonFlowHandler, addr: str) -> None: """Validate address.""" try: @@ -547,100 +523,6 @@ OPTIONS_FLOW = { class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Lutron Homeworks.""" - import_config: dict[str, Any] - - async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult: - """Start importing configuration from yaml.""" - self.import_config = { - CONF_HOST: config[CONF_HOST], - CONF_PORT: config[CONF_PORT], - CONF_DIMMERS: [ - { - CONF_ADDR: light[CONF_ADDR], - CONF_NAME: light[CONF_NAME], - CONF_RATE: light[CONF_RATE], - } - for light in config[CONF_DIMMERS] - ], - CONF_KEYPADS: [ - { - CONF_ADDR: keypad[CONF_ADDR], - CONF_BUTTONS: [], - CONF_NAME: keypad[CONF_NAME], - } - for keypad in config[CONF_KEYPADS] - ], - } - return await self.async_step_import_controller_name() - - async def async_step_import_controller_name( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Ask user to set a name of the controller.""" - errors = {} - try: - self._async_abort_entries_match( - { - CONF_HOST: self.import_config[CONF_HOST], - CONF_PORT: self.import_config[CONF_PORT], - } - ) - except AbortFlow: - _create_import_issue(self.hass) - raise - - if user_input: - try: - user_input[CONF_CONTROLLER_ID] = slugify(user_input[CONF_NAME]) - self._async_abort_entries_match( - {CONF_CONTROLLER_ID: user_input[CONF_CONTROLLER_ID]} - ) - except AbortFlow: - errors["base"] = "duplicated_controller_id" - else: - self.import_config |= user_input - return await self.async_step_import_finish() - - return self.async_show_form( - step_id="import_controller_name", - data_schema=vol.Schema( - { - vol.Required( - CONF_NAME, description={"suggested_value": "Lutron Homeworks"} - ): selector.TextSelector(), - } - ), - errors=errors, - ) - - async def async_step_import_finish( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Ask user to remove YAML configuration.""" - - if user_input is not None: - entity_registry = er.async_get(self.hass) - config = self.import_config - for light in config[CONF_DIMMERS]: - addr = light[CONF_ADDR] - if entity_id := entity_registry.async_get_entity_id( - LIGHT_DOMAIN, DOMAIN, f"homeworks.{addr}" - ): - entity_registry.async_update_entity( - entity_id, - new_unique_id=calculate_unique_id( - config[CONF_CONTROLLER_ID], addr, 0 - ), - ) - name = config.pop(CONF_NAME) - return self.async_create_entry( - title=name, - data={}, - options=config, - ) - - return self.async_show_form(step_id="import_finish", data_schema=vol.Schema({})) - async def _validate_edit_controller( self, user_input: dict[str, Any] ) -> dict[str, Any]: diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 8349c383e9f..5a4d6374304 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass +from aiohttp.client_exceptions import ClientConnectionError import aiosomecomfort from homeassistant.config_entries import ConfigEntry @@ -68,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, aiosomecomfort.device.SomeComfortError, + ClientConnectionError, TimeoutError, ) as ex: raise ConfigEntryNotReady( diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index f9a1cc54c7a..d9260fc3be5 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -214,13 +214,13 @@ class HoneywellUSThermostat(ClimateEntity): ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if device._data.get("canControlHumidification"): + if device._data.get("canControlHumidification"): # noqa: SLF001 self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY if device.raw_ui_data.get("SwitchEmergencyHeatAllowed"): self._attr_supported_features |= ClimateEntityFeature.AUX_HEAT - if not device._data.get("hasFan"): + if not device._data.get("hasFan"): # noqa: SLF001 return # not all honeywell fans support all modes diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 85877046bc0..7f298aee632 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -54,6 +54,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): """Confirm re-authentication with Honeywell.""" errors: dict[str, str] = {} assert self.entry is not None + if user_input: try: await self.is_valid( @@ -63,14 +64,12 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): except aiosomecomfort.AuthError: errors["base"] = "invalid_auth" - except ( aiosomecomfort.ConnectionError, aiosomecomfort.ConnectionTimeout, TimeoutError, ): errors["base"] = "cannot_connect" - else: return self.async_update_reload_and_abort( self.entry, @@ -83,14 +82,16 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", data_schema=self.add_suggested_values_to_schema( - REAUTH_SCHEMA, self.entry.data + REAUTH_SCHEMA, + self.entry.data, ), errors=errors, + description_placeholders={"name": "Honeywell"}, ) async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Create config entry. Show the setup form to the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: await self.is_valid(**user_input) @@ -102,7 +103,6 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): TimeoutError, ): errors["base"] = "cannot_connect" - if not errors: return self.async_create_entry( title=DOMAIN, @@ -114,7 +114,9 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_PASSWORD): str, } return self.async_show_form( - step_id="user", data_schema=vol.Schema(data_schema), errors=errors + step_id="user", + data_schema=vol.Schema(data_schema), + errors=errors, ) async def is_valid(self, **kwargs) -> bool: diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 83601599d88..38f0b628b2c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -3,15 +3,17 @@ from __future__ import annotations import asyncio +from collections.abc import Collection +from dataclasses import dataclass import datetime +from functools import partial from ipaddress import IPv4Network, IPv6Network, ip_network import logging import os import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, Required, TypedDict, cast -from urllib.parse import quote_plus, urljoin +from typing import Any, Final, TypedDict, cast from aiohttp import web from aiohttp.abc import AbstractStreamWriter @@ -21,7 +23,6 @@ from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_protocol import RequestHandler from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher -from aiohttp_isal import enable_isal from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -31,29 +32,18 @@ from yarl import URL from homeassistant.components.network import async_get_source_ip from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT -from homeassistant.core import ( - Event, - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, - callback, -) -from homeassistant.exceptions import ( - HomeAssistantError, - ServiceValidationError, - Unauthorized, - UnknownUser, -) -from homeassistant.helpers import storage +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import frame, storage import homeassistant.helpers.config_validation as cv from homeassistant.helpers.http import ( - KEY_ALLOW_CONFIGRED_CORS, + KEY_ALLOW_CONFIGURED_CORS, KEY_AUTHENTICATED, # noqa: F401 KEY_HASS, HomeAssistantView, current_request, ) +from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -66,14 +56,9 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util.async_ import create_eager_task from homeassistant.util.json import json_loads -from .auth import async_setup_auth, async_sign_path +from .auth import async_setup_auth from .ban import setup_bans -from .const import ( # noqa: F401 - DOMAIN, - KEY_HASS_REFRESH_TOKEN_ID, - KEY_HASS_USER, - StrictConnectionMode, -) +from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401 from .cors import setup_cors from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded @@ -96,7 +81,6 @@ CONF_TRUSTED_PROXIES: Final = "trusted_proxies" CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" CONF_SSL_PROFILE: Final = "ssl_profile" -CONF_STRICT_CONNECTION: Final = "strict_connection" SSL_MODERN: Final = "modern" SSL_INTERMEDIATE: Final = "intermediate" @@ -146,9 +130,6 @@ HTTP_SCHEMA: Final = vol.All( [SSL_INTERMEDIATE, SSL_MODERN] ), vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, - vol.Optional( - CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED - ): vol.Coerce(StrictConnectionMode), } ), ) @@ -156,6 +137,21 @@ HTTP_SCHEMA: Final = vol.All( CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA) +@dataclass(slots=True) +class StaticPathConfig: + """Configuration for a static path.""" + + url_path: str + path: str + cache_headers: bool = True + + +_STATIC_CLASSES = { + True: CachingStaticResource, + False: web.StaticResource, +} + + class ConfData(TypedDict, total=False): """Typed dict for config data.""" @@ -172,7 +168,6 @@ class ConfData(TypedDict, total=False): login_attempts_threshold: int ip_ban_enabled: bool ssl_profile: str - strict_connection: Required[StrictConnectionMode] @bind_hass @@ -201,7 +196,9 @@ class ApiConfig: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" - enable_isal() + # Late import to ensure isal is updated before + # we import aiohttp_fast_zlib + (await async_import_module(hass, "aiohttp_fast_zlib")).enable() conf: ConfData | None = config.get(DOMAIN) @@ -239,7 +236,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, use_x_frame_options=use_x_frame_options, - strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION], ) async def stop_server(event: Event) -> None: @@ -269,7 +265,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: local_ip, host, server_port, ssl_certificate is not None ) - _setup_services(hass, conf) return True @@ -307,6 +302,16 @@ class HomeAssistantApplication(web.Application): ) +async def _serve_file_with_cache_headers( + path: str, request: web.Request +) -> web.FileResponse: + return web.FileResponse(path, headers=CACHE_HEADERS) + + +async def _serve_file(path: str, request: web.Request) -> web.FileResponse: + return web.FileResponse(path) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -354,7 +359,6 @@ class HomeAssistantHTTP: login_threshold: int, is_ban_enabled: bool, use_x_frame_options: bool, - strict_connection_non_cloud: StrictConnectionMode, ) -> None: """Initialize the server.""" self.app[KEY_HASS] = self.hass @@ -371,7 +375,7 @@ class HomeAssistantHTTP: if is_ban_enabled: setup_bans(self.hass, self.app, login_threshold) - await async_setup_auth(self.hass, self.app, strict_connection_non_cloud) + await async_setup_auth(self.hass, self.app) setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) @@ -423,34 +427,72 @@ class HomeAssistantHTTP: # Should be instance of aiohttp.web_exceptions._HTTPMove. raise redirect_exc(redirect_to) # type: ignore[arg-type,misc] - self.app[KEY_ALLOW_CONFIGRED_CORS]( + self.app[KEY_ALLOW_CONFIGURED_CORS]( self.app.router.add_route("GET", url, redirect) ) + def _make_static_resources( + self, configs: Collection[StaticPathConfig] + ) -> dict[str, CachingStaticResource | web.StaticResource | None]: + """Create a list of static resources.""" + return { + config.url_path: _STATIC_CLASSES[config.cache_headers]( + config.url_path, config.path + ) + if os.path.isdir(config.path) + else None + for config in configs + } + + async def async_register_static_paths( + self, configs: Collection[StaticPathConfig] + ) -> None: + """Register a folder or file to serve as a static path.""" + resources = await self.hass.async_add_executor_job( + self._make_static_resources, configs + ) + self._async_register_static_paths(configs, resources) + + @callback + def _async_register_static_paths( + self, + configs: Collection[StaticPathConfig], + resources: dict[str, CachingStaticResource | web.StaticResource | None], + ) -> None: + """Register a folders or files to serve as a static path.""" + app = self.app + allow_cors = app[KEY_ALLOW_CONFIGURED_CORS] + for config in configs: + if resource := resources[config.url_path]: + app.router.register_resource(resource) + allow_cors(resource) + + target = ( + _serve_file_with_cache_headers if config.cache_headers else _serve_file + ) + allow_cors( + self.app.router.add_route( + "GET", config.url_path, partial(target, config.path) + ) + ) + def register_static_path( self, url_path: str, path: str, cache_headers: bool = True ) -> None: """Register a folder or file to serve as a static path.""" - if os.path.isdir(path): - if cache_headers: - resource: CachingStaticResource | web.StaticResource = ( - CachingStaticResource(url_path, path) - ) - else: - resource = web.StaticResource(url_path, path) - self.app.router.register_resource(resource) - self.app[KEY_ALLOW_CONFIGRED_CORS](resource) - return - - async def serve_file(request: web.Request) -> web.FileResponse: - """Serve file from disk.""" - if cache_headers: - return web.FileResponse(path, headers=CACHE_HEADERS) - return web.FileResponse(path) - - self.app[KEY_ALLOW_CONFIGRED_CORS]( - self.app.router.add_route("GET", url_path, serve_file) + frame.report( + "calls hass.http.register_static_path which is deprecated because " + "it does blocking I/O in the event loop, instead " + "call `await hass.http.async_register_static_path(" + f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`; ' + "This function will be removed in 2025.7", + exclude_integrations={"http"}, + error_if_core=False, + error_if_integration=False, ) + configs = [StaticPathConfig(url_path, path, cache_headers)] + resources = self._make_static_resources(configs) + self._async_register_static_paths(configs, resources) def _create_ssl_context(self) -> ssl.SSLContext | None: context: ssl.SSLContext | None = None @@ -555,8 +597,7 @@ class HomeAssistantHTTP: # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen - # pylint: disable-next=protected-access - self.app._router.freeze = lambda: None # type: ignore[method-assign] + self.app._router.freeze = lambda: None # type: ignore[method-assign] # noqa: SLF001 self.runner = web.AppRunner( self.app, handler_cancellation=True, shutdown_timeout=10 @@ -601,61 +642,3 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) - - -@callback -def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: - """Set up services for HTTP component.""" - - async def create_temporary_strict_connection_url( - call: ServiceCall, - ) -> ServiceResponse: - """Create a strict connection url and return it.""" - # Copied form homeassistant/helpers/service.py#_async_admin_handler - # as the helper supports no responses yet - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - if not user.is_admin: - raise Unauthorized(context=call.context) - - if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="strict_connection_not_enabled_non_cloud", - ) - - try: - url = get_url( - hass, prefer_external=True, allow_internal=False, allow_cloud=False - ) - except NoURLAvailableError as ex: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_external_url_available", - ) from ex - - # to avoid circular import - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.auth import STRICT_CONNECTION_URL - - path = async_sign_path( - hass, - STRICT_CONNECTION_URL, - datetime.timedelta(hours=1), - use_content_user=True, - ) - url = urljoin(url, path) - - return { - "url": f"https://login.home-assistant.io?u={quote_plus(url)}", - "direct_url": url, - } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 58dae21d2a6..0f43aac0115 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -4,18 +4,14 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from datetime import timedelta -from http import HTTPStatus from ipaddress import ip_address import logging -import os import secrets import time from typing import Any, Final from aiohttp import hdrs -from aiohttp.web import Application, Request, Response, StreamResponse, middleware -from aiohttp.web_exceptions import HTTPBadRequest -from aiohttp_session import session_middleware +from aiohttp.web import Application, Request, StreamResponse, middleware import jwt from jwt import api_jws from yarl import URL @@ -25,21 +21,13 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import singleton from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local -from .const import ( - DOMAIN, - KEY_AUTHENTICATED, - KEY_HASS_REFRESH_TOKEN_ID, - KEY_HASS_USER, - StrictConnectionMode, -) -from .session import HomeAssistantCookieStorage +from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER _LOGGER = logging.getLogger(__name__) @@ -51,11 +39,6 @@ SAFE_QUERY_PARAMS: Final = ["height", "width"] STORAGE_VERSION = 1 STORAGE_KEY = "http.auth" CONTENT_USER_NAME = "Home Assistant Content" -STRICT_CONNECTION_EXCLUDED_PATH = "/api/webhook/" -STRICT_CONNECTION_GUARD_PAGE_NAME = "strict_connection_guard_page.html" -STRICT_CONNECTION_GUARD_PAGE = os.path.join( - os.path.dirname(__file__), STRICT_CONNECTION_GUARD_PAGE_NAME -) @callback @@ -137,7 +120,6 @@ def async_user_not_allowed_do_auth( async def async_setup_auth( hass: HomeAssistant, app: Application, - strict_connection_mode_non_cloud: StrictConnectionMode, ) -> None: """Create auth middleware for the app.""" store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) @@ -160,10 +142,6 @@ async def async_setup_auth( hass.data[STORAGE_KEY] = refresh_token.id - if strict_connection_mode_non_cloud is StrictConnectionMode.GUARD_PAGE: - # Load the guard page content on setup - await _read_strict_connection_guard_page(hass) - @callback def async_validate_auth_header(request: Request) -> bool: """Test authorization header against access token. @@ -252,37 +230,6 @@ async def async_setup_auth( authenticated = True auth_type = "signed request" - if not authenticated and not request.path.startswith( - STRICT_CONNECTION_EXCLUDED_PATH - ): - strict_connection_mode = strict_connection_mode_non_cloud - strict_connection_func = ( - _async_perform_strict_connection_action_on_non_local - ) - if is_cloud_connection(hass): - from homeassistant.components.cloud.util import ( # pylint: disable=import-outside-toplevel - get_strict_connection_mode, - ) - - strict_connection_mode = get_strict_connection_mode(hass) - strict_connection_func = _async_perform_strict_connection_action - - if ( - strict_connection_mode is not StrictConnectionMode.DISABLED - and not await hass.auth.session.async_validate_request_for_strict_connection_session( - request - ) - and ( - resp := await strict_connection_func( - hass, - request, - strict_connection_mode is StrictConnectionMode.GUARD_PAGE, - ) - ) - is not None - ): - return resp - if authenticated and _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Authenticated %s for %s using %s", @@ -294,69 +241,4 @@ async def async_setup_auth( request[KEY_AUTHENTICATED] = authenticated return await handler(request) - app.middlewares.append(session_middleware(HomeAssistantCookieStorage(hass))) app.middlewares.append(auth_middleware) - - -async def _async_perform_strict_connection_action_on_non_local( - hass: HomeAssistant, - request: Request, - guard_page: bool, -) -> StreamResponse | None: - """Perform strict connection mode action if the request is not local. - - The function does the following: - - Try to get the IP address of the request. If it fails, assume it's not local - - If the request is local, return None (allow the request to continue) - - If guard_page is True, return a response with the content - - Otherwise close the connection and raise an exception - """ - try: - ip_address_ = ip_address(request.remote) # type: ignore[arg-type] - except ValueError: - _LOGGER.debug("Invalid IP address: %s", request.remote) - ip_address_ = None - - if ip_address_ and is_local(ip_address_): - return None - - return await _async_perform_strict_connection_action(hass, request, guard_page) - - -async def _async_perform_strict_connection_action( - hass: HomeAssistant, - request: Request, - guard_page: bool, -) -> StreamResponse | None: - """Perform strict connection mode action. - - The function does the following: - - If guard_page is True, return a response with the content - - Otherwise close the connection and raise an exception - """ - - _LOGGER.debug("Perform strict connection action for %s", request.remote) - if guard_page: - return Response( - text=await _read_strict_connection_guard_page(hass), - content_type="text/html", - status=HTTPStatus.IM_A_TEAPOT, - ) - - if transport := request.transport: - # it should never happen that we don't have a transport - transport.close() - - # We need to raise an exception to stop processing the request - raise HTTPBadRequest - - -@singleton.singleton(f"{DOMAIN}_{STRICT_CONNECTION_GUARD_PAGE_NAME}") -async def _read_strict_connection_guard_page(hass: HomeAssistant) -> str: - """Read the strict connection guard page from disk via executor.""" - - def read_guard_page() -> str: - with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file: - return file.read() - - return await hass.async_add_executor_job(read_guard_page) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index b4e949514b8..dd5f1ed1b05 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -10,7 +10,7 @@ from http import HTTPStatus from ipaddress import IPv4Address, IPv6Address, ip_address import logging from socket import gethostbyaddr, herror -from typing import Any, Concatenate, Final, ParamSpec, TypeVar +from typing import Any, Concatenate, Final from aiohttp.web import ( AppKey, @@ -32,9 +32,6 @@ from homeassistant.util import dt as dt_util, yaml from .const import KEY_HASS from .view import HomeAssistantView -_HassViewT = TypeVar("_HassViewT", bound=HomeAssistantView) -_P = ParamSpec("_P") - _LOGGER: Final = logging.getLogger(__name__) KEY_BAN_MANAGER = AppKey["IpBanManager"]("ha_banned_ips_manager") @@ -91,7 +88,7 @@ async def ban_middleware( raise -def log_invalid_auth( +def log_invalid_auth[_HassViewT: HomeAssistantView, **_P]( 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/const.py b/homeassistant/components/http/const.py index 4a15e310b11..1a5d7a603d7 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,6 +1,5 @@ """HTTP specific constants.""" -from enum import StrEnum from typing import Final from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 @@ -9,11 +8,3 @@ DOMAIN: Final = "http" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" - - -class StrictConnectionMode(StrEnum): - """Enum for strict connection mode.""" - - DISABLED = "disabled" - GUARD_PAGE = "guard_page" - DROP_CONNECTION = "drop_connection" diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index d97ac9922a2..69e7c7ea2d5 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -19,7 +19,7 @@ from homeassistant.const import HTTP_HEADER_X_REQUESTED_WITH from homeassistant.core import callback from homeassistant.helpers.http import ( KEY_ALLOW_ALL_CORS, - KEY_ALLOW_CONFIGRED_CORS, + KEY_ALLOW_CONFIGURED_CORS, AllowCorsType, ) @@ -82,6 +82,6 @@ def setup_cors(app: Application, origins: list[str]) -> None: ) if origins: - app[KEY_ALLOW_CONFIGRED_CORS] = cast(AllowCorsType, _allow_cors) + app[KEY_ALLOW_CONFIGURED_CORS] = cast(AllowCorsType, _allow_cors) else: - app[KEY_ALLOW_CONFIGRED_CORS] = lambda _: None + app[KEY_ALLOW_CONFIGURED_CORS] = lambda _: None diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index e1ba1caae56..b2f6496a77b 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -6,16 +6,13 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from http import HTTPStatus import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from aiohttp import web import voluptuous as vol from .view import HomeAssistantView -_HassViewT = TypeVar("_HassViewT", bound=HomeAssistantView) -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) @@ -36,7 +33,7 @@ class RequestDataValidator: self._schema = schema self._allow_empty = allow_empty - def __call__( + def __call__[_HassViewT: HomeAssistantView, **_P]( self, method: Callable[ Concatenate[_HassViewT, web.Request, dict[str, Any], _P], diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index d2e6121b08e..1adc21be09f 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar, overload +from typing import Any, Concatenate, overload from aiohttp.web import Request, Response, StreamResponse @@ -13,16 +13,18 @@ from homeassistant.exceptions import Unauthorized from .view import HomeAssistantView -_HomeAssistantViewT = TypeVar("_HomeAssistantViewT", bound=HomeAssistantView) -_ResponseT = TypeVar("_ResponseT", bound=Response | StreamResponse) -_P = ParamSpec("_P") -_FuncType = Callable[ - Concatenate[_HomeAssistantViewT, Request, _P], Coroutine[Any, Any, _ResponseT] +type _ResponseType = Response | StreamResponse +type _FuncType[_T, **_P, _R] = Callable[ + Concatenate[_T, Request, _P], Coroutine[Any, Any, _R] ] @overload -def require_admin( +def require_admin[ + _HomeAssistantViewT: HomeAssistantView, + **_P, + _ResponseT: _ResponseType, +]( _func: None = None, *, error: Unauthorized | None = None, @@ -33,12 +35,20 @@ def require_admin( @overload -def require_admin( +def require_admin[ + _HomeAssistantViewT: HomeAssistantView, + **_P, + _ResponseT: _ResponseType, +]( _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT], ) -> _FuncType[_HomeAssistantViewT, _P, _ResponseT]: ... -def require_admin( +def require_admin[ + _HomeAssistantViewT: HomeAssistantView, + **_P, + _ResponseT: _ResponseType, +]( _func: _FuncType[_HomeAssistantViewT, _P, _ResponseT] | None = None, *, error: Unauthorized | None = None, diff --git a/homeassistant/components/http/icons.json b/homeassistant/components/http/icons.json deleted file mode 100644 index 8e8b6285db7..00000000000 --- a/homeassistant/components/http/icons.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "services": { - "create_temporary_strict_connection_url": "mdi:login-variant" - } -} diff --git a/homeassistant/components/http/services.yaml b/homeassistant/components/http/services.yaml deleted file mode 100644 index 16b0debb144..00000000000 --- a/homeassistant/components/http/services.yaml +++ /dev/null @@ -1 +0,0 @@ -create_temporary_strict_connection_url: ~ diff --git a/homeassistant/components/http/session.py b/homeassistant/components/http/session.py deleted file mode 100644 index 81668ec2ccc..00000000000 --- a/homeassistant/components/http/session.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Session http module.""" - -from functools import lru_cache -import logging - -from aiohttp.web import Request, StreamResponse -from aiohttp_session import Session, SessionData -from aiohttp_session.cookie_storage import EncryptedCookieStorage -from cryptography.fernet import InvalidToken - -from homeassistant.auth.const import REFRESH_TOKEN_EXPIRATION -from homeassistant.core import HomeAssistant -from homeassistant.helpers.json import json_dumps -from homeassistant.helpers.network import is_cloud_connection -from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads - -from .ban import process_wrong_login - -_LOGGER = logging.getLogger(__name__) - -COOKIE_NAME = "SC" -PREFIXED_COOKIE_NAME = f"__Host-{COOKIE_NAME}" -SESSION_CACHE_SIZE = 16 - - -def _get_cookie_name(is_secure: bool) -> str: - """Return the cookie name.""" - return PREFIXED_COOKIE_NAME if is_secure else COOKIE_NAME - - -class HomeAssistantCookieStorage(EncryptedCookieStorage): - """Home Assistant cookie storage. - - Own class is required: - - to set the secure flag based on the connection type - - to use a LRU cache for session decryption - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the cookie storage.""" - super().__init__( - hass.auth.session.key, - cookie_name=PREFIXED_COOKIE_NAME, - max_age=int(REFRESH_TOKEN_EXPIRATION), - httponly=True, - samesite="Lax", - secure=True, - encoder=json_dumps, - decoder=json_loads, - ) - self._hass = hass - - def _secure_connection(self, request: Request) -> bool: - """Return if the connection is secure (https).""" - return is_cloud_connection(self._hass) or request.secure - - def load_cookie(self, request: Request) -> str | None: - """Load cookie.""" - is_secure = self._secure_connection(request) - cookie_name = _get_cookie_name(is_secure) - return request.cookies.get(cookie_name) - - @lru_cache(maxsize=SESSION_CACHE_SIZE) - def _decrypt_cookie(self, cookie: str) -> Session | None: - """Decrypt and validate cookie.""" - try: - data = SessionData( # type: ignore[misc] - self._decoder( - self._fernet.decrypt( - cookie.encode("utf-8"), ttl=self.max_age - ).decode("utf-8") - ) - ) - except (InvalidToken, TypeError, ValueError, *JSON_DECODE_EXCEPTIONS): - _LOGGER.warning("Cannot decrypt/parse cookie value") - return None - - session = Session(None, data=data, new=data is None, max_age=self.max_age) - - # Validate session if not empty - if ( - not session.empty - and not self._hass.auth.session.async_validate_strict_connection_session( - session - ) - ): - # Invalidate session as it is not valid - session.invalidate() - - return session - - async def new_session(self) -> Session: - """Create a new session and mark it as changed.""" - session = Session(None, data=None, new=True, max_age=self.max_age) - session.changed() - return session - - async def load_session(self, request: Request) -> Session: - """Load session.""" - # Split parent function to use lru_cache - if (cookie := self.load_cookie(request)) is None: - return await self.new_session() - - if (session := self._decrypt_cookie(cookie)) is None: - # Decrypting/parsing failed, log wrong login and create a new session - await process_wrong_login(request) - session = await self.new_session() - - return session - - async def save_session( - self, request: Request, response: StreamResponse, session: Session - ) -> None: - """Save session.""" - - is_secure = self._secure_connection(request) - cookie_name = _get_cookie_name(is_secure) - - if session.empty: - response.del_cookie(cookie_name) - else: - params = self.cookie_params.copy() - params["secure"] = is_secure - params["max_age"] = session.max_age - - cookie_data = self._encoder(self._get_session_data(session)).encode("utf-8") - response.set_cookie( - cookie_name, - self._fernet.encrypt(cookie_data).decode("utf-8"), - **params, - ) - # Add Cache-Control header to not cache the cookie as it - # is used for session management - self._add_cache_control_header(response) - - @staticmethod - def _add_cache_control_header(response: StreamResponse) -> None: - """Add/set cache control header to no-cache="Set-Cookie".""" - # Structure of the Cache-Control header defined in - # https://datatracker.ietf.org/doc/html/rfc2068#section-14.9 - if header := response.headers.get("Cache-Control"): - directives = [] - for directive in header.split(","): - directive = directive.strip() - directive_lowered = directive.lower() - if directive_lowered.startswith("no-cache"): - if "set-cookie" in directive_lowered or directive.find("=") == -1: - # Set-Cookie is already in the no-cache directive or - # the whole request should not be cached -> Nothing to do - return - - # Add Set-Cookie to the no-cache - # [:-1] to remove the " at the end of the directive - directive = f"{directive[:-1]}, Set-Cookie" - - directives.append(directive) - header = ", ".join(directives) - else: - header = 'no-cache="Set-Cookie"' - response.headers["Cache-Control"] = header diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index b7bb9d4f3a8..a7280fb9b2f 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -80,4 +80,4 @@ class CachingStaticResource(StaticResource): }, ) - return await super()._handle(request) + raise HTTPForbidden if filepath is None else HTTPNotFound diff --git a/homeassistant/components/http/strict_connection_guard_page.html b/homeassistant/components/http/strict_connection_guard_page.html deleted file mode 100644 index 8567e500c9d..00000000000 --- a/homeassistant/components/http/strict_connection_guard_page.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - Home Assistant - Access denied - - - - -
- - - - -
-
-

You need access

-

- This device is not known to - Home Assistant. -

- - - Learn how to get access - -
- - diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json deleted file mode 100644 index 7cd64f5f297..00000000000 --- a/homeassistant/components/http/strings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "exceptions": { - "strict_connection_not_enabled_non_cloud": { - "message": "Strict connection is not enabled for non-cloud requests" - }, - "no_external_url_available": { - "message": "No external URL available" - } - }, - "services": { - "create_temporary_strict_connection_url": { - "name": "Create a temporary strict connection URL", - "description": "Create a temporary strict connection URL, which can be used to login on another device." - } - } -} diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index fcdfbc661a7..4ca39eaab0c 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -27,7 +27,7 @@ class HomeAssistantTCPSite(web.BaseSite): def __init__( self, runner: web.BaseRunner, - host: None | str | list[str], + host: str | list[str] | None, port: int, *, ssl_context: SSLContext | None = None, diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 7d28d6c187f..b0c40c71658 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -308,7 +308,7 @@ class Router: ResponseErrorNotSupportedException, ): pass # Ok, normal, nothing to do - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.warning("Logout error", exc_info=True) def cleanup(self, *_: Any) -> None: @@ -406,7 +406,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: wlan_settings = await hass.async_add_executor_job( router.client.wlan.multi_basic_settings ) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # Assume not supported, or authentication required but in unauthenticated mode wlan_settings = {} macs = get_device_macs(router_info or {}, wlan_settings) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 84cf88786a9..ce6131c784f 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -171,7 +171,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): except Timeout: _LOGGER.warning("Connection timeout", exc_info=True) errors[CONF_URL] = "connection_timeout" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.warning("Unknown error connecting to device", exc_info=True) errors[CONF_URL] = "unknown" return conn @@ -181,7 +181,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): try: conn.close() conn.requests_session.close() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.debug("Disconnect error", exc_info=True) async def async_step_user( @@ -210,18 +210,18 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): client = Client(conn) try: device_info = client.device.information() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.debug("Could not get device.information", exc_info=True) try: device_info = client.device.basic_information() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.debug( "Could not get device.basic_information", exc_info=True ) device_info = {} try: wlan_settings = client.wlan.multi_basic_settings() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.debug("Could not get wlan.multi_basic_settings", exc_info=True) wlan_settings = {} return device_info, wlan_settings @@ -291,7 +291,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): basic_info = Client(conn).device.basic_information() except ResponseErrorException: # API compatible error return True - except Exception: # API incompatible error # pylint: disable=broad-except + except Exception: # API incompatible error # noqa: BLE001 return False return isinstance(basic_info, dict) # Crude content check diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 1f9905f4e9c..0e35208dcce 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) _DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/device_scan" -_HostType = dict[str, Any] +type _HostType = dict[str, Any] def _get_hosts( diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index cef5bc5030e..2a7fe5c29b2 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -54,7 +54,7 @@ def format_default(value: StateType) -> tuple[StateType, str | None]: if value is not None: # Clean up value and infer unit, e.g. -71dBm, 15 dB if match := re.match( - r"([>=<]*)(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) + r"((&[gl]t;|[><])=?)?(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) ): try: value = float(match.group("value")) @@ -193,6 +193,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { key="cqi1", translation_key="cqi1", icon="mdi:speedometer", + entity_category=EntityCategory.DIAGNOSTIC, ), "dl_mcs": HuaweiSensorEntityDescription( key="dl_mcs", @@ -268,6 +269,97 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), entity_category=EntityCategory.DIAGNOSTIC, ), + "nrbler": HuaweiSensorEntityDescription( + key="nrbler", + translation_key="nrbler", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrcqi0": HuaweiSensorEntityDescription( + key="nrcqi0", + translation_key="nrcqi0", + icon="mdi:speedometer", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrcqi1": HuaweiSensorEntityDescription( + key="nrcqi1", + translation_key="nrcqi1", + icon="mdi:speedometer", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrdlbandwidth": HuaweiSensorEntityDescription( + key="nrdlbandwidth", + translation_key="nrdlbandwidth", + # Could add icon_fn like we have for dlbandwidth, + # if we find a good source what to use as 5G thresholds. + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrdlmcs": HuaweiSensorEntityDescription( + key="nrdlmcs", + translation_key="nrdlmcs", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrearfcn": HuaweiSensorEntityDescription( + key="nrearfcn", + translation_key="nrearfcn", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrrank": HuaweiSensorEntityDescription( + key="nrrank", + translation_key="nrrank", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrrsrp": HuaweiSensorEntityDescription( + key="nrrsrp", + translation_key="nrrsrp", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + # Could add icon_fn as in rsrp, source for 5G thresholds? + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=True, + ), + "nrrsrq": HuaweiSensorEntityDescription( + key="nrrsrq", + translation_key="nrrsrq", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + # Could add icon_fn as in rsrq, source for 5G thresholds? + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=True, + ), + "nrsinr": HuaweiSensorEntityDescription( + key="nrsinr", + translation_key="nrsinr", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + # Could add icon_fn as in sinr, source for thresholds? + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=True, + ), + "nrtxpower": HuaweiSensorEntityDescription( + key="nrtxpower", + translation_key="nrtxpower", + # The value we get from the API tends to consist of several, e.g. + # PPusch:21dBm PPucch:2dBm PSrs:0dBm PPrach:10dBm + # Present as SIGNAL_STRENGTH only if it was parsed to a number. + # We could try to parse this to separate component sensors sometime. + device_class_fn=lambda x: ( + SensorDeviceClass.SIGNAL_STRENGTH + if isinstance(x, (float, int)) + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrulbandwidth": HuaweiSensorEntityDescription( + key="nrulbandwidth", + translation_key="nrulbandwidth", + # Could add icon_fn as in ulbandwidth, source for 5G thresholds? + entity_category=EntityCategory.DIAGNOSTIC, + ), + "nrulmcs": HuaweiSensorEntityDescription( + key="nrulmcs", + translation_key="nrulmcs", + entity_category=EntityCategory.DIAGNOSTIC, + ), "pci": HuaweiSensorEntityDescription( key="pci", translation_key="pci", @@ -303,7 +395,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { key="rsrp", translation_key="rsrp", device_class=SensorDeviceClass.SIGNAL_STRENGTH, - # http://www.lte-anbieter.info/technik/rsrp.php + # http://www.lte-anbieter.info/technik/rsrp.php # codespell:ignore technik icon_fn=lambda x: signal_icon((-110, -95, -80), x), state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -313,7 +405,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { key="rsrq", translation_key="rsrq", device_class=SensorDeviceClass.SIGNAL_STRENGTH, - # http://www.lte-anbieter.info/technik/rsrq.php + # http://www.lte-anbieter.info/technik/rsrq.php # codespell:ignore technik icon_fn=lambda x: signal_icon((-11, -8, -5), x), state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -333,7 +425,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { key="sinr", translation_key="sinr", device_class=SensorDeviceClass.SIGNAL_STRENGTH, - # http://www.lte-anbieter.info/technik/sinr.php + # http://www.lte-anbieter.info/technik/sinr.php # codespell:ignore technik icon_fn=lambda x: signal_icon((0, 5, 10), x), state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index a1a3f5c9416..b1b16184b0c 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -125,6 +125,45 @@ "lte_uplink_frequency": { "name": "LTE uplink frequency" }, + "nrbler": { + "name": "5G block error rate" + }, + "nrcqi0": { + "name": "5G CQI 0" + }, + "nrcqi1": { + "name": "5G CQI 1" + }, + "nrdlbandwidth": { + "name": "5G downlink bandwidth" + }, + "nrdlmcs": { + "name": "5G downlink MCS" + }, + "nrearfcn": { + "name": "5G EARFCN" + }, + "nrrank": { + "name": "5G rank" + }, + "nrrsrp": { + "name": "5G RSRP" + }, + "nrrsrq": { + "name": "5G RSRQ" + }, + "nrsinr": { + "name": "5G SINR" + }, + "nrtxpower": { + "name": "5G transmit power" + }, + "nrulbandwidth": { + "name": "5G uplink bandwidth" + }, + "nrulmcs": { + "name": "5G uplink MCS" + }, "pci": { "name": "PCI" }, diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index f167897d77b..5397eeebd96 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -94,7 +94,7 @@ class HueBridge: raise ConfigEntryNotReady( f"Error connecting to the Hue bridge at {self.host}" ) from err - except Exception: # pylint: disable=broad-except + except Exception: self.logger.exception("Unknown error connecting to Hue bridge") return False finally: diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index de2d9363ac7..fb32f568ee1 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -189,7 +189,7 @@ class HueFlowHandler(ConfigFlow, domain=DOMAIN): except CannotConnect: LOGGER.error("Error connecting to the Hue bridge at %s", bridge.host) return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: LOGGER.exception( "Unknown error connecting with Hue bridge at %s", bridge.host ) diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 1ba974fa167..64f3ccba9f9 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -95,7 +95,9 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): def _handle_event(self, event_type: EventType, resource: Button) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: - self._trigger_event(resource.button.last_event.value) + if resource.button is None or resource.button.button_report is None: + return + self._trigger_event(resource.button.button_report.event.value) self.async_write_ha_state() return super()._handle_event(event_type, resource) @@ -119,11 +121,16 @@ class HueRotaryEventEntity(HueBaseEntity, EventEntity): def _handle_event(self, event_type: EventType, resource: RelativeRotary) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: - event_key = resource.relative_rotary.last_event.rotation.direction.value + if ( + resource.relative_rotary is None + or resource.relative_rotary.rotary_report is None + ): + return + event_key = resource.relative_rotary.rotary_report.rotation.direction.value event_data = { - "duration": resource.relative_rotary.last_event.rotation.duration, - "steps": resource.relative_rotary.last_event.rotation.steps, - "action": resource.relative_rotary.last_event.action.value, + "duration": resource.relative_rotary.rotary_report.rotation.duration, + "steps": resource.relative_rotary.rotary_report.rotation.steps, + "action": resource.relative_rotary.rotary_report.action.value, } self._trigger_event(event_key, event_data) self.async_write_ha_state() diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index f4bf6366d61..1214f39d146 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -12,15 +12,10 @@ 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_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, - async_get as async_get_device_registry, -) -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry as entities_for_config_entry, - async_entries_for_device, - async_get as async_get_entity_registry, +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + entity_registry as er, ) from .const import DOMAIN @@ -75,15 +70,15 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> N """Perform migration of devices and entities to V2 Id's.""" host = entry.data[CONF_HOST] api_key = entry.data[CONF_API_KEY] - dev_reg = async_get_device_registry(hass) - ent_reg = async_get_entity_registry(hass) + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) LOGGER.info("Start of migration of devices and entities to support API schema 2") # Create mapping of mac address to HA device id's. # Identifier in dev reg should be mac-address, # but in some cases it has a postfix like `-0b` or `-01`. dev_ids = {} - for hass_dev in devices_for_config_entries(dev_reg, entry.entry_id): + for hass_dev in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): for domain, mac in hass_dev.identifiers: if domain != DOMAIN: continue @@ -128,7 +123,7 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> N LOGGER.info("Migrated device %s (%s)", hue_dev.metadata.name, hass_dev_id) # loop through all entities for device and find match - for ent in async_entries_for_device(ent_reg, hass_dev_id, True): + for ent in er.async_entries_for_device(ent_reg, hass_dev_id, True): if ent.entity_id.startswith("light"): # migrate light # should always return one lightid here @@ -179,7 +174,7 @@ async def handle_v2_migration(hass: core.HomeAssistant, entry: ConfigEntry) -> N ) # migrate entities that are not connected to a device (groups) - for ent in entities_for_config_entry(ent_reg, entry.entry_id): + for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id): if ent.device_id is not None: continue if "-" in ent.unique_id: diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index bc650569a63..650a9384e35 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from functools import partial -from typing import TypeAlias from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.config import ( @@ -37,10 +36,8 @@ from ..bridge import HueBridge from ..const import DOMAIN from .entity import HueBaseEntity -SensorType: TypeAlias = ( - CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper -) -ControllerType: TypeAlias = ( +type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +type ControllerType = ( CameraMotionController | ContactController | MotionController diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 38c5724d4a8..25a027f9ebe 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -1,5 +1,7 @@ """Handles Hue resource of type `device` mapping to Home Assistant device.""" +from __future__ import annotations + from typing import TYPE_CHECKING from aiohue.v2 import HueBridgeV2 @@ -27,7 +29,7 @@ if TYPE_CHECKING: from ..bridge import HueBridge -async def async_setup_devices(bridge: "HueBridge"): +async def async_setup_devices(bridge: HueBridge): """Manage setup of devices from Hue devices.""" entry = bridge.config_entry hass = bridge.hass diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 8aeac4d8180..6575d7f4702 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING from aiohue.v2.controllers.base import BaseResourcesController from aiohue.v2.controllers.events import EventType @@ -10,9 +10,9 @@ from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.zigbee_connectivity import ConnectivityServiceStatus from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from ..bridge import HueBridge from ..const import CONF_IGNORE_AVAILABILITY, DOMAIN @@ -24,7 +24,7 @@ if TYPE_CHECKING: from aiohue.v2.models.light_level import LightLevel from aiohue.v2.models.motion import Motion - HueResource: TypeAlias = Light | DevicePower | GroupedLight | LightLevel | Motion + type HueResource = Light | DevicePower | GroupedLight | LightLevel | Motion RESOURCE_TYPE_NAMES = { @@ -128,7 +128,7 @@ class HueBaseEntity(Entity): if event_type == EventType.RESOURCE_DELETED: # cleanup entities that are not strictly device-bound and have the bridge as parent if self.device is None and resource.id == self.resource.id: - ent_reg = async_get_entity_registry(self.hass) + ent_reg = er.async_get(self.hass) ent_reg.async_remove(self.entity_id) return diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index db30800a333..34797b0e42c 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -26,6 +26,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from ..bridge import HueBridge from ..const import DOMAIN @@ -136,15 +137,18 @@ class GroupedHueLight(HueBaseEntity, LightEntity): scenes = { x.metadata.name for x in self.api.scenes if x.group.rid == self.group.id } - lights = { - self.controller.get_device(x.id).metadata.name - for x in self.controller.get_lights(self.resource.id) - } + light_resource_ids = tuple( + x.id for x in self.controller.get_lights(self.resource.id) + ) + light_names, light_entities = self._get_names_and_entity_ids_for_resource_ids( + light_resource_ids + ) return { "is_hue_group": True, "hue_scenes": scenes, "hue_type": self.group.type.value, - "lights": lights, + "lights": light_names, + "entity_id": light_entities, "dynamics": self._dynamic_mode_active, } @@ -278,3 +282,19 @@ class GroupedHueLight(HueBaseEntity, LightEntity): self._attr_color_mode = ColorMode.BRIGHTNESS else: self._attr_color_mode = ColorMode.ONOFF + + @callback + def _get_names_and_entity_ids_for_resource_ids( + self, resource_ids: tuple[str] + ) -> tuple[set[str], set[str]]: + """Return the names and entity ids for the given Hue (light) resource IDs.""" + ent_reg = er.async_get(self.hass) + light_names: set[str] = set() + light_entities: set[str] = set() + for resource_id in resource_ids: + light_names.add(self.controller.get_device(resource_id).metadata.name) + if entity_id := ent_reg.async_get_entity_id( + self.platform.domain, DOMAIN, resource_id + ): + light_entities.add(entity_id) + return light_names, light_entities diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index 6aee6c67bf3..b0e0de234f1 100644 --- a/homeassistant/components/hue/v2/hue_event.py +++ b/homeassistant/components/hue/v2/hue_event.py @@ -1,5 +1,7 @@ """Handle forward of events transmitted by Hue devices to HASS.""" +from __future__ import annotations + import logging from typing import TYPE_CHECKING @@ -25,7 +27,7 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) -async def async_setup_hue_events(bridge: "HueBridge"): +async def async_setup_hue_events(bridge: HueBridge): """Manage listeners for stateless Hue sensors that emit events.""" hass = bridge.hass api: HueBridgeV2 = bridge.api # to satisfy typing diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index e46ca561964..6e90d3ca775 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from typing import Any, TypeAlias +from typing import Any from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType @@ -34,8 +34,8 @@ from ..bridge import HueBridge from ..const import DOMAIN from .entity import HueBaseEntity -SensorType: TypeAlias = DevicePower | LightLevel | Temperature | ZigbeeConnectivity -ControllerType: TypeAlias = ( +type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity +type ControllerType = ( DevicePowerController | LightLevelController | TemperatureController diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py index b02d0bf577c..3e0c9845c92 100644 --- a/homeassistant/components/huisbaasje/__init__.py +++ b/homeassistant/components/huisbaasje/__init__.py @@ -1,4 +1,4 @@ -"""The Huisbaasje integration.""" +"""The EnergyFlip integration.""" import asyncio from datetime import timedelta @@ -31,8 +31,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Huisbaasje from a config entry.""" - # Create the Huisbaasje client + """Set up EnergyFlip from a config entry.""" + # Create the EnergyFlip client energyflip = EnergyFlip( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False async def async_update_data() -> dict[str, dict[str, Any]]: - return await async_update_huisbaasje(energyflip) + return await async_update_energyflip(energyflip) # Create a coordinator for polling updates coordinator = DataUpdateCoordinator( @@ -75,21 +75,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Forward the unloading of the entry to the platform unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - # If successful, unload the Huisbaasje client + # If successful, unload the EnergyFlip client if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok -async def async_update_huisbaasje(energyflip: EnergyFlip) -> dict[str, dict[str, Any]]: - """Update the data by performing a request to Huisbaasje.""" +async def async_update_energyflip(energyflip: EnergyFlip) -> dict[str, dict[str, Any]]: + """Update the data by performing a request to EnergyFlip.""" try: # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(FETCH_TIMEOUT): if not energyflip.is_authenticated(): - _LOGGER.warning("Huisbaasje is unauthenticated. Reauthenticating") + _LOGGER.warning("EnergyFlip is unauthenticated. Reauthenticating") await energyflip.authenticate() current_measurements = await energyflip.current_measurements() @@ -125,7 +125,7 @@ def _get_cumulative_value( ): """Get the cumulative energy consumption for a certain period. - :param current_measurements: The result from the Huisbaasje client + :param current_measurements: The result from the EnergyFlip client :param source_type: The source of energy (electricity or gas) :param period_type: The period for which cumulative value should be given. """ diff --git a/homeassistant/components/huisbaasje/config_flow.py b/homeassistant/components/huisbaasje/config_flow.py index 3697c1fcb86..ecf8cdbe431 100644 --- a/homeassistant/components/huisbaasje/config_flow.py +++ b/homeassistant/components/huisbaasje/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Huisbaasje integration.""" +"""Config flow for EnergyFlip integration.""" import logging @@ -18,8 +18,8 @@ DATA_SCHEMA = vol.Schema( ) -class HuisbaasjeConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Huisbaasje.""" +class EnergyFlipConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for EnergyFlip.""" VERSION = 1 @@ -40,7 +40,7 @@ class HuisbaasjeConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except AbortFlow: raise - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/huisbaasje/const.py b/homeassistant/components/huisbaasje/const.py index 108e3fffa1e..2738289343f 100644 --- a/homeassistant/components/huisbaasje/const.py +++ b/homeassistant/components/huisbaasje/const.py @@ -1,4 +1,4 @@ -"""Constants for the Huisbaasje integration.""" +"""Constants for the EnergyFlip integration.""" from energyflip.const import ( SOURCE_TYPE_ELECTRICITY, @@ -13,7 +13,7 @@ DATA_COORDINATOR = "coordinator" DOMAIN = "huisbaasje" -"""Interval in seconds between polls to huisbaasje.""" +"""Interval in seconds between polls to EnergyFlip.""" POLLING_INTERVAL = 20 """Timeout for fetching sensor data""" diff --git a/homeassistant/components/huisbaasje/manifest.json b/homeassistant/components/huisbaasje/manifest.json index 610abc833ce..7ea7be258b6 100644 --- a/homeassistant/components/huisbaasje/manifest.json +++ b/homeassistant/components/huisbaasje/manifest.json @@ -1,10 +1,10 @@ { "domain": "huisbaasje", - "name": "Huisbaasje", + "name": "EnergyFlip", "codeowners": ["@dennisschroer"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huisbaasje", "iot_class": "cloud_polling", - "loggers": ["huisbaasje"], + "loggers": ["energyflip"], "requirements": ["energyflip-client==0.2.2"] } diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index d09b559516b..c024e3030fa 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -50,15 +50,14 @@ _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True) -class HuisbaasjeSensorEntityDescription(SensorEntityDescription): +class EnergyFlipSensorEntityDescription(SensorEntityDescription): """Class describing Airly sensor entities.""" sensor_type: str = SENSOR_TYPE_RATE - precision: int = 0 SENSORS_INFO = [ - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="current_power", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, @@ -66,7 +65,7 @@ SENSORS_INFO = [ key=SOURCE_TYPE_ELECTRICITY, state_class=SensorStateClass.MEASUREMENT, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="current_power_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, @@ -74,7 +73,7 @@ SENSORS_INFO = [ key=SOURCE_TYPE_ELECTRICITY_IN, state_class=SensorStateClass.MEASUREMENT, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="current_power_off_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, @@ -82,7 +81,7 @@ SENSORS_INFO = [ key=SOURCE_TYPE_ELECTRICITY_IN_LOW, state_class=SensorStateClass.MEASUREMENT, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="current_power_out_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, @@ -90,7 +89,7 @@ SENSORS_INFO = [ key=SOURCE_TYPE_ELECTRICITY_OUT, state_class=SensorStateClass.MEASUREMENT, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="current_power_out_off_peak", sensor_type=SENSOR_TYPE_RATE, device_class=SensorDeviceClass.POWER, @@ -98,121 +97,121 @@ SENSORS_INFO = [ key=SOURCE_TYPE_ELECTRICITY_OUT_LOW, state_class=SensorStateClass.MEASUREMENT, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_consumption_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_IN, sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=3, + suggested_display_precision=3, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_consumption_off_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_IN_LOW, sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=3, + suggested_display_precision=3, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_production_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_OUT, sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=3, + suggested_display_precision=3, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_production_off_peak_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, key=SOURCE_TYPE_ELECTRICITY_OUT_LOW, sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=3, + suggested_display_precision=3, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, - precision=1, + suggested_display_precision=1, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, - precision=1, + suggested_display_precision=1, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, - precision=1, + suggested_display_precision=1, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, - precision=1, + suggested_display_precision=1, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="current_gas", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, key=SOURCE_TYPE_GAS, - precision=1, + suggested_display_precision=2, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="gas_today", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=2, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="gas_week", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=2, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="gas_month", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=2, ), - HuisbaasjeSensorEntityDescription( + EnergyFlipSensorEntityDescription( translation_key="gas_year", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, state_class=SensorStateClass.TOTAL_INCREASING, - precision=1, + suggested_display_precision=2, ), ] @@ -229,31 +228,30 @@ async def async_setup_entry( user_id = config_entry.data[CONF_ID] async_add_entities( - HuisbaasjeSensor(coordinator, user_id, description) + EnergyFlipSensor(coordinator, user_id, description) for description in SENSORS_INFO ) -class HuisbaasjeSensor( +class EnergyFlipSensor( CoordinatorEntity[DataUpdateCoordinator[dict[str, dict[str, Any]]]], SensorEntity ): - """Defines a Huisbaasje sensor.""" + """Defines a EnergyFlip sensor.""" - entity_description: HuisbaasjeSensorEntityDescription + entity_description: EnergyFlipSensorEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]], user_id: str, - description: HuisbaasjeSensorEntityDescription, + description: EnergyFlipSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description self._source_type = description.key self._sensor_type = description.sensor_type - self._precision = description.precision self._attr_unique_id = ( f"{DOMAIN}_{user_id}_{description.key}_{description.sensor_type}" ) @@ -266,7 +264,7 @@ class HuisbaasjeSensor( self.entity_description.sensor_type ] ) is not None: - return round(data, self._precision) + return data return None @property diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index 361de8e36db..425fdbcc679 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -33,27 +33,30 @@ class HumidityHandler(intent.IntentHandler): """Handle set humidity intents.""" intent_type = INTENT_HUMIDITY + description = "Set desired humidity level" slot_schema = { - vol.Required("name"): cv.string, + vol.Required("name"): intent.non_empty_string, vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)), } + platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - states = list( - intent.async_match_states( - hass, - name=slots["name"]["value"], - states=hass.states.async_all(DOMAIN), - ) + + match_constraints = intent.MatchTargetsConstraints( + name=slots["name"]["value"], + domains=[DOMAIN], + assistant=intent_obj.assistant, ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) - if not states: - raise intent.IntentHandleError("No entities matched") - - state = states[0] + state = match_result.states[0] service_data = {ATTR_ENTITY_ID: state.entity_id} humidity = slots["humidity"]["value"] @@ -85,27 +88,29 @@ class SetModeHandler(intent.IntentHandler): """Handle set humidity intents.""" intent_type = INTENT_MODE + description = "Set humidifier mode" slot_schema = { - vol.Required("name"): cv.string, + vol.Required("name"): intent.non_empty_string, vol.Required("mode"): cv.string, } + platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - states = list( - intent.async_match_states( - hass, - name=slots["name"]["value"], - states=hass.states.async_all(DOMAIN), - ) + match_constraints = intent.MatchTargetsConstraints( + name=slots["name"]["value"], + domains=[DOMAIN], + assistant=intent_obj.assistant, ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) - if not states: - raise intent.IntentHandleError("No entities matched") - - state = states[0] + state = match_result.states[0] service_data = {ATTR_ENTITY_ID: state.entity_id} intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes") diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index cb59dd04bdd..0416f4a68a6 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -18,6 +18,13 @@ "toggle": "[%key:common::device_automation::action_type::toggle%]", "turn_on": "[%key:common::device_automation::action_type::turn_on%]", "turn_off": "[%key:common::device_automation::action_type::turn_off%]" + }, + "extra_fields": { + "above": "[%key:common::device_automation::extra_fields::above%]", + "below": "[%key:common::device_automation::extra_fields::below%]", + "for": "[%key:common::device_automation::extra_fields::for%]", + "mode": "Mode", + "humidity": "Humidity" } }, "entity_component": { diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 7753f4ba94b..88ccf890c66 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -114,7 +114,7 @@ class PowerviewConfigFlow(ConfigFlow, domain=DOMAIN): return None, "cannot_connect" except UnsupportedDevice: return None, "unsupported_device" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return None, "unknown" diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index fe6f6978014..326a9a010ef 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -12,13 +12,13 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.LAWN_MOWER, Platform.NUMBER, @@ -27,8 +27,10 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] +type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Set up this integration using UI.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -47,23 +49,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if 400 <= err.status < 500: raise ConfigEntryAuthFailed from err raise ConfigEntryNotReady from err + coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + entry.async_create_background_task( hass, coordinator.client_listen(hass, entry, automower_api), "websocket_task", ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + if "amc:api" not in entry.data["token"]["scope"]: + # We raise ConfigEntryAuthFailed here because the websocket can't be used + # without the scope. So only polling would be possible. + raise ConfigEntryAuthFailed 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, entry: AutomowerConfigEntry) -> bool: """Handle unload of an 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index e8e64e7ffc7..922f7deb99b 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -11,11 +11,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity @@ -49,10 +48,12 @@ BINARY_SENSOR_TYPES: tuple[AutomowerBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up binary sensor platform.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AutomowerBinarySensorEntity(mower_id, coordinator, description) for mower_id in coordinator.data diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py new file mode 100644 index 00000000000..60c05b92a31 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/button.py @@ -0,0 +1,61 @@ +"""Creates a button entity for Husqvarna Automower integration.""" + +import logging + +from aioautomower.exceptions import ApiException + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AutomowerConfigEntry +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerControlEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button platform.""" + coordinator = entry.runtime_data + async_add_entities( + AutomowerButtonEntity(mower_id, coordinator) for mower_id in coordinator.data + ) + + +class AutomowerButtonEntity(AutomowerControlEntity, ButtonEntity): + """Defining the AutomowerButtonEntity.""" + + _attr_translation_key = "confirm_error" + _attr_entity_registry_enabled_default = False + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up button platform.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = f"{mower_id}_confirm_error" + + @property + def available(self) -> bool: + """Return True if the device and entity is available.""" + return super().available and self.mower_attributes.mower.is_error_confirmable + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.coordinator.api.commands.error_confirm(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_send_failed", + translation_placeholders={"exception": str(exception)}, + ) from exception diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py index b25a185c75f..c848f823b13 100644 --- a/homeassistant/components/husqvarna_automower/config_flow.py +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -13,7 +13,9 @@ from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, NAME _LOGGER = logging.getLogger(__name__) + CONF_USER_ID = "user_id" +HUSQVARNA_DEV_PORTAL_URL = "https://developer.husqvarnagroup.cloud/applications" class HusqvarnaConfigFlowHandler( @@ -29,8 +31,14 @@ class HusqvarnaConfigFlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow.""" token = data[CONF_TOKEN] + if "amc:api" not in token["scope"] and not self.reauth_entry: + return self.async_abort(reason="missing_amc_scope") user_id = token[CONF_USER_ID] if self.reauth_entry: + if "amc:api" not in token["scope"]: + return self.async_update_reload_and_abort( + self.reauth_entry, data=data, reason="missing_amc_scope" + ) if self.reauth_entry.unique_id != user_id: return self.async_abort(reason="wrong_account") return self.async_update_reload_and_abort(self.reauth_entry, data=data) @@ -56,6 +64,9 @@ class HusqvarnaConfigFlowHandler( self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + if self.reauth_entry is not None: + if "amc:api" not in self.reauth_entry.data["token"]["scope"]: + return await self.async_step_missing_scope() return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -65,3 +76,19 @@ class HusqvarnaConfigFlowHandler( if user_input is None: return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + + async def async_step_missing_scope( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth for missing scope.""" + if user_input is None and self.reauth_entry is not None: + token_structured = structure_token( + self.reauth_entry.data["token"]["access_token"] + ) + return self.async_show_form( + step_id="missing_scope", + description_placeholders={ + "application_url": f"{HUSQVARNA_DEV_PORTAL_URL}/{token_structured.client_id}" + }, + ) + return await self.async_step_user() diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py index 5e38b354957..1ea0511d721 100644 --- a/homeassistant/components/husqvarna_automower/const.py +++ b/homeassistant/components/husqvarna_automower/const.py @@ -1,6 +1,7 @@ """The constants for the Husqvarna Automower integration.""" DOMAIN = "husqvarna_automower" +EXECUTION_TIME_DELAY = 5 NAME = "Husqvarna Automower" OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 8d9588db5b7..817789727ca 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -4,12 +4,17 @@ import asyncio from datetime import timedelta import logging -from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError +from aioautomower.exceptions import ( + ApiException, + AuthException, + HusqvarnaWSServerHandshakeError, +) from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -46,6 +51,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib return await self.api.get_status() except ApiException as err: raise UpdateFailed(err) from err + except AuthException as err: + raise ConfigEntryAuthFailed(err) from err @callback def callback(self, ws_data: dict[str, MowerAttributes]) -> None: diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 780d1da76fb..74ad624a515 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -3,20 +3,21 @@ from typing import TYPE_CHECKING from homeassistant.components.device_tracker import SourceType, TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker platform.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AutomowerDeviceTrackerEntity(mower_id, coordinator) for mower_id in coordinator.data diff --git a/homeassistant/components/husqvarna_automower/diagnostics.py b/homeassistant/components/husqvarna_automower/diagnostics.py index f5677d4cb4b..658f6f94445 100644 --- a/homeassistant/components/husqvarna_automower/diagnostics.py +++ b/homeassistant/components/husqvarna_automower/diagnostics.py @@ -11,8 +11,8 @@ from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry +from . import AutomowerConfigEntry from .const import DOMAIN -from .coordinator import AutomowerDataUpdateCoordinator CONF_REFRESH_TOKEN = "refresh_token" POSITIONS = "positions" @@ -33,10 +33,10 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: AutomowerConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data for identifier in device.identifiers: if identifier[0] == DOMAIN: if ( diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index 2ecbf9c198a..a9002c5b44a 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -32,5 +32,8 @@ "default": "mdi:tooltip-question" } } + }, + "services": { + "override_schedule": "mdi:debug-step-over" } } diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index e9ed9187530..c0b566a7f66 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -1,30 +1,30 @@ """Husqvarna Automower lawn mower entity.""" +from collections.abc import Awaitable, Callable, Coroutine +from datetime import timedelta +import functools import logging +from typing import Any from aioautomower.exceptions import ApiException from aioautomower.model import MowerActivities, MowerStates +import voluptuous as vol from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AutomowerConfigEntry from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerControlEntity -SUPPORT_STATE_SERVICES = ( - LawnMowerEntityFeature.DOCK - | LawnMowerEntityFeature.PAUSE - | LawnMowerEntityFeature.START_MOWING -) - DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) MOWING_ACTIVITIES = ( MowerActivities.MOWING, @@ -36,20 +36,63 @@ PAUSED_STATES = [ MowerStates.WAIT_UPDATING, MowerStates.WAIT_POWER_UP, ] +SUPPORT_STATE_SERVICES = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING +) +MOW = "mow" +PARK = "park" +OVERRIDE_MODES = [MOW, PARK] _LOGGER = logging.getLogger(__name__) +def handle_sending_exception( + func: Callable[..., Awaitable[Any]], +) -> Callable[..., Coroutine[Any, Any, None]]: + """Handle exceptions while sending a command.""" + + @functools.wraps(func) + async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + try: + return await func(self, *args, **kwargs) + except ApiException as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_send_failed", + translation_placeholders={"exception": str(exception)}, + ) from exception + + return wrapper + + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up lawn mower platform.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + "override_schedule", + { + vol.Required("override_mode"): vol.In(OVERRIDE_MODES), + vol.Required("duration"): vol.All( + cv.time_period, + cv.positive_timedelta, + vol.Range(min=timedelta(minutes=1), max=timedelta(days=42)), + ), + }, + "async_override_schedule", + ) + class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity): """Defining each mower Entity.""" @@ -80,29 +123,27 @@ class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity): return LawnMowerActivity.DOCKED return LawnMowerActivity.ERROR + @handle_sending_exception async def async_start_mowing(self) -> None: """Resume schedule.""" - try: - await self.coordinator.api.resume_schedule(self.mower_id) - except ApiException as exception: - raise HomeAssistantError( - f"Command couldn't be sent to the command queue: {exception}" - ) from exception + await self.coordinator.api.commands.resume_schedule(self.mower_id) + @handle_sending_exception async def async_pause(self) -> None: """Pauses the mower.""" - try: - await self.coordinator.api.pause_mowing(self.mower_id) - except ApiException as exception: - raise HomeAssistantError( - f"Command couldn't be sent to the command queue: {exception}" - ) from exception + await self.coordinator.api.commands.pause_mowing(self.mower_id) + @handle_sending_exception async def async_dock(self) -> None: """Parks the mower until next schedule.""" - try: - await self.coordinator.api.park_until_next_schedule(self.mower_id) - except ApiException as exception: - raise HomeAssistantError( - f"Command couldn't be sent to the command queue: {exception}" - ) from exception + await self.coordinator.api.commands.park_until_next_schedule(self.mower_id) + + @handle_sending_exception + async def async_override_schedule( + self, override_mode: str, duration: timedelta + ) -> None: + """Override the schedule with mowing or parking.""" + if override_mode == MOW: + await self.coordinator.api.commands.start_for(self.mower_id, duration) + if override_mode == PARK: + await self.coordinator.api.commands.park_for(self.mower_id, duration) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 647320a8bf3..5ca1b500340 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", "loggers": ["aioautomower"], - "requirements": ["aioautomower==2024.4.4"] + "requirements": ["aioautomower==2024.6.1"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index bcf74ac4d33..f6d55389195 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -11,14 +11,14 @@ from aioautomower.model import MowerAttributes, WorkArea from aioautomower.session import AutomowerSession from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AutomowerConfigEntry +from .const import EXECUTION_TIME_DELAY from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerControlEntity @@ -30,8 +30,8 @@ def _async_get_cutting_height(data: MowerAttributes) -> int: """Return the cutting height.""" if TYPE_CHECKING: # Sensor does not get created if it is None - assert data.cutting_height is not None - return data.cutting_height + assert data.settings.cutting_height is not None + return data.settings.cutting_height @callback @@ -49,13 +49,18 @@ async def async_set_work_area_cutting_height( work_area_id: int, ) -> None: """Set cutting height for work area.""" - await coordinator.api.set_cutting_height_workarea( + await coordinator.api.commands.set_cutting_height_workarea( mower_id, int(cheight), work_area_id ) - # As there are no updates from the websocket regarding work area changes, - # we need to wait 5s and then poll the API. - await asyncio.sleep(5) - await coordinator.async_request_refresh() + + +async def async_set_cutting_height( + session: AutomowerSession, + mower_id: str, + cheight: float, +) -> None: + """Set cutting height.""" + await session.commands.set_cutting_height(mower_id, int(cheight)) @dataclass(frozen=True, kw_only=True) @@ -75,11 +80,9 @@ NUMBER_TYPES: tuple[AutomowerNumberEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, native_min_value=1, native_max_value=9, - exists_fn=lambda data: data.cutting_height is not None, + exists_fn=lambda data: data.settings.cutting_height is not None, value_fn=_async_get_cutting_height, - set_value_fn=lambda session, mower_id, cheight: session.set_cutting_height( - mower_id, int(cheight) - ), + set_value_fn=async_set_cutting_height, ), ) @@ -108,10 +111,12 @@ WORK_AREA_NUMBER_TYPES: tuple[AutomowerWorkAreaNumberEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up number platform.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[NumberEntity] = [] for mower_id in coordinator.data: @@ -126,12 +131,11 @@ async def async_setup_entry( for work_area_id in _work_areas ) async_remove_entities(hass, coordinator, entry, mower_id) - entities.extend( - AutomowerNumberEntity(mower_id, coordinator, description) - for mower_id in coordinator.data - for description in NUMBER_TYPES - if description.exists_fn(coordinator.data[mower_id]) - ) + entities.extend( + AutomowerNumberEntity(mower_id, coordinator, description) + for description in NUMBER_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) async_add_entities(entities) @@ -214,13 +218,18 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity): raise HomeAssistantError( f"Command couldn't be sent to the command queue: {exception}" ) from exception + else: + # As there are no updates from the websocket regarding work area changes, + # we need to wait 5s and then poll the API. + await asyncio.sleep(EXECUTION_TIME_DELAY) + await self.coordinator.async_request_refresh() @callback def async_remove_entities( hass: HomeAssistant, coordinator: AutomowerDataUpdateCoordinator, - config_entry: ConfigEntry, + entry: AutomowerConfigEntry, mower_id: str, ) -> None: """Remove deleted work areas from Home Assistant.""" @@ -231,10 +240,11 @@ def async_remove_entities( for work_area_id in _work_areas: uid = f"{mower_id}_{work_area_id}_cutting_height_work_area" active_work_areas.add(uid) - for entity_entry in er.async_entries_for_config_entry( - entity_reg, config_entry.entry_id + for entity_entry in er.async_entries_for_config_entry(entity_reg, entry.entry_id): + if ( + entity_entry.domain == Platform.NUMBER + and (split := entity_entry.unique_id.split("_"))[0] == mower_id + and split[-1] == "area" + and entity_entry.unique_id not in active_work_areas ): - if entity_entry.unique_id.split("_")[0] == mower_id: - if entity_entry.unique_id.endswith("cutting_height_work_area"): - if entity_entry.unique_id not in active_work_areas: - entity_reg.async_remove(entity_entry.entity_id) + entity_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index 67aac4a2046..b647407581f 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -7,13 +7,12 @@ from aioautomower.exceptions import ApiException from aioautomower.model import HeadlightModes from homeassistant.components.select import SelectEntity -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 . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerControlEntity @@ -29,10 +28,12 @@ HEADLIGHT_MODES: list = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up select platform.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AutomowerSelectEntity(mower_id, coordinator) for mower_id in coordinator.data @@ -59,12 +60,14 @@ class AutomowerSelectEntity(AutomowerControlEntity, SelectEntity): @property def current_option(self) -> str: """Return the current option for the entity.""" - return cast(HeadlightModes, self.mower_attributes.headlight.mode).lower() + return cast( + HeadlightModes, self.mower_attributes.settings.headlight.mode + ).lower() async def async_select_option(self, option: str) -> None: """Change the selected option.""" try: - await self.coordinator.api.set_headlight_mode( + await self.coordinator.api.commands.set_headlight_mode( self.mower_id, cast(HeadlightModes, option.upper()) ) except ApiException as exception: diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 6840708ed42..146ef17a6e4 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -1,9 +1,10 @@ -"""Creates a the sensor entities for the mower.""" +"""Creates the sensor entities for the mower.""" from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import logging +from typing import TYPE_CHECKING from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons @@ -13,13 +14,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfTime -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity @@ -185,11 +185,31 @@ RESTRICTED_REASONS: list = [ ] +@callback +def _get_work_area_names(data: MowerAttributes) -> list[str]: + """Return a list with all work area names.""" + if TYPE_CHECKING: + # Sensor does not get created if it is None + assert data.work_areas is not None + return [data.work_areas[work_area_id].name for work_area_id in data.work_areas] + + +@callback +def _get_current_work_area_name(data: MowerAttributes) -> str: + """Return the name of the current work area.""" + if TYPE_CHECKING: + # Sensor does not get created if values are None + assert data.work_areas is not None + assert data.mower.work_area_id is not None + return data.work_areas[data.mower.work_area_id].name + + @dataclass(frozen=True, kw_only=True) class AutomowerSensorEntityDescription(SensorEntityDescription): """Describes Automower sensor entity.""" exists_fn: Callable[[MowerAttributes], bool] = lambda _: True + option_fn: Callable[[MowerAttributes], list[str] | None] = lambda _: None value_fn: Callable[[MowerAttributes], StateType | datetime] @@ -205,7 +225,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( key="mode", translation_key="mode", device_class=SensorDeviceClass.ENUM, - options=[option.lower() for option in list(MowerModes)], + option_fn=lambda data: [option.lower() for option in list(MowerModes)], value_fn=( lambda data: data.mower.mode.lower() if data.mower.mode != MowerModes.UNKNOWN @@ -303,26 +323,36 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( key="error", translation_key="error", device_class=SensorDeviceClass.ENUM, + option_fn=lambda data: ERROR_KEY_LIST, value_fn=lambda data: ( "no_error" if data.mower.error_key is None else data.mower.error_key ), - options=ERROR_KEY_LIST, ), AutomowerSensorEntityDescription( key="restricted_reason", translation_key="restricted_reason", device_class=SensorDeviceClass.ENUM, - options=RESTRICTED_REASONS, + option_fn=lambda data: RESTRICTED_REASONS, value_fn=lambda data: data.planner.restricted_reason.lower(), ), + AutomowerSensorEntityDescription( + key="work_area", + translation_key="work_area", + device_class=SensorDeviceClass.ENUM, + exists_fn=lambda data: data.capabilities.work_areas, + option_fn=_get_work_area_names, + value_fn=_get_current_work_area_name, + ), ) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AutomowerSensorEntity(mower_id, coordinator, description) for mower_id in coordinator.data @@ -351,3 +381,8 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.mower_attributes) + + @property + def options(self) -> list[str] | None: + """Return the option of the sensor.""" + return self.entity_description.option_fn(self.mower_attributes) diff --git a/homeassistant/components/husqvarna_automower/services.yaml b/homeassistant/components/husqvarna_automower/services.yaml new file mode 100644 index 00000000000..94687a2ebfa --- /dev/null +++ b/homeassistant/components/husqvarna_automower/services.yaml @@ -0,0 +1,21 @@ +override_schedule: + target: + entity: + integration: "husqvarna_automower" + domain: "lawn_mower" + fields: + duration: + required: true + example: "{'days': 1, 'hours': 12, 'minutes': 30}" + selector: + duration: + enable_day: true + override_mode: + required: true + example: "mow" + selector: + select: + translation_key: override_modes + options: + - "mow" + - "park" diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index d8d0c296745..6cb1c17421a 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -5,6 +5,10 @@ "title": "[%key:common::config_flow::title::reauth%]", "description": "The Husqvarna Automower integration needs to re-authenticate your account" }, + "missing_scope": { + "title": "Your account is missing some API connections", + "description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url})." + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" } @@ -22,7 +26,8 @@ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "You can only reauthenticate this entry with the same Husqvarna account." + "wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.", + "missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -37,6 +42,11 @@ "name": "Returning to dock" } }, + "button": { + "confirm_error": { + "name": "Confirm error" + } + }, "number": { "cutting_height": { "name": "Cutting height" @@ -238,11 +248,49 @@ "home": "Home", "demo": "Demo" } + }, + "work_area": { + "name": "Work area", + "state": { + "my_lawn": "My lawn" + } } }, "switch": { "enable_schedule": { "name": "Enable schedule" + }, + "stay_out_zones": { + "name": "Avoid {stay_out_zone}" + } + } + }, + "exceptions": { + "command_send_failed": { + "message": "Failed to send command: {exception}" + } + }, + "selector": { + "override_modes": { + "options": { + "mow": "Mow", + "park": "Park" + } + } + }, + "services": { + "override_schedule": { + "name": "Override schedule", + "description": "Override the schedule to either mow or park for a duration of time.", + "fields": { + "duration": { + "name": "Duration", + "description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored." + }, + "override_mode": { + "name": "Override mode", + "description": "With which action the schedule should be overridden." + } } } } diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index b178fc05c50..a856e9c9050 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -1,18 +1,27 @@ """Creates a switch entity for the mower.""" +import asyncio import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aioautomower.exceptions import ApiException -from aioautomower.model import MowerActivities, MowerStates, RestrictedReasons +from aioautomower.model import ( + MowerActivities, + MowerModes, + MowerStates, + StayOutZones, + Zone, +) from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import AutomowerConfigEntry +from .const import EXECUTION_TIME_DELAY from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerControlEntity @@ -35,17 +44,33 @@ ERROR_STATES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up switch platform.""" - coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - AutomowerSwitchEntity(mower_id, coordinator) for mower_id in coordinator.data + coordinator = entry.runtime_data + entities: list[SwitchEntity] = [] + entities.extend( + AutomowerScheduleSwitchEntity(mower_id, coordinator) + for mower_id in coordinator.data ) + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.stay_out_zones: + _stay_out_zones = coordinator.data[mower_id].stay_out_zones + if _stay_out_zones is not None: + entities.extend( + AutomowerStayOutZoneSwitchEntity( + coordinator, mower_id, stay_out_zone_uid + ) + for stay_out_zone_uid in _stay_out_zones.zones + ) + async_remove_entities(hass, coordinator, entry, mower_id) + async_add_entities(entities) -class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): - """Defining the Automower switch.""" +class AutomowerScheduleSwitchEntity(AutomowerControlEntity, SwitchEntity): + """Defining the Automower schedule switch.""" _attr_translation_key = "enable_schedule" @@ -61,11 +86,7 @@ class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): @property def is_on(self) -> bool: """Return the state of the switch.""" - attributes = self.mower_attributes - return not ( - attributes.mower.state == MowerStates.RESTRICTED - and attributes.planner.restricted_reason == RestrictedReasons.NOT_APPLICABLE - ) + return self.mower_attributes.mower.mode != MowerModes.HOME @property def available(self) -> bool: @@ -78,7 +99,7 @@ class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" try: - await self.coordinator.api.park_until_further_notice(self.mower_id) + await self.coordinator.api.commands.park_until_further_notice(self.mower_id) except ApiException as exception: raise HomeAssistantError( f"Command couldn't be sent to the command queue: {exception}" @@ -87,8 +108,108 @@ class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" try: - await self.coordinator.api.resume_schedule(self.mower_id) + await self.coordinator.api.commands.resume_schedule(self.mower_id) except ApiException as exception: raise HomeAssistantError( f"Command couldn't be sent to the command queue: {exception}" ) from exception + + +class AutomowerStayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity): + """Defining the Automower stay out zone switch.""" + + _attr_translation_key = "stay_out_zones" + + def __init__( + self, + coordinator: AutomowerDataUpdateCoordinator, + mower_id: str, + stay_out_zone_uid: str, + ) -> None: + """Set up Automower switch.""" + super().__init__(mower_id, coordinator) + self.coordinator = coordinator + self.stay_out_zone_uid = stay_out_zone_uid + self._attr_unique_id = ( + f"{self.mower_id}_{stay_out_zone_uid}_{self._attr_translation_key}" + ) + self._attr_translation_placeholders = {"stay_out_zone": self.stay_out_zone.name} + + @property + def stay_out_zones(self) -> StayOutZones: + """Return all stay out zones.""" + if TYPE_CHECKING: + assert self.mower_attributes.stay_out_zones is not None + return self.mower_attributes.stay_out_zones + + @property + def stay_out_zone(self) -> Zone: + """Return the specific stay out zone.""" + return self.stay_out_zones.zones[self.stay_out_zone_uid] + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.stay_out_zone.enabled + + @property + def available(self) -> bool: + """Return True if the device is available and the zones are not `dirty`.""" + return super().available and not self.stay_out_zones.dirty + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.coordinator.api.commands.switch_stay_out_zone( + self.mower_id, self.stay_out_zone_uid, False + ) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + else: + # As there are no updates from the websocket regarding stay out zone changes, + # we need to wait until the command is executed and then poll the API. + await asyncio.sleep(EXECUTION_TIME_DELAY) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.coordinator.api.commands.switch_stay_out_zone( + self.mower_id, self.stay_out_zone_uid, True + ) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + else: + # As there are no updates from the websocket regarding stay out zone changes, + # we need to wait until the command is executed and then poll the API. + await asyncio.sleep(EXECUTION_TIME_DELAY) + await self.coordinator.async_request_refresh() + + +@callback +def async_remove_entities( + hass: HomeAssistant, + coordinator: AutomowerDataUpdateCoordinator, + entry: AutomowerConfigEntry, + mower_id: str, +) -> None: + """Remove deleted stay-out-zones from Home Assistant.""" + entity_reg = er.async_get(hass) + active_zones = set() + _zones = coordinator.data[mower_id].stay_out_zones + if _zones is not None: + for zones_uid in _zones.zones: + uid = f"{mower_id}_{zones_uid}_stay_out_zones" + active_zones.add(uid) + for entity_entry in er.async_entries_for_config_entry(entity_reg, entry.entry_id): + if ( + entity_entry.domain == Platform.SWITCH + and (split := entity_entry.unique_id.split("_"))[0] == mower_id + and split[-1] == "zones" + and entity_entry.unique_id not in active_zones + ): + entity_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 5de94260a4b..6a5fd96b99d 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -47,7 +47,7 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN): # Most likely Forbidden as that is what is returned from `.status()` with bad creds _LOGGER.error("Could not log in to Huum with given credentials") errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 5998a3dd826..6ad61295d04 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -9,7 +9,7 @@ from pygti.exceptions import InvalidAuth from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ID +from homeassistant.const import ATTR_ID, CONF_OFFSET from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -92,7 +92,7 @@ class HVVDepartureSensor(SensorEntity): async def async_update(self, **kwargs: Any) -> None: """Update the sensor.""" departure_time = utcnow() + timedelta( - minutes=self.config_entry.options.get("offset", 0) + minutes=self.config_entry.options.get(CONF_OFFSET, 0) ) departure_time_tz_berlin = departure_time.astimezone(BERLIN_TIME_ZONE) @@ -125,7 +125,7 @@ class HVVDepartureSensor(SensorEntity): _LOGGER.warning("Network unavailable: %r", error) self._last_error = ClientConnectorError self._attr_available = False - except Exception as error: # pylint: disable=broad-except + except Exception as error: # noqa: BLE001 if self._last_error != error: _LOGGER.error("Error occurred while fetching data: %r", error) self._last_error = error diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index b8c5dbddc7c..52b4c28d718 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -15,22 +18,48 @@ from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity -BINARY_SENSOR_STATUS = BinarySensorEntityDescription( - key="status", - device_class=BinarySensorDeviceClass.CONNECTIVITY, -) -BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( - key="is_watering", - translation_key="watering", - device_class=BinarySensorDeviceClass.MOISTURE, +@dataclass(frozen=True, kw_only=True) +class HydrawiseBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Hydrawise binary sensor.""" + + value_fn: Callable[[HydrawiseBinarySensor], bool | None] + always_available: bool = False + + +CONTROLLER_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( + HydrawiseBinarySensorEntityDescription( + key="status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=( + lambda status_sensor: status_sensor.coordinator.last_update_success + and status_sensor.controller.online + ), + # Connectivtiy sensor is always available + always_available=True, ), ) -BINARY_SENSOR_KEYS: list[str] = [ - desc.key for desc in (BINARY_SENSOR_STATUS, *BINARY_SENSOR_TYPES) -] +RAIN_SENSOR_BINARY_SENSOR: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( + HydrawiseBinarySensorEntityDescription( + key="rain_sensor", + translation_key="rain_sensor", + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda rain_sensor: rain_sensor.sensor.status.active, + ), +) + +ZONE_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( + HydrawiseBinarySensorEntityDescription( + key="is_watering", + translation_key="watering", + device_class=BinarySensorDeviceClass.RUNNING, + value_fn=( + lambda watering_sensor: watering_sensor.zone.scheduled_runs.current_run + is not None + ), + ), +) async def async_setup_entry( @@ -42,15 +71,27 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - entities = [] + entities: list[HydrawiseBinarySensor] = [] for controller in coordinator.data.controllers.values(): - entities.append( - HydrawiseBinarySensor(coordinator, BINARY_SENSOR_STATUS, controller) + entities.extend( + HydrawiseBinarySensor(coordinator, description, controller) + for description in CONTROLLER_BINARY_SENSORS ) entities.extend( - HydrawiseBinarySensor(coordinator, description, controller, zone) + HydrawiseBinarySensor( + coordinator, + description, + controller, + sensor_id=sensor.id, + ) + for sensor in controller.sensors + for description in RAIN_SENSOR_BINARY_SENSOR + if "rain sensor" in sensor.model.name.lower() + ) + entities.extend( + HydrawiseBinarySensor(coordinator, description, controller, zone_id=zone.id) for zone in controller.zones - for description in BINARY_SENSOR_TYPES + for description in ZONE_BINARY_SENSORS ) async_add_entities(entities) @@ -58,10 +99,15 @@ async def async_setup_entry( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" + entity_description: HydrawiseBinarySensorEntityDescription + def _update_attrs(self) -> None: """Update state attributes.""" - if self.entity_description.key == "status": - self._attr_is_on = self.coordinator.last_update_success - elif self.entity_description.key == "is_watering": - assert self.zone is not None - self._attr_is_on = self.zone.scheduled_runs.current_run is not None + self._attr_is_on = self.entity_description.value_fn(self) + + @property + def available(self) -> bool: + """Set the entity availability.""" + if self.entity_description.always_available: + return True + return super().available diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 71922928651..d046dfcc92a 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -5,11 +5,12 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from pydrawise import HydrawiseBase -from pydrawise.schema import Controller, User, Zone +from pydrawise import Hydrawise +from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import now from .const import DOMAIN, LOGGER @@ -21,15 +22,17 @@ class HydrawiseData: user: User controllers: dict[int, Controller] zones: dict[int, Zone] + sensors: dict[int, Sensor] + daily_water_use: dict[int, ControllerWaterUseSummary] class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """The Hydrawise Data Update Coordinator.""" - api: HydrawiseBase + api: Hydrawise def __init__( - self, hass: HomeAssistant, api: HydrawiseBase, scan_interval: timedelta + self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta ) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) @@ -40,8 +43,30 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): user = await self.api.get_user() controllers = {} zones = {} + sensors = {} + daily_water_use: dict[int, ControllerWaterUseSummary] = {} 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) + for sensor in controller.sensors: + sensors[sensor.id] = sensor + if any( + "flow meter" in sensor.model.name.lower() + for sensor in controller.sensors + ): + daily_water_use[controller.id] = await self.api.get_water_use_summary( + controller, + now().replace(hour=0, minute=0, second=0, microsecond=0), + now(), + ) + else: + daily_water_use[controller.id] = ControllerWaterUseSummary() + + return HydrawiseData( + user=user, + controllers=controllers, + zones=zones, + sensors=sensors, + daily_water_use=daily_water_use, + ) diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 2ae893887e6..67dd6375b0e 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydrawise.schema import Controller, Zone +from pydrawise.schema import Controller, Sensor, Zone from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -24,24 +24,42 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): coordinator: HydrawiseDataUpdateCoordinator, description: EntityDescription, controller: Controller, - zone: Zone | None = None, + *, + zone_id: int | None = None, + sensor_id: int | None = None, ) -> None: """Initialize the Hydrawise entity.""" super().__init__(coordinator=coordinator) self.entity_description = description self.controller = controller - self.zone = zone - self._device_id = str(controller.id if zone is None else zone.id) + self.zone_id = zone_id + self.sensor_id = sensor_id + self._device_id = str(zone_id) if zone_id is not None else str(controller.id) self._attr_unique_id = f"{self._device_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, - name=controller.name if zone is None else zone.name, + name=self.zone.name if zone_id is not None else controller.name, + model=( + "Zone" if zone_id is not None else controller.hardware.model.description + ), manufacturer=MANUFACTURER, ) - if zone is not None: + if zone_id is not None or sensor_id is not None: self._attr_device_info["via_device"] = (DOMAIN, str(controller.id)) self._update_attrs() + @property + def zone(self) -> Zone: + """Return the entity zone.""" + assert self.zone_id is not None # needed for mypy + return self.coordinator.data.zones[self.zone_id] + + @property + def sensor(self) -> Sensor: + """Return the entity sensor.""" + assert self.sensor_id is not None # needed for mypy + return self.coordinator.data.sensors[self.sensor_id] + def _update_attrs(self) -> None: """Update state attributes.""" return # pragma: no cover @@ -50,7 +68,10 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): 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() + + @property + def available(self) -> bool: + """Set the entity availability.""" + return super().available and self.controller.online diff --git a/homeassistant/components/hydrawise/icons.json b/homeassistant/components/hydrawise/icons.json index 717b5c48357..64deab590da 100644 --- a/homeassistant/components/hydrawise/icons.json +++ b/homeassistant/components/hydrawise/icons.json @@ -1,8 +1,29 @@ { "entity": { "sensor": { + "daily_active_water_use": { + "default": "mdi:water" + }, + "daily_inactive_water_use": { + "default": "mdi:water" + }, + "daily_total_water_use": { + "default": "mdi:water" + }, + "next_cycle": { + "default": "mdi:clock-outline" + }, "watering_time": { - "default": "mdi:water-pump" + "default": "mdi:timer-outline" + } + }, + "binary_sensor": { + "rain_sensor": { + "default": "mdi:weather-sunny", + "state": { + "off": "mdi:weather-sunny", + "on": "mdi:weather-pouring" + } } } } diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 8a0d52d550c..b85ddca042e 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.4.1"] + "requirements": ["pydrawise==2024.6.4"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 84e9f979878..fe4b33d5851 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime - -from pydrawise.schema import Zone +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime +from homeassistant.const import UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -21,22 +22,100 @@ from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="next_cycle", - translation_key="next_cycle", - device_class=SensorDeviceClass.TIMESTAMP, + +@dataclass(frozen=True, kw_only=True) +class HydrawiseSensorEntityDescription(SensorEntityDescription): + """Describes Hydrawise binary sensor.""" + + value_fn: Callable[[HydrawiseSensor], Any] + + +def _get_zone_watering_time(sensor: HydrawiseSensor) -> int: + if (current_run := sensor.zone.scheduled_runs.current_run) is not None: + return int(current_run.remaining_time.total_seconds() / 60) + return 0 + + +def _get_zone_next_cycle(sensor: HydrawiseSensor) -> datetime | None: + if (next_run := sensor.zone.scheduled_runs.next_run) is not None: + return dt_util.as_utc(next_run.start_time) + return None + + +def _get_zone_daily_active_water_use(sensor: HydrawiseSensor) -> float: + """Get active water use for the zone.""" + daily_water_summary = sensor.coordinator.data.daily_water_use[sensor.controller.id] + return float(daily_water_summary.active_use_by_zone_id.get(sensor.zone.id, 0.0)) + + +def _get_controller_daily_active_water_use(sensor: HydrawiseSensor) -> float | None: + """Get active water use for the controller.""" + daily_water_summary = sensor.coordinator.data.daily_water_use[sensor.controller.id] + return daily_water_summary.total_active_use + + +def _get_controller_daily_inactive_water_use(sensor: HydrawiseSensor) -> float | None: + """Get inactive water use for the controller.""" + daily_water_summary = sensor.coordinator.data.daily_water_use[sensor.controller.id] + return daily_water_summary.total_inactive_use + + +def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | None: + """Get inactive water use for the controller.""" + daily_water_summary = sensor.coordinator.data.daily_water_use[sensor.controller.id] + return daily_water_summary.total_use + + +FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( + HydrawiseSensorEntityDescription( + key="daily_total_water_use", + translation_key="daily_total_water_use", + device_class=SensorDeviceClass.VOLUME, + suggested_display_precision=1, + value_fn=_get_controller_daily_total_water_use, ), - SensorEntityDescription( - key="watering_time", - translation_key="watering_time", - native_unit_of_measurement=UnitOfTime.MINUTES, + HydrawiseSensorEntityDescription( + key="daily_active_water_use", + translation_key="daily_active_water_use", + device_class=SensorDeviceClass.VOLUME, + suggested_display_precision=1, + value_fn=_get_controller_daily_active_water_use, + ), + HydrawiseSensorEntityDescription( + key="daily_inactive_water_use", + translation_key="daily_inactive_water_use", + device_class=SensorDeviceClass.VOLUME, + suggested_display_precision=1, + value_fn=_get_controller_daily_inactive_water_use, ), ) -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -TWO_YEAR_SECONDS = 60 * 60 * 24 * 365 * 2 -WATERING_TIME_ICON = "mdi:water-pump" +FLOW_ZONE_SENSORS: tuple[SensorEntityDescription, ...] = ( + HydrawiseSensorEntityDescription( + key="daily_active_water_use", + translation_key="daily_active_water_use", + device_class=SensorDeviceClass.VOLUME, + suggested_display_precision=1, + value_fn=_get_zone_daily_active_water_use, + ), +) + +ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = ( + HydrawiseSensorEntityDescription( + key="next_cycle", + translation_key="next_cycle", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=_get_zone_next_cycle, + ), + HydrawiseSensorEntityDescription( + key="watering_time", + translation_key="watering_time", + native_unit_of_measurement=UnitOfTime.MINUTES, + value_fn=_get_zone_watering_time, + ), +) + +FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS] async def async_setup_entry( @@ -48,30 +127,61 @@ async def async_setup_entry( coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - async_add_entities( - HydrawiseSensor(coordinator, description, controller, zone) - for controller in coordinator.data.controllers.values() - for zone in controller.zones - for description in SENSOR_TYPES - ) + entities: list[HydrawiseSensor] = [] + for controller in coordinator.data.controllers.values(): + entities.extend( + HydrawiseSensor(coordinator, description, controller, zone_id=zone.id) + for zone in controller.zones + for description in ZONE_SENSORS + ) + entities.extend( + HydrawiseSensor(coordinator, description, controller, sensor_id=sensor.id) + for sensor in controller.sensors + for description in FLOW_CONTROLLER_SENSORS + if "flow meter" in sensor.model.name.lower() + ) + entities.extend( + HydrawiseSensor( + coordinator, + description, + controller, + zone_id=zone.id, + sensor_id=sensor.id, + ) + for zone in controller.zones + for sensor in controller.sensors + for description in FLOW_ZONE_SENSORS + if "flow meter" in sensor.model.name.lower() + ) + async_add_entities(entities) class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - zone: Zone + entity_description: HydrawiseSensorEntityDescription + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit_of_measurement of the sensor.""" + if self.entity_description.device_class != SensorDeviceClass.VOLUME: + return self.entity_description.native_unit_of_measurement + return ( + UnitOfVolume.GALLONS + if self.coordinator.data.user.units.units_name == "imperial" + else UnitOfVolume.LITERS + ) + + @property + def icon(self) -> str | None: + """Icon of the entity based on the value.""" + if ( + self.entity_description.key in FLOW_MEASUREMENT_KEYS + and round(self.state, 2) == 0.0 + ): + return "mdi:water-outline" + return None def _update_attrs(self) -> None: """Update state attributes.""" - if self.entity_description.key == "watering_time": - if (current_run := self.zone.scheduled_runs.current_run) is not None: - self._attr_native_value = int( - current_run.remaining_time.total_seconds() / 60 - ) - else: - self._attr_native_value = 0 - elif self.entity_description.key == "next_cycle": - if (next_run := self.zone.scheduled_runs.next_run) is not None: - self._attr_native_value = dt_util.as_utc(next_run.start_time) - else: - self._attr_native_value = datetime.max.replace(tzinfo=dt_util.UTC) + self._attr_native_value = self.entity_description.value_fn(self) diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index ee5cc0a541c..1bc5525c9d9 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -24,9 +24,21 @@ "binary_sensor": { "watering": { "name": "Watering" + }, + "rain_sensor": { + "name": "Rain sensor" } }, "sensor": { + "daily_total_water_use": { + "name": "Daily total water use" + }, + "daily_active_water_use": { + "name": "Daily active water use" + }, + "daily_inactive_water_use": { + "name": "Daily inactive water use" + }, "next_cycle": { "name": "Next cycle" }, diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index bceaa85eb73..001a8e399ee 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine +from dataclasses import dataclass from datetime import timedelta from typing import Any -from pydrawise.schema import Zone +from pydrawise import Hydrawise, Zone from homeassistant.components.switch import ( SwitchDeviceClass, @@ -21,16 +23,37 @@ from .const import DEFAULT_WATERING_TIME, DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity -SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class HydrawiseSwitchEntityDescription(SwitchEntityDescription): + """Describes Hydrawise binary sensor.""" + + turn_on_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[Hydrawise, Zone], Coroutine[Any, Any, None]] + value_fn: Callable[[Zone], bool] + + +SWITCH_TYPES: tuple[HydrawiseSwitchEntityDescription, ...] = ( + HydrawiseSwitchEntityDescription( key="auto_watering", translation_key="auto_watering", device_class=SwitchDeviceClass.SWITCH, + value_fn=lambda zone: zone.status.suspended_until is None, + turn_on_fn=lambda api, zone: api.resume_zone(zone), + turn_off_fn=lambda api, zone: api.suspend_zone( + zone, dt_util.now() + timedelta(days=365) + ), ), - SwitchEntityDescription( + HydrawiseSwitchEntityDescription( key="manual_watering", translation_key="manual_watering", device_class=SwitchDeviceClass.SWITCH, + value_fn=lambda zone: zone.scheduled_runs.current_run is not None, + turn_on_fn=lambda api, zone: api.start_zone( + zone, + custom_run_duration=int(DEFAULT_WATERING_TIME.total_seconds()), + ), + turn_off_fn=lambda api, zone: api.stop_zone(zone), ), ) @@ -47,7 +70,7 @@ async def async_setup_entry( config_entry.entry_id ] async_add_entities( - HydrawiseSwitch(coordinator, description, controller, zone) + HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id) for controller in coordinator.data.controllers.values() for zone in controller.zones for description in SWITCH_TYPES @@ -57,34 +80,21 @@ async def async_setup_entry( class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" + entity_description: HydrawiseSwitchEntityDescription zone: Zone async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self.entity_description.key == "manual_watering": - await self.coordinator.api.start_zone( - self.zone, - custom_run_duration=int(DEFAULT_WATERING_TIME.total_seconds()), - ) - elif self.entity_description.key == "auto_watering": - await self.coordinator.api.resume_zone(self.zone) + await self.entity_description.turn_on_fn(self.coordinator.api, self.zone) self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self.entity_description.key == "manual_watering": - await self.coordinator.api.stop_zone(self.zone) - elif self.entity_description.key == "auto_watering": - await self.coordinator.api.suspend_zone( - self.zone, dt_util.now() + timedelta(days=365) - ) + await self.entity_description.turn_off_fn(self.coordinator.api, self.zone) self._attr_is_on = False self.async_write_ha_state() def _update_attrs(self) -> None: """Update state attributes.""" - if self.entity_description.key == "manual_watering": - self._attr_is_on = self.zone.scheduled_runs.current_run is not None - elif self.entity_description.key == "auto_watering": - self._attr_is_on = self.zone.status.suspended_until is None + self._attr_is_on = self.entity_description.value_fn(self.zone) diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index 6ebd219f6ec..95c62b87a19 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -3,21 +3,18 @@ from __future__ import annotations import asyncio -import logging from pyialarm import IAlarm -from homeassistant.components.alarm_control_panel import SCAN_INTERVAL 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.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DATA_COORDINATOR, DOMAIN, IALARM_TO_HASS +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import IAlarmDataUpdateCoordinator PLATFORMS = [Platform.ALARM_CONTROL_PANEL] -_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -52,36 +49,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching iAlarm data.""" - - def __init__(self, hass: HomeAssistant, ialarm: IAlarm, mac: str) -> None: - """Initialize global iAlarm data updater.""" - self.ialarm = ialarm - self.state: str | None = None - self.host: str = ialarm.host - self.mac = mac - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - def _update_data(self) -> None: - """Fetch data from iAlarm via sync functions.""" - status = self.ialarm.get_status() - _LOGGER.debug("iAlarm status: %s", status) - - self.state = IALARM_TO_HASS.get(status) - - async def _async_update_data(self) -> None: - """Fetch data from iAlarm.""" - try: - async with asyncio.timeout(10): - await self.hass.async_add_executor_job(self._update_data) - except ConnectionError as error: - raise UpdateFailed(error) from error diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index 44e676fc32e..912f04a1d1e 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -12,8 +12,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import IAlarmDataUpdateCoordinator from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import IAlarmDataUpdateCoordinator async def async_setup_entry( @@ -37,6 +37,7 @@ class IAlarmPanel( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_code_arm_required = False def __init__(self, coordinator: IAlarmDataUpdateCoordinator) -> None: """Create the entity with a DataUpdateCoordinator.""" diff --git a/homeassistant/components/ialarm/config_flow.py b/homeassistant/components/ialarm/config_flow.py index 6aef66922b4..08cb9868357 100644 --- a/homeassistant/components/ialarm/config_flow.py +++ b/homeassistant/components/ialarm/config_flow.py @@ -48,7 +48,7 @@ class IAlarmConfigFlow(ConfigFlow, domain=DOMAIN): mac = await _get_device_mac(self.hass, host, port) except ConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/ialarm/coordinator.py b/homeassistant/components/ialarm/coordinator.py new file mode 100644 index 00000000000..2aec99c98c4 --- /dev/null +++ b/homeassistant/components/ialarm/coordinator.py @@ -0,0 +1,49 @@ +"""Coordinator for the iAlarm integration.""" + +from __future__ import annotations + +import asyncio +import logging + +from pyialarm import IAlarm + +from homeassistant.components.alarm_control_panel import SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, IALARM_TO_HASS + +_LOGGER = logging.getLogger(__name__) + + +class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching iAlarm data.""" + + def __init__(self, hass: HomeAssistant, ialarm: IAlarm, mac: str) -> None: + """Initialize global iAlarm data updater.""" + self.ialarm = ialarm + self.state: str | None = None + self.host: str = ialarm.host + self.mac = mac + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + def _update_data(self) -> None: + """Fetch data from iAlarm via sync functions.""" + status = self.ialarm.get_status() + _LOGGER.debug("iAlarm status: %s", status) + + self.state = IALARM_TO_HASS.get(status) + + async def _async_update_data(self) -> None: + """Fetch data from iAlarm.""" + try: + async with asyncio.timeout(10): + await self.hass.async_add_executor_job(self._update_data) + except ConnectionError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 33697dfb2cc..fd03168714d 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate import httpx from iaqualink.client import AqualinkClient @@ -39,9 +39,6 @@ from homeassistant.helpers.event import async_track_time_interval from .const import DOMAIN, UPDATE_INTERVAL -_AqualinkEntityT = TypeVar("_AqualinkEntityT", bound="AqualinkEntity") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) ATTR_CONFIG = "config" @@ -182,7 +179,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, platforms_to_unload) -def refresh_system( +def refresh_system[_AqualinkEntityT: AqualinkEntity, **_P]( 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/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 868b5a32c67..8ed3026e72e 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -87,7 +87,7 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction: """Return the current HVAC action.""" - state = AqualinkState(self.dev._heater.state) + state = AqualinkState(self.dev._heater.state) # noqa: SLF001 if state == AqualinkState.ON: return HVACAction.HEATING if state == AqualinkState.ENABLED: diff --git a/homeassistant/components/ibeacon/__init__.py b/homeassistant/components/ibeacon/__init__.py index 0e89ee3bbcd..14d5bbca17f 100644 --- a/homeassistant/components/ibeacon/__init__.py +++ b/homeassistant/components/ibeacon/__init__.py @@ -4,15 +4,20 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry, async_get +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN, PLATFORMS from .coordinator import IBeaconCoordinator +type IBeaconConfigEntry = ConfigEntry[IBeaconCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: IBeaconConfigEntry) -> bool: """Set up Bluetooth LE Tracker from a config entry.""" - coordinator = hass.data[DOMAIN] = IBeaconCoordinator(hass, entry, async_get(hass)) + entry.runtime_data = coordinator = IBeaconCoordinator( + hass, entry, dr.async_get(hass) + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await coordinator.async_start() return True @@ -20,16 +25,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: IBeaconConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove iBeacon config entry from a device.""" - coordinator: IBeaconCoordinator = hass.data[DOMAIN] + coordinator = config_entry.runtime_data return not any( identifier for identifier in device_entry.identifiers diff --git a/homeassistant/components/ibeacon/device_tracker.py b/homeassistant/components/ibeacon/device_tracker.py index 8d24d7f0aa9..d002cb10f44 100644 --- a/homeassistant/components/ibeacon/device_tracker.py +++ b/homeassistant/components/ibeacon/device_tracker.py @@ -6,22 +6,24 @@ from ibeacon_ble import iBeaconAdvertisement from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, SIGNAL_IBEACON_DEVICE_NEW +from . import IBeaconConfigEntry +from .const import SIGNAL_IBEACON_DEVICE_NEW from .coordinator import IBeaconCoordinator from .entity import IBeaconEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: IBeaconConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for iBeacon Tracker component.""" - coordinator: IBeaconCoordinator = hass.data[DOMAIN] + coordinator = entry.runtime_data @callback def _async_device_new( diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index 3b7ba3d5dbf..f73aef4b803 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -13,13 +13,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, SIGNAL_IBEACON_DEVICE_NEW +from . import IBeaconConfigEntry +from .const import SIGNAL_IBEACON_DEVICE_NEW from .coordinator import IBeaconCoordinator from .entity import IBeaconEntity @@ -67,10 +67,12 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: IBeaconConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for iBeacon Tracker component.""" - coordinator: IBeaconCoordinator = hass.data[DOMAIN] + coordinator = entry.runtime_data @callback def _async_device_new( diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 015726fbf73..2b3d1a22f21 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -169,7 +169,7 @@ class IcloudAccount: api_devices = {} try: api_devices = self.api.devices - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.error("Unknown iCloud error: %s", err) self._fetch_interval = 2 dispatcher_send(self.hass, self.signal_device_update) diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 2edd04b1d59..1ea9b3b2f00 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -6,10 +6,10 @@ import logging from attr import dataclass from bleak.exc import BleakError -from idasen_ha import Desk from idasen_ha.errors import AuthFailedError from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_NAME, @@ -21,70 +21,15 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import IdasenDeskCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage updates for the Idasen Desk.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - name: str, - address: str, - ) -> None: - """Init IdasenDeskCoordinator.""" - - super().__init__(hass, logger, name=name) - self._address = address - self._expected_connected = False - self._connection_lost = False - - self.desk = Desk(self.async_set_updated_data) - - async def async_connect(self) -> bool: - """Connect to desk.""" - _LOGGER.debug("Trying to connect %s", self._address) - ble_device = bluetooth.async_ble_device_from_address( - self.hass, self._address, connectable=True - ) - if ble_device is None: - return False - self._expected_connected = True - await self.desk.connect(ble_device) - return True - - async def async_disconnect(self) -> None: - """Disconnect from desk.""" - _LOGGER.debug("Disconnecting from %s", self._address) - self._expected_connected = False - self._connection_lost = False - await self.desk.disconnect() - - @callback - def async_set_updated_data(self, data: int | None) -> None: - """Handle data update.""" - if self._expected_connected: - if not self.desk.is_connected: - _LOGGER.debug("Desk disconnected. Reconnecting") - self._connection_lost = True - self.hass.async_create_task(self.async_connect(), eager_start=False) - elif self._connection_lost: - _LOGGER.info("Reconnected to desk") - self._connection_lost = False - elif self.desk.is_connected: - _LOGGER.warning("Desk is connected but should not be. Disconnecting") - self.hass.async_create_task(self.desk.disconnect()) - return super().async_set_updated_data(data) - - @dataclass class DeskData: """Data for the Idasen Desk integration.""" @@ -116,6 +61,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + @callback + def _async_bluetooth_callback( + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a Bluetooth callback to ensure that a new BLEDevice is fetched.""" + _LOGGER.debug("Bluetooth callback triggered") + hass.async_create_task(coordinator.async_ensure_connection_state()) + + entry.async_on_unload( + bluetooth.async_register_callback( + hass, + _async_bluetooth_callback, + BluetoothCallbackMatcher({ADDRESS: address}), + bluetooth.BluetoothScanningMode.ACTIVE, + ) + ) + async def _async_stop(event: Event) -> None: """Close the connection.""" await coordinator.async_disconnect() diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index 8d6af14f043..782d4988a3c 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -64,7 +64,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN): desk = Desk(None, monitor_height=False) try: - await desk.connect(discovery_info.device, auto_reconnect=False) + await desk.connect(discovery_info.device, retry=False) except AuthFailedError: errors["base"] = "auth_failed" except TimeoutError: @@ -72,7 +72,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN): except BleakError: _LOGGER.exception("Unexpected Bluetooth error") errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py new file mode 100644 index 00000000000..5bdf1b37331 --- /dev/null +++ b/homeassistant/components/idasen_desk/coordinator.py @@ -0,0 +1,83 @@ +"""Coordinator for the IKEA Idasen Desk integration.""" + +from __future__ import annotations + +import asyncio +import logging + +from idasen_ha import Desk + +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): + """Class to manage updates for the Idasen Desk.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + address: str, + ) -> None: + """Init IdasenDeskCoordinator.""" + + super().__init__(hass, logger, name=name) + self._address = address + self._expected_connected = False + self._connection_lost = False + self._disconnect_lock = asyncio.Lock() + + self.desk = Desk(self.async_set_updated_data) + + async def async_connect(self) -> bool: + """Connect to desk.""" + _LOGGER.debug("Trying to connect %s", self._address) + ble_device = bluetooth.async_ble_device_from_address( + self.hass, self._address, connectable=True + ) + if ble_device is None: + _LOGGER.debug("No BLEDevice for %s", self._address) + return False + self._expected_connected = True + await self.desk.connect(ble_device) + return True + + async def async_disconnect(self) -> None: + """Disconnect from desk.""" + _LOGGER.debug("Disconnecting from %s", self._address) + self._expected_connected = False + self._connection_lost = False + await self.desk.disconnect() + + async def async_ensure_connection_state(self) -> None: + """Check if the expected connection state matches the current state. + + If the expected and current state don't match, calls connect/disconnect + as needed. + """ + if self._expected_connected: + if not self.desk.is_connected: + _LOGGER.debug("Desk disconnected. Reconnecting") + self._connection_lost = True + await self.async_connect() + elif self._connection_lost: + _LOGGER.info("Reconnected to desk") + self._connection_lost = False + elif self.desk.is_connected: + if self._disconnect_lock.locked(): + _LOGGER.debug("Already disconnecting") + return + async with self._disconnect_lock: + _LOGGER.debug("Desk is connected but should not be. Disconnecting") + await self.desk.disconnect() + + @callback + def async_set_updated_data(self, data: int | None) -> None: + """Handle data update.""" + self.hass.async_create_task(self.async_ensure_connection_state()) + return super().async_set_updated_data(data) diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 84e97534d7c..a912fabfa54 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["idasen-ha==2.5.1"] + "requirements": ["idasen-ha==2.5.3"] } diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 19763e65fa5..69e2b0f12db 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -160,7 +160,7 @@ class ImageUploadView(HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Handle upload.""" # Increase max payload - request._client_max_size = MAX_SIZE # pylint: disable=protected-access + request._client_max_size = MAX_SIZE # noqa: SLF001 data = await request.post() item = await request.app[KEY_HASS].data[DOMAIN].async_create_item(data) @@ -191,31 +191,33 @@ class ImageServeView(HomeAssistantView): filename: str, ) -> web.FileResponse: """Serve image.""" - try: - width, height = _validate_size_from_filename(filename) - except (ValueError, IndexError) as err: - raise web.HTTPBadRequest from err - image_info = self.image_collection.data.get(image_id) - if image_info is None: raise web.HTTPNotFound - hass = request.app[KEY_HASS] - target_file = self.image_folder / image_id / f"{width}x{height}" + if filename == "original": + target_file = self.image_folder / image_id / filename + else: + try: + width, height = _validate_size_from_filename(filename) + except (ValueError, IndexError) as err: + raise web.HTTPBadRequest from err - if not target_file.is_file(): - async with self.transform_lock: - # Another check in case another request already - # finished it while waiting - if not target_file.is_file(): - await hass.async_add_executor_job( - _generate_thumbnail, - self.image_folder / image_id / "original", - image_info["content_type"], - target_file, - (width, height), - ) + hass = request.app[KEY_HASS] + target_file = self.image_folder / image_id / f"{width}x{height}" + + if not target_file.is_file(): + async with self.transform_lock: + # Another check in case another request already + # finished it while waiting + if not target_file.is_file(): + await hass.async_add_executor_job( + _generate_thumbnail, + self.image_folder / image_id / "original", + image_info["content_type"], + target_file, + (width, height), + ) return web.FileResponse( target_file, diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index f39a78925c1..f62edf1451f 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING from aioimaplib import IMAP4_SSL, AioImapException, Response import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import ( HomeAssistant, @@ -29,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_ENABLE_PUSH, DOMAIN from .coordinator import ( + ImapDataUpdateCoordinator, ImapMessage, ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator, @@ -65,17 +65,18 @@ SERVICE_MOVE_SCHEMA = _SERVICE_UID_SCHEMA.extend( SERVICE_DELETE_SCHEMA = _SERVICE_UID_SCHEMA SERVICE_FETCH_TEXT_SCHEMA = _SERVICE_UID_SCHEMA +type ImapConfigEntry = ConfigEntry[ImapDataUpdateCoordinator] + async def async_get_imap_client(hass: HomeAssistant, entry_id: str) -> IMAP4_SSL: """Get IMAP client and connect.""" - if hass.data[DOMAIN].get(entry_id) is None: + if (entry := hass.config_entries.async_get_entry(entry_id)) is None or ( + entry.state is not ConfigEntryState.LOADED + ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_entry", ) - entry = hass.config_entries.async_get_entry(entry_id) - if TYPE_CHECKING: - assert entry is not None try: client = await connect_to_server(entry.data) except InvalidAuth as exc: @@ -235,7 +236,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ImapConfigEntry) -> bool: """Set up imap from a config entry.""" try: imap_client: IMAP4_SSL = await connect_to_server(dict(entry.data)) @@ -255,12 +256,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: coordinator_class = ImapPollingDataUpdateCoordinator - coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( - coordinator_class(hass, imap_client, entry) - ) + coordinator: ImapDataUpdateCoordinator = coordinator_class(hass, imap_client, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown) @@ -271,11 +270,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ImapConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: ( - ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator - ) = hass.data[DOMAIN].pop(entry.entry_id) + coordinator = entry.runtime_data await coordinator.shutdown() return unload_ok diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index c0123b89ee4..a9d0fdfbd48 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -195,13 +195,13 @@ class ImapMessage: ): message_untyped_text = str(part.get_payload()) - if message_text is not None: + if message_text is not None and message_text.strip(): return message_text - if message_html is not None: + if message_html: return message_html - if message_untyped_text is not None: + if message_untyped_text: return message_untyped_text return str(self.email_message.get_payload()) diff --git a/homeassistant/components/imap/diagnostics.py b/homeassistant/components/imap/diagnostics.py index 8afe3e327ba..d402053520a 100644 --- a/homeassistant/components/imap/diagnostics.py +++ b/homeassistant/components/imap/diagnostics.py @@ -5,18 +5,16 @@ 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_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN -from .coordinator import ImapDataUpdateCoordinator +from . import ImapConfigEntry REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ImapConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return _async_get_diagnostics(hass, entry) @@ -25,11 +23,11 @@ async def async_get_config_entry_diagnostics( @callback def _async_get_diagnostics( hass: HomeAssistant, - entry: ConfigEntry, + entry: ImapConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" redacted_config = async_redact_data(entry.data, REDACT_CONFIG) - coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "config": redacted_config, diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 3c35d00f714..b058a3d50f4 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/imap", "iot_class": "cloud_push", "loggers": ["aioimaplib"], - "requirements": ["aioimaplib==1.0.1"] + "requirements": ["aioimaplib==1.1.0"] } diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 0a9070d7a5e..625af9ce6a1 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -7,15 +7,15 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME 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 ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator +from . import ImapConfigEntry from .const import DOMAIN +from .coordinator import ImapDataUpdateCoordinator IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( key="imap_mail_count", @@ -27,27 +27,22 @@ IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: ImapConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Imap sensor.""" - coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( - hass.data[DOMAIN][entry.entry_id] - ) + coordinator = entry.runtime_data async_add_entities([ImapSensor(coordinator, IMAP_MAIL_COUNT_DESCRIPTION)]) -class ImapSensor( - CoordinatorEntity[ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator], - SensorEntity, -): +class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity): """Representation of an IMAP sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator, + coordinator: ImapDataUpdateCoordinator, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index 54511e76020..caf4e058e06 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -22,7 +22,7 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -ImgwPibConfigEntry = ConfigEntry["ImgwPibData"] +type ImgwPibConfigEntry = ConfigEntry[ImgwPibData] @dataclass diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index 7ad72efca80..bf8608ae21b 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -15,6 +15,12 @@ } }, "sensor": { + "flood_warning_level": { + "default": "mdi:alert-outline" + }, + "flood_alarm_level": { + "default": "mdi:alert" + }, "water_level": { "default": "mdi:waves" }, diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 2b04482e2fb..08946a802f1 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "requirements": ["imgw_pib==1.0.1"] + "quality_scale": "platinum", + "requirements": ["imgw_pib==1.0.5"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index d3f2162c056..f000222b31b 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfLength, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfLength, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -33,6 +33,26 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="flood_alarm_level", + translation_key="flood_alarm_level", + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=SensorDeviceClass.DISTANCE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + entity_registry_enabled_default=False, + value=lambda data: data.flood_alarm_level.value, + ), + ImgwPibSensorEntityDescription( + key="flood_warning_level", + translation_key="flood_warning_level", + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=SensorDeviceClass.DISTANCE, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + entity_registry_enabled_default=False, + value=lambda data: data.flood_warning_level.value, + ), ImgwPibSensorEntityDescription( key="water_level", translation_key="water_level", diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index b4246861d4c..6bc337d5720 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -26,6 +26,12 @@ } }, "sensor": { + "flood_alarm_level": { + "name": "Flood alarm level" + }, + "flood_warning_level": { + "name": "Flood warning level" + }, "water_level": { "name": "Water level" }, diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 370b244dac2..f38f4830ace 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -from typing import Any, TypeVar +from typing import Any from bleak import BleakError from improv_ble_client import ( @@ -30,8 +30,6 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -_T = TypeVar("_T") - STEP_PROVISION_SCHEMA = vol.Schema( { vol.Required("ssid"): str, @@ -392,7 +390,7 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_progress_done(next_step_id="provision") @staticmethod - async def _try_call(func: Coroutine[Any, Any, _T]) -> _T: + async def _try_call[_T](func: Coroutine[Any, Any, _T]) -> _T: """Call the library and abort flow on common errors.""" try: return await func diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 3311bda23ee..39e471b7614 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -2,24 +2,21 @@ from __future__ import annotations -import logging - from aiohttp import ClientResponseError -from incomfortclient import Gateway as InComfortGateway +from incomfortclient import IncomfortError, InvalidHeaterList import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "incomfort" +from .const import DOMAIN +from .coordinator import InComfortDataCoordinator, async_connect_gateway +from .errors import InConfortTimeout, InConfortUnknownError, NoHeaters, NotFound CONFIG_SCHEMA = vol.Schema( { @@ -41,63 +38,84 @@ PLATFORMS = ( Platform.CLIMATE, ) +INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" + +type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] + + +async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: + """Import config entry from configuration.yaml.""" + if not hass.config_entries.async_entries(DOMAIN): + # Start import flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if result["type"] == FlowResultType.ABORT: + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2025.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + return + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: """Create an Intergas InComfort/Intouch system.""" - incomfort_data = hass.data[DOMAIN] = {} - - credentials = dict(hass_config[DOMAIN]) - hostname = credentials.pop(CONF_HOST) - - client = incomfort_data["client"] = InComfortGateway( - hostname, **credentials, session=async_get_clientsession(hass) - ) - - try: - heaters = incomfort_data["heaters"] = list(await client.heaters()) - except ClientResponseError as err: - _LOGGER.warning("Setup failed, check your configuration, message is: %s", err) - return False - - for heater in heaters: - await heater.update() - - for platform in PLATFORMS: - hass.async_create_task( - async_load_platform(hass, platform, DOMAIN, {}, hass_config) - ) - + if config := hass_config.get(DOMAIN): + hass.async_create_task(_async_import(hass, config)) return True -class IncomfortEntity(Entity): - """Base class for all InComfort entities.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + try: + data = await async_connect_gateway(hass, dict(entry.data)) + for heater in data.heaters: + await heater.update() + except InvalidHeaterList as exc: + raise NoHeaters from exc + except IncomfortError as exc: + if isinstance(exc.message, ClientResponseError): + if exc.message.status == 401: + raise ConfigEntryAuthFailed("Incorrect credentials") from exc + if exc.message.status == 404: + raise NotFound from exc + raise InConfortUnknownError from exc + except TimeoutError as exc: + raise InConfortTimeout from exc - def __init__(self) -> None: - """Initialize the class.""" - self._name: str | None = None - self._unique_id: str | None = None + coordinator = InComfortDataCoordinator(hass, data) + entry.runtime_data = coordinator + await coordinator.async_config_entry_first_refresh() - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str | None: - """Return the name of the sensor.""" - return self._name + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True -class IncomfortChild(IncomfortEntity): - """Base class for all InComfort entities (excluding the boiler).""" - - _attr_should_poll = False - - async def async_added_to_hass(self) -> None: - """Set up a listener when this entity is added to HA.""" - self.async_on_remove(async_dispatcher_connect(self.hass, DOMAIN, self._refresh)) - - @callback - def _refresh(self) -> None: - self.async_schedule_update_ha_state(force_refresh=True) +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 59096038d6c..a94e1fac504 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -2,55 +2,103 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any +from incomfortclient import Heater as InComfortHeater + from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, IncomfortChild +from . import InComfortConfigEntry +from .coordinator import InComfortDataCoordinator +from .entity import IncomfortBoilerEntity -async def async_setup_platform( +@dataclass(frozen=True, kw_only=True) +class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Incomfort binary sensor entity.""" + + value_key: str + extra_state_attributes_fn: Callable[[dict[str, Any]], dict[str, Any]] | None = None + + +SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = ( + IncomfortBinarySensorEntityDescription( + key="failed", + translation_key="fault", + device_class=BinarySensorDeviceClass.PROBLEM, + value_key="is_failed", + extra_state_attributes_fn=lambda status: { + "fault_code": status["fault_code"] or "none", + }, + ), + IncomfortBinarySensorEntityDescription( + key="is_pumping", + translation_key="is_pumping", + device_class=BinarySensorDeviceClass.RUNNING, + value_key="is_pumping", + ), + IncomfortBinarySensorEntityDescription( + key="is_burning", + translation_key="is_burning", + device_class=BinarySensorDeviceClass.RUNNING, + value_key="is_burning", + ), + IncomfortBinarySensorEntityDescription( + key="is_tapping", + translation_key="is_tapping", + device_class=BinarySensorDeviceClass.RUNNING, + value_key="is_tapping", + ), +) + + +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: InComfortConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/InTouch binary_sensor device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - - async_add_entities([IncomfortFailed(client, h) for h in heaters]) + """Set up an InComfort/InTouch binary_sensor entity.""" + incomfort_coordinator = entry.runtime_data + heaters = incomfort_coordinator.data.heaters + async_add_entities( + IncomfortBinarySensor(incomfort_coordinator, h, description) + for h in heaters + for description in SENSOR_TYPES + ) -class IncomfortFailed(IncomfortChild, BinarySensorEntity): - """Representation of an InComfort Failed sensor.""" +class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity): + """Representation of an InComfort binary sensor.""" - def __init__(self, client, heater) -> None: + entity_description: IncomfortBinarySensorEntityDescription + + def __init__( + self, + coordinator: InComfortDataCoordinator, + heater: InComfortHeater, + description: IncomfortBinarySensorEntityDescription, + ) -> None: """Initialize the binary sensor.""" - super().__init__() - - self._unique_id = f"{heater.serial_no}_failed" - self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{DOMAIN}_failed" - self._name = "Boiler Fault" - - self._client = client - self._heater = heater + super().__init__(coordinator, heater) + self.entity_description = description + self._attr_unique_id = f"{heater.serial_no}_{description.key}" @property def is_on(self) -> bool: """Return the status of the sensor.""" - return self._heater.status["is_failed"] + return self._heater.status[self.entity_description.value_key] @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the device state attributes.""" - return {"fault_code": self._heater.status["fault_code"]} + if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None: + return None + return attributes_fn(self._heater.status) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index cc61e179aa4..dc08ce8a6c0 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -4,58 +4,69 @@ from __future__ import annotations from typing import Any +from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom + from homeassistant.components.climate import ( - DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) 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.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, IncomfortChild +from . import InComfortConfigEntry +from .const import DOMAIN +from .coordinator import InComfortDataCoordinator +from .entity import IncomfortEntity -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: InComfortConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/InTouch climate device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - + """Set up InComfort/InTouch climate devices.""" + incomfort_coordinator = entry.runtime_data + heaters = incomfort_coordinator.data.heaters async_add_entities( - [InComfortClimate(client, h, r) for h in heaters for r in h.rooms] + InComfortClimate(incomfort_coordinator, h, r) for h in heaters for r in h.rooms ) -class InComfortClimate(IncomfortChild, ClimateEntity): +class InComfortClimate(IncomfortEntity, ClimateEntity): """Representation of an InComfort/InTouch climate device.""" + _attr_min_temp = 5.0 + _attr_max_temp = 30.0 + _attr_name = None _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS _enable_turn_on_off_backwards_compatibility = False - def __init__(self, client, heater, room) -> None: + def __init__( + self, + coordinator: InComfortDataCoordinator, + heater: InComfortHeater, + room: InComfortRoom, + ) -> None: """Initialize the climate device.""" - super().__init__() + super().__init__(coordinator) - self._unique_id = f"{heater.serial_no}_{room.room_no}" - self.entity_id = f"{CLIMATE_DOMAIN}.{DOMAIN}_{room.room_no}" - self._name = f"Thermostat {room.room_no}" - - self._client = client + self._heater = heater self._room = room + self._attr_unique_id = f"{heater.serial_no}_{room.room_no}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="Intergas", + name=f"Thermostat {room.room_no}", + ) + @property def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" @@ -66,25 +77,27 @@ class InComfortClimate(IncomfortChild, ClimateEntity): """Return the current temperature.""" return self._room.room_temp + @property + def hvac_action(self) -> HVACAction | None: + """Return the actual current HVAC action.""" + if self._heater.is_burning and self._heater.is_pumping: + return HVACAction.HEATING + return HVACAction.IDLE + @property def target_temperature(self) -> float | None: - """Return the temperature we try to reach.""" - return self._room.setpoint + """Return the (override)temperature we try to reach. - @property - def min_temp(self) -> float: - """Return max valid temperature that can be set.""" - return 5.0 - - @property - def max_temp(self) -> float: - """Return max valid temperature that can be set.""" - return 30.0 + As we set the override, we report back the override. The actual set point is + is returned at a later time. + """ + return self._room.override async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature for this zone.""" temperature = kwargs.get(ATTR_TEMPERATURE) await self._room.set_override(temperature) + await self.coordinator.async_refresh() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py new file mode 100644 index 00000000000..e905f0d743d --- /dev/null +++ b/homeassistant/components/incomfort/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow support for Intergas InComfort integration.""" + +from typing import Any + +from aiohttp import ClientResponseError +from incomfortclient import IncomfortError, InvalidHeaterList +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN +from .coordinator import async_connect_gateway + +TITLE = "Intergas InComfort/Intouch Lan2RF gateway" + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, autocomplete="admin") + ), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + +ERROR_STATUS_MAPPING: dict[int, tuple[str, str]] = { + 401: (CONF_PASSWORD, "auth_error"), + 404: ("base", "not_found"), +} + + +async def async_try_connect_gateway( + hass: HomeAssistant, config: dict[str, Any] +) -> dict[str, str] | None: + """Try to connect to the Lan2RF gateway.""" + try: + await async_connect_gateway(hass, config) + except InvalidHeaterList: + return {"base": "no_heaters"} + except IncomfortError as exc: + if isinstance(exc.message, ClientResponseError): + scope, error = ERROR_STATUS_MAPPING.get( + exc.message.status, ("base", "unknown") + ) + return {scope: error} + return {"base": "unknown"} + except TimeoutError: + return {"base": "timeout_error"} + except Exception: # noqa: BLE001 + return {"base": "unknown"} + + return None + + +class InComfortConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow to set up an Intergas InComfort boyler and thermostats.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] | None = None + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + if ( + errors := await async_try_connect_gateway(self.hass, user_input) + ) is None: + return self.async_create_entry(title=TITLE, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import `incomfort` config entry from configuration.yaml.""" + errors: dict[str, str] | None = None + if (errors := await async_try_connect_gateway(self.hass, import_data)) is None: + return self.async_create_entry(title=TITLE, data=import_data) + reason = next(iter(errors.items()))[1] + return self.async_abort(reason=reason) diff --git a/homeassistant/components/incomfort/const.py b/homeassistant/components/incomfort/const.py new file mode 100644 index 00000000000..721dd8591b0 --- /dev/null +++ b/homeassistant/components/incomfort/const.py @@ -0,0 +1,3 @@ +"""Constants for Intergas InComfort integration.""" + +DOMAIN = "incomfort" diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py new file mode 100644 index 00000000000..a5c8da0c208 --- /dev/null +++ b/homeassistant/components/incomfort/coordinator.py @@ -0,0 +1,75 @@ +"""Datacoordinator for InComfort integration.""" + +from dataclasses import dataclass, field +from datetime import timedelta +import logging +from typing import Any + +from aiohttp import ClientResponseError +from incomfortclient import ( + Gateway as InComfortGateway, + Heater as InComfortHeater, + IncomfortError, +) + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = 30 + + +@dataclass +class InComfortData: + """Keep the Intergas InComfort entry data.""" + + client: InComfortGateway + heaters: list[InComfortHeater] = field(default_factory=list) + + +async def async_connect_gateway( + hass: HomeAssistant, + entry_data: dict[str, Any], +) -> InComfortData: + """Validate the configuration.""" + credentials = dict(entry_data) + hostname = credentials.pop(CONF_HOST) + + client = InComfortGateway( + hostname, **credentials, session=async_get_clientsession(hass) + ) + heaters = await client.heaters() + + return InComfortData(client=client, heaters=heaters) + + +class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): + """Data coordinator for InComfort entities.""" + + def __init__(self, hass: HomeAssistant, incomfort_data: InComfortData) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="InComfort datacoordinator", + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + self.incomfort_data = incomfort_data + + async def _async_update_data(self) -> InComfortData: + """Fetch data from API endpoint.""" + try: + for heater in self.incomfort_data.heaters: + await heater.update() + except TimeoutError as exc: + raise UpdateFailed from exc + except IncomfortError as exc: + if isinstance(exc.message, ClientResponseError): + if exc.message.status == 401: + raise ConfigEntryError("Incorrect credentials") from exc + raise UpdateFailed from exc + return self.incomfort_data diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py new file mode 100644 index 00000000000..33037a78edf --- /dev/null +++ b/homeassistant/components/incomfort/entity.py @@ -0,0 +1,29 @@ +"""Common entity classes for InComfort integration.""" + +from incomfortclient import Heater + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import InComfortDataCoordinator + + +class IncomfortEntity(CoordinatorEntity[InComfortDataCoordinator]): + """Base class for all InComfort entities.""" + + _attr_has_entity_name = True + + +class IncomfortBoilerEntity(IncomfortEntity): + """Base class for all InComfort boiler entities.""" + + def __init__(self, coordinator: InComfortDataCoordinator, heater: Heater) -> None: + """Initialize the boiler entity.""" + super().__init__(coordinator) + self._heater = heater + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, heater.serial_no)}, + manufacturer="Intergas", + name="Boiler", + ) diff --git a/homeassistant/components/incomfort/errors.py b/homeassistant/components/incomfort/errors.py new file mode 100644 index 00000000000..1023ce70eec --- /dev/null +++ b/homeassistant/components/incomfort/errors.py @@ -0,0 +1,32 @@ +"""Exceptions raised by Intergas InComfort integration.""" + +from homeassistant.core import DOMAIN +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError + + +class NotFound(HomeAssistantError): + """Raise exception if no Lan2RF Gateway was found.""" + + translation_domain = DOMAIN + translation_key = "not_found" + + +class NoHeaters(ConfigEntryNotReady): + """Raise exception if no heaters are found.""" + + translation_domain = DOMAIN + translation_key = "no_heaters" + + +class InConfortTimeout(ConfigEntryNotReady): + """Raise exception if no heaters are found.""" + + translation_domain = DOMAIN + translation_key = "timeout_error" + + +class InConfortUnknownError(ConfigEntryNotReady): + """Raise exception if no heaters are found.""" + + translation_domain = DOMAIN + translation_key = "unknown" diff --git a/homeassistant/components/incomfort/icons.json b/homeassistant/components/incomfort/icons.json new file mode 100644 index 00000000000..6e33ac75eee --- /dev/null +++ b/homeassistant/components/incomfort/icons.json @@ -0,0 +1,65 @@ +{ + "entity": { + "binary_sensor": { + "is_burning": { + "state": { + "off": "mdi:fire-off", + "on": "mdi:fire" + } + }, + "is_pumping": { + "state": { + "off": "mdi:pump-off", + "on": "mdi:pump" + } + }, + "is_tapping": { + "state": { + "off": "mdi:water-pump-off", + "on": "mdi:water-pump" + } + } + }, + "water_heater": { + "boiler": { + "state": { + "unknown": "mdi:water-boiler-alert", + "opentherm": "mdi:radiator", + "boiler_ext": "mdi:water-boiler", + "frost": "mdi:snowflake-thermometer", + "central_heating_rf": "mdi:radiator", + "tapwater_int": "mdi:faucet", + "sensor_test": "mdi:thermometer-check", + "central_heating": "mdi:radiator", + "standby": "mdi:water-boiler-off", + "postrun_boyler": "mdi:water-boiler-auto", + "service": "mdi:progress-wrench", + "tapwater": "mdi:faucet", + "postrun_ch": "mdi:radiator-disabled", + "boiler_int": "mdi:water-boiler", + "buffer": "mdi:water-boiler-auto", + "sensor_fault_after_self_check_e0": "mdi:thermometer-alert", + "cv_temperature_too_high_e1": "mdi:thermometer-alert", + "s1_and_s2_interchanged_e2": "mdi:thermometer-alert", + "no_flame_signal_e4": "mdi:fire-alert", + "poor_flame_signal_e5": "mdi:fire-alert", + "flame_detection_fault_e6": "mdi:fire-alert", + "incorrect_fan_speed_e8": "mdi:water-boiler-alert", + "sensor_fault_s1_e10": "mdi:water-boiler-alert", + "sensor_fault_s1_e11": "mdi:water-boiler-alert", + "sensor_fault_s1_e12": "mdi:water-boiler-alert", + "sensor_fault_s1_e13": "mdi:water-boiler-alert", + "sensor_fault_s1_e14": "mdi:water-boiler-alert", + "sensor_fault_s2_e20": "mdi:water-boiler-alert", + "sensor_fault_s2_e21": "mdi:water-boiler-alert", + "sensor_fault_s2_e22": "mdi:water-boiler-alert", + "sensor_fault_s2_e23": "mdi:water-boiler-alert", + "sensor_fault_s2_e24": "mdi:water-boiler-alert", + "shortcut_outside_sensor_temperature_e27": "mdi:thermometer-alert", + "gas_valve_relay_faulty_e29": "mdi:water-boiler-alert", + "gas_valve_relay_faulty_e30": "mdi:water-boiler-alert" + } + } + } + } +} diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index e1c14533d8c..c0b536dabe5 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -1,9 +1,10 @@ { "domain": "incomfort", "name": "Intergas InComfort/Intouch Lan2RF gateway", - "codeowners": ["@zxdavb"], + "codeowners": ["@jbouwh"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], - "requirements": ["incomfort-client==0.5.0"] + "requirements": ["incomfort-client==0.6.2"] } diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 9106afacb26..e0d6740f1d4 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -5,104 +5,95 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +from incomfortclient import Heater as InComfortHeater + from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.helpers.typing import StateType -from . import DOMAIN, IncomfortChild - -INCOMFORT_HEATER_TEMP = "CV Temp" -INCOMFORT_PRESSURE = "CV Pressure" -INCOMFORT_TAP_TEMP = "Tap Temp" +from . import InComfortConfigEntry +from .coordinator import InComfortDataCoordinator +from .entity import IncomfortBoilerEntity -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" + value_key: str extra_key: str | None = None - # IncomfortSensor does not support UNDEFINED or None, - # restrict the type to str - name: str = "" SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( IncomfortSensorEntityDescription( - key="pressure", - name=INCOMFORT_PRESSURE, + key="cv_pressure", device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, + value_key="pressure", ), IncomfortSensorEntityDescription( - key="heater_temp", - name=INCOMFORT_HEATER_TEMP, + key="cv_temp", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_pumping", + value_key="heater_temp", ), IncomfortSensorEntityDescription( key="tap_temp", - name=INCOMFORT_TAP_TEMP, + translation_key="tap_temperature", device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, extra_key="is_tapping", + value_key="tap_temp", ), ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: InComfortConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/InTouch sensor device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - - entities = [ - IncomfortSensor(client, heater, description) + """Set up InComfort/InTouch sensor entities.""" + incomfort_coordinator = entry.runtime_data + heaters = incomfort_coordinator.data.heaters + async_add_entities( + IncomfortSensor(incomfort_coordinator, heater, description) for heater in heaters for description in SENSOR_TYPES - ] - - async_add_entities(entities) + ) -class IncomfortSensor(IncomfortChild, SensorEntity): +class IncomfortSensor(IncomfortBoilerEntity, SensorEntity): """Representation of an InComfort/InTouch sensor device.""" entity_description: IncomfortSensorEntityDescription def __init__( - self, client, heater, description: IncomfortSensorEntityDescription + self, + coordinator: InComfortDataCoordinator, + heater: InComfortHeater, + description: IncomfortSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__() + super().__init__(coordinator, heater) self.entity_description = description - - self._client = client - self._heater = heater - - self._unique_id = f"{heater.serial_no}_{slugify(description.name)}" - self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{slugify(description.name)}" - self._name = f"Boiler {description.name}" + self._attr_unique_id = f"{heater.serial_no}_{description.key}" @property - def native_value(self) -> str | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._heater.status[self.entity_description.key] + return self._heater.status[self.entity_description.value_key] @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json new file mode 100644 index 00000000000..a2bb874142b --- /dev/null +++ b/homeassistant/components/incomfort/strings.json @@ -0,0 +1,125 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.", + "username": "The username to log into the gateway. This is `admin` in most cases.", + "password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "auth_error": "Invalid credentials.", + "no_heaters": "No heaters found.", + "not_found": "No Lan2RF gateway found.", + "timeout_error": "Time out when connection to Lan2RF gateway.", + "unknown": "Unknown error when connection to Lan2RF gateway." + }, + "error": { + "auth_error": "[%key:component::incomfort::config::abort::auth_error%]", + "no_heaters": "[%key:component::incomfort::config::abort::no_heaters%]", + "not_found": "[%key:component::incomfort::config::abort::not_found%]", + "timeout_error": "[%key:component::incomfort::config::abort::timeout_error%]", + "unknown": "[%key:component::incomfort::config::abort::unknown%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_unknown": { + "title": "YAML import failed with unknown error", + "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_auth_error": { + "title": "YAML import failed due to an authentication error", + "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_no_heaters": { + "title": "YAML import failed because no heaters were found", + "description": "Configuring {integration_title} using YAML is being removed but no heaters were found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_not_found": { + "title": "YAML import failed because no gateway was found", + "description": "Configuring {integration_title} using YAML is being removed but no Lan2RF gateway was found while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_timeout_error": { + "title": "YAML import failed because of timeout issues", + "description": "Configuring {integration_title} using YAML is being removed but there was a timeout while connecting to your {integration_title} while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + } + }, + "entity": { + "binary_sensor": { + "fault": { + "name": "Fault", + "state_attributes": { + "fault_code": { + "state": { + "none": "None" + } + } + } + }, + "is_burning": { + "name": "Burner" + }, + "is_pumping": { + "name": "Pump" + }, + "is_tapping": { + "name": "Hot water tap" + } + }, + "sensor": { + "tap_temperature": { + "name": "Tap temperature" + } + }, + "water_heater": { + "boiler": { + "state": { + "unknown": "Unknown", + "opentherm": "OpenTherm", + "boiler_ext": "Boiler external", + "frost": "Frost protection", + "central_heating_rf": "Central heating rf", + "tapwater_int": "Tap water internal", + "sensor_test": "Sensor test", + "central_heating": "Central heating", + "standby": "Stand-by", + "postrun_boyler": "Post run boiler", + "service": "Service", + "tapwater": "Tap water", + "postrun_ch": "Post run central heating", + "boiler_int": "Boiler internal", + "buffer": "Buffer", + "sensor_fault_after_self_check_e0": "Sensor fault after self check", + "cv_temperature_too_high_e1": "Temperature too high", + "s1_and_s2_interchanged_e2": "S1 and S2 interchanged", + "no_flame_signal_e4": "No flame signal", + "poor_flame_signal_e5": "Poor flame signal", + "flame_detection_fault_e6": "Flame detection fault", + "incorrect_fan_speed_e8": "Incorrect fan speed", + "sensor_fault_s1_e10": "Sensor fault S1", + "sensor_fault_s1_e11": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s1_e10%]", + "sensor_fault_s1_e12": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s1_e10%]", + "sensor_fault_s1_e13": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s1_e10%]", + "sensor_fault_s1_e14": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s1_e10%]", + "sensor_fault_s2_e20": "Sensor fault S2", + "sensor_fault_s2_e21": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", + "sensor_fault_s2_e22": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", + "sensor_fault_s2_e23": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", + "sensor_fault_s2_e24": "[%key:component::incomfort::entity::water_heater::boiler::state::sensor_fault_s2_e20%]", + "shortcut_outside_sensor_temperature_e27": "Shortcut outside sensor temperature", + "gas_valve_relay_faulty_e29": "Gas valve relay faulty", + "gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]" + } + } + } + } +} diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 2cd7c84a666..28424069d1c 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -5,59 +5,48 @@ from __future__ import annotations import logging from typing import Any -from aiohttp import ClientResponseError +from incomfortclient import Heater as InComfortHeater -from homeassistant.components.water_heater import ( - DOMAIN as WATER_HEATER_DOMAIN, - WaterHeaterEntity, -) +from homeassistant.components.water_heater import WaterHeaterEntity from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, IncomfortEntity +from . import InComfortConfigEntry +from .coordinator import InComfortDataCoordinator +from .entity import IncomfortBoilerEntity _LOGGER = logging.getLogger(__name__) HEATER_ATTRS = ["display_code", "display_text", "is_burning"] -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: InComfortConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up an InComfort/Intouch water_heater device.""" - if discovery_info is None: - return - - client = hass.data[DOMAIN]["client"] - heaters = hass.data[DOMAIN]["heaters"] - - async_add_entities([IncomfortWaterHeater(client, h) for h in heaters]) + """Set up an InComfort/InTouch water_heater device.""" + incomfort_coordinator = entry.runtime_data + heaters = incomfort_coordinator.data.heaters + async_add_entities(IncomfortWaterHeater(incomfort_coordinator, h) for h in heaters) -class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): +class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity): """Representation of an InComfort/Intouch water_heater device.""" - def __init__(self, client, heater) -> None: + _attr_min_temp = 30.0 + _attr_max_temp = 80.0 + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "boiler" + + def __init__( + self, coordinator: InComfortDataCoordinator, heater: InComfortHeater + ) -> None: """Initialize the water_heater device.""" - super().__init__() - - self._unique_id = f"{heater.serial_no}" - self.entity_id = f"{WATER_HEATER_DOMAIN}.{DOMAIN}" - self._name = "Boiler" - - self._client = client - self._heater = heater - - @property - def icon(self) -> str: - """Return the icon of the water_heater device.""" - return "mdi:thermometer-lines" + super().__init__(coordinator, heater) + self._attr_unique_id = heater.serial_no @property def extra_state_attributes(self) -> dict[str, Any]: @@ -74,35 +63,6 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): return max(self._heater.heater_temp, self._heater.tap_temp) @property - def min_temp(self) -> float: - """Return min valid temperature that can be set.""" - return 30.0 - - @property - def max_temp(self) -> float: - """Return max valid temperature that can be set.""" - return 80.0 - - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return UnitOfTemperature.CELSIUS - - @property - def current_operation(self) -> str: + def current_operation(self) -> str | None: """Return the current operation mode.""" - if self._heater.is_failed: - return f"Fault code: {self._heater.fault_code}" - return self._heater.display_text - - async def async_update(self) -> None: - """Get the latest state data from the gateway.""" - try: - await self._heater.update() - - except (ClientResponseError, TimeoutError) as err: - _LOGGER.warning("Update failed, message is: %s", err) - - else: - async_dispatcher_send(self.hass, DOMAIN) diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index a7bd71005ab..05b2ebbafa0 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -114,7 +114,9 @@ async def async_setup_entry( class INKBIRDBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a inkbird ble sensor.""" diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 9546b51ee4f..11aab52e6a4 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -237,11 +237,11 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): # If the user passed in an initial value with a timezone, convert it to right tz if current_datetime.tzinfo is not None: self._current_datetime = current_datetime.astimezone( - dt_util.DEFAULT_TIME_ZONE + dt_util.get_default_time_zone() ) else: self._current_datetime = current_datetime.replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE + tzinfo=dt_util.get_default_time_zone() ) @classmethod @@ -295,7 +295,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): ) self._current_datetime = current_datetime.replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE + tzinfo=dt_util.get_default_time_zone() ) @property @@ -409,7 +409,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): time = self._current_datetime.time() self._current_datetime = py_datetime.datetime.combine( - date, time, dt_util.DEFAULT_TIME_ZONE + date, time, dt_util.get_default_time_zone() ) self.async_write_ha_state() diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index dcb75a92d20..2741c9e21bc 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -250,7 +250,7 @@ class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" _entity_component_unrecorded_attributes = ( - SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} + SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} # noqa: SLF001 ) _unrecorded_attributes = frozenset({ATTR_EDITABLE}) diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index 1f671aa1343..b19b1912340 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -3,6 +3,7 @@ from insteon_frontend import get_build_id, locate_dir from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant, callback from ..const import CONF_DEV_PATH, DOMAIN @@ -91,7 +92,9 @@ async def async_register_insteon_frontend(hass: HomeAssistant): is_dev = dev_path is not None path = dev_path if dev_path else locate_dir() build_id = get_build_id(is_dev) - hass.http.register_static_path(URL_BASE, path, cache_headers=not is_dev) + await hass.http.async_register_static_paths( + [StaticPathConfig(URL_BASE, path, cache_headers=not is_dev)] + ) await panel_custom.async_register_panel( hass=hass, diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py index e8bd08bc4ee..ff688eef40c 100644 --- a/homeassistant/components/insteon/api/device.py +++ b/homeassistant/components/insteon/api/device.py @@ -65,7 +65,7 @@ async def async_device_name(dev_registry, address): def notify_device_not_found(connection, msg, text): """Notify the caller that the device was not found.""" connection.send_message( - websocket_api.error_message(msg[ID], websocket_api.const.ERR_NOT_FOUND, text) + websocket_api.error_message(msg[ID], websocket_api.ERR_NOT_FOUND, text) ) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 7d12436d0fb..456bc124b66 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.5.3", + "pyinsteon==1.6.1", "insteon-frontend-home-assistant==0.5.0" ], "usb": [ diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 837c6224014..4cf8d49d170 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -22,6 +22,7 @@ from .const import ( CONF_CAT, CONF_DIM_STEPS, CONF_HOUSECODE, + CONF_HUB_VERSION, CONF_SUBCAT, CONF_UNITCODE, HOUSECODES, @@ -143,6 +144,7 @@ def build_hub_schema( schema = { vol.Required(CONF_HOST, default=host): str, vol.Required(CONF_PORT, default=port): int, + vol.Required(CONF_HUB_VERSION, default=hub_version): int, } if hub_version == 2: schema[vol.Required(CONF_USERNAME, default=username)] = str diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index db25d8c97a9..26d1aab4928 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -404,7 +404,7 @@ def print_aldb_to_log(aldb): hwm = "Y" if rec.is_high_water_mark else "N" log_msg = ( f" {rec.mem_addr:04x} {in_use:s} {mode:s} {hwm:s} " - f"{rec.group:3d} {str(rec.target):s} {rec.data1:3d} " + f"{rec.group:3d} {rec.target!s:s} {rec.data1:3d} " f"{rec.data2:3d} {rec.data3:3d}" ) logger.info(log_msg) diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 4a8d4baa3f2..4ccf0dec258 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -5,10 +5,22 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device import ( + async_remove_stale_devices_links_keep_entity_device, +) + +from .const import CONF_SOURCE_SENSOR async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Integration from a config entry.""" + + async_remove_stale_devices_links_keep_entity_device( + hass, + entry.entry_id, + entry.options[CONF_SOURCE_SENSOR], + ) + await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True @@ -16,6 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" + # Remove device link for entry, the source device may have changed. + # The link will be recreated after load. await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 318f1355aae..28cd280f7f8 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -10,14 +10,23 @@ import voluptuous as vol from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_METHOD, CONF_NAME, UnitOfTime +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_METHOD, + CONF_NAME, + UnitOfTime, +) +from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, SchemaConfigFlowHandler, SchemaFlowFormStep, + SchemaOptionsFlowHandler, ) from .const import ( + CONF_MAX_SUB_INTERVAL, CONF_ROUND_DIGITS, CONF_SOURCE_SENSOR, CONF_UNIT_PREFIX, @@ -45,57 +54,93 @@ INTEGRATION_METHODS = [ METHOD_LEFT, METHOD_RIGHT, ] +ALLOWED_DOMAINS = [COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] -OPTIONS_SCHEMA = vol.Schema( - { - vol.Required(CONF_ROUND_DIGITS, default=2): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, max=6, mode=selector.NumberSelectorMode.BOX - ), - ), - } -) -CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): selector.TextSelector(), - vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( - selector.EntitySelectorConfig( - domain=[COUNTER_DOMAIN, INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN] - ), - ), +@callback +def entity_selector_compatible( + handler: SchemaOptionsFlowHandler, +) -> selector.EntitySelector: + """Return an entity selector which compatible entities.""" + current = handler.hass.states.get(handler.options[CONF_SOURCE_SENSOR]) + unit_of_measurement = ( + current.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if current else None + ) + + entities = [ + ent.entity_id + for ent in handler.hass.states.async_all(ALLOWED_DOMAINS) + if ent.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement + and ent.domain in ALLOWED_DOMAINS + ] + + return selector.EntitySelector( + selector.EntitySelectorConfig(include_entities=entities) + ) + + +async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict: + if handler is None or not isinstance( + handler.parent_handler, SchemaOptionsFlowHandler + ): + entity_selector = selector.EntitySelector( + selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS) + ) + else: + entity_selector = entity_selector_compatible(handler.parent_handler) + + return { + vol.Required(CONF_SOURCE_SENSOR): entity_selector, vol.Required(CONF_METHOD, default=METHOD_TRAPEZOIDAL): selector.SelectSelector( selector.SelectSelectorConfig( options=INTEGRATION_METHODS, translation_key=CONF_METHOD ), ), - vol.Required(CONF_ROUND_DIGITS, default=2): selector.NumberSelector( + vol.Optional(CONF_ROUND_DIGITS): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, - max=6, - mode=selector.NumberSelectorMode.BOX, - unit_of_measurement="decimals", + min=0, max=6, mode=selector.NumberSelectorMode.BOX ), ), - vol.Optional(CONF_UNIT_PREFIX): selector.SelectSelector( - selector.SelectSelectorConfig(options=UNIT_PREFIXES), - ), - vol.Required(CONF_UNIT_TIME, default=UnitOfTime.HOURS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=TIME_UNITS, - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key=CONF_UNIT_TIME, - ), + vol.Optional(CONF_MAX_SUB_INTERVAL): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) ), } -) + + +async def _get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + return vol.Schema(await _get_options_dict(handler)) + + +async def _get_config_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + options = await _get_options_dict(handler) + return vol.Schema( + { + vol.Required(CONF_NAME): selector.TextSelector(), + vol.Optional(CONF_UNIT_PREFIX): selector.SelectSelector( + selector.SelectSelectorConfig( + options=UNIT_PREFIXES, mode=selector.SelectSelectorMode.DROPDOWN + ) + ), + vol.Required( + CONF_UNIT_TIME, default=UnitOfTime.HOURS + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=TIME_UNITS, + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key=CONF_UNIT_TIME, + ), + ), + **options, + } + ) + CONFIG_FLOW = { - "user": SchemaFlowFormStep(CONFIG_SCHEMA), + "user": SchemaFlowFormStep(_get_config_schema), } OPTIONS_FLOW = { - "init": SchemaFlowFormStep(OPTIONS_SCHEMA), + "init": SchemaFlowFormStep(_get_options_schema), } diff --git a/homeassistant/components/integration/const.py b/homeassistant/components/integration/const.py index b05e4e8f80b..9c3aa04a969 100644 --- a/homeassistant/components/integration/const.py +++ b/homeassistant/components/integration/const.py @@ -7,6 +7,7 @@ CONF_SOURCE_SENSOR = "source" CONF_UNIT_OF_MEASUREMENT = "unit" CONF_UNIT_PREFIX = "unit_prefix" CONF_UNIT_TIME = "unit_time" +CONF_MAX_SUB_INTERVAL = "max_sub_interval" METHOD_TRAPEZOIDAL = "trapezoidal" METHOD_LEFT = "left" diff --git a/homeassistant/components/integration/manifest.json b/homeassistant/components/integration/manifest.json index 9e5c597bd1a..029d4740c6f 100644 --- a/homeassistant/components/integration/manifest.json +++ b/homeassistant/components/integration/manifest.json @@ -1,6 +1,6 @@ { "domain": "integration", - "name": "Integration - Riemann sum integral", + "name": "Integral", "after_dependencies": ["counter"], "codeowners": ["@dgomes"], "config_flow": true, diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 65e967d2af7..ffb7a3d8e6a 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -4,13 +4,16 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from decimal import Decimal, DecimalException, InvalidOperation +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation +from enum import Enum import logging from typing import Any, Final, Self import voluptuous as vol from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS, PLATFORM_SCHEMA, RestoreSensor, SensorDeviceClass, @@ -25,27 +28,25 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE, - STATE_UNKNOWN, UnitOfTime, ) from homeassistant.core import ( + CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant, State, callback, ) -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import async_call_later, async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + CONF_MAX_SUB_INTERVAL, CONF_ROUND_DIGITS, CONF_SOURCE_SENSOR, CONF_UNIT_OF_MEASUREMENT, @@ -72,6 +73,10 @@ UNIT_TIME = { UnitOfTime.DAYS: 24 * 60 * 60, } +DEVICE_CLASS_MAP = { + SensorDeviceClass.POWER: SensorDeviceClass.ENERGY, +} + DEFAULT_ROUND = 3 PLATFORM_SCHEMA = vol.All( @@ -81,10 +86,13 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, - vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), + vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Any( + None, vol.Coerce(int) + ), vol.Optional(CONF_UNIT_PREFIX): vol.In(UNIT_PREFIXES), vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME), vol.Remove(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_MAX_SUB_INTERVAL): cv.positive_time_period, vol.Optional(CONF_METHOD, default=METHOD_TRAPEZOIDAL): vol.In( INTEGRATION_METHODS ), @@ -174,6 +182,11 @@ _NAME_TO_INTEGRATION_METHOD: dict[str, type[_IntegrationMethod]] = { } +class _IntegrationTrigger(Enum): + StateChange = "state_change" + TimeElapsed = "time_elapsed" + + @dataclass class IntegrationSensorExtraStoredData(SensorExtraStoredData): """Object to hold extra stored data.""" @@ -233,41 +246,34 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - source_entity = er.EntityRegistry.async_get(registry, source_entity_id) - dev_reg = dr.async_get(hass) - # Resolve source entity device - if ( - (source_entity is not None) - and (source_entity.device_id is not None) - and ( - ( - device := dev_reg.async_get( - device_id=source_entity.device_id, - ) - ) - is not None - ) - ): - device_info = DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, - ) - else: - device_info = None + device_info = async_device_info_to_link_from_entity( + hass, + source_entity_id, + ) if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None + if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): + max_sub_interval = cv.time_period(max_sub_interval_dict) + else: + max_sub_interval = None + + round_digits = config_entry.options.get(CONF_ROUND_DIGITS) + if round_digits: + round_digits = int(round_digits) + integral = IntegrationSensor( integration_method=config_entry.options[CONF_METHOD], name=config_entry.title, - round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), + round_digits=round_digits, source_entity=source_entity_id, unique_id=config_entry.entry_id, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, + max_sub_interval=max_sub_interval, ) async_add_entities([integral]) @@ -283,11 +289,12 @@ async def async_setup_platform( integral = IntegrationSensor( integration_method=config[CONF_METHOD], name=config.get(CONF_NAME), - round_digits=config[CONF_ROUND_DIGITS], + round_digits=config.get(CONF_ROUND_DIGITS), source_entity=config[CONF_SOURCE_SENSOR], unique_id=config.get(CONF_UNIQUE_ID), unit_prefix=config.get(CONF_UNIT_PREFIX), unit_time=config[CONF_UNIT_TIME], + max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL), ) async_add_entities([integral]) @@ -304,11 +311,12 @@ class IntegrationSensor(RestoreSensor): *, integration_method: str, name: str | None, - round_digits: int, + round_digits: int | None, source_entity: str, unique_id: str | None, unit_prefix: str | None, unit_time: UnitOfTime, + max_sub_interval: timedelta | None, device_info: DeviceInfo | None = None, ) -> None: """Initialize the integration sensor.""" @@ -328,6 +336,15 @@ class IntegrationSensor(RestoreSensor): self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None self._attr_device_info = device_info + self._max_sub_interval: timedelta | None = ( + None # disable time based integration + if max_sub_interval is None or max_sub_interval.total_seconds() == 0 + else max_sub_interval + ) + self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None + self._last_integration_time: datetime = datetime.now(tz=UTC) + self._last_integration_trigger = _IntegrationTrigger.StateChange + self._attr_suggested_display_precision = round_digits or 2 def _calculate_unit(self, source_unit: str) -> str: """Multiply source_unit with time unit of the integral. @@ -349,6 +366,22 @@ class IntegrationSensor(RestoreSensor): return f"{self._unit_prefix_string}{integral_unit}" + def _calculate_device_class( + self, + source_device_class: SensorDeviceClass | None, + unit_of_measurement: str | None, + ) -> SensorDeviceClass | None: + """Deduce device class if possible from source device class and target unit.""" + if source_device_class is None: + return None + + if (device_class := DEVICE_CLASS_MAP.get(source_device_class)) is None: + return None + + if unit_of_measurement not in DEVICE_CLASS_UNITS.get(device_class, set()): + return None + return device_class + def _derive_and_set_attributes_from_state(self, source_state: State) -> None: source_unit = source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if source_unit is not None: @@ -357,13 +390,13 @@ class IntegrationSensor(RestoreSensor): # If the source has no defined unit we cannot derive a unit for the integral self._unit_of_measurement = None - if ( - self.device_class is None - and source_state.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.POWER - ): - self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_icon = None # Remove this sensors icon default and allow to fallback to the ENERGY default + self._attr_device_class = self._calculate_device_class( + source_state.attributes.get(ATTR_DEVICE_CLASS), self.unit_of_measurement + ) + if self._attr_device_class: + self._attr_icon = None # Remove this sensors icon default and allow to fallback to the device class default + else: + self._attr_icon = "mdi:chart-histogram" def _update_integral(self, area: Decimal) -> None: area_scaled = area / (self._unit_prefix * self._unit_time) @@ -395,39 +428,62 @@ class IntegrationSensor(RestoreSensor): self._state, self._last_valid_state, ) - elif (state := await self.async_get_last_state()) is not None: - # legacy to be removed on 2023.10 (we are keeping this to avoid losing data during the transition) - if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: - if state.state == STATE_UNAVAILABLE: - self._attr_available = False - else: - try: - self._state = Decimal(state.state) - except (DecimalException, ValueError) as err: - _LOGGER.warning( - "%s could not restore last state %s: %s", - self.entity_id, - state.state, - err, - ) - self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) - self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if self._max_sub_interval is not None: + source_state = self.hass.states.get(self._sensor_source_id) + self._schedule_max_sub_interval_exceeded_if_state_is_numeric(source_state) + self.async_on_remove(self._cancel_max_sub_interval_exceeded_callback) + handle_state_change = self._integrate_on_state_change_and_max_sub_interval + else: + handle_state_change = self._integrate_on_state_change_callback + + if ( + state := self.hass.states.get(self._source_entity) + ) and state.state != STATE_UNAVAILABLE: + self._derive_and_set_attributes_from_state(state) self.async_on_remove( async_track_state_change_event( self.hass, [self._sensor_source_id], - self._handle_state_change, + handle_state_change, ) ) @callback - def _handle_state_change(self, event: Event[EventStateChangedData]) -> None: + def _integrate_on_state_change_and_max_sub_interval( + self, event: Event[EventStateChangedData] + ) -> None: + """Integrate based on state change and time. + + Next to doing the integration based on state change this method cancels and + reschedules time based integration. + """ + self._cancel_max_sub_interval_exceeded_callback() old_state = event.data["old_state"] new_state = event.data["new_state"] + try: + self._integrate_on_state_change(old_state, new_state) + self._last_integration_trigger = _IntegrationTrigger.StateChange + self._last_integration_time = datetime.now(tz=UTC) + finally: + # When max_sub_interval exceeds without state change the source is assumed + # constant with the last known state (new_state). + self._schedule_max_sub_interval_exceeded_if_state_is_numeric(new_state) - if old_state is None or new_state is None: + @callback + def _integrate_on_state_change_callback( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle the sensor state changes.""" + old_state = event.data["old_state"] + new_state = event.data["new_state"] + return self._integrate_on_state_change(old_state, new_state) + + def _integrate_on_state_change( + self, old_state: State | None, new_state: State | None + ) -> None: + if new_state is None: return if new_state.state == STATE_UNAVAILABLE: @@ -438,12 +494,18 @@ class IntegrationSensor(RestoreSensor): self._attr_available = True self._derive_and_set_attributes_from_state(new_state) + if old_state is None: + self.async_write_ha_state() + return + if not (states := self._method.validate_states(old_state, new_state)): self.async_write_ha_state() return elapsed_seconds = Decimal( (new_state.last_updated - old_state.last_updated).total_seconds() + if self._last_integration_trigger == _IntegrationTrigger.StateChange + else (new_state.last_updated - self._last_integration_time).total_seconds() ) area = self._method.calculate_area_with_two_states(elapsed_seconds, *states) @@ -451,10 +513,56 @@ class IntegrationSensor(RestoreSensor): self._update_integral(area) self.async_write_ha_state() + def _schedule_max_sub_interval_exceeded_if_state_is_numeric( + self, source_state: State | None + ) -> None: + """Schedule possible integration using the source state and max_sub_interval. + + The callback reference is stored for possible cancellation if the source state + reports a change before max_sub_interval has passed. + + If the callback is executed, meaning there was no state change reported, the + source_state is assumed constant and integration is done using its value. + """ + if ( + self._max_sub_interval is not None + and source_state is not None + and (source_state_dec := _decimal_state(source_state.state)) + ): + + @callback + def _integrate_on_max_sub_interval_exceeded_callback(now: datetime) -> None: + """Integrate based on time and reschedule.""" + elapsed_seconds = Decimal( + (now - self._last_integration_time).total_seconds() + ) + self._derive_and_set_attributes_from_state(source_state) + area = self._method.calculate_area_with_one_state( + elapsed_seconds, source_state_dec + ) + self._update_integral(area) + self.async_write_ha_state() + + self._last_integration_time = datetime.now(tz=UTC) + self._last_integration_trigger = _IntegrationTrigger.TimeElapsed + + self._schedule_max_sub_interval_exceeded_if_state_is_numeric( + source_state + ) + + self._max_sub_interval_exceeded_callback = async_call_later( + self.hass, + self._max_sub_interval, + _integrate_on_max_sub_interval_exceeded_callback, + ) + + def _cancel_max_sub_interval_exceeded_callback(self) -> None: + self._max_sub_interval_exceeded_callback() + @property def native_value(self) -> Decimal | None: """Return the state of the sensor.""" - if isinstance(self._state, Decimal): + if isinstance(self._state, Decimal) and self._round_digits: return round(self._state, self._round_digits) return self._state diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json index 74c2b3ee440..55d4df1b45e 100644 --- a/homeassistant/components/integration/strings.json +++ b/homeassistant/components/integration/strings.json @@ -1,5 +1,5 @@ { - "title": "Integration - Riemann sum integral sensor", + "title": "Integral sensor", "config": { "step": { "user": { @@ -11,12 +11,14 @@ "round": "Precision", "source": "Input sensor", "unit_prefix": "Metric prefix", - "unit_time": "Time unit" + "unit_time": "Time unit", + "max_sub_interval": "Max sub-interval" }, "data_description": { "round": "Controls the number of decimal digits in the output.", "unit_prefix": "The output will be scaled according to the selected metric prefix.", - "unit_time": "The output will be scaled according to the selected time unit." + "unit_time": "The output will be scaled according to the selected time unit.", + "max_sub_interval": "Applies time based integration if the source did not change for this duration. Use 0 for no time based updates." } } } @@ -25,10 +27,16 @@ "step": { "init": { "data": { - "round": "[%key:component::integration::config::step::user::data::round%]" + "method": "[%key:component::integration::config::step::user::data::method%]", + "round": "[%key:component::integration::config::step::user::data::round%]", + "source": "[%key:component::integration::config::step::user::data::source%]", + "unit_prefix": "[%key:component::integration::config::step::user::data::unit_prefix%]", + "unit_time": "[%key:component::integration::config::step::user::data::unit_time%]" }, "data_description": { - "round": "[%key:component::integration::config::step::user::data_description::round%]" + "round": "[%key:component::integration::config::step::user::data_description::round%]", + "unit_prefix": "[%key:component::integration::config::step::user::data_description::unit_prefix%]", + "unit_time": "[%key:component::integration::config::step::user::data_description::unit_time%]" } } } diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 7fd9fd4b712..9b09fa9167b 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -35,23 +35,42 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, State -from homeassistant.helpers import ( - area_registry as ar, - config_validation as cv, - integration_platform, - intent, -) +from homeassistant.helpers import config_validation as cv, integration_platform, intent from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DOMAIN, TIMER_DATA +from .timers import ( + CancelTimerIntentHandler, + DecreaseTimerIntentHandler, + IncreaseTimerIntentHandler, + PauseTimerIntentHandler, + StartTimerIntentHandler, + TimerEventType, + TimerInfo, + TimerManager, + TimerStatusIntentHandler, + UnpauseTimerIntentHandler, + async_device_supports_timers, + async_register_timer_handler, +) _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +__all__ = [ + "async_register_timer_handler", + "async_device_supports_timers", + "TimerInfo", + "TimerEventType", + "DOMAIN", +] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Intent component.""" + hass.data[TIMER_DATA] = TimerManager(hass) + hass.http.register_view(IntentHandleView()) await integration_platform.async_process_integration_platforms( @@ -60,15 +79,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register( hass, - OnOffIntentHandler(intent.INTENT_TURN_ON, HA_DOMAIN, SERVICE_TURN_ON), + OnOffIntentHandler( + intent.INTENT_TURN_ON, + HA_DOMAIN, + SERVICE_TURN_ON, + description="Turns on/opens a device or entity", + ), ) intent.async_register( hass, - OnOffIntentHandler(intent.INTENT_TURN_OFF, HA_DOMAIN, SERVICE_TURN_OFF), + OnOffIntentHandler( + intent.INTENT_TURN_OFF, + HA_DOMAIN, + SERVICE_TURN_OFF, + description="Turns off/closes a device or entity", + ), ) intent.async_register( hass, - intent.ServiceIntentHandler(intent.INTENT_TOGGLE, HA_DOMAIN, SERVICE_TOGGLE), + intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, + HA_DOMAIN, + SERVICE_TOGGLE, + description="Toggles a device or entity", + ), ) intent.async_register( hass, @@ -79,6 +113,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: NevermindIntentHandler(), ) intent.async_register(hass, SetPositionIntentHandler()) + intent.async_register(hass, StartTimerIntentHandler()) + intent.async_register(hass, CancelTimerIntentHandler()) + intent.async_register(hass, IncreaseTimerIntentHandler()) + intent.async_register(hass, DecreaseTimerIntentHandler()) + intent.async_register(hass, PauseTimerIntentHandler()) + intent.async_register(hass, UnpauseTimerIntentHandler()) + intent.async_register(hass, TimerStatusIntentHandler()) return True @@ -175,8 +216,9 @@ class GetStateIntentHandler(intent.IntentHandler): """Answer questions about entity states.""" intent_type = intent.INTENT_GET_STATE + description = "Gets or checks the state of a device or entity" slot_schema = { - vol.Any("name", "area"): cv.string, + vol.Any("name", "area", "floor"): cv.string, vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), vol.Optional("state"): vol.All(cv.ensure_list, [cv.string]), @@ -190,18 +232,13 @@ class GetStateIntentHandler(intent.IntentHandler): # Entity name to match name_slot = slots.get("name", {}) entity_name: str | None = name_slot.get("value") - entity_text: str | None = name_slot.get("text") - # Look up area first to fail early + # Get area/floor info area_slot = slots.get("area", {}) area_id = area_slot.get("value") - area_name = area_slot.get("text") - area: ar.AreaEntry | None = None - if area_id is not None: - areas = ar.async_get(hass) - area = areas.async_get_area(area_id) - if area is None: - raise intent.IntentHandleError(f"No area named {area_name}") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") # Optional domain/device class filters. # Convert to sets for speed. @@ -218,32 +255,24 @@ class GetStateIntentHandler(intent.IntentHandler): if "state" in slots: state_names = set(slots["state"]["value"]) - states = list( - intent.async_match_states( - hass, - name=entity_name, - area=area, - domains=domains, - device_classes=device_classes, - assistant=intent_obj.assistant, - ) + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains=domains, + device_classes=device_classes, + assistant=intent_obj.assistant, ) - - _LOGGER.debug( - "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", - len(states), - entity_name, - area, - domains, - device_classes, - intent_obj.assistant, - ) - - if entity_name and (len(states) > 1): - # Multiple entities matched for the same name - raise intent.DuplicateNamesMatchedError( - name=entity_text or entity_name, - area=area_name or area_id, + match_result = intent.async_match_targets(hass, match_constraints) + if ( + (not match_result.is_match) + and (match_result.no_match_reason is not None) + and (not match_result.no_match_reason.is_no_entities_reason()) + ): + # Don't try to answer questions for certain errors. + # Other match failure reasons are OK. + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints ) # Create response @@ -251,13 +280,24 @@ class GetStateIntentHandler(intent.IntentHandler): response.response_type = intent.IntentResponseType.QUERY_ANSWER success_results: list[intent.IntentResponseTarget] = [] - if area is not None: - success_results.append( + if match_result.areas: + success_results.extend( intent.IntentResponseTarget( type=intent.IntentResponseTargetType.AREA, name=area.name, id=area.id, ) + for area in match_result.areas + ) + + if match_result.floors: + success_results.extend( + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.FLOOR, + name=floor.name, + id=floor.floor_id, + ) + for floor in match_result.floors ) # If we are matching a state name (e.g., "which lights are on?"), then @@ -271,7 +311,7 @@ class GetStateIntentHandler(intent.IntentHandler): matched_states: list[State] = [] unmatched_states: list[State] = [] - for state in states: + for state in match_result.states: success_results.append( intent.IntentResponseTarget( type=intent.IntentResponseTargetType.ENTITY, @@ -296,6 +336,7 @@ class NevermindIntentHandler(intent.IntentHandler): """Takes no action.""" intent_type = intent.INTENT_NEVERMIND + description = "Cancels the current request and does nothing" async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Doe not do anything, and produces an empty response.""" @@ -309,7 +350,11 @@ class SetPositionIntentHandler(intent.DynamicServiceIntentHandler): """Create set position handler.""" super().__init__( intent.INTENT_SET_POSITION, - extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))}, + required_slots={ + ATTR_POSITION: vol.All(vol.Coerce(int), vol.Range(min=0, max=100)) + }, + description="Sets the position of a device or entity", + platforms={COVER_DOMAIN, VALVE_DOMAIN}, ) def get_domain_and_service( diff --git a/homeassistant/components/intent/const.py b/homeassistant/components/intent/const.py index 61b97c20537..56b6d83bade 100644 --- a/homeassistant/components/intent/const.py +++ b/homeassistant/components/intent/const.py @@ -1,3 +1,5 @@ """Constants for the Intent integration.""" DOMAIN = "intent" + +TIMER_DATA = f"{DOMAIN}.timer" diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py new file mode 100644 index 00000000000..cddfce55b9f --- /dev/null +++ b/homeassistant/components/intent/timers.py @@ -0,0 +1,1053 @@ +"""Timer implementation for intents.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from functools import cached_property +import logging +import time +from typing import Any + +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + intent, +) +from homeassistant.util import ulid + +from .const import TIMER_DATA + +_LOGGER = logging.getLogger(__name__) + +TIMER_NOT_FOUND_RESPONSE = "timer_not_found" +MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched" +NO_TIMER_SUPPORT_RESPONSE = "no_timer_support" + + +@dataclass +class TimerInfo: + """Information for a single timer.""" + + id: str + """Unique id of the timer.""" + + name: str | None + """User-provided name for timer.""" + + seconds: int + """Total number of seconds the timer should run for.""" + + device_id: str | None + """Id of the device where the timer was set. + + May be None only if conversation_command is set. + """ + + start_hours: int | None + """Number of hours the timer should run as given by the user.""" + + start_minutes: int | None + """Number of minutes the timer should run as given by the user.""" + + start_seconds: int | None + """Number of seconds the timer should run as given by the user.""" + + created_at: int + """Timestamp when timer was created (time.monotonic_ns)""" + + updated_at: int + """Timestamp when timer was last updated (time.monotonic_ns)""" + + language: str + """Language of command used to set the timer.""" + + is_active: bool = True + """True if timer is ticking down.""" + + area_id: str | None = None + """Id of area that the device belongs to.""" + + area_name: str | None = None + """Normalized name of the area that the device belongs to.""" + + floor_id: str | None = None + """Id of floor that the device's area belongs to.""" + + conversation_command: str | None = None + """Text of conversation command to execute when timer is finished. + + This command must be in the language used to set the timer. + """ + + conversation_agent_id: str | None = None + """Id of the conversation agent used to set the timer. + + This agent will be used to execute the conversation command. + """ + + @property + def seconds_left(self) -> int: + """Return number of seconds left on the timer.""" + if not self.is_active: + return self.seconds + + now = time.monotonic_ns() + seconds_running = int((now - self.updated_at) / 1e9) + return max(0, self.seconds - seconds_running) + + @cached_property + def name_normalized(self) -> str: + """Return normalized timer name.""" + return _normalize_name(self.name or "") + + def cancel(self) -> None: + """Cancel the timer.""" + self.seconds = 0 + self.updated_at = time.monotonic_ns() + self.is_active = False + + def pause(self) -> None: + """Pause the timer.""" + self.seconds = self.seconds_left + self.updated_at = time.monotonic_ns() + self.is_active = False + + def unpause(self) -> None: + """Unpause the timer.""" + self.updated_at = time.monotonic_ns() + self.is_active = True + + def add_time(self, seconds: int) -> None: + """Add time to the timer. + + Seconds may be negative to remove time instead. + """ + self.seconds = max(0, self.seconds_left + seconds) + self.updated_at = time.monotonic_ns() + + def finish(self) -> None: + """Finish the timer.""" + self.seconds = 0 + self.updated_at = time.monotonic_ns() + self.is_active = False + + +class TimerEventType(StrEnum): + """Event type in timer handler.""" + + STARTED = "started" + """Timer has started.""" + + UPDATED = "updated" + """Timer has been increased, decreased, paused, or unpaused.""" + + CANCELLED = "cancelled" + """Timer has been cancelled.""" + + FINISHED = "finished" + """Timer finished without being cancelled.""" + + +type TimerHandler = Callable[[TimerEventType, TimerInfo], None] + + +class TimerNotFoundError(intent.IntentHandleError): + """Error when a timer could not be found by name or start time.""" + + def __init__(self) -> None: + """Initialize error.""" + super().__init__("Timer not found", TIMER_NOT_FOUND_RESPONSE) + + +class MultipleTimersMatchedError(intent.IntentHandleError): + """Error when multiple timers matched name or start time.""" + + def __init__(self) -> None: + """Initialize error.""" + super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE) + + +class TimersNotSupportedError(intent.IntentHandleError): + """Error when a timer intent is used from a device that isn't registered to handle timer events.""" + + def __init__(self, device_id: str | None = None) -> None: + """Initialize error.""" + super().__init__( + f"Device does not support timers: device_id={device_id}", + NO_TIMER_SUPPORT_RESPONSE, + ) + + +class TimerManager: + """Manager for intent timers.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize timer manager.""" + self.hass = hass + + # timer id -> timer + self.timers: dict[str, TimerInfo] = {} + self.timer_tasks: dict[str, asyncio.Task] = {} + + # device_id -> handler + self.handlers: dict[str, TimerHandler] = {} + + def register_handler( + self, device_id: str, handler: TimerHandler + ) -> Callable[[], None]: + """Register a timer handler. + + Returns a callable to unregister. + """ + self.handlers[device_id] = handler + + def unregister() -> None: + self.handlers.pop(device_id) + + return unregister + + def start_timer( + self, + device_id: str | None, + hours: int | None, + minutes: int | None, + seconds: int | None, + language: str, + name: str | None = None, + conversation_command: str | None = None, + conversation_agent_id: str | None = None, + ) -> str: + """Start a timer.""" + if (not conversation_command) and (device_id is None): + raise ValueError("Conversation command must be set if no device id") + + if (not conversation_command) and ( + (device_id is None) or (not self.is_timer_device(device_id)) + ): + raise TimersNotSupportedError(device_id) + + total_seconds = 0 + if hours is not None: + total_seconds += 60 * 60 * hours + + if minutes is not None: + total_seconds += 60 * minutes + + if seconds is not None: + total_seconds += seconds + + timer_id = ulid.ulid_now() + created_at = time.monotonic_ns() + timer = TimerInfo( + id=timer_id, + name=name, + start_hours=hours, + start_minutes=minutes, + start_seconds=seconds, + seconds=total_seconds, + language=language, + device_id=device_id, + created_at=created_at, + updated_at=created_at, + conversation_command=conversation_command, + conversation_agent_id=conversation_agent_id, + ) + + # Fill in area/floor info + device_registry = dr.async_get(self.hass) + if device_id and (device := device_registry.async_get(device_id)): + timer.area_id = device.area_id + area_registry = ar.async_get(self.hass) + if device.area_id and ( + area := area_registry.async_get_area(device.area_id) + ): + timer.area_name = _normalize_name(area.name) + timer.floor_id = area.floor_id + + self.timers[timer_id] = timer + self.timer_tasks[timer_id] = self.hass.async_create_background_task( + self._wait_for_timer(timer_id, total_seconds, created_at), + name=f"Timer {timer_id}", + ) + + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.STARTED, timer) + _LOGGER.debug( + "Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", + timer_id, + name, + hours, + minutes, + seconds, + device_id, + ) + + return timer_id + + async def _wait_for_timer( + self, timer_id: str, seconds: int, updated_at: int + ) -> None: + """Sleep until timer is up. Timer is only finished if it hasn't been updated.""" + try: + await asyncio.sleep(seconds) + if (timer := self.timers.get(timer_id)) and ( + timer.updated_at == updated_at + ): + self._timer_finished(timer_id) + except asyncio.CancelledError: + pass # expected when timer is updated + + def cancel_timer(self, timer_id: str) -> None: + """Cancel a timer.""" + timer = self.timers.pop(timer_id, None) + if timer is None: + raise TimerNotFoundError + + if timer.is_active: + task = self.timer_tasks.pop(timer_id) + task.cancel() + + timer.cancel() + + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.CANCELLED, timer) + _LOGGER.debug( + "Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s", + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def add_time(self, timer_id: str, seconds: int) -> None: + """Add time to a timer.""" + timer = self.timers.get(timer_id) + if timer is None: + raise TimerNotFoundError + + if seconds == 0: + # Don't bother cancelling and recreating the timer task + return + + timer.add_time(seconds) + if timer.is_active: + task = self.timer_tasks.pop(timer_id) + task.cancel() + self.timer_tasks[timer_id] = self.hass.async_create_background_task( + self._wait_for_timer(timer_id, timer.seconds, timer.updated_at), + name=f"Timer {timer_id}", + ) + + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) + + if seconds > 0: + log_verb = "increased" + log_seconds = seconds + else: + log_verb = "decreased" + log_seconds = -seconds + + _LOGGER.debug( + "Timer %s by %s second(s): id=%s, name=%s, seconds_left=%s, device_id=%s", + log_verb, + log_seconds, + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def remove_time(self, timer_id: str, seconds: int) -> None: + """Remove time from a timer.""" + self.add_time(timer_id, -seconds) + + def pause_timer(self, timer_id: str) -> None: + """Pauses a timer.""" + timer = self.timers.get(timer_id) + if timer is None: + raise TimerNotFoundError + + if not timer.is_active: + # Already paused + return + + timer.pause() + task = self.timer_tasks.pop(timer_id) + task.cancel() + + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) + _LOGGER.debug( + "Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s", + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def unpause_timer(self, timer_id: str) -> None: + """Unpause a timer.""" + timer = self.timers.get(timer_id) + if timer is None: + raise TimerNotFoundError + + if timer.is_active: + # Already unpaused + return + + timer.unpause() + self.timer_tasks[timer_id] = self.hass.async_create_background_task( + self._wait_for_timer(timer_id, timer.seconds_left, timer.updated_at), + name=f"Timer {timer.id}", + ) + + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) + _LOGGER.debug( + "Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s", + timer_id, + timer.name, + timer.seconds_left, + timer.device_id, + ) + + def _timer_finished(self, timer_id: str) -> None: + """Call event handlers when a timer finishes.""" + timer = self.timers.pop(timer_id) + + timer.finish() + + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.FINISHED, timer) + _LOGGER.debug( + "Timer finished: id=%s, name=%s, device_id=%s", + timer_id, + timer.name, + timer.device_id, + ) + + if timer.conversation_command: + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.conversation import async_converse + + self.hass.async_create_background_task( + async_converse( + self.hass, + timer.conversation_command, + conversation_id=None, + context=Context(), + language=timer.language, + agent_id=timer.conversation_agent_id, + device_id=timer.device_id, + ), + "timer assist command", + ) + + def is_timer_device(self, device_id: str) -> bool: + """Return True if device has been registered to handle timer events.""" + return device_id in self.handlers + + +@callback +def async_device_supports_timers(hass: HomeAssistant, device_id: str) -> bool: + """Return True if device has been registered to handle timer events.""" + timer_manager: TimerManager | None = hass.data.get(TIMER_DATA) + if timer_manager is None: + return False + return timer_manager.is_timer_device(device_id) + + +@callback +def async_register_timer_handler( + hass: HomeAssistant, device_id: str, handler: TimerHandler +) -> Callable[[], None]: + """Register a handler for timer events. + + Returns a callable to unregister. + """ + timer_manager: TimerManager = hass.data[TIMER_DATA] + return timer_manager.register_handler(device_id, handler) + + +# ----------------------------------------------------------------------------- + + +class FindTimerFilter(StrEnum): + """Type of filter to apply when finding a timer.""" + + ONLY_ACTIVE = "only_active" + ONLY_INACTIVE = "only_inactive" + + +def _find_timer( + hass: HomeAssistant, + device_id: str, + slots: dict[str, Any], + find_filter: FindTimerFilter | None = None, +) -> TimerInfo: + """Match a single timer with constraints or raise an error.""" + timer_manager: TimerManager = hass.data[TIMER_DATA] + + # Ignore delayed command timers + matching_timers: list[TimerInfo] = [ + t for t in timer_manager.timers.values() if not t.conversation_command + ] + has_filter = False + + if find_filter: + # Filter by active state + has_filter = True + if find_filter == FindTimerFilter.ONLY_ACTIVE: + matching_timers = [t for t in matching_timers if t.is_active] + elif find_filter == FindTimerFilter.ONLY_INACTIVE: + matching_timers = [t for t in matching_timers if not t.is_active] + + if len(matching_timers) == 1: + # Only 1 match + return matching_timers[0] + + # Search by name first + name: str | None = None + if "name" in slots: + has_filter = True + name = slots["name"]["value"] + assert name is not None + name_norm = _normalize_name(name) + + matching_timers = [t for t in matching_timers if t.name_normalized == name_norm] + if len(matching_timers) == 1: + # Only 1 match + return matching_timers[0] + + # Search by area name + area_name: str | None = None + if "area" in slots: + has_filter = True + area_name = slots["area"]["value"] + assert area_name is not None + area_name_norm = _normalize_name(area_name) + + matching_timers = [t for t in matching_timers if t.area_name == area_name_norm] + if len(matching_timers) == 1: + # Only 1 match + return matching_timers[0] + + # Use starting time to disambiguate + start_hours: int | None = None + if "start_hours" in slots: + start_hours = int(slots["start_hours"]["value"]) + + start_minutes: int | None = None + if "start_minutes" in slots: + start_minutes = int(slots["start_minutes"]["value"]) + + start_seconds: int | None = None + if "start_seconds" in slots: + start_seconds = int(slots["start_seconds"]["value"]) + + if ( + (start_hours is not None) + or (start_minutes is not None) + or (start_seconds is not None) + ): + has_filter = True + matching_timers = [ + t + for t in matching_timers + if (t.start_hours == start_hours) + and (t.start_minutes == start_minutes) + and (t.start_seconds == start_seconds) + ] + + if len(matching_timers) == 1: + # Only 1 match remaining + return matching_timers[0] + + if (not has_filter) and (len(matching_timers) == 1): + # Only 1 match remaining with no filter + return matching_timers[0] + + # Use device id + if matching_timers: + matching_device_timers = [ + t for t in matching_timers if (t.device_id == device_id) + ] + if len(matching_device_timers) == 1: + # Only 1 match remaining + return matching_device_timers[0] + + # Try area/floor + device_registry = dr.async_get(hass) + area_registry = ar.async_get(hass) + if ( + (device := device_registry.async_get(device_id)) + and device.area_id + and (area := area_registry.async_get_area(device.area_id)) + ): + # Try area + matching_area_timers = [ + t for t in matching_timers if (t.area_id == area.id) + ] + if len(matching_area_timers) == 1: + # Only 1 match remaining + return matching_area_timers[0] + + # Try floor + matching_floor_timers = [ + t for t in matching_timers if (t.floor_id == area.floor_id) + ] + if len(matching_floor_timers) == 1: + # Only 1 match remaining + return matching_floor_timers[0] + + if matching_timers: + raise MultipleTimersMatchedError + + _LOGGER.warning( + "Timer not found: name=%s, area=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", + name, + area_name, + start_hours, + start_minutes, + start_seconds, + device_id, + ) + + raise TimerNotFoundError + + +def _find_timers( + hass: HomeAssistant, device_id: str, slots: dict[str, Any] +) -> list[TimerInfo]: + """Match multiple timers with constraints or raise an error.""" + timer_manager: TimerManager = hass.data[TIMER_DATA] + + # Ignore delayed command timers + matching_timers: list[TimerInfo] = [ + t for t in timer_manager.timers.values() if not t.conversation_command + ] + + # Filter by name first + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + assert name is not None + name_norm = _normalize_name(name) + + matching_timers = [t for t in matching_timers if t.name_normalized == name_norm] + if not matching_timers: + # No matches + return matching_timers + + # Filter by area name + area_name: str | None = None + if "area" in slots: + area_name = slots["area"]["value"] + assert area_name is not None + area_name_norm = _normalize_name(area_name) + + matching_timers = [t for t in matching_timers if t.area_name == area_name_norm] + if not matching_timers: + # No matches + return matching_timers + + # Use starting time to filter, if present + start_hours: int | None = None + if "start_hours" in slots: + start_hours = int(slots["start_hours"]["value"]) + + start_minutes: int | None = None + if "start_minutes" in slots: + start_minutes = int(slots["start_minutes"]["value"]) + + start_seconds: int | None = None + if "start_seconds" in slots: + start_seconds = int(slots["start_seconds"]["value"]) + + if ( + (start_hours is not None) + or (start_minutes is not None) + or (start_seconds is not None) + ): + matching_timers = [ + t + for t in matching_timers + if (t.start_hours == start_hours) + and (t.start_minutes == start_minutes) + and (t.start_seconds == start_seconds) + ] + if not matching_timers: + # No matches + return matching_timers + + # Use device id to order remaining timers + device_registry = dr.async_get(hass) + device = device_registry.async_get(device_id) + if (device is None) or (device.area_id is None): + return matching_timers + + area_registry = ar.async_get(hass) + area = area_registry.async_get_area(device.area_id) + if area is None: + return matching_timers + + def area_floor_sort(timer: TimerInfo) -> int: + """Sort by area, then floor.""" + if timer.area_id == area.id: + return -2 + + if timer.floor_id == area.floor_id: + return -1 + + return 0 + + matching_timers.sort(key=area_floor_sort) + + return matching_timers + + +def _normalize_name(name: str) -> str: + """Normalize name for comparison.""" + return name.strip().casefold() + + +def _get_total_seconds(slots: dict[str, Any]) -> int: + """Return the total number of seconds from hours/minutes/seconds slots.""" + total_seconds = 0 + if "hours" in slots: + total_seconds += 60 * 60 * int(slots["hours"]["value"]) + + if "minutes" in slots: + total_seconds += 60 * int(slots["minutes"]["value"]) + + if "seconds" in slots: + total_seconds += int(slots["seconds"]["value"]) + + return total_seconds + + +def _round_time(hours: int, minutes: int, seconds: int) -> tuple[int, int, int]: + """Round time to a lower precision for feedback.""" + if hours > 0: + # No seconds, round up above 45 minutes and down below 15 + rounded_hours = hours + rounded_seconds = 0 + if minutes > 45: + # 01:50:30 -> 02:00:00 + rounded_hours += 1 + rounded_minutes = 0 + elif minutes < 15: + # 01:10:30 -> 01:00:00 + rounded_minutes = 0 + else: + # 01:25:30 -> 01:30:00 + rounded_minutes = 30 + elif minutes > 0: + # Round up above 45 seconds, down below 15 + rounded_hours = 0 + rounded_minutes = minutes + if seconds > 45: + # 00:01:50 -> 00:02:00 + rounded_minutes += 1 + rounded_seconds = 0 + elif seconds < 15: + # 00:01:10 -> 00:01:00 + rounded_seconds = 0 + else: + # 00:01:25 -> 00:01:30 + rounded_seconds = 30 + else: + # Round up above 50 seconds, exact below 10, and down to nearest 10 + # otherwise. + rounded_hours = 0 + rounded_minutes = 0 + if seconds > 50: + # 00:00:55 -> 00:01:00 + rounded_minutes = 1 + rounded_seconds = 0 + elif seconds < 10: + # 00:00:09 -> 00:00:09 + rounded_seconds = seconds + else: + # 00:01:25 -> 00:01:20 + rounded_seconds = seconds - (seconds % 10) + + return rounded_hours, rounded_minutes, rounded_seconds + + +class StartTimerIntentHandler(intent.IntentHandler): + """Intent handler for starting a new timer.""" + + intent_type = intent.INTENT_START_TIMER + description = "Starts a new timer" + slot_schema = { + vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("conversation_command"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + conversation_command: str | None = None + if "conversation_command" in slots: + conversation_command = slots["conversation_command"]["value"].strip() + + if (not conversation_command) and ( + not ( + intent_obj.device_id + and timer_manager.is_timer_device(intent_obj.device_id) + ) + ): + # Fail early if this is not a delayed command + raise TimersNotSupportedError(intent_obj.device_id) + + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] + + hours: int | None = None + if "hours" in slots: + hours = int(slots["hours"]["value"]) + + minutes: int | None = None + if "minutes" in slots: + minutes = int(slots["minutes"]["value"]) + + seconds: int | None = None + if "seconds" in slots: + seconds = int(slots["seconds"]["value"]) + + timer_manager.start_timer( + intent_obj.device_id, + hours, + minutes, + seconds, + language=intent_obj.language, + name=name, + conversation_command=conversation_command, + conversation_agent_id=intent_obj.conversation_agent_id, + ) + + return intent_obj.create_response() + + +class CancelTimerIntentHandler(intent.IntentHandler): + """Intent handler for cancelling a timer.""" + + intent_type = intent.INTENT_CANCEL_TIMER + description = "Cancels a timer" + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.cancel_timer(timer.id) + return intent_obj.create_response() + + +class IncreaseTimerIntentHandler(intent.IntentHandler): + """Intent handler for increasing the time of a timer.""" + + intent_type = intent.INTENT_INCREASE_TIMER + description = "Adds more time to a timer" + slot_schema = { + vol.Any("hours", "minutes", "seconds"): cv.positive_int, + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + + total_seconds = _get_total_seconds(slots) + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.add_time(timer.id, total_seconds) + return intent_obj.create_response() + + +class DecreaseTimerIntentHandler(intent.IntentHandler): + """Intent handler for decreasing the time of a timer.""" + + intent_type = intent.INTENT_DECREASE_TIMER + description = "Removes time from a timer" + slot_schema = { + vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int, + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + + total_seconds = _get_total_seconds(slots) + timer = _find_timer(hass, intent_obj.device_id, slots) + timer_manager.remove_time(timer.id, total_seconds) + return intent_obj.create_response() + + +class PauseTimerIntentHandler(intent.IntentHandler): + """Intent handler for pausing a running timer.""" + + intent_type = intent.INTENT_PAUSE_TIMER + description = "Pauses a running timer" + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + + timer = _find_timer( + hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_ACTIVE + ) + timer_manager.pause_timer(timer.id) + return intent_obj.create_response() + + +class UnpauseTimerIntentHandler(intent.IntentHandler): + """Intent handler for unpausing a paused timer.""" + + intent_type = intent.INTENT_UNPAUSE_TIMER + description = "Resumes a paused timer" + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + + timer = _find_timer( + hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_INACTIVE + ) + timer_manager.unpause_timer(timer.id) + return intent_obj.create_response() + + +class TimerStatusIntentHandler(intent.IntentHandler): + """Intent handler for reporting the status of a timer.""" + + intent_type = intent.INTENT_TIMER_STATUS + description = "Reports the current status of timers" + slot_schema = { + vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int, + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + timer_manager: TimerManager = hass.data[TIMER_DATA] + slots = self.async_validate_slots(intent_obj.slots) + + if not ( + intent_obj.device_id and timer_manager.is_timer_device(intent_obj.device_id) + ): + # Fail early + raise TimersNotSupportedError(intent_obj.device_id) + + statuses: list[dict[str, Any]] = [] + for timer in _find_timers(hass, intent_obj.device_id, slots): + total_seconds = timer.seconds_left + + minutes, seconds = divmod(total_seconds, 60) + hours, minutes = divmod(minutes, 60) + + # Get lower-precision time for feedback + rounded_hours, rounded_minutes, rounded_seconds = _round_time( + hours, minutes, seconds + ) + + statuses.append( + { + ATTR_ID: timer.id, + ATTR_NAME: timer.name or "", + ATTR_DEVICE_ID: timer.device_id or "", + "language": timer.language, + "start_hours": timer.start_hours or 0, + "start_minutes": timer.start_minutes or 0, + "start_seconds": timer.start_seconds or 0, + "is_active": timer.is_active, + "hours_left": hours, + "minutes_left": minutes, + "seconds_left": seconds, + "rounded_hours_left": rounded_hours, + "rounded_minutes_left": rounded_minutes, + "rounded_seconds_left": rounded_seconds, + "total_seconds_left": total_seconds, + } + ) + + response = intent_obj.create_response() + response.async_set_speech_slots({"timers": statuses}) + + return response diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 63b37c08950..d6fbb1edd80 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -8,7 +8,7 @@ from typing import Any, TypedDict import voluptuous as vol from homeassistant.components.script import CONF_MODE -from homeassistant.const import CONF_TYPE, SERVICE_RELOAD +from homeassistant.const import CONF_DESCRIPTION, CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( config_validation as cv, @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "intent_script" +CONF_PLATFORMS = "platforms" CONF_INTENTS = "intents" CONF_SPEECH = "speech" CONF_REPROMPT = "reprompt" @@ -41,6 +42,8 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: { cv.string: { + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_PLATFORMS): vol.All([cv.string], vol.Coerce(set)), vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional( CONF_ASYNC_ACTION, default=DEFAULT_CONF_ASYNC_ACTION @@ -146,6 +149,8 @@ class ScriptIntentHandler(intent.IntentHandler): """Initialize the script intent handler.""" self.intent_type = intent_type self.config = config + self.description = config.get(CONF_DESCRIPTION) + self.platforms = config.get(CONF_PLATFORMS) async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" diff --git a/homeassistant/components/iotawatt/config_flow.py b/homeassistant/components/iotawatt/config_flow.py index b9310b8a2b9..f8821784a1d 100644 --- a/homeassistant/components/iotawatt/config_flow.py +++ b/homeassistant/components/iotawatt/config_flow.py @@ -31,7 +31,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, is_connected = await iotawatt.connect() except CONNECTION_ERRORS: return {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return {"base": "unknown"} diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index ff6d8c3e86c..855587eee2e 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -141,7 +141,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): forecast = self._hourly_forecast if not forecast: - return + return None return self._condition_conversion(forecast[0].weather_type.id, None) diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 10f24a1499d..0a94795613b 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -12,13 +12,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import CONF_BASE_PATH, DOMAIN +from .const import CONF_BASE_PATH from .coordinator import IPPDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +type IPPConfigEntry = ConfigEntry[IPPDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: IPPConfigEntry) -> bool: """Set up IPP from a config entry.""" # config flow sets this to either UUID, serial number or None if (device_id := entry.unique_id) is None: @@ -35,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -44,6 +46,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ipp/diagnostics.py b/homeassistant/components/ipp/diagnostics.py index 67b84183977..9b10dc68966 100644 --- a/homeassistant/components/ipp/diagnostics.py +++ b/homeassistant/components/ipp/diagnostics.py @@ -4,18 +4,16 @@ 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 IPPDataUpdateCoordinator +from . import IPPConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: IPPConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data return { "entry": { diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 5168c5de1fa..2ba82b2cfec 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.15.0"], + "requirements": ["pyipp==0.16.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 8d3b97d0ca5..e872fc7977f 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -15,13 +15,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow +from . import IPPConfigEntry from .const import ( ATTR_COMMAND_SET, ATTR_INFO, @@ -32,9 +32,7 @@ from .const import ( ATTR_STATE_MESSAGE, ATTR_STATE_REASON, ATTR_URI_SUPPORTED, - DOMAIN, ) -from .coordinator import IPPDataUpdateCoordinator from .entity import IPPEntity @@ -89,11 +87,11 @@ PRINTER_SENSORS: tuple[IPPSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: IPPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up IPP sensor based on a config entry.""" - coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors: list[SensorEntity] = [ IPPSensor( coordinator, diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index eef7f929cab..ab05ae19d86 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -58,7 +58,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/isal/__init__.py b/homeassistant/components/isal/__init__.py new file mode 100644 index 00000000000..3df59b7ea9f --- /dev/null +++ b/homeassistant/components/isal/__init__.py @@ -0,0 +1,20 @@ +"""The isal integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType + +DOMAIN = "isal" + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up up isal. + + This integration is only used so that isal can be an optional + dep for aiohttp-fast-zlib. + """ + return True diff --git a/homeassistant/components/isal/manifest.json b/homeassistant/components/isal/manifest.json new file mode 100644 index 00000000000..d367b1c8eb9 --- /dev/null +++ b/homeassistant/components/isal/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "isal", + "name": "Intelligent Storage Acceleration", + "codeowners": ["@bdraco"], + "documentation": "https://www.home-assistant.io/integrations/isal", + "integration_type": "system", + "iot_class": "local_polling", + "quality_scale": "internal", + "requirements": ["isal==1.6.1"] +} diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 15e165d2f48..089afc88564 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -18,8 +18,12 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) _LOGGER = logging.getLogger(__name__) +type IslamicPrayerTimesConfigEntry = ConfigEntry[IslamicPrayerDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: IslamicPrayerTimesConfigEntry +) -> bool: """Set up the Islamic Prayer Component.""" @callback @@ -37,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = IslamicPrayerDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator config_entry.async_on_unload( config_entry.add_update_listener(async_options_updated) ) @@ -72,24 +76,24 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: IslamicPrayerTimesConfigEntry +) -> bool: """Unload Islamic Prayer entry from config_entry.""" if unload_ok := await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ): - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN].pop( - config_entry.entry_id - ) + coordinator = config_entry.runtime_data if coordinator.event_unsub: coordinator.event_unsub() - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] return unload_ok -async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_options_updated( + hass: HomeAssistant, entry: IslamicPrayerTimesConfigEntry +) -> None: """Triggered by config entry options updates.""" - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.event_unsub: coordinator.event_unsub() await coordinator.async_request_refresh() diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index dc4237e5efa..c749c66f8b3 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -23,6 +23,14 @@ CALC_METHODS: Final = [ "turkey", "russia", "moonsighting", + "dubai", + "jakim", + "tunisia", + "algeria", + "kemenag", + "morocco", + "portugal", + "jordan", "custom", ] DEFAULT_CALC_METHOD: Final = "isna" diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index eb042d83c49..c46b629d2d8 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -7,14 +7,14 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -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 IslamicPrayerDataUpdateCoordinator +from . import IslamicPrayerTimesConfigEntry from .const import DOMAIN, NAME +from .coordinator import IslamicPrayerDataUpdateCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -50,15 +50,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: IslamicPrayerTimesConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Islamic prayer times sensor platform.""" - coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] - + coordinator = config_entry.runtime_data async_add_entities( IslamicPrayerTimeSensor(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index 87703e5fdae..359d4626bd4 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -41,6 +41,14 @@ "turkey": "Diyanet İşleri Başkanlığı, Turkey", "russia": "Spiritual Administration of Muslims of Russia", "moonsighting": "Moonsighting Committee Worldwide", + "dubai": "Dubai", + "jakim": "Jabatan Kemajuan Islam Malaysia (JAKIM)", + "tunisia": "Tunisia", + "algeria": "Algeria", + "kemenag": "ementerian Agama Republik Indonesia", + "morocco": "Morocco", + "portugal": "Comunidade Islamica de Lisboa", + "jordan": "Ministry of Awqaf, Islamic Affairs and Holy Places, Jordan", "custom": "Custom" } }, diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py new file mode 100644 index 00000000000..76ef8d13fd4 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -0,0 +1,57 @@ +"""The ista Ecotrend integration.""" + +from __future__ import annotations + +import logging + +from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import IstaCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type IstaConfigEntry = ConfigEntry[IstaCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: + """Set up ista EcoTrend from a config entry.""" + ista = PyEcotrendIsta( + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + _LOGGER, + ) + try: + await hass.async_add_executor_job(ista.login) + except ServerError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_exception", + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, + ) from e + + coordinator = IstaCoordinator(hass, ista) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py new file mode 100644 index 00000000000..15222995a37 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow for ista EcoTrend integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import TYPE_CHECKING, Any + +from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from . import IstaConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +class IstaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ista EcoTrend.""" + + reauth_entry: IstaConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + ista = PyEcotrendIsta( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + _LOGGER, + ) + try: + await self.hass.async_add_executor_job(ista.login) + info = ista.get_account() + except ServerError: + errors["base"] = "cannot_connect" + except (LoginError, KeycloakError): + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if TYPE_CHECKING: + assert info + title = f"{info["firstName"]} {info["lastName"]}".strip() + await self.async_set_unique_id(info["activeConsumptionUnit"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=title or "ista EcoTrend", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + if TYPE_CHECKING: + assert self.reauth_entry + + if user_input is not None: + ista = PyEcotrendIsta( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + _LOGGER, + ) + try: + await self.hass.async_add_executor_job(ista.login) + except ServerError: + errors["base"] = "cannot_connect" + except (LoginError, KeycloakError): + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self.reauth_entry, data=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values={ + CONF_EMAIL: user_input[CONF_EMAIL] + if user_input is not None + else self.reauth_entry.data[CONF_EMAIL] + }, + ), + description_placeholders={ + CONF_NAME: self.reauth_entry.title, + CONF_EMAIL: self.reauth_entry.data[CONF_EMAIL], + }, + errors=errors, + ) diff --git a/homeassistant/components/ista_ecotrend/const.py b/homeassistant/components/ista_ecotrend/const.py new file mode 100644 index 00000000000..92c12b0f0e4 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/const.py @@ -0,0 +1,3 @@ +"""Constants for the ista Ecotrend integration.""" + +DOMAIN = "ista_ecotrend" diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py new file mode 100644 index 00000000000..8d55574f0a1 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -0,0 +1,86 @@ +"""DataUpdateCoordinator for Ista EcoTrend integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError + +from homeassistant.const import CONF_EMAIL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Ista EcoTrend data update coordinator.""" + + def __init__(self, hass: HomeAssistant, ista: PyEcotrendIsta) -> None: + """Initialize ista EcoTrend data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(days=1), + ) + self.ista = ista + self.details: dict[str, Any] = {} + + async def _async_update_data(self): + """Fetch ista EcoTrend data.""" + + if not self.details: + self.details = await self.async_get_details() + + try: + return await self.hass.async_add_executor_job(self.get_consumption_data) + except ServerError as e: + raise UpdateFailed( + "Unable to connect and retrieve data from ista EcoTrend, try again later" + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={CONF_EMAIL: self.ista._email}, # noqa: SLF001 + ) from e + + def get_consumption_data(self) -> dict[str, Any]: + """Get raw json data for all consumption units.""" + + return { + consumption_unit: self.ista.get_consumption_data(consumption_unit) + for consumption_unit in self.ista.get_uuids() + } + + async def async_get_details(self) -> dict[str, Any]: + """Retrieve details of consumption units.""" + try: + result = await self.hass.async_add_executor_job( + self.ista.get_consumption_unit_details + ) + except ServerError as e: + raise UpdateFailed( + "Unable to connect and retrieve data from ista EcoTrend, try again later" + ) from e + except (LoginError, KeycloakError) as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_exception", + translation_placeholders={CONF_EMAIL: self.ista._email}, # noqa: SLF001 + ) from e + else: + return { + consumption_unit: next( + details + for details in result["consumptionUnits"] + if details["id"] == consumption_unit + ) + for consumption_unit in self.ista.get_uuids() + } diff --git a/homeassistant/components/ista_ecotrend/icons.json b/homeassistant/components/ista_ecotrend/icons.json new file mode 100644 index 00000000000..4223e8488ff --- /dev/null +++ b/homeassistant/components/ista_ecotrend/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "heating": { + "default": "mdi:radiator" + }, + "water": { + "default": "mdi:faucet" + }, + "hot_water": { + "default": "mdi:faucet" + } + } + } +} diff --git a/homeassistant/components/ista_ecotrend/manifest.json b/homeassistant/components/ista_ecotrend/manifest.json new file mode 100644 index 00000000000..23d60a0a5bb --- /dev/null +++ b/homeassistant/components/ista_ecotrend/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "ista_ecotrend", + "name": "ista EcoTrend", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ista_ecotrend", + "iot_class": "cloud_polling", + "loggers": ["pyecotrend_ista"], + "requirements": ["pyecotrend-ista==3.3.1"] +} diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py new file mode 100644 index 00000000000..c50f322c356 --- /dev/null +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -0,0 +1,186 @@ +"""Sensor platform for Ista EcoTrend integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfVolume +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 IstaConfigEntry +from .const import DOMAIN +from .coordinator import IstaCoordinator +from .util import IstaConsumptionType, IstaValueType, get_native_value + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(kw_only=True, frozen=True) +class IstaSensorEntityDescription(SensorEntityDescription): + """Ista EcoTrend Sensor Description.""" + + consumption_type: IstaConsumptionType + value_type: IstaValueType | None = None + + +class IstaSensorEntity(StrEnum): + """Ista EcoTrend Entities.""" + + HEATING = "heating" + HEATING_ENERGY = "heating_energy" + HEATING_COST = "heating_cost" + + HOT_WATER = "hot_water" + HOT_WATER_ENERGY = "hot_water_energy" + HOT_WATER_COST = "hot_water_cost" + + WATER = "water" + WATER_COST = "water_cost" + + +SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = ( + IstaSensorEntityDescription( + key=IstaSensorEntity.HEATING, + translation_key=IstaSensorEntity.HEATING, + suggested_display_precision=0, + consumption_type=IstaConsumptionType.HEATING, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HEATING_ENERGY, + translation_key=IstaSensorEntity.HEATING_ENERGY, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + consumption_type=IstaConsumptionType.HEATING, + value_type=IstaValueType.ENERGY, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HEATING_COST, + translation_key=IstaSensorEntity.HEATING_COST, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="EUR", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.HEATING, + value_type=IstaValueType.COSTS, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HOT_WATER, + translation_key=IstaSensorEntity.HOT_WATER, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + consumption_type=IstaConsumptionType.HOT_WATER, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HOT_WATER_ENERGY, + translation_key=IstaSensorEntity.HOT_WATER_ENERGY, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + consumption_type=IstaConsumptionType.HOT_WATER, + value_type=IstaValueType.ENERGY, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.HOT_WATER_COST, + translation_key=IstaSensorEntity.HOT_WATER_COST, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="EUR", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.HOT_WATER, + value_type=IstaValueType.COSTS, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.WATER, + translation_key=IstaSensorEntity.WATER, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=1, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.WATER, + ), + IstaSensorEntityDescription( + key=IstaSensorEntity.WATER_COST, + translation_key=IstaSensorEntity.WATER_COST, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="EUR", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + entity_registry_enabled_default=False, + consumption_type=IstaConsumptionType.WATER, + value_type=IstaValueType.COSTS, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IstaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the ista EcoTrend sensors.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + IstaSensor(coordinator, description, consumption_unit) + for description in SENSOR_DESCRIPTIONS + for consumption_unit in coordinator.data + ) + + +class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity): + """Ista EcoTrend sensor.""" + + entity_description: IstaSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IstaCoordinator, + entity_description: IstaSensorEntityDescription, + consumption_unit: str, + ) -> None: + """Initialize the ista EcoTrend sensor.""" + super().__init__(coordinator) + self.consumption_unit = consumption_unit + self.entity_description = entity_description + self._attr_unique_id = f"{consumption_unit}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ista SE", + model="ista EcoTrend", + name=f"{coordinator.details[consumption_unit]["address"]["street"]} " + f"{coordinator.details[consumption_unit]["address"]["houseNumber"]}".strip(), + configuration_url="https://ecotrend.ista.de/", + identifiers={(DOMAIN, consumption_unit)}, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the device.""" + + return get_native_value( + data=self.coordinator.data[self.consumption_unit], + consumption_type=self.entity_description.consumption_type, + value_type=self.entity_description.value_type, + ) diff --git a/homeassistant/components/ista_ecotrend/strings.json b/homeassistant/components/ista_ecotrend/strings.json new file mode 100644 index 00000000000..f76cf5286cb --- /dev/null +++ b/homeassistant/components/ista_ecotrend/strings.json @@ -0,0 +1,65 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "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%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please reenter the password for: {email}", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + }, + "entity": { + "sensor": { + "heating": { + "name": "Heating" + }, + "heating_cost": { + "name": "Heating cost" + }, + "heating_energy": { + "name": "Heating energy" + }, + "hot_water": { + "name": "Hot water" + }, + "hot_water_cost": { + "name": "Hot water cost" + }, + "hot_water_energy": { + "name": "Hot water energy" + }, + "water": { + "name": "Water" + }, + "water_cost": { + "name": "Water cost" + } + } + }, + "exceptions": { + "authentication_exception": { + "message": "Authentication failed for {email}, check your login credentials" + }, + "connection_exception": { + "message": "Unable to connect and retrieve data from ista EcoTrend, try again later" + } + } +} diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py new file mode 100644 index 00000000000..db64dbf85db --- /dev/null +++ b/homeassistant/components/ista_ecotrend/util.py @@ -0,0 +1,129 @@ +"""Utility functions for Ista EcoTrend integration.""" + +from __future__ import annotations + +import datetime +from enum import StrEnum +from typing import Any + +from homeassistant.util import dt as dt_util + + +class IstaConsumptionType(StrEnum): + """Types of consumptions from ista.""" + + HEATING = "heating" + HOT_WATER = "warmwater" + WATER = "water" + + +class IstaValueType(StrEnum): + """Values type Costs or energy.""" + + COSTS = "costs" + ENERGY = "energy" + + +def get_consumptions( + data: dict[str, Any], value_type: IstaValueType | None = None +) -> list[dict[str, Any]]: + """Get consumption readings and sort in ascending order by date.""" + result: list = [] + if consumptions := data.get( + "costs" if value_type == IstaValueType.COSTS else "consumptions", [] + ): + result = [ + { + "readings": readings.get("costsByEnergyType") + if value_type == IstaValueType.COSTS + else readings.get("readings"), + "date": last_day_of_month(**readings["date"]), + } + for readings in consumptions + ] + result.sort(key=lambda d: d["date"]) + return result + + +def get_values_by_type( + consumptions: dict[str, Any], consumption_type: IstaConsumptionType +) -> dict[str, Any]: + """Get the readings of a certain type.""" + + readings: list = consumptions.get("readings", []) or consumptions.get( + "costsByEnergyType", [] + ) + + return next( + (values for values in readings if values.get("type") == consumption_type.value), + {}, + ) + + +def as_number(value: str | float | None) -> float | int | None: + """Convert readings to float or int. + + Readings in the json response are returned as strings, + float values have comma as decimal separator + """ + if isinstance(value, str): + return int(value) if value.isdigit() else float(value.replace(",", ".")) + + return value + + +def last_day_of_month(month: int, year: int) -> datetime.datetime: + """Get the last day of the month.""" + + return dt_util.as_local( + datetime.datetime( + month=month + 1 if month < 12 else 1, + year=year if month < 12 else year + 1, + day=1, + tzinfo=datetime.UTC, + ) + + datetime.timedelta(days=-1) + ) + + +def get_native_value( + data, + consumption_type: IstaConsumptionType, + value_type: IstaValueType | None = None, +) -> int | float | None: + """Determine the latest value for the sensor.""" + + if last_value := get_statistics(data, consumption_type, value_type): + return last_value[-1].get("value") + return None + + +def get_statistics( + data, + consumption_type: IstaConsumptionType, + value_type: IstaValueType | None = None, +) -> list[dict[str, Any]] | None: + """Determine the latest value for the sensor.""" + + if monthly_consumptions := get_consumptions(data, value_type): + return [ + { + "value": as_number( + get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ).get( + "additionalValue" + if value_type == IstaValueType.ENERGY + else "value" + ) + ), + "date": consumptions["date"], + } + for consumptions in monthly_consumptions + if get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") + ] + return None diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index c130ba32746..179944ad35f 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -447,7 +447,7 @@ class ISYBinarySensorHeartbeat(ISYNodeEntity, BinarySensorEntity, RestoreEntity) self._node.control_events.subscribe(self._heartbeat_node_control_handler) - # Start the timer on bootup, so we can change from UNKNOWN to OFF + # Start the timer on boot-up, so we can change from UNKNOWN to OFF self._restart_timer() if (last_state := await self.async_get_last_state()) is not None: diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 639e591746d..0239926f5e3 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -157,7 +157,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except InvalidAuth: errors[CONF_PASSWORD] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 391ad18e02f..c05bd2ddbbb 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -34,7 +34,7 @@ from .models import IsyData @dataclass(frozen=True) class ISYSwitchEntityDescription(SwitchEntityDescription): - """Describes IST switch.""" + """Describes ISY switch.""" # ISYEnableSwitchEntity does not support UNDEFINED or None, # restrict the type to str. diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 1786ef23522..14267a626fc 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from pizone import Controller, Zone import voluptuous as vol @@ -48,11 +48,7 @@ from .const import ( IZONE, ) -_DeviceT = TypeVar("_DeviceT", bound="ControllerDevice | ZoneDevice") -_T = TypeVar("_T") -_R = TypeVar("_R") -_P = ParamSpec("_P") -_FuncType = Callable[Concatenate[_T, _P], _R] +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] _LOGGER = logging.getLogger(__name__) @@ -119,7 +115,7 @@ async def async_setup_entry( ) -def _return_on_connection_error( +def _return_on_connection_error[_DeviceT: ControllerDevice | ZoneDevice, **_P, _R, _T]( ret: _T = None, # type: ignore[assignment] ) -> Callable[[_FuncType[_DeviceT, _P, _R]], _FuncType[_DeviceT, _P, _R | _T]]: def wrap(func: _FuncType[_DeviceT, _P, _R]) -> _FuncType[_DeviceT, _P, _R | _T]: diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 9a1e3d5985c..4798a07b9cd 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -8,12 +8,18 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import callback from homeassistant.util.uuid import random_uuid_hex from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, SUPPORTED_AUDIO_CODECS _LOGGER = logging.getLogger(__name__) @@ -32,6 +38,11 @@ REAUTH_DATA_SCHEMA = vol.Schema( ) +OPTIONAL_DATA_SCHEMA = vol.Schema( + {vol.Optional("audio_codec"): vol.In(SUPPORTED_AUDIO_CODECS)} +) + + def _generate_client_device_id() -> str: """Generate a random UUID4 string to identify ourselves.""" return random_uuid_hex() @@ -66,7 +77,7 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: errors["base"] = "unknown" _LOGGER.exception("Unexpected exception") else: @@ -116,7 +127,7 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: errors["base"] = "unknown" _LOGGER.exception("Unexpected exception") else: @@ -128,3 +139,31 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(OptionsFlow): + """Handle an option flow for jellyfin.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONAL_DATA_SCHEMA, self.config_entry.options + ), + ) diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index 764356e2ea6..34fb040115f 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -14,6 +14,7 @@ COLLECTION_TYPE_MOVIES: Final = "movies" COLLECTION_TYPE_MUSIC: Final = "music" COLLECTION_TYPE_TVSHOWS: Final = "tvshows" +CONF_AUDIO_CODEC: Final = "audio_codec" CONF_CLIENT_DEVICE_ID: Final = "client_device_id" DEFAULT_NAME: Final = "Jellyfin" @@ -50,6 +51,8 @@ SUPPORTED_COLLECTION_TYPES: Final = [ COLLECTION_TYPE_TVSHOWS, ] +SUPPORTED_AUDIO_CODECS: Final = ["aac", "mp3", "vorbis", "wma"] + PLAYABLE_ITEM_TYPES: Final = [ITEM_TYPE_AUDIO, ITEM_TYPE_EPISODE, ITEM_TYPE_MOVIE] diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index 6d982458378..8901e9e32c0 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -17,11 +17,13 @@ from homeassistant.components.media_source.models import ( MediaSourceItem, PlayMedia, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import ( COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, + CONF_AUDIO_CODEC, DOMAIN, ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_ID, @@ -57,7 +59,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: entry = hass.config_entries.async_entries(DOMAIN)[0] jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] - return JellyfinSource(hass, jellyfin_data.jellyfin_client) + return JellyfinSource(hass, jellyfin_data.jellyfin_client, entry) class JellyfinSource(MediaSource): @@ -65,11 +67,14 @@ class JellyfinSource(MediaSource): name: str = "Jellyfin" - def __init__(self, hass: HomeAssistant, client: JellyfinClient) -> None: + def __init__( + self, hass: HomeAssistant, client: JellyfinClient, entry: ConfigEntry + ) -> None: """Initialize the Jellyfin media source.""" super().__init__(DOMAIN) self.hass = hass + self.entry = entry self.client = client self.api = client.jellyfin @@ -391,7 +396,7 @@ class JellyfinSource(MediaSource): k.get(ITEM_KEY_NAME), ), ) - return [await self._build_series(serie, False) for serie in series] + return [await self._build_series(s, False) for s in series] async def _build_series( self, series: dict[str, Any], include_children: bool @@ -524,6 +529,8 @@ class JellyfinSource(MediaSource): item_id = media_item[ITEM_KEY_ID] if media_type == MEDIA_TYPE_AUDIO: + if audio_codec := self.entry.options.get(CONF_AUDIO_CODEC): + return self.api.audio_url(item_id, audio_codec=audio_codec) # type: ignore[no-any-return] return self.api.audio_url(item_id) # type: ignore[no-any-return] if media_type == MEDIA_TYPE_VIDEO: return self.api.video_url(item_id) # type: ignore[no-any-return] diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index 3e4c8066b77..fd11d8fbad2 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -25,5 +25,14 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "audio_codec": "Audio codec" + } + } + } } } diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 1ce5386d2c2..81fe6cb5377 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -5,41 +5,60 @@ from __future__ import annotations from hdate import Location import voluptuous as vol -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_TIME_ZONE, + Platform, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -DOMAIN = "jewish_calendar" +from .binary_sensor import BINARY_SENSORS +from .const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_CANDLE_LIGHT, + DEFAULT_DIASPORA, + DEFAULT_HAVDALAH_OFFSET_MINUTES, + DEFAULT_LANGUAGE, + DEFAULT_NAME, + DOMAIN, +) +from .sensor import INFO_SENSORS, TIME_SENSORS + PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -CONF_DIASPORA = "diaspora" -CONF_LANGUAGE = "language" -CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" -CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" - -CANDLE_LIGHT_DEFAULT = 18 - -DEFAULT_NAME = "Jewish Calendar" - CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( + DOMAIN: vol.All( + cv.deprecated(DOMAIN), { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DIASPORA, default=False): cv.boolean, + vol.Optional(CONF_DIASPORA, default=DEFAULT_DIASPORA): cv.boolean, vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_LANGUAGE, default="english"): vol.In( + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( ["hebrew", "english"] ), vol.Optional( - CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT + CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT ): int, # Default of 0 means use 8.5 degrees / 'three_stars' time. - vol.Optional(CONF_HAVDALAH_OFFSET_MINUTES, default=0): int, - } + vol.Optional( + CONF_HAVDALAH_OFFSET_MINUTES, + default=DEFAULT_HAVDALAH_OFFSET_MINUTES, + ): int, + }, ) }, extra=vol.ALLOW_EXTRA, @@ -53,11 +72,14 @@ def get_unique_prefix( havdalah_offset: int | None, ) -> str: """Create a prefix for unique ids.""" + # location.altitude was unset before 2024.6 when this method + # was used to create the unique id. As such it would always + # use the default altitude of 754. config_properties = [ location.latitude, location.longitude, location.timezone, - location.altitude, + 754, location.diaspora, language, candle_lighting_offset, @@ -72,37 +94,107 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True - name = config[DOMAIN][CONF_NAME] - language = config[DOMAIN][CONF_LANGUAGE] - - latitude = config[DOMAIN].get(CONF_LATITUDE, hass.config.latitude) - longitude = config[DOMAIN].get(CONF_LONGITUDE, hass.config.longitude) - diaspora = config[DOMAIN][CONF_DIASPORA] - - candle_lighting_offset = config[DOMAIN][CONF_CANDLE_LIGHT_MINUTES] - havdalah_offset = config[DOMAIN][CONF_HAVDALAH_OFFSET_MINUTES] - - location = Location( - latitude=latitude, - longitude=longitude, - timezone=hass.config.time_zone, - diaspora=diaspora, + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2024.12.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": DEFAULT_NAME, + }, ) - prefix = get_unique_prefix( - location, language, candle_lighting_offset, havdalah_offset + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) ) - hass.data[DOMAIN] = { - "location": location, - "name": name, - "language": language, - "candle_lighting_offset": candle_lighting_offset, - "havdalah_offset": havdalah_offset, - "diaspora": diaspora, - "prefix": prefix, - } - - for platform in PLATFORMS: - hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up a configuration entry for Jewish calendar.""" + language = config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE) + diaspora = config_entry.data.get(CONF_DIASPORA, DEFAULT_DIASPORA) + candle_lighting_offset = config_entry.options.get( + CONF_CANDLE_LIGHT_MINUTES, DEFAULT_CANDLE_LIGHT + ) + havdalah_offset = config_entry.options.get( + CONF_HAVDALAH_OFFSET_MINUTES, DEFAULT_HAVDALAH_OFFSET_MINUTES + ) + + location = Location( + name=hass.config.location_name, + diaspora=diaspora, + latitude=config_entry.data.get(CONF_LATITUDE, hass.config.latitude), + longitude=config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), + altitude=config_entry.data.get(CONF_ELEVATION, hass.config.elevation), + timezone=config_entry.data.get(CONF_TIME_ZONE, hass.config.time_zone), + ) + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { + CONF_LANGUAGE: language, + CONF_DIASPORA: diaspora, + CONF_LOCATION: location, + CONF_CANDLE_LIGHT_MINUTES: candle_lighting_offset, + CONF_HAVDALAH_OFFSET_MINUTES: havdalah_offset, + } + + # Update unique ID to be unrelated to user defined options + old_prefix = get_unique_prefix( + location, language, candle_lighting_offset, havdalah_offset + ) + + ent_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) + if not entries or any(entry.unique_id.startswith(old_prefix) for entry in entries): + async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + # Trigger update of states for all platforms + await hass.config_entries.async_reload(config_entry.entry_id) + + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a 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 + + +@callback +def async_update_unique_ids( + ent_reg: er.EntityRegistry, new_prefix: str, old_prefix: str +) -> None: + """Update unique ID to be unrelated to user defined options. + + Introduced with release 2024.6 + """ + platform_descriptions = { + Platform.BINARY_SENSOR: BINARY_SENSORS, + Platform.SENSOR: (*INFO_SENSORS, *TIME_SENSORS), + } + for platform, descriptions in platform_descriptions.items(): + for description in descriptions: + new_unique_id = f"{new_prefix}-{description.key}" + old_unique_id = f"{old_prefix}_{description.key}" + if entity_id := ent_reg.async_get_entity_id( + platform, DOMAIN, old_unique_id + ): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 73ddca27cc1..c28dee88cf5 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass import datetime as dt from datetime import datetime +from typing import Any import hdate from hdate.zmanim import Zmanim @@ -14,20 +15,26 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LANGUAGE, CONF_LOCATION from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN +from .const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_NAME, + DOMAIN, +) @dataclass(frozen=True) class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): """Binary Sensor description mixin class for Jewish Calendar.""" - is_on: Callable[..., bool] = lambda _: False + is_on: Callable[[Zmanim], bool] = lambda _: False @dataclass(frozen=True) @@ -47,31 +54,27 @@ BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", name="Erev Shabbat/Hag", - is_on=lambda state: bool(state.erev_shabbat_hag), + is_on=lambda state: bool(state.erev_shabbat_chag), ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", name="Motzei Shabbat/Hag", - is_on=lambda state: bool(state.motzei_shabbat_hag), + is_on=lambda state: bool(state.motzei_shabbat_chag), ), ) -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 Jewish Calendar binary sensor devices.""" - if discovery_info is None: - return + """Set up the Jewish Calendar binary sensors.""" + entry = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - [ - JewishCalendarBinarySensor(hass.data[DOMAIN], description) - for description in BINARY_SENSORS - ] + JewishCalendarBinarySensor(config_entry.entry_id, entry, description) + for description in BINARY_SENSORS ) @@ -83,17 +86,18 @@ class JewishCalendarBinarySensor(BinarySensorEntity): def __init__( self, - data: dict[str, str | bool | int | float], + entry_id: str, + data: dict[str, Any], description: JewishCalendarBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor.""" self.entity_description = description - self._attr_name = f"{data['name']} {description.name}" - self._attr_unique_id = f"{data['prefix']}_{description.key}" - self._location = data["location"] - self._hebrew = data["language"] == "hebrew" - self._candle_lighting_offset = data["candle_lighting_offset"] - self._havdalah_offset = data["havdalah_offset"] + self._attr_name = f"{DEFAULT_NAME} {description.name}" + self._attr_unique_id = f"{entry_id}-{description.key}" + self._location = data[CONF_LOCATION] + self._hebrew = data[CONF_LANGUAGE] == "hebrew" + self._candle_lighting_offset = data[CONF_CANDLE_LIGHT_MINUTES] + self._havdalah_offset = data[CONF_HAVDALAH_OFFSET_MINUTES] self._update_unsub: CALLBACK_TYPE | None = None @property diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py new file mode 100644 index 00000000000..8f04d73915f --- /dev/null +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -0,0 +1,150 @@ +"""Config flow for Jewish calendar integration.""" + +from __future__ import annotations + +import logging +from typing import Any +import zoneinfo + +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_TIME_ZONE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.selector import ( + BooleanSelector, + LocationSelector, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_CANDLE_LIGHT, + DEFAULT_DIASPORA, + DEFAULT_HAVDALAH_OFFSET_MINUTES, + DEFAULT_LANGUAGE, + DEFAULT_NAME, + DOMAIN, +) + +LANGUAGE = [ + SelectOptionDict(value="hebrew", label="Hebrew"), + SelectOptionDict(value="english", label="English"), +] + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT): int, + vol.Optional( + CONF_HAVDALAH_OFFSET_MINUTES, default=DEFAULT_HAVDALAH_OFFSET_MINUTES + ): int, + } +) + + +_LOGGER = logging.getLogger(__name__) + + +def _get_data_schema(hass: HomeAssistant) -> vol.Schema: + default_location = { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + } + return vol.Schema( + { + vol.Required(CONF_DIASPORA, default=DEFAULT_DIASPORA): BooleanSelector(), + vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): SelectSelector( + SelectSelectorConfig(options=LANGUAGE) + ), + vol.Optional(CONF_LOCATION, default=default_location): LocationSelector(), + vol.Optional(CONF_ELEVATION, default=hass.config.elevation): int, + vol.Optional(CONF_TIME_ZONE, default=hass.config.time_zone): SelectSelector( + SelectSelectorConfig( + options=sorted(zoneinfo.available_timezones()), + ) + ), + } + ) + + +class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Jewish calendar.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithConfigEntry: + """Get the options flow for this handler.""" + return JewishCalendarOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is not None: + _options = {} + if CONF_CANDLE_LIGHT_MINUTES in user_input: + _options[CONF_CANDLE_LIGHT_MINUTES] = user_input[ + CONF_CANDLE_LIGHT_MINUTES + ] + del user_input[CONF_CANDLE_LIGHT_MINUTES] + if CONF_HAVDALAH_OFFSET_MINUTES in user_input: + _options[CONF_HAVDALAH_OFFSET_MINUTES] = user_input[ + CONF_HAVDALAH_OFFSET_MINUTES + ] + del user_input[CONF_HAVDALAH_OFFSET_MINUTES] + if CONF_LOCATION in user_input: + user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE] + user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE] + return self.async_create_entry( + title=DEFAULT_NAME, data=user_input, options=_options + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + _get_data_schema(self.hass), user_input + ), + ) + + async def async_step_import( + self, import_config: ConfigType | None + ) -> ConfigFlowResult: + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + +class JewishCalendarOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handle Jewish Calendar options.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Manage the Jewish Calendar options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options + ), + ) diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py new file mode 100644 index 00000000000..4af76a8927b --- /dev/null +++ b/homeassistant/components/jewish_calendar/const.py @@ -0,0 +1,13 @@ +"""Jewish Calendar constants.""" + +DOMAIN = "jewish_calendar" + +CONF_DIASPORA = "diaspora" +CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" +CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" + +DEFAULT_NAME = "Jewish Calendar" +DEFAULT_CANDLE_LIGHT = 18 +DEFAULT_DIASPORA = False +DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 +DEFAULT_LANGUAGE = "english" diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 787550745d7..6d2fe8ecfa1 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -2,8 +2,10 @@ "domain": "jewish_calendar", "name": "Jewish Calendar", "codeowners": ["@tsvi"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate==0.10.4"] + "requirements": ["hdate==0.10.9"], + "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 2a16ecb9c14..aff9d7ee602 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -1,12 +1,12 @@ -"""Platform to retrieve Jewish calendar information for Home Assistant.""" +"""Support for Jewish calendar sensors.""" from __future__ import annotations from datetime import date as Date import logging -from typing import Any +from typing import Any, cast -from hdate import HDate +from hdate import HDate, HebrewDate, htables from hdate.zmanim import Zmanim from homeassistant.components.sensor import ( @@ -14,32 +14,41 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import SUN_EVENT_SUNSET +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LANGUAGE, CONF_LOCATION, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DOMAIN +from .const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_NAME, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -INFO_SENSORS = ( +INFO_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="date", name="Date", icon="mdi:star-david", + translation_key="hebrew_date", ), SensorEntityDescription( key="weekly_portion", name="Parshat Hashavua", icon="mdi:book-open-variant", + device_class=SensorDeviceClass.ENUM, ), SensorEntityDescription( key="holiday", name="Holiday", icon="mdi:calendar-star", + device_class=SensorDeviceClass.ENUM, ), SensorEntityDescription( key="omer_count", @@ -53,10 +62,10 @@ INFO_SENSORS = ( ), ) -TIME_SENSORS = ( +TIME_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="first_light", - name="Alot Hashachar", + name="Alot Hashachar", # codespell:ignore alot icon="mdi:weather-sunset-up", ), SensorEntityDescription( @@ -119,6 +128,11 @@ TIME_SENSORS = ( name="T'set Hakochavim", icon="mdi:weather-night", ), + SensorEntityDescription( + key="three_stars", + name="T'set Hakochavim, 3 stars", + icon="mdi:weather-night", + ), SensorEntityDescription( key="upcoming_shabbat_candle_lighting", name="Upcoming Shabbat Candle Lighting", @@ -142,22 +156,19 @@ TIME_SENSORS = ( ) -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 Jewish calendar sensor platform.""" - if discovery_info is None: - return - + """Set up the Jewish calendar sensors .""" + entry = hass.data[DOMAIN][config_entry.entry_id] sensors = [ - JewishCalendarSensor(hass.data[DOMAIN], description) + JewishCalendarSensor(config_entry.entry_id, entry, description) for description in INFO_SENSORS ] sensors.extend( - JewishCalendarTimeSensor(hass.data[DOMAIN], description) + JewishCalendarTimeSensor(config_entry.entry_id, entry, description) for description in TIME_SENSORS ) @@ -169,19 +180,20 @@ class JewishCalendarSensor(SensorEntity): def __init__( self, - data: dict[str, str | bool | int | float], + entry_id: str, + data: dict[str, Any], description: SensorEntityDescription, ) -> None: """Initialize the Jewish calendar sensor.""" self.entity_description = description - self._attr_name = f"{data['name']} {description.name}" - self._attr_unique_id = f"{data['prefix']}_{description.key}" - self._location = data["location"] - self._hebrew = data["language"] == "hebrew" - self._candle_lighting_offset = data["candle_lighting_offset"] - self._havdalah_offset = data["havdalah_offset"] - self._diaspora = data["diaspora"] - self._holiday_attrs: dict[str, str] = {} + self._attr_name = f"{DEFAULT_NAME} {description.name}" + self._attr_unique_id = f"{entry_id}-{description.key}" + self._location = data[CONF_LOCATION] + self._hebrew = data[CONF_LANGUAGE] == "hebrew" + self._candle_lighting_offset = data[CONF_CANDLE_LIGHT_MINUTES] + self._havdalah_offset = data[CONF_HAVDALAH_OFFSET_MINUTES] + self._diaspora = data[CONF_DIASPORA] + self._attrs: dict[str, str] = {} async def async_update(self) -> None: """Update the state of the sensor.""" @@ -202,8 +214,9 @@ class JewishCalendarSensor(SensorEntity): daytime_date = HDate(today, diaspora=self._diaspora, hebrew=self._hebrew) # The Jewish day starts after darkness (called "tzais") and finishes at - # sunset ("shkia"). The time in between is a gray area (aka "Bein - # Hashmashot" - literally: "in between the sun and the moon"). + # sunset ("shkia"). The time in between is a gray area + # (aka "Bein Hashmashot" # codespell:ignore + # - literally: "in between the sun and the moon"). # For some sensors, it is more interesting to consider the date to be # tomorrow based on sunset ("shkia"), for others based on "tzais". @@ -237,9 +250,7 @@ class JewishCalendarSensor(SensorEntity): @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - if self.entity_description.key != "holiday": - return {} - return self._holiday_attrs + return self._attrs def get_state( self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate @@ -248,16 +259,31 @@ class JewishCalendarSensor(SensorEntity): # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. if self.entity_description.key == "date": + hdate = cast(HebrewDate, after_shkia_date.hdate) + month = htables.MONTHS[hdate.month.value - 1] + self._attrs = { + "hebrew_year": hdate.year, + "hebrew_month_name": month.hebrew if self._hebrew else month.english, + "hebrew_day": hdate.day, + } return after_shkia_date.hebrew_date if self.entity_description.key == "weekly_portion": + self._attr_options = [ + (p.hebrew if self._hebrew else p.english) for p in htables.PARASHAOT + ] # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha if self.entity_description.key == "holiday": - self._holiday_attrs = { + self._attrs = { "id": after_shkia_date.holiday_name, "type": after_shkia_date.holiday_type.name, "type_id": after_shkia_date.holiday_type.value, } + self._attr_options = [ + h.description.hebrew.long if self._hebrew else h.description.english + for h in htables.HOLIDAYS + ] + return after_shkia_date.holiday_description if self.entity_description.key == "omer_count": return after_shkia_date.omer_day diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json new file mode 100644 index 00000000000..e5367b5819e --- /dev/null +++ b/homeassistant/components/jewish_calendar/strings.json @@ -0,0 +1,48 @@ +{ + "entity": { + "sensor": { + "hebrew_date": { + "state_attributes": { + "hebrew_year": { "name": "Hebrew Year" }, + "hebrew_month_name": { "name": "Hebrew Month Name" }, + "hebrew_day": { "name": "Hebrew Day" } + } + } + } + }, + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "diaspora": "Outside of Israel?", + "language": "Language for Holidays and Dates", + "location": "[%key:common::config_flow::data::location%]", + "elevation": "[%key:common::config_flow::data::elevation%]", + "time_zone": "Time Zone" + }, + "data_description": { + "time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure options for Jewish Calendar", + "data": { + "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighthing", + "havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah" + }, + "data_description": { + "candle_lighting_minutes_before_sunset": "Defaults to 18 minutes. In Israel you probably want to use 20/30/40 depending on your location. Outside of Israel you probably want to use 18/24.", + "havdalah_minutes_after_sunset": "Setting this to 0 means 36 minutes as fixed degrees (8.5°) will be used instead" + } + } + } + } +} diff --git a/homeassistant/components/juicenet/config_flow.py b/homeassistant/components/juicenet/config_flow.py index 237c89922b2..607ffb6ffe2 100644 --- a/homeassistant/components/juicenet/config_flow.py +++ b/homeassistant/components/juicenet/config_flow.py @@ -58,7 +58,7 @@ class JuiceNetConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/justnimbus/config_flow.py b/homeassistant/components/justnimbus/config_flow.py index 2a286c41b5f..0520c558266 100644 --- a/homeassistant/components/justnimbus/config_flow.py +++ b/homeassistant/components/justnimbus/config_flow.py @@ -56,7 +56,7 @@ class JustNimbusConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except justnimbus.JustNimbusError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py index 945cc6e9b86..476571a12bf 100644 --- a/homeassistant/components/kaiterra/api_data.py +++ b/homeassistant/components/kaiterra/api_data.py @@ -87,10 +87,11 @@ class KaiterraApiData: main_pollutant = POLLUTANTS.get(sensor_name) level = None - for j in range(1, len(self._scale)): - if aqi <= self._scale[j]: - level = self._level[j - 1] - break + if aqi is not None: + for j in range(1, len(self._scale)): + if aqi <= self._scale[j]: + level = self._level[j - 1] + break device["aqi"] = {"value": aqi} device["aqi_level"] = {"value": level} diff --git a/homeassistant/components/kegtron/sensor.py b/homeassistant/components/kegtron/sensor.py index 4fc4ac9242f..e0638fccea0 100644 --- a/homeassistant/components/kegtron/sensor.py +++ b/homeassistant/components/kegtron/sensor.py @@ -126,7 +126,9 @@ async def async_setup_entry( class KegtronBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a Kegtron sensor.""" diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py index 228e383e94d..9b8093c2f0b 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import STATE_LOCKED, STATE_OPEN, STATE_UNLOCKED from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -79,6 +79,11 @@ class DemoLock(LockEntity): """Return true if lock is locked.""" return self._state == STATE_LOCKED + @property + def is_open(self) -> bool: + """Return true if lock is open.""" + return self._state == STATE_OPEN + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" self._attr_is_locking = True @@ -97,5 +102,5 @@ class DemoLock(LockEntity): async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._state = STATE_UNLOCKED + self._state = STATE_OPEN self.async_write_ha_state() diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py index dd0a7652418..746b075789f 100644 --- a/homeassistant/components/kmtronic/config_flow.py +++ b/homeassistant/components/kmtronic/config_flow.py @@ -74,7 +74,7 @@ class KmtronicConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py new file mode 100644 index 00000000000..ef024d6f4d6 --- /dev/null +++ b/homeassistant/components/knocki/__init__.py @@ -0,0 +1,52 @@ +"""The Knocki integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from knocki import KnockiClient, KnockiConnectionError, Trigger + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +PLATFORMS: list[Platform] = [Platform.EVENT] + +type KnockiConfigEntry = ConfigEntry[KnockiData] + + +@dataclass +class KnockiData: + """Knocki data.""" + + client: KnockiClient + triggers: list[Trigger] + + +async def async_setup_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: + """Set up Knocki from a config entry.""" + client = KnockiClient( + session=async_get_clientsession(hass), token=entry.data[CONF_TOKEN] + ) + + try: + triggers = await client.get_triggers() + except KnockiConnectionError as exc: + raise ConfigEntryNotReady from exc + + entry.runtime_data = KnockiData(client=client, triggers=triggers) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_create_background_task( + hass, client.start_websocket(), "knocki-websocket" + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/knocki/config_flow.py b/homeassistant/components/knocki/config_flow.py new file mode 100644 index 00000000000..724c65f83df --- /dev/null +++ b/homeassistant/components/knocki/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Knocki integration.""" + +from __future__ import annotations + +from typing import Any + +from knocki import KnockiClient, KnockiConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class KnockiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Knocki.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + client = KnockiClient(session=async_get_clientsession(self.hass)) + try: + token_response = await client.login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + await self.async_set_unique_id(token_response.user_id) + self._abort_if_unique_id_configured() + client.token = token_response.token + await client.link() + except HomeAssistantError: + # Catch the unique_id abort and reraise it to keep the code clean + raise + except KnockiConnectionError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Error logging into the Knocki API") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_TOKEN: token_response.token, + }, + ) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=DATA_SCHEMA, + ) diff --git a/homeassistant/components/knocki/const.py b/homeassistant/components/knocki/const.py new file mode 100644 index 00000000000..a54852e9292 --- /dev/null +++ b/homeassistant/components/knocki/const.py @@ -0,0 +1,7 @@ +"""Constants for the Knocki integration.""" + +import logging + +DOMAIN = "knocki" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/knocki/event.py b/homeassistant/components/knocki/event.py new file mode 100644 index 00000000000..8cd5de21958 --- /dev/null +++ b/homeassistant/components/knocki/event.py @@ -0,0 +1,64 @@ +"""Event entity for Knocki integration.""" + +from knocki import Event, EventType, KnockiClient, Trigger + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import KnockiConfigEntry +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: KnockiConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Knocki from a config entry.""" + entry_data = entry.runtime_data + + async_add_entities( + KnockiTrigger(trigger, entry_data.client) for trigger in entry_data.triggers + ) + + +EVENT_TRIGGERED = "triggered" + + +class KnockiTrigger(EventEntity): + """Representation of a Knocki trigger.""" + + _attr_event_types = [EVENT_TRIGGERED] + _attr_has_entity_name = True + _attr_translation_key = "knocki" + + def __init__(self, trigger: Trigger, client: KnockiClient) -> None: + """Initialize the entity.""" + self._trigger = trigger + self._client = client + self._attr_name = trigger.details.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, trigger.device_id)}, + manufacturer="Knocki", + serial_number=trigger.device_id, + name=trigger.device_id, + ) + self._attr_unique_id = f"{trigger.device_id}_{trigger.details.trigger_id}" + + async def async_added_to_hass(self) -> None: + """Register listener.""" + await super().async_added_to_hass() + self.async_on_remove( + self._client.register_listener(EventType.TRIGGERED, self._handle_event) + ) + + def _handle_event(self, event: Event) -> None: + """Handle incoming event.""" + if ( + event.payload.details.trigger_id == self._trigger.details.trigger_id + and event.payload.device_id == self._trigger.device_id + ): + self._trigger_event(EVENT_TRIGGERED) + self.schedule_update_ha_state() diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json new file mode 100644 index 00000000000..bf4dcea4b67 --- /dev/null +++ b/homeassistant/components/knocki/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "knocki", + "name": "Knocki", + "codeowners": ["@joostlek", "@jgatto1"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/knocki", + "integration_type": "device", + "iot_class": "cloud_push", + "loggers": ["knocki"], + "requirements": ["knocki==0.1.5"] +} diff --git a/homeassistant/components/knocki/strings.json b/homeassistant/components/knocki/strings.json new file mode 100644 index 00000000000..b7a7daad1fc --- /dev/null +++ b/homeassistant/components/knocki/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "event": { + "knocki": { + "state_attributes": { + "event_type": { + "state": { + "triggered": "Triggered" + } + } + } + } + } + } +} diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index da68dc36a6d..9c64b4e1b31 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -191,15 +191,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: create_knx_exposure(hass, knx_module.xknx, expose_config) ) # always forward sensor for system entities (telegram counter, etc.) - await hass.config_entries.async_forward_entry_setup(entry, Platform.SENSOR) - await hass.config_entries.async_forward_entry_setups( - entry, - [ - platform - for platform in SUPPORTED_PLATFORMS - if platform in config and platform is not Platform.SENSOR - ], - ) + platforms = {platform for platform in SUPPORTED_PLATFORMS if platform in config} + platforms.add(Platform.SENSOR) + await hass.config_entries.async_forward_entry_setups(entry, platforms) # set up notify service for backwards compatibility - remove 2024.11 if NotifySchema.PLATFORM in config: diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index ce1e4f018b9..e1179641cdc 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -141,11 +141,20 @@ class KNXClimate(KnxEntity, ClimateEntity): """Initialize of a KNX climate device.""" super().__init__(_create_climate(xknx, config)) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON - ) + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE if self._device.supports_on_off: - self._attr_supported_features |= ClimateEntityFeature.TURN_OFF + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if ( + self._device.mode is not None + and len(self._device.mode.controller_modes) >= 2 + and HVACControllerMode.OFF in self._device.mode.controller_modes + ): + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if self.preset_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_target_temperature_step = self._device.temperature_step @@ -153,11 +162,13 @@ class KNXClimate(KnxEntity, ClimateEntity): f"{self._device.temperature.group_address_state}_" f"{self._device.target_temperature.group_address_state}_" f"{self._device.target_temperature.group_address}_" - f"{self._device._setpoint_shift.group_address}" + f"{self._device._setpoint_shift.group_address}" # noqa: SLF001 ) self.default_hvac_mode: HVACMode = config[ ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE ] + # non-OFF HVAC mode to be used when turning on the device without on_off address + self._last_hvac_mode: HVACMode = self.default_hvac_mode @property def current_temperature(self) -> float | None: @@ -181,6 +192,34 @@ class KNXClimate(KnxEntity, ClimateEntity): temp = self._device.target_temperature_max return temp if temp is not None else super().max_temp + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if self._device.supports_on_off: + await self._device.turn_on() + self.async_write_ha_state() + return + + if self._device.mode is not None and self._device.mode.supports_controller_mode: + knx_controller_mode = HVACControllerMode( + CONTROLLER_MODES_INV.get(self._last_hvac_mode) + ) + await self._device.mode.set_controller_mode(knx_controller_mode) + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if self._device.supports_on_off: + await self._device.turn_off() + self.async_write_ha_state() + return + + if ( + self._device.mode is not None + and HVACControllerMode.OFF in self._device.mode.controller_modes + ): + await self._device.mode.set_controller_mode(HVACControllerMode.OFF) + self.async_write_ha_state() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) @@ -194,9 +233,12 @@ class KNXClimate(KnxEntity, ClimateEntity): if self._device.supports_on_off and not self._device.is_on: return HVACMode.OFF if self._device.mode is not None and self._device.mode.supports_controller_mode: - return CONTROLLER_MODES.get( + hvac_mode = CONTROLLER_MODES.get( self._device.mode.controller_mode.value, self.default_hvac_mode ) + if hvac_mode is not HVACMode.OFF: + self._last_hvac_mode = hvac_mode + return hvac_mode return self.default_hvac_mode @property @@ -234,20 +276,19 @@ class KNXClimate(KnxEntity, ClimateEntity): return None async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set operation mode.""" - if self._device.supports_on_off and hvac_mode == HVACMode.OFF: - await self._device.turn_off() - else: - if self._device.supports_on_off and not self._device.is_on: - await self._device.turn_on() - if ( - self._device.mode is not None - and self._device.mode.supports_controller_mode - ): - knx_controller_mode = HVACControllerMode( - CONTROLLER_MODES_INV.get(hvac_mode) - ) + """Set controller mode.""" + if self._device.mode is not None and self._device.mode.supports_controller_mode: + knx_controller_mode = HVACControllerMode( + CONTROLLER_MODES_INV.get(hvac_mode) + ) + if knx_controller_mode in self._device.mode.controller_modes: await self._device.mode.set_controller_mode(knx_controller_mode) + + if self._device.supports_on_off: + if hvac_mode == HVACMode.OFF: + await self._device.turn_off() + elif not self._device.is_on: + await self._device.turn_on() self.async_write_ha_state() @property diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index af1eee89af7..22c4a647e80 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -3,9 +3,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import AsyncGenerator from typing import Any, Final +from typing_extensions import AsyncGenerator import voluptuous as vol from xknx import XKNX from xknx.exceptions.exception import ( @@ -118,7 +118,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self._tunnel_endpoints: list[XMLInterface] = [] self._gatewayscanner: GatewayScanner | None = None - self._async_scan_gen: AsyncGenerator[GatewayDescriptor, None] | None = None + self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None @abstractmethod def finish_flow(self) -> ConfigFlowResult: diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 9c0d5e1125a..6cec901adc7 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -83,11 +83,9 @@ DATA_HASS_CONFIG: Final = "knx_hass_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" -# dispatcher signal for KNX interface device triggers -SIGNAL_KNX_TELEGRAM_DICT: Final = "knx_telegram_dict" -AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] -MessageCallbackType = Callable[[Telegram], None] +type AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] +type MessageCallbackType = Callable[[Telegram], None] SERVICE_KNX_SEND: Final = "send" SERVICE_KNX_ATTR_PAYLOAD: Final = "payload" diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 47d9b9f55b2..2a1a9e2f9c9 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -80,7 +80,7 @@ class KNXDateTime(KnxEntity, DateTimeEntity, RestoreEntity): ): self._device.remote_value.value = ( datetime.fromisoformat(last_state.state) - .astimezone(dt_util.DEFAULT_TIME_ZONE) + .astimezone(dt_util.get_default_time_zone()) .timetuple() ) @@ -96,9 +96,11 @@ class KNXDateTime(KnxEntity, DateTimeEntity, RestoreEntity): hour=time_struct.tm_hour, minute=time_struct.tm_min, second=min(time_struct.tm_sec, 59), # account for leap seconds - tzinfo=dt_util.DEFAULT_TIME_ZONE, + tzinfo=dt_util.get_default_time_zone(), ) async def async_set_value(self, value: datetime) -> None: """Change the value.""" - await self._device.set(value.astimezone(dt_util.DEFAULT_TIME_ZONE).timetuple()) + await self._device.set( + value.astimezone(dt_util.get_default_time_zone()).timetuple() + ) diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 93e1623f88c..ea3cc5faad4 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -7,26 +7,36 @@ from typing import Any, Final import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import selector -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import KNXModule -from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT +from . import KNXModule, trigger +from .const import DOMAIN from .project import KNXProject -from .schema import ga_list_validator -from .telegrams import TelegramDict +from .trigger import ( + CONF_KNX_DESTINATION, + CONF_KNX_GROUP_VALUE_READ, + CONF_KNX_GROUP_VALUE_RESPONSE, + CONF_KNX_GROUP_VALUE_WRITE, + CONF_KNX_INCOMING, + CONF_KNX_OUTGOING, + PLATFORM_TYPE_TRIGGER_TELEGRAM, + TELEGRAM_TRIGGER_SCHEMA, + TRIGGER_SCHEMA as TRIGGER_TRIGGER_SCHEMA, +) TRIGGER_TELEGRAM: Final = "telegram" -EXTRA_FIELD_DESTINATION: Final = "destination" # no translation support -TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Optional(EXTRA_FIELD_DESTINATION): ga_list_validator, vol.Required(CONF_TYPE): TRIGGER_TELEGRAM, + **TELEGRAM_TRIGGER_SCHEMA, } ) @@ -42,11 +52,10 @@ async def async_get_triggers( # Add trigger for KNX telegrams to interface device triggers.append( { - # Required fields of TRIGGER_BASE_SCHEMA + # Default fields when initializing the trigger CONF_PLATFORM: "device", CONF_DOMAIN: DOMAIN, CONF_DEVICE_ID: device_id, - # Required fields of TRIGGER_SCHEMA CONF_TYPE: TRIGGER_TELEGRAM, } ) @@ -66,7 +75,7 @@ async def async_get_trigger_capabilities( return { "extra_fields": vol.Schema( { - vol.Optional(EXTRA_FIELD_DESTINATION): selector.SelectSelector( + vol.Optional(CONF_KNX_DESTINATION): selector.SelectSelector( selector.SelectSelectorConfig( mode=selector.SelectSelectorMode.DROPDOWN, multiple=True, @@ -74,6 +83,21 @@ async def async_get_trigger_capabilities( options=options, ), ), + vol.Optional( + CONF_KNX_GROUP_VALUE_WRITE, default=True + ): selector.BooleanSelector(), + vol.Optional( + CONF_KNX_GROUP_VALUE_RESPONSE, default=True + ): selector.BooleanSelector(), + vol.Optional( + CONF_KNX_GROUP_VALUE_READ, default=True + ): selector.BooleanSelector(), + vol.Optional( + CONF_KNX_INCOMING, default=True + ): selector.BooleanSelector(), + vol.Optional( + CONF_KNX_OUTGOING, default=True + ): selector.BooleanSelector(), } ) } @@ -86,22 +110,16 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" - trigger_data = trigger_info["trigger_data"] - dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) - job = HassJob(action, f"KNX device trigger {trigger_info}") + # Remove device trigger specific fields and add trigger platform identifier + trigger_config = { + key: config[key] for key in (config.keys() & TELEGRAM_TRIGGER_SCHEMA.keys()) + } | {CONF_PLATFORM: PLATFORM_TYPE_TRIGGER_TELEGRAM} - @callback - def async_call_trigger_action(telegram: TelegramDict) -> None: - """Filter Telegram and call trigger action.""" - if dst_addresses and telegram["destination"] not in dst_addresses: - return - hass.async_run_hass_job( - job, - {"trigger": {**trigger_data, **telegram}}, - ) + try: + trigger_config = TRIGGER_TRIGGER_SCHEMA(trigger_config) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig(f"{err}") from err - return async_dispatcher_connect( - hass, - signal=SIGNAL_KNX_TELEGRAM_DICT, - target=async_call_trigger_action, + return await trigger.async_attach_trigger( + hass, config=trigger_config, action=action, trigger_info=trigger_info ) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 12343f0dca7..695fe3b3851 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -13,6 +13,7 @@ from xknx.remote_value import RemoteValueSensor from homeassistant.const import ( CONF_ENTITY_ID, + CONF_VALUE_TEMPLATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -25,7 +26,9 @@ from homeassistant.core import ( State, callback, ) +from homeassistant.exceptions import TemplateError from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, StateType from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS @@ -79,6 +82,9 @@ class KNXExposeSensor: ) self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT) self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE] + self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + if self.value_template is not None: + self.value_template.hass = hass self._remove_listener: Callable[[], None] | None = None self.device: ExposeSensor = self.async_register(config) @@ -87,13 +93,10 @@ class KNXExposeSensor: @callback def async_register(self, config: ConfigType) -> ExposeSensor: """Register listener.""" - if self.expose_attribute is not None: - _name = self.entity_id + "__" + self.expose_attribute - else: - _name = self.entity_id + name = f"{self.entity_id}__{self.expose_attribute or "state"}" device = ExposeSensor( xknx=self.xknx, - name=_name, + name=name, group_address=config[KNX_ADDRESS], respond_to_read=config[CONF_RESPOND_TO_READ], value_type=self.expose_type, @@ -132,24 +135,33 @@ class KNXExposeSensor: else: value = state.state + if self.value_template is not None: + try: + value = self.value_template.async_render_with_possible_json_value( + value, error_value=None + ) + except (TemplateError, TypeError, ValueError) as err: + _LOGGER.warning( + "Error rendering value template for KNX expose %s %s: %s", + self.device.name, + self.value_template.template, + err, + ) + return None + if self.expose_type == "binary": if value in (1, STATE_ON, "True"): return True if value in (0, STATE_OFF, "False"): return False - if ( - value is not None - and isinstance(self.device.sensor_value, RemoteValueSensor) - and issubclass(self.device.sensor_value.dpt_class, DPTNumeric) + if value is not None and ( + isinstance(self.device.sensor_value, RemoteValueSensor) ): - return float(value) - if ( - value is not None - and isinstance(self.device.sensor_value, RemoteValueSensor) - and issubclass(self.device.sensor_value.dpt_class, DPTString) - ): - # DPT 16.000 only allows up to 14 Bytes - return str(value)[:14] + if issubclass(self.device.sensor_value.dpt_class, DPTNumeric): + return float(value) + if issubclass(self.device.sensor_value.dpt_class, DPTString): + # DPT 16.000 only allows up to 14 Bytes + return str(value)[:14] return value async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 77f3db3f9f3..3e8986641e7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["panel_custom"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "config_flow": true, - "dependencies": ["file_upload", "repairs", "websocket_api"], + "dependencies": ["file_upload", "http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/knx", "integration_type": "hub", "iot_class": "local_push", diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 9390acb2c85..997bdb81057 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -8,7 +8,11 @@ from xknx import XKNX from xknx.devices import Notification as XknxNotification from homeassistant import config_entries -from homeassistant.components.notify import BaseNotificationService, NotifyEntity +from homeassistant.components.notify import ( + BaseNotificationService, + NotifyEntity, + migrate_notify_issue, +) from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,7 +20,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity -from .repairs import migrate_notify_issue async def async_get_service( @@ -57,7 +60,9 @@ class KNXNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a notification to knx bus.""" - migrate_notify_issue(self.hass) + migrate_notify_issue( + self.hass, DOMAIN, "KNX", "2024.11.0", service_name=self._service_name + ) if "target" in kwargs: await self._async_send_to_device(message, kwargs["target"]) else: diff --git a/homeassistant/components/knx/repairs.py b/homeassistant/components/knx/repairs.py deleted file mode 100644 index f0a92850d36..00000000000 --- a/homeassistant/components/knx/repairs.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Repairs support for KNX.""" - -from __future__ import annotations - -from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir - -from .const import DOMAIN - - -@callback -def migrate_notify_issue(hass: HomeAssistant) -> None: - """Create issue for notify service deprecation.""" - ir.async_create_issue( - hass, - DOMAIN, - "migrate_notify", - breaks_in_ha_version="2024.11.0", - issue_domain=Platform.NOTIFY.value, - is_fixable=True, - is_persistent=True, - translation_key="migrate_notify", - severity=ir.IssueSeverity.WARNING, - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None] | None, -) -> RepairsFlow: - """Create flow.""" - assert issue_id == "migrate_notify" - return ConfirmRepairFlow() diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 462605c3985..34a145eadb3 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -37,6 +37,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD, CONF_TYPE, + CONF_VALUE_TEMPLATE, Platform, ) import homeassistant.helpers.config_validation as cv @@ -559,6 +560,7 @@ class ExposeSchema(KNXPlatformSchema): vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_KNX_EXPOSE_ATTRIBUTE): cv.string, vol.Optional(CONF_KNX_EXPOSE_DEFAULT): cv.match_all, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ) ENTITY_SCHEMA = vol.Any(EXPOSE_SENSOR_SCHEMA, EXPOSE_TIME_SCHEMA) diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index 813bf758eb0..5eaaca25fd7 100644 --- a/homeassistant/components/knx/services.yaml +++ b/homeassistant/components/knx/services.yaml @@ -16,6 +16,7 @@ send: selector: text: response: + required: true default: false selector: boolean: @@ -40,6 +41,7 @@ event_register: text: remove: default: false + required: true selector: boolean: exposure_register: @@ -68,6 +70,7 @@ exposure_register: object: remove: default: false + required: true selector: boolean: reload: diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index a69ba106ffd..d6e1e2f49f0 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -296,7 +296,23 @@ }, "device_automation": { "trigger_type": { - "telegram": "Telegram sent or received" + "telegram": "Telegram" + }, + "extra_fields": { + "destination": "Group addresses", + "group_value_write": "GroupValueWrite", + "group_value_read": "GroupValueRead", + "group_value_response": "GroupValueResponse", + "incoming": "Incoming", + "outgoing": "Outgoing" + }, + "extra_fields_descriptions": { + "destination": "The trigger will listen to telegrams sent or received on these group addresses. If no address is selected, the trigger will fire for every group address.", + "group_value_write": "Listen on GroupValueWrite telegrams.", + "group_value_read": "Listen on GroupValueRead telegrams.", + "group_value_response": "Listen on GroupValueResponse telegrams.", + "incoming": "Listen on incoming telegrams.", + "outgoing": "Listen on outgoing telegrams." } }, "services": { @@ -384,18 +400,5 @@ "name": "[%key:common::action::reload%]", "description": "Reloads the KNX integration." } - }, - "issues": { - "migrate_notify": { - "title": "Migration of KNX notify service", - "fix_flow": { - "step": { - "confirm": { - "description": "The KNX `notify` service has been migrated. New `notify` entities are available now.\n\nUpdate any automations to use the new `notify.send_message` exposed by these new entities. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy KNX notify service" - } - } - } - } } } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 7c3ea28c4df..6945bb50746 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -7,6 +7,7 @@ from collections.abc import Callable from typing import Final, TypedDict from xknx import XKNX +from xknx.dpt import DPTArray, DPTBase, DPTBinary from xknx.exceptions import XKNXException from xknx.telegram import Telegram from xknx.telegram.apci import GroupValueResponse, GroupValueWrite @@ -15,31 +16,40 @@ from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store import homeassistant.util.dt as dt_util +from homeassistant.util.signal_type import SignalType -from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT +from .const import DOMAIN from .project import KNXProject STORAGE_VERSION: Final = 1 STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json" +# dispatcher signal for KNX interface device triggers +SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram") -class TelegramDict(TypedDict): + +class DecodedTelegramPayload(TypedDict): + """Decoded payload value and metadata.""" + + dpt_main: int | None + dpt_sub: int | None + dpt_name: str | None + unit: str | None + value: str | int | float | bool | None + + +class TelegramDict(DecodedTelegramPayload): """Represent a Telegram as a dict.""" # this has to be in sync with the frontend implementation destination: str destination_name: str direction: str - dpt_main: int | None - dpt_sub: int | None - dpt_name: str | None payload: int | tuple[int, ...] | None source: str source_name: str telegramtype: str timestamp: str # ISO format - unit: str | None - value: str | int | float | bool | None class Telegrams: @@ -89,7 +99,7 @@ class Telegrams: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) self.recent_telegrams.append(telegram_dict) - async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM_DICT, telegram_dict) + async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict) for job in self._jobs: self.hass.async_run_hass_job(job, telegram_dict) @@ -112,14 +122,10 @@ class Telegrams: def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: """Convert a Telegram to a dict.""" dst_name = "" - dpt_main = None - dpt_sub = None - dpt_name = None payload_data: int | tuple[int, ...] | None = None src_name = "" transcoder = None - unit = None - value: str | int | float | bool | None = None + decoded_payload: DecodedTelegramPayload | None = None if ( ga_info := self.project.group_addresses.get( @@ -137,27 +143,44 @@ class Telegrams: if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)): payload_data = telegram.payload.value.value if transcoder is not None: - try: - value = transcoder.from_knx(telegram.payload.value) - dpt_main = transcoder.dpt_main_number - dpt_sub = transcoder.dpt_sub_number - dpt_name = transcoder.value_type - unit = transcoder.unit - except XKNXException: - value = "Error decoding value" + decoded_payload = decode_telegram_payload( + payload=telegram.payload.value, transcoder=transcoder + ) return TelegramDict( destination=f"{telegram.destination_address}", destination_name=dst_name, direction=telegram.direction.value, - dpt_main=dpt_main, - dpt_sub=dpt_sub, - dpt_name=dpt_name, + dpt_main=decoded_payload["dpt_main"] + if decoded_payload is not None + else None, + dpt_sub=decoded_payload["dpt_sub"] if decoded_payload is not None else None, + dpt_name=decoded_payload["dpt_name"] + if decoded_payload is not None + else None, payload=payload_data, source=f"{telegram.source_address}", source_name=src_name, telegramtype=telegram.payload.__class__.__name__, timestamp=dt_util.now().isoformat(), - unit=unit, - value=value, + unit=decoded_payload["unit"] if decoded_payload is not None else None, + value=decoded_payload["value"] if decoded_payload is not None else None, ) + + +def decode_telegram_payload( + payload: DPTArray | DPTBinary, transcoder: type[DPTBase] +) -> DecodedTelegramPayload: + """Decode the payload of a KNX telegram.""" + try: + value = transcoder.from_knx(payload) + except XKNXException: + value = "Error decoding value" + + return DecodedTelegramPayload( + dpt_main=transcoder.dpt_main_number, + dpt_sub=transcoder.dpt_sub_number, + dpt_name=transcoder.value_type, + unit=transcoder.unit, + value=value, + ) diff --git a/homeassistant/components/knx/trigger.py b/homeassistant/components/knx/trigger.py new file mode 100644 index 00000000000..1df1ffd6c3b --- /dev/null +++ b/homeassistant/components/knx/trigger.py @@ -0,0 +1,115 @@ +"""Offer knx telegram automation triggers.""" + +from typing import Final + +import voluptuous as vol +from xknx.dpt import DPTBase +from xknx.telegram import Telegram, TelegramDirection +from xknx.telegram.address import DeviceGroupAddress, parse_device_group_address +from xknx.telegram.apci import GroupValueRead, GroupValueResponse, GroupValueWrite + +from homeassistant.const import CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .schema import ga_validator +from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict, decode_telegram_payload +from .validation import sensor_type_validator + +TRIGGER_TELEGRAM: Final = "telegram" + +PLATFORM_TYPE_TRIGGER_TELEGRAM: Final = f"{DOMAIN}.{TRIGGER_TELEGRAM}" + +CONF_KNX_DESTINATION: Final = "destination" +CONF_KNX_GROUP_VALUE_WRITE: Final = "group_value_write" +CONF_KNX_GROUP_VALUE_READ: Final = "group_value_read" +CONF_KNX_GROUP_VALUE_RESPONSE: Final = "group_value_response" +CONF_KNX_INCOMING: Final = "incoming" +CONF_KNX_OUTGOING: Final = "outgoing" + + +TELEGRAM_TRIGGER_SCHEMA: Final = { + vol.Optional(CONF_KNX_DESTINATION): vol.All(cv.ensure_list, [ga_validator]), + vol.Optional(CONF_KNX_GROUP_VALUE_WRITE, default=True): cv.boolean, + vol.Optional(CONF_KNX_GROUP_VALUE_RESPONSE, default=True): cv.boolean, + vol.Optional(CONF_KNX_GROUP_VALUE_READ, default=True): cv.boolean, + vol.Optional(CONF_KNX_INCOMING, default=True): cv.boolean, + vol.Optional(CONF_KNX_OUTGOING, default=True): cv.boolean, +} +# TRIGGER_SCHEMA is exclusive to triggers, the above are used in device triggers too +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE_TRIGGER_TELEGRAM, + vol.Optional(CONF_TYPE, default=None): vol.Any(sensor_type_validator, None), + **TELEGRAM_TRIGGER_SCHEMA, + } +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for telegrams based on configuration.""" + _addresses: list[str] = config.get(CONF_KNX_DESTINATION, []) + dst_addresses: list[DeviceGroupAddress] = [ + parse_device_group_address(address) for address in _addresses + ] + _transcoder = config.get(CONF_TYPE) + trigger_transcoder = DPTBase.parse_transcoder(_transcoder) if _transcoder else None + + job = HassJob(action, f"KNX trigger {trigger_info}") + trigger_data = trigger_info["trigger_data"] + + @callback + def async_call_trigger_action( + telegram: Telegram, telegram_dict: TelegramDict + ) -> None: + """Filter Telegram and call trigger action.""" + payload_apci = type(telegram.payload) + if payload_apci is GroupValueWrite: + if config[CONF_KNX_GROUP_VALUE_WRITE] is False: + return + elif payload_apci is GroupValueResponse: + if config[CONF_KNX_GROUP_VALUE_RESPONSE] is False: + return + elif payload_apci is GroupValueRead: + if config[CONF_KNX_GROUP_VALUE_READ] is False: + return + + if telegram.direction is TelegramDirection.INCOMING: + if config[CONF_KNX_INCOMING] is False: + return + elif config[CONF_KNX_OUTGOING] is False: + return + + if dst_addresses and telegram.destination_address not in dst_addresses: + return + + if ( + trigger_transcoder is not None + and payload_apci in (GroupValueWrite, GroupValueResponse) + and trigger_transcoder.value_type != telegram_dict["dpt_name"] + ): + decoded_payload = decode_telegram_payload( + payload=telegram.payload.value, # type: ignore[union-attr] # checked via payload_apci + transcoder=trigger_transcoder, # type: ignore[type-abstract] # parse_transcoder don't return abstract classes + ) + # overwrite decoded payload values in telegram_dict + telegram_trigger_data = {**trigger_data, **telegram_dict, **decoded_payload} + else: + telegram_trigger_data = {**trigger_data, **telegram_dict} + + hass.async_run_hass_job(job, {"trigger": telegram_trigger_data}) + + return async_dispatcher_connect( + hass, + signal=SIGNAL_KNX_TELEGRAM, + target=async_call_trigger_action, + ) diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 90796f26f1a..584c9fd3323 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -83,7 +83,7 @@ class KNXWeather(KnxEntity, WeatherEntity): def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of a KNX sensor.""" super().__init__(_create_weather(xknx, config)) - self._attr_unique_id = str(self._device._temperature.group_address_state) + self._attr_unique_id = str(self._device._temperature.group_address_state) # noqa: SLF001 self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) @property diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index f6869902793..0ac5a21d333 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -9,6 +9,7 @@ import voluptuous as vol from xknxproject.exceptions import XknxProjectException from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.http import StaticPathConfig from homeassistant.core import HomeAssistant, callback from .const import DOMAIN @@ -31,10 +32,14 @@ async def register_panel(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_knx_project) if DOMAIN not in hass.data.get("frontend_panels", {}): - hass.http.register_static_path( - URL_BASE, - path=knx_panel.locate_dir(), - cache_headers=knx_panel.is_prod_build, + await hass.http.async_register_static_paths( + [ + StaticPathConfig( + URL_BASE, + path=knx_panel.locate_dir(), + cache_headers=knx_panel.is_prod_build, + ) + ] ) await panel_custom.async_register_panel( hass=hass, @@ -131,7 +136,7 @@ async def ws_project_file_process( except (ValueError, XknxProjectException) as err: # ValueError could raise from file_upload integration connection.send_error( - msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + msg["id"], websocket_api.ERR_HOME_ASSISTANT_ERROR, str(err) ) return diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index b4d9c575122..e431c72d21e 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -133,7 +133,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_ws_port() except CannotConnect: return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -167,7 +167,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_ws_port() except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -192,7 +192,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_ws_port() except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -215,7 +215,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): await validate_ws(self.hass, self._get_data()) except WSCannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -235,7 +235,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): except CannotConnect: _LOGGER.exception("Cannot connect to Kodi") reason = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") reason = "unknown" else: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 74140ca873c..2bfe21b6eaa 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -7,7 +7,7 @@ from datetime import timedelta from functools import wraps import logging import re -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from jsonrpc_base.jsonrpc import ProtocolError, TransportError from pykodi import CannotConnectError @@ -71,9 +71,6 @@ from .const import ( EVENT_TURN_ON, ) -_KodiEntityT = TypeVar("_KodiEntityT", bound="KodiEntity") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) EVENT_KODI_CALL_METHOD_RESULT = "kodi_call_method_result" @@ -231,7 +228,7 @@ async def async_setup_entry( async_add_entities([entity]) -def cmd( +def cmd[_KodiEntityT: KodiEntity, **_P]( func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_KodiEntityT, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" @@ -262,6 +259,7 @@ class KodiEntity(MediaPlayerEntity): _attr_has_entity_name = True _attr_name = None + _attr_translation_key = "media_player" _attr_supported_features = ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.NEXT_TRACK @@ -480,7 +478,13 @@ class KodiEntity(MediaPlayerEntity): self._reset_state() return - self._players = await self._kodi.get_players() + try: + self._players = await self._kodi.get_players() + except (TransportError, ProtocolError): + if not self._connection.can_subscribe: + self._reset_state() + return + raise if self._kodi_is_off: self._reset_state() @@ -513,6 +517,7 @@ class KodiEntity(MediaPlayerEntity): "album", "season", "episode", + "streamdetails", ], ) else: @@ -629,6 +634,23 @@ class KodiEntity(MediaPlayerEntity): return None + @property + def extra_state_attributes(self) -> dict[str, str | None]: + """Return the state attributes.""" + state_attr: dict[str, str | None] = {} + if self.state == MediaPlayerState.OFF: + return state_attr + + hdr_type = ( + self._item.get("streamdetails", {}).get("video", [{}])[0].get("hdrtype") + ) + if hdr_type == "": + state_attr["dynamic_range"] = "sdr" + else: + state_attr["dynamic_range"] = hdr_type + + return state_attr + async def async_turn_on(self) -> None: """Turn the media player on.""" _LOGGER.debug("Firing event to turn on device") diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 7c7d53b33ac..5b472e0c193 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -83,5 +83,14 @@ } } } + }, + "entity": { + "media_player": { + "media_player": { + "state_attributes": { + "dynamic_range": { "name": "Dynamic range" } + } + } + } } } diff --git a/homeassistant/components/kostal_plenticore/__init__.py b/homeassistant/components/kostal_plenticore/__init__.py index d3fb65ad77b..3675b4342b4 100644 --- a/homeassistant/components/kostal_plenticore/__init__.py +++ b/homeassistant/components/kostal_plenticore/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -from .helper import Plenticore +from .coordinator import Plenticore _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index c1c8ac249e0..547afa9d71b 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -59,7 +59,7 @@ class KostalPlenticoreConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error response: %s", ex) except (ClientError, TimeoutError): errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors[CONF_BASE] = "unknown" diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py new file mode 100644 index 00000000000..fa6aa92856b --- /dev/null +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -0,0 +1,315 @@ +"""Code to handle the Plenticore API.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Mapping +from datetime import datetime, timedelta +import logging +from typing import cast + +from aiohttp.client_exceptions import ClientError +from pykoplenti import ( + ApiClient, + ApiException, + AuthenticationException, + ExtendedApiClient, +) + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN +from .helper import get_hostname_id + +_LOGGER = logging.getLogger(__name__) + + +class Plenticore: + """Manages the Plenticore API.""" + + def __init__(self, hass, config_entry): + """Create a new plenticore manager instance.""" + self.hass = hass + self.config_entry = config_entry + + self._client = None + self._shutdown_remove_listener = None + + self.device_info = {} + + @property + def host(self) -> str: + """Return the host of the Plenticore inverter.""" + return self.config_entry.data[CONF_HOST] + + @property + def client(self) -> ApiClient: + """Return the Plenticore API client.""" + return self._client + + async def async_setup(self) -> bool: + """Set up Plenticore API client.""" + self._client = ExtendedApiClient( + async_get_clientsession(self.hass), host=self.host + ) + try: + await self._client.login(self.config_entry.data[CONF_PASSWORD]) + except AuthenticationException as err: + _LOGGER.error( + "Authentication exception connecting to %s: %s", self.host, err + ) + return False + except (ClientError, TimeoutError) as err: + _LOGGER.error("Error connecting to %s", self.host) + raise ConfigEntryNotReady from err + else: + _LOGGER.debug("Log-in successfully to %s", self.host) + + self._shutdown_remove_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._async_shutdown + ) + + # get some device meta data + hostname_id = await get_hostname_id(self._client) + settings = await self._client.get_setting_values( + { + "devices:local": [ + "Properties:SerialNo", + "Branding:ProductName1", + "Branding:ProductName2", + "Properties:VersionIOC", + "Properties:VersionMC", + ], + "scb:network": [hostname_id], + } + ) + + device_local = settings["devices:local"] + prod1 = device_local["Branding:ProductName1"] + prod2 = device_local["Branding:ProductName2"] + + self.device_info = DeviceInfo( + configuration_url=f"http://{self.host}", + identifiers={(DOMAIN, device_local["Properties:SerialNo"])}, + manufacturer="Kostal", + model=f"{prod1} {prod2}", + name=settings["scb:network"][hostname_id], + sw_version=( + f'IOC: {device_local["Properties:VersionIOC"]}' + f' MC: {device_local["Properties:VersionMC"]}' + ), + ) + + return True + + async def _async_shutdown(self, event): + """Call from Homeassistant shutdown event.""" + # unset remove listener otherwise calling it would raise an exception + self._shutdown_remove_listener = None + await self.async_unload() + + async def async_unload(self) -> None: + """Unload the Plenticore API client.""" + if self._shutdown_remove_listener: + self._shutdown_remove_listener() + + await self._client.logout() + self._client = None + _LOGGER.debug("Logged out from %s", self.host) + + +class DataUpdateCoordinatorMixin: + """Base implementation for read and write data.""" + + _plenticore: Plenticore + name: str + + async def async_read_data( + self, module_id: str, data_id: str + ) -> Mapping[str, Mapping[str, str]] | None: + """Read data from Plenticore.""" + if (client := self._plenticore.client) is None: + return None + + try: + return await client.get_setting_values(module_id, data_id) + except ApiException: + return None + + async def async_write_data(self, module_id: str, value: dict[str, str]) -> bool: + """Write settings back to Plenticore.""" + if (client := self._plenticore.client) is None: + return False + + _LOGGER.debug( + "Setting value for %s in module %s to %s", self.name, module_id, value + ) + + try: + await client.set_setting_values(module_id, value) + except ApiException: + return False + + return True + + +class PlenticoreUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base implementation of DataUpdateCoordinator for Plenticore data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + update_inverval: timedelta, + plenticore: Plenticore, + ) -> None: + """Create a new update coordinator for plenticore data.""" + super().__init__( + hass=hass, + logger=logger, + name=name, + update_interval=update_inverval, + ) + # data ids to poll + self._fetch: dict[str, list[str]] = defaultdict(list) + self._plenticore = plenticore + + def start_fetch_data(self, module_id: str, data_id: str) -> CALLBACK_TYPE: + """Start fetching the given data (module-id and data-id).""" + self._fetch[module_id].append(data_id) + + # Force an update of all data. Multiple refresh calls + # are ignored by the debouncer. + async def force_refresh(event_time: datetime) -> None: + await self.async_request_refresh() + + return async_call_later(self.hass, 2, force_refresh) + + def stop_fetch_data(self, module_id: str, data_id: str) -> None: + """Stop fetching the given data (module-id and data-id).""" + self._fetch[module_id].remove(data_id) + + +class ProcessDataUpdateCoordinator( + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] +): + """Implementation of PlenticoreUpdateCoordinator for process data.""" + + async def _async_update_data(self) -> dict[str, dict[str, str]]: + client = self._plenticore.client + + if not self._fetch or client is None: + return {} + + _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) + + fetched_data = await client.get_process_data_values(self._fetch) + return { + module_id: { + process_data.id: process_data.value + for process_data in fetched_data[module_id].values() + } + for module_id in fetched_data + } + + +class SettingDataUpdateCoordinator( + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], + DataUpdateCoordinatorMixin, +): + """Implementation of PlenticoreUpdateCoordinator for settings data.""" + + async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: + client = self._plenticore.client + + if not self._fetch or client is None: + return {} + + _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) + + return await client.get_setting_values(self._fetch) + + +class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base implementation of DataUpdateCoordinator for Plenticore data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + update_inverval: timedelta, + plenticore: Plenticore, + ) -> None: + """Create a new update coordinator for plenticore data.""" + super().__init__( + hass=hass, + logger=logger, + name=name, + update_interval=update_inverval, + ) + # data ids to poll + self._fetch: dict[str, list[str | list[str]]] = defaultdict(list) + self._plenticore = plenticore + + def start_fetch_data( + self, module_id: str, data_id: str, all_options: list[str] + ) -> CALLBACK_TYPE: + """Start fetching the given data (module-id and entry-id).""" + self._fetch[module_id].append(data_id) + self._fetch[module_id].append(all_options) + + # Force an update of all data. Multiple refresh calls + # are ignored by the debouncer. + async def force_refresh(event_time: datetime) -> None: + await self.async_request_refresh() + + return async_call_later(self.hass, 2, force_refresh) + + def stop_fetch_data( + self, module_id: str, data_id: str, all_options: list[str] + ) -> None: + """Stop fetching the given data (module-id and entry-id).""" + self._fetch[module_id].remove(all_options) + self._fetch[module_id].remove(data_id) + + +class SelectDataUpdateCoordinator( + PlenticoreSelectUpdateCoordinator[dict[str, dict[str, str]]], + DataUpdateCoordinatorMixin, +): + """Implementation of PlenticoreUpdateCoordinator for select data.""" + + async def _async_update_data(self) -> dict[str, dict[str, str]]: + if self._plenticore.client is None: + return {} + + _LOGGER.debug("Fetching select %s for %s", self.name, self._fetch) + + return await self._async_get_current_option(self._fetch) + + async def _async_get_current_option( + self, + module_id: dict[str, list[str | list[str]]], + ) -> dict[str, dict[str, str]]: + """Get current option.""" + for mid, pids in module_id.items(): + all_options = cast(list[str], pids[1]) + for all_option in all_options: + if all_option == "None" or not ( + val := await self.async_read_data(mid, all_option) + ): + continue + for option in val.values(): + if option[all_option] == "1": + return {mid: {cast(str, pids[0]): all_option}} + + return {mid: {cast(str, pids[0]): "None"}} + return {} diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index 9b78265971c..3978869c524 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -10,7 +10,7 @@ from homeassistant.const import ATTR_IDENTIFIERS, CONF_PASSWORD from homeassistant.core import HomeAssistant from .const import DOMAIN -from .helper import Plenticore +from .coordinator import Plenticore TO_REDACT = {CONF_PASSWORD} diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 37666557eff..bcb50682141 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -2,320 +2,14 @@ from __future__ import annotations -from collections import defaultdict -from collections.abc import Callable, Mapping -from datetime import datetime, timedelta -import logging -from typing import Any, TypeVar, cast +from collections.abc import Callable +from typing import Any -from aiohttp.client_exceptions import ClientError -from pykoplenti import ( - ApiClient, - ApiException, - AuthenticationException, - ExtendedApiClient, -) +from pykoplenti import ApiClient, ApiException -from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) -_DataT = TypeVar("_DataT") _KNOWN_HOSTNAME_IDS = ("Network:Hostname", "Hostname") -class Plenticore: - """Manages the Plenticore API.""" - - def __init__(self, hass, config_entry): - """Create a new plenticore manager instance.""" - self.hass = hass - self.config_entry = config_entry - - self._client = None - self._shutdown_remove_listener = None - - self.device_info = {} - - @property - def host(self) -> str: - """Return the host of the Plenticore inverter.""" - return self.config_entry.data[CONF_HOST] - - @property - def client(self) -> ApiClient: - """Return the Plenticore API client.""" - return self._client - - async def async_setup(self) -> bool: - """Set up Plenticore API client.""" - self._client = ExtendedApiClient( - async_get_clientsession(self.hass), host=self.host - ) - try: - await self._client.login(self.config_entry.data[CONF_PASSWORD]) - except AuthenticationException as err: - _LOGGER.error( - "Authentication exception connecting to %s: %s", self.host, err - ) - return False - except (ClientError, TimeoutError) as err: - _LOGGER.error("Error connecting to %s", self.host) - raise ConfigEntryNotReady from err - else: - _LOGGER.debug("Log-in successfully to %s", self.host) - - self._shutdown_remove_listener = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self._async_shutdown - ) - - # get some device meta data - hostname_id = await get_hostname_id(self._client) - settings = await self._client.get_setting_values( - { - "devices:local": [ - "Properties:SerialNo", - "Branding:ProductName1", - "Branding:ProductName2", - "Properties:VersionIOC", - "Properties:VersionMC", - ], - "scb:network": [hostname_id], - } - ) - - device_local = settings["devices:local"] - prod1 = device_local["Branding:ProductName1"] - prod2 = device_local["Branding:ProductName2"] - - self.device_info = DeviceInfo( - configuration_url=f"http://{self.host}", - identifiers={(DOMAIN, device_local["Properties:SerialNo"])}, - manufacturer="Kostal", - model=f"{prod1} {prod2}", - name=settings["scb:network"][hostname_id], - sw_version=( - f'IOC: {device_local["Properties:VersionIOC"]}' - f' MC: {device_local["Properties:VersionMC"]}' - ), - ) - - return True - - async def _async_shutdown(self, event): - """Call from Homeassistant shutdown event.""" - # unset remove listener otherwise calling it would raise an exception - self._shutdown_remove_listener = None - await self.async_unload() - - async def async_unload(self) -> None: - """Unload the Plenticore API client.""" - if self._shutdown_remove_listener: - self._shutdown_remove_listener() - - await self._client.logout() - self._client = None - _LOGGER.debug("Logged out from %s", self.host) - - -class DataUpdateCoordinatorMixin: - """Base implementation for read and write data.""" - - _plenticore: Plenticore - name: str - - async def async_read_data( - self, module_id: str, data_id: str - ) -> Mapping[str, Mapping[str, str]] | None: - """Read data from Plenticore.""" - if (client := self._plenticore.client) is None: - return None - - try: - return await client.get_setting_values(module_id, data_id) - except ApiException: - return None - - async def async_write_data(self, module_id: str, value: dict[str, str]) -> bool: - """Write settings back to Plenticore.""" - if (client := self._plenticore.client) is None: - return False - - _LOGGER.debug( - "Setting value for %s in module %s to %s", self.name, module_id, value - ) - - try: - await client.set_setting_values(module_id, value) - except ApiException: - return False - - return True - - -class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): # pylint: disable=hass-enforce-coordinator-module - """Base implementation of DataUpdateCoordinator for Plenticore data.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - name: str, - update_inverval: timedelta, - plenticore: Plenticore, - ) -> None: - """Create a new update coordinator for plenticore data.""" - super().__init__( - hass=hass, - logger=logger, - name=name, - update_interval=update_inverval, - ) - # data ids to poll - self._fetch: dict[str, list[str]] = defaultdict(list) - self._plenticore = plenticore - - def start_fetch_data(self, module_id: str, data_id: str) -> CALLBACK_TYPE: - """Start fetching the given data (module-id and data-id).""" - self._fetch[module_id].append(data_id) - - # Force an update of all data. Multiple refresh calls - # are ignored by the debouncer. - async def force_refresh(event_time: datetime) -> None: - await self.async_request_refresh() - - return async_call_later(self.hass, 2, force_refresh) - - def stop_fetch_data(self, module_id: str, data_id: str) -> None: - """Stop fetching the given data (module-id and data-id).""" - self._fetch[module_id].remove(data_id) - - -class ProcessDataUpdateCoordinator( - PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] -): # pylint: disable=hass-enforce-coordinator-module - """Implementation of PlenticoreUpdateCoordinator for process data.""" - - async def _async_update_data(self) -> dict[str, dict[str, str]]: - client = self._plenticore.client - - if not self._fetch or client is None: - return {} - - _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) - - fetched_data = await client.get_process_data_values(self._fetch) - return { - module_id: { - process_data.id: process_data.value - for process_data in fetched_data[module_id].values() - } - for module_id in fetched_data - } - - -class SettingDataUpdateCoordinator( - PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], - DataUpdateCoordinatorMixin, -): # pylint: disable=hass-enforce-coordinator-module - """Implementation of PlenticoreUpdateCoordinator for settings data.""" - - async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: - client = self._plenticore.client - - if not self._fetch or client is None: - return {} - - _LOGGER.debug("Fetching %s for %s", self.name, self._fetch) - - return await client.get_setting_values(self._fetch) - - -class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): # pylint: disable=hass-enforce-coordinator-module - """Base implementation of DataUpdateCoordinator for Plenticore data.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - name: str, - update_inverval: timedelta, - plenticore: Plenticore, - ) -> None: - """Create a new update coordinator for plenticore data.""" - super().__init__( - hass=hass, - logger=logger, - name=name, - update_interval=update_inverval, - ) - # data ids to poll - self._fetch: dict[str, list[str | list[str]]] = defaultdict(list) - self._plenticore = plenticore - - def start_fetch_data( - self, module_id: str, data_id: str, all_options: list[str] - ) -> CALLBACK_TYPE: - """Start fetching the given data (module-id and entry-id).""" - self._fetch[module_id].append(data_id) - self._fetch[module_id].append(all_options) - - # Force an update of all data. Multiple refresh calls - # are ignored by the debouncer. - async def force_refresh(event_time: datetime) -> None: - await self.async_request_refresh() - - return async_call_later(self.hass, 2, force_refresh) - - def stop_fetch_data( - self, module_id: str, data_id: str, all_options: list[str] - ) -> None: - """Stop fetching the given data (module-id and entry-id).""" - self._fetch[module_id].remove(all_options) - self._fetch[module_id].remove(data_id) - - -class SelectDataUpdateCoordinator( - PlenticoreSelectUpdateCoordinator[dict[str, dict[str, str]]], - DataUpdateCoordinatorMixin, -): # pylint: disable=hass-enforce-coordinator-module - """Implementation of PlenticoreUpdateCoordinator for select data.""" - - async def _async_update_data(self) -> dict[str, dict[str, str]]: - if self._plenticore.client is None: - return {} - - _LOGGER.debug("Fetching select %s for %s", self.name, self._fetch) - - return await self._async_get_current_option(self._fetch) - - async def _async_get_current_option( - self, - module_id: dict[str, list[str | list[str]]], - ) -> dict[str, dict[str, str]]: - """Get current option.""" - for mid, pids in module_id.items(): - all_options = cast(list[str], pids[1]) - for all_option in all_options: - if all_option == "None" or not ( - val := await self.async_read_data(mid, all_option) - ): - continue - for option in val.values(): - if option[all_option] == "1": - return {mid: {cast(str, pids[0]): all_option}} - - return {mid: {cast(str, pids[0]): "None"}} - return {} - - class PlenticoreDataFormatter: """Provides method to format values of process or settings data.""" diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 2e544a16fec..8afe69a7749 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -22,7 +22,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .helper import PlenticoreDataFormatter, SettingDataUpdateCoordinator +from .coordinator import SettingDataUpdateCoordinator +from .helper import PlenticoreDataFormatter _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 555bb89641b..73f3f94eda8 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .helper import Plenticore, SelectDataUpdateCoordinator +from .coordinator import Plenticore, SelectDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index d6e13ecb5b7..fbbfb03fb3e 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -29,7 +29,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .helper import PlenticoreDataFormatter, ProcessDataUpdateCoordinator +from .coordinator import ProcessDataUpdateCoordinator +from .helper import PlenticoreDataFormatter _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index f2ea1a5ef7c..7ce2d468c88 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .helper import SettingDataUpdateCoordinator +from .coordinator import SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/kraken/config_flow.py b/homeassistant/components/kraken/config_flow.py index 3375746f25d..93c3c6606a3 100644 --- a/homeassistant/components/kraken/config_flow.py +++ b/homeassistant/components/kraken/config_flow.py @@ -69,6 +69,17 @@ class KrakenOptionsFlowHandler(OptionsFlow): get_tradable_asset_pairs, api ) tradable_asset_pairs_for_multi_select = {v: v for v in tradable_asset_pairs} + + # Ensure that a previously selected tracked asset pair is still available in multiselect + # even if it is not tradable anymore + tracked_asset_pairs = self.config_entry.options.get( + CONF_TRACKED_ASSET_PAIRS, [] + ) + for tracked_asset_pair in tracked_asset_pairs: + tradable_asset_pairs_for_multi_select[tracked_asset_pair] = ( + tracked_asset_pair + ) + options = { vol.Optional( CONF_SCAN_INTERVAL, @@ -78,7 +89,7 @@ class KrakenOptionsFlowHandler(OptionsFlow): ): int, vol.Optional( CONF_TRACKED_ASSET_PAIRS, - default=self.config_entry.options.get(CONF_TRACKED_ASSET_PAIRS, []), + default=tracked_asset_pairs, ): cv.multi_select(tradable_asset_pairs_for_multi_select), } diff --git a/homeassistant/components/kraken/const.py b/homeassistant/components/kraken/const.py index 3b1bc29c7cd..9fbad46dd4b 100644 --- a/homeassistant/components/kraken/const.py +++ b/homeassistant/components/kraken/const.py @@ -19,7 +19,7 @@ class KrakenResponseEntry(TypedDict): opening_price: float -KrakenResponse = dict[str, KrakenResponseEntry] +type KrakenResponse = dict[str, KrakenResponseEntry] DEFAULT_SCAN_INTERVAL = 60 diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index 805afc40d2b..5a3fe4a03ca 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -75,7 +75,7 @@ class LaCrosseViewConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoLocations: errors["base"] = "no_locations" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index d2a7bbb6216..9c66fdd1b60 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -1,10 +1,31 @@ """The La Marzocco integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +import logging -from .const import DOMAIN +from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient +from lmcloud.client_cloud import LaMarzoccoCloudClient +from lmcloud.client_local import LaMarzoccoLocalClient +from lmcloud.const import BT_MODEL_PREFIXES, FirmwareType +from lmcloud.exceptions import AuthFail, RequestNotSuccessful +from packaging import version + +from homeassistant.components.bluetooth import async_discovered_service_info +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.httpx_client import get_async_client + +from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ @@ -18,14 +39,90 @@ PLATFORMS = [ Platform.UPDATE, ] +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -> bool: """Set up La Marzocco as config entry.""" - coordinator = LaMarzoccoUpdateCoordinator(hass) + assert entry.unique_id + serial = entry.unique_id + cloud_client = LaMarzoccoCloudClient( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + + # initialize local API + local_client: LaMarzoccoLocalClient | None = None + if (host := entry.data.get(CONF_HOST)) is not None: + _LOGGER.debug("Initializing local API") + local_client = LaMarzoccoLocalClient( + host=host, + local_bearer=entry.data[CONF_TOKEN], + client=get_async_client(hass), + ) + + # initialize Bluetooth + bluetooth_client: LaMarzoccoBluetoothClient | None = None + if entry.options.get(CONF_USE_BLUETOOTH, True): + + def bluetooth_configured() -> bool: + return entry.data.get(CONF_MAC, "") and entry.data.get(CONF_NAME, "") + + if not bluetooth_configured(): + for discovery_info in async_discovered_service_info(hass): + if ( + (name := discovery_info.name) + and name.startswith(BT_MODEL_PREFIXES) + and name.split("_")[1] == serial + ): + _LOGGER.debug("Found Bluetooth device, configuring with Bluetooth") + # found a device, add MAC address to config entry + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_MAC: discovery_info.address, + CONF_NAME: discovery_info.name, + }, + ) + break + + if bluetooth_configured(): + _LOGGER.debug("Initializing Bluetooth device") + bluetooth_client = LaMarzoccoBluetoothClient( + username=entry.data[CONF_USERNAME], + serial_number=serial, + token=entry.data[CONF_TOKEN], + address_or_ble_device=entry.data[CONF_MAC], + ) + + coordinator = LaMarzoccoUpdateCoordinator( + hass=hass, + local_client=local_client, + cloud_client=cloud_client, + bluetooth_client=bluetooth_client, + ) + + await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator + + gateway_version = coordinator.device.firmware[FirmwareType.GATEWAY].current_version + if version.parse(gateway_version) < version.parse("v3.5-rc5"): + # incompatible gateway firmware, create an issue + ir.async_create_issue( + hass, + DOMAIN, + "unsupported_gateway_firmware", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_gateway_firmware", + translation_placeholders={"gateway_version": gateway_version}, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -39,10 +136,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate config entry.""" + if entry.version > 2: + # guard against downgrade from a future version + return False - return unload_ok + if entry.version == 1: + cloud_client = LaMarzoccoCloudClient( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + try: + fleet = await cloud_client.get_customer_fleet() + except (AuthFail, RequestNotSuccessful) as exc: + _LOGGER.error("Migration failed with error %s", exc) + return False + + assert entry.unique_id is not None + device = fleet[entry.unique_id] + v2_data = { + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_PASSWORD: entry.data[CONF_PASSWORD], + CONF_MODEL: device.model, + CONF_NAME: device.name, + CONF_TOKEN: device.communication_key, + } + + if CONF_HOST in entry.data: + v2_data[CONF_HOST] = entry.data[CONF_HOST] + + if CONF_MAC in entry.data: + v2_data[CONF_MAC] = entry.data[CONF_MAC] + + hass.config_entries.async_update_entry( + entry, + data=v2_data, + version=2, + ) + _LOGGER.debug("Migrated La Marzocco config entry to version 2") + return True diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 0eb28fa9558..81ac3672a0f 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -3,19 +3,18 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.models import LaMarzoccoMachineConfig 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 . import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription @@ -26,7 +25,7 @@ class LaMarzoccoBinarySensorEntityDescription( ): """Description of a La Marzocco binary sensor.""" - is_on_fn: Callable[[LaMarzoccoClient], bool] + is_on_fn: Callable[[LaMarzoccoMachineConfig], bool] ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( @@ -34,7 +33,7 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( key="water_tank", translation_key="water_tank", device_class=BinarySensorDeviceClass.PROBLEM, - is_on_fn=lambda lm: not lm.current_status.get("water_reservoir_contact"), + is_on_fn=lambda config: not config.water_contact, entity_category=EntityCategory.DIAGNOSTIC, supported_fn=lambda coordinator: coordinator.local_connection_configured, ), @@ -42,8 +41,15 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( key="brew_active", translation_key="brew_active", device_class=BinarySensorDeviceClass.RUNNING, - is_on_fn=lambda lm: bool(lm.current_status.get("brew_active")), - available_fn=lambda lm: lm.websocket_connected, + is_on_fn=lambda config: config.brew_active, + available_fn=lambda device: device.websocket_connected, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoBinarySensorEntityDescription( + key="backflush_enabled", + translation_key="backflush_enabled", + device_class=BinarySensorDeviceClass.RUNNING, + is_on_fn=lambda config: config.backflush_enabled, entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -51,11 +57,11 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up binary sensor entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMarzoccoBinarySensorEntity(coordinator, description) @@ -72,4 +78,4 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.entity_description.is_on_fn(self.coordinator.lm) + return self.entity_description.is_on_fn(self.coordinator.device.config) diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index 68bae5feeb9..7b38c9fbf72 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -4,14 +4,13 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.lm_machine import LaMarzoccoMachine 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 . import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription @@ -22,26 +21,26 @@ class LaMarzoccoButtonEntityDescription( ): """Description of a La Marzocco button.""" - press_fn: Callable[[LaMarzoccoClient], Coroutine[Any, Any, None]] + press_fn: Callable[[LaMarzoccoMachine], Coroutine[Any, Any, None]] ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( LaMarzoccoButtonEntityDescription( key="start_backflush", translation_key="start_backflush", - press_fn=lambda lm: lm.start_backflush(), + press_fn=lambda machine: machine.start_backflush(), ), ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up button entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMarzoccoButtonEntity(coordinator, description) for description in ENTITIES @@ -56,4 +55,5 @@ class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity): async def async_press(self) -> None: """Press button.""" - await self.entity_description.press_fn(self.coordinator.lm) + await self.entity_description.press_fn(self.coordinator.device) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 2a08a90a1b2..8b3240ff7a1 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -3,27 +3,42 @@ from collections.abc import Iterator from datetime import datetime, timedelta +from lmcloud.models import LaMarzoccoWakeUpSleepEntry + from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN +from . import LaMarzoccoConfigEntry +from .coordinator import LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoBaseEntity CALENDAR_KEY = "auto_on_off_schedule" +DAY_OF_WEEK = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +] + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switch entities and services.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY)]) + coordinator = entry.runtime_data + async_add_entities( + LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry) + for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values() + ) class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): @@ -31,6 +46,17 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): _attr_translation_key = CALENDAR_KEY + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + key: str, + wake_up_sleep_entry: LaMarzoccoWakeUpSleepEntry, + ) -> None: + """Set up calendar.""" + super().__init__(coordinator, f"{key}_{wake_up_sleep_entry.entry_id}") + self.wake_up_sleep_entry = wake_up_sleep_entry + self._attr_translation_placeholders = {"id": wake_up_sleep_entry.entry_id} + @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" @@ -85,29 +111,36 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): """Return calendar event for a given weekday.""" # check first if auto/on off is turned on in general - # because could still be on for that day but disabled - if self.coordinator.lm.current_status["global_auto"] != "Enabled": + if not self.wake_up_sleep_entry.enabled: return None # parse the schedule for the day - schedule_day = self.coordinator.lm.schedule[date.weekday()] - if schedule_day["enable"] == "Disabled": + + if DAY_OF_WEEK[date.weekday()] not in self.wake_up_sleep_entry.days: return None - hour_on, minute_on = schedule_day["on"].split(":") - hour_off, minute_off = schedule_day["off"].split(":") + + hour_on, minute_on = self.wake_up_sleep_entry.time_on.split(":") + hour_off, minute_off = self.wake_up_sleep_entry.time_off.split(":") + + # if off time is 24:00, then it means the off time is the next day + # only for legacy schedules + day_offset = 0 + if hour_off == "24": + day_offset = 1 + hour_off = "0" + + end_date = date.replace( + hour=int(hour_off), + minute=int(minute_off), + ) + end_date += timedelta(days=day_offset) + return CalendarEvent( start=date.replace( hour=int(hour_on), minute=int(minute_on), - second=0, - microsecond=0, - ), - end=date.replace( - hour=int(hour_off), - minute=int(minute_off), - second=0, - microsecond=0, ), + end=end_date, summary=f"Machine {self.coordinator.config_entry.title} on", description="Machine is scheduled to turn on at the start time and off at the end time", ) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 3cacdae1749..b4fed615733 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -4,8 +4,10 @@ from collections.abc import Mapping import logging from typing import Any -from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.client_cloud import LaMarzoccoCloudClient +from lmcloud.client_local import LaMarzoccoLocalClient from lmcloud.exceptions import AuthFail, RequestNotSuccessful +from lmcloud.models import LaMarzoccoDeviceInfo import voluptuous as vol from homeassistant.components.bluetooth import BluetoothServiceInfo @@ -19,12 +21,15 @@ from homeassistant.config_entries import ( from homeassistant.const import ( CONF_HOST, CONF_MAC, + CONF_MODEL, CONF_NAME, CONF_PASSWORD, + CONF_TOKEN, CONF_USERNAME, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -32,7 +37,9 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_MACHINE, CONF_USE_BLUETOOTH, DOMAIN +from .const import CONF_USE_BLUETOOTH, DOMAIN + +CONF_MACHINE = "machine" _LOGGER = logging.getLogger(__name__) @@ -40,12 +47,14 @@ _LOGGER = logging.getLogger(__name__) class LmConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for La Marzocco.""" + VERSION = 2 + def __init__(self) -> None: """Initialize the config flow.""" self.reauth_entry: ConfigEntry | None = None self._config: dict[str, Any] = {} - self._machines: list[tuple[str, str]] = [] + self._fleet: dict[str, LaMarzoccoDeviceInfo] = {} self._discovered: dict[str, str] = {} async def async_step_user( @@ -65,9 +74,12 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): **self._discovered, } - lm = LaMarzoccoClient() + cloud_client = LaMarzoccoCloudClient( + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + ) try: - self._machines = await lm.get_all_machines(data) + self._fleet = await cloud_client.get_customer_fleet() except AuthFail: _LOGGER.debug("Server rejected login credentials") errors["base"] = "invalid_auth" @@ -75,7 +87,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error connecting to server: %s", exc) errors["base"] = "cannot_connect" else: - if not self._machines: + if not self._fleet: errors["base"] = "no_machines" if not errors: @@ -88,8 +100,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="reauth_successful") if self._discovered: - serials = [machine[0] for machine in self._machines] - if self._discovered[CONF_MACHINE] not in serials: + if self._discovered[CONF_MACHINE] not in self._fleet: errors["base"] = "machine_not_found" else: self._config = data @@ -128,28 +139,36 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): else: serial_number = self._discovered[CONF_MACHINE] + selected_device = self._fleet[serial_number] + # validate local connection if host is provided if user_input.get(CONF_HOST): - lm = LaMarzoccoClient() - if not await lm.check_local_connection( - credentials=self._config, + if not await LaMarzoccoLocalClient.validate_connection( + client=get_async_client(self.hass), host=user_input[CONF_HOST], - serial=serial_number, + token=selected_device.communication_key, ): errors[CONF_HOST] = "cannot_connect" + else: + self._config[CONF_HOST] = user_input[CONF_HOST] if not errors: return self.async_create_entry( - title=serial_number, - data=self._config | user_input, + title=selected_device.name, + data={ + **self._config, + CONF_NAME: selected_device.name, + CONF_MODEL: selected_device.model, + CONF_TOKEN: selected_device.communication_key, + }, ) machine_options = [ SelectOptionDict( - value=serial_number, - label=f"{model_name} ({serial_number})", + value=device.serial_number, + label=f"{device.model} ({device.serial_number})", ) - for serial_number, model_name in self._machines + for device in self._fleet.values() ] machine_selection_schema = vol.Schema( diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py index 87878ea5089..57db84f94da 100644 --- a/homeassistant/components/lamarzocco/const.py +++ b/homeassistant/components/lamarzocco/const.py @@ -4,6 +4,4 @@ from typing import Final DOMAIN: Final = "lamarzocco" -CONF_MACHINE: Final = "machine" - -CONF_USE_BLUETOOTH = "use_bluetooth" +CONF_USE_BLUETOOTH: Final = "use_bluetooth" diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 412fe9ee3ce..2c78a925ca4 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -3,144 +3,117 @@ from collections.abc import Callable, Coroutine from datetime import timedelta import logging +from time import time from typing import Any -from bleak.backends.device import BLEDevice -from lmcloud import LMCloud as LaMarzoccoClient -from lmcloud.const import BT_MODEL_NAMES +from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient +from lmcloud.client_cloud import LaMarzoccoCloudClient +from lmcloud.client_local import LaMarzoccoLocalClient from lmcloud.exceptions import AuthFail, RequestNotSuccessful +from lmcloud.lm_machine import LaMarzoccoMachine -from homeassistant.components.bluetooth import ( - async_ble_device_from_address, - async_discovered_service_info, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_USERNAME +from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_MACHINE, CONF_USE_BLUETOOTH, DOMAIN +from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=30) +FIRMWARE_UPDATE_INTERVAL = 3600 +STATISTICS_UPDATE_INTERVAL = 300 _LOGGER = logging.getLogger(__name__) -NAME_PREFIXES = tuple(BT_MODEL_NAMES) - - class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): """Class to handle fetching data from the La Marzocco API centrally.""" config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, + hass: HomeAssistant, + cloud_client: LaMarzoccoCloudClient, + local_client: LaMarzoccoLocalClient | None, + bluetooth_client: LaMarzoccoBluetoothClient | None, + ) -> None: """Initialize coordinator.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - self.lm = LaMarzoccoClient( - callback_websocket_notify=self.async_update_listeners, - ) - self.local_connection_configured = ( - self.config_entry.data.get(CONF_HOST) is not None - ) - self._use_bluetooth = False + self.local_connection_configured = local_client is not None - async def _async_update_data(self) -> None: - """Fetch data from API endpoint.""" - - if not self.lm.initialized: - await self._async_init_client() - - await self._async_handle_request( - self.lm.update_local_machine_status, force_update=True + assert self.config_entry.unique_id + self.device = LaMarzoccoMachine( + model=self.config_entry.data[CONF_MODEL], + serial_number=self.config_entry.unique_id, + name=self.config_entry.data[CONF_NAME], + cloud_client=cloud_client, + local_client=local_client, + bluetooth_client=bluetooth_client, ) - _LOGGER.debug("Current status: %s", str(self.lm.current_status)) + self._last_firmware_data_update: float | None = None + self._last_statistics_data_update: float | None = None + self._local_client = local_client - async def _async_init_client(self) -> None: - """Initialize the La Marzocco Client.""" - - # Initialize cloud API - _LOGGER.debug("Initializing Cloud API") - await self._async_handle_request( - self.lm.init_cloud_api, - credentials=self.config_entry.data, - machine_serial=self.config_entry.data[CONF_MACHINE], - ) - _LOGGER.debug("Model name: %s", self.lm.model_name) - - # initialize local API - if (host := self.config_entry.data.get(CONF_HOST)) is not None: - _LOGGER.debug("Initializing local API") - await self.lm.init_local_api( - host=host, - client=get_async_client(self.hass), - ) - - _LOGGER.debug("Init WebSocket in Background Task") + async def async_setup(self) -> None: + """Set up the coordinator.""" + if self._local_client is not None: + _LOGGER.debug("Init WebSocket in background task") self.config_entry.async_create_background_task( hass=self.hass, - target=self.lm.lm_local_api.websocket_connect( - callback=self.lm.on_websocket_message_received, - use_sigterm_handler=False, + target=self.device.websocket_connect( + notify_callback=lambda: self.async_set_updated_data(None) ), name="lm_websocket_task", ) - # initialize Bluetooth - if self.config_entry.options.get(CONF_USE_BLUETOOTH, True): + async def websocket_close(_: Any | None = None) -> None: + if ( + self._local_client is not None + and self._local_client.websocket is not None + and self._local_client.websocket.open + ): + self._local_client.terminating = True + await self._local_client.websocket.close() - def bluetooth_configured() -> bool: - return self.config_entry.data.get( - CONF_MAC, "" - ) and self.config_entry.data.get(CONF_NAME, "") - - if not bluetooth_configured(): - machine = self.config_entry.data[CONF_MACHINE] - for discovery_info in async_discovered_service_info(self.hass): - if ( - (name := discovery_info.name) - and name.startswith(NAME_PREFIXES) - and name.split("_")[1] == machine - ): - _LOGGER.debug( - "Found Bluetooth device, configuring with Bluetooth" - ) - # found a device, add MAC address to config entry - self.hass.config_entries.async_update_entry( - self.config_entry, - data={ - **self.config_entry.data, - CONF_MAC: discovery_info.address, - CONF_NAME: discovery_info.name, - }, - ) - break - - if bluetooth_configured(): - # config entry contains BT config - _LOGGER.debug("Initializing with known Bluetooth device") - await self.lm.init_bluetooth_with_known_device( - self.config_entry.data[CONF_USERNAME], - self.config_entry.data.get(CONF_MAC, ""), - self.config_entry.data.get(CONF_NAME, ""), + self.config_entry.async_on_unload( + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, websocket_close ) - self._use_bluetooth = True + ) + self.config_entry.async_on_unload(websocket_close) - self.lm.initialized = True + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + await self._async_handle_request(self.device.get_config) - async def _async_handle_request( + if ( + self._last_firmware_data_update is None + or (self._last_firmware_data_update + FIRMWARE_UPDATE_INTERVAL) < time() + ): + await self._async_handle_request(self.device.get_firmware) + self._last_firmware_data_update = time() + + if ( + self._last_statistics_data_update is None + or (self._last_statistics_data_update + STATISTICS_UPDATE_INTERVAL) < time() + ): + await self._async_handle_request(self.device.get_statistics) + self._last_statistics_data_update = time() + + _LOGGER.debug("Current status: %s", str(self.device.config)) + + async def _async_handle_request[**_P]( self, - func: Callable[..., Coroutine[None, None, None]], - *args: Any, - **kwargs: Any, + func: Callable[_P, Coroutine[None, None, None]], + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: - """Handle a request to the API.""" try: - await func(*args, **kwargs) + await func() except AuthFail as ex: msg = "Authentication failed." _LOGGER.debug(msg, exc_info=True) @@ -148,15 +121,3 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): except RequestNotSuccessful as ex: _LOGGER.debug(ex, exc_info=True) raise UpdateFailed(f"Querying API failed. Error: {ex}") from ex - - def async_get_ble_device(self) -> BLEDevice | None: - """Get a Bleak Client for the machine.""" - # according to HA best practices, we should not reuse the same client - # get a new BLE device from hass and init a new Bleak Client with it - if not self._use_bluetooth: - return None - - return async_ble_device_from_address( - self.hass, - self.lm.lm_bluetooth.address, - ) diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index 648d1357a35..4293fdca615 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -2,42 +2,43 @@ from __future__ import annotations -from typing import Any +from dataclasses import asdict +from typing import Any, TypedDict + +from lmcloud.const import FirmwareType from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaMarzoccoUpdateCoordinator +from . import LaMarzoccoConfigEntry TO_REDACT = { "serial_number", - "machine_sn", } +class DiagnosticsData(TypedDict): + """Diagnostic data for La Marzocco.""" + + model: str + config: dict[str, Any] + firmware: list[dict[FirmwareType, dict[str, Any]]] + statistics: dict[str, Any] + + async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, + entry: LaMarzoccoConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LaMarzoccoUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data + device = coordinator.device # collect all data sources - data = {} - data["current_status"] = coordinator.lm.current_status - data["machine_info"] = coordinator.lm.machine_info - data["config"] = coordinator.lm.config - data["statistics"] = {"stats": coordinator.lm.statistics} # wrap to satisfy mypy + diagnostics_data = DiagnosticsData( + model=device.model, + config=asdict(device.config), + firmware=[{key: asdict(firmware)} for key, firmware in device.firmware.items()], + statistics=asdict(device.statistics), + ) - # build a firmware section - data["firmware"] = { - "machine": { - "version": coordinator.lm.firmware_version, - "latest_version": coordinator.lm.latest_firmware_version, - }, - "gateway": { - "version": coordinator.lm.gateway_version, - "latest_version": coordinator.lm.latest_gateway_version, - }, - } - return async_redact_data(data, TO_REDACT) + return async_redact_data(diagnostics_data, TO_REDACT) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 4cb9d4a580a..9cc2ce8ef6b 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -3,7 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import FirmwareType +from lmcloud.lm_machine import LaMarzoccoMachine from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -17,11 +18,13 @@ from .coordinator import LaMarzoccoUpdateCoordinator class LaMarzoccoEntityDescription(EntityDescription): """Description for all LM entities.""" - available_fn: Callable[[LaMarzoccoClient], bool] = lambda _: True + available_fn: Callable[[LaMarzoccoMachine], bool] = lambda _: True supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True -class LaMarzoccoBaseEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): +class LaMarzoccoBaseEntity( + CoordinatorEntity[LaMarzoccoUpdateCoordinator], +): """Common elements for all entities.""" _attr_has_entity_name = True @@ -33,15 +36,15 @@ class LaMarzoccoBaseEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): ) -> None: """Initialize the entity.""" super().__init__(coordinator) - lm = coordinator.lm - self._attr_unique_id = f"{lm.serial_number}_{key}" + device = coordinator.device + self._attr_unique_id = f"{device.serial_number}_{key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, lm.serial_number)}, - name=lm.machine_name, + identifiers={(DOMAIN, device.serial_number)}, + name=device.name, manufacturer="La Marzocco", - model=lm.true_model_name, - serial_number=lm.serial_number, - sw_version=lm.firmware_version, + model=device.full_model_name, + serial_number=device.serial_number, + sw_version=device.firmware[FirmwareType.MACHINE].current_version, ) @@ -50,19 +53,18 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity): entity_description: LaMarzoccoEntityDescription + @property + def available(self) -> bool: + """Return True if entity is available.""" + if super().available: + return self.entity_description.available_fn(self.coordinator.device) + return False + def __init__( self, coordinator: LaMarzoccoUpdateCoordinator, entity_description: LaMarzoccoEntityDescription, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, entity_description.key) self.entity_description = entity_description - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self.entity_description.available_fn( - self.coordinator.lm - ) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 727d3c66009..bc7d621d91d 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -14,6 +14,12 @@ "on": "mdi:cup-water", "off": "mdi:cup-off" } + }, + "backflush_enabled": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } } }, "button": { @@ -26,10 +32,7 @@ "default": "mdi:thermometer-water" }, "dose": { - "default": "mdi:weight-kilogram" - }, - "steam_temp": { - "default": "mdi:thermometer-water" + "default": "mdi:cup-water" }, "prebrew_off": { "default": "mdi:water-off" @@ -40,6 +43,9 @@ "preinfusion_off": { "default": "mdi:water" }, + "steam_temp": { + "default": "mdi:thermometer-water" + }, "tea_water_duration": { "default": "mdi:timer-sand" } @@ -58,7 +64,7 @@ "state": { "disabled": "mdi:water-pump-off", "prebrew": "mdi:water-pump", - "preinfusion": "mdi:water-pump" + "typeb": "mdi:water-pump" } } }, diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index ec6068e1988..73d14250525 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["lmcloud"], - "requirements": ["lmcloud==0.4.35"] + "requirements": ["lmcloud==1.1.13"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index af5256bc77b..69e5b42c116 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -4,15 +4,21 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from lmcloud import LMCloud as LaMarzoccoClient -from lmcloud.const import KEYS_PER_MODEL, LaMarzoccoModel +from lmcloud.const import ( + KEYS_PER_MODEL, + BoilerType, + MachineModel, + PhysicalKey, + PrebrewMode, +) +from lmcloud.lm_machine import LaMarzoccoMachine +from lmcloud.models import LaMarzoccoMachineConfig from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PRECISION_TENTHS, PRECISION_WHOLE, @@ -23,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LaMarzoccoConfigEntry from .coordinator import LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription @@ -35,10 +41,8 @@ class LaMarzoccoNumberEntityDescription( ): """Description of a La Marzocco number entity.""" - native_value_fn: Callable[[LaMarzoccoClient], float | int] - set_value_fn: Callable[ - [LaMarzoccoUpdateCoordinator, float | int], Coroutine[Any, Any, bool] - ] + native_value_fn: Callable[[LaMarzoccoMachineConfig], float | int] + set_value_fn: Callable[[LaMarzoccoMachine, float | int], Coroutine[Any, Any, bool]] @dataclass(frozen=True, kw_only=True) @@ -48,9 +52,9 @@ class LaMarzoccoKeyNumberEntityDescription( ): """Description of an La Marzocco number entity with keys.""" - native_value_fn: Callable[[LaMarzoccoClient, int], float | int] + native_value_fn: Callable[[LaMarzoccoMachineConfig, PhysicalKey], float | int] set_value_fn: Callable[ - [LaMarzoccoClient, float | int, int], Coroutine[Any, Any, bool] + [LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool] ] @@ -63,10 +67,10 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( native_step=PRECISION_TENTHS, native_min_value=85, native_max_value=104, - set_value_fn=lambda coordinator, temp: coordinator.lm.set_coffee_temp( - temp, coordinator.async_get_ble_device() - ), - native_value_fn=lambda lm: lm.current_status["coffee_set_temp"], + set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp), + native_value_fn=lambda config: config.boilers[ + BoilerType.COFFEE + ].target_temperature, ), LaMarzoccoNumberEntityDescription( key="steam_temp", @@ -76,14 +80,14 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( native_step=PRECISION_WHOLE, native_min_value=126, native_max_value=131, - set_value_fn=lambda coordinator, temp: coordinator.lm.set_steam_temp( - int(temp), coordinator.async_get_ble_device() - ), - native_value_fn=lambda lm: lm.current_status["steam_set_temp"], - supported_fn=lambda coordinator: coordinator.lm.model_name + set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.STEAM, temp), + native_value_fn=lambda config: config.boilers[ + BoilerType.STEAM + ].target_temperature, + supported_fn=lambda coordinator: coordinator.device.model in ( - LaMarzoccoModel.GS3_AV, - LaMarzoccoModel.GS3_MP, + MachineModel.GS3_AV, + MachineModel.GS3_MP, ), ), LaMarzoccoNumberEntityDescription( @@ -94,54 +98,17 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( native_step=PRECISION_WHOLE, native_min_value=0, native_max_value=30, - set_value_fn=lambda coordinator, value: coordinator.lm.set_dose_hot_water( - value=int(value) - ), - native_value_fn=lambda lm: lm.current_status["dose_hot_water"], - supported_fn=lambda coordinator: coordinator.lm.model_name + set_value_fn=lambda machine, value: machine.set_dose_tea_water(int(value)), + native_value_fn=lambda config: config.dose_hot_water, + supported_fn=lambda coordinator: coordinator.device.model in ( - LaMarzoccoModel.GS3_AV, - LaMarzoccoModel.GS3_MP, + MachineModel.GS3_AV, + MachineModel.GS3_MP, ), ), ) -async def _set_prebrew_on( - lm: LaMarzoccoClient, - value: float, - key: int, -) -> bool: - return await lm.configure_prebrew( - on_time=int(value * 1000), - off_time=int(lm.current_status[f"prebrewing_toff_k{key}"] * 1000), - key=key, - ) - - -async def _set_prebrew_off( - lm: LaMarzoccoClient, - value: float, - key: int, -) -> bool: - return await lm.configure_prebrew( - on_time=int(lm.current_status[f"prebrewing_ton_k{key}"] * 1000), - off_time=int(value * 1000), - key=key, - ) - - -async def _set_preinfusion( - lm: LaMarzoccoClient, - value: float, - key: int, -) -> bool: - return await lm.configure_prebrew( - off_time=int(value * 1000), - key=key, - ) - - KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( LaMarzoccoKeyNumberEntityDescription( key="prebrew_off", @@ -152,11 +119,14 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( native_min_value=1, native_max_value=10, entity_category=EntityCategory.CONFIG, - set_value_fn=_set_prebrew_off, - native_value_fn=lambda lm, key: lm.current_status[f"prebrewing_ton_k{key}"], - available_fn=lambda lm: lm.current_status["enable_prebrewing"], - supported_fn=lambda coordinator: coordinator.lm.model_name - != LaMarzoccoModel.GS3_MP, + set_value_fn=lambda machine, value, key: machine.set_prebrew_time( + prebrew_off_time=value, key=key + ), + native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + available_fn=lambda device: len(device.config.prebrew_configuration) > 0 + and device.config.prebrew_mode == PrebrewMode.PREBREW, + supported_fn=lambda coordinator: coordinator.device.model + != MachineModel.GS3_MP, ), LaMarzoccoKeyNumberEntityDescription( key="prebrew_on", @@ -167,11 +137,14 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( native_min_value=2, native_max_value=10, entity_category=EntityCategory.CONFIG, - set_value_fn=_set_prebrew_on, - native_value_fn=lambda lm, key: lm.current_status[f"prebrewing_toff_k{key}"], - available_fn=lambda lm: lm.current_status["enable_prebrewing"], - supported_fn=lambda coordinator: coordinator.lm.model_name - != LaMarzoccoModel.GS3_MP, + set_value_fn=lambda machine, value, key: machine.set_prebrew_time( + prebrew_on_time=value, key=key + ), + native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + available_fn=lambda device: len(device.config.prebrew_configuration) > 0 + and device.config.prebrew_mode == PrebrewMode.PREBREW, + supported_fn=lambda coordinator: coordinator.device.model + != MachineModel.GS3_MP, ), LaMarzoccoKeyNumberEntityDescription( key="preinfusion_off", @@ -182,11 +155,16 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( native_min_value=2, native_max_value=29, entity_category=EntityCategory.CONFIG, - set_value_fn=_set_preinfusion, - native_value_fn=lambda lm, key: lm.current_status[f"preinfusion_k{key}"], - available_fn=lambda lm: lm.current_status["enable_preinfusion"], - supported_fn=lambda coordinator: coordinator.lm.model_name - != LaMarzoccoModel.GS3_MP, + set_value_fn=lambda machine, value, key: machine.set_preinfusion_time( + preinfusion_time=value, key=key + ), + native_value_fn=lambda config, key: config.prebrew_configuration[ + key + ].preinfusion_time, + available_fn=lambda device: len(device.config.prebrew_configuration) > 0 + and device.config.prebrew_mode == PrebrewMode.PREINFUSION, + supported_fn=lambda coordinator: coordinator.device.model + != MachineModel.GS3_MP, ), LaMarzoccoKeyNumberEntityDescription( key="dose", @@ -196,22 +174,23 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( native_min_value=0, native_max_value=999, entity_category=EntityCategory.CONFIG, - set_value_fn=lambda lm, ticks, key: lm.set_dose(key=key, value=int(ticks)), - native_value_fn=lambda lm, key: lm.current_status[f"dose_k{key}"], - supported_fn=lambda coordinator: coordinator.lm.model_name - == LaMarzoccoModel.GS3_AV, + set_value_fn=lambda machine, ticks, key: machine.set_dose( + dose=int(ticks), key=key + ), + native_value_fn=lambda config, key: config.doses[key], + supported_fn=lambda coordinator: coordinator.device.model + == MachineModel.GS3_AV, ), ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up number entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - + coordinator = entry.runtime_data entities: list[NumberEntity] = [ LaMarzoccoNumberEntity(coordinator, description) for description in ENTITIES @@ -220,12 +199,11 @@ async def async_setup_entry( for description in KEY_ENTITIES: if description.supported_fn(coordinator): - num_keys = KEYS_PER_MODEL[coordinator.lm.model_name] + num_keys = KEYS_PER_MODEL[MachineModel(coordinator.device.model)] entities.extend( LaMarzoccoKeyNumberEntity(coordinator, description, key) for key in range(min(num_keys, 1), num_keys + 1) ) - async_add_entities(entities) @@ -237,12 +215,13 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): @property def native_value(self) -> float: """Return the current value.""" - return self.entity_description.native_value_fn(self.coordinator.lm) + return self.entity_description.native_value_fn(self.coordinator.device.config) async def async_set_native_value(self, value: float) -> None: """Set the value.""" - await self.entity_description.set_value_fn(self.coordinator, value) - self.async_write_ha_state() + if value != self.native_value: + await self.entity_description.set_value_fn(self.coordinator.device, value) + self.async_write_ha_state() class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): @@ -273,12 +252,13 @@ class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): def native_value(self) -> float: """Return the current value.""" return self.entity_description.native_value_fn( - self.coordinator.lm, self.pyhsical_key + self.coordinator.device.config, PhysicalKey(self.pyhsical_key) ) async def async_set_native_value(self, value: float) -> None: """Set the value.""" - await self.entity_description.set_value_fn( - self.coordinator.lm, value, self.pyhsical_key - ) - self.async_write_ha_state() + if value != self.native_value: + await self.entity_description.set_value_fn( + self.coordinator.device, value, PhysicalKey(self.pyhsical_key) + ) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index f063f8e6336..5bff815fb95 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -4,18 +4,42 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from lmcloud import LMCloud as LaMarzoccoClient -from lmcloud.const import LaMarzoccoModel +from lmcloud.const import MachineModel, PrebrewMode, SteamLevel +from lmcloud.lm_machine import LaMarzoccoMachine +from lmcloud.models import LaMarzoccoMachineConfig 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.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMarzoccoUpdateCoordinator +from . import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +STEAM_LEVEL_HA_TO_LM = { + "1": SteamLevel.LEVEL_1, + "2": SteamLevel.LEVEL_2, + "3": SteamLevel.LEVEL_3, +} + +STEAM_LEVEL_LM_TO_HA = { + SteamLevel.LEVEL_1: "1", + SteamLevel.LEVEL_2: "2", + SteamLevel.LEVEL_3: "3", +} + +PREBREW_MODE_HA_TO_LM = { + "disabled": PrebrewMode.DISABLED, + "prebrew": PrebrewMode.PREBREW, + "preinfusion": PrebrewMode.PREINFUSION, +} + +PREBREW_MODE_LM_TO_HA = { + PrebrewMode.DISABLED: "disabled", + PrebrewMode.PREBREW: "prebrew", + PrebrewMode.PREINFUSION: "preinfusion", +} + @dataclass(frozen=True, kw_only=True) class LaMarzoccoSelectEntityDescription( @@ -24,10 +48,8 @@ class LaMarzoccoSelectEntityDescription( ): """Description of a La Marzocco select entity.""" - current_option_fn: Callable[[LaMarzoccoClient], str] - select_option_fn: Callable[ - [LaMarzoccoUpdateCoordinator, str], Coroutine[Any, Any, bool] - ] + current_option_fn: Callable[[LaMarzoccoMachineConfig], str] + select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]] ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( @@ -35,25 +57,27 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( key="steam_temp_select", translation_key="steam_temp_select", options=["1", "2", "3"], - select_option_fn=lambda coordinator, option: coordinator.lm.set_steam_level( - int(option), coordinator.async_get_ble_device() + select_option_fn=lambda machine, option: machine.set_steam_level( + STEAM_LEVEL_HA_TO_LM[option] ), - current_option_fn=lambda lm: lm.current_status["steam_level_set"], - supported_fn=lambda coordinator: coordinator.lm.model_name - == LaMarzoccoModel.LINEA_MICRA, + current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level], + supported_fn=lambda coordinator: coordinator.device.model + == MachineModel.LINEA_MICRA, ), LaMarzoccoSelectEntityDescription( key="prebrew_infusion_select", translation_key="prebrew_infusion_select", + entity_category=EntityCategory.CONFIG, options=["disabled", "prebrew", "preinfusion"], - select_option_fn=lambda coordinator, - option: coordinator.lm.select_pre_brew_infusion_mode(option.capitalize()), - current_option_fn=lambda lm: lm.pre_brew_infusion_mode.lower(), - supported_fn=lambda coordinator: coordinator.lm.model_name + select_option_fn=lambda machine, option: machine.set_prebrew_mode( + PREBREW_MODE_HA_TO_LM[option] + ), + current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode], + supported_fn=lambda coordinator: coordinator.device.model in ( - LaMarzoccoModel.GS3_AV, - LaMarzoccoModel.LINEA_MICRA, - LaMarzoccoModel.LINEA_MINI, + MachineModel.GS3_AV, + MachineModel.LINEA_MICRA, + MachineModel.LINEA_MINI, ), ), ) @@ -61,11 +85,11 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up select entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMarzoccoSelectEntity(coordinator, description) @@ -82,9 +106,14 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): @property def current_option(self) -> str: """Return the current selected option.""" - return str(self.entity_description.current_option_fn(self.coordinator.lm)) + return str( + self.entity_description.current_option_fn(self.coordinator.device.config) + ) async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.entity_description.select_option_fn(self.coordinator, option) - self.async_write_ha_state() + if option != self.current_option: + await self.entity_description.select_option_fn( + self.coordinator.device, option + ) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index ea5a5e184e1..225f0a43c5c 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,7 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import BoilerType, MachineModel, PhysicalKey +from lmcloud.lm_machine import LaMarzoccoMachine from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,23 +12,21 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription @dataclass(frozen=True, kw_only=True) class LaMarzoccoSensorEntityDescription( - LaMarzoccoEntityDescription, - SensorEntityDescription, + LaMarzoccoEntityDescription, SensorEntityDescription ): """Description of a La Marzocco sensor.""" - value_fn: Callable[[LaMarzoccoClient], float | int] + value_fn: Callable[[LaMarzoccoMachine], float | int] ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( @@ -36,7 +35,8 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( translation_key="drink_stats_coffee", native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda lm: lm.current_status.get("drinks_k1", 0), + value_fn=lambda device: device.statistics.drink_stats.get(PhysicalKey.A, 0), + available_fn=lambda device: len(device.statistics.drink_stats) > 0, entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( @@ -44,7 +44,8 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( translation_key="drink_stats_flushing", native_unit_of_measurement="drinks", state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda lm: lm.current_status.get("total_flushing", 0), + value_fn=lambda device: device.statistics.total_flushes, + available_fn=lambda device: len(device.statistics.drink_stats) > 0, entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( @@ -53,8 +54,8 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DURATION, - value_fn=lambda lm: lm.current_status.get("brew_active_duration", 0), - available_fn=lambda lm: lm.websocket_connected, + value_fn=lambda device: device.config.brew_active_duration, + available_fn=lambda device: device.websocket_connected, entity_category=EntityCategory.DIAGNOSTIC, supported_fn=lambda coordinator: coordinator.local_connection_configured, ), @@ -65,7 +66,9 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda lm: lm.current_status.get("coffee_temp", 0), + value_fn=lambda device: device.config.boilers[ + BoilerType.COFFEE + ].current_temperature, ), LaMarzoccoSensorEntityDescription( key="current_temp_steam", @@ -74,18 +77,22 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, - value_fn=lambda lm: lm.current_status.get("steam_temp", 0), + value_fn=lambda device: device.config.boilers[ + BoilerType.STEAM + ].current_temperature, + supported_fn=lambda coordinator: coordinator.device.model + != MachineModel.LINEA_MINI, ), ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMarzoccoSensorEntity(coordinator, description) @@ -102,4 +109,4 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): @property def native_value(self) -> int | float: """State of the sensor.""" - return self.entity_description.value_fn(self.coordinator.lm) + return self.entity_description.value_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 03ce2eb93e8..08e3e764379 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -54,6 +54,9 @@ }, "entity": { "binary_sensor": { + "backflush_enabled": { + "name": "Backflush active" + }, "brew_active": { "name": "Brewing active" }, @@ -68,7 +71,7 @@ }, "calendar": { "auto_on_off_schedule": { - "name": "Auto on/off schedule" + "name": "Auto on/off schedule ({id})" } }, "number": { @@ -140,7 +143,7 @@ }, "switch": { "auto_on_off": { - "name": "Auto on/off" + "name": "Auto on/off ({id})" }, "steam_boiler": { "name": "Steam boiler" @@ -154,5 +157,11 @@ "name": "Gateway firmware" } } + }, + "issues": { + "unsupported_gateway_firmware": { + "title": "Unsupported gateway firmware", + "description": "Gateway firmware {gateway_version} is no longer supported by this integration, please update." + } } } diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index dd647bf4582..e21cd2f3d94 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -4,15 +4,17 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any +from lmcloud.const import BoilerType +from lmcloud.lm_machine import LaMarzoccoMachine +from lmcloud.models import LaMarzoccoMachineConfig + from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LaMarzoccoConfigEntry from .coordinator import LaMarzoccoUpdateCoordinator -from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +from .entity import LaMarzoccoBaseEntity, LaMarzoccoEntity, LaMarzoccoEntityDescription @dataclass(frozen=True, kw_only=True) @@ -22,8 +24,8 @@ class LaMarzoccoSwitchEntityDescription( ): """Description of a La Marzocco Switch.""" - control_fn: Callable[[LaMarzoccoUpdateCoordinator, bool], Coroutine[Any, Any, bool]] - is_on_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] + control_fn: Callable[[LaMarzoccoMachine, bool], Coroutine[Any, Any, bool]] + is_on_fn: Callable[[LaMarzoccoMachineConfig], bool] ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( @@ -31,48 +33,41 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( key="main", translation_key="main", name=None, - control_fn=lambda coordinator, state: coordinator.lm.set_power( - state, coordinator.async_get_ble_device() - ), - is_on_fn=lambda coordinator: coordinator.lm.current_status["power"], - ), - LaMarzoccoSwitchEntityDescription( - key="auto_on_off", - translation_key="auto_on_off", - control_fn=lambda coordinator, state: coordinator.lm.set_auto_on_off_global( - state - ), - is_on_fn=lambda coordinator: coordinator.lm.current_status["global_auto"] - == "Enabled", - entity_category=EntityCategory.CONFIG, + control_fn=lambda machine, state: machine.set_power(state), + is_on_fn=lambda config: config.turned_on, ), LaMarzoccoSwitchEntityDescription( key="steam_boiler_enable", translation_key="steam_boiler", - control_fn=lambda coordinator, state: coordinator.lm.set_steam( - state, coordinator.async_get_ble_device() - ), - is_on_fn=lambda coordinator: coordinator.lm.current_status[ - "steam_boiler_enable" - ], + control_fn=lambda machine, state: machine.set_steam(state), + is_on_fn=lambda config: config.boilers[BoilerType.STEAM].enabled, ), ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switch entities and services.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( + coordinator = entry.runtime_data + + entities: list[SwitchEntity] = [] + entities.extend( LaMarzoccoSwitchEntity(coordinator, description) for description in ENTITIES if description.supported_fn(coordinator) ) + entities.extend( + LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry_id) + for wake_up_sleep_entry_id in coordinator.device.config.wake_up_sleep_entries + ) + + async_add_entities(entities) + class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): """Switches representing espresso machine power, prebrew, and auto on/off.""" @@ -81,15 +76,56 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn device on.""" - await self.entity_description.control_fn(self.coordinator, True) + await self.entity_description.control_fn(self.coordinator.device, True) self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" - await self.entity_description.control_fn(self.coordinator, False) + await self.entity_description.control_fn(self.coordinator.device, False) self.async_write_ha_state() @property def is_on(self) -> bool: """Return true if device is on.""" - return self.entity_description.is_on_fn(self.coordinator) + return self.entity_description.is_on_fn(self.coordinator.device.config) + + +class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): + """Switch representing espresso machine auto on/off.""" + + coordinator: LaMarzoccoUpdateCoordinator + _attr_translation_key = "auto_on_off" + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + identifier: str, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, f"auto_on_off_{identifier}") + self._identifier = identifier + self._attr_translation_placeholders = {"id": identifier} + + async def _async_enable(self, state: bool) -> None: + """Enable or disable the auto on/off schedule.""" + wake_up_sleep_entry = self.coordinator.device.config.wake_up_sleep_entries[ + self._identifier + ] + wake_up_sleep_entry.enabled = state + await self.coordinator.device.set_wake_up_sleep(wake_up_sleep_entry) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + await self._async_enable(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + await self._async_enable(False) + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.coordinator.device.config.wake_up_sleep_entries[ + self._identifier + ].enabled diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index cc3e665725b..342a3e09071 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -1,11 +1,9 @@ """Support for La Marzocco update entities.""" -from collections.abc import Callable from dataclasses import dataclass from typing import Any -from lmcloud import LMCloud as LaMarzoccoClient -from lmcloud.const import LaMarzoccoUpdateableComponent +from lmcloud.const import FirmwareType from homeassistant.components.update import ( UpdateDeviceClass, @@ -13,13 +11,12 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -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 . import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription @@ -30,9 +27,7 @@ class LaMarzoccoUpdateEntityDescription( ): """Description of a La Marzocco update entities.""" - current_fw_fn: Callable[[LaMarzoccoClient], str] - latest_fw_fn: Callable[[LaMarzoccoClient], str] - component: LaMarzoccoUpdateableComponent + component: FirmwareType ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( @@ -40,18 +35,14 @@ ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( key="machine_firmware", translation_key="machine_firmware", device_class=UpdateDeviceClass.FIRMWARE, - current_fw_fn=lambda lm: lm.firmware_version, - latest_fw_fn=lambda lm: lm.latest_firmware_version, - component=LaMarzoccoUpdateableComponent.MACHINE, + component=FirmwareType.MACHINE, entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoUpdateEntityDescription( key="gateway_firmware", translation_key="gateway_firmware", device_class=UpdateDeviceClass.FIRMWARE, - current_fw_fn=lambda lm: lm.gateway_version, - latest_fw_fn=lambda lm: lm.latest_gateway_version, - component=LaMarzoccoUpdateableComponent.GATEWAY, + component=FirmwareType.GATEWAY, entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -59,12 +50,12 @@ ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LaMarzoccoConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create update entities.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMarzoccoUpdateEntity(coordinator, description) for description in ENTITIES @@ -81,12 +72,16 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): @property def installed_version(self) -> str | None: """Return the current firmware version.""" - return self.entity_description.current_fw_fn(self.coordinator.lm) + return self.coordinator.device.firmware[ + self.entity_description.component + ].current_version @property def latest_version(self) -> str: """Return the latest firmware version.""" - return self.entity_description.latest_fw_fn(self.coordinator.lm) + return self.coordinator.device.firmware[ + self.entity_description.component + ].latest_version async def async_install( self, version: str | None, backup: bool, **kwargs: Any @@ -94,7 +89,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): """Install an update.""" self._attr_in_progress = True self.async_write_ha_state() - success = await self.coordinator.lm.update_firmware( + success = await self.coordinator.device.update_firmware( self.entity_description.component ) if not success: diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index f21b0cb0a3c..8dbd5279bc6 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -152,7 +152,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): except LaMetricConnectionError as ex: LOGGER.error("Error connecting to LaMetric: %s", ex) errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error occurred") errors["base"] = "unknown" @@ -214,7 +214,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): except LaMetricConnectionError as ex: LOGGER.error("Error connecting to LaMetric: %s", ex) errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error occurred") errors["base"] = "unknown" diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py index 24c028da78c..8620b0c7cd9 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from demetriek import LaMetricConnectionError, LaMetricError @@ -15,11 +15,8 @@ from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator from .entity import LaMetricEntity -_LaMetricEntityT = TypeVar("_LaMetricEntityT", bound=LaMetricEntity) -_P = ParamSpec("_P") - -def lametric_exception_handler( +def lametric_exception_handler[_LaMetricEntityT: LaMetricEntity, **_P]( 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/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 154409ac66d..c6ea120242d 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -49,7 +49,7 @@ def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: errors["base"] = "invalid_auth" else: errors["base"] = "unknown" - except Exception: # pylint:disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" return user, errors diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 23bf159ac61..66e7eb832fe 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -6,9 +6,8 @@ from datetime import timedelta import logging from typing import TypedDict -from pylaunches import PyLaunches, PyLaunchesException -from pylaunches.objects.launch import Launch -from pylaunches.objects.starship import StarshipResponse +from pylaunches import PyLaunches, PyLaunchesError +from pylaunches.types import Launch, StarshipResponse from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -41,12 +40,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update() -> LaunchLibraryData: try: return LaunchLibraryData( - upcoming_launches=await launches.upcoming_launches( + upcoming_launches=await launches.launch_upcoming( filters={"limit": 1, "hide_recent_previous": "True"}, ), - starship_events=await launches.starship_events(), + starship_events=await launches.dashboard_starship(), ) - except PyLaunchesException as ex: + except PyLaunchesError as ex: raise UpdateFailed(ex) from ex coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/launch_library/diagnostics.py b/homeassistant/components/launch_library/diagnostics.py index 35d0a699ab5..75541598ef5 100644 --- a/homeassistant/components/launch_library/diagnostics.py +++ b/homeassistant/components/launch_library/diagnostics.py @@ -4,8 +4,7 @@ from __future__ import annotations from typing import Any -from pylaunches.objects.event import Event -from pylaunches.objects.launch import Launch +from pylaunches.types import Event, Launch from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -28,7 +27,7 @@ async def async_get_config_entry_diagnostics( def _first_element(data: list[Launch | Event]) -> dict[str, Any] | None: if not data: return None - return data[0].raw_data_contents + return data[0] return { "next_launch": _first_element(coordinator.data["upcoming_launches"]), diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json index 778e5634b8c..00f11f95a44 100644 --- a/homeassistant/components/launch_library/manifest.json +++ b/homeassistant/components/launch_library/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/launch_library", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pylaunches==1.4.0"] + "requirements": ["pylaunches==2.0.0"] } diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 66b1d95ba2a..7d3b2bd97b6 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -7,8 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Any -from pylaunches.objects.event import Event -from pylaunches.objects.launch import Launch +from pylaunches.types import Event, Launch from homeassistant.components.sensor import ( SensorDeviceClass, @@ -45,12 +44,12 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( key="next_launch", icon="mdi:rocket-launch", translation_key="next_launch", - value_fn=lambda nl: nl.name, + value_fn=lambda nl: nl["name"], attributes_fn=lambda nl: { - "provider": nl.launch_service_provider.name, - "pad": nl.pad.name, - "facility": nl.pad.location.name, - "provider_country_code": nl.pad.location.country_code, + "provider": nl["launch_service_provider"]["name"], + "pad": nl["pad"]["name"], + "facility": nl["pad"]["location"]["name"], + "provider_country_code": nl["pad"]["location"]["country_code"], }, ), LaunchLibrarySensorEntityDescription( @@ -58,11 +57,11 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( icon="mdi:clock-outline", translation_key="launch_time", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda nl: parse_datetime(nl.net), + value_fn=lambda nl: parse_datetime(nl["net"]), attributes_fn=lambda nl: { - "window_start": nl.window_start, - "window_end": nl.window_end, - "stream_live": nl.webcast_live, + "window_start": nl["window_start"], + "window_end": nl["window_end"], + "stream_live": nl["window_start"], }, ), LaunchLibrarySensorEntityDescription( @@ -70,25 +69,25 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( icon="mdi:dice-multiple", translation_key="launch_probability", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda nl: None if nl.probability == -1 else nl.probability, + value_fn=lambda nl: None if nl["probability"] == -1 else nl["probability"], attributes_fn=lambda nl: None, ), LaunchLibrarySensorEntityDescription( key="launch_status", icon="mdi:rocket-launch", translation_key="launch_status", - value_fn=lambda nl: nl.status.name, - attributes_fn=lambda nl: {"reason": nl.holdreason} if nl.inhold else None, + value_fn=lambda nl: nl["status"]["name"], + attributes_fn=lambda nl: {"reason": nl.get("holdreason")}, ), LaunchLibrarySensorEntityDescription( key="launch_mission", icon="mdi:orbit", translation_key="launch_mission", - value_fn=lambda nl: nl.mission.name, + value_fn=lambda nl: nl["mission"]["name"], attributes_fn=lambda nl: { - "mission_type": nl.mission.type, - "target_orbit": nl.mission.orbit.name, - "description": nl.mission.description, + "mission_type": nl["mission"]["type"], + "target_orbit": nl["mission"]["orbit"]["name"], + "description": nl["mission"]["description"], }, ), LaunchLibrarySensorEntityDescription( @@ -96,12 +95,12 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( icon="mdi:rocket", translation_key="starship_launch", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda sl: parse_datetime(sl.net), + value_fn=lambda sl: parse_datetime(sl["net"]), attributes_fn=lambda sl: { - "title": sl.mission.name, - "status": sl.status.name, - "target_orbit": sl.mission.orbit.name, - "description": sl.mission.description, + "title": sl["mission"]["name"], + "status": sl["status"]["name"], + "target_orbit": sl["mission"]["orbit"]["name"], + "description": sl["mission"]["description"], }, ), LaunchLibrarySensorEntityDescription( @@ -109,12 +108,12 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( icon="mdi:calendar", translation_key="starship_event", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda se: parse_datetime(se.date), + value_fn=lambda se: parse_datetime(se["date"]), attributes_fn=lambda se: { - "title": se.name, - "location": se.location, - "stream": se.video_url, - "description": se.description, + "title": se["name"], + "location": se["location"], + "stream": se["video_url"], + "description": se["description"], }, ), ) @@ -190,9 +189,9 @@ class LaunchLibrarySensor( def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.entity_description.key == "starship_launch": - events = self.coordinator.data["starship_events"].upcoming.launches + events = self.coordinator.data["starship_events"]["upcoming"]["launches"] elif self.entity_description.key == "starship_event": - events = self.coordinator.data["starship_events"].upcoming.events + events = self.coordinator.data["starship_events"]["upcoming"]["events"] else: events = self.coordinator.data["upcoming_launches"] diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index c131befd7d4..5a608954321 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -58,7 +58,7 @@ class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_CODE] = "invalid_auth" except ApiConnectionException: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index b0b1a2f1c04..d46628fc6da 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -6,7 +6,7 @@ import asyncio from copy import deepcopy from itertools import chain import re -from typing import TypeAlias, cast +from typing import cast import pypck import voluptuous as vol @@ -60,12 +60,10 @@ from .const import ( ) # typing -AddressType = tuple[int, int, bool] -DeviceConnectionType: TypeAlias = ( - pypck.module.ModuleConnection | pypck.module.GroupConnection -) +type AddressType = tuple[int, int, bool] +type DeviceConnectionType = pypck.module.ModuleConnection | pypck.module.GroupConnection -InputType = type[pypck.inputs.Input] +type InputType = type[pypck.inputs.Input] # Regex for address validation PATTERN_ADDRESS = re.compile( diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index e441832926b..3bab17cbbcd 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -6,6 +6,12 @@ "fingerprint": "Fingerprint code received", "codelock": "Code lock code received", "send_keys": "Send keys received" + }, + "extra_fields": { + "action": "Action", + "code": "Code", + "key": "Key", + "level": "Level" } }, "services": { diff --git a/homeassistant/components/ld2410_ble/config_flow.py b/homeassistant/components/ld2410_ble/config_flow.py index 10d282cb8c7..2cbc660aec6 100644 --- a/homeassistant/components/ld2410_ble/config_flow.py +++ b/homeassistant/components/ld2410_ble/config_flow.py @@ -64,7 +64,7 @@ class Ld2410BleConfigFlow(ConfigFlow, domain=DOMAIN): await ld2410_ble.initialise() except BLEAK_EXCEPTIONS: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py index c57f6678897..62948868870 100644 --- a/homeassistant/components/leaone/sensor.py +++ b/homeassistant/components/leaone/sensor.py @@ -125,7 +125,9 @@ async def async_setup_entry( class LeaoneBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a Leaone sensor.""" diff --git a/homeassistant/components/led_ble/config_flow.py b/homeassistant/components/led_ble/config_flow.py index a5afbcc6c0d..90d86d44160 100644 --- a/homeassistant/components/led_ble/config_flow.py +++ b/homeassistant/components/led_ble/config_flow.py @@ -68,7 +68,7 @@ class LedBleConfigFlow(ConfigFlow, domain=DOMAIN): await led_ble.update() except BLEAK_EXCEPTIONS: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error") errors["base"] = "unknown" else: diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 9a496dbd049..ee5d0431fc8 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -25,6 +25,9 @@ }, { "local_name": "AP-*" + }, + { + "local_name": "MELK-*" } ], "codeowners": ["@bdraco"], diff --git a/homeassistant/components/lg_netcast/config_flow.py b/homeassistant/components/lg_netcast/config_flow.py index 3c1d3d73e0f..c4e6c75edea 100644 --- a/homeassistant/components/lg_netcast/config_flow.py +++ b/homeassistant/components/lg_netcast/config_flow.py @@ -162,7 +162,7 @@ class LGNetCast(config_entries.ConfigFlow, domain=DOMAIN): try: await self.hass.async_add_executor_job( - self.client._get_session_id # pylint: disable=protected-access + self.client._get_session_id # noqa: SLF001 ) except AccessTokenError: if user_input is not None: @@ -194,7 +194,7 @@ class LGNetCast(config_entries.ConfigFlow, domain=DOMAIN): assert self.client is not None with contextlib.suppress(AccessTokenError, SessionIdError): await self.hass.async_add_executor_job( - self.client._get_session_id # pylint: disable=protected-access + self.client._get_session_id # noqa: SLF001 ) @callback diff --git a/homeassistant/components/lidarr/__init__.py b/homeassistant/components/lidarr/__init__.py index acfb8f30f30..e7935501650 100644 --- a/homeassistant/components/lidarr/__init__.py +++ b/homeassistant/components/lidarr/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from dataclasses import dataclass, fields from aiopyarr.lidarr_client import LidarrClient from aiopyarr.models.host_configuration import PyArrHostConfiguration @@ -10,6 +10,7 @@ from aiopyarr.models.host_configuration import PyArrHostConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -25,10 +26,22 @@ from .coordinator import ( WantedDataUpdateCoordinator, ) +type LidarrConfigEntry = ConfigEntry[LidarrData] + PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +@dataclass(kw_only=True, slots=True) +class LidarrData: + """Lidarr data type.""" + + disk_space: DiskSpaceDataUpdateCoordinator + queue: QueueDataUpdateCoordinator + status: StatusDataUpdateCoordinator + wanted: WantedDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: LidarrConfigEntry) -> bool: """Set up Lidarr from a config entry.""" host_configuration = PyArrHostConfiguration( api_token=entry.data[CONF_API_KEY], @@ -40,31 +53,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, host_configuration.verify_ssl), request_timeout=60, ) - coordinators: dict[str, LidarrDataUpdateCoordinator[Any]] = { - "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, lidarr), - "queue": QueueDataUpdateCoordinator(hass, host_configuration, lidarr), - "status": StatusDataUpdateCoordinator(hass, host_configuration, lidarr), - "wanted": WantedDataUpdateCoordinator(hass, host_configuration, lidarr), - } - # Temporary, until we add diagnostic entities - _version = None - for coordinator in coordinators.values(): + data = LidarrData( + disk_space=DiskSpaceDataUpdateCoordinator(hass, host_configuration, lidarr), + queue=QueueDataUpdateCoordinator(hass, host_configuration, lidarr), + status=StatusDataUpdateCoordinator(hass, host_configuration, lidarr), + wanted=WantedDataUpdateCoordinator(hass, host_configuration, lidarr), + ) + for field in fields(data): + coordinator = getattr(data, field.name) await coordinator.async_config_entry_first_refresh() - if isinstance(coordinator, StatusDataUpdateCoordinator): - _version = coordinator.data - coordinator.system_version = _version - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + configuration_url=entry.data[CONF_URL], + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer=DEFAULT_NAME, + sw_version=data.status.data, + ) + entry.runtime_data = data 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, entry: LidarrConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator[T]]): @@ -82,10 +97,5 @@ class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator[T]]): self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" self._attr_device_info = DeviceInfo( - configuration_url=coordinator.host_configuration.base_url, - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - manufacturer=DEFAULT_NAME, - name=coordinator.config_entry.title, - sw_version=coordinator.system_version, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)} ) diff --git a/homeassistant/components/lidarr/config_flow.py b/homeassistant/components/lidarr/config_flow.py index 379a01375b6..05d6900bb41 100644 --- a/homeassistant/components/lidarr/config_flow.py +++ b/homeassistant/components/lidarr/config_flow.py @@ -10,11 +10,12 @@ from aiopyarr import exceptions from aiopyarr.lidarr_client import LidarrClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import LidarrConfigEntry from .const import DEFAULT_NAME, DOMAIN @@ -25,7 +26,7 @@ class LidarrConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the flow.""" - self.entry: ConfigEntry | None = None + self.entry: LidarrConfigEntry | None = None async def async_step_reauth( self, user_input: Mapping[str, Any] diff --git a/homeassistant/components/lidarr/coordinator.py b/homeassistant/components/lidarr/coordinator.py index 8b3116055d4..2f18e4f0ebb 100644 --- a/homeassistant/components/lidarr/coordinator.py +++ b/homeassistant/components/lidarr/coordinator.py @@ -40,7 +40,6 @@ class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): ) self.api_client = api_client self.host_configuration = host_configuration - self.system_version: str | None = None async def _async_update_data(self) -> T: """Get the latest data from Lidarr.""" diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index c876aec4623..b50a826a1c7 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -14,13 +14,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LidarrEntity -from .const import BYTE_SIZES, DOMAIN +from . import LidarrConfigEntry, LidarrEntity +from .const import BYTE_SIZES from .coordinator import LidarrDataUpdateCoordinator, T @@ -106,16 +105,13 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LidarrConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Lidarr sensors based on a config entry.""" - coordinators: dict[str, LidarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ] entities: list[LidarrSensor[Any]] = [] for coordinator_type, description in SENSOR_TYPES.items(): - coordinator = coordinators[coordinator_type] + coordinator = getattr(entry.runtime_data, coordinator_type) if coordinator_type != "disk_space": entities.append(LidarrSensor(coordinator, description)) else: diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index b3b1330b3a1..6d3065c48c9 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -368,7 +368,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st params.pop(ATTR_TRANSITION, None) supported_color_modes = ( - light._light_internal_supported_color_modes # pylint:disable=protected-access + light._light_internal_supported_color_modes # noqa: SLF001 ) if not brightness_supported(supported_color_modes): params.pop(ATTR_BRIGHTNESS, None) @@ -445,8 +445,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ): profiles.apply_default(light.entity_id, light.is_on, params) - # pylint: disable-next=protected-access - legacy_supported_color_modes = light._light_internal_supported_color_modes + legacy_supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001 supported_color_modes = light.supported_color_modes # If a color temperature is specified, emulate it if not supported by the light diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 53127babee9..458dbbde770 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -2,25 +2,16 @@ from __future__ import annotations -import asyncio import logging -from typing import Any import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON +from homeassistant.const import SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, config_validation as cv, intent +from homeassistant.helpers import config_validation as cv, intent import homeassistant.util.color as color_util -from . import ( - ATTR_BRIGHTNESS_PCT, - ATTR_RGB_COLOR, - ATTR_SUPPORTED_COLOR_MODES, - DOMAIN, - brightness_supported, - color_supported, -) +from . import ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -29,120 +20,20 @@ INTENT_SET = "HassLightSet" async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the light intents.""" - intent.async_register(hass, SetIntentHandler()) - - -class SetIntentHandler(intent.IntentHandler): - """Handle set color intents.""" - - intent_type = INTENT_SET - slot_schema = { - vol.Any("name", "area"): cv.string, - vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), - vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), - vol.Optional("color"): color_util.color_name_to_rgb, - vol.Optional("brightness"): vol.All(vol.Coerce(int), vol.Range(0, 100)), - } - - async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Handle the hass intent.""" - hass = intent_obj.hass - service_data: dict[str, Any] = {} - slots = self.async_validate_slots(intent_obj.slots) - - name: str | None = slots.get("name", {}).get("value") - if name == "all": - # Don't match on name if targeting all entities - name = None - - # Look up area first to fail early - area_name = slots.get("area", {}).get("value") - area: ar.AreaEntry | None = None - if area_name is not None: - areas = ar.async_get(hass) - area = areas.async_get_area(area_name) or areas.async_get_area_by_name( - area_name - ) - if area is None: - raise intent.IntentHandleError(f"No area named {area_name}") - - # Optional domain/device class filters. - # Convert to sets for speed. - domains: set[str] | None = None - device_classes: set[str] | None = None - - if "domain" in slots: - domains = set(slots["domain"]["value"]) - - if "device_class" in slots: - device_classes = set(slots["device_class"]["value"]) - - states = list( - intent.async_match_states( - hass, - name=name, - area=area, - domains=domains, - device_classes=device_classes, - ) - ) - - if not states: - raise intent.IntentHandleError("No entities matched") - - if "color" in slots: - service_data[ATTR_RGB_COLOR] = slots["color"]["value"] - - if "brightness" in slots: - service_data[ATTR_BRIGHTNESS_PCT] = slots["brightness"]["value"] - - response = intent_obj.create_response() - needs_brightness = ATTR_BRIGHTNESS_PCT in service_data - needs_color = ATTR_RGB_COLOR in service_data - - success_results: list[intent.IntentResponseTarget] = [] - failed_results: list[intent.IntentResponseTarget] = [] - service_coros = [] - - if area is not None: - success_results.append( - intent.IntentResponseTarget( - type=intent.IntentResponseTargetType.AREA, - name=area.name, - id=area.id, - ) - ) - - for state in states: - target = intent.IntentResponseTarget( - type=intent.IntentResponseTargetType.ENTITY, - name=state.name, - id=state.entity_id, - ) - - # Test brightness/color - supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) - if (needs_color and not color_supported(supported_color_modes)) or ( - needs_brightness and not brightness_supported(supported_color_modes) - ): - failed_results.append(target) - continue - - service_coros.append( - hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON, - {**service_data, ATTR_ENTITY_ID: state.entity_id}, - context=intent_obj.context, - ) - ) - success_results.append(target) - - # Handle service calls in parallel. - await asyncio.gather(*service_coros) - - response.async_set_results( - success_results=success_results, failed_results=failed_results - ) - - return response + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_SET, + DOMAIN, + SERVICE_TURN_ON, + optional_slots={ + ("color", ATTR_RGB_COLOR): color_util.color_name_to_rgb, + ("temperature", ATTR_COLOR_TEMP_KELVIN): cv.positive_int, + ("brightness", ATTR_BRIGHTNESS_PCT): vol.All( + vol.Coerce(int), vol.Range(0, 100) + ), + }, + description="Sets the brightness or color of a light", + platforms={DOMAIN}, + ), + ) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index fb7a1539944..6183d2a49df 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -1,11 +1,191 @@ # Describes the format for available light services +.brightness_support: &brightness_support + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW + +.color_support: &color_support + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW + +.color_temp_support: &color_temp_support + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW + +.named_colors: &named_colors + - "homeassistant" + - "aliceblue" + - "antiquewhite" + - "aqua" + - "aquamarine" + - "azure" + - "beige" + - "bisque" + # Black is omitted from this list as nonsensical for lights + - "blanchedalmond" + - "blue" + - "blueviolet" + - "brown" + - "burlywood" + - "cadetblue" + - "chartreuse" + - "chocolate" + - "coral" + - "cornflowerblue" + - "cornsilk" + - "crimson" + - "cyan" + - "darkblue" + - "darkcyan" + - "darkgoldenrod" + - "darkgray" + - "darkgreen" + - "darkgrey" + - "darkkhaki" + - "darkmagenta" + - "darkolivegreen" + - "darkorange" + - "darkorchid" + - "darkred" + - "darksalmon" + - "darkseagreen" + - "darkslateblue" + - "darkslategray" + - "darkslategrey" + - "darkturquoise" + - "darkviolet" + - "deeppink" + - "deepskyblue" + - "dimgray" + - "dimgrey" + - "dodgerblue" + - "firebrick" + - "floralwhite" + - "forestgreen" + - "fuchsia" + - "gainsboro" + - "ghostwhite" + - "gold" + - "goldenrod" + - "gray" + - "green" + - "greenyellow" + - "grey" + - "honeydew" + - "hotpink" + - "indianred" + - "indigo" + - "ivory" + - "khaki" + - "lavender" + - "lavenderblush" + - "lawngreen" + - "lemonchiffon" + - "lightblue" + - "lightcoral" + - "lightcyan" + - "lightgoldenrodyellow" + - "lightgray" + - "lightgreen" + - "lightgrey" + - "lightpink" + - "lightsalmon" + - "lightseagreen" + - "lightskyblue" + - "lightslategray" + - "lightslategrey" + - "lightsteelblue" + - "lightyellow" + - "lime" + - "limegreen" + - "linen" + - "magenta" + - "maroon" + - "mediumaquamarine" + - "mediumblue" + - "mediumorchid" + - "mediumpurple" + - "mediumseagreen" + - "mediumslateblue" + - "mediumspringgreen" + - "mediumturquoise" + - "mediumvioletred" + - "midnightblue" + - "mintcream" + - "mistyrose" + - "moccasin" + - "navajowhite" + - "navy" + - "navyblue" + - "oldlace" + - "olive" + - "olivedrab" + - "orange" + - "orangered" + - "orchid" + - "palegoldenrod" + - "palegreen" + - "paleturquoise" + - "palevioletred" + - "papayawhip" + - "peachpuff" + - "peru" + - "pink" + - "plum" + - "powderblue" + - "purple" + - "red" + - "rosybrown" + - "royalblue" + - "saddlebrown" + - "salmon" + - "sandybrown" + - "seagreen" + - "seashell" + - "sienna" + - "silver" + - "skyblue" + - "slateblue" + - "slategray" + - "slategrey" + - "snow" + - "springgreen" + - "steelblue" + - "tan" + - "teal" + - "thistle" + - "tomato" + - "turquoise" + - "violet" + - "wheat" + - "white" + - "whitesmoke" + - "yellow" + - "yellowgreen" turn_on: target: entity: domain: light fields: - transition: + transition: &transition filter: supported_features: - light.LightEntityFeature.TRANSITION @@ -14,328 +194,86 @@ turn_on: min: 0 max: 300 unit_of_measurement: seconds - rgb_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + rgb_color: &rgb_color + filter: *color_support + example: "[255, 100, 100]" selector: color_rgb: - rgbw_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + rgbw_color: &rgbw_color + filter: *color_support advanced: true example: "[255, 100, 100, 50]" selector: object: - rgbww_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + rgbww_color: &rgbww_color + filter: *color_support advanced: true example: "[255, 100, 100, 50, 70]" selector: object: - color_name: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + color_name: &color_name + filter: *color_support advanced: true selector: select: translation_key: color_name - options: - - "homeassistant" - - "aliceblue" - - "antiquewhite" - - "aqua" - - "aquamarine" - - "azure" - - "beige" - - "bisque" - # Black is omitted from this list as nonsensical for lights - - "blanchedalmond" - - "blue" - - "blueviolet" - - "brown" - - "burlywood" - - "cadetblue" - - "chartreuse" - - "chocolate" - - "coral" - - "cornflowerblue" - - "cornsilk" - - "crimson" - - "cyan" - - "darkblue" - - "darkcyan" - - "darkgoldenrod" - - "darkgray" - - "darkgreen" - - "darkgrey" - - "darkkhaki" - - "darkmagenta" - - "darkolivegreen" - - "darkorange" - - "darkorchid" - - "darkred" - - "darksalmon" - - "darkseagreen" - - "darkslateblue" - - "darkslategray" - - "darkslategrey" - - "darkturquoise" - - "darkviolet" - - "deeppink" - - "deepskyblue" - - "dimgray" - - "dimgrey" - - "dodgerblue" - - "firebrick" - - "floralwhite" - - "forestgreen" - - "fuchsia" - - "gainsboro" - - "ghostwhite" - - "gold" - - "goldenrod" - - "gray" - - "green" - - "greenyellow" - - "grey" - - "honeydew" - - "hotpink" - - "indianred" - - "indigo" - - "ivory" - - "khaki" - - "lavender" - - "lavenderblush" - - "lawngreen" - - "lemonchiffon" - - "lightblue" - - "lightcoral" - - "lightcyan" - - "lightgoldenrodyellow" - - "lightgray" - - "lightgreen" - - "lightgrey" - - "lightpink" - - "lightsalmon" - - "lightseagreen" - - "lightskyblue" - - "lightslategray" - - "lightslategrey" - - "lightsteelblue" - - "lightyellow" - - "lime" - - "limegreen" - - "linen" - - "magenta" - - "maroon" - - "mediumaquamarine" - - "mediumblue" - - "mediumorchid" - - "mediumpurple" - - "mediumseagreen" - - "mediumslateblue" - - "mediumspringgreen" - - "mediumturquoise" - - "mediumvioletred" - - "midnightblue" - - "mintcream" - - "mistyrose" - - "moccasin" - - "navajowhite" - - "navy" - - "navyblue" - - "oldlace" - - "olive" - - "olivedrab" - - "orange" - - "orangered" - - "orchid" - - "palegoldenrod" - - "palegreen" - - "paleturquoise" - - "palevioletred" - - "papayawhip" - - "peachpuff" - - "peru" - - "pink" - - "plum" - - "powderblue" - - "purple" - - "red" - - "rosybrown" - - "royalblue" - - "saddlebrown" - - "salmon" - - "sandybrown" - - "seagreen" - - "seashell" - - "sienna" - - "silver" - - "skyblue" - - "slateblue" - - "slategray" - - "slategrey" - - "snow" - - "springgreen" - - "steelblue" - - "tan" - - "teal" - - "thistle" - - "tomato" - - "turquoise" - - "violet" - - "wheat" - - "white" - - "whitesmoke" - - "yellow" - - "yellowgreen" - hs_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + options: *named_colors + hs_color: &hs_color + filter: *color_support advanced: true example: "[300, 70]" selector: object: - xy_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + xy_color: &xy_color + filter: *color_support advanced: true example: "[0.52, 0.43]" selector: object: - color_temp: - filter: - attribute: - supported_color_modes: - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + color_temp: &color_temp + filter: *color_temp_support + advanced: true selector: color_temp: unit: "mired" min: 153 max: 500 - kelvin: - filter: - attribute: - supported_color_modes: - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true + kelvin: &kelvin + filter: *color_temp_support selector: color_temp: unit: "kelvin" min: 2000 max: 6500 - brightness: - filter: - attribute: - supported_color_modes: - - light.ColorMode.BRIGHTNESS - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + brightness: &brightness + filter: *brightness_support advanced: true selector: number: min: 0 max: 255 - brightness_pct: - filter: - attribute: - supported_color_modes: - - light.ColorMode.BRIGHTNESS - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + brightness_pct: &brightness_pct + filter: *brightness_support selector: number: min: 0 max: 100 unit_of_measurement: "%" brightness_step: - filter: - attribute: - supported_color_modes: - - light.ColorMode.BRIGHTNESS - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + filter: *brightness_support advanced: true selector: number: min: -225 max: 255 brightness_step_pct: - filter: - attribute: - supported_color_modes: - - light.ColorMode.BRIGHTNESS - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW + filter: *brightness_support selector: number: min: -100 max: 100 unit_of_measurement: "%" - white: + white: &white filter: attribute: supported_color_modes: @@ -345,12 +283,12 @@ turn_on: constant: value: true label: Enabled - profile: + profile: &profile advanced: true example: relax selector: text: - flash: + flash: &flash filter: supported_features: - light.LightEntityFeature.FLASH @@ -362,7 +300,7 @@ turn_on: value: "long" - label: "Short" value: "short" - effect: + effect: &effect filter: supported_features: - light.LightEntityFeature.EFFECT @@ -374,335 +312,26 @@ turn_off: entity: domain: light fields: - transition: - filter: - supported_features: - - light.LightEntityFeature.TRANSITION - selector: - number: - min: 0 - max: 300 - unit_of_measurement: seconds - flash: - filter: - supported_features: - - light.LightEntityFeature.FLASH - advanced: true - selector: - select: - options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" + transition: *transition + flash: *flash toggle: target: entity: domain: light fields: - transition: - filter: - supported_features: - - light.LightEntityFeature.TRANSITION - selector: - number: - min: 0 - max: 300 - unit_of_measurement: seconds - rgb_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true - example: "[255, 100, 100]" - selector: - color_rgb: - color_name: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true - selector: - select: - translation_key: color_name - options: - - "homeassistant" - - "aliceblue" - - "antiquewhite" - - "aqua" - - "aquamarine" - - "azure" - - "beige" - - "bisque" - # Black is omitted from this list as nonsensical for lights - - "blanchedalmond" - - "blue" - - "blueviolet" - - "brown" - - "burlywood" - - "cadetblue" - - "chartreuse" - - "chocolate" - - "coral" - - "cornflowerblue" - - "cornsilk" - - "crimson" - - "cyan" - - "darkblue" - - "darkcyan" - - "darkgoldenrod" - - "darkgray" - - "darkgreen" - - "darkgrey" - - "darkkhaki" - - "darkmagenta" - - "darkolivegreen" - - "darkorange" - - "darkorchid" - - "darkred" - - "darksalmon" - - "darkseagreen" - - "darkslateblue" - - "darkslategray" - - "darkslategrey" - - "darkturquoise" - - "darkviolet" - - "deeppink" - - "deepskyblue" - - "dimgray" - - "dimgrey" - - "dodgerblue" - - "firebrick" - - "floralwhite" - - "forestgreen" - - "fuchsia" - - "gainsboro" - - "ghostwhite" - - "gold" - - "goldenrod" - - "gray" - - "green" - - "greenyellow" - - "grey" - - "honeydew" - - "hotpink" - - "indianred" - - "indigo" - - "ivory" - - "khaki" - - "lavender" - - "lavenderblush" - - "lawngreen" - - "lemonchiffon" - - "lightblue" - - "lightcoral" - - "lightcyan" - - "lightgoldenrodyellow" - - "lightgray" - - "lightgreen" - - "lightgrey" - - "lightpink" - - "lightsalmon" - - "lightseagreen" - - "lightskyblue" - - "lightslategray" - - "lightslategrey" - - "lightsteelblue" - - "lightyellow" - - "lime" - - "limegreen" - - "linen" - - "magenta" - - "maroon" - - "mediumaquamarine" - - "mediumblue" - - "mediumorchid" - - "mediumpurple" - - "mediumseagreen" - - "mediumslateblue" - - "mediumspringgreen" - - "mediumturquoise" - - "mediumvioletred" - - "midnightblue" - - "mintcream" - - "mistyrose" - - "moccasin" - - "navajowhite" - - "navy" - - "navyblue" - - "oldlace" - - "olive" - - "olivedrab" - - "orange" - - "orangered" - - "orchid" - - "palegoldenrod" - - "palegreen" - - "paleturquoise" - - "palevioletred" - - "papayawhip" - - "peachpuff" - - "peru" - - "pink" - - "plum" - - "powderblue" - - "purple" - - "red" - - "rosybrown" - - "royalblue" - - "saddlebrown" - - "salmon" - - "sandybrown" - - "seagreen" - - "seashell" - - "sienna" - - "silver" - - "skyblue" - - "slateblue" - - "slategray" - - "slategrey" - - "snow" - - "springgreen" - - "steelblue" - - "tan" - - "teal" - - "thistle" - - "tomato" - - "turquoise" - - "violet" - - "wheat" - - "white" - - "whitesmoke" - - "yellow" - - "yellowgreen" - hs_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true - example: "[300, 70]" - selector: - object: - xy_color: - filter: - attribute: - supported_color_modes: - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true - example: "[0.52, 0.43]" - selector: - object: - color_temp: - filter: - attribute: - supported_color_modes: - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true - selector: - color_temp: - kelvin: - filter: - attribute: - supported_color_modes: - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true - selector: - color_temp: - unit: "kelvin" - min: 2000 - max: 6500 - brightness: - filter: - attribute: - supported_color_modes: - - light.ColorMode.BRIGHTNESS - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - advanced: true - selector: - number: - min: 0 - max: 255 - brightness_pct: - filter: - attribute: - supported_color_modes: - - light.ColorMode.BRIGHTNESS - - light.ColorMode.COLOR_TEMP - - light.ColorMode.HS - - light.ColorMode.XY - - light.ColorMode.RGB - - light.ColorMode.RGBW - - light.ColorMode.RGBWW - selector: - number: - min: 0 - max: 100 - unit_of_measurement: "%" - white: - filter: - attribute: - supported_color_modes: - - light.ColorMode.WHITE - advanced: true - selector: - constant: - value: true - label: Enabled - profile: - advanced: true - example: relax - selector: - text: - flash: - filter: - supported_features: - - light.LightEntityFeature.FLASH - advanced: true - selector: - select: - options: - - label: "Long" - value: "long" - - label: "Short" - value: "short" - effect: - filter: - supported_features: - - light.LightEntityFeature.EFFECT - selector: - text: + transition: *transition + rgb_color: *rgb_color + rgbw_color: *rgbw_color + rgbww_color: *rgbww_color + color_name: *color_name + hs_color: *hs_color + xy_color: *xy_color + color_temp: *color_temp + kelvin: *kelvin + brightness: *brightness + brightness_pct: *brightness_pct + white: *white + profile: *profile + flash: *flash + effect: *effect diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 8be954f4653..76156404991 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,5 +1,41 @@ { "title": "Light", + "common": { + "field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.", + "field_brightness_name": "Brightness value", + "field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.", + "field_brightness_pct_name": "Brightness", + "field_brightness_step_description": "Change brightness by an amount.", + "field_brightness_step_name": "Brightness step value", + "field_brightness_step_pct_description": "Change brightness by a percentage.", + "field_brightness_step_pct_name": "Brightness step", + "field_color_name_description": "A human-readable color name.", + "field_color_name_name": "Color name", + "field_color_temp_description": "Color temperature in mireds.", + "field_color_temp_name": "Color temperature", + "field_effect_description": "Light effect.", + "field_effect_name": "Effect", + "field_flash_description": "Tell light to flash, can be either value short or long.", + "field_flash_name": "Flash", + "field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.", + "field_hs_color_name": "Hue/Sat color", + "field_kelvin_description": "Color temperature in Kelvin.", + "field_kelvin_name": "Color temperature", + "field_profile_description": "Name of a light profile to use.", + "field_profile_name": "Profile", + "field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.", + "field_rgb_color_name": "Color", + "field_rgbw_color_description": "The color in RGBW format. A list of four integers between 0 and 255 representing the values of red, green, blue, and white.", + "field_rgbw_color_name": "RGBW-color", + "field_rgbww_color_description": "The color in RGBWW format. A list of five integers between 0 and 255 representing the values of red, green, blue, cold white, and warm white.", + "field_rgbww_color_name": "RGBWW-color", + "field_transition_description": "Duration it takes to get to next state.", + "field_transition_name": "Transition", + "field_white_description": "Set the light to white mode.", + "field_white_name": "White", + "field_xy_color_description": "Color in XY-format. A list of two decimal numbers between 0 and 1.", + "field_xy_color_name": "XY-color" + }, "device_automation": { "action_type": { "brightness_decrease": "Decrease {entity_name} brightness", @@ -17,6 +53,10 @@ "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" + }, + "extra_fields": { + "brightness_pct": "Brightness", + "flash": "Flash" } }, "entity_component": { @@ -247,72 +287,72 @@ "description": "Turn on one or more lights and adjust properties of the light, even when they are turned on already.", "fields": { "transition": { - "name": "Transition", - "description": "Duration it takes to get to next state." + "name": "[%key:component::light::common::field_transition_name%]", + "description": "[%key:component::light::common::field_transition_description%]" }, "rgb_color": { - "name": "Color", - "description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue." + "name": "[%key:component::light::common::field_rgb_color_name%]", + "description": "[%key:component::light::common::field_rgb_color_description%]" }, "rgbw_color": { - "name": "RGBW-color", - "description": "The color in RGBW format. A list of four integers between 0 and 255 representing the values of red, green, blue, and white." + "name": "[%key:component::light::common::field_rgbw_color_name%]", + "description": "[%key:component::light::common::field_rgbw_color_description%]" }, "rgbww_color": { - "name": "RGBWW-color", - "description": "The color in RGBWW format. A list of five integers between 0 and 255 representing the values of red, green, blue, cold white, and warm white." + "name": "[%key:component::light::common::field_rgbww_color_name%]", + "description": "[%key:component::light::common::field_rgbww_color_description%]" }, "color_name": { - "name": "Color name", - "description": "A human-readable color name." + "name": "[%key:component::light::common::field_color_name_name%]", + "description": "[%key:component::light::common::field_color_name_description%]" }, "hs_color": { - "name": "Hue/Sat color", - "description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100." + "name": "[%key:component::light::common::field_hs_color_name%]", + "description": "[%key:component::light::common::field_hs_color_description%]" }, "xy_color": { - "name": "XY-color", - "description": "Color in XY-format. A list of two decimal numbers between 0 and 1." + "name": "[%key:component::light::common::field_xy_color_name%]", + "description": "[%key:component::light::common::field_xy_color_description%]" }, "color_temp": { - "name": "Color temperature", - "description": "Color temperature in mireds." + "name": "[%key:component::light::common::field_color_temp_name%]", + "description": "[%key:component::light::common::field_color_temp_description%]" }, "kelvin": { - "name": "Color temperature", - "description": "Color temperature in Kelvin." + "name": "[%key:component::light::common::field_kelvin_name%]", + "description": "[%key:component::light::common::field_kelvin_description%]" }, "brightness": { - "name": "Brightness value", - "description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness." + "name": "[%key:component::light::common::field_brightness_name%]", + "description": "[%key:component::light::common::field_brightness_description%]" }, "brightness_pct": { - "name": "Brightness", - "description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness." + "name": "[%key:component::light::common::field_brightness_pct_name%]", + "description": "[%key:component::light::common::field_brightness_pct_description%]" }, "brightness_step": { - "name": "Brightness step value", - "description": "Change brightness by an amount." + "name": "[%key:component::light::common::field_brightness_step_name%]", + "description": "[%key:component::light::common::field_brightness_step_description%]" }, "brightness_step_pct": { - "name": "Brightness step", - "description": "Change brightness by a percentage." + "name": "[%key:component::light::common::field_brightness_step_pct_name%]", + "description": "[%key:component::light::common::field_brightness_step_pct_description%]" }, "white": { - "name": "White", - "description": "Set the light to white mode." + "name": "[%key:component::light::common::field_white_name%]", + "description": "[%key:component::light::common::field_white_description%]" }, "profile": { - "name": "Profile", - "description": "Name of a light profile to use." + "name": "[%key:component::light::common::field_profile_name%]", + "description": "[%key:component::light::common::field_profile_description%]" }, "flash": { - "name": "Flash", - "description": "Tell light to flash, can be either value short or long." + "name": "[%key:component::light::common::field_flash_name%]", + "description": "[%key:component::light::common::field_flash_description%]" }, "effect": { - "name": "Effect", - "description": "Light effect." + "name": "[%key:component::light::common::field_effect_name%]", + "description": "[%key:component::light::common::field_effect_description%]" } } }, @@ -321,12 +361,12 @@ "description": "Turn off one or more lights.", "fields": { "transition": { - "name": "[%key:component::light::services::turn_on::fields::transition::name%]", - "description": "[%key:component::light::services::turn_on::fields::transition::description%]" + "name": "[%key:component::light::common::field_transition_name%]", + "description": "[%key:component::light::common::field_transition_description%]" }, "flash": { - "name": "[%key:component::light::services::turn_on::fields::flash::name%]", - "description": "[%key:component::light::services::turn_on::fields::flash::description%]" + "name": "[%key:component::light::common::field_flash_name%]", + "description": "[%key:component::light::common::field_flash_description%]" } } }, @@ -335,56 +375,64 @@ "description": "Toggles one or more lights, from on to off, or, off to on, based on their current state.", "fields": { "transition": { - "name": "[%key:component::light::services::turn_on::fields::transition::name%]", - "description": "[%key:component::light::services::turn_on::fields::transition::description%]" + "name": "[%key:component::light::common::field_transition_name%]", + "description": "[%key:component::light::common::field_transition_description%]" }, "rgb_color": { - "name": "[%key:component::light::services::turn_on::fields::rgb_color::name%]", - "description": "[%key:component::light::services::turn_on::fields::rgb_color::description%]" + "name": "[%key:component::light::common::field_rgb_color_name%]", + "description": "[%key:component::light::common::field_rgb_color_description%]" + }, + "rgbw_color": { + "name": "[%key:component::light::common::field_rgbw_color_name%]", + "description": "[%key:component::light::common::field_rgbw_color_description%]" + }, + "rgbww_color": { + "name": "[%key:component::light::common::field_rgbww_color_name%]", + "description": "[%key:component::light::common::field_rgbww_color_description%]" }, "color_name": { - "name": "[%key:component::light::services::turn_on::fields::color_name::name%]", - "description": "[%key:component::light::services::turn_on::fields::color_name::description%]" + "name": "[%key:component::light::common::field_color_name_name%]", + "description": "[%key:component::light::common::field_color_name_description%]" }, "hs_color": { - "name": "[%key:component::light::services::turn_on::fields::hs_color::name%]", - "description": "[%key:component::light::services::turn_on::fields::hs_color::description%]" + "name": "[%key:component::light::common::field_hs_color_name%]", + "description": "[%key:component::light::common::field_hs_color_description%]" }, "xy_color": { - "name": "[%key:component::light::services::turn_on::fields::xy_color::name%]", - "description": "[%key:component::light::services::turn_on::fields::xy_color::description%]" + "name": "[%key:component::light::common::field_xy_color_name%]", + "description": "[%key:component::light::common::field_xy_color_description%]" }, "color_temp": { - "name": "[%key:component::light::services::turn_on::fields::color_temp::name%]", - "description": "[%key:component::light::services::turn_on::fields::color_temp::description%]" + "name": "[%key:component::light::common::field_color_temp_name%]", + "description": "[%key:component::light::common::field_color_temp_description%]" }, "kelvin": { - "name": "[%key:component::light::services::turn_on::fields::kelvin::name%]", - "description": "[%key:component::light::services::turn_on::fields::kelvin::description%]" + "name": "[%key:component::light::common::field_kelvin_name%]", + "description": "[%key:component::light::common::field_kelvin_description%]" }, "brightness": { - "name": "[%key:component::light::services::turn_on::fields::brightness::name%]", - "description": "[%key:component::light::services::turn_on::fields::brightness::description%]" + "name": "[%key:component::light::common::field_brightness_name%]", + "description": "[%key:component::light::common::field_brightness_description%]" }, "brightness_pct": { - "name": "[%key:component::light::services::turn_on::fields::brightness_pct::name%]", - "description": "[%key:component::light::services::turn_on::fields::brightness_pct::description%]" + "name": "[%key:component::light::common::field_brightness_pct_name%]", + "description": "[%key:component::light::common::field_brightness_pct_description%]" }, "white": { - "name": "[%key:component::light::services::turn_on::fields::white::name%]", - "description": "[%key:component::light::services::turn_on::fields::white::description%]" + "name": "[%key:component::light::common::field_white_name%]", + "description": "[%key:component::light::common::field_white_description%]" }, "profile": { - "name": "[%key:component::light::services::turn_on::fields::profile::name%]", - "description": "[%key:component::light::services::turn_on::fields::profile::description%]" + "name": "[%key:component::light::common::field_profile_name%]", + "description": "[%key:component::light::common::field_profile_description%]" }, "flash": { - "name": "[%key:component::light::services::turn_on::fields::flash::name%]", - "description": "[%key:component::light::services::turn_on::fields::flash::description%]" + "name": "[%key:component::light::common::field_flash_name%]", + "description": "[%key:component::light::common::field_flash_description%]" }, "effect": { - "name": "[%key:component::light::services::turn_on::fields::effect::name%]", - "description": "[%key:component::light::services::turn_on::fields::effect::description%]" + "name": "[%key:component::light::common::field_effect_name%]", + "description": "[%key:component::light::common::field_effect_description%]" } } } diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 0b666b59faa..182c12eb395 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, cast from limitlessled import Color from limitlessled.bridge import Bridge @@ -40,9 +40,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.color import color_hs_to_RGB, color_temperature_mired_to_kelvin -_LimitlessLEDGroupT = TypeVar("_LimitlessLEDGroupT", bound="LimitlessLEDGroup") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) CONF_BRIDGES = "bridges" @@ -176,7 +173,7 @@ def setup_platform( add_entities(lights) -def state( +def state[_LimitlessLEDGroupT: LimitlessLEDGroup, **_P]( new_state: bool, ) -> Callable[ [Callable[Concatenate[_LimitlessLEDGroupT, int, Pipeline, _P], Any]], @@ -200,14 +197,14 @@ def state( transition_time = DEFAULT_TRANSITION if self.effect == EFFECT_COLORLOOP: self.group.stop() - self._attr_effect = None # pylint: disable=protected-access + self._attr_effect = None # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(cast(float, kwargs[ATTR_TRANSITION])) # Do group type-specific work. function(self, transition_time, pipeline, *args, **kwargs) # Update state. - self._attr_is_on = new_state # pylint: disable=protected-access + self._attr_is_on = new_state self.group.enqueue(pipeline) self.schedule_update_ha_state() diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py index 16e743e00b5..5d987a24b2a 100644 --- a/homeassistant/components/linear_garage_door/__init__.py +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import LinearUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.COVER] +PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/linear_garage_door/config_flow.py b/homeassistant/components/linear_garage_door/config_flow.py index 31629f8e3b0..dca2780cfea 100644 --- a/homeassistant/components/linear_garage_door/config_flow.py +++ b/homeassistant/components/linear_garage_door/config_flow.py @@ -88,7 +88,7 @@ class LinearGarageDoorConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py index 91ff0165163..35ccced3274 100644 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, TypeVar +from typing import Any from linear_garage_door import Linear from linear_garage_door.errors import InvalidLoginError @@ -19,8 +19,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -_T = TypeVar("_T") - @dataclass class LinearDevice: @@ -63,7 +61,7 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): return await self.execute(update_data) - async def execute(self, func: Callable[[Linear], Awaitable[_T]]) -> _T: + async def execute[_T](self, func: Callable[[Linear], Awaitable[_T]]) -> _T: """Execute an API call.""" linear = Linear() try: diff --git a/homeassistant/components/linear_garage_door/light.py b/homeassistant/components/linear_garage_door/light.py new file mode 100644 index 00000000000..3679491712f --- /dev/null +++ b/homeassistant/components/linear_garage_door/light.py @@ -0,0 +1,80 @@ +"""Linear garage door light.""" + +from typing import Any + +from linear_garage_door import Linear + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +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 LinearUpdateCoordinator +from .entity import LinearEntity + +SUPPORTED_SUBDEVICES = ["Light"] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Linear Garage Door cover.""" + coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + data = coordinator.data + + async_add_entities( + LinearLightEntity( + device_id=device_id, + device_name=data[device_id].name, + sub_device_id=subdev, + coordinator=coordinator, + ) + for device_id in data + for subdev in data[device_id].subdevices + if subdev in SUPPORTED_SUBDEVICES + ) + + +class LinearLightEntity(LinearEntity, LightEntity): + """Light for Linear devices.""" + + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_translation_key = "light" + + @property + def is_on(self) -> bool: + """Return if the light is on or not.""" + return bool(self.sub_device["On_B"] == "true") + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + return round(int(self.sub_device["On_P"]) / 100 * 255) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + + async def _turn_on(linear: Linear) -> None: + """Turn on the light.""" + if not kwargs: + await linear.operate_device(self._device_id, self._sub_device_id, "On") + elif ATTR_BRIGHTNESS in kwargs: + brightness = round((kwargs[ATTR_BRIGHTNESS] / 255) * 100) + await linear.operate_device( + self._device_id, self._sub_device_id, f"DimPercent:{brightness}" + ) + + await self.coordinator.execute(_turn_on) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + + await self.coordinator.execute( + lambda linear: linear.operate_device( + self._device_id, self._sub_device_id, "Off" + ) + ) diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json index 93dd17c5bce..23624b4acfd 100644 --- a/homeassistant/components/linear_garage_door/strings.json +++ b/homeassistant/components/linear_garage_door/strings.json @@ -16,5 +16,12 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + } } } diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index ec9849bbb89..3c55c4c4035 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -12,6 +12,8 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .hub import LitterRobotHub +type LitterRobotConfigEntry = ConfigEntry[LitterRobotHub] + PLATFORMS_BY_TYPE = { Robot: ( Platform.BINARY_SENSOR, @@ -37,40 +39,35 @@ def get_platforms_for_robots(robots: list[Robot]) -> set[Platform]: } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool: """Set up Litter-Robot from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hub = hass.data[DOMAIN][entry.entry_id] = LitterRobotHub(hass, entry.data) + hub = LitterRobotHub(hass, entry.data) await hub.login(load_robots=True, subscribe_for_updates=True) + entry.runtime_data = hub if platforms := get_platforms_for_robots(hub.account.robots): 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, entry: LitterRobotConfigEntry +) -> bool: """Unload a config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] - await hub.account.disconnect() + await entry.runtime_data.account.disconnect() - platforms = get_platforms_for_robots(hub.account.robots) - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + platforms = get_platforms_for_robots(entry.runtime_data.account.robots) + return await hass.config_entries.async_unload_platforms(entry, platforms) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, entry: LitterRobotConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" - hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] return not any( identifier for identifier in device_entry.identifiers if identifier[0] == DOMAIN - for robot in hub.account.robots + for robot in entry.runtime_data.account.robots if robot.serial == identifier[1] ) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 2f44f44ed53..91113d6c094 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -13,14 +13,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -from .hub import LitterRobotHub @dataclass(frozen=True) @@ -80,11 +78,11 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot binary sensors using config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data async_add_entities( LitterRobotBinarySensorEntity(robot=robot, hub=hub, description=description) for robot in hub.account.robots diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 02477e7fa03..6e6cc563c8e 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -10,23 +10,21 @@ from typing import Any, Generic from pylitterbot import FeederRobot, LitterRobot3 from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -from .hub import LitterRobotHub async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data entities: list[LitterRobotButtonEntity] = list( itertools.chain( ( diff --git a/homeassistant/components/litterrobot/config_flow.py b/homeassistant/components/litterrobot/config_flow.py index aada2f6c9cb..633c6a5a5a2 100644 --- a/homeassistant/components/litterrobot/config_flow.py +++ b/homeassistant/components/litterrobot/config_flow.py @@ -94,7 +94,7 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN): return "invalid_auth" except LitterRobotException: return "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return "unknown" return "" diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index e7ecbada10d..948fad45a76 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -10,12 +10,11 @@ from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot from pylitterbot.robot.litterrobot4 import BrightnessLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub @@ -82,11 +81,11 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot selects using config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] + hub = entry.runtime_data entities = [ LitterRobotSelectEntity(robot=robot, hub=hub, description=description) for robot in hub.account.robots diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 1b4b7f78fdc..c110b89c7da 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -15,14 +15,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -from .hub import LitterRobotHub def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str: @@ -157,11 +155,11 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot sensors using config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data entities = [ LitterRobotSensorEntity(robot=robot, hub=hub, description=description) for robot in hub.account.robots diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 60ca9b4d6c7..133fd897cc6 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -9,14 +9,12 @@ from typing import Any, Generic from pylitterbot import FeederRobot, LitterRobot from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -from .hub import LitterRobotHub @dataclass(frozen=True) @@ -68,11 +66,11 @@ class RobotSwitchEntity(LitterRobotEntity[_RobotT], SwitchEntity): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot switches using config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data entities = [ RobotSwitchEntity(robot=robot, hub=hub, description=description) for description in ROBOT_SWITCHES diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 4e5e80a8ca6..ace30d9f3a9 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -10,15 +10,13 @@ from typing import Any, Generic from pylitterbot import LitterRobot3 from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN +from . import LitterRobotConfigEntry from .entity import LitterRobotEntity, _RobotT -from .hub import LitterRobotHub @dataclass(frozen=True) @@ -45,18 +43,18 @@ LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3]( entity_category=EntityCategory.CONFIG, value_fn=lambda robot: _as_local_time(robot.sleep_mode_start_time), set_fn=lambda robot, value: robot.set_sleep_mode( - robot.sleep_mode_enabled, value.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + robot.sleep_mode_enabled, value.replace(tzinfo=dt_util.get_default_time_zone()) ), ) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data async_add_entities( [ LitterRobotTimeEntity( diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index c4d1ada6080..1d3e1dff57c 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -13,13 +13,12 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .entity import LitterRobotEntity, LitterRobotHub +from . import LitterRobotConfigEntry +from .entity import LitterRobotEntity SCAN_INTERVAL = timedelta(days=1) @@ -31,11 +30,11 @@ FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot update platform.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data entities = [ RobotUpdateEntity(robot=robot, hub=hub, description=FIRMWARE_UPDATE_ENTITY) for robot in hub.litter_robots() diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index d752609d7de..a1ed2ea600d 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -18,16 +18,14 @@ from homeassistant.components.vacuum import ( StateVacuumEntityDescription, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN +from . import LitterRobotConfigEntry from .entity import LitterRobotEntity -from .hub import LitterRobotHub SERVICE_SET_SLEEP_MODE = "set_sleep_mode" @@ -51,11 +49,11 @@ LITTER_BOX_ENTITY = StateVacuumEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LitterRobotConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" - hub: LitterRobotHub = hass.data[DOMAIN][entry.entry_id] + hub = entry.runtime_data entities = [ LitterRobotCleaner(robot=robot, hub=hub, description=LITTER_BOX_ENTITY) for robot in hub.litter_robots() diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py index c3b9e5d151c..52c685e4929 100644 --- a/homeassistant/components/local_calendar/diagnostics.py +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -18,7 +18,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" payload: dict[str, Any] = { "now": dt_util.now().isoformat(), - "timezone": str(dt_util.DEFAULT_TIME_ZONE), + "timezone": str(dt_util.get_default_time_zone()), "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } store = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index b1c7d6a3a34..73619b6bfe9 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==8.0.0"] + "requirements": ["ical==8.0.1"] } diff --git a/homeassistant/components/local_todo/__init__.py b/homeassistant/components/local_todo/__init__.py index c01f5a748ec..4b8f02736bf 100644 --- a/homeassistant/components/local_todo/__init__.py +++ b/homeassistant/components/local_todo/__init__.py @@ -17,7 +17,7 @@ PLATFORMS: list[Platform] = [Platform.TODO] STORAGE_PATH = ".storage/local_todo.{key}.ics" -LocalTodoConfigEntry = ConfigEntry[LocalTodoListStore] +type LocalTodoConfigEntry = ConfigEntry[LocalTodoListStore] async def async_setup_entry(hass: HomeAssistant, entry: LocalTodoConfigEntry) -> bool: diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 44c76a56a8f..4fa8e2982f9 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==8.0.0"] + "requirements": ["ical==8.0.1"] } diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 548b4fa87fe..a5f40c26738 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -134,7 +134,7 @@ class LocalTodoListEntity(TodoListEntity): self._attr_unique_id = unique_id def _new_todo_store(self) -> TodoStore: - return TodoStore(self._calendar, tzinfo=dt_util.DEFAULT_TIME_ZONE) + return TodoStore(self._calendar, tzinfo=dt_util.get_default_time_zone()) async def async_update(self) -> None: """Update entity state based on the local To-do items.""" diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index bdd65868e62..21533353ac7 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -22,6 +22,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -43,7 +45,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType -from . import group as group_pre_import # noqa: F401 from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -121,6 +122,8 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "is_locked", "is_locking", "is_unlocking", + "is_open", + "is_opening", "is_jammed", "supported_features", } @@ -134,6 +137,8 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_code_format: str | None = None _attr_is_locked: bool | None = None _attr_is_locking: bool | None = None + _attr_is_open: bool | None = None + _attr_is_opening: bool | None = None _attr_is_unlocking: bool | None = None _attr_is_jammed: bool | None = None _attr_state: None = None @@ -202,6 +207,16 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return true if the lock is unlocking.""" return self._attr_is_unlocking + @cached_property + def is_open(self) -> bool | None: + """Return true if the lock is open.""" + return self._attr_is_open + + @cached_property + def is_opening(self) -> bool | None: + """Return true if the lock is opening.""" + return self._attr_is_opening + @cached_property def is_jammed(self) -> bool | None: """Return true if the lock is jammed (incomplete locking).""" @@ -262,8 +277,12 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the state.""" if self.is_jammed: return STATE_JAMMED + if self.is_opening: + return STATE_OPENING if self.is_locking: return STATE_LOCKING + if self.is_open: + return STATE_OPEN if self.is_unlocking: return STATE_UNLOCKING if (locked := self.is_locked) is None: diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index 327bde2c0e3..ec6373c889f 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -14,6 +14,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -31,11 +33,13 @@ from . import DOMAIN # mypy: disallow-any-generics CONDITION_TYPES = { - "is_locked", - "is_unlocked", - "is_locking", - "is_unlocking", "is_jammed", + "is_locked", + "is_locking", + "is_open", + "is_opening", + "is_unlocked", + "is_unlocking", } CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( @@ -78,8 +82,12 @@ def async_condition_from_config( """Create a function to test a device condition.""" if config[CONF_TYPE] == "is_jammed": state = STATE_JAMMED + elif config[CONF_TYPE] == "is_opening": + state = STATE_OPENING elif config[CONF_TYPE] == "is_locking": state = STATE_LOCKING + elif config[CONF_TYPE] == "is_open": + state = STATE_OPEN elif config[CONF_TYPE] == "is_unlocking": state = STATE_UNLOCKING elif config[CONF_TYPE] == "is_locked": diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 57a83c7dc7a..336fe127ca6 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -16,6 +16,8 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -26,7 +28,15 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN -TRIGGER_TYPES = {"locked", "unlocked", "locking", "unlocking", "jammed"} +TRIGGER_TYPES = { + "jammed", + "locked", + "locking", + "open", + "opening", + "unlocked", + "unlocking", +} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { @@ -84,8 +94,12 @@ async def async_attach_trigger( """Attach a trigger.""" if config[CONF_TYPE] == "jammed": to_state = STATE_JAMMED + elif config[CONF_TYPE] == "opening": + to_state = STATE_OPENING elif config[CONF_TYPE] == "locking": to_state = STATE_LOCKING + elif config[CONF_TYPE] == "open": + to_state = STATE_OPEN elif config[CONF_TYPE] == "unlocking": to_state = STATE_UNLOCKING elif config[CONF_TYPE] == "locked": diff --git a/homeassistant/components/lock/group.py b/homeassistant/components/lock/group.py deleted file mode 100644 index 20aaed2b39a..00000000000 --- a/homeassistant/components/lock/group.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states(DOMAIN, {STATE_UNLOCKED}, STATE_UNLOCKED, STATE_LOCKED) diff --git a/homeassistant/components/lock/icons.json b/homeassistant/components/lock/icons.json index 0ce2e70d372..009bd84a372 100644 --- a/homeassistant/components/lock/icons.json +++ b/homeassistant/components/lock/icons.json @@ -5,6 +5,8 @@ "state": { "jammed": "mdi:lock-alert", "locking": "mdi:lock-clock", + "open": "mdi:lock-open-variant", + "opening": "mdi:lock-clock", "unlocked": "mdi:lock-open-variant", "unlocking": "mdi:lock-clock" } diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index 36afcf5f310..5fc3345c1f6 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -10,9 +10,12 @@ from typing import Any from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, ) @@ -22,7 +25,14 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) -VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED, STATE_LOCKING, STATE_UNLOCKING} +VALID_STATES = { + STATE_LOCKED, + STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, + STATE_UNLOCKED, + STATE_UNLOCKING, +} async def _async_reproduce_state( @@ -53,6 +63,8 @@ async def _async_reproduce_state( service = SERVICE_LOCK elif state.state in {STATE_UNLOCKED, STATE_UNLOCKING}: service = SERVICE_UNLOCK + elif state.state in {STATE_OPEN, STATE_OPENING}: + service = SERVICE_OPEN await hass.services.async_call( DOMAIN, service, service_data, context=context, blocking=True diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index 152a06f9e53..fd8636acf97 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -8,11 +8,16 @@ }, "condition_type": { "is_locked": "{entity_name} is locked", - "is_unlocked": "{entity_name} is unlocked" + "is_unlocked": "{entity_name} is unlocked", + "is_open": "{entity_name} is open" }, "trigger_type": { "locked": "{entity_name} locked", - "unlocked": "{entity_name} unlocked" + "unlocked": "{entity_name} unlocked", + "open": "{entity_name} opened" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { @@ -22,6 +27,8 @@ "jammed": "Jammed", "locked": "[%key:common::state::locked%]", "locking": "Locking", + "open": "[%key:common::state::open%]", + "opening": "Opening", "unlocked": "[%key:common::state::unlocked%]", "unlocking": "Unlocking" }, diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 4fa0da9033a..674f1643793 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -58,7 +58,7 @@ def _async_config_entries_for_ids( dev_reg = dr.async_get(hass) for device_id in device_ids: if (device := dev_reg.async_get(device_id)) and device.config_entries: - config_entry_ids |= device.config_entries + config_entry_ids.update(device.config_entries) return config_entry_ids diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index df1eb6a15f2..4e245189154 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Generator, Sequence +from collections.abc import Callable, Sequence from contextlib import suppress from dataclasses import dataclass from datetime import datetime as dt @@ -11,6 +11,7 @@ from typing import Any from sqlalchemy.engine import Result from sqlalchemy.engine.row import Row +from typing_extensions import Generator from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters @@ -173,7 +174,7 @@ class EventProcessor: ) def humanify( - self, rows: Generator[EventAsRow, None, None] | Sequence[Row] | Result + self, rows: Generator[EventAsRow] | Sequence[Row] | Result ) -> list[dict[str, str]]: """Humanify rows.""" return list( @@ -189,11 +190,11 @@ class EventProcessor: def _humanify( hass: HomeAssistant, - rows: Generator[EventAsRow, None, None] | Sequence[Row] | Result, + rows: Generator[EventAsRow] | Sequence[Row] | Result, ent_reg: er.EntityRegistry, logbook_run: LogbookRun, context_augmenter: ContextAugmenter, -) -> Generator[dict[str, Any], None, None]: +) -> Generator[dict[str, Any]]: """Generate a converted list of events into entries.""" # Continuous sensors, will be excluded from the logbook continuous_sensors: dict[str, bool] = {} @@ -204,13 +205,12 @@ def _humanify( include_entity_name = logbook_run.include_entity_name format_time = logbook_run.format_time memoize_new_contexts = logbook_run.memoize_new_contexts - memoize_context = context_lookup.setdefault # Process rows for row in rows: context_id_bin: bytes = row.context_id_bin - if memoize_new_contexts: - memoize_context(context_id_bin, row) + if memoize_new_contexts and context_id_bin not in context_lookup: + context_lookup[context_id_bin] = row if row.context_only: continue event_type = row.event_type @@ -246,7 +246,7 @@ def _humanify( domain, describe_event = external_events[event_type] try: data = describe_event(event_cache.get(row)) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error with %s describe event for %s", domain, event_type ) @@ -358,7 +358,7 @@ class ContextAugmenter: event = self.event_cache.get(context_row) try: described = describe_event(event) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error with %s describe event for %s", domain, event_type) return if name := described.get(LOGBOOK_ENTRY_NAME): diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index fafc2d3eedb..6d34b10bd34 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -65,7 +65,7 @@ async def handle_integration_log_level( await async_get_integration(hass, msg["integration"]) except IntegrationNotFound: connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Integration not found" + msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found" ) return await async_get_domain_config(hass).settings.async_update( diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py index 61dfd9a2c20..ce798b8f24b 100644 --- a/homeassistant/components/lookin/config_flow.py +++ b/homeassistant/components/lookin/config_flow.py @@ -40,7 +40,7 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN): device: Device = await self._validate_device(host=host) except (aiohttp.ClientError, NoUsableService): return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -62,7 +62,7 @@ class LookinFlowHandler(ConfigFlow, domain=DOMAIN): device = await self._validate_device(host=host) except (aiohttp.ClientError, NoUsableService): errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index 925a7416731..d9834bd1d94 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -6,7 +6,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time -from typing import TypeVar from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,7 +13,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import NEVER_TIME, POLLING_FALLBACK_SECONDS _LOGGER = logging.getLogger(__name__) -_DataT = TypeVar("_DataT") class LookinPushCoordinator: @@ -42,7 +40,7 @@ class LookinPushCoordinator: return is_active -class LookinDataUpdateCoordinator(DataUpdateCoordinator[_DataT]): +class LookinDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator to gather data for a specific lookin devices.""" def __init__( diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 60d03717be0..d26e4f1d2d7 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -115,6 +115,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: reload_resources_service_handler, schema=RESOURCE_RELOAD_SERVICE_SCHEMA, ) + # Register lovelace/resources for backwards compatibility, remove in + # Home Assistant Core 2025.1 + for command in ("lovelace/resources", "lovelace/resources/list"): + websocket_api.async_register_command( + hass, + command, + websocket.websocket_lovelace_resources, + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {"type": command}, + ), + ) else: default_config = dashboard.LovelaceStorage(hass, None) @@ -127,22 +138,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: resource_collection = resources.ResourceStorageCollection(hass, default_config) - collection.DictStorageCollectionWebsocket( + resources.ResourceStorageCollectionWebsocket( resource_collection, "lovelace/resources", "resource", RESOURCE_CREATE_FIELDS, RESOURCE_UPDATE_FIELDS, - ).async_setup(hass, create_list=False) + ).async_setup(hass) websocket_api.async_register_command(hass, websocket.websocket_lovelace_config) websocket_api.async_register_command(hass, websocket.websocket_lovelace_save_config) websocket_api.async_register_command( hass, websocket.websocket_lovelace_delete_config ) - websocket_api.async_register_command(hass, websocket.websocket_lovelace_resources) - - websocket_api.async_register_command(hass, websocket.websocket_lovelace_dashboards) hass.data[DOMAIN] = { # We store a dictionary mapping url_path: config. None is the default. @@ -209,13 +217,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: dashboards_collection.async_add_listener(storage_dashboard_changed) await dashboards_collection.async_load() - collection.DictStorageCollectionWebsocket( + dashboard.DashboardsCollectionWebSocket( dashboards_collection, "lovelace/dashboards", "dashboard", STORAGE_DASHBOARD_CREATE_FIELDS, STORAGE_DASHBOARD_UPDATE_FIELDS, - ).async_setup(hass, create_list=False) + ).async_setup(hass) def create_map_dashboard(): hass.async_create_task(_create_map_dashboard(hass)) diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 17116a011a4..db6db2fa7ef 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -7,14 +7,17 @@ import logging import os from pathlib import Path import time +from typing import Any import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.components.frontend import DATA_PANELS from homeassistant.const import CONF_FILENAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage +from homeassistant.helpers.json import json_bytes, json_fragment from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( @@ -42,11 +45,13 @@ _LOGGER = logging.getLogger(__name__) class LovelaceConfig(ABC): """Base class for Lovelace config.""" - def __init__(self, hass, url_path, config): + def __init__( + self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None + ) -> None: """Initialize Lovelace config.""" self.hass = hass if config: - self.config = {**config, CONF_URL_PATH: url_path} + self.config: dict[str, Any] | None = {**config, CONF_URL_PATH: url_path} else: self.config = None @@ -65,7 +70,7 @@ class LovelaceConfig(ABC): """Return the config info.""" @abstractmethod - async def async_load(self, force): + async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" async def async_save(self, config): @@ -77,7 +82,7 @@ class LovelaceConfig(ABC): raise HomeAssistantError("Not supported") @callback - def _config_updated(self): + def _config_updated(self) -> None: """Fire config updated event.""" self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path}) @@ -85,10 +90,10 @@ class LovelaceConfig(ABC): class LovelaceStorage(LovelaceConfig): """Class to handle Storage based Lovelace config.""" - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: dict[str, Any] | None) -> None: """Initialize Lovelace config based on storage helper.""" if config is None: - url_path = None + url_path: str | None = None storage_key = CONFIG_STORAGE_KEY_DEFAULT else: url_path = config[CONF_URL_PATH] @@ -96,8 +101,11 @@ class LovelaceStorage(LovelaceConfig): super().__init__(hass, url_path, config) - self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) - self._data = None + self._store = storage.Store[dict[str, Any]]( + hass, CONFIG_STORAGE_VERSION, storage_key + ) + self._data: dict[str, Any] | None = None + self._json_config: json_fragment | None = None @property def mode(self) -> str: @@ -106,27 +114,30 @@ class LovelaceStorage(LovelaceConfig): async def async_get_info(self): """Return the Lovelace storage info.""" - if self._data is None: - await self._load() - - if self._data["config"] is None: + data = self._data or await self._load() + if data["config"] is None: return {"mode": "auto-gen"} + return _config_info(self.mode, data["config"]) - return _config_info(self.mode, self._data["config"]) - - async def async_load(self, force): + async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" if self.hass.config.recovery_mode: raise ConfigNotFound - if self._data is None: - await self._load() - - if (config := self._data["config"]) is None: + data = self._data or await self._load() + if (config := data["config"]) is None: raise ConfigNotFound return config + async def async_json(self, force: bool) -> json_fragment: + """Return JSON representation of the config.""" + if self.hass.config.recovery_mode: + raise ConfigNotFound + if self._data is None: + await self._load() + return self._json_config or self._async_build_json() + async def async_save(self, config): """Save config.""" if self.hass.config.recovery_mode: @@ -135,6 +146,7 @@ class LovelaceStorage(LovelaceConfig): if self._data is None: await self._load() self._data["config"] = config + self._json_config = None self._config_updated() await self._store.async_save(self._data) @@ -145,25 +157,37 @@ class LovelaceStorage(LovelaceConfig): await self._store.async_remove() self._data = None + self._json_config = None self._config_updated() - async def _load(self): + async def _load(self) -> dict[str, Any]: """Load the config.""" data = await self._store.async_load() self._data = data if data else {"config": None} + return self._data + + @callback + def _async_build_json(self) -> json_fragment: + """Build JSON representation of the config.""" + if self._data is None or self._data["config"] is None: + raise ConfigNotFound + self._json_config = json_fragment(json_bytes(self._data["config"])) + return self._json_config class LovelaceYAML(LovelaceConfig): """Class to handle YAML-based Lovelace config.""" - def __init__(self, hass, url_path, config): + def __init__( + self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None + ) -> None: """Initialize the YAML config.""" super().__init__(hass, url_path, config) self.path = hass.config.path( config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE ) - self._cache = None + self._cache: tuple[dict[str, Any], float, json_fragment] | None = None @property def mode(self) -> str: @@ -182,23 +206,35 @@ class LovelaceYAML(LovelaceConfig): return _config_info(self.mode, config) - async def async_load(self, force): + async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" - is_updated, config = await self.hass.async_add_executor_job( + config, json = await self._async_load_or_cached(force) + return config + + async def async_json(self, force: bool) -> json_fragment: + """Return JSON representation of the config.""" + config, json = await self._async_load_or_cached(force) + return json + + async def _async_load_or_cached( + self, force: bool + ) -> tuple[dict[str, Any], json_fragment]: + """Load the config or return a cached version.""" + is_updated, config, json = await self.hass.async_add_executor_job( self._load_config, force ) if is_updated: self._config_updated() - return config + return config, json - def _load_config(self, force): + def _load_config(self, force: bool) -> tuple[bool, dict[str, Any], json_fragment]: """Load the actual config.""" # Check for a cached version of the config if not force and self._cache is not None: - config, last_update = self._cache + config, last_update, json = self._cache modtime = os.path.getmtime(self.path) if config and last_update > modtime: - return False, config + return False, config, json is_updated = self._cache is not None @@ -209,8 +245,9 @@ class LovelaceYAML(LovelaceConfig): except FileNotFoundError: raise ConfigNotFound from None - self._cache = (config, time.time()) - return is_updated, config + json = json_fragment(json_bytes(config)) + self._cache = (config, time.time(), json) + return is_updated, config, json def _config_info(mode, config): @@ -261,3 +298,24 @@ class DashboardsCollection(collection.DictStorageCollection): updated.pop(CONF_ICON) return updated + + +class DashboardsCollectionWebSocket(collection.DictStorageCollectionWebsocket): + """Class to expose storage collection management over websocket.""" + + @callback + def ws_list_item( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Send Lovelace UI resources over WebSocket connection.""" + connection.send_result( + msg["id"], + [ + dashboard.config + for dashboard in hass.data[DOMAIN]["dashboards"].values() + if dashboard.config + ], + ) diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 2dbbbacabea..c25c81e2c6f 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -8,6 +8,7 @@ import uuid import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.const import CONF_ID, CONF_RESOURCES, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -21,6 +22,7 @@ from .const import ( RESOURCE_UPDATE_FIELDS, ) from .dashboard import LovelaceConfig +from .websocket import websocket_lovelace_resources_impl RESOURCE_STORAGE_KEY = f"{DOMAIN}_resources" RESOURCES_STORAGE_VERSION = 1 @@ -125,3 +127,38 @@ class ResourceStorageCollection(collection.DictStorageCollection): update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) return {**item, **update_data} + + +class ResourceStorageCollectionWebsocket(collection.DictStorageCollectionWebsocket): + """Class to expose storage collection management over websocket.""" + + @callback + def async_setup( + self, + hass: HomeAssistant, + *, + create_create: bool = True, + ) -> None: + """Set up the websocket commands.""" + super().async_setup(hass, create_create=create_create) + + # Register lovelace/resources for backwards compatibility, remove in + # Home Assistant Core 2025.1 + websocket_api.async_register_command( + hass, + self.api_prefix, + self.ws_list_item, + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): f"{self.api_prefix}"} + ), + ) + + @staticmethod + @websocket_api.async_response + async def ws_list_item( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Send Lovelace UI resources over WebSocket connection.""" + await websocket_lovelace_resources_impl(hass, connection, msg) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index e4eaa42073f..e402ba92f16 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -8,9 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.json import json_fragment from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound from .dashboard import LovelaceStorage @@ -51,14 +52,28 @@ def _handle_errors(func): return send_with_error_handling -@websocket_api.websocket_command({"type": "lovelace/resources"}) @websocket_api.async_response async def websocket_lovelace_resources( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Send Lovelace UI resources over WebSocket configuration.""" + """Send Lovelace UI resources over WebSocket connection. + + This function is used in YAML mode. + """ + await websocket_lovelace_resources_impl(hass, connection, msg) + + +async def websocket_lovelace_resources_impl( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Help send Lovelace UI resources over WebSocket connection. + + This function is called by both Storage and YAML mode WS handlers. + """ resources = hass.data[DOMAIN]["resources"] if hass.config.safe_mode: @@ -86,9 +101,9 @@ async def websocket_lovelace_config( connection: websocket_api.ActiveConnection, msg: dict[str, Any], config: LovelaceStorage, -) -> None: - """Send Lovelace UI config over WebSocket configuration.""" - return await config.async_load(msg["force"]) +) -> json_fragment: + """Send Lovelace UI config over WebSocket connection.""" + return await config.async_json(msg["force"]) @websocket_api.require_admin @@ -128,21 +143,3 @@ async def websocket_lovelace_delete_config( ) -> None: """Delete Lovelace UI configuration.""" await config.async_delete() - - -@websocket_api.websocket_command({"type": "lovelace/dashboards/list"}) -@callback -def websocket_lovelace_dashboards( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Delete Lovelace UI configuration.""" - connection.send_result( - msg["id"], - [ - dashboard.config - for dashboard in hass.data[DOMAIN]["dashboards"].values() - if dashboard.config - ], - ) diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 090d9ab3ced..73aba775a2a 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -48,6 +48,7 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_code_arm_required = False def __init__( self, data: lupupy.Lupusec, device: lupupy.devices.LupusecAlarm, entry_id: str diff --git a/homeassistant/components/lupusec/config_flow.py b/homeassistant/components/lupusec/config_flow.py index 3af823e4fa1..82162bccf80 100644 --- a/homeassistant/components/lupusec/config_flow.py +++ b/homeassistant/components/lupusec/config_flow.py @@ -52,7 +52,7 @@ class LupusecConfigFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except JSONDecodeError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -84,7 +84,7 @@ class LupusecConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") except JSONDecodeError: return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 828182547c2..1521a05df8e 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -4,17 +4,11 @@ from dataclasses import dataclass import logging from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output -import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -35,69 +29,6 @@ ATTR_ACTION = "action" ATTR_FULL_ID = "full_id" ATTR_UUID = "uuid" -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: - """Import a config entry from configuration.yaml.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=base_config[DOMAIN], - ) - if ( - result["type"] == FlowResultType.CREATE_ENTRY - or result["reason"] == "single_instance_allowed" - ): - 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": "Lutron", - }, - ) - return - 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={ - "domain": DOMAIN, - "integration_title": "Lutron", - }, - ) - - -async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: - """Set up the Lutron component.""" - if DOMAIN in base_config: - hass.async_create_task(_async_import(hass, base_config)) - return True - @dataclass(slots=True, kw_only=True) class LutronData: diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index 8fd11484a72..e14d56fde57 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -47,7 +47,7 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN): except HTTPError: _LOGGER.exception("Http error") errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: @@ -73,37 +73,3 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - async def async_step_import( - self, import_config: dict[str, Any] - ) -> ConfigFlowResult: - """Attempt to import the existing configuration.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - main_repeater = Lutron( - import_config[CONF_HOST], - import_config[CONF_USERNAME], - import_config[CONF_PASSWORD], - ) - - def _load_db() -> None: - main_repeater.load_xml_db() - - try: - await self.hass.async_add_executor_job(_load_db) - except HTTPError: - _LOGGER.exception("Http error") - return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error") - return self.async_abort(reason="unknown") - - guid = main_repeater.guid - - if len(guid) <= 10: - return self.async_abort(reason="cannot_connect") - _LOGGER.debug("Main Repeater GUID: %s", main_repeater.guid) - - await self.async_set_unique_id(guid) - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Lutron", data=import_config) diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index f231c33a296..7b1b9e65137 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -75,13 +75,7 @@ class LutronEventEntity(LutronKeypad, EventEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - self._lutron_device.subscribe(self.handle_event, None) - - async def async_will_remove_from_hass(self) -> None: - """Unregister callbacks.""" - await super().async_will_remove_from_hass() - # Temporary solution until https://github.com/thecynic/pylutron/pull/93 gets merged - self._lutron_device._subscribers.remove((self.handle_event, None)) # pylint: disable=protected-access + self.async_on_remove(self._lutron_device.subscribe(self.handle_event, None)) @callback def handle_event( diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 73f1028bb72..f3aeb5feb90 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.12"] + "requirements": ["pylutron==0.2.13"] } diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json index 0212c8845d5..d5197375dc1 100644 --- a/homeassistant/components/lutron/strings.json +++ b/homeassistant/components/lutron/strings.json @@ -38,14 +38,6 @@ } }, "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The Lutron YAML configuration import cannot connect to server", - "description": "Configuring Lutron using YAML is being removed but there was an connection error importing your YAML configuration.\n\nThings you can try:\nMake sure your home assistant can reach the main repeater.\nRestart the main repeater by unplugging it for 60 seconds.\nTry logging into the main repeater at the IP address you specified in a web browser and the same login information.\n\nThen restart Home Assistant to try importing this integration again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." - }, - "deprecated_yaml_import_issue_unknown": { - "title": "The Lutron YAML configuration import request failed due to an unknown error", - "description": "Configuring Lutron using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nThe specific error can be found in the logs. The most likely cause is a networking error or the Main Repeater is down or has an invalid configuration.\n\nVerify that your Lutron system is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the Lutron configuration from your YAML configuration entirely, restart Home Assistant, and add the Lutron integration manually." - }, "deprecated_light_fan_entity": { "title": "Detected Lutron fan entity created as a light", "description": "Fan entities have been added to the Lutron integration.\nWe detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new fan entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated light entity removed, disable the entity and restart Home Assistant." diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index aa5c2f4e0b9..04fbb9e54c1 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -96,7 +96,7 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaDeviceUpdatableEntity, CoverEntity): async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the blind to a specific tilt.""" - self._smartbridge.set_tilt(self.device_id, kwargs[ATTR_TILT_POSITION]) + await self._smartbridge.set_tilt(self.device_id, kwargs[ATTR_TILT_POSITION]) PYLUTRON_TYPE_TO_CLASSES = { diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 349e4f871a3..7c002229741 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -84,6 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for location in lyric.locations for device in location.devices if device.deviceClass == "Thermostat" + and device.deviceID.startswith("LCC") ) ) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 337a58e3b2f..b446ba3704e 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -98,7 +98,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error("Failed to initialize mailbox platform %s", p_type) return - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error setting up platform %s", p_type) return diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 6f4a3306c29..5b344dd01ac 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -8,8 +8,11 @@ from typing import Any import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) from homeassistant.const import ( CONF_ARMING_TIME, CONF_CODE, @@ -39,6 +42,7 @@ import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) +CONF_ARMING_STATES = "arming_states" CONF_CODE_TEMPLATE = "code_template" CONF_CODE_ARM_REQUIRED = "code_arm_required" @@ -68,6 +72,14 @@ SUPPORTED_ARMING_STATES = [ if state not in (STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) ] +SUPPORTED_ARMING_STATE_TO_FEATURE = { + STATE_ALARM_ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY, + STATE_ALARM_ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME, + STATE_ALARM_ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT, + STATE_ALARM_ARMED_VACATION: AlarmControlPanelEntityFeature.ARM_VACATION, + STATE_ALARM_ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, +} + ATTR_PREVIOUS_STATE = "previous_state" ATTR_NEXT_STATE = "next_state" @@ -125,6 +137,9 @@ PLATFORM_SCHEMA = vol.Schema( vol.Optional( CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER ): cv.boolean, + vol.Optional(CONF_ARMING_STATES, default=SUPPORTED_ARMING_STATES): vol.All( + cv.ensure_list, [vol.In(SUPPORTED_ARMING_STATES)] + ), vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): _state_schema( STATE_ALARM_ARMED_AWAY ), @@ -174,7 +189,7 @@ def setup_platform( ) -class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): +class ManualAlarm(AlarmControlPanelEntity, RestoreEntity): """Representation of an alarm status. When armed, will be arming for 'arming_time', after that armed. @@ -185,14 +200,6 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """ _attr_should_poll = False - _attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_VACATION - | AlarmControlPanelEntityFeature.TRIGGER - | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - ) def __init__( self, @@ -230,6 +237,12 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): state: config[state][CONF_ARMING_TIME] for state in SUPPORTED_ARMING_STATES } + self._attr_supported_features = AlarmControlPanelEntityFeature.TRIGGER + for arming_state in config.get(CONF_ARMING_STATES, SUPPORTED_ARMING_STATES): + self._attr_supported_features |= SUPPORTED_ARMING_STATE_TO_FEATURE[ + arming_state + ] + @property def state(self) -> str: """Return the state of the device.""" @@ -276,13 +289,13 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property - def code_format(self) -> alarm.CodeFormat | None: + def code_format(self) -> CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None if isinstance(self._code, str) and self._code.isdigit(): - return alarm.CodeFormat.NUMBER - return alarm.CodeFormat.TEXT + return CodeFormat.NUMBER + return CodeFormat.TEXT async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 0bb7c57599a..26946a2a45c 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -9,8 +9,11 @@ from typing import Any import voluptuous as vol from homeassistant.components import mqtt -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) from homeassistant.const import ( CONF_CODE, CONF_DELAY_TIME, @@ -224,7 +227,7 @@ async def async_setup_platform( ) -class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): +class ManualMQTTAlarm(AlarmControlPanelEntity): """Representation of an alarm status. When armed, will be pending for 'pending_time', after that armed. @@ -342,13 +345,13 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property - def code_format(self) -> alarm.CodeFormat | None: + def code_format(self) -> CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None if isinstance(self._code, str) and self._code.isdigit(): - return alarm.CodeFormat.NUMBER - return alarm.CodeFormat.TEXT + return CodeFormat.NUMBER + return CodeFormat.TEXT async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 97ab2145486..1ab47896b0d 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -2,13 +2,18 @@ from __future__ import annotations +import mimetypes from typing import Any from mastodon import Mastodon from mastodon.Mastodon import MastodonAPIError, MastodonUnauthorizedError import voluptuous as vol -from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.components.notify import ( + ATTR_DATA, + PLATFORM_SCHEMA, + BaseNotificationService, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -16,6 +21,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_BASE_URL, DEFAULT_URL, LOGGER +ATTR_MEDIA = "media" +ATTR_TARGET = "target" +ATTR_MEDIA_WARNING = "media_warning" +ATTR_CONTENT_WARNING = "content_warning" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_ACCESS_TOKEN): cv.string, @@ -60,8 +70,59 @@ class MastodonNotificationService(BaseNotificationService): self._api = api def send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a user.""" + """Toot a message, with media perhaps.""" + data = kwargs.get(ATTR_DATA) + + media = None + mediadata = None + target = None + sensitive = False + content_warning = None + + if data: + media = data.get(ATTR_MEDIA) + if media: + if not self.hass.config.is_allowed_path(media): + LOGGER.warning("'%s' is not a whitelisted directory", media) + return + mediadata = self._upload_media(media) + + target = data.get(ATTR_TARGET) + sensitive = data.get(ATTR_MEDIA_WARNING) + content_warning = data.get(ATTR_CONTENT_WARNING) + + if mediadata: + try: + self._api.status_post( + message, + media_ids=mediadata["id"], + sensitive=sensitive, + visibility=target, + spoiler_text=content_warning, + ) + except MastodonAPIError: + LOGGER.error("Unable to send message") + else: + try: + self._api.status_post( + message, visibility=target, spoiler_text=content_warning + ) + except MastodonAPIError: + LOGGER.error("Unable to send message") + + def _upload_media(self, media_path: Any = None) -> Any: + """Upload media.""" + with open(media_path, "rb"): + media_type = self._media_type(media_path) try: - self._api.toot(message) + mediadata = self._api.media_post(media_path, mime_type=media_type) except MastodonAPIError: - LOGGER.error("Unable to send message") + LOGGER.error(f"Unable to upload image {media_path}") + + return mediadata + + def _media_type(self, media_path: Any = None) -> Any: + """Get media Type.""" + (media_type, _) = mimetypes.guess_type(media_path) + + return media_type diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 06c205859bb..86b642f7389 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -153,7 +153,7 @@ async def _client_listen( if entry.state != ConfigEntryState.LOADED: raise LOGGER.error("Failed to listen: %s", err) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) if entry.state != ConfigEntryState.LOADED: diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index e6a2a6c54d5..39597bc2ab2 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, ParamSpec +from typing import Any, Concatenate from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError @@ -18,8 +18,6 @@ from homeassistant.core import HomeAssistant, callback from .adapter import MatterAdapter from .helpers import MissingNode, get_matter, node_from_ha_device_id -_P = ParamSpec("_P") - ID = "id" TYPE = "type" DEVICE_ID = "device_id" @@ -93,7 +91,7 @@ def async_get_matter_adapter( return _get_matter -def async_handle_failed_command( +def async_handle_failed_command[**_P]( func: Callable[ Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], Coroutine[Any, Any, None], diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 23ac2195355..b71c35c9cce 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Objects import uint from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models import device_types from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -73,17 +74,6 @@ DISCOVERY_SCHEMAS = [ vendor_id=(4107,), product_name=("Hue motion sensor",), ), - MatterDiscoverySchema( - platform=Platform.BINARY_SENSOR, - entity_description=MatterBinarySensorEntityDescription( - key="ContactSensor", - device_class=BinarySensorDeviceClass.DOOR, - # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, - ), - entity_class=MatterBinarySensor, - required_attributes=(clusters.BooleanState.Attributes.StateValue,), - ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( @@ -109,4 +99,50 @@ DISCOVERY_SCHEMAS = [ # only add binary battery sensor if a regular percentage based is not available absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + # BooleanState sensors (tied to device type) + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="ContactSensor", + device_class=BinarySensorDeviceClass.DOOR, + # value is inverted on matter to what we expect + measurement_to_ha=lambda x: not x, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.BooleanState.Attributes.StateValue,), + device_type=(device_types.ContactSensor,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="WaterLeakDetector", + translation_key="water_leak", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.BooleanState.Attributes.StateValue,), + device_type=(device_types.WaterLeakDetector,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="WaterFreezeDetector", + translation_key="water_freeze", + device_class=BinarySensorDeviceClass.COLD, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.BooleanState.Attributes.StateValue,), + device_type=(device_types.WaterFreezeDetector,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="RainSensor", + translation_key="rain", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.BooleanState.Attributes.StateValue,), + device_type=(device_types.RainSensor,), + ), ] diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 1b949d3ebfb..d2656d59138 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -42,9 +42,37 @@ HVAC_SYSTEM_MODE_MAP = { HVACMode.HEAT_COOL: 1, HVACMode.COOL: 3, HVACMode.HEAT: 4, + HVACMode.DRY: 8, + HVACMode.FAN_ONLY: 7, } -SystemModeEnum = clusters.Thermostat.Enums.ThermostatSystemMode -ControlSequenceEnum = clusters.Thermostat.Enums.ThermostatControlSequence + +SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = { + # Some devices only have a single setpoint while the matter spec + # assumes that you need separate setpoints for heating and cooling. + # We were told this is just some legacy inheritance from zigbee specs. + # In the list below specify tuples of (vendorid, productid) of devices for + # which we just need a single setpoint to control both heating and cooling. + (0x1209, 0x8007), +} + +SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = { + # The Matter spec is missing a feature flag if the device supports a dry mode. + # In the list below specify tuples of (vendorid, productid) of devices that + # support dry mode. + (0x0001, 0x0108), + (0x1209, 0x8007), +} + +SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { + # The Matter spec is missing a feature flag if the device supports a fan-only mode. + # In the list below specify tuples of (vendorid, productid) of devices that + # support fan-only mode. + (0x0001, 0x0108), + (0x1209, 0x8007), +} + +SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum +ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum ThermostatFeature = clusters.Thermostat.Bitmaps.Feature @@ -85,80 +113,91 @@ class MatterClimate(MatterEntity, ClimateEntity): ) -> None: """Initialize the Matter climate entity.""" super().__init__(matter_client, endpoint, entity_info) + product_id = self._endpoint.node.device_info.productID + vendor_id = self._endpoint.node.device_info.vendorID # set hvac_modes based on feature map self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF] feature_map = int( self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) ) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF + ) if feature_map & ThermostatFeature.kHeating: self._attr_hvac_modes.append(HVACMode.HEAT) if feature_map & ThermostatFeature.kCooling: self._attr_hvac_modes.append(HVACMode.COOL) + if (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES: + self._attr_hvac_modes.append(HVACMode.DRY) + if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES: + self._attr_hvac_modes.append(HVACMode.FAN_ONLY) if feature_map & ThermostatFeature.kAutoMode: self._attr_hvac_modes.append(HVACMode.HEAT_COOL) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TURN_OFF - ) + # only enable temperature_range feature if the device actually supports that + + if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES: + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF): self._attr_supported_features |= ClimateEntityFeature.TURN_ON async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) + target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE) + target_temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if target_hvac_mode is not None: await self.async_set_hvac_mode(target_hvac_mode) - current_mode = target_hvac_mode or self.hvac_mode - command = None - if current_mode in (HVACMode.HEAT, HVACMode.COOL): - # when current mode is either heat or cool, the temperature arg must be provided. - temperature: float | None = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - raise ValueError("Temperature must be provided") - if self.target_temperature is None: - raise ValueError("Current target_temperature should not be None") - command = self._create_optional_setpoint_command( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool - if current_mode == HVACMode.COOL - else clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, - temperature, - self.target_temperature, - ) - elif current_mode == HVACMode.HEAT_COOL: - temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) - temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if temperature_low is None or temperature_high is None: - raise ValueError( - "temperature_low and temperature_high must be provided" + + if target_temperature is not None: + # single setpoint control + if self.target_temperature != target_temperature: + if current_mode == HVACMode.COOL: + matter_attribute = ( + clusters.Thermostat.Attributes.OccupiedCoolingSetpoint + ) + else: + matter_attribute = ( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ) + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + matter_attribute, + ), + value=int(target_temperature * TEMPERATURE_SCALING_FACTOR), ) - if ( - self.target_temperature_low is None - or self.target_temperature_high is None - ): - raise ValueError( - "current target_temperature_low and target_temperature_high should not be None" + return + + if target_temperature_low is not None: + # multi setpoint control - low setpoint (heat) + if self.target_temperature_low != target_temperature_low: + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + ), + value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR), ) - # due to ha send both high and low temperature, we need to check which one is changed - command = self._create_optional_setpoint_command( - clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, - temperature_low, - self.target_temperature_low, - ) - if command is None: - command = self._create_optional_setpoint_command( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, - temperature_high, - self.target_temperature_high, + + if target_temperature_high is not None: + # multi setpoint control - high setpoint (cool) + if self.target_temperature_high != target_temperature_high: + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, + ), + value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR), ) - if command: - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -201,6 +240,10 @@ class MatterClimate(MatterEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.COOL case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat: self._attr_hvac_mode = HVACMode.HEAT + case SystemModeEnum.kFanOnly: + self._attr_hvac_mode = HVACMode.FAN_ONLY + case SystemModeEnum.kDry: + self._attr_hvac_mode = HVACMode.DRY case _: self._attr_hvac_mode = HVACMode.OFF # running state is an optional attribute @@ -271,24 +314,6 @@ class MatterClimate(MatterEntity, ClimateEntity): return float(value) / TEMPERATURE_SCALING_FACTOR return None - @staticmethod - def _create_optional_setpoint_command( - mode: clusters.Thermostat.Enums.SetpointAdjustMode | int, - target_temp: float, - current_target_temp: float, - ) -> clusters.Thermostat.Commands.SetpointRaiseLower | None: - """Create a setpoint command if the target temperature is different from the current one.""" - - temp_diff = int((target_temp - current_target_temp) * 10) - - if temp_diff == 0: - return None - - return clusters.Thermostat.Commands.SetpointRaiseLower( - mode, - temp_diff, - ) - # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ @@ -296,7 +321,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.CLIMATE, entity_description=ClimateEntityDescription( key="MatterThermostat", - name=None, + translation_key="thermostat", ), entity_class=MatterClimate, required_attributes=(clusters.Thermostat.Attributes.LocalTemperature,), diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index b079dcd9b54..ae71b7a1711 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -222,7 +222,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidServerVersion: errors["base"] = "invalid_server_version" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index ea5250c9bd3..c32b7bc9e1a 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -200,7 +200,9 @@ class MatterCover(MatterEntity, CoverEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.COVER, - entity_description=CoverEntityDescription(key="MatterCover", name=None), + entity_description=CoverEntityDescription( + key="MatterCover", translation_key="cover" + ), entity_class=MatterCover, required_attributes=( clusters.WindowCovering.Attributes.OperationalStatus, @@ -214,7 +216,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.COVER, entity_description=CoverEntityDescription( - key="MatterCoverPositionAwareLift", name=None + key="MatterCoverPositionAwareLift", translation_key="cover" ), entity_class=MatterCover, required_attributes=( @@ -229,7 +231,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.COVER, entity_description=CoverEntityDescription( - key="MatterCoverPositionAwareTilt", name=None + key="MatterCoverPositionAwareTilt", translation_key="cover" ), entity_class=MatterCover, required_attributes=( @@ -244,7 +246,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.COVER, entity_description=CoverEntityDescription( - key="MatterCoverPositionAwareLiftAndTilt", name=None + key="MatterCoverPositionAwareLiftAndTilt", translation_key="cover" ), entity_class=MatterCover, required_attributes=( diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 985ac1c996e..b457be8583c 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Generator - from chip.clusters.Objects import ClusterAttributeDescriptor from matter_server.client.models.node import MatterEndpoint +from typing_extensions import Generator from homeassistant.const import Platform from homeassistant.core import callback @@ -14,9 +13,11 @@ from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS +from .fan import DISCOVERY_SCHEMAS as FAN_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo +from .number import DISCOVERY_SCHEMAS as NUMBER_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS @@ -25,8 +26,10 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS, Platform.COVER: COVER_SCHEMAS, Platform.EVENT: EVENT_SCHEMAS, + Platform.FAN: FAN_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, + Platform.NUMBER: NUMBER_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } @@ -34,7 +37,7 @@ SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) @callback -def iter_schemas() -> Generator[MatterDiscoverySchema, None, None]: +def iter_schemas() -> Generator[MatterDiscoverySchema]: """Iterate over all available discovery schemas.""" for platform_schemas in DISCOVERY_SCHEMAS.values(): yield from platform_schemas @@ -43,7 +46,7 @@ def iter_schemas() -> Generator[MatterDiscoverySchema, None, None]: @callback def async_discover_entities( endpoint: MatterEndpoint, -) -> Generator[MatterEntityInfo, None, None]: +) -> Generator[MatterEntityInfo]: """Run discovery on MatterEndpoint and return matching MatterEntityInfo(s).""" discovered_attributes: set[type[ClusterAttributeDescriptor]] = set() device_info = endpoint.device_info @@ -116,7 +119,6 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, - should_poll=schema.should_poll, ) # prevent re-discovery of the primary attribute if not allowed diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a47147e874a..aaaaf074ddd 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -4,20 +4,20 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable -from contextlib import suppress from dataclasses import dataclass -from datetime import datetime +from functools import cached_property import logging from typing import TYPE_CHECKING, Any, cast +from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.typing import UndefinedType from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -30,13 +30,6 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) -# For some manually polled values (e.g. custom clusters) we perform -# an additional poll as soon as a secondary value changes. -# For example update the energy consumption meter when a relay is toggled -# of an energy metering powerplug. The below constant defined the delay after -# which we poll the primary value (debounced). -EXTRA_POLL_DELAY = 3.0 - @dataclass(frozen=True) class MatterEntityDescription(EntityDescription): @@ -44,12 +37,14 @@ class MatterEntityDescription(EntityDescription): # convert the value from the primary attribute to the value used by HA measurement_to_ha: Callable[[Any], Any] | None = None + ha_to_native_value: Callable[[Any], Any] | None = None class MatterEntity(Entity): """Entity class for Matter devices.""" _attr_has_entity_name = True + _name_postfix: str | None = None def __init__( self, @@ -80,8 +75,35 @@ class MatterEntity(Entity): identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) self._attr_available = self._endpoint.node.available - self._attr_should_poll = entity_info.should_poll - self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None + # mark endpoint postfix if the device has the primary attribute on multiple endpoints + if not self._endpoint.node.is_bridge_device and any( + ep + for ep in self._endpoint.node.endpoints.values() + if ep != self._endpoint + and ep.has_attribute(None, entity_info.primary_attribute) + ): + self._name_postfix = str(self._endpoint.endpoint_id) + + # prefer the label attribute for the entity name + # Matter has a way for users and/or vendors to specify a name for an endpoint + # which is always preferred over a standard HA (generated) name + for attr in ( + clusters.FixedLabel.Attributes.LabelList, + clusters.UserLabel.Attributes.LabelList, + ): + if not (labels := self.get_matter_attribute_value(attr)): + continue + for label in labels: + if label.label not in ["Label", "Button"]: + continue + # fixed or user label found: use it + label_value: str = label.value + # in the case the label is only the label id, use it as postfix only + if label_value.isnumeric(): + self._name_postfix = label_value + else: + self._attr_name = label_value + break # make sure to update the attributes once self._update_from_device() @@ -116,40 +138,21 @@ class MatterEntity(Entity): ) ) - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._extra_poll_timer_unsub: - self._extra_poll_timer_unsub() - for unsub in self._unsubscribes: - with suppress(ValueError): - # suppress ValueError to prevent race conditions - unsub() - - async def async_update(self) -> None: - """Call when the entity needs to be updated.""" - if not self._endpoint.node.available: - # skip poll when the node is not (yet) available - return - # manually poll/refresh the primary value - await self.matter_client.refresh_attribute( - self._endpoint.node.node_id, - self.get_matter_attribute_path(self._entity_info.primary_attribute), - ) - self._update_from_device() + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + if hasattr(self, "_attr_name"): + # an explicit entity name was defined, we use that + return self._attr_name + name = super().name + if name and self._name_postfix: + name = f"{name} ({self._name_postfix})" + return name @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: """Call on update from the device.""" self._attr_available = self._endpoint.node.available - if self._attr_should_poll: - # secondary attribute updated of a polled primary value - # enforce poll of the primary value a few seconds later - if self._extra_poll_timer_unsub: - self._extra_poll_timer_unsub() - self._extra_poll_timer_unsub = async_call_later( - self.hass, EXTRA_POLL_DELAY, self._do_extra_poll - ) - return self._update_from_device() self.async_write_ha_state() @@ -176,9 +179,3 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) - - @callback - def _do_extra_poll(self, called_at: datetime) -> None: - """Perform (extra) poll of primary value.""" - # scheduling the regulat update is enough to perform a poll/refresh - self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index ea48beef782..dcb67d50523 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -49,8 +49,6 @@ async def async_setup_entry( class MatterEventEntity(MatterEntity, EventEntity): """Representation of a Matter Event entity.""" - _attr_translation_key = "push" - def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the entity.""" super().__init__(*args, **kwargs) @@ -72,21 +70,6 @@ class MatterEventEntity(MatterEntity, EventEntity): event_types.append("multi_press_ongoing") event_types.append("multi_press_complete") self._attr_event_types = event_types - # the optional label attribute could be used to identify multiple buttons - # e.g. in case of a dimmer switch with 4 buttons, each button - # will have its own name, prefixed by the device name. - if labels := self.get_matter_attribute_value( - clusters.FixedLabel.Attributes.LabelList - ): - for label in labels: - if label.label == "Label": - label_value: str = label.value - # in the case the label is only the label id, prettify it a bit - if label_value.isnumeric(): - self._attr_name = f"Button {label_value}" - else: - self._attr_name = label_value - break async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -122,7 +105,9 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.EVENT, entity_description=EventEntityDescription( - key="GenericSwitch", device_class=EventDeviceClass.BUTTON, name=None + key="GenericSwitch", + device_class=EventDeviceClass.BUTTON, + translation_key="button", ), entity_class=MatterEventEntity, required_attributes=( diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py new file mode 100644 index 00000000000..0ce42f14d39 --- /dev/null +++ b/homeassistant/components/matter/fan.py @@ -0,0 +1,304 @@ +"""Matter Fan platform support.""" + +from __future__ import annotations + +from typing import Any + +from chip.clusters import Objects as clusters +from matter_server.common.helpers.util import create_attribute_path_from_attribute + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityDescription, + FanEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +FanControlFeature = clusters.FanControl.Bitmaps.Feature +WindBitmap = clusters.FanControl.Bitmaps.WindBitmap +FanModeSequenceEnum = clusters.FanControl.Enums.FanModeSequenceEnum + +PRESET_LOW = "low" +PRESET_MEDIUM = "medium" +PRESET_HIGH = "high" +PRESET_AUTO = "auto" +FAN_MODE_MAP = { + PRESET_LOW: clusters.FanControl.Enums.FanModeEnum.kLow, + PRESET_MEDIUM: clusters.FanControl.Enums.FanModeEnum.kMedium, + PRESET_HIGH: clusters.FanControl.Enums.FanModeEnum.kHigh, + PRESET_AUTO: clusters.FanControl.Enums.FanModeEnum.kAuto, +} +FAN_MODE_MAP_REVERSE = {v: k for k, v in FAN_MODE_MAP.items()} +# special preset modes for wind feature +PRESET_NATURAL_WIND = "natural_wind" +PRESET_SLEEP_WIND = "sleep_wind" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter fan from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.FAN, async_add_entities) + + +class MatterFan(MatterEntity, FanEntity): + """Representation of a Matter fan.""" + + _last_known_preset_mode: str | None = None + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is not None: + # handle setting fan speed by percentage + await self.async_set_percentage(percentage) + return + # handle setting fan mode by preset + if preset_mode is None: + # no preset given, try to handle this with the last known value + preset_mode = self._last_known_preset_mode or PRESET_AUTO + await self.async_set_preset_mode(preset_mode) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn fan off.""" + # clear the wind setting if its currently set + if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: + await self._set_wind_mode(None) + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.FanMode, + ), + value=clusters.FanControl.Enums.FanModeEnum.kOff, + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.PercentSetting, + ), + value=percentage, + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + # handle wind as preset + if preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: + await self._set_wind_mode(preset_mode) + return + + # clear the wind setting if its currently set + if self._attr_preset_mode in [PRESET_NATURAL_WIND, PRESET_SLEEP_WIND]: + await self._set_wind_mode(None) + + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.FanMode, + ), + value=FAN_MODE_MAP[preset_mode], + ) + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.RockSetting, + ), + value=self.get_matter_attribute_value( + clusters.FanControl.Attributes.RockSupport + ) + if oscillating + else 0, + ) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.AirflowDirection, + ), + value=clusters.FanControl.Enums.AirflowDirectionEnum.kReverse + if direction == DIRECTION_REVERSE + else clusters.FanControl.Enums.AirflowDirectionEnum.kForward, + ) + + async def _set_wind_mode(self, wind_mode: str | None) -> None: + """Set wind mode.""" + if wind_mode == PRESET_NATURAL_WIND: + wind_setting = WindBitmap.kNaturalWind + elif wind_mode == PRESET_SLEEP_WIND: + wind_setting = WindBitmap.kSleepWind + else: + wind_setting = 0 + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.FanControl.Attributes.WindSetting, + ), + value=wind_setting, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + if not hasattr(self, "_attr_preset_modes"): + self._calculate_features() + if self._attr_supported_features & FanEntityFeature.DIRECTION: + direction_value = self.get_matter_attribute_value( + clusters.FanControl.Attributes.AirflowDirection + ) + self._attr_current_direction = ( + DIRECTION_REVERSE + if direction_value + == clusters.FanControl.Enums.AirflowDirectionEnum.kReverse + else DIRECTION_FORWARD + ) + if self._attr_supported_features & FanEntityFeature.OSCILLATE: + self._attr_oscillating = ( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.RockSetting + ) + != 0 + ) + + # speed percentage is always provided + current_percent = self.get_matter_attribute_value( + clusters.FanControl.Attributes.PercentCurrent + ) + # NOTE that a device may give back 255 as a special value to indicate that + # the speed is under automatic control and not set to a specific value. + self._attr_percentage = None if current_percent == 255 else current_percent + + # get preset mode from fan mode (and wind feature if available) + wind_setting = self.get_matter_attribute_value( + clusters.FanControl.Attributes.WindSetting + ) + if ( + self._attr_preset_modes + and PRESET_NATURAL_WIND in self._attr_preset_modes + and wind_setting & WindBitmap.kNaturalWind + ): + self._attr_preset_mode = PRESET_NATURAL_WIND + elif ( + self._attr_preset_modes + and PRESET_SLEEP_WIND in self._attr_preset_modes + and wind_setting & WindBitmap.kSleepWind + ): + self._attr_preset_mode = PRESET_SLEEP_WIND + else: + fan_mode = self.get_matter_attribute_value( + clusters.FanControl.Attributes.FanMode + ) + self._attr_preset_mode = FAN_MODE_MAP_REVERSE.get(fan_mode) + + # keep track of the last known mode for turn_on commands without preset + if self._attr_preset_mode is not None: + self._last_known_preset_mode = self._attr_preset_mode + + @callback + def _calculate_features( + self, + ) -> None: + """Calculate features and preset modes for HA Fan platform from Matter attributes..""" + # work out supported features and presets from matter featuremap + feature_map = int( + self.get_matter_attribute_value(clusters.FanControl.Attributes.FeatureMap) + ) + if feature_map & FanControlFeature.kMultiSpeed: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + self._attr_speed_count = int( + self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax) + ) + if feature_map & FanControlFeature.kRocking: + # NOTE: the Matter model allows that a device can have multiple/different + # rock directions while HA doesn't allow this in the entity model. + # For now we just assume that a device has a single rock direction and the + # Matter spec is just future proofing for devices that might have multiple + # rock directions. As soon as devices show up that actually support multiple + # directions, we need to either update the HA Fan entity model or maybe add + # this as a separate entity. + self._attr_supported_features |= FanEntityFeature.OSCILLATE + + # figure out supported preset modes + preset_modes = [] + fan_mode_seq = int( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.FanModeSequence + ) + ) + if fan_mode_seq == FanModeSequenceEnum.kOffLowHigh: + preset_modes = [PRESET_LOW, PRESET_HIGH] + elif fan_mode_seq == FanModeSequenceEnum.kOffLowHighAuto: + preset_modes = [PRESET_LOW, PRESET_HIGH, PRESET_AUTO] + elif fan_mode_seq == FanModeSequenceEnum.kOffLowMedHigh: + preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH] + elif fan_mode_seq == FanModeSequenceEnum.kOffLowMedHighAuto: + preset_modes = [PRESET_LOW, PRESET_MEDIUM, PRESET_HIGH, PRESET_AUTO] + elif fan_mode_seq == FanModeSequenceEnum.kOffOnAuto: + preset_modes = [PRESET_AUTO] + # treat Matter Wind feature as additional preset(s) + if feature_map & FanControlFeature.kWind: + wind_support = int( + self.get_matter_attribute_value( + clusters.FanControl.Attributes.WindSupport + ) + ) + if wind_support & WindBitmap.kNaturalWind: + preset_modes.append(PRESET_NATURAL_WIND) + if wind_support & WindBitmap.kSleepWind: + preset_modes.append(PRESET_SLEEP_WIND) + if len(preset_modes) > 0: + self._attr_supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes = preset_modes + if feature_map & FanControlFeature.kAirflowDirection: + self._attr_supported_features |= FanEntityFeature.DIRECTION + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.FAN, + entity_description=FanEntityDescription( + key="MatterFan", name=None, translation_key="fan" + ), + entity_class=MatterFan, + # FanEntityFeature + required_attributes=( + clusters.FanControl.Attributes.FanMode, + clusters.FanControl.Attributes.PercentCurrent, + ), + optional_attributes=( + clusters.FanControl.Attributes.SpeedSetting, + clusters.FanControl.Attributes.RockSetting, + clusters.FanControl.Attributes.WindSetting, + clusters.FanControl.Attributes.AirflowDirection, + ), + ), +] diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index cab9b602753..fc06bfd4822 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -59,12 +59,12 @@ def get_device_id( ) -> str: """Return HA device_id for the given MatterEndpoint.""" operational_instance_id = get_operational_instance_id(server_info, endpoint.node) - # Append endpoint ID if this endpoint is a bridged or composed device - if endpoint.is_composed_device: - compose_parent = endpoint.node.get_compose_parent(endpoint.endpoint_id) - assert compose_parent is not None - postfix = str(compose_parent.endpoint_id) - elif endpoint.is_bridged_device: + # if this is a composed device we need to get the compose parent + # example: Philips Hue motion sensor on Hue Hub (bridged to Matter) + if compose_parent := endpoint.node.get_compose_parent(endpoint.endpoint_id): + endpoint = compose_parent + if endpoint.is_bridged_device: + # Append endpoint ID if this endpoint is a bridged device postfix = str(endpoint.endpoint_id) else: # this should be compatible with previous versions diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json new file mode 100644 index 00000000000..94da41931de --- /dev/null +++ b/homeassistant/components/matter/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "fan": { + "fan": { + "state_attributes": { + "preset_mode": { + "default": "mdi:fan", + "state": { + "low": "mdi:fan-speed-1", + "medium": "mdi:fan-speed-2", + "high": "mdi:fan-speed-3", + "auto": "mdi:fan-auto", + "natural_wind": "mdi:tailwind", + "sleep_wind": "mdi:sleep" + } + } + } + } + } + } +} diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index da72798dda1..777e4a69010 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -52,7 +52,11 @@ DEFAULT_TRANSITION = 0.2 # sw version (attributeKey 0/40/10) TRANSITION_BLOCKLIST = ( (4488, 514, "1.0", "1.0.0"), + (4488, 260, "1.0", "1.0.0"), (5010, 769, "3.0", "1.0.0"), + (4999, 25057, "1.0", "27.0"), + (4448, 36866, "V1", "V1.0.0.5"), + (5009, 514, "1.0", "1.0.0"), ) @@ -417,7 +421,9 @@ class MatterLight(MatterEntity, LightEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LIGHT, - entity_description=LightEntityDescription(key="MatterLight", name=None), + entity_description=LightEntityDescription( + key="MatterLight", translation_key="light" + ), entity_class=MatterLight, required_attributes=(clusters.OnOff.Attributes.OnOff,), optional_attributes=( @@ -432,6 +438,7 @@ DISCOVERY_SCHEMAS = [ device_type=( device_types.ColorTemperatureLight, device_types.DimmableLight, + device_types.DimmablePlugInUnit, device_types.ExtendedColorLight, device_types.OnOffLight, ), @@ -440,7 +447,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LIGHT, entity_description=LightEntityDescription( - key="MatterHSColorLightFallback", name=None + key="MatterHSColorLightFallback", translation_key="light" ), entity_class=MatterLight, required_attributes=( @@ -460,7 +467,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LIGHT, entity_description=LightEntityDescription( - key="MatterXYColorLightFallback", name=None + key="MatterXYColorLightFallback", translation_key="light" ), entity_class=MatterLight, required_attributes=( @@ -480,7 +487,7 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LIGHT, entity_description=LightEntityDescription( - key="MatterColorTemperatureLightFallback", name=None + key="MatterColorTemperatureLightFallback", translation_key="light" ), entity_class=MatterLight, required_attributes=( diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index e5067efd482..5456554a535 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -168,12 +168,17 @@ class MatterLock(MatterEntity, LockEntity): self._attr_is_jammed = ( door_state is clusters.DoorLock.Enums.DoorStateEnum.kDoorJammed ) + self._attr_is_open = ( + door_state is clusters.DoorLock.Enums.DoorStateEnum.kDoorOpen + ) DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LOCK, - entity_description=LockEntityDescription(key="MatterLock", name=None), + entity_description=LockEntityDescription( + key="MatterLock", translation_key="lock" + ), entity_class=MatterLock, required_attributes=(clusters.DoorLock.Attributes.LockState,), optional_attributes=(clusters.DoorLock.Attributes.DoorState,), diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 20988e387fe..369657df90c 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.10.0"], + "requirements": ["python-matter-server==6.1.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 18e503523ae..bb79d3571cf 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -13,7 +13,7 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription -SensorValueTypes = type[ +type SensorValueTypes = type[ clusters.uint | int | clusters.Nullable | clusters.float32 | float ] @@ -51,9 +51,6 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type - # [optional] bool to specify if this primary value should be polled - should_poll: bool - @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -110,6 +107,3 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False - - # [optional] bool to specify if this primary value should be polled - should_poll: bool = False diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py new file mode 100644 index 00000000000..c9b40ef71a0 --- /dev/null +++ b/homeassistant/components/matter/number.py @@ -0,0 +1,140 @@ +"""Matter Number Inputs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from chip.clusters import Objects as clusters +from matter_server.common.helpers.util import create_attribute_path_from_attribute + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity, MatterEntityDescription +from .helpers import get_matter +from .models import MatterDiscoverySchema + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Number Input from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.NUMBER, async_add_entities) + + +@dataclass(frozen=True) +class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescription): + """Describe Matter Number Input entities.""" + + +class MatterNumber(MatterEntity, NumberEntity): + """Representation of a Matter Attribute as a Number entity.""" + + entity_description: MatterNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + matter_attribute = self._entity_info.primary_attribute + sendvalue = int(value) + if value_convert := self.entity_description.ha_to_native_value: + sendvalue = value_convert(value) + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + matter_attribute, + ), + value=sendvalue, + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_native_value = value + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="on_level", + entity_category=EntityCategory.CONFIG, + translation_key="on_level", + native_max_value=255, + native_min_value=0, + mode=NumberMode.BOX, + # use 255 to indicate that the value should revert to the default + measurement_to_ha=lambda x: 255 if x is None else x, + ha_to_native_value=lambda x: None if x == 255 else int(x), + native_step=1, + native_unit_of_measurement=None, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OnLevel,), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="on_transition_time", + entity_category=EntityCategory.CONFIG, + translation_key="on_transition_time", + native_max_value=65534, + native_min_value=0, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + native_step=0.1, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OnTransitionTime,), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="off_transition_time", + entity_category=EntityCategory.CONFIG, + translation_key="off_transition_time", + native_max_value=65534, + native_min_value=0, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + native_step=0.1, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OffTransitionTime,), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="on_off_transition_time", + entity_category=EntityCategory.CONFIG, + translation_key="on_off_transition_time", + native_max_value=65534, + native_min_value=0, + measurement_to_ha=lambda x: None if x is None else x / 10, + ha_to_native_value=lambda x: round(x * 10), + native_step=0.1, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(clusters.LevelControl.Attributes.OnOffTransitionTime,), + ), +] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 6f1bd1d142b..d91d4d33471 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue -from matter_server.client.models.clusters import EveEnergyCluster +from matter_server.common.custom_clusters import EveCluster from homeassistant.components.sensor import ( SensorDeviceClass, @@ -37,6 +37,17 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +AIR_QUALITY_MAP = { + clusters.AirQuality.Enums.AirQualityEnum.kExtremelyPoor: "extremely_poor", + clusters.AirQuality.Enums.AirQualityEnum.kVeryPoor: "very_poor", + clusters.AirQuality.Enums.AirQualityEnum.kPoor: "poor", + clusters.AirQuality.Enums.AirQualityEnum.kFair: "fair", + clusters.AirQuality.Enums.AirQualityEnum.kGood: "good", + clusters.AirQuality.Enums.AirQualityEnum.kModerate: "moderate", + clusters.AirQuality.Enums.AirQualityEnum.kUnknown: "unknown", + clusters.AirQuality.Enums.AirQualityEnum.kUnknownEnumValue: "unknown", +} + async def async_setup_entry( hass: HomeAssistant, @@ -159,11 +170,10 @@ DISCOVERY_SCHEMAS = [ state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, - required_attributes=(EveEnergyCluster.Attributes.Watt,), + required_attributes=(EveCluster.Attributes.Watt,), # Add OnOff Attribute as optional attribute to poll # the primary value when the relay is toggled optional_attributes=(clusters.OnOff.Attributes.OnOff,), - should_poll=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -176,8 +186,7 @@ DISCOVERY_SCHEMAS = [ state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, - required_attributes=(EveEnergyCluster.Attributes.Voltage,), - should_poll=True, + required_attributes=(EveCluster.Attributes.Voltage,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -190,8 +199,7 @@ DISCOVERY_SCHEMAS = [ state_class=SensorStateClass.TOTAL_INCREASING, ), entity_class=MatterSensor, - required_attributes=(EveEnergyCluster.Attributes.WattAccumulated,), - should_poll=True, + required_attributes=(EveCluster.Attributes.WattAccumulated,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -204,11 +212,10 @@ DISCOVERY_SCHEMAS = [ state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, - required_attributes=(EveEnergyCluster.Attributes.Current,), + required_attributes=(EveCluster.Attributes.Current,), # Add OnOff Attribute as optional attribute to poll # the primary value when the relay is toggled optional_attributes=(clusters.OnOff.Attributes.OnOff,), - should_poll=True, ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -223,6 +230,19 @@ DISCOVERY_SCHEMAS = [ clusters.CarbonDioxideConcentrationMeasurement.Attributes.MeasuredValue, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="TotalVolatileOrganicCompoundsSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -262,4 +282,86 @@ DISCOVERY_SCHEMAS = [ clusters.Pm10ConcentrationMeasurement.Attributes.MeasuredValue, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="AirQuality", + translation_key="air_quality", + device_class=SensorDeviceClass.ENUM, + state_class=None, + # convert to set first to remove the duplicate unknown value + options=list(set(AIR_QUALITY_MAP.values())), + measurement_to_ha=lambda x: AIR_QUALITY_MAP[x], + icon="mdi:air-filter", + ), + entity_class=MatterSensor, + required_attributes=(clusters.AirQuality.Attributes.AirQuality,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="CarbonMonoxideSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.CarbonMonoxideConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="NitrogenDioxideSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.NitrogenDioxideConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="OzoneConcentrationSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.OZONE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.OzoneConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="HepaFilterCondition", + native_unit_of_measurement=PERCENTAGE, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + translation_key="hepa_filter_condition", + icon="mdi:filter-check", + ), + entity_class=MatterSensor, + required_attributes=(clusters.HepaFilterMonitoring.Attributes.Condition,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ActivatedCarbonFilterCondition", + native_unit_of_measurement=PERCENTAGE, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + translation_key="activated_carbon_filter_condition", + icon="mdi:filter-check", + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ActivatedCarbonFilterMonitoring.Attributes.Condition, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c68b38bbb8c..e94ab2e1780 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -45,8 +45,30 @@ } }, "entity": { + "binary_sensor": { + "water_leak": { + "name": "Water leak" + }, + "water_freeze": { + "name": "Water freeze" + }, + "rain": { + "name": "Rain" + } + }, + "climate": { + "thermostat": { + "name": "Thermostat" + } + }, + "cover": { + "cover": { + "name": "[%key:component::cover::title%]" + } + }, "event": { - "push": { + "button": { + "name": "Button", "state_attributes": { "event_type": { "state": { @@ -62,9 +84,76 @@ } } }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "low": "Low", + "medium": "Medium", + "high": "High", + "auto": "Auto", + "natural_wind": "Natural wind", + "sleep_wind": "Sleep wind" + } + } + } + } + }, + "number": { + "on_level": { + "name": "On level" + }, + "on_transition_time": { + "name": "On transition time" + }, + "off_transition_time": { + "name": "Off transition time" + }, + "on_off_transition_time": { + "name": "On/Off transition time" + } + }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + }, + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, "sensor": { + "activated_carbon_filter_condition": { + "name": "Activated carbon filter condition" + }, + "air_quality": { + "name": "Air quality", + "state": { + "extremely_poor": "Extremely poor", + "very_poor": "Very poor", + "poor": "Poor", + "fair": "Fair", + "good": "Good", + "moderate": "Moderate", + "unknown": "Unknown" + } + }, "flow": { "name": "Flow" + }, + "hepa_filter_condition": { + "name": "Hepa filter condition" + } + }, + "switch": { + "switch": { + "name": "[%key:component::switch::title%]" + }, + "power": { + "name": "Power" } } }, diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index f148102cfcd..efa78446fc5 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -64,7 +64,9 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SWITCH, entity_description=SwitchEntityDescription( - key="MatterPlug", device_class=SwitchDeviceClass.OUTLET, name=None + key="MatterPlug", + device_class=SwitchDeviceClass.OUTLET, + translation_key="switch", ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), @@ -73,7 +75,38 @@ DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SWITCH, entity_description=SwitchEntityDescription( - key="MatterSwitch", device_class=SwitchDeviceClass.SWITCH, name=None + key="MatterPowerToggle", + device_class=SwitchDeviceClass.SWITCH, + translation_key="power", + ), + entity_class=MatterSwitch, + required_attributes=(clusters.OnOff.Attributes.OnOff,), + device_type=( + device_types.AirPurifier, + device_types.BasicVideoPlayer, + device_types.CastingVideoPlayer, + device_types.CookSurface, + device_types.Cooktop, + device_types.Dishwasher, + device_types.ExtractorHood, + device_types.HeatingCoolingUnit, + device_types.LaundryDryer, + device_types.LaundryWasher, + device_types.Oven, + device_types.Pump, + device_types.PumpController, + device_types.Refrigerator, + device_types.RoboticVacuumCleaner, + device_types.RoomAirConditioner, + device_types.Speaker, + ), + ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=SwitchEntityDescription( + key="MatterSwitch", + device_class=SwitchDeviceClass.OUTLET, + translation_key="switch", ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), @@ -83,6 +116,23 @@ DISCOVERY_SCHEMAS = [ device_types.ExtendedColorLight, device_types.ColorDimmerSwitch, device_types.OnOffLight, + device_types.AirPurifier, + device_types.BasicVideoPlayer, + device_types.CastingVideoPlayer, + device_types.CookSurface, + device_types.Cooktop, + device_types.Dishwasher, + device_types.ExtractorHood, + device_types.HeatingCoolingUnit, + device_types.LaundryDryer, + device_types.LaundryWasher, + device_types.Oven, + device_types.Pump, + device_types.PumpController, + device_types.Refrigerator, + device_types.RoboticVacuumCleaner, + device_types.RoomAirConditioner, + device_types.Speaker, ), ), ] diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py new file mode 100644 index 00000000000..c316cf04545 --- /dev/null +++ b/homeassistant/components/mealie/__init__.py @@ -0,0 +1,33 @@ +"""The Mealie integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import MealieCoordinator + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +type MealieConfigEntry = ConfigEntry[MealieCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bool: + """Set up Mealie from a config entry.""" + + coordinator = MealieCoordinator(hass) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py new file mode 100644 index 00000000000..08e90ebf5ea --- /dev/null +++ b/homeassistant/components/mealie/calendar.py @@ -0,0 +1,81 @@ +"""Calendar platform for Mealie.""" + +from __future__ import annotations + +from datetime import datetime + +from aiomealie import Mealplan, MealplanEntryType + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MealieConfigEntry, MealieCoordinator +from .entity import MealieEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MealieConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the calendar platform for entity.""" + coordinator = entry.runtime_data + + async_add_entities( + MealieMealplanCalendarEntity(coordinator, entry_type) + for entry_type in MealplanEntryType + ) + + +def _get_event_from_mealplan(mealplan: Mealplan) -> CalendarEvent: + """Create a CalendarEvent from a Mealplan.""" + description: str | None = None + name = "No recipe" + if mealplan.recipe: + name = mealplan.recipe.name + description = mealplan.recipe.description + return CalendarEvent( + start=mealplan.mealplan_date, + end=mealplan.mealplan_date, + summary=name, + description=description, + ) + + +class MealieMealplanCalendarEntity(MealieEntity, CalendarEntity): + """A calendar entity.""" + + def __init__( + self, coordinator: MealieCoordinator, entry_type: MealplanEntryType + ) -> None: + """Create the Calendar entity.""" + super().__init__(coordinator) + self._entry_type = entry_type + self._attr_translation_key = entry_type.name.lower() + self._attr_unique_id = ( + f"{self.coordinator.config_entry.entry_id}_{entry_type.name.lower()}" + ) + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + mealplans = self.coordinator.data[self._entry_type] + if not mealplans: + return None + return _get_event_from_mealplan(mealplans[0]) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + mealplans = ( + await self.coordinator.client.get_mealplans( + start_date.date(), end_date.date() + ) + ).items + return [ + _get_event_from_mealplan(mealplan) + for mealplan in mealplans + if mealplan.entry_type is self._entry_type + ] diff --git a/homeassistant/components/mealie/config_flow.py b/homeassistant/components/mealie/config_flow.py new file mode 100644 index 00000000000..b25cade148a --- /dev/null +++ b/homeassistant/components/mealie/config_flow.py @@ -0,0 +1,55 @@ +"""Config flow for Mealie.""" + +from typing import Any + +from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN, CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + +SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_TOKEN): str, + } +) + + +class MealieConfigFlow(ConfigFlow, domain=DOMAIN): + """Mealie config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + client = MealieClient( + user_input[CONF_HOST], + token=user_input[CONF_API_TOKEN], + session=async_get_clientsession(self.hass), + ) + try: + await client.get_mealplan_today() + except MealieConnectionError: + errors["base"] = "cannot_connect" + except MealieAuthenticationError: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Mealie", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py new file mode 100644 index 00000000000..28c64d3c0f0 --- /dev/null +++ b/homeassistant/components/mealie/const.py @@ -0,0 +1,7 @@ +"""Constants for the Mealie integration.""" + +import logging + +DOMAIN = "mealie" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py new file mode 100644 index 00000000000..0c32544d4d7 --- /dev/null +++ b/homeassistant/components/mealie/coordinator.py @@ -0,0 +1,65 @@ +"""Define an object to manage fetching Mealie data.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from aiomealie import ( + MealieAuthenticationError, + MealieClient, + MealieConnectionError, + Mealplan, + MealplanEntryType, +) + +from homeassistant.const import CONF_API_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import LOGGER + +if TYPE_CHECKING: + from . import MealieConfigEntry + +WEEK = timedelta(days=7) + + +class MealieCoordinator(DataUpdateCoordinator[dict[MealplanEntryType, list[Mealplan]]]): + """Class to manage fetching Mealie data.""" + + config_entry: MealieConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize coordinator.""" + super().__init__( + hass, logger=LOGGER, name="Mealie", update_interval=timedelta(hours=1) + ) + self.client = MealieClient( + self.config_entry.data[CONF_HOST], + token=self.config_entry.data[CONF_API_TOKEN], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> dict[MealplanEntryType, list[Mealplan]]: + next_week = dt_util.now() + WEEK + try: + data = ( + await self.client.get_mealplans(dt_util.now().date(), next_week.date()) + ).items + except MealieAuthenticationError as error: + raise ConfigEntryError("Authentication failed") from error + except MealieConnectionError as error: + raise UpdateFailed(error) from error + res: dict[MealplanEntryType, list[Mealplan]] = { + MealplanEntryType.BREAKFAST: [], + MealplanEntryType.LUNCH: [], + MealplanEntryType.DINNER: [], + MealplanEntryType.SIDE: [], + } + for meal in data: + res[meal.entry_type].append(meal) + return res diff --git a/homeassistant/components/mealie/entity.py b/homeassistant/components/mealie/entity.py new file mode 100644 index 00000000000..5e339c1d4b8 --- /dev/null +++ b/homeassistant/components/mealie/entity.py @@ -0,0 +1,21 @@ +"""Base class for Mealie entities.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MealieCoordinator + + +class MealieEntity(CoordinatorEntity[MealieCoordinator]): + """Defines a base Mealie entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: MealieCoordinator) -> None: + """Initialize Mealie entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json new file mode 100644 index 00000000000..fb81ff850b8 --- /dev/null +++ b/homeassistant/components/mealie/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "mealie", + "name": "Mealie", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mealie", + "integration_type": "service", + "iot_class": "local_polling", + "requirements": ["aiomealie==0.4.0"] +} diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json new file mode 100644 index 00000000000..0d67bb89759 --- /dev/null +++ b/homeassistant/components/mealie/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "api_token": "[%key:common::config_flow::data::api_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_service%]" + } + }, + "entity": { + "calendar": { + "breakfast": { + "name": "Breakfast" + }, + "dinner": { + "name": "Dinner" + }, + "lunch": { + "name": "Lunch" + }, + "side": { + "name": "Side" + } + } + } +} diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index 0f2bb35755f..a7ba3ba1498 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -84,7 +84,7 @@ class MeaterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ServiceUnavailableError: errors["base"] = "service_unavailable_error" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown_auth_error" else: data = {"username": username, "password": password} diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index f719cb0f0e3..2a26d848ac2 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -147,7 +147,7 @@ async def async_setup_entry( def async_update_data(): """Handle updated data from the API endpoint.""" if not coordinator.last_update_success: - return + return None devices = coordinator.data entities = [] diff --git a/homeassistant/components/medcom_ble/config_flow.py b/homeassistant/components/medcom_ble/config_flow.py index a50a5876cc7..fc5bab1734b 100644 --- a/homeassistant/components/medcom_ble/config_flow.py +++ b/homeassistant/components/medcom_ble/config_flow.py @@ -136,7 +136,7 @@ class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") except AbortFlow: raise - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error occurred reading information from %s", self._discovery_info.address, diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 56b768c26a2..b8bb5f98cd0 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -16,8 +16,10 @@ from homeassistant.components.media_player import ( MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, SERVICE_PLAY_MEDIA, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, ServiceCall, ServiceResponse, @@ -25,6 +27,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import ( @@ -55,16 +58,49 @@ CONFIG_SCHEMA = vol.Schema( ) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Media Extractor from a config entry.""" + + return True + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media extractor service.""" - async def extract_media_url(call: ServiceCall) -> ServiceResponse: - """Extract media url.""" - youtube_dl = YoutubeDL( - {"quiet": True, "logger": _LOGGER, "format": call.data[ATTR_FORMAT_QUERY]} + if DOMAIN in config: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Media extractor", + }, ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + ) + ) + + async def extract_media_url(call: ServiceCall) -> ServiceResponse: + """Extract media url.""" + def extract_info() -> dict[str, Any]: + youtube_dl = YoutubeDL( + { + "quiet": True, + "logger": _LOGGER, + "format": call.data[ATTR_FORMAT_QUERY], + } + ) return cast( dict[str, Any], youtube_dl.extract_info( @@ -93,7 +129,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def play_media(call: ServiceCall) -> None: """Get stream URL and send it to the play_media service.""" - MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() + MediaExtractor(hass, config.get(DOMAIN, {}), call.data).extract_and_send() default_format_query = config.get(DOMAIN, {}).get( CONF_DEFAULT_STREAM_QUERY, DEFAULT_STREAM_QUERY diff --git a/homeassistant/components/media_extractor/config_flow.py b/homeassistant/components/media_extractor/config_flow.py new file mode 100644 index 00000000000..4343d0551e0 --- /dev/null +++ b/homeassistant/components/media_extractor/config_flow.py @@ -0,0 +1,32 @@ +"""Config flow for Media Extractor integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DOMAIN + + +class MediaExtractorConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Media Extractor.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is not None: + return self.async_create_entry(title="Media extractor", data={}) + + return self.async_show_form(step_id="user", data_schema=vol.Schema({})) + + async def async_step_import( + self, import_config: dict[str, Any] + ) -> ConfigFlowResult: + """Handle import.""" + return self.async_create_entry(title="Media extractor", data={}) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 940d1d7bb18..7ed4e93bb56 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,10 +2,12 @@ "domain": "media_extractor", "name": "Media Extractor", "codeowners": ["@joostlek"], + "config_flow": true, "dependencies": ["media_player"], "documentation": "https://www.home-assistant.io/integrations/media_extractor", "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.04.09"] + "requirements": ["yt-dlp==2024.05.27"], + "single_config_entry": true } diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 1af84b5b8c8..125aa08337a 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -1,4 +1,11 @@ { + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + } + }, "services": { "play_media": { "name": "Play media", @@ -16,7 +23,7 @@ }, "extract_media_url": { "name": "Get Media URL", - "description": "Extract media url from a service.", + "description": "Extract media URL from a service.", "fields": { "url": { "name": "Media URL", diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index b90de95a489..3679b5f89c5 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -64,7 +64,6 @@ from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import group as group_pre_import # noqa: F401 from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401 from .const import ( # noqa: F401 ATTR_APP_ID, diff --git a/homeassistant/components/media_player/group.py b/homeassistant/components/media_player/group.py deleted file mode 100644 index 1987ecf3470..00000000000 --- a/homeassistant/components/media_player/group.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import ( - STATE_IDLE, - STATE_OFF, - STATE_ON, - STATE_PAUSED, - STATE_PLAYING, -) -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states( - DOMAIN, - { - STATE_ON, - STATE_PAUSED, - STATE_PLAYING, - STATE_IDLE, - }, - STATE_ON, - STATE_OFF, - ) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index b0c0e7f559e..77220a87622 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -1,38 +1,87 @@ """Intents for the media_player integration.""" +from collections.abc import Iterable +from dataclasses import dataclass, field +import time + import voluptuous as vol from homeassistant.const import ( SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import intent from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN +from .const import MediaPlayerEntityFeature, MediaPlayerState INTENT_MEDIA_PAUSE = "HassMediaPause" INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" +INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +@dataclass +class LastPaused: + """Information about last media players that were paused by voice.""" + + timestamp: float | None = None + context: Context | None = None + entity_ids: set[str] = field(default_factory=set) + + def clear(self) -> None: + """Clear timestamp and entities.""" + self.timestamp = None + self.context = None + self.entity_ids.clear() + + def update(self, context: Context | None, entity_ids: Iterable[str]) -> None: + """Update last paused group.""" + self.context = context + self.entity_ids = set(entity_ids) + if self.entity_ids: + self.timestamp = time.time() + + def __bool__(self) -> bool: + """Return True if timestamp is set.""" + return self.timestamp is not None + + async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the media_player intents.""" + last_paused = LastPaused() + + intent.async_register(hass, MediaUnpauseHandler(last_paused)) + intent.async_register(hass, MediaPauseHandler(last_paused)) intent.async_register( hass, - intent.ServiceIntentHandler(INTENT_MEDIA_UNPAUSE, DOMAIN, SERVICE_MEDIA_PLAY), - ) - intent.async_register( - hass, - intent.ServiceIntentHandler(INTENT_MEDIA_PAUSE, DOMAIN, SERVICE_MEDIA_PAUSE), + intent.ServiceIntentHandler( + INTENT_MEDIA_NEXT, + DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + required_domains={DOMAIN}, + required_features=MediaPlayerEntityFeature.NEXT_TRACK, + required_states={MediaPlayerState.PLAYING}, + description="Skips a media player to the next item", + platforms={DOMAIN}, + ), ) intent.async_register( hass, intent.ServiceIntentHandler( - INTENT_MEDIA_NEXT, DOMAIN, SERVICE_MEDIA_NEXT_TRACK + INTENT_MEDIA_PREVIOUS, + DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + required_domains={DOMAIN}, + required_features=MediaPlayerEntityFeature.PREVIOUS_TRACK, + required_states={MediaPlayerState.PLAYING}, + description="Replays the previous item for a media player", + platforms={DOMAIN}, ), ) intent.async_register( @@ -41,10 +90,114 @@ async def async_setup_intents(hass: HomeAssistant) -> None: INTENT_SET_VOLUME, DOMAIN, SERVICE_VOLUME_SET, - extra_slots={ + required_domains={DOMAIN}, + required_states={MediaPlayerState.PLAYING}, + required_features=MediaPlayerEntityFeature.VOLUME_SET, + required_slots={ ATTR_MEDIA_VOLUME_LEVEL: vol.All( - vol.Range(min=0, max=100), lambda val: val / 100 + vol.Coerce(int), vol.Range(min=0, max=100), lambda val: val / 100 ) }, + description="Sets the volume of a media player", + platforms={DOMAIN}, ), ) + + +class MediaPauseHandler(intent.ServiceIntentHandler): + """Handler for pause intent. Records last paused media players.""" + + platforms = {DOMAIN} + + def __init__(self, last_paused: LastPaused) -> None: + """Initialize handler.""" + super().__init__( + INTENT_MEDIA_PAUSE, + DOMAIN, + SERVICE_MEDIA_PAUSE, + required_domains={DOMAIN}, + required_features=MediaPlayerEntityFeature.PAUSE, + required_states={MediaPlayerState.PLAYING}, + description="Pauses a media player", + ) + self.last_paused = last_paused + + async def async_handle_states( + self, + intent_obj: intent.Intent, + match_result: intent.MatchTargetsResult, + match_constraints: intent.MatchTargetsConstraints, + match_preferences: intent.MatchTargetsPreferences | None = None, + ) -> intent.IntentResponse: + """Record last paused media players.""" + if match_result.is_match: + # Save entity ids of paused media players + self.last_paused.update( + intent_obj.context, (s.entity_id for s in match_result.states) + ) + + return await super().async_handle_states( + intent_obj, match_result, match_constraints + ) + + +class MediaUnpauseHandler(intent.ServiceIntentHandler): + """Handler for unpause/resume intent. Uses last paused media players.""" + + platforms = {DOMAIN} + + def __init__(self, last_paused: LastPaused) -> None: + """Initialize handler.""" + super().__init__( + INTENT_MEDIA_UNPAUSE, + DOMAIN, + SERVICE_MEDIA_PLAY, + required_domains={DOMAIN}, + required_states={MediaPlayerState.PAUSED}, + description="Resumes a media player", + ) + self.last_paused = last_paused + + async def async_handle_states( + self, + intent_obj: intent.Intent, + match_result: intent.MatchTargetsResult, + match_constraints: intent.MatchTargetsConstraints, + match_preferences: intent.MatchTargetsPreferences | None = None, + ) -> intent.IntentResponse: + """Unpause last paused media players.""" + if match_result.is_match and (not match_constraints.name) and self.last_paused: + assert self.last_paused.timestamp is not None + + # Check for a media player that was paused more recently than the + # ones by voice. + recent_state: State | None = None + for state in match_result.states: + if (state.last_changed_timestamp <= self.last_paused.timestamp) or ( + state.context == self.last_paused.context + ): + continue + + if (recent_state is None) or ( + state.last_changed_timestamp > recent_state.last_changed_timestamp + ): + recent_state = state + + if recent_state is not None: + # Resume the more recently paused media player (outside of voice). + match_result.states = [recent_state] + else: + # Resume only the previously paused media players if they are in the + # targeted set. + targeted_ids = {s.entity_id for s in match_result.states} + overlapping_ids = targeted_ids.intersection(self.last_paused.entity_ids) + if overlapping_ids: + match_result.states = [ + s for s in match_result.states if s.entity_id in overlapping_ids + ] + + self.last_paused.clear() + + return await super().async_handle_states( + intent_obj, match_result, match_constraints + ) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index bcf594a2675..ff246e420ce 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -17,6 +17,9 @@ "paused": "{entity_name} is paused", "playing": "{entity_name} starts playing", "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 2f996523fdc..928e46ab528 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from typing import Any, Protocol import voluptuous as vol @@ -58,6 +58,13 @@ __all__ = [ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +class MediaSourceProtocol(Protocol): + """Define the format of media_source platforms.""" + + async def async_get_media_source(self, hass: HomeAssistant) -> MediaSource: + """Set up media source.""" + + def is_media_source_id(media_content_id: str) -> bool: """Test if identifier is a media source.""" return URI_SCHEME_REGEX.match(media_content_id) is not None @@ -87,7 +94,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _process_media_source_platform( - hass: HomeAssistant, domain: str, platform: Any + hass: HomeAssistant, + domain: str, + platform: MediaSourceProtocol, ) -> None: """Process a media source platform.""" hass.data[DOMAIN][domain] = await platform.async_get_media_source(hass) diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index a1685df285e..dff851896dd 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -257,7 +257,7 @@ class UploadMediaView(http.HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Handle upload.""" # Increase max payload - request._client_max_size = MAX_UPLOAD_SIZE # pylint: disable=protected-access + request._client_max_size = MAX_UPLOAD_SIZE # noqa: SLF001 try: data = self.schema(dict(await request.post())) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index f071b64988d..c4392535364 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -25,7 +25,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 - entry: ConfigEntry | None = None async def _create_entry(self, username: str, token: str) -> ConfigFlowResult: @@ -148,3 +147,66 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" return acquired_token, errors + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors: dict[str, str] = {} + acquired_token = None + assert self.entry + + if user_input is not None: + user_input[CONF_USERNAME] = self.entry.data[CONF_USERNAME] + 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, + ) + or isinstance(err, AttributeError) + and err.name == "get" + ): + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ( + TimeoutError, + ClientError, + ): + errors["base"] = "cannot_connect" + + if not errors: + user_input[CONF_TOKEN] = acquired_token + return self.async_update_reload_and_abort( + self.entry, + data={**self.entry.data, **user_input}, + reason="reconfigure_successful", + ) + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + description_placeholders={CONF_USERNAME: self.entry.data[CONF_USERNAME]}, + ) diff --git a/homeassistant/components/melcloud/diagnostics.py b/homeassistant/components/melcloud/diagnostics.py new file mode 100644 index 00000000000..8c2ad0818ff --- /dev/null +++ b/homeassistant/components/melcloud/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for MelCloud.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +TO_REDACT = { + CONF_USERNAME, + CONF_TOKEN, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + ent_reg = er.async_get(hass) + entities = [ + entity.entity_id + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ] + + entity_states = {entity: hass.states.get(entity) for entity in entities} + + entry_dict = entry.as_dict() + if "data" in entry_dict: + entry_dict["data"] = async_redact_data(entry_dict["data"], TO_REDACT) + + return { + "entry": entry_dict, + "entities": entity_states, + } diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 0122c840373..f61ed412be1 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -1,7 +1,7 @@ { "domain": "melcloud", "name": "MELCloud", - "codeowners": [], + "codeowners": ["@erwindouna"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", "iot_class": "cloud_polling", diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 6a98b88e2d3..968f9cf4e50 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -16,6 +16,16 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reconfigure_confirm": { + "title": "Reconfigure your MelCloud", + "description": "Reconfigure the entry to obtain a new token, for your account: `{username}`.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the (new) password for MelCloud." + } } }, "error": { @@ -25,7 +35,8 @@ }, "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." + "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "services": { diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 9a15e81dc22..afaf8eb95f8 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN -from .models import MelnorDataUpdateCoordinator +from .coordinator import MelnorDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.NUMBER, diff --git a/homeassistant/components/melnor/coordinator.py b/homeassistant/components/melnor/coordinator.py new file mode 100644 index 00000000000..669fe916082 --- /dev/null +++ b/homeassistant/components/melnor/coordinator.py @@ -0,0 +1,33 @@ +"""Coordinator for the Melnor integration.""" + +from datetime import timedelta +import logging + +from melnor_bluetooth.device import Device + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): + """Melnor data update coordinator.""" + + _device: Device + + def __init__(self, hass: HomeAssistant, device: Device) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Melnor Bluetooth", + update_interval=timedelta(seconds=5), + ) + self._device = device + + async def _async_update_data(self): + """Update the device state.""" + + await self._device.fetch_state() + return self._device diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py index ffcccccb789..377a758a2be 100644 --- a/homeassistant/components/melnor/models.py +++ b/homeassistant/components/melnor/models.py @@ -1,48 +1,19 @@ """Melnor integration models.""" from collections.abc import Callable -from datetime import timedelta -import logging -from typing import TypeVar from melnor_bluetooth.device import Device, Valve from homeassistant.components.number import EntityDescription -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import MelnorDataUpdateCoordinator -class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): # pylint: disable=hass-enforce-coordinator-module - """Melnor data update coordinator.""" - - _device: Device - - def __init__(self, hass: HomeAssistant, device: Device) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Melnor Bluetooth", - update_interval=timedelta(seconds=5), - ) - self._device = device - - async def _async_update_data(self): - """Update the device state.""" - - await self._device.fetch_state() - return self._device - - -class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module +class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): """Base class for melnor entities.""" _device: Device @@ -105,14 +76,11 @@ class MelnorZoneEntity(MelnorBluetoothEntity): ) -T = TypeVar("T", bound=EntityDescription) - - -def get_entities_for_valves( +def get_entities_for_valves[_T: EntityDescription]( coordinator: MelnorDataUpdateCoordinator, - descriptions: list[T], + descriptions: list[_T], function: Callable[ - [Valve, T], + [Valve, _T], CoordinatorEntity[MelnorDataUpdateCoordinator], ], ) -> list[CoordinatorEntity[MelnorDataUpdateCoordinator]]: diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index 33d9fa443b1..beaa0fd913b 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -19,11 +19,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .models import ( - MelnorDataUpdateCoordinator, - MelnorZoneEntity, - get_entities_for_valves, -) +from .coordinator import MelnorDataUpdateCoordinator +from .models import MelnorZoneEntity, get_entities_for_valves @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 6528773d9d8..233dada8ab2 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -27,12 +27,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import DOMAIN -from .models import ( - MelnorBluetoothEntity, - MelnorDataUpdateCoordinator, - MelnorZoneEntity, - get_entities_for_valves, -) +from .coordinator import MelnorDataUpdateCoordinator +from .models import MelnorBluetoothEntity, MelnorZoneEntity, get_entities_for_valves def watering_seconds_left(valve: Valve) -> datetime | None: diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index f912db1e981..efa779f04b0 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -18,11 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .models import ( - MelnorDataUpdateCoordinator, - MelnorZoneEntity, - get_entities_for_valves, -) +from .coordinator import MelnorDataUpdateCoordinator +from .models import MelnorZoneEntity, get_entities_for_valves @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index d2d05f6517f..373a22c8ff4 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -16,11 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .models import ( - MelnorDataUpdateCoordinator, - MelnorZoneEntity, - get_entities_for_valves, -) +from .coordinator import MelnorDataUpdateCoordinator +from .models import MelnorZoneEntity, get_entities_for_valves @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 58da08d984c..a6eefe7345f 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType CONF_VALIDATOR = "validator" CONF_SECRET = "secret" URL = "/api/meraki" -VERSION = "2.0" +ACCEPTED_VERSIONS = ["2.0", "2.1"] _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ class MerakiView(HomeAssistantView): if data["secret"] != self.secret: _LOGGER.error("Invalid Secret received from Meraki") return self.json_message("Invalid secret", HTTPStatus.UNPROCESSABLE_ENTITY) - if data["version"] != VERSION: + if data["version"] not in ACCEPTED_VERSIONS: _LOGGER.error("Invalid API version: %s", data["version"]) return self.json_message("Invalid version", HTTPStatus.UNPROCESSABLE_ENTITY) _LOGGER.debug("Valid Secret") @@ -86,7 +86,7 @@ class MerakiView(HomeAssistantView): _LOGGER.debug("Processing %s", data["type"]) if not data["data"]["observations"]: _LOGGER.debug("No observations found") - return + return None self._handle(request.app[KEY_HASS], data) @callback diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 540a7867203..1cd7a4bde57 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -21,7 +21,7 @@ PLATFORMS = [Platform.WEATHER] _LOGGER = logging.getLogger(__name__) -MetWeatherConfigEntry = ConfigEntry[MetDataUpdateCoordinator] +type MetWeatherConfigEntry = ConfigEntry[MetDataUpdateCoordinator] async def async_setup_entry( diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index ef73e1b52ab..3887a29f83c 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -80,7 +80,7 @@ class MetWeatherData: if not resp: raise CannotConnect self.current_weather_data = self._weather_data.get_current_weather() - time_zone = dt_util.DEFAULT_TIME_ZONE + time_zone = dt_util.get_default_time_zone() self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) return self diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 92f2ffcfac6..7d0e6401bd6 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -86,7 +86,7 @@ class MetEireannWeatherData: """Fetch data from API - (current weather and forecast).""" await self._weather_data.fetching_data() self.current_weather_data = self._weather_data.get_current_weather() - time_zone = dt_util.DEFAULT_TIME_ZONE + time_zone = dt_util.get_default_time_zone() self.daily_forecast = self._weather_data.get_forecast(time_zone, False) self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) return self diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 23ea6bb1500..d8dbdfc4265 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, TypeVar +from typing import Any from meteofrance_api.helpers import ( get_warning_text_status_from_indice_color, @@ -49,8 +49,6 @@ from .const import ( MODEL, ) -_DataT = TypeVar("_DataT", bound=Rain | Forecast | CurrentPhenomenons) - @dataclass(frozen=True, kw_only=True) class MeteoFranceSensorEntityDescription(SensorEntityDescription): @@ -226,7 +224,9 @@ async def async_setup_entry( async_add_entities(entities, False) -class MeteoFranceSensor(CoordinatorEntity[DataUpdateCoordinator[_DataT]], SensorEntity): +class MeteoFranceSensor[_DataT: Rain | Forecast | CurrentPhenomenons]( + CoordinatorEntity[DataUpdateCoordinator[_DataT]], SensorEntity +): """Representation of a Meteo-France sensor.""" entity_description: MeteoFranceSensorEntityDescription diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 9edc557aafc..943d30fccfd 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -200,7 +200,7 @@ class MeteoFranceWeather( break forecast_data.append( { - ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + ATTR_FORECAST_TIME: dt_util.utc_from_timestamp( forecast["dt"] ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 8b38ac6dbb3..8fb0ae5cdc8 100644 --- a/homeassistant/components/meteoalarm/binary_sensor.py +++ b/homeassistant/components/meteoalarm/binary_sensor.py @@ -30,7 +30,7 @@ CONF_PROVINCE = "province" DEFAULT_NAME = "meteoalarm" -SCAN_INTERVAL = timedelta(minutes=30) +SCAN_INTERVAL = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/metoffice/config_flow.py b/homeassistant/components/metoffice/config_flow.py index 8b3c10cd460..d46e537dadb 100644 --- a/homeassistant/components/metoffice/config_flow.py +++ b/homeassistant/components/metoffice/config_flow.py @@ -61,7 +61,7 @@ class MetOfficeConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/microbees/climate.py b/homeassistant/components/microbees/climate.py new file mode 100644 index 00000000000..077048ee352 --- /dev/null +++ b/homeassistant/components/microbees/climate.py @@ -0,0 +1,145 @@ +"""Climate integration microBees.""" + +from typing import Any + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import MicroBeesUpdateCoordinator +from .entity import MicroBeesActuatorEntity + +CLIMATE_PRODUCT_IDS = { + 76, # Thermostat, + 78, # Thermovalve, +} +THERMOSTAT_SENSOR_ID = 762 +THERMOVALVE_SENSOR_ID = 782 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the microBees climate platform.""" + coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ].coordinator + async_add_entities( + MBClimate( + coordinator, + bee_id, + bee.actuators[0].id, + next( + sensor.id + for sensor in bee.sensors + if sensor.deviceID + == ( + THERMOSTAT_SENSOR_ID + if bee.productID == 76 + else THERMOVALVE_SENSOR_ID + ) + ), + ) + for bee_id, bee in coordinator.data.bees.items() + if bee.productID in CLIMATE_PRODUCT_IDS + ) + + +class MBClimate(MicroBeesActuatorEntity, ClimateEntity): + """Representation of a microBees climate.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature_step = 0.5 + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_fan_modes = None + _attr_min_temp = 15 + _attr_max_temp = 35 + _attr_name = None + + def __init__( + self, + coordinator: MicroBeesUpdateCoordinator, + bee_id: int, + actuator_id: int, + sensor_id: int, + ) -> None: + """Initialize the microBees climate.""" + super().__init__(coordinator, bee_id, actuator_id) + self.sensor_id = sensor_id + + @property + def current_temperature(self) -> float | None: + """Return the sensor temperature.""" + return self.coordinator.data.sensors[self.sensor_id].value + + @property + def hvac_mode(self) -> HVACMode | None: + """Return current hvac operation i.e. heat, cool mode.""" + if self.actuator.value == 1: + return HVACMode.HEAT + return HVACMode.OFF + + @property + def target_temperature(self) -> float | None: + """Return the current target temperature.""" + return self.bee.instanceData.targetTemp + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + send_command = await self.coordinator.microbees.sendCommand( + self.actuator_id, self.actuator.value, temperature=temperature + ) + + if not send_command: + raise HomeAssistantError(f"Failed to set temperature {self.name}") + + self.bee.instanceData.targetTemp = temperature + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode, **kwargs: Any) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + return await self.async_turn_off() + return await self.async_turn_on() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the climate.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + send_command = await self.coordinator.microbees.sendCommand( + self.actuator_id, 1, temperature=temperature + ) + + if not send_command: + raise HomeAssistantError(f"Failed to set temperature {self.name}") + + self.actuator.value = 1 + self._attr_hvac_mode = HVACMode.HEAT + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the climate.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + send_command = await self.coordinator.microbees.sendCommand( + self.actuator_id, 0, temperature=temperature + ) + + if not send_command: + raise HomeAssistantError(f"Failed to set temperature {self.name}") + + self.actuator.value = 0 + self._attr_hvac_mode = HVACMode.OFF + self.async_write_ha_state() diff --git a/homeassistant/components/microbees/config_flow.py b/homeassistant/components/microbees/config_flow.py index c54f8939145..4d0f5b4474b 100644 --- a/homeassistant/components/microbees/config_flow.py +++ b/homeassistant/components/microbees/config_flow.py @@ -45,7 +45,7 @@ class OAuth2FlowHandler( current_user = await microbees.getMyProfile() except MicroBeesException: return self.async_abort(reason="invalid_auth") - except Exception: # pylint: disable=broad-except + except Exception: self.logger.exception("Unexpected error") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/microbees/const.py b/homeassistant/components/microbees/const.py index ab8637f0f75..faeefbfc10e 100644 --- a/homeassistant/components/microbees/const.py +++ b/homeassistant/components/microbees/const.py @@ -8,6 +8,7 @@ OAUTH2_TOKEN = "https://dev.microbees.com/oauth/token" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.SENSOR, diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 76d9a57c7ef..9f2b40bf1c8 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -7,15 +7,19 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ATTR_MANUFACTURER, DOMAIN +from .coordinator import MikrotikDataUpdateCoordinator, get_api from .errors import CannotConnect, LoginError -from .hub import MikrotikDataUpdateCoordinator, get_api CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) PLATFORMS = [Platform.DEVICE_TRACKER] +type MikrotikConfigEntry = ConfigEntry[MikrotikDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: MikrotikConfigEntry +) -> bool: """Set up the Mikrotik component.""" try: api = await hass.async_add_executor_job(get_api, dict(config_entry.data)) @@ -28,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.async_add_executor_job(coordinator.api.get_hub_details) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -47,9 +51,4 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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 + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 8e5ff50e590..fe0d020d373 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -31,8 +31,8 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) +from .coordinator import get_api from .errors import CannotConnect, LoginError -from .hub import get_api class MikrotikFlowHandler(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/coordinator.py similarity index 99% rename from homeassistant/components/mikrotik/hub.py rename to homeassistant/components/mikrotik/coordinator.py index 2830372f882..6cb36d58fbe 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -243,7 +243,7 @@ class MikrotikData: return [] -class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module +class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): """Mikrotik Hub Object.""" def __init__( diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 866eba0b8bb..aa19da01369 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -9,26 +9,23 @@ from homeassistant.components.device_tracker import ( ScannerEntity, SourceType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util -from .const import DOMAIN -from .hub import Device, MikrotikDataUpdateCoordinator +from . import MikrotikConfigEntry +from .coordinator import Device, MikrotikDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MikrotikConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for Mikrotik component.""" - coordinator: MikrotikDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {} diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index b2f06597563..11199e126cf 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import timedelta -import logging from mill import Mill from mill_local import Mill as MillLocal @@ -13,37 +12,13 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, P from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CLOUD, CONNECTION_TYPE, DOMAIN, LOCAL - -_LOGGER = logging.getLogger(__name__) +from .coordinator import MillDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -class MillDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching Mill data.""" - - def __init__( - self, - hass: HomeAssistant, - update_interval: timedelta | None = None, - *, - mill_data_connection: Mill | MillLocal, - ) -> None: - """Initialize global Mill data updater.""" - self.mill_data_connection = mill_data_connection - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_method=mill_data_connection.fetch_heater_and_sensor_data, - update_interval=update_interval, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Mill heater.""" hass.data.setdefault(DOMAIN, {LOCAL: {}, CLOUD: {}}) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index a2e70b8f9c8..5c5c7882634 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -26,7 +26,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import MillDataUpdateCoordinator from .const import ( ATTR_AWAY_TEMP, ATTR_COMFORT_TEMP, @@ -41,6 +40,7 @@ from .const import ( MIN_TEMP, SERVICE_SET_ROOM_TEMP, ) +from .coordinator import MillDataUpdateCoordinator SET_ROOM_TEMP_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py new file mode 100644 index 00000000000..9821519ca84 --- /dev/null +++ b/homeassistant/components/mill/coordinator.py @@ -0,0 +1,38 @@ +"""Coordinator for the mill component.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from mill import Mill +from mill_local import Mill as MillLocal + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MillDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Mill data.""" + + def __init__( + self, + hass: HomeAssistant, + update_interval: timedelta | None = None, + *, + mill_data_connection: Mill | MillLocal, + ) -> None: + """Initialize global Mill data updater.""" + self.mill_data_connection = mill_data_connection + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_method=mill_data_connection.fetch_heater_and_sensor_data, + update_interval=update_interval, + ) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 654d903068f..3ffdc33f3b2 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -32,6 +32,9 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: address = user_input[CONF_ADDRESS] + # Abort config flow if service is already configured. + self._async_abort_entries_match({CONF_ADDRESS: address}) + # Prepare config entry data. config_data = { CONF_NAME: user_input[CONF_NAME], diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index a00936852f0..8e098f98a15 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/minecraft_server", "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], - "quality_scale": "gold", + "quality_scale": "platinum", "requirements": ["mcstatus==11.1.1"] } diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index 622a45a5aeb..c084c9e6df0 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -10,6 +10,9 @@ } } }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, "error": { "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version 1.7." } diff --git a/homeassistant/components/minio/minio_helper.py b/homeassistant/components/minio/minio_helper.py index 979de40ece7..bd814bdf349 100644 --- a/homeassistant/components/minio/minio_helper.py +++ b/homeassistant/components/minio/minio_helper.py @@ -46,8 +46,7 @@ def get_minio_notification_response( ): """Start listening to minio events. Copied from minio-py.""" query = {"prefix": prefix, "suffix": suffix, "events": events} - # pylint: disable-next=protected-access - return minio_client._url_open( + return minio_client._url_open( # noqa: SLF001 "GET", bucket_name=bucket_name, query=query, preload_content=False ) @@ -161,8 +160,7 @@ class MinioEventThread(threading.Thread): presigned_url = minio_client.presigned_get_object(bucket, key) # Fail gracefully. If for whatever reason this stops working, # it shouldn't prevent it from firing events. - # pylint: disable-next=broad-except - except Exception as error: + except Exception as error: # noqa: BLE001 _LOGGER.error("Failed to generate presigned url: %s", error) queue_entry = { diff --git a/homeassistant/components/moat/sensor.py b/homeassistant/components/moat/sensor.py index 3118c539d3a..66edfbe91f2 100644 --- a/homeassistant/components/moat/sensor.py +++ b/homeassistant/components/moat/sensor.py @@ -121,7 +121,9 @@ async def async_setup_entry( class MoatBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a moat ble sensor.""" diff --git a/homeassistant/components/mobile_app/strings.json b/homeassistant/components/mobile_app/strings.json index 9e388ebc76c..3d3e0767312 100644 --- a/homeassistant/components/mobile_app/strings.json +++ b/homeassistant/components/mobile_app/strings.json @@ -13,6 +13,10 @@ "device_automation": { "action_type": { "notify": "Send a notification" + }, + "extra_fields": { + "message": "Message", + "title": "Title" } } } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index a5c0867dedb..82caa772ac4 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -337,7 +337,7 @@ class ModbusHub: try: await self._client.connect() # type: ignore[union-attr] except ModbusException as exception_error: - err = f"{self.name} connect failed, retry in pymodbus ({str(exception_error)})" + err = f"{self.name} connect failed, retry in pymodbus ({exception_error!s})" self._log_error(err, error_state=False) return message = f"modbus {self.name} communication open" @@ -404,9 +404,7 @@ class ModbusHub: try: result: ModbusResponse = await entry.func(address, value, **kwargs) except ModbusException as exception_error: - error = ( - f"Error: device: {slave} address: {address} -> {str(exception_error)}" - ) + error = f"Error: device: {slave} address: {address} -> {exception_error!s}" self._log_error(error) return None if not result: @@ -416,7 +414,7 @@ class ModbusHub: self._log_error(error) return None if not hasattr(result, entry.attr): - error = f"Error: device: {slave} address: {address} -> {str(result)}" + error = f"Error: device: {slave} address: {address} -> {result!s}" self._log_error(error) return None if result.isError(): diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 5071d098db7..5220891ac27 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -183,9 +183,7 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: try: size = struct.calcsize(structure) except struct.error as err: - raise vol.Invalid( - f"{name}: error in structure format --> {str(err)}" - ) from err + raise vol.Invalid(f"{name}: error in structure format --> {err!s}") from err bytecount = count * 2 if bytecount != size: raise vol.Invalid( diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index 5b33a85578c..dea7d4fadea 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -3,36 +3,20 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from datetime import timedelta import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate -from aiomodernforms import ( - ModernFormsConnectionError, - ModernFormsDevice, - ModernFormsError, -) -from aiomodernforms.models import Device as ModernFormsDeviceState +from aiomodernforms import ModernFormsConnectionError, ModernFormsError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import ModernFormsDataUpdateCoordinator -_ModernFormsDeviceEntityT = TypeVar( - "_ModernFormsDeviceEntityT", bound="ModernFormsDeviceEntity" -) -_P = ParamSpec("_P") - -SCAN_INTERVAL = timedelta(seconds=5) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.FAN, @@ -72,7 +56,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def modernforms_exception_handler( +def modernforms_exception_handler[ + _ModernFormsDeviceEntityT: ModernFormsDeviceEntity, + **_P, +]( func: Callable[Concatenate[_ModernFormsDeviceEntityT, _P], Any], ) -> Callable[Concatenate[_ModernFormsDeviceEntityT, _P], Coroutine[Any, Any, None]]: """Decorate Modern Forms calls to handle Modern Forms exceptions. @@ -99,37 +86,6 @@ def modernforms_exception_handler( return handler -class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching Modern Forms data from single endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - *, - host: str, - ) -> None: - """Initialize global Modern Forms data updater.""" - self.modern_forms = ModernFormsDevice( - host, session=async_get_clientsession(hass) - ) - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self) -> ModernFormsDevice: - """Fetch data from Modern Forms.""" - try: - return await self.modern_forms.update( - full_update=not self.last_update_success - ) - except ModernFormsError as error: - raise UpdateFailed(f"Invalid response from API: {error}") from error - - class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]): """Defines a Modern Forms device entity.""" diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index 0322c5e39d7..5fb0096b477 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -8,8 +8,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import ModernFormsDataUpdateCoordinator, ModernFormsDeviceEntity +from . import ModernFormsDeviceEntity from .const import CLEAR_TIMER, DOMAIN +from .coordinator import ModernFormsDataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/modern_forms/coordinator.py b/homeassistant/components/modern_forms/coordinator.py new file mode 100644 index 00000000000..ecd928aa922 --- /dev/null +++ b/homeassistant/components/modern_forms/coordinator.py @@ -0,0 +1,49 @@ +"""Coordinator for the Modern Forms integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiomodernforms import ModernFormsDevice, ModernFormsError +from aiomodernforms.models import Device as ModernFormsDeviceState + +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 DOMAIN + +SCAN_INTERVAL = timedelta(seconds=5) +_LOGGER = logging.getLogger(__name__) + + +class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): + """Class to manage fetching Modern Forms data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + *, + host: str, + ) -> None: + """Initialize global Modern Forms data updater.""" + self.modern_forms = ModernFormsDevice( + host, session=async_get_clientsession(hass) + ) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> ModernFormsDevice: + """Fetch data from Modern Forms.""" + try: + return await self.modern_forms.update( + full_update=not self.last_update_success + ) + except ModernFormsError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index b714cf04879..5f6b699fb47 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -18,11 +18,7 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import ( - ModernFormsDataUpdateCoordinator, - ModernFormsDeviceEntity, - modernforms_exception_handler, -) +from . import ModernFormsDeviceEntity, modernforms_exception_handler from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, @@ -32,6 +28,7 @@ from .const import ( SERVICE_CLEAR_FAN_SLEEP_TIMER, SERVICE_SET_FAN_SLEEP_TIMER, ) +from .coordinator import ModernFormsDataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 3284b96d31f..e758a50e77e 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -17,11 +17,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import ( - ModernFormsDataUpdateCoordinator, - ModernFormsDeviceEntity, - modernforms_exception_handler, -) +from . import ModernFormsDeviceEntity, modernforms_exception_handler from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, @@ -31,6 +27,7 @@ from .const import ( SERVICE_CLEAR_LIGHT_SLEEP_TIMER, SERVICE_SET_LIGHT_SLEEP_TIMER, ) +from .coordinator import ModernFormsDataUpdateCoordinator BRIGHTNESS_RANGE = (1, 255) diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 6a92f0fcac2..851e3092ce5 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -11,8 +11,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import ModernFormsDataUpdateCoordinator, ModernFormsDeviceEntity +from . import ModernFormsDeviceEntity from .const import CLEAR_TIMER, DOMAIN +from .coordinator import ModernFormsDataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index d8c76d733fc..a80115c0f93 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -9,12 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - ModernFormsDataUpdateCoordinator, - ModernFormsDeviceEntity, - modernforms_exception_handler, -) +from . import ModernFormsDeviceEntity, modernforms_exception_handler from .const import DOMAIN +from .coordinator import ModernFormsDataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 1611d8ac4bc..244e3bc701b 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -2,26 +2,17 @@ from __future__ import annotations -from datetime import timedelta -import logging - -import aiohttp from moehlenhoff_alpha2 import Alpha2Base from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import Alpha2BaseCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] -UPDATE_INTERVAL = timedelta(seconds=60) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" @@ -51,114 +42,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): # pylint: disable=hass-enforce-coordinator-module - """Keep the base instance in one place and centralize the update.""" - - def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None: - """Initialize Alpha2Base data updater.""" - self.base = base - super().__init__( - hass=hass, - logger=_LOGGER, - name="alpha2_base", - update_interval=UPDATE_INTERVAL, - ) - - async def _async_update_data(self) -> dict[str, dict[str, dict]]: - """Fetch the latest data from the source.""" - await self.base.update_data() - return { - "heat_areas": {ha["ID"]: ha for ha in self.base.heat_areas if ha.get("ID")}, - "heat_controls": { - hc["ID"]: hc for hc in self.base.heat_controls if hc.get("ID") - }, - "io_devices": {io["ID"]: io for io in self.base.io_devices if io.get("ID")}, - } - - def get_cooling(self) -> bool: - """Return if cooling mode is enabled.""" - return self.base.cooling - - async def async_set_cooling(self, enabled: bool) -> None: - """Enable or disable cooling mode.""" - await self.base.set_cooling(enabled) - self.async_update_listeners() - - async def async_set_target_temperature( - self, heat_area_id: str, target_temperature: float - ) -> None: - """Set the target temperature of the given heat area.""" - _LOGGER.debug( - "Setting target temperature of heat area %s to %0.1f", - heat_area_id, - target_temperature, - ) - - update_data = {"T_TARGET": target_temperature} - is_cooling = self.get_cooling() - heat_area_mode = self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] - if heat_area_mode == 1: - if is_cooling: - update_data["T_COOL_DAY"] = target_temperature - else: - update_data["T_HEAT_DAY"] = target_temperature - elif heat_area_mode == 2: - if is_cooling: - update_data["T_COOL_NIGHT"] = target_temperature - else: - update_data["T_HEAT_NIGHT"] = target_temperature - - try: - await self.base.update_heat_area(heat_area_id, update_data) - except aiohttp.ClientError as http_err: - raise HomeAssistantError( - "Failed to set target temperature, communication error with alpha2 base" - ) from http_err - self.data["heat_areas"][heat_area_id].update(update_data) - self.async_update_listeners() - - async def async_set_heat_area_mode( - self, heat_area_id: str, heat_area_mode: int - ) -> None: - """Set the mode of the given heat area.""" - # HEATAREA_MODE: 0=Auto, 1=Tag, 2=Nacht - if heat_area_mode not in (0, 1, 2): - raise ValueError(f"Invalid heat area mode: {heat_area_mode}") - _LOGGER.debug( - "Setting mode of heat area %s to %d", - heat_area_id, - heat_area_mode, - ) - try: - await self.base.update_heat_area( - heat_area_id, {"HEATAREA_MODE": heat_area_mode} - ) - except aiohttp.ClientError as http_err: - raise HomeAssistantError( - "Failed to set heat area mode, communication error with alpha2 base" - ) from http_err - - self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] = heat_area_mode - is_cooling = self.get_cooling() - if heat_area_mode == 1: - if is_cooling: - self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ - "heat_areas" - ][heat_area_id]["T_COOL_DAY"] - else: - self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ - "heat_areas" - ][heat_area_id]["T_HEAT_DAY"] - elif heat_area_mode == 2: - if is_cooling: - self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ - "heat_areas" - ][heat_area_id]["T_COOL_NIGHT"] - else: - self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ - "heat_areas" - ][heat_area_id]["T_HEAT_NIGHT"] - - self.async_update_listeners() diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 5cdca72fa55..1e7018ff1c7 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Alpha2BaseCoordinator from .const import DOMAIN +from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py index c637909417c..c7ac574724a 100644 --- a/homeassistant/components/moehlenhoff_alpha2/button.py +++ b/homeassistant/components/moehlenhoff_alpha2/button.py @@ -8,8 +8,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import Alpha2BaseCoordinator from .const import DOMAIN +from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 147e4bda2fa..33f17271800 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Alpha2BaseCoordinator from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT +from .coordinator import Alpha2BaseCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/moehlenhoff_alpha2/config_flow.py b/homeassistant/components/moehlenhoff_alpha2/config_flow.py index a2a43c7bc5d..3651885e4e1 100644 --- a/homeassistant/components/moehlenhoff_alpha2/config_flow.py +++ b/homeassistant/components/moehlenhoff_alpha2/config_flow.py @@ -28,7 +28,7 @@ async def validate_input(data: dict[str, Any]) -> dict[str, str]: await base.update_data() except (aiohttp.client_exceptions.ClientConnectorError, TimeoutError): return {"error": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return {"error": "unknown"} diff --git a/homeassistant/components/moehlenhoff_alpha2/coordinator.py b/homeassistant/components/moehlenhoff_alpha2/coordinator.py new file mode 100644 index 00000000000..2bac4b49575 --- /dev/null +++ b/homeassistant/components/moehlenhoff_alpha2/coordinator.py @@ -0,0 +1,128 @@ +"""Coordinator for the Moehlenhoff Alpha2.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import aiohttp +from moehlenhoff_alpha2 import Alpha2Base + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=60) + + +class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Keep the base instance in one place and centralize the update.""" + + def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None: + """Initialize Alpha2Base data updater.""" + self.base = base + super().__init__( + hass=hass, + logger=_LOGGER, + name="alpha2_base", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, dict[str, dict]]: + """Fetch the latest data from the source.""" + await self.base.update_data() + return { + "heat_areas": {ha["ID"]: ha for ha in self.base.heat_areas if ha.get("ID")}, + "heat_controls": { + hc["ID"]: hc for hc in self.base.heat_controls if hc.get("ID") + }, + "io_devices": {io["ID"]: io for io in self.base.io_devices if io.get("ID")}, + } + + def get_cooling(self) -> bool: + """Return if cooling mode is enabled.""" + return self.base.cooling + + async def async_set_cooling(self, enabled: bool) -> None: + """Enable or disable cooling mode.""" + await self.base.set_cooling(enabled) + self.async_update_listeners() + + async def async_set_target_temperature( + self, heat_area_id: str, target_temperature: float + ) -> None: + """Set the target temperature of the given heat area.""" + _LOGGER.debug( + "Setting target temperature of heat area %s to %0.1f", + heat_area_id, + target_temperature, + ) + + update_data = {"T_TARGET": target_temperature} + is_cooling = self.get_cooling() + heat_area_mode = self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] + if heat_area_mode == 1: + if is_cooling: + update_data["T_COOL_DAY"] = target_temperature + else: + update_data["T_HEAT_DAY"] = target_temperature + elif heat_area_mode == 2: + if is_cooling: + update_data["T_COOL_NIGHT"] = target_temperature + else: + update_data["T_HEAT_NIGHT"] = target_temperature + + try: + await self.base.update_heat_area(heat_area_id, update_data) + except aiohttp.ClientError as http_err: + raise HomeAssistantError( + "Failed to set target temperature, communication error with alpha2 base" + ) from http_err + self.data["heat_areas"][heat_area_id].update(update_data) + self.async_update_listeners() + + async def async_set_heat_area_mode( + self, heat_area_id: str, heat_area_mode: int + ) -> None: + """Set the mode of the given heat area.""" + # HEATAREA_MODE: 0=Auto, 1=Tag, 2=Nacht + if heat_area_mode not in (0, 1, 2): + raise ValueError(f"Invalid heat area mode: {heat_area_mode}") + _LOGGER.debug( + "Setting mode of heat area %s to %d", + heat_area_id, + heat_area_mode, + ) + try: + await self.base.update_heat_area( + heat_area_id, {"HEATAREA_MODE": heat_area_mode} + ) + except aiohttp.ClientError as http_err: + raise HomeAssistantError( + "Failed to set heat area mode, communication error with alpha2 base" + ) from http_err + + self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] = heat_area_mode + is_cooling = self.get_cooling() + if heat_area_mode == 1: + if is_cooling: + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_COOL_DAY"] + else: + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_HEAT_DAY"] + elif heat_area_mode == 2: + if is_cooling: + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_COOL_NIGHT"] + else: + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_HEAT_NIGHT"] + + self.async_update_listeners() diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index 2c2e44f451d..5286257ff61 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Alpha2BaseCoordinator from .const import DOMAIN +from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index 7b9113821d1..542e729dbd2 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -85,7 +85,7 @@ class MonoPriceConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_PORT], data=info) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py new file mode 100644 index 00000000000..a88082b2ce6 --- /dev/null +++ b/homeassistant/components/monzo/__init__.py @@ -0,0 +1,44 @@ +"""The Monzo integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AuthenticatedMonzoAPI +from .const import DOMAIN +from .coordinator import MonzoCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Monzo from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + + external_api = AuthenticatedMonzoAPI(async_get_clientsession(hass), session) + + coordinator = MonzoCoordinator(hass, external_api) + + 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.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/monzo/api.py b/homeassistant/components/monzo/api.py new file mode 100644 index 00000000000..6862564d343 --- /dev/null +++ b/homeassistant/components/monzo/api.py @@ -0,0 +1,26 @@ +"""API for Monzo bound to Home Assistant OAuth.""" + +from aiohttp import ClientSession +from monzopy import AbstractMonzoApi + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AuthenticatedMonzoAPI(AbstractMonzoApi): + """A Monzo API instance with authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Monzo auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return str(self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/monzo/application_credentials.py b/homeassistant/components/monzo/application_credentials.py new file mode 100644 index 00000000000..f040c150853 --- /dev/null +++ b/homeassistant/components/monzo/application_credentials.py @@ -0,0 +1,15 @@ +"""application_credentials platform the Monzo integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +OAUTH2_AUTHORIZE = "https://auth.monzo.com" +OAUTH2_TOKEN = "https://api.monzo.com/oauth2/token" + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/monzo/config_flow.py b/homeassistant/components/monzo/config_flow.py new file mode 100644 index 00000000000..2eb51b4d305 --- /dev/null +++ b/homeassistant/components/monzo/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for Monzo.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.const import CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN + + +class MonzoFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Handle a config flow.""" + + DOMAIN = DOMAIN + + oauth_data: dict[str, Any] + reauth_entry: ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + async def async_step_await_approval_confirmation( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Wait for the user to confirm in-app approval.""" + if user_input is not None: + if not self.reauth_entry: + return self.async_create_entry(title=DOMAIN, data=self.oauth_data) + return self.async_update_reload_and_abort( + self.reauth_entry, data={**self.reauth_entry.data, **self.oauth_data} + ) + + data_schema = vol.Schema({vol.Required("confirm"): bool}) + + return self.async_show_form( + step_id="await_approval_confirmation", data_schema=data_schema + ) + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow.""" + self.oauth_data = data + user_id = data[CONF_TOKEN]["user_id"] + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + elif self.reauth_entry.unique_id != user_id: + return self.async_abort(reason="wrong_account") + + return await self.async_step_await_approval_confirmation() + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/monzo/const.py b/homeassistant/components/monzo/const.py new file mode 100644 index 00000000000..619daf120f7 --- /dev/null +++ b/homeassistant/components/monzo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Monzo integration.""" + +DOMAIN = "monzo" diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py new file mode 100644 index 00000000000..223d7b05ffe --- /dev/null +++ b/homeassistant/components/monzo/coordinator.py @@ -0,0 +1,49 @@ +"""The Monzo integration.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from monzopy import AuthorisationExpiredError + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AuthenticatedMonzoAPI +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MonzoData: + """A dataclass for holding sensor data returned by the DataUpdateCoordinator.""" + + accounts: list[dict[str, Any]] + pots: list[dict[str, Any]] + + +class MonzoCoordinator(DataUpdateCoordinator[MonzoData]): + """Class to manage fetching Monzo data from the API.""" + + def __init__(self, hass: HomeAssistant, api: AuthenticatedMonzoAPI) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=1), + ) + self.api = api + + async def _async_update_data(self) -> MonzoData: + """Fetch data from Monzo API.""" + try: + accounts = await self.api.user_account.accounts() + pots = await self.api.user_account.pots() + except AuthorisationExpiredError as err: + raise ConfigEntryAuthFailed from err + + return MonzoData(accounts, pots) diff --git a/homeassistant/components/monzo/entity.py b/homeassistant/components/monzo/entity.py new file mode 100644 index 00000000000..bf83e3a9bfb --- /dev/null +++ b/homeassistant/components/monzo/entity.py @@ -0,0 +1,44 @@ +"""Base entity for Monzo.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MonzoCoordinator, MonzoData + + +class MonzoBaseEntity(CoordinatorEntity[MonzoCoordinator]): + """Common base for Monzo entities.""" + + _attr_attribution = "Data provided by Monzo" + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MonzoCoordinator, + index: int, + device_model: str, + data_accessor: Callable[[MonzoData], list[dict[str, Any]]], + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator) + self.index = index + self._data_accessor = data_accessor + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(self.data["id"]))}, + manufacturer="Monzo", + model=device_model, + name=self.data["name"], + ) + + @property + def data(self) -> dict[str, Any]: + """Shortcut to access coordinator data for the entity.""" + return self._data_accessor(self.coordinator.data)[self.index] diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json new file mode 100644 index 00000000000..8b816457004 --- /dev/null +++ b/homeassistant/components/monzo/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "monzo", + "name": "Monzo", + "codeowners": ["@jakemartin-icl"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/monzo", + "iot_class": "cloud_polling", + "requirements": ["monzopy==1.3.0"] +} diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py new file mode 100644 index 00000000000..41b97d90452 --- /dev/null +++ b/homeassistant/components/monzo/sensor.py @@ -0,0 +1,121 @@ +"""Platform for sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import MonzoCoordinator +from .const import DOMAIN +from .coordinator import MonzoData +from .entity import MonzoBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MonzoSensorEntityDescription(SensorEntityDescription): + """Describes Monzo sensor entity.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +ACCOUNT_SENSORS = ( + MonzoSensorEntityDescription( + key="balance", + translation_key="balance", + value_fn=lambda data: data["balance"]["balance"] / 100, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="GBP", + suggested_display_precision=2, + ), + MonzoSensorEntityDescription( + key="total_balance", + translation_key="total_balance", + value_fn=lambda data: data["balance"]["total_balance"] / 100, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="GBP", + suggested_display_precision=2, + ), +) + +POT_SENSORS = ( + MonzoSensorEntityDescription( + key="pot_balance", + translation_key="pot_balance", + value_fn=lambda data: data["balance"] / 100, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="GBP", + suggested_display_precision=2, + ), +) + +MODEL_POT = "Pot" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Defer sensor setup to the shared sensor module.""" + coordinator: MonzoCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + accounts = [ + MonzoSensor( + coordinator, + entity_description, + index, + account["name"], + lambda x: x.accounts, + ) + for entity_description in ACCOUNT_SENSORS + for index, account in enumerate(coordinator.data.accounts) + ] + + pots = [ + MonzoSensor(coordinator, entity_description, index, MODEL_POT, lambda x: x.pots) + for entity_description in POT_SENSORS + for index, _pot in enumerate(coordinator.data.pots) + ] + + async_add_entities(accounts + pots) + + +class MonzoSensor(MonzoBaseEntity, SensorEntity): + """Represents a Monzo sensor.""" + + entity_description: MonzoSensorEntityDescription + + def __init__( + self, + coordinator: MonzoCoordinator, + entity_description: MonzoSensorEntityDescription, + index: int, + device_model: str, + data_accessor: Callable[[MonzoData], list[dict[str, Any]]], + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, index, device_model, data_accessor) + self.entity_description = entity_description + self._attr_unique_id = f"{self.data['id']}_{entity_description.key}" + + @property + def native_value(self) -> StateType: + """Return the state.""" + + try: + state = self.entity_description.value_fn(self.data) + except (KeyError, ValueError): + return None + + return state diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json new file mode 100644 index 00000000000..e4ec34a8459 --- /dev/null +++ b/homeassistant/components/monzo/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Monzo integration needs to re-authenticate your account" + }, + "await_approval_confirmation": { + "title": "Confirm in Monzo app", + "description": "Before proceeding, open your Monzo app and approve the request from Home Assistant.", + "data": { + "confirm": "I've approved" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "Wrong account: The credentials provided do not match this Monzo account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "sensor": { + "balance": { + "name": "Balance" + }, + "total_balance": { + "name": "Total balance" + }, + "pot_balance": { + "name": "[%key:component::monzo::entity::sensor::balance::name%]" + } + } + } +} diff --git a/homeassistant/components/mopeka/sensor.py b/homeassistant/components/mopeka/sensor.py index b4b02bb083f..74beaccd001 100644 --- a/homeassistant/components/mopeka/sensor.py +++ b/homeassistant/components/mopeka/sensor.py @@ -133,7 +133,9 @@ async def async_setup_entry( class MopekaBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a Mopeka sensor.""" diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 3ba215a3f4c..c838825a4bd 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -97,7 +97,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): try: # key not needed for GetDeviceList request await self.hass.async_add_executor_job(gateway.GetDeviceList) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return self.async_abort(reason="not_motionblinds") if not gateway.available: diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index b1495dd8ecf..4734d4d9a65 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -39,7 +39,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind if blind.device_type in DEVICE_TYPES_GATEWAY: gateway = blind else: - gateway = blind._gateway + gateway = blind._gateway # noqa: SLF001 if gateway.firmware is not None: sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}" else: @@ -70,7 +70,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind manufacturer=MANUFACTURER, model=blind.blind_type, name=device_name(blind), - via_device=(DOMAIN, blind._gateway.mac), + via_device=(DOMAIN, blind._gateway.mac), # noqa: SLF001 hw_version=blind.wireless_name, ) diff --git a/homeassistant/components/motionblinds_ble/__init__.py b/homeassistant/components/motionblinds_ble/__init__.py index 3c6df12e878..76ceac1097c 100644 --- a/homeassistant/components/motionblinds_ble/__init__.py +++ b/homeassistant/components/motionblinds_ble/__init__.py @@ -24,11 +24,22 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType -from .const import CONF_BLIND_TYPE, CONF_MAC_CODE, DOMAIN +from .const import ( + CONF_BLIND_TYPE, + CONF_MAC_CODE, + DOMAIN, + OPTION_DISCONNECT_TIME, + OPTION_PERMANENT_CONNECTION, +) _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SELECT] +PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.COVER, + Platform.SELECT, + Platform.SENSOR, +] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -86,13 +97,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + # Register OptionsFlow update listener + entry.async_on_unload(entry.add_update_listener(options_update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Apply options + entry.async_create_background_task( + hass, apply_options(hass, entry), device.ble_device.address + ) + _LOGGER.debug("(%s) Finished setting up device", entry.data[CONF_MAC_CODE]) return True +async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + _LOGGER.debug( + "(%s) Updated device options: %s", entry.data[CONF_MAC_CODE], entry.options + ) + await apply_options(hass, entry) + + +async def apply_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Apply the options from the OptionsFlow.""" + + device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + disconnect_time: float | None = entry.options.get(OPTION_DISCONNECT_TIME, None) + permanent_connection: bool = entry.options.get(OPTION_PERMANENT_CONNECTION, False) + + device.set_custom_disconnect_time(disconnect_time) + await device.set_permanent_connection(permanent_connection) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Motionblinds Bluetooth device from a config entry.""" diff --git a/homeassistant/components/motionblinds_ble/config_flow.py b/homeassistant/components/motionblinds_ble/config_flow.py index 23302ae9624..b8e03386844 100644 --- a/homeassistant/components/motionblinds_ble/config_flow.py +++ b/homeassistant/components/motionblinds_ble/config_flow.py @@ -7,13 +7,19 @@ import re from typing import TYPE_CHECKING, Any from bleak.backends.device import BLEDevice -from motionblindsble.const import DISPLAY_NAME, MotionBlindType +from motionblindsble.const import DISPLAY_NAME, SETTING_DISCONNECT_TIME, MotionBlindType import voluptuous as vol from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( SelectSelector, @@ -30,6 +36,8 @@ from .const import ( ERROR_INVALID_MAC_CODE, ERROR_NO_BLUETOOTH_ADAPTER, ERROR_NO_DEVICES_FOUND, + OPTION_DISCONNECT_TIME, + OPTION_PERMANENT_CONNECTION, ) _LOGGER = logging.getLogger(__name__) @@ -174,6 +182,53 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self._mac_code = mac_code.upper() self._display_name = DISPLAY_NAME.format(mac_code=self._mac_code) + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(OptionsFlow): + """Handle an options flow for Motionblinds BLE.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + OPTION_PERMANENT_CONNECTION, + default=( + self.config_entry.options.get( + OPTION_PERMANENT_CONNECTION, False + ) + ), + ): bool, + vol.Optional( + OPTION_DISCONNECT_TIME, + default=( + self.config_entry.options.get( + OPTION_DISCONNECT_TIME, SETTING_DISCONNECT_TIME + ) + ), + ): vol.All(vol.Coerce(int), vol.Range(min=0)), + } + ), + ) + def is_valid_mac(data: str) -> bool: """Validate the provided MAC address.""" diff --git a/homeassistant/components/motionblinds_ble/const.py b/homeassistant/components/motionblinds_ble/const.py index bd88927559e..6b958170a4a 100644 --- a/homeassistant/components/motionblinds_ble/const.py +++ b/homeassistant/components/motionblinds_ble/const.py @@ -1,8 +1,12 @@ """Constants for the Motionblinds Bluetooth integration.""" +ATTR_BATTERY = "battery" +ATTR_CALIBRATION = "calibration" ATTR_CONNECT = "connect" +ATTR_CONNECTION = "connection" ATTR_DISCONNECT = "disconnect" ATTR_FAVORITE = "favorite" +ATTR_SIGNAL_STRENGTH = "signal_strength" ATTR_SPEED = "speed" CONF_LOCAL_NAME = "local_name" @@ -19,3 +23,6 @@ ERROR_NO_DEVICES_FOUND = "no_devices_found" ICON_VERTICAL_BLIND = "mdi:blinds-vertical-closed" MANUFACTURER = "Motionblinds" + +OPTION_DISCONNECT_TIME = "disconnect_time" +OPTION_PERMANENT_CONNECTION = "permanent_connection" diff --git a/homeassistant/components/motionblinds_ble/icons.json b/homeassistant/components/motionblinds_ble/icons.json index c8d2b085d75..7a7561360a2 100644 --- a/homeassistant/components/motionblinds_ble/icons.json +++ b/homeassistant/components/motionblinds_ble/icons.json @@ -15,6 +15,14 @@ "speed": { "default": "mdi:run-fast" } + }, + "sensor": { + "calibration": { + "default": "mdi:tune" + }, + "connection": { + "default": "mdi:bluetooth-connect" + } } } } diff --git a/homeassistant/components/motionblinds_ble/manifest.json b/homeassistant/components/motionblinds_ble/manifest.json index aa727be13f8..454c873dfa2 100644 --- a/homeassistant/components/motionblinds_ble/manifest.json +++ b/homeassistant/components/motionblinds_ble/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "assumed_state", "loggers": ["motionblindsble"], - "requirements": ["motionblindsble==0.0.9"] + "requirements": ["motionblindsble==0.1.0"] } diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py new file mode 100644 index 00000000000..fbab5d06251 --- /dev/null +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -0,0 +1,195 @@ +"""Sensor entities for the Motionblinds BLE integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from math import ceil +from typing import Generic, TypeVar + +from motionblindsble.const import ( + MotionBlindType, + MotionCalibrationType, + MotionConnectionType, +) +from motionblindsble.device import MotionDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + ATTR_BATTERY, + ATTR_CALIBRATION, + ATTR_CONNECTION, + ATTR_SIGNAL_STRENGTH, + CONF_MAC_CODE, + DOMAIN, +) +from .entity import MotionblindsBLEEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +_T = TypeVar("_T") + + +@dataclass(frozen=True, kw_only=True) +class MotionblindsBLESensorEntityDescription(SensorEntityDescription, Generic[_T]): + """Entity description of a sensor entity with initial_value attribute.""" + + initial_value: str | None = None + register_callback_func: Callable[ + [MotionDevice], Callable[[Callable[[_T | None], None]], None] + ] + value_func: Callable[[_T | None], StateType] + is_supported: Callable[[MotionDevice], bool] = lambda device: True + + +SENSORS: tuple[MotionblindsBLESensorEntityDescription, ...] = ( + MotionblindsBLESensorEntityDescription[MotionConnectionType]( + key=ATTR_CONNECTION, + translation_key=ATTR_CONNECTION, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["connected", "connecting", "disconnected", "disconnecting"], + initial_value=MotionConnectionType.DISCONNECTED.value, + register_callback_func=lambda device: device.register_connection_callback, + value_func=lambda value: value.value if value else None, + ), + MotionblindsBLESensorEntityDescription[MotionCalibrationType]( + key=ATTR_CALIBRATION, + translation_key=ATTR_CALIBRATION, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["calibrated", "uncalibrated", "calibrating"], + register_callback_func=lambda device: device.register_calibration_callback, + value_func=lambda value: value.value if value else None, + is_supported=lambda device: device.blind_type + in {MotionBlindType.CURTAIN, MotionBlindType.VERTICAL}, + ), + MotionblindsBLESensorEntityDescription[int]( + key=ATTR_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + register_callback_func=lambda device: device.register_signal_strength_callback, + value_func=lambda value: value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensor entities based on a config entry.""" + + device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + + entities: list[SensorEntity] = [ + MotionblindsBLESensorEntity(device, entry, description) + for description in SENSORS + if description.is_supported(device) + ] + entities.append(BatterySensor(device, entry)) + async_add_entities(entities) + + +class MotionblindsBLESensorEntity(MotionblindsBLEEntity, SensorEntity, Generic[_T]): + """Representation of a sensor entity.""" + + entity_description: MotionblindsBLESensorEntityDescription[_T] + + def __init__( + self, + device: MotionDevice, + entry: ConfigEntry, + entity_description: MotionblindsBLESensorEntityDescription[_T], + ) -> None: + """Initialize the sensor entity.""" + super().__init__( + device, entry, entity_description, unique_id_suffix=entity_description.key + ) + self._attr_native_value = entity_description.initial_value + + async def async_added_to_hass(self) -> None: + """Log sensor entity information.""" + _LOGGER.debug( + "(%s) Setting up %s sensor entity", + self.entry.data[CONF_MAC_CODE], + self.entity_description.key.replace("_", " "), + ) + + def async_callback(value: _T | None) -> None: + """Update the sensor value.""" + self._attr_native_value = self.entity_description.value_func(value) + self.async_write_ha_state() + + self.entity_description.register_callback_func(self.device)(async_callback) + + +class BatterySensor(MotionblindsBLEEntity, SensorEntity): + """Representation of a battery sensor entity.""" + + def __init__( + self, + device: MotionDevice, + entry: ConfigEntry, + ) -> None: + """Initialize the sensor entity.""" + entity_description = SensorEntityDescription( + key=ATTR_BATTERY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + super().__init__(device, entry, entity_description) + + async def async_added_to_hass(self) -> None: + """Register device callbacks.""" + await super().async_added_to_hass() + self.device.register_battery_callback(self.async_update_battery) + + @callback + def async_update_battery( + self, + battery_percentage: int | None, + is_charging: bool | None, + is_wired: bool | None, + ) -> None: + """Update the battery sensor value and icon.""" + self._attr_native_value = battery_percentage + if battery_percentage is None: + # Battery percentage is unknown + self._attr_icon = "mdi:battery-unknown" + elif is_wired: + # Motor is wired and does not have a battery + self._attr_icon = "mdi:power-plug-outline" + elif battery_percentage > 90 and not is_charging: + # Full battery icon if battery > 90% and not charging + self._attr_icon = "mdi:battery" + elif battery_percentage <= 5 and not is_charging: + # Empty battery icon with alert if battery <= 5% and not charging + self._attr_icon = "mdi:battery-alert-variant-outline" + else: + battery_icon_prefix = ( + "mdi:battery-charging" if is_charging else "mdi:battery" + ) + battery_percentage_multiple_ten = ceil(battery_percentage / 10) * 10 + self._attr_icon = f"{battery_icon_prefix}-{battery_percentage_multiple_ten}" + self.async_write_ha_state() diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json index 0bc9ad4c012..d6532f12386 100644 --- a/homeassistant/components/motionblinds_ble/strings.json +++ b/homeassistant/components/motionblinds_ble/strings.json @@ -20,6 +20,18 @@ } } }, + "options": { + "step": { + "init": { + "title": "Connection options", + "description": "The default disconnect time is 15 seconds, adjustable using the slider below. You may want to adjust this if you have larger blinds or other specific needs. You can also enable a permanent connection to the motor, which disables the disconnect time and automatically reconnects when the motor is disconnected for any reason.\n**WARNING**: Changing any of the below options may significantly reduce battery life of your motor!", + "data": { + "permanent_connection": "Permanent connection", + "disconnect_time": "Disconnect time (seconds)" + } + } + } + }, "selector": { "blind_type": { "options": { @@ -55,6 +67,25 @@ "3": "High" } } + }, + "sensor": { + "connection": { + "name": "Connection status", + "state": { + "connected": "Connected", + "disconnected": "Disconnected", + "connecting": "Connecting", + "disconnecting": "Disconnecting" + } + }, + "calibration": { + "name": "Calibration status", + "state": { + "calibrated": "Calibrated", + "uncalibrated": "Uncalibrated", + "calibrating": "Calibration in progress" + } + } } } } diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 43869ef51de..6ec3092ab35 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -414,7 +414,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def handle_webhook( hass: HomeAssistant, webhook_id: str, request: Request -) -> None | Response: +) -> Response | None: """Handle webhook callback.""" try: diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index c3f7c9c9358..8403af05491 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -1,5 +1,7 @@ """Support for MotionMount sensors.""" +from typing import TYPE_CHECKING + import motionmount from homeassistant.config_entries import ConfigEntry @@ -42,12 +44,28 @@ class MotionMountEntity(Entity): (dr.CONNECTION_NETWORK_MAC, mac) } + @property + def available(self) -> bool: + """Return True if the MotionMount is available (we're connected).""" + return self.mm.is_connected + + def update_name(self) -> None: + """Update the name of the associated device.""" + if TYPE_CHECKING: + assert self.device_entry + # Update the name in the device registry if needed + if self.device_entry.name != self.mm.name: + device_registry = dr.async_get(self.hass) + device_registry.async_update_device(self.device_entry.id, name=self.mm.name) + async def async_added_to_hass(self) -> None: """Store register state change callback.""" self.mm.add_listener(self.async_write_ha_state) + self.mm.add_listener(self.update_name) 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) + self.mm.remove_listener(self.update_name) await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index e6a7bd50fba..b7ce3ad1fd9 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", - "requirements": ["python-MotionMount==1.0.0"], + "requirements": ["python-MotionMount==2.0.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 7d8a6ccdbc4..d15bbb7326b 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -24,7 +24,6 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): """The presets of a MotionMount.""" _attr_translation_key = "motionmount_preset" - _attr_current_option: str | None = None def __init__( self, @@ -34,22 +33,41 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): """Initialize Preset selector.""" super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-preset" + self._presets: list[motionmount.Preset] = [] - def _update_options(self, presets: dict[int, str]) -> None: + def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" - options = [WALL_PRESET_NAME] - for index, name in presets.items(): - options.append(f"{index}: {name}") + options = [f"{preset.index}: {preset.name}" for preset in presets] + options.insert(0, WALL_PRESET_NAME) self._attr_options = options async def async_update(self) -> None: """Get latest state from MotionMount.""" - presets = await self.mm.get_presets() - self._update_options(presets) + self._presets = await self.mm.get_presets() + self._update_options(self._presets) - if self._attr_current_option is None: - self._attr_current_option = self._attr_options[0] + @property + def current_option(self) -> str | None: + """Get the current option.""" + # When the mount is moving we return the currently selected option + if self.mm.is_moving: + return self._attr_current_option + + # When the mount isn't moving we select the option that matches the current position + self._attr_current_option = None + if self.mm.extension == 0 and self.mm.turn == 0: + self._attr_current_option = self._attr_options[0] # Select Wall preset + else: + for preset in self._presets: + if ( + preset.extension == self.mm.extension + and preset.turn == self.mm.turn + ): + self._attr_current_option = f"{preset.index}: {preset.name}" + break + + return self._attr_current_option async def async_select_option(self, option: str) -> None: """Set the new option.""" diff --git a/homeassistant/components/mpd/__init__.py b/homeassistant/components/mpd/__init__.py index bf917ff19aa..01ea159cf02 100644 --- a/homeassistant/components/mpd/__init__.py +++ b/homeassistant/components/mpd/__init__.py @@ -1 +1,22 @@ -"""The mpd component.""" +"""The Music Player Daemon 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.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Music Player Daemon 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/mpd/config_flow.py b/homeassistant/components/mpd/config_flow.py new file mode 100644 index 00000000000..619fb8936e2 --- /dev/null +++ b/homeassistant/components/mpd/config_flow.py @@ -0,0 +1,101 @@ +"""Music Player Daemon config flow.""" + +from asyncio import timeout +from contextlib import suppress +from socket import gaierror +from typing import Any + +import mpd +from mpd.asyncio import MPDClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT + +from .const import DOMAIN, LOGGER + +SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=6600): int, + } +) + + +class MPDConfigFlow(ConfigFlow, domain=DOMAIN): + """Music Player Daemon config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + client = MPDClient() + client.timeout = 30 + client.idletimeout = 10 + try: + async with timeout(35): + await client.connect(user_input[CONF_HOST], user_input[CONF_PORT]) + if CONF_PASSWORD in user_input: + await client.password(user_input[CONF_PASSWORD]) + with suppress(mpd.ConnectionError): + client.disconnect() + except ( + TimeoutError, + gaierror, + mpd.ConnectionError, + OSError, + ): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unknown exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Music Player Daemon", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=SCHEMA, + errors=errors, + ) + + async def async_step_import( + self, import_config: dict[str, Any] + ) -> ConfigFlowResult: + """Attempt to import the existing configuration.""" + self._async_abort_entries_match({CONF_HOST: import_config[CONF_HOST]}) + client = MPDClient() + client.timeout = 30 + client.idletimeout = 10 + try: + async with timeout(35): + await client.connect(import_config[CONF_HOST], import_config[CONF_PORT]) + if CONF_PASSWORD in import_config: + await client.password(import_config[CONF_PASSWORD]) + with suppress(mpd.ConnectionError): + client.disconnect() + except ( + TimeoutError, + gaierror, + mpd.ConnectionError, + OSError, + ): + return self.async_abort(reason="cannot_connect") + except Exception: # noqa: BLE001 + LOGGER.exception("Unknown exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=import_config.get(CONF_NAME, "Music Player Daemon"), + data={ + CONF_HOST: import_config[CONF_HOST], + CONF_PORT: import_config[CONF_PORT], + CONF_PASSWORD: import_config.get(CONF_PASSWORD), + }, + ) diff --git a/homeassistant/components/mpd/const.py b/homeassistant/components/mpd/const.py new file mode 100644 index 00000000000..0aed3bb8106 --- /dev/null +++ b/homeassistant/components/mpd/const.py @@ -0,0 +1,7 @@ +"""Constants for the MPD integration.""" + +import logging + +DOMAIN = "mpd" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 7f69b7bf914..f0df2cdbbe2 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -26,15 +26,18 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT -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 homeassistant.util import Throttle import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER DEFAULT_NAME = "MPD" DEFAULT_PORT = 6600 @@ -74,13 +77,63 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the MPD platform.""" - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - name = config.get(CONF_NAME) - password = config.get(CONF_PASSWORD) - entity = MpdDevice(host, port, password, name) - async_add_entities([entity], True) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result["type"] is FlowResultType.CREATE_ENTRY + or result["reason"] == "single_instance_allowed" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Music Player Daemon", + }, + ) + return + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Music Player Daemon", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up media player from config_entry.""" + + async_add_entities( + [ + MpdDevice( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data.get(CONF_PASSWORD), + entry.title, + ) + ], + True, + ) class MpdDevice(MediaPlayerEntity): @@ -148,7 +201,7 @@ class MpdDevice(MediaPlayerEntity): log_level = logging.DEBUG if self._is_available is not False: log_level = logging.WARNING - _LOGGER.log( + LOGGER.log( log_level, "Error connecting to '%s': %s", self.server, error ) self._is_available = False @@ -181,7 +234,7 @@ class MpdDevice(MediaPlayerEntity): await self._update_playlists() except (mpd.ConnectionError, ValueError) as error: - _LOGGER.debug("Error updating status: %s", error) + LOGGER.debug("Error updating status: %s", error) @property def available(self) -> bool: @@ -340,7 +393,7 @@ class MpdDevice(MediaPlayerEntity): response = await self._client.readpicture(file) except mpd.CommandError as error: if error.errno is not mpd.FailureResponseCode.NO_EXIST: - _LOGGER.warning( + LOGGER.warning( "Retrieving artwork through `readpicture` command failed: %s", error, ) @@ -352,7 +405,7 @@ class MpdDevice(MediaPlayerEntity): response = await self._client.albumart(file) except mpd.CommandError as error: if error.errno is not mpd.FailureResponseCode.NO_EXIST: - _LOGGER.warning( + LOGGER.warning( "Retrieving artwork through `albumart` command failed: %s", error, ) @@ -412,7 +465,7 @@ class MpdDevice(MediaPlayerEntity): self._playlists.append(playlist_data["playlist"]) except mpd.CommandError as error: self._playlists = None - _LOGGER.warning("Playlists could not be updated: %s:", error) + LOGGER.warning("Playlists could not be updated: %s:", error) async def async_set_volume_level(self, volume: float) -> None: """Set volume of media player.""" @@ -489,12 +542,12 @@ class MpdDevice(MediaPlayerEntity): media_id = async_process_play_media_url(self.hass, play_item.url) if media_type == MediaType.PLAYLIST: - _LOGGER.debug("Playing playlist: %s", media_id) + LOGGER.debug("Playing playlist: %s", media_id) if media_id in self._playlists: self._currentplaylist = media_id else: self._currentplaylist = None - _LOGGER.warning("Unknown playlist name %s", media_id) + LOGGER.warning("Unknown playlist name %s", media_id) await self._client.clear() await self._client.load(media_id) await self._client.play() diff --git a/homeassistant/components/mpd/strings.json b/homeassistant/components/mpd/strings.json new file mode 100644 index 00000000000..fc922ab128a --- /dev/null +++ b/homeassistant/components/mpd/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Music Player Daemon instance." + } + } + }, + "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%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The {integration_title} YAML configuration import cannot connect to daemon", + "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The {integration_title} YAML configuration could not be imported", + "description": "Configuring {integration_title} using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually." + } + } +} diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3178d68c9d6..f057dab8bc4 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -14,24 +14,28 @@ from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, SERVICE_RELOAD -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, ServiceValidationError, Unauthorized, ) -from homeassistant.helpers import config_validation as cv, event as ev, template +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + event as ev, + issue_registry as ir, + template, +) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.issue_registry import ( - async_delete_issue, - async_get as async_get_issue_registry, -) from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_integration +from homeassistant.loader import async_get_integration, async_get_loaded_integration +from homeassistant.setup import SetupPhases, async_pause_setup +from homeassistant.util.async_ import create_eager_task # Loading the config flow file will register the flow from . import debug_info, discovery @@ -39,6 +43,7 @@ from .client import ( # noqa: F401 MQTT, async_publish, async_subscribe, + async_subscribe_internal, publish, subscribe, ) @@ -65,20 +70,19 @@ from .const import ( # noqa: F401 CONF_WILL_MESSAGE, CONF_WS_HEADERS, CONF_WS_PATH, - DATA_MQTT, - DATA_MQTT_AVAILABLE, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, - MQTT_CONNECTED, - MQTT_DISCONNECTED, + MQTT_CONNECTION_STATE, RELOADABLE_PLATFORMS, TEMPLATE_ERRORS, ) from .models import ( # noqa: F401 + DATA_MQTT, + DATA_MQTT_AVAILABLE, MqttCommandTemplate, MqttData, MqttValueTemplate, @@ -87,11 +91,16 @@ from .models import ( # noqa: F401 ReceiveMessage, ReceivePayloadType, ) +from .subscription import ( # noqa: F401 + EntitySubscription, + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) from .util import ( # noqa: F401 async_create_certificate_temp_files, async_forward_entry_setup_and_setup_discovery, async_wait_for_mqtt_client, - get_mqtt_data, mqtt_config_entry_enabled, platforms_from_config, valid_publish_topic, @@ -174,21 +183,21 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - @callback def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: """Unregister open config issues.""" - issue_registry = async_get_issue_registry(hass) + issue_registry = ir.async_get(hass) open_issues = [ issue_id for (domain, issue_id), issue_entry in issue_registry.issues.items() if domain == DOMAIN and issue_entry.translation_key == "invalid_platform_config" ] for issue in open_issues: - async_delete_issue(hass, DOMAIN, issue) + ir.async_delete_issue(hass, DOMAIN, issue) async def async_check_config_schema( hass: HomeAssistant, config_yaml: ConfigType ) -> None: """Validate manually configured MQTT items.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml.get(DOMAIN, {}) for mqtt_config_item in mqtt_config: for domain, config_items in mqtt_config_item.items(): @@ -227,7 +236,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_create_certificate_temp_files(hass, conf) client = MQTT(hass, entry, conf) if DOMAIN in hass.data: - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] mqtt_data.config = mqtt_yaml mqtt_data.client = client else: @@ -235,7 +244,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_mqtt_info) hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) - client.start(mqtt_data) + await client.async_start(mqtt_data) # Restore saved subscriptions if mqtt_data.subscriptions_to_restore: @@ -247,7 +256,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.add_update_listener(_async_config_entry_updated) ) - await mqtt_data.client.async_connect(client_available) return (mqtt_data, conf) client_available: asyncio.Future[bool] @@ -257,6 +265,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client_available = hass.data[DATA_MQTT_AVAILABLE] mqtt_data, conf = await _setup_client(client_available) + platforms_used = platforms_from_config(mqtt_data.config) + platforms_used.update( + entry.domain + for entry in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + ) + integration = async_get_loaded_integration(hass, DOMAIN) + # Preload platforms we know we are going to use so + # discovery can setup each platform synchronously + # and avoid creating a flood of tasks at startup + # while waiting for the the imports to complete + if not integration.platforms_are_loaded(platforms_used): + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): + await integration.async_get_platforms(platforms_used) + + # Wait to connect until the platforms are loaded so + # we can be sure discovery does not have to wait for + # each platform to load when we get the flood of retained + # messages on connect + await mqtt_data.client.async_connect(client_available) async def async_publish_service(call: ServiceCall) -> None: """Handle MQTT publish service calls.""" @@ -306,7 +335,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def collect_msg(msg: ReceiveMessage) -> None: messages.append((msg.topic, str(msg.payload).replace("\n", ""))) - unsub = await async_subscribe(hass, call.data["topic"], collect_msg) + unsub = async_subscribe_internal(hass, call.data["topic"], collect_msg) def write_dump() -> None: with open(hass.config.path("mqtt_dump.txt"), "w", encoding="utf8") as fp: @@ -333,64 +362,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # setup platforms and discovery - - async def async_setup_reload_service() -> None: - """Create the reload service for the MQTT domain.""" - if hass.services.has_service(DOMAIN, SERVICE_RELOAD): - return - - async def _reload_config(call: ServiceCall) -> None: - """Reload the platforms.""" - # Fetch updated manually configured items and validate - try: - config_yaml = await async_integration_yaml_config( - hass, DOMAIN, raise_on_failure=True - ) - except ConfigValidationError as ex: - raise ServiceValidationError( - translation_domain=ex.translation_domain, - translation_key=ex.translation_key, - translation_placeholders=ex.translation_placeholders, - ) from ex - - new_config: list[ConfigType] = config_yaml.get(DOMAIN, []) - platforms_used = platforms_from_config(new_config) - new_platforms = platforms_used - mqtt_data.platforms_loaded - await async_forward_entry_setup_and_setup_discovery( - hass, entry, new_platforms + async def _reload_config(call: ServiceCall) -> None: + """Reload the platforms.""" + # Fetch updated manually configured items and validate + try: + config_yaml = await async_integration_yaml_config( + hass, DOMAIN, raise_on_failure=True ) - # Check the schema before continuing reload - await async_check_config_schema(hass, config_yaml) + except ConfigValidationError as ex: + raise ServiceValidationError( + translation_domain=ex.translation_domain, + translation_key=ex.translation_key, + translation_placeholders=ex.translation_placeholders, + ) from ex - # Remove repair issues - _async_remove_mqtt_issues(hass, mqtt_data) + new_config: list[ConfigType] = config_yaml.get(DOMAIN, []) + platforms_used = platforms_from_config(new_config) + new_platforms = platforms_used - mqtt_data.platforms_loaded + await async_forward_entry_setup_and_setup_discovery(hass, entry, new_platforms) + # Check the schema before continuing reload + await async_check_config_schema(hass, config_yaml) - mqtt_data.config = new_config + # Remove repair issues + _async_remove_mqtt_issues(hass, mqtt_data) - # Reload the modern yaml platforms - mqtt_platforms = async_get_platforms(hass, DOMAIN) - tasks = [ - entity.async_remove() - for mqtt_platform in mqtt_platforms - for entity in mqtt_platform.entities.values() - if getattr(entity, "_discovery_data", None) is None - and mqtt_platform.config_entry - and mqtt_platform.domain in RELOADABLE_PLATFORMS - ] - await asyncio.gather(*tasks) + mqtt_data.config = new_config - for component in mqtt_data.reload_handlers.values(): - component() + # Reload the modern yaml platforms + mqtt_platforms = async_get_platforms(hass, DOMAIN) + tasks = [ + create_eager_task(entity.async_remove()) + for mqtt_platform in mqtt_platforms + for entity in list(mqtt_platform.entities.values()) + if getattr(entity, "_discovery_data", None) is None + and mqtt_platform.config_entry + and mqtt_platform.domain in RELOADABLE_PLATFORMS + ] + await asyncio.gather(*tasks) - # Fire event - hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + for component in mqtt_data.reload_handlers.values(): + component() - async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) + # Fire event + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) - platforms_used = platforms_from_config(mqtt_data.config) await async_forward_entry_setup_and_setup_discovery(hass, entry, platforms_used) # Setup reload service after all platforms have loaded - await async_setup_reload_service() + if not hass.services.has_service(DOMAIN, SERVICE_RELOAD): + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) # Setup discovery if conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): await discovery.async_start( @@ -454,14 +473,14 @@ async def websocket_subscribe( # Perform UTF-8 decoding directly in callback routine qos: int = msg.get("qos", DEFAULT_QOS) - connection.subscriptions[msg["id"]] = await async_subscribe( + connection.subscriptions[msg["id"]] = async_subscribe_internal( hass, msg["topic"], forward_messages, encoding=None, qos=qos ) connection.send_message(websocket_api.result_message(msg["id"])) -ConnectionStatusCallback = Callable[[bool], None] +type ConnectionStatusCallback = Callable[[bool], None] @callback @@ -469,34 +488,14 @@ def async_subscribe_connection_status( hass: HomeAssistant, connection_status_callback: ConnectionStatusCallback ) -> Callable[[], None]: """Subscribe to MQTT connection changes.""" - connection_status_callback_job = HassJob(connection_status_callback) - - async def connected() -> None: - task = hass.async_run_hass_job(connection_status_callback_job, True) - if task: - await task - - async def disconnected() -> None: - task = hass.async_run_hass_job(connection_status_callback_job, False) - if task: - await task - - subscriptions = { - "connect": async_dispatcher_connect(hass, MQTT_CONNECTED, connected), - "disconnect": async_dispatcher_connect(hass, MQTT_DISCONNECTED, disconnected), - } - - @callback - def unsubscribe() -> None: - subscriptions["connect"]() - subscriptions["disconnect"]() - - return unsubscribe + return async_dispatcher_connect( + hass, MQTT_CONNECTION_STATE, connection_status_callback + ) def is_connected(hass: HomeAssistant) -> bool: """Return if MQTT client is connected.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] return mqtt_data.client.connected @@ -513,28 +512,17 @@ async def async_remove_config_entry_device( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload MQTT dump and publish service when the config entry is unloaded.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] mqtt_client = mqtt_data.client # Unload publish and dump services. - hass.services.async_remove( - DOMAIN, - SERVICE_PUBLISH, - ) - hass.services.async_remove( - DOMAIN, - SERVICE_DUMP, - ) + hass.services.async_remove(DOMAIN, SERVICE_PUBLISH) + hass.services.async_remove(DOMAIN, SERVICE_DUMP) # Stop the discovery await discovery.async_stop(hass) # Unload the platforms - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(entry, component) - for component in mqtt_data.platforms_loaded - ) - ) + await hass.config_entries.async_unload_platforms(entry, mqtt_data.platforms_loaded) mqtt_data.platforms_loaded = set() await asyncio.sleep(0) # Unsubscribe reload dispatchers @@ -547,8 +535,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registry_hooks = mqtt_data.discovery_registry_hooks while registry_hooks: registry_hooks.popitem()[1]() - # Wait for all ACKs and stop the loop - await mqtt_client.async_disconnect() + # Wait for all ACKs, stop the loop and disconnect the client + await mqtt_client.async_disconnect(disconnect_paho_client=True) # Cleanup MQTT client availability hass.data.pop(DATA_MQTT_AVAILABLE, None) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index e4614817790..3cdb3efea7f 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -34,20 +34,14 @@ from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, CONF_SUPPORTED_FEATURES, + PAYLOAD_NONE, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -133,7 +127,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT alarm control panel through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttAlarm, @@ -177,47 +171,45 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): self._attr_code_format = alarm.CodeFormat.TEXT self._attr_code_arm_required = bool(self._config[CONF_CODE_ARM_REQUIRED]) + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Run when new MQTT message has been received.""" + payload = self._value_template(msg.payload) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + if payload == PAYLOAD_NONE: + self._attr_state = None + return + if payload not in ( + STATE_ALARM_DISARMED, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_PENDING, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMING, + STATE_ALARM_TRIGGERED, + ): + _LOGGER.warning("Received unexpected payload: %s", msg.payload) + return + self._attr_state = str(payload) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_state"}) - def message_received(msg: ReceiveMessage) -> None: - """Run when new MQTT message has been received.""" - payload = self._value_template(msg.payload) - if payload not in ( - STATE_ALARM_DISARMED, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_ARMED_VACATION, - STATE_ALARM_ARMED_CUSTOM_BYPASS, - STATE_ALARM_PENDING, - STATE_ALARM_ARMING, - STATE_ALARM_DISARMING, - STATE_ALARM_TRIGGERED, - ): - _LOGGER.warning("Received unexpected payload: %s", msg.payload) - return - self._attr_state = str(payload) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, + self.add_subscription( + CONF_STATE_TOPIC, self._state_message_received, {"_attr_state"} ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. @@ -300,13 +292,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """Publish via mqtt.""" variables = {"action": action, "code": code} payload = self._command_template(None, variables=variables) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) def _validate_code(self, code: str | None, state: str) -> bool: """Validate given code.""" diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py new file mode 100644 index 00000000000..882e910d7e8 --- /dev/null +++ b/homeassistant/components/mqtt/async_client.py @@ -0,0 +1,60 @@ +"""Async wrappings for mqtt client.""" + +from __future__ import annotations + +from functools import lru_cache +from types import TracebackType +from typing import Self + +from paho.mqtt.client import Client as MQTTClient + +_MQTT_LOCK_COUNT = 7 + + +class NullLock: + """Null lock.""" + + @lru_cache(maxsize=_MQTT_LOCK_COUNT) + def __enter__(self) -> Self: + """Enter the lock.""" + return self + + @lru_cache(maxsize=_MQTT_LOCK_COUNT) + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Exit the lock.""" + + @lru_cache(maxsize=_MQTT_LOCK_COUNT) + def acquire(self, blocking: bool = False, timeout: int = -1) -> None: + """Acquire the lock.""" + + @lru_cache(maxsize=_MQTT_LOCK_COUNT) + def release(self) -> None: + """Release the lock.""" + + +class AsyncMQTTClient(MQTTClient): + """Async MQTT Client. + + Wrapper around paho.mqtt.client.Client to remove the locking + that is not needed since we are running in an async event loop. + """ + + def setup(self) -> None: + """Set up the client. + + All the threading locks are replaced with NullLock + since the client is running in an async event loop + and will never run in multiple threads. + """ + self._in_callback_mutex = NullLock() + self._callback_mutex = NullLock() + self._msgtime_mutex = NullLock() + self._out_message_mutex = NullLock() + self._in_message_mutex = NullLock() + self._reconnect_delay_mutex = NullLock() + self._mid_generate_mutex = NullLock() diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 80ab11925d4..293b6e5f1f4 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -36,16 +36,10 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, PAYLOAD_NONE -from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttAvailability, - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .const import CONF_STATE_TOPIC, PAYLOAD_NONE +from .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -77,7 +71,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT binary sensor through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttBinarySensor, @@ -162,101 +156,90 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): entity=self, ).async_render_with_possible_json_value + @callback + def _off_delay_listener(self, now: datetime) -> None: + """Switch device off after a delay.""" + self._delay_listener = None + self._attr_is_on = False + self.async_write_ha_state() + + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + + # auto-expire enabled? + if self._expire_after: + # When expire_after is set, and we receive a message, assume device is + # not expired since it has to be to receive the message + self._expired = False + + # Reset old trigger + if self._expiration_trigger: + self._expiration_trigger() + + # Set new trigger + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired + ) + + payload = self._value_template(msg.payload) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + ( + "Empty template output for entity: %s with state topic: %s." + " Payload: '%s', with value template '%s'" + ), + self.entity_id, + self._config[CONF_STATE_TOPIC], + msg.payload, + self._config.get(CONF_VALUE_TEMPLATE), + ) + return + + if payload == self._config[CONF_PAYLOAD_ON]: + self._attr_is_on = True + elif payload == self._config[CONF_PAYLOAD_OFF]: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None + else: # Payload is not for this entity + template_info = "" + if self._config.get(CONF_VALUE_TEMPLATE) is not None: + template_info = ( + f", template output: '{payload!s}', with value template" + f" '{self._config.get(CONF_VALUE_TEMPLATE)!s}'" + ) + _LOGGER.info( + ( + "No matching payload found for entity: %s with state topic: %s." + " Payload: '%s'%s" + ), + self.entity_id, + self._config[CONF_STATE_TOPIC], + msg.payload, + template_info, + ) + return + + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = None + + off_delay: int | None = self._config.get(CONF_OFF_DELAY) + if self._attr_is_on and off_delay is not None: + self._delay_listener = evt.async_call_later( + self.hass, off_delay, self._off_delay_listener + ) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - def off_delay_listener(now: datetime) -> None: - """Switch device off after a delay.""" - self._delay_listener = None - self._attr_is_on = False - self.async_write_ha_state() - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on", "_expired"}) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle a new received MQTT state message.""" - # auto-expire enabled? - if self._expire_after: - # When expire_after is set, and we receive a message, assume device is - # not expired since it has to be to receive the message - self._expired = False - - # Reset old trigger - if self._expiration_trigger: - self._expiration_trigger() - - # Set new trigger - self._expiration_trigger = async_call_later( - self.hass, self._expire_after, self._value_is_expired - ) - - payload = self._value_template(msg.payload) - if not payload.strip(): # No output from template, ignore - _LOGGER.debug( - ( - "Empty template output for entity: %s with state topic: %s." - " Payload: '%s', with value template '%s'" - ), - self.entity_id, - self._config[CONF_STATE_TOPIC], - msg.payload, - self._config.get(CONF_VALUE_TEMPLATE), - ) - return - - if payload == self._config[CONF_PAYLOAD_ON]: - self._attr_is_on = True - elif payload == self._config[CONF_PAYLOAD_OFF]: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - else: # Payload is not for this entity - template_info = "" - if self._config.get(CONF_VALUE_TEMPLATE) is not None: - template_info = ( - f", template output: '{str(payload)}', with value template" - f" '{str(self._config.get(CONF_VALUE_TEMPLATE))}'" - ) - _LOGGER.info( - ( - "No matching payload found for entity: %s with state topic: %s." - " Payload: '%s'%s" - ), - self.entity_id, - self._config[CONF_STATE_TOPIC], - msg.payload, - template_info, - ) - return - - if self._delay_listener is not None: - self._delay_listener() - self._delay_listener = None - - off_delay: int | None = self._config.get(CONF_OFF_DELAY) - if self._attr_is_on and off_delay is not None: - self._delay_listener = evt.async_call_later( - self.hass, off_delay, off_delay_listener - ) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": state_message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, + self.add_subscription( + CONF_STATE_TOPIC, self._state_message_received, {"_attr_is_on", "_expired"} ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @callback def _value_is_expired(self, *_: Any) -> None: @@ -270,6 +253,6 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): def available(self) -> bool: """Return true if the device is available and value has not expired.""" # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - return MqttAvailability.available.fget(self) and ( # type: ignore[attr-defined] + return MqttAvailabilityMixin.available.fget(self) and ( # type: ignore[attr-defined] self._expire_after is None or not self._expired ) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index f6374aaa3cd..6ad11859f44 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -8,25 +8,16 @@ from homeassistant.components import button from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME -from homeassistant.core import HomeAssistant +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 .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, -) -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entity_entry_helper, -) +from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic CONF_PAYLOAD_PRESS = "payload_press" @@ -53,7 +44,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT button through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttButton, @@ -82,6 +73,7 @@ class MqttButton(MqttEntity, ButtonEntity): ).async_render self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -94,10 +86,4 @@ class MqttButton(MqttEntity, ButtonEntity): This method is a coroutine. """ payload = self._command_template(self._config[CONF_PAYLOAD_PRESS]) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 605d37834ec..fa550b9fd0c 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -19,14 +19,10 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_QOS, CONF_TOPIC -from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entity_entry_helper, -) +from .const import CONF_TOPIC +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -65,7 +61,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT camera through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttCamera, @@ -100,36 +96,27 @@ class MqttCamera(MqttEntity, Camera): """Return the config schema.""" return DISCOVERY_SCHEMA + @callback + def _image_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + if CONF_IMAGE_ENCODING in self._config: + self._last_image = b64decode(msg.payload) + else: + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) + self._last_image = msg.payload + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - if CONF_IMAGE_ENCODING in self._config: - self._last_image = b64decode(msg.payload) - else: - if TYPE_CHECKING: - assert isinstance(msg.payload, bytes) - self._last_image = msg.payload - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config[CONF_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": None, - } - }, + self.add_subscription( + CONF_TOPIC, self._image_received, None, disable_encoding=True ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 88f9598596b..18ce89beb9b 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -3,7 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable +from collections import defaultdict +from collections.abc import Callable, Coroutine, Iterable import contextlib from dataclasses import dataclass from functools import lru_cache, partial @@ -17,6 +18,7 @@ from typing import TYPE_CHECKING, Any import uuid import certifi +from typing_extensions import AsyncGenerator from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,14 +29,25 @@ from homeassistant.const import ( CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + callback, + get_hassjob_callable_job_type, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.async_ import create_eager_task -from homeassistant.util.logging import catch_log_exception +from homeassistant.util.collection import chunked_or_all +from homeassistant.util.logging import catch_log_exception, log_exception from .const import ( CONF_BIRTH_MESSAGE, @@ -59,39 +72,55 @@ from .const import ( DEFAULT_WS_HEADERS, DEFAULT_WS_PATH, DOMAIN, - MQTT_CONNECTED, - MQTT_DISCONNECTED, + MQTT_CONNECTION_STATE, PROTOCOL_5, PROTOCOL_31, TRANSPORT_WEBSOCKETS, ) from .models import ( - AsyncMessageCallbackType, + DATA_MQTT, MessageCallbackType, MqttData, PublishMessage, PublishPayloadType, ReceiveMessage, ) -from .util import get_file_path, get_mqtt_data, mqtt_config_entry_enabled +from .util import get_file_path, mqtt_config_entry_enabled if TYPE_CHECKING: # Only import for paho-mqtt type checking here, imports are done locally # because integrations should be able to optionally rely on MQTT. import paho.mqtt.client as mqtt + from .async_client import AsyncMQTTClient + _LOGGER = logging.getLogger(__name__) +MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails +PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB + DISCOVERY_COOLDOWN = 5 -INITIAL_SUBSCRIBE_COOLDOWN = 1.0 +# The initial subscribe cooldown controls how long to wait to group +# subscriptions together. This is to avoid making too many subscribe +# requests in a short period of time. If the number is too low, the +# system will be flooded with subscribe requests. If the number is too +# high, we risk being flooded with responses to the subscribe requests +# which can exceed the receive buffer size of the socket. To mitigate +# this, we increase the receive buffer size of the socket as well. +INITIAL_SUBSCRIBE_COOLDOWN = 0.5 SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 RECONNECT_INTERVAL_SECONDS = 10 -SocketType = socket.socket | ssl.SSLSocket | Any +MAX_SUBSCRIBES_PER_CALL = 500 +MAX_UNSUBSCRIBES_PER_CALL = 500 -SubscribePayloadType = str | bytes # Only bytes if encoding is None +MAX_PACKETS_TO_READ = 500 + +type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any + +type SubscribePayloadType = str | bytes # Only bytes if encoding is None def publish( @@ -122,9 +151,9 @@ async def async_publish( translation_domain=DOMAIN, translation_placeholders={"topic": topic}, ) - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] outgoing_payload = payload - if not isinstance(payload, bytes): + if not isinstance(payload, bytes) and payload is not None: if not encoding: _LOGGER.error( ( @@ -160,7 +189,7 @@ async def async_publish( async def async_subscribe( hass: HomeAssistant, topic: str, - msg_callback: AsyncMessageCallbackType | MessageCallbackType, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], qos: int = DEFAULT_QOS, encoding: str | None = DEFAULT_ENCODING, ) -> CALLBACK_TYPE: @@ -168,15 +197,28 @@ async def async_subscribe( Call the return value to unsubscribe. """ - if not mqtt_config_entry_enabled(hass): - raise HomeAssistantError( - f"Cannot subscribe to topic '{topic}', MQTT is not enabled", - translation_key="mqtt_not_setup_cannot_subscribe", - translation_domain=DOMAIN, - translation_placeholders={"topic": topic}, - ) + return async_subscribe_internal(hass, topic, msg_callback, qos, encoding) + + +@callback +def async_subscribe_internal( + hass: HomeAssistant, + topic: str, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], + qos: int = DEFAULT_QOS, + encoding: str | None = DEFAULT_ENCODING, + job_type: HassJobType | None = None, +) -> CALLBACK_TYPE: + """Subscribe to an MQTT topic. + + This function is internal to the MQTT integration + and may change at any time. It should not be considered + a stable API. + + Call the return value to unsubscribe. + """ try: - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] except KeyError as exc: raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', " @@ -185,18 +227,15 @@ async def async_subscribe( translation_domain=DOMAIN, translation_placeholders={"topic": topic}, ) from exc - return await mqtt_data.client.async_subscribe( - topic, - catch_log_exception( - msg_callback, - lambda msg: ( - f"Exception in {msg_callback.__name__} when handling msg on " - f"'{msg.topic}': '{msg.payload}'" - ), - ), - qos, - encoding, - ) + client = mqtt_data.client + if not client.connected and not mqtt_config_entry_enabled(hass): + raise HomeAssistantError( + f"Cannot subscribe to topic '{topic}', MQTT is not enabled", + translation_key="mqtt_not_setup_cannot_subscribe", + translation_domain=DOMAIN, + translation_placeholders={"topic": topic}, + ) + return client.async_subscribe(topic, msg_callback, qos, encoding, job_type) @bind_hass @@ -223,12 +262,13 @@ def subscribe( return remove -@dataclass(frozen=True) +@dataclass(slots=True, frozen=True) class Subscription: """Class to hold data about an active subscription.""" topic: str - matcher: Any + is_simple_match: bool + complex_matcher: Callable[[str], bool] | None job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] qos: int = 0 encoding: str | None = "utf-8" @@ -237,13 +277,30 @@ class Subscription: class MqttClientSetup: """Helper class to setup the paho mqtt client from config.""" - def __init__(self, config: ConfigType) -> None: - """Initialize the MQTT client setup helper.""" + _client: AsyncMQTTClient + def __init__(self, config: ConfigType) -> None: + """Initialize the MQTT client setup helper. + + self.setup must be run in an executor job. + """ + + self._config = config + + def setup(self) -> None: + """Set up the MQTT client. + + The setup of the MQTT client should be run in an executor job, + because it accesses files, so it does IO. + """ # We don't import on the top because some integrations # should be able to optionally rely on MQTT. import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel + from .async_client import AsyncMQTTClient + + config = self._config if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31: proto = mqtt.MQTTv31 elif protocol == PROTOCOL_5: @@ -255,10 +312,14 @@ class MqttClientSetup: # PAHO MQTT relies on the MQTT server to generate random client IDs. # However, that feature is not mandatory so we generate our own. client_id = mqtt.base62(uuid.uuid4().int, padding=22) - transport = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) - self._client = mqtt.Client( - client_id, protocol=proto, transport=transport, reconnect_on_failure=False + transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) + self._client = AsyncMQTTClient( + client_id, + protocol=proto, + transport=transport, + reconnect_on_failure=False, ) + self._client.setup() # Enable logging self._client.enable_logger() @@ -292,16 +353,11 @@ class MqttClientSetup: self._client.tls_insecure_set(tls_insecure) @property - def client(self) -> mqtt.Client: + def client(self) -> AsyncMQTTClient: """Return the paho MQTT client.""" return self._client -def _is_simple_match(topic: str) -> bool: - """Return if a topic is a simple match.""" - return not ("+" in topic or "#" in topic) - - class EnsureJobAfterCooldown: """Ensure a cool down period before executing a job. @@ -316,8 +372,9 @@ class EnsureJobAfterCooldown: self._loop = asyncio.get_running_loop() self._timeout = timeout self._callback = callback_job - self._task: asyncio.Future | None = None + self._task: asyncio.Task | None = None self._timer: asyncio.TimerHandle | None = None + self._next_execute_time = 0.0 def set_timeout(self, timeout: float) -> None: """Set a new timeout period.""" @@ -331,28 +388,23 @@ class EnsureJobAfterCooldown: _LOGGER.error("%s", ha_error) @callback - def _async_task_done(self, task: asyncio.Future) -> None: + def _async_task_done(self, task: asyncio.Task) -> None: """Handle task done.""" self._task = None @callback - def _async_execute(self) -> None: + def async_execute(self) -> asyncio.Task: """Execute the job.""" if self._task: # Task already running, # so we schedule another run self.async_schedule() - return + return self._task self._async_cancel_timer() self._task = create_eager_task(self._async_job()) self._task.add_done_callback(self._async_task_done) - - async def async_fire(self) -> None: - """Execute the job immediately.""" - if self._task: - await self._task - self._async_execute() + return self._task @callback def _async_cancel_timer(self) -> None: @@ -366,8 +418,28 @@ class EnsureJobAfterCooldown: """Ensure we execute after a cooldown period.""" # We want to reschedule the timer in the future # every time this is called. - self._async_cancel_timer() - self._timer = self._loop.call_later(self._timeout, self._async_execute) + next_when = self._loop.time() + self._timeout + if not self._timer: + self._timer = self._loop.call_at(next_when, self._async_timer_reached) + return + + if self._timer.when() < next_when: + # Timer already running, set the next execute time + # if it fires too early, it will get rescheduled + self._next_execute_time = next_when + + @callback + def _async_timer_reached(self) -> None: + """Handle timer fire.""" + self._timer = None + if self._loop.time() >= self._next_execute_time: + self.async_execute() + return + # Timer fired too early because there were multiple + # calls async_schedule. Reschedule the timer. + self._timer = self._loop.call_at( + self._next_execute_time, self._async_timer_reached + ) async def async_cleanup(self) -> None: """Cleanup any pending task.""" @@ -379,14 +451,14 @@ class EnsureJobAfterCooldown: await self._task except asyncio.CancelledError: pass - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error cleaning up task") class MQTT: """Home Assistant MQTT client.""" - _mqttc: mqtt.Client + _mqttc: AsyncMQTTClient _last_subscribe: float _mqtt_data: MqttData @@ -399,13 +471,15 @@ class MQTT: self.config_entry = config_entry self.conf = conf - self._simple_subscriptions: dict[str, list[Subscription]] = {} - self._wildcard_subscriptions: list[Subscription] = [] + self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict( + set + ) + self._wildcard_subscriptions: set[Subscription] = set() # _retained_topics prevents a Subscription from receiving a # retained message more than once per topic. This prevents flooding # already active subscribers when new subscribers subscribe to a topic # which has subscribed messages. - self._retained_topics: dict[Subscription, set[str]] = {} + self._retained_topics: defaultdict[Subscription, set[str]] = defaultdict(set) self.connected = False self._ha_started = asyncio.Event() self._cleanup_on_unload: list[Callable[[], None]] = [] @@ -415,12 +489,12 @@ class MQTT: self._subscribe_debouncer = EnsureJobAfterCooldown( INITIAL_SUBSCRIBE_COOLDOWN, self._async_perform_subscriptions ) - self._misc_task: asyncio.Task | None = None + self._misc_timer: asyncio.TimerHandle | None = None self._reconnect_task: asyncio.Task | None = None self._should_reconnect: bool = True self._available_future: asyncio.Future[bool] | None = None - self._max_qos: dict[str, int] = {} # topic, max qos + self._max_qos: defaultdict[str, int] = defaultdict(int) # topic, max qos self._pending_subscriptions: dict[str, int] = {} # topic, qos self._unsubscribe_debouncer = EnsureJobAfterCooldown( UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes @@ -432,6 +506,7 @@ class MQTT: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._async_ha_stop), ) ) + self._socket_buffersize: int | None = None @callback def _async_ha_started(self, _hass: HomeAssistant) -> None: @@ -442,13 +517,13 @@ class MQTT: """Handle HA stop.""" await self.async_disconnect() - def start( + async def async_start( self, mqtt_data: MqttData, ) -> None: """Start Home Assistant MQTT client.""" self._mqtt_data = mqtt_data - self.init_client() + await self.async_init_client() @property def subscriptions(self) -> list[Subscription]: @@ -464,7 +539,7 @@ class MQTT: self._cleanup_on_unload.pop()() @contextlib.asynccontextmanager - async def _async_connect_in_executor(self) -> AsyncGenerator[None, None]: + async def _async_connect_in_executor(self) -> AsyncGenerator[None]: # While we are connecting in the executor we need to # handle on_socket_open and on_socket_register_write # in the executor as well. @@ -479,9 +554,16 @@ class MQTT: mqttc.on_socket_open = self._async_on_socket_open mqttc.on_socket_register_write = self._async_on_socket_register_write - def init_client(self) -> None: + async def async_init_client(self) -> None: """Initialize paho client.""" - mqttc = MqttClientSetup(self.conf).client + with async_pause_setup(self.hass, SetupPhases.WAIT_IMPORT_PACKAGES): + await async_import_module( + self.hass, "homeassistant.components.mqtt.async_client" + ) + + mqttc_setup = MqttClientSetup(self.conf) + await self.hass.async_add_executor_job(mqttc_setup.setup) + mqttc = mqttc_setup.client # on_socket_unregister_write and _async_on_socket_close # are only ever called in the event loop mqttc.on_socket_close = self._async_on_socket_close @@ -495,6 +577,9 @@ class MQTT: mqttc.on_subscribe = self._async_mqtt_on_callback mqttc.on_unsubscribe = self._async_mqtt_on_callback + # suppress exceptions at callback + mqttc.suppress_exceptions = True + if will := self.conf.get(CONF_WILL_MESSAGE, DEFAULT_WILL): will_message = PublishMessage(**will) mqttc.will_set( @@ -506,28 +591,60 @@ class MQTT: self._mqttc = mqttc - async def _misc_loop(self) -> None: - """Start the MQTT client misc loop.""" - # pylint: disable=import-outside-toplevel - import paho.mqtt.client as mqtt - - while self._mqttc.loop_misc() == mqtt.MQTT_ERR_SUCCESS: - await asyncio.sleep(1) - @callback def _async_reader_callback(self, client: mqtt.Client) -> None: """Handle reading data from the socket.""" - if (status := client.loop_read()) != 0: + if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0: self._async_on_disconnect(status) @callback - def _async_start_misc_loop(self) -> None: - """Start the misc loop.""" - if self._misc_task is None or self._misc_task.done(): - _LOGGER.debug("%s: Starting client misc loop", self.config_entry.title) - self._misc_task = self.config_entry.async_create_background_task( - self.hass, self._misc_loop(), name="mqtt misc loop" - ) + def _async_start_misc_periodic(self) -> None: + """Start the misc periodic.""" + assert self._misc_timer is None, "Misc periodic already started" + _LOGGER.debug("%s: Starting client misc loop", self.config_entry.title) + # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt + + # Inner function to avoid having to check late import + # each time the function is called. + @callback + def _async_misc() -> None: + """Start the MQTT client misc loop.""" + if self._mqttc.loop_misc() == mqtt.MQTT_ERR_SUCCESS: + self._misc_timer = self.loop.call_at(self.loop.time() + 1, _async_misc) + + self._misc_timer = self.loop.call_at(self.loop.time() + 1, _async_misc) + + def _increase_socket_buffer_size(self, sock: SocketType) -> None: + """Increase the socket buffer size.""" + if not hasattr(sock, "setsockopt") and hasattr(sock, "_socket"): + # The WebsocketWrapper does not wrap setsockopt + # so we need to get the underlying socket + # Remove this once + # https://github.com/eclipse/paho.mqtt.python/pull/843 + # is available. + sock = sock._socket # noqa: SLF001 + + new_buffer_size = PREFERRED_BUFFER_SIZE + while True: + try: + # Some operating systems do not allow us to set the preferred + # buffer size. In that case we try some other size options. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) + except OSError as err: + if new_buffer_size <= MIN_BUFFER_SIZE: + _LOGGER.warning( + "Unable to increase the socket buffer size to %s; " + "The connection may be unstable if the MQTT broker " + "sends data at volume or a large amount of subscriptions " + "need to be processed: %s", + new_buffer_size, + err, + ) + return + new_buffer_size //= 2 + else: + return def _on_socket_open( self, client: mqtt.Client, userdata: Any, sock: SocketType @@ -545,8 +662,13 @@ class MQTT: fileno = sock.fileno() _LOGGER.debug("%s: connection opened %s", self.config_entry.title, fileno) if fileno > -1: + self._increase_socket_buffer_size(sock) self.loop.add_reader(sock, partial(self._async_reader_callback, client)) - self._async_start_misc_loop() + if not self._misc_timer: + self._async_start_misc_periodic() + # Try to consume the buffer right away so it doesn't fill up + # since add_reader will wait for the next loop iteration + self._async_reader_callback(client) @callback def _async_on_socket_close( @@ -560,8 +682,9 @@ class MQTT: self._async_connection_result(False) if fileno > -1: self.loop.remove_reader(sock) - if self._misc_task is not None and not self._misc_task.done(): - self._misc_task.cancel() + if self._misc_timer: + self._misc_timer.cancel() + self._misc_timer = None @callback def _async_writer_callback(self, client: mqtt.Client) -> None: @@ -616,8 +739,7 @@ class MQTT: msg_info.mid, qos, ) - _raise_on_error(msg_info.rc) - await self._async_wait_for_mid(msg_info.mid) + await self._async_wait_for_mid_or_raise(msg_info.mid, msg_info.rc) async def async_connect(self, client_available: asyncio.Future[bool]) -> None: """Connect to the host. Does not process messages yet.""" @@ -681,8 +803,12 @@ class MQTT: await asyncio.sleep(RECONNECT_INTERVAL_SECONDS) - async def async_disconnect(self) -> None: - """Stop the MQTT client.""" + async def async_disconnect(self, disconnect_paho_client: bool = False) -> None: + """Stop the MQTT client. + + We only disconnect grafully if disconnect_paho_client is set, but not + when Home Assistant is shut down. + """ # stop waiting for any pending subscriptions await self._subscribe_debouncer.async_cleanup() @@ -702,7 +828,9 @@ class MQTT: self._should_reconnect = False self._async_cancel_reconnect() # We do not gracefully disconnect to ensure - # the broker publishes the will message + # the broker publishes the will message unless the entry is reloaded + if disconnect_paho_client: + self._mqttc.disconnect() @callback def async_restore_tracked_subscriptions( @@ -721,12 +849,10 @@ class MQTT: The caller is responsible clearing the cache of _matching_subscriptions. """ - if _is_simple_match(subscription.topic): - self._simple_subscriptions.setdefault(subscription.topic, []).append( - subscription - ) + if subscription.is_simple_match: + self._simple_subscriptions[subscription.topic].add(subscription) else: - self._wildcard_subscriptions.append(subscription) + self._wildcard_subscriptions.add(subscription) @callback def _async_untrack_subscription(self, subscription: Subscription) -> None: @@ -738,7 +864,7 @@ class MQTT: """ topic = subscription.topic try: - if _is_simple_match(topic): + if subscription.is_simple_match: simple_subscriptions = self._simple_subscriptions simple_subscriptions[topic].remove(subscription) if not simple_subscriptions[topic]: @@ -755,8 +881,8 @@ class MQTT: """Queue requested subscriptions.""" for subscription in subscriptions: topic, qos = subscription - max_qos = max(qos, self._max_qos.setdefault(topic, qos)) - self._max_qos[topic] = max_qos + if (max_qos := self._max_qos[topic]) < qos: + self._max_qos[topic] = (max_qos := qos) self._pending_subscriptions[topic] = max_qos # Cancel any pending unsubscribe since we are subscribing now if topic in self._pending_unsubscribes: @@ -765,23 +891,51 @@ class MQTT: return self._subscribe_debouncer.async_schedule() - async def async_subscribe( + def _exception_message( + self, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], + msg: ReceiveMessage, + ) -> str: + """Return a string with the exception message.""" + # if msg_callback is a partial we return the name of the first argument + if isinstance(msg_callback, partial): + call_back_name = getattr(msg_callback.args[0], "__name__") + else: + call_back_name = getattr(msg_callback, "__name__") + return ( + f"Exception in {call_back_name} when handling msg on " + f"'{msg.topic}': '{msg.payload}'" # type: ignore[str-bytes-safe] + ) + + @callback + def async_subscribe( self, topic: str, - msg_callback: AsyncMessageCallbackType | MessageCallbackType, + msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], qos: int, encoding: str | None = None, + job_type: HassJobType | None = None, ) -> Callable[[], None]: - """Set up a subscription to a topic with the provided qos. - - This method is a coroutine. - """ + """Set up a subscription to a topic with the provided qos.""" if not isinstance(topic, str): raise HomeAssistantError("Topic needs to be a string!") - subscription = Subscription( - topic, _matcher_for_topic(topic), HassJob(msg_callback), qos, encoding - ) + if job_type is None: + job_type = get_hassjob_callable_job_type(msg_callback) + if job_type is not HassJobType.Callback: + # Only wrap the callback with catch_log_exception + # if it is not a simple callback since we catch + # exceptions for simple callbacks inline for + # performance reasons. + msg_callback = catch_log_exception( + msg_callback, partial(self._exception_message, msg_callback) + ) + + job = HassJob(msg_callback, job_type=job_type) + is_simple_match = not ("+" in topic or "#" in topic) + matcher = None if is_simple_match else _matcher_for_topic(topic) + + subscription = Subscription(topic, is_simple_match, matcher, job, qos, encoding) self._async_track_subscription(subscription) self._matching_subscriptions.cache_clear() @@ -789,18 +943,18 @@ class MQTT: if self.connected: self._async_queue_subscriptions(((topic, qos),)) - @callback - def async_remove() -> None: - """Remove subscription.""" - self._async_untrack_subscription(subscription) - self._matching_subscriptions.cache_clear() - if subscription in self._retained_topics: - del self._retained_topics[subscription] - # Only unsubscribe if currently connected - if self.connected: - self._async_unsubscribe(topic) + return partial(self._async_remove, subscription) - return async_remove + @callback + def _async_remove(self, subscription: Subscription) -> None: + """Remove subscription.""" + self._async_untrack_subscription(subscription) + self._matching_subscriptions.cache_clear() + if subscription in self._retained_topics: + del self._retained_topics[subscription] + # Only unsubscribe if currently connected + if self.connected: + self._async_unsubscribe(subscription.topic) @callback def _async_unsubscribe(self, topic: str) -> None: @@ -842,16 +996,20 @@ class MQTT: self._pending_subscriptions = {} subscription_list = list(subscriptions.items()) - result, mid = self._mqttc.subscribe(subscription_list) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) - for topic, qos in subscriptions.items(): - _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) - self._last_subscribe = time.monotonic() + for chunk in chunked_or_all(subscription_list, MAX_SUBSCRIBES_PER_CALL): + chunk_list = list(chunk) - if result == 0: - await self._async_wait_for_mid(mid) - else: - _raise_on_error(result) + result, mid = self._mqttc.subscribe(chunk_list) + + if debug_enabled: + _LOGGER.debug( + "Subscribing with mid: %s to topics with qos: %s", mid, chunk_list + ) + self._last_subscribe = time.monotonic() + + await self._async_wait_for_mid_or_raise(mid, result) async def _async_perform_unsubscribes(self) -> None: """Perform pending MQTT client unsubscribes.""" @@ -860,24 +1018,30 @@ class MQTT: topics = list(self._pending_unsubscribes) self._pending_unsubscribes = set() + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) - result, mid = self._mqttc.unsubscribe(topics) - _raise_on_error(result) - for topic in topics: - _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) + for chunk in chunked_or_all(topics, MAX_UNSUBSCRIBES_PER_CALL): + chunk_list = list(chunk) - await self._async_wait_for_mid(mid) + result, mid = self._mqttc.unsubscribe(chunk_list) + if debug_enabled: + _LOGGER.debug( + "Unsubscribing with mid: %s to topics: %s", mid, chunk_list + ) + + await self._async_wait_for_mid_or_raise(mid, result) async def _async_resubscribe_and_publish_birth_message( self, birth_message: PublishMessage ) -> None: """Resubscribe to all topics and publish birth message.""" - await self._async_perform_subscriptions() + self._async_queue_resubscribe() + self._subscribe_debouncer.async_schedule() await self._ha_started.wait() # Wait for Home Assistant to start await self._discovery_cooldown() # Wait for MQTT discovery to cool down # Update subscribe cooldown period to a shorter time # and make sure we flush the debouncer - await self._subscribe_debouncer.async_fire() + await self._subscribe_debouncer.async_execute() self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) await self.async_publish( topic=birth_message.topic, @@ -885,6 +1049,7 @@ class MQTT: qos=birth_message.qos, retain=birth_message.retain, ) + _LOGGER.info("MQTT client initialized, birth message sent") @callback def _async_mqtt_on_connect( @@ -919,15 +1084,14 @@ class MQTT: return self.connected = True - async_dispatcher_send(self.hass, MQTT_CONNECTED) - _LOGGER.info( + async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, True) + _LOGGER.debug( "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), result_code, ) - self._async_queue_resubscribe() birth: dict[str, Any] if birth := self.conf.get(CONF_BIRTH_MESSAGE, DEFAULT_BIRTH): birth_message = PublishMessage(**birth) @@ -938,12 +1102,8 @@ class MQTT: ) else: # Update subscribe cooldown period to a shorter time - self.config_entry.async_create_background_task( - self.hass, - self._async_perform_subscriptions(), - name="mqtt re-subscribe", - ) - self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) + self._async_queue_resubscribe() + self._subscribe_debouncer.async_schedule() self._async_connection_result(True) @@ -977,7 +1137,9 @@ class MQTT: subscriptions.extend( subscription for subscription in self._wildcard_subscriptions - if subscription.matcher(topic) + # mypy doesn't know that complex_matcher is always set when + # is_simple_match is False + if subscription.complex_matcher(topic) # type: ignore[misc] ) return subscriptions @@ -985,10 +1147,21 @@ class MQTT: def _async_mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: - topic = msg.topic - # msg.topic is a property that decodes the topic to a string - # every time it is accessed. Save the result to avoid - # decoding the same topic multiple times. + try: + # msg.topic is a property that decodes the topic to a string + # every time it is accessed. Save the result to avoid + # decoding the same topic multiple times. + topic = msg.topic + except UnicodeDecodeError: + bare_topic: bytes = getattr(msg, "_topic") + _LOGGER.warning( + "Skipping received%s message on invalid topic %s (qos=%s): %s", + " retained" if msg.retain else "", + bare_topic, + msg.qos, + msg.payload[0:8192], + ) + return _LOGGER.debug( "Received%s message on %s (qos=%s): %s", " retained" if msg.retain else "", @@ -1001,7 +1174,7 @@ class MQTT: for subscription in subscriptions: if msg.retain: - retained_topics = self._retained_topics.setdefault(subscription, set()) + retained_topics = self._retained_topics[subscription] # Skip if the subscription already received a retained message if topic in retained_topics: continue @@ -1038,7 +1211,18 @@ class MQTT: msg_cache_by_subscription_topic[subscription_topic] = receive_msg else: receive_msg = msg_cache_by_subscription_topic[subscription_topic] - self.hass.async_run_hass_job(subscription.job, receive_msg) + job = subscription.job + if job.job_type is HassJobType.Callback: + # We do not wrap Callback jobs in catch_log_exception since + # its expensive and we have to do it 2x for every entity + try: + job.target(receive_msg) + except Exception: # noqa: BLE001 + log_exception( + partial(self._exception_message, job.target, receive_msg) + ) + else: + self.hass.async_run_hass_job(job, receive_msg) self._mqtt_data.state_write_requests.process_write_state_requests(msg) @callback @@ -1090,8 +1274,9 @@ class MQTT: # result is set make sure the first connection result is set self._async_connection_result(False) self.connected = False - async_dispatcher_send(self.hass, MQTT_DISCONNECTED) - _LOGGER.warning( + async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False) + _LOGGER.log( + logging.INFO if result_code == 0 else logging.DEBUG, "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), @@ -1104,10 +1289,18 @@ class MQTT: if not future.done(): future.set_exception(asyncio.TimeoutError) - async def _async_wait_for_mid(self, mid: int) -> None: - """Wait for ACK from broker.""" - # Create the mid event if not created, either _mqtt_handle_mid or _async_wait_for_mid - # may be executed first. + async def _async_wait_for_mid_or_raise(self, mid: int, result_code: int) -> None: + """Wait for ACK from broker or raise on error.""" + if result_code != 0: + # pylint: disable-next=import-outside-toplevel + import paho.mqtt.client as mqtt + + raise HomeAssistantError( + f"Error talking to MQTT: {mqtt.error_string(result_code)}" + ) + + # Create the mid event if not created, either _mqtt_handle_mid or + # _async_wait_for_mid_or_raise may be executed first. future = self._async_get_mid_future(mid) loop = self.hass.loop timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future) @@ -1130,9 +1323,7 @@ class MQTT: last_discovery = self._mqtt_data.last_discovery last_subscribe = now if self._pending_subscriptions else self._last_subscribe - wait_until = max( - last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN - ) + wait_until = max(last_discovery, last_subscribe) + DISCOVERY_COOLDOWN while now < wait_until: await asyncio.sleep(wait_until - now) now = time.monotonic() @@ -1140,21 +1331,10 @@ class MQTT: last_subscribe = ( now if self._pending_subscriptions else self._last_subscribe ) - wait_until = max( - last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN - ) + wait_until = max(last_discovery, last_subscribe) + DISCOVERY_COOLDOWN -def _raise_on_error(result_code: int) -> None: - """Raise error if error result.""" - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - - if result_code and (message := mqtt.error_string(result_code)): - raise HomeAssistantError(f"Error talking to MQTT: {message}") - - -def _matcher_for_topic(subscription: str) -> Any: +def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: # pylint: disable-next=import-outside-toplevel from paho.mqtt.matcher import MQTTMatcher diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 972bf02ecea..f63c9ecc7ae 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable +from functools import partial import logging from typing import Any @@ -58,7 +59,6 @@ from .const import ( CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, CONF_CURRENT_TEMP_TOPIC, - CONF_ENCODING, CONF_MODE_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TOPIC, CONF_MODE_LIST, @@ -67,7 +67,6 @@ from .const import ( CONF_POWER_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, - CONF_QOS, CONF_RETAIN, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TOPIC, @@ -79,13 +78,7 @@ from .const import ( DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -93,6 +86,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -385,7 +379,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT climate through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttClimate, @@ -413,22 +407,6 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - def add_subscription( - self, - topics: dict[str, dict[str, Any]], - topic: str, - msg_callback: Callable[[ReceiveMessage], None], - ) -> None: - """Add a subscription.""" - qos: int = self._config[CONF_QOS] - if topic in self._topic and self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": qos, - "encoding": self._config[CONF_ENCODING] or None, - } - def render_template( self, msg: ReceiveMessage, template_name: str ) -> ReceivePayloadType: @@ -438,7 +416,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback def handle_climate_attribute_received( - self, msg: ReceiveMessage, template_name: str, attr: str + self, template_name: str, attr: str, msg: ReceiveMessage ) -> None: """Handle climate attributes coming via MQTT.""" payload = self.render_template(msg, template_name) @@ -456,81 +434,55 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): except ValueError: _LOGGER.error("Could not parse %s from %s", template_name, payload) + @callback def prepare_subscribe_topics( self, - topics: dict[str, dict[str, Any]], ) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_temperature"}) - def handle_current_temperature_received(msg: ReceiveMessage) -> None: - """Handle current temperature coming via MQTT.""" - self.handle_climate_attribute_received( - msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" - ) - self.add_subscription( - topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received + CONF_CURRENT_TEMP_TOPIC, + partial( + self.handle_climate_attribute_received, + CONF_CURRENT_TEMP_TEMPLATE, + "_attr_current_temperature", + ), + {"_attr_current_temperature"}, ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_target_temperature"}) - def handle_target_temperature_received(msg: ReceiveMessage) -> None: - """Handle target temperature coming via MQTT.""" - self.handle_climate_attribute_received( - msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" - ) - self.add_subscription( - topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received + CONF_TEMP_STATE_TOPIC, + partial( + self.handle_climate_attribute_received, + CONF_TEMP_STATE_TEMPLATE, + "_attr_target_temperature", + ), + {"_attr_target_temperature"}, ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_target_temperature_low"}) - def handle_temperature_low_received(msg: ReceiveMessage) -> None: - """Handle target temperature low coming via MQTT.""" - self.handle_climate_attribute_received( - msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" - ) - self.add_subscription( - topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received + CONF_TEMP_LOW_STATE_TOPIC, + partial( + self.handle_climate_attribute_received, + CONF_TEMP_LOW_STATE_TEMPLATE, + "_attr_target_temperature_low", + ), + {"_attr_target_temperature_low"}, ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_target_temperature_high"}) - def handle_temperature_high_received(msg: ReceiveMessage) -> None: - """Handle target temperature high coming via MQTT.""" - self.handle_climate_attribute_received( - msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" - ) - self.add_subscription( - topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received - ) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + CONF_TEMP_HIGH_STATE_TOPIC, + partial( + self.handle_climate_attribute_received, + CONF_TEMP_HIGH_STATE_TEMPLATE, + "_attr_target_temperature_high", + ), + {"_attr_target_temperature_high"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def _publish(self, topic: str, payload: PublishPayloadType) -> None: if self._topic[topic] is not None: - await self.async_publish( - self._topic[topic], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._topic[topic], payload) async def _set_climate_attribute( self, @@ -714,149 +666,128 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self._attr_supported_features = support + @callback + def _handle_action_received(self, msg: ReceiveMessage) -> None: + """Handle receiving action via MQTT.""" + payload = self.render_template(msg, CONF_ACTION_TEMPLATE) + if not payload: + _LOGGER.debug( + "Invalid %s action: %s, ignoring", + [e.value for e in HVACAction], + payload, + ) + return + if payload == PAYLOAD_NONE: + self._attr_hvac_action = None + return + try: + self._attr_hvac_action = HVACAction(str(payload)) + except ValueError: + _LOGGER.warning( + "Invalid %s action: %s", + [e.value for e in HVACAction], + payload, + ) + return + + @callback + def _handle_mode_received( + self, template_name: str, attr: str, mode_list: str, msg: ReceiveMessage + ) -> None: + """Handle receiving listed mode via MQTT.""" + payload = self.render_template(msg, template_name) + + if payload == PAYLOAD_NONE: + setattr(self, attr, None) + elif payload not in self._config[mode_list]: + _LOGGER.warning("Invalid %s mode: %s", mode_list, payload) + else: + setattr(self, attr, payload) + + @callback + def _handle_preset_mode_received(self, msg: ReceiveMessage) -> None: + """Handle receiving preset mode via MQTT.""" + preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) + if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: + self._attr_preset_mode = PRESET_NONE + return + if not preset_mode: + _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) + return + if not self._attr_preset_modes or preset_mode not in self._attr_preset_modes: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid preset mode", + msg.payload, + msg.topic, + preset_mode, + ) + else: + self._attr_preset_mode = str(preset_mode) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_hvac_action"}) - def handle_action_received(msg: ReceiveMessage) -> None: - """Handle receiving action via MQTT.""" - payload = self.render_template(msg, CONF_ACTION_TEMPLATE) - if not payload or payload == PAYLOAD_NONE: - _LOGGER.debug( - "Invalid %s action: %s, ignoring", - [e.value for e in HVACAction], - payload, - ) - return - try: - self._attr_hvac_action = HVACAction(str(payload)) - except ValueError: - _LOGGER.warning( - "Invalid %s action: %s", - [e.value for e in HVACAction], - payload, - ) - return - - self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_humidity"}) - def handle_current_humidity_received(msg: ReceiveMessage) -> None: - """Handle current humidity coming via MQTT.""" - self.handle_climate_attribute_received( - msg, CONF_CURRENT_HUMIDITY_TEMPLATE, "_attr_current_humidity" - ) - + # add subscriptions for MqttClimate self.add_subscription( - topics, CONF_CURRENT_HUMIDITY_TOPIC, handle_current_humidity_received + CONF_ACTION_TOPIC, + self._handle_action_received, + {"_attr_hvac_action"}, ) - - @callback - @write_state_on_attr_change(self, {"_attr_target_humidity"}) - @log_messages(self.hass, self.entity_id) - def handle_target_humidity_received(msg: ReceiveMessage) -> None: - """Handle target humidity coming via MQTT.""" - - self.handle_climate_attribute_received( - msg, CONF_HUMIDITY_STATE_TEMPLATE, "_attr_target_humidity" - ) - self.add_subscription( - topics, CONF_HUMIDITY_STATE_TOPIC, handle_target_humidity_received + CONF_CURRENT_HUMIDITY_TOPIC, + partial( + self.handle_climate_attribute_received, + CONF_CURRENT_HUMIDITY_TEMPLATE, + "_attr_current_humidity", + ), + {"_attr_current_humidity"}, ) - - @callback - def handle_mode_received( - msg: ReceiveMessage, template_name: str, attr: str, mode_list: str - ) -> None: - """Handle receiving listed mode via MQTT.""" - payload = self.render_template(msg, template_name) - - if payload not in self._config[mode_list]: - _LOGGER.error("Invalid %s mode: %s", mode_list, payload) - else: - setattr(self, attr, payload) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_hvac_mode"}) - def handle_current_mode_received(msg: ReceiveMessage) -> None: - """Handle receiving mode via MQTT.""" - handle_mode_received( - msg, CONF_MODE_STATE_TEMPLATE, "_attr_hvac_mode", CONF_MODE_LIST - ) - self.add_subscription( - topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + CONF_HUMIDITY_STATE_TOPIC, + partial( + self.handle_climate_attribute_received, + CONF_HUMIDITY_STATE_TEMPLATE, + "_attr_target_humidity", + ), + {"_attr_target_humidity"}, ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_fan_mode"}) - def handle_fan_mode_received(msg: ReceiveMessage) -> None: - """Handle receiving fan mode via MQTT.""" - handle_mode_received( - msg, + self.add_subscription( + CONF_MODE_STATE_TOPIC, + partial( + self._handle_mode_received, + CONF_MODE_STATE_TEMPLATE, + "_attr_hvac_mode", + CONF_MODE_LIST, + ), + {"_attr_hvac_mode"}, + ) + self.add_subscription( + CONF_FAN_MODE_STATE_TOPIC, + partial( + self._handle_mode_received, CONF_FAN_MODE_STATE_TEMPLATE, "_attr_fan_mode", CONF_FAN_MODE_LIST, - ) - - self.add_subscription( - topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received + ), + {"_attr_fan_mode"}, ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_swing_mode"}) - def handle_swing_mode_received(msg: ReceiveMessage) -> None: - """Handle receiving swing mode via MQTT.""" - handle_mode_received( - msg, + self.add_subscription( + CONF_SWING_MODE_STATE_TOPIC, + partial( + self._handle_mode_received, CONF_SWING_MODE_STATE_TEMPLATE, "_attr_swing_mode", CONF_SWING_MODE_LIST, - ) - - self.add_subscription( - topics, CONF_SWING_MODE_STATE_TOPIC, handle_swing_mode_received + ), + {"_attr_swing_mode"}, ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_preset_mode"}) - def handle_preset_mode_received(msg: ReceiveMessage) -> None: - """Handle receiving preset mode via MQTT.""" - preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) - if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: - self._attr_preset_mode = PRESET_NONE - return - if not preset_mode: - _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) - return - if ( - not self._attr_preset_modes - or preset_mode not in self._attr_preset_modes - ): - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid preset mode", - msg.payload, - msg.topic, - preset_mode, - ) - else: - self._attr_preset_mode = str(preset_mode) - self.add_subscription( - topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received + CONF_PRESET_MODE_STATE_TOPIC, + self._handle_preset_mode_received, + {"_attr_preset_mode"}, ) - - self.prepare_subscribe_topics(topics) + # add subscriptions for MqttTemperatureControlEntity + self.prepare_subscribe_topics() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 2c5d921e1db..17dfc6512b3 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -834,7 +834,9 @@ def try_connection( # should be able to optionally rely on MQTT. import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel - client = MqttClientSetup(user_input).client + mqtt_client_setup = MqttClientSetup(user_input) + mqtt_client_setup.setup() + client = mqtt_client_setup.client result: queue.Queue[bool] = queue.Queue(maxsize=1) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 7eca266edfa..9a8e6ae22df 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -14,13 +14,28 @@ ATTR_RETAIN = "retain" ATTR_SERIAL_NUMBER = "serial_number" ATTR_TOPIC = "topic" +AVAILABILITY_ALL = "all" +AVAILABILITY_ANY = "any" +AVAILABILITY_LATEST = "latest" + +AVAILABILITY_MODES = [AVAILABILITY_ALL, AVAILABILITY_ANY, AVAILABILITY_LATEST] + +CONF_PAYLOAD_AVAILABLE = "payload_available" +CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" + CONF_AVAILABILITY = "availability" + +CONF_AVAILABILITY_MODE = "availability_mode" +CONF_AVAILABILITY_TEMPLATE = "availability_template" +CONF_AVAILABILITY_TOPIC = "availability_topic" CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" CONF_COMMAND_TEMPLATE = "command_template" CONF_COMMAND_TOPIC = "command_topic" CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" +CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" +CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" CONF_KEEPALIVE = "keepalive" CONF_ORIGIN = "origin" CONF_QOS = ATTR_QOS @@ -42,6 +57,7 @@ CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template" CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic" CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template" CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic" +CONF_ENABLED_BY_DEFAULT = "enabled_by_default" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" @@ -86,9 +102,6 @@ CONF_CONFIGURATION_URL = "configuration_url" CONF_OBJECT_ID = "object_id" CONF_SUPPORT_URL = "support_url" -DATA_MQTT = "mqtt" -DATA_MQTT_AVAILABLE = "mqtt_client_available" - DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" DEFAULT_DISCOVERY = True @@ -136,8 +149,7 @@ DEFAULT_WILL = { DOMAIN = "mqtt" -MQTT_CONNECTED = "mqtt_connected" -MQTT_DISCONNECTED = "mqtt_disconnected" +MQTT_CONNECTION_STATE = "mqtt_connection_state" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" @@ -172,3 +184,34 @@ RELOADABLE_PLATFORMS = [ ] TEMPLATE_ERRORS = (jinja2.TemplateError, TemplateError, TypeError, ValueError) + +SUPPORTED_COMPONENTS = { + "alarm_control_panel", + "binary_sensor", + "button", + "camera", + "climate", + "cover", + "device_automation", + "device_tracker", + "event", + "fan", + "humidifier", + "image", + "lawn_mower", + "light", + "lock", + "notify", + "number", + "scene", + "siren", + "select", + "sensor", + "switch", + "tag", + "text", + "update", + "vacuum", + "valve", + "water_heater", +} diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index a659b1bb0c1..bd79c0f9470 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -42,13 +42,11 @@ 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, @@ -61,15 +59,11 @@ from .const import ( DEFAULT_POSITION_CLOSED, DEFAULT_POSITION_OPEN, DEFAULT_RETAIN, + PAYLOAD_NONE, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -226,7 +220,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT cover through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttCover, @@ -354,79 +348,118 @@ class MqttCover(MqttEntity, CoverEntity): self._attr_supported_features = supported_features @callback - def _update_state(self, state: str) -> None: + def _update_state(self, state: str | None) -> None: """Update the cover state.""" - self._attr_is_closed = state == STATE_CLOSED + if state is None: + # Reset the state to `unknown` + self._attr_is_closed = None + else: + self._attr_is_closed = state == STATE_CLOSED self._attr_is_opening = state == STATE_OPENING self._attr_is_closing = state == STATE_CLOSING - def _prepare_subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - topics = {} + @callback + def _tilt_message_received(self, msg: ReceiveMessage) -> None: + """Handle tilt updates.""" + payload = self._tilt_status_template(msg.payload) - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_cover_tilt_position"}) - def tilt_message_received(msg: ReceiveMessage) -> None: - """Handle tilt updates.""" - payload = self._tilt_status_template(msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty tilt message from '%s'", msg.topic) + return - if not payload: - _LOGGER.debug("Ignoring empty tilt message from '%s'", msg.topic) - return + self.tilt_payload_received(payload) - self.tilt_payload_received(payload) + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload = self._value_template(msg.payload) - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_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) + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return - if not payload: - _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) - return - - state: str - if payload == self._config[CONF_STATE_STOPPED]: - if self._config.get(CONF_GET_POSITION_TOPIC) is not None: - state = ( - STATE_CLOSED - if self._attr_current_cover_position == DEFAULT_POSITION_CLOSED - else STATE_OPEN - ) - else: - state = ( - STATE_CLOSED - if self.state in [STATE_CLOSED, STATE_CLOSING] - else STATE_OPEN - ) - elif payload == self._config[CONF_STATE_OPENING]: - state = STATE_OPENING - elif payload == self._config[CONF_STATE_CLOSING]: - state = STATE_CLOSING - elif payload == self._config[CONF_STATE_OPEN]: - state = STATE_OPEN - elif payload == self._config[CONF_STATE_CLOSED]: - state = STATE_CLOSED + state: str | None + if payload == self._config[CONF_STATE_STOPPED]: + if self._config.get(CONF_GET_POSITION_TOPIC) is not None: + state = ( + STATE_CLOSED + if self._attr_current_cover_position == DEFAULT_POSITION_CLOSED + else STATE_OPEN + ) else: + state = ( + STATE_CLOSED + if self.state in [STATE_CLOSED, STATE_CLOSING] + else STATE_OPEN + ) + elif payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + elif payload == self._config[CONF_STATE_OPEN]: + state = STATE_OPEN + elif payload == self._config[CONF_STATE_CLOSED]: + state = STATE_CLOSED + elif payload == PAYLOAD_NONE: + state = None + else: + _LOGGER.warning( + ( + "Payload is not supported (e.g. open, closed, opening, closing," + " stopped): %s" + ), + payload, + ) + return + self._update_state(state) + + @callback + def _position_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT position messages.""" + payload: ReceivePayloadType = self._get_position_template(msg.payload) + payload_dict: Any = None + + if not payload: + _LOGGER.debug("Ignoring empty position message from '%s'", msg.topic) + return + + with suppress(*JSON_DECODE_EXCEPTIONS): + payload_dict = json_loads(payload) + + if payload_dict and isinstance(payload_dict, dict): + if "position" not in payload_dict: _LOGGER.warning( - ( - "Payload is not supported (e.g. open, closed, opening, closing," - " stopped): %s" - ), - payload, + "Template (position_template) returned JSON without position" + " attribute" ) return - self._update_state(state) + if "tilt_position" in payload_dict: + if not self._config.get(CONF_TILT_STATE_OPTIMISTIC): + # reset forced set tilt optimistic + self._tilt_optimistic = False + self.tilt_payload_received(payload_dict["tilt_position"]) + payload = payload_dict["position"] - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + try: + 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 = min(100, max(0, percentage_payload)) + if self._config.get(CONF_STATE_TOPIC) is None: + self._update_state( + STATE_CLOSED if self.current_cover_position == 0 else STATE_OPEN + ) + + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription( + CONF_GET_POSITION_TOPIC, + self._position_message_received, { "_attr_current_cover_position", "_attr_current_cover_tilt_position", @@ -435,89 +468,28 @@ class MqttCover(MqttEntity, CoverEntity): "_attr_is_opening", }, ) - def position_message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT position messages.""" - payload: ReceivePayloadType = self._get_position_template(msg.payload) - payload_dict: Any = None - - if not payload: - _LOGGER.debug("Ignoring empty position message from '%s'", msg.topic) - return - - with suppress(*JSON_DECODE_EXCEPTIONS): - payload_dict = json_loads(payload) - - if payload_dict and isinstance(payload_dict, dict): - if "position" not in payload_dict: - _LOGGER.warning( - "Template (position_template) returned JSON without position" - " attribute" - ) - return - if "tilt_position" in payload_dict: - if not self._config.get(CONF_TILT_STATE_OPTIMISTIC): - # reset forced set tilt optimistic - self._tilt_optimistic = False - self.tilt_payload_received(payload_dict["tilt_position"]) - payload = payload_dict["position"] - - try: - 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 = min(100, max(0, percentage_payload)) - if self._config.get(CONF_STATE_TOPIC) is None: - self._update_state( - STATE_CLOSED if self.current_cover_position == 0 else STATE_OPEN - ) - - if self._config.get(CONF_GET_POSITION_TOPIC): - topics["get_position_topic"] = { - "topic": self._config.get(CONF_GET_POSITION_TOPIC), - "msg_callback": position_message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - - 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, - } - - if self._config.get(CONF_TILT_STATUS_TOPIC) is not None: - topics["tilt_status_topic"] = { - "topic": self._config.get(CONF_TILT_STATUS_TOPIC), - "msg_callback": tilt_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 + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_is_closed", "_attr_is_closing", "_attr_is_opening"}, + ) + self.add_subscription( + CONF_TILT_STATUS_TOPIC, + self._tilt_message_received, + {"_attr_current_cover_tilt_position"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_OPEN], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_OPEN] ) if self._optimistic: # Optimistically assume that cover has changed state. @@ -531,12 +503,8 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_CLOSE], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_CLOSE] ) if self._optimistic: # Optimistically assume that cover has changed state. @@ -550,12 +518,8 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_STOP], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_STOP] ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: @@ -570,12 +534,8 @@ class MqttCover(MqttEntity, CoverEntity): "tilt_max": self._config.get(CONF_TILT_MAX), } tilt_payload = self._set_tilt_template(tilt_open_position, variables=variables) - await self.async_publish( - self._config[CONF_TILT_COMMAND_TOPIC], - tilt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], tilt_payload ) if self._tilt_optimistic: self._attr_current_cover_tilt_position = self._tilt_open_percentage @@ -595,12 +555,8 @@ class MqttCover(MqttEntity, CoverEntity): tilt_payload = self._set_tilt_template( tilt_closed_position, variables=variables ) - await self.async_publish( - self._config[CONF_TILT_COMMAND_TOPIC], - tilt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], tilt_payload ) if self._tilt_optimistic: self._attr_current_cover_tilt_position = self._tilt_closed_percentage @@ -623,13 +579,8 @@ class MqttCover(MqttEntity, CoverEntity): "tilt_max": self._config.get(CONF_TILT_MAX), } tilt_rendered = self._set_tilt_template(tilt_ranged, variables=variables) - - await self.async_publish( - self._config[CONF_TILT_COMMAND_TOPIC], - tilt_rendered, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], tilt_rendered ) if self._tilt_optimistic: _LOGGER.debug("Set tilt value optimistic") @@ -653,13 +604,8 @@ class MqttCover(MqttEntity, CoverEntity): position_rendered = self._set_position_template( position_ranged, variables=variables ) - - await self.async_publish( - self._config[CONF_SET_POSITION_TOPIC], - position_rendered, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_SET_POSITION_TOPIC], position_rendered ) if self._optimistic: self._update_state( diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index e84dedde785..a8fd318b1e9 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -3,10 +3,8 @@ from __future__ import annotations from collections import deque -from collections.abc import Callable from dataclasses import dataclass import datetime as dt -from functools import wraps import time from typing import TYPE_CHECKING, Any @@ -16,40 +14,11 @@ from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util import dt as dt_util from .const import ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC -from .models import MessageCallbackType, PublishPayloadType -from .util import get_mqtt_data +from .models import DATA_MQTT, PublishPayloadType STORED_MESSAGES = 10 -def log_messages( - hass: HomeAssistant, entity_id: str -) -> Callable[[MessageCallbackType], MessageCallbackType]: - """Wrap an MQTT message callback to support message logging.""" - - debug_info_entities = get_mqtt_data(hass).debug_info_entities - - def _log_message(msg: Any) -> None: - """Log message.""" - messages = debug_info_entities[entity_id]["subscriptions"][ - msg.subscribed_topic - ]["messages"] - if msg not in messages: - messages.append(msg) - - def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: - @wraps(msg_callback) - def wrapper(msg: Any) -> None: - """Log message.""" - _log_message(msg) - msg_callback(msg) - - setattr(wrapper, "__entity_id", entity_id) - return wrapper - - return _decorator - - @dataclass class TimestampedPublishMessage: """MQTT Message.""" @@ -70,7 +39,7 @@ def log_message( retain: bool, ) -> None: """Log an outgoing MQTT message.""" - entity_info = get_mqtt_data(hass).debug_info_entities.setdefault( + entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault( entity_id, {"subscriptions": {}, "discovery_data": {}, "transmitted": {}} ) if topic not in entity_info["transmitted"]: @@ -84,42 +53,40 @@ def log_message( def add_subscription( - hass: HomeAssistant, - message_callback: MessageCallbackType, - subscription: str, + hass: HomeAssistant, subscription: str, entity_id: str | None ) -> None: """Prepare debug data for subscription.""" - if entity_id := getattr(message_callback, "__entity_id", None): - entity_info = get_mqtt_data(hass).debug_info_entities.setdefault( + if entity_id: + entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault( entity_id, {"subscriptions": {}, "discovery_data": {}, "transmitted": {}} ) if subscription not in entity_info["subscriptions"]: entity_info["subscriptions"][subscription] = { - "count": 0, + "count": 1, "messages": deque([], STORED_MESSAGES), } - entity_info["subscriptions"][subscription]["count"] += 1 + else: + entity_info["subscriptions"][subscription]["count"] += 1 def remove_subscription( - hass: HomeAssistant, - message_callback: MessageCallbackType, - subscription: str, + hass: HomeAssistant, subscription: str, entity_id: str | None ) -> None: """Remove debug data for subscription if it exists.""" - if (entity_id := getattr(message_callback, "__entity_id", None)) and entity_id in ( - debug_info_entities := get_mqtt_data(hass).debug_info_entities + if entity_id and entity_id in ( + debug_info_entities := hass.data[DATA_MQTT].debug_info_entities ): - debug_info_entities[entity_id]["subscriptions"][subscription]["count"] -= 1 - if not debug_info_entities[entity_id]["subscriptions"][subscription]["count"]: - debug_info_entities[entity_id]["subscriptions"].pop(subscription) + subscriptions = debug_info_entities[entity_id]["subscriptions"] + subscriptions[subscription]["count"] -= 1 + if not subscriptions[subscription]["count"]: + del subscriptions[subscription] def add_entity_discovery_data( hass: HomeAssistant, discovery_data: DiscoveryInfoType, entity_id: str ) -> None: """Add discovery data.""" - entity_info = get_mqtt_data(hass).debug_info_entities.setdefault( + entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault( entity_id, {"subscriptions": {}, "discovery_data": {}, "transmitted": {}} ) entity_info["discovery_data"] = discovery_data @@ -129,7 +96,7 @@ def update_entity_discovery_data( hass: HomeAssistant, discovery_payload: DiscoveryInfoType, entity_id: str ) -> None: """Update discovery data.""" - discovery_data = get_mqtt_data(hass).debug_info_entities[entity_id][ + discovery_data = hass.data[DATA_MQTT].debug_info_entities[entity_id][ "discovery_data" ] if TYPE_CHECKING: @@ -139,8 +106,8 @@ def update_entity_discovery_data( def remove_entity_data(hass: HomeAssistant, entity_id: str) -> None: """Remove discovery data.""" - if entity_id in (debug_info_entities := get_mqtt_data(hass).debug_info_entities): - debug_info_entities.pop(entity_id) + if entity_id in (debug_info_entities := hass.data[DATA_MQTT].debug_info_entities): + del debug_info_entities[entity_id] def add_trigger_discovery_data( @@ -150,7 +117,7 @@ def add_trigger_discovery_data( device_id: str, ) -> None: """Add discovery data.""" - get_mqtt_data(hass).debug_info_triggers[discovery_hash] = { + hass.data[DATA_MQTT].debug_info_triggers[discovery_hash] = { "device_id": device_id, "discovery_data": discovery_data, } @@ -162,7 +129,7 @@ def update_trigger_discovery_data( discovery_payload: DiscoveryInfoType, ) -> None: """Update discovery data.""" - get_mqtt_data(hass).debug_info_triggers[discovery_hash]["discovery_data"][ + hass.data[DATA_MQTT].debug_info_triggers[discovery_hash]["discovery_data"][ ATTR_DISCOVERY_PAYLOAD ] = discovery_payload @@ -171,11 +138,11 @@ def remove_trigger_discovery_data( hass: HomeAssistant, discovery_hash: tuple[str, str] ) -> None: """Remove discovery data.""" - get_mqtt_data(hass).debug_info_triggers.pop(discovery_hash) + del hass.data[DATA_MQTT].debug_info_triggers[discovery_hash] def _info_for_entity(hass: HomeAssistant, entity_id: str) -> dict[str, Any]: - entity_info = get_mqtt_data(hass).debug_info_entities[entity_id] + entity_info = hass.data[DATA_MQTT].debug_info_entities[entity_id] monotonic_time_diff = time.time() - time.monotonic() subscriptions = [ { @@ -231,7 +198,7 @@ def _info_for_entity(hass: HomeAssistant, entity_id: str) -> dict[str, Any]: def _info_for_trigger( hass: HomeAssistant, trigger_key: tuple[str, str] ) -> dict[str, Any]: - trigger = get_mqtt_data(hass).debug_info_triggers[trigger_key] + trigger = hass.data[DATA_MQTT].debug_info_triggers[trigger_key] discovery_data = None if trigger["discovery_data"] is not None: discovery_data = { @@ -244,7 +211,7 @@ def _info_for_trigger( def info_for_config_entry(hass: HomeAssistant) -> dict[str, list[Any]]: """Get debug info for all entities and triggers.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] mqtt_info: dict[str, list[Any]] = {"entities": [], "triggers": []} mqtt_info["entities"].extend( @@ -262,7 +229,7 @@ def info_for_config_entry(hass: HomeAssistant) -> dict[str, list[Any]]: def info_for_device(hass: HomeAssistant, device_id: str) -> dict[str, list[Any]]: """Get debug info for a device.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] mqtt_info: dict[str, list[Any]] = {"entities": [], "triggers": []} entity_registry = er.async_get(hass) diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index 25fb510a07e..8d23d32326b 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N """Set up MQTT device automation dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_automation, hass, config_entry=config_entry) - await async_setup_non_entity_entry_helper( + async_setup_non_entity_entry_helper( hass, "device_automation", setup, DISCOVERY_SCHEMA ) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 417a636434f..082483a64a3 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import logging from typing import TYPE_CHECKING import voluptuous as vol @@ -30,18 +31,14 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_PAYLOAD_RESET, CONF_QOS, CONF_STATE_TOPIC -from .debug_info import log_messages -from .mixins import ( - CONF_JSON_ATTRS_TOPIC, - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .const import CONF_PAYLOAD_RESET, CONF_STATE_TOPIC +from .mixins import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic +_LOGGER = logging.getLogger(__name__) + CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_SOURCE_TYPE = "source_type" @@ -86,7 +83,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttDeviceTracker, @@ -103,7 +100,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): _default_name = None _entity_id_format = device_tracker.ENTITY_ID_FORMAT _location_name: str | None = None - _value_template: Callable[..., ReceivePayloadType] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] @staticmethod def config_schema() -> vol.Schema: @@ -116,39 +113,33 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _tracker_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = self._value_template(msg.payload) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + if payload == self._config[CONF_PAYLOAD_HOME]: + self._location_name = STATE_HOME + elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: + self._location_name = STATE_NOT_HOME + elif payload == self._config[CONF_PAYLOAD_RESET]: + self._location_name = None + else: + if TYPE_CHECKING: + assert isinstance(msg.payload, str) + self._location_name = msg.payload + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_location_name"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload: ReceivePayloadType = self._value_template(msg.payload) - if payload == self._config[CONF_PAYLOAD_HOME]: - self._location_name = STATE_HOME - elif payload == self._config[CONF_PAYLOAD_NOT_HOME]: - self._location_name = STATE_NOT_HOME - elif payload == self._config[CONF_PAYLOAD_RESET]: - self._location_name = None - else: - if TYPE_CHECKING: - assert isinstance(msg.payload, str) - self._location_name = msg.payload - - state_topic: str | None = self._config.get(CONF_STATE_TOPIC) - if state_topic is None: - return - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": state_topic, - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - } - }, + self.add_subscription( + CONF_STATE_TOPIC, self._tracker_message_received, {"_location_name"} ) @property @@ -158,7 +149,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @property def latitude(self) -> float | None: diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index db94305f9d7..bd02b95a311 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass, field import logging from typing import TYPE_CHECKING, Any -import attr import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -36,13 +36,9 @@ from .const import ( DOMAIN, ) from .discovery import MQTTDiscoveryPayload, clear_discovery_hash -from .mixins import ( - MQTT_ENTITY_DEVICE_INFO_SCHEMA, - MqttDiscoveryDeviceUpdate, - send_discovery_done, - update_device, -) -from .util import get_mqtt_data +from .mixins import MqttDiscoveryDeviceUpdateMixin, send_discovery_done, update_device +from .models import DATA_MQTT +from .schemas import MQTT_ENTITY_DEVICE_INFO_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -88,14 +84,14 @@ TRIGGER_DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( LOG_NAME = "Device trigger" -@attr.s(slots=True) +@dataclass(slots=True) class TriggerInstance: """Attached trigger settings.""" - action: TriggerActionType = attr.ib() - trigger_info: TriggerInfo = attr.ib() - trigger: Trigger = attr.ib() - remove: CALLBACK_TYPE | None = attr.ib(default=None) + action: TriggerActionType + trigger_info: TriggerInfo + trigger: Trigger + remove: CALLBACK_TYPE | None = None async def async_attach_trigger(self) -> None: """Attach MQTT trigger.""" @@ -121,21 +117,21 @@ class TriggerInstance: ) -@attr.s(slots=True) +@dataclass(slots=True, kw_only=True) class Trigger: """Device trigger settings.""" - device_id: str = attr.ib() - discovery_data: DiscoveryInfoType | None = attr.ib() - discovery_id: str | None = attr.ib() - hass: HomeAssistant = attr.ib() - payload: str | None = attr.ib() - qos: int | None = attr.ib() - subtype: str = attr.ib() - topic: str | None = attr.ib() - type: str = attr.ib() - value_template: str | None = attr.ib() - trigger_instances: list[TriggerInstance] = attr.ib(factory=list) + device_id: str + discovery_data: DiscoveryInfoType | None = None + discovery_id: str | None = None + hass: HomeAssistant + payload: str | None + qos: int | None + subtype: str + topic: str | None + type: str + value_template: str | None + trigger_instances: list[TriggerInstance] = field(default_factory=list) async def add_trigger( self, action: TriggerActionType, trigger_info: TriggerInfo @@ -189,7 +185,7 @@ class Trigger: trig.remove = None -class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): +class MqttDeviceTrigger(MqttDiscoveryDeviceUpdateMixin): """Setup a MQTT device trigger with auto discovery.""" def __init__( @@ -206,10 +202,10 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): self.device_id = device_id self.discovery_data = discovery_data self.hass = hass - self._mqtt_data = get_mqtt_data(hass) + self._mqtt_data = hass.data[DATA_MQTT] self.trigger_id = f"{device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" - MqttDiscoveryDeviceUpdate.__init__( + MqttDiscoveryDeviceUpdateMixin.__init__( self, hass, discovery_data, @@ -259,7 +255,7 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate): config = TRIGGER_DISCOVERY_SCHEMA(discovery_data) new_trigger_id = f"{self.device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" if new_trigger_id != self.trigger_id: - mqtt_data = get_mqtt_data(self.hass) + mqtt_data = self.hass.data[DATA_MQTT] if new_trigger_id in mqtt_data.device_triggers: _LOGGER.error( "Cannot update device trigger %s due to an existing duplicate " @@ -308,7 +304,7 @@ async def async_setup_trigger( trigger_type = config[CONF_TYPE] trigger_subtype = config[CONF_SUBTYPE] trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] if ( trigger_id in mqtt_data.device_triggers and mqtt_data.device_triggers[trigger_id].discovery_data is not None @@ -334,7 +330,7 @@ async def async_setup_trigger( async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None: """Handle Mqtt removed from a device.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] triggers = await async_get_triggers(hass, device_id) for trig in triggers: trigger_id = f"{device_id}_{trig[CONF_TYPE]}_{trig[CONF_SUBTYPE]}" @@ -352,7 +348,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for MQTT devices.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] if not mqtt_data.device_triggers: return [] @@ -377,7 +373,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_id: str | None = None - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] device_id = config[CONF_DEVICE_ID] # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 9c0f59fe8c3..8104c37574b 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -18,7 +18,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from . import debug_info, is_connected -from .util import get_mqtt_data +from .models import DATA_MQTT REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} @@ -45,7 +45,7 @@ def _async_get_diagnostics( device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - mqtt_instance = get_mqtt_data(hass).client + mqtt_instance = hass.data[DATA_MQTT].client if TYPE_CHECKING: assert mqtt_instance is not None diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 08d86c1a1a4..0d93af26a57 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -10,11 +10,9 @@ import re import time from typing import TYPE_CHECKING, Any -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, CONF_NAME, CONF_PLATFORM -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_DEVICE, CONF_PLATFORM +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -35,13 +33,17 @@ from .const import ( ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, CONF_ORIGIN, - CONF_SUPPORT_URL, - CONF_SW_VERSION, CONF_TOPIC, DOMAIN, + SUPPORTED_COMPONENTS, ) -from .models import MqttOriginInfo, ReceiveMessage -from .util import async_forward_entry_setup_and_setup_discovery, get_mqtt_data +from .models import DATA_MQTT, MqttOriginInfo, ReceiveMessage +from .schemas import MQTT_ORIGIN_INFO_SCHEMA +from .util import async_forward_entry_setup_and_setup_discovery + +ABBREVIATIONS_SET = set(ABBREVIATIONS) +DEVICE_ABBREVIATIONS_SET = set(DEVICE_ABBREVIATIONS) +ORIGIN_ABBREVIATIONS_SET = set(ORIGIN_ABBREVIATIONS) _LOGGER = logging.getLogger(__name__) @@ -50,58 +52,18 @@ TOPIC_MATCHER = re.compile( r"?(?P[a-zA-Z0-9_-]+)/config" ) -SUPPORTED_COMPONENTS = { - "alarm_control_panel", - "binary_sensor", - "button", - "camera", - "climate", - "cover", - "device_automation", - "device_tracker", - "event", - "fan", - "humidifier", - "image", - "lawn_mower", - "light", - "lock", - "notify", - "number", - "scene", - "siren", - "select", - "sensor", - "switch", - "tag", - "text", - "update", - "vacuum", - "valve", - "water_heater", -} - MQTT_DISCOVERY_UPDATED: SignalTypeFormat[MQTTDiscoveryPayload] = SignalTypeFormat( - "mqtt_discovery_updated_{}" + "mqtt_discovery_updated_{}_{}" ) MQTT_DISCOVERY_NEW: SignalTypeFormat[MQTTDiscoveryPayload] = SignalTypeFormat( "mqtt_discovery_new_{}_{}" ) -MQTT_DISCOVERY_NEW_COMPONENT = "mqtt_discovery_new_component" -MQTT_DISCOVERY_DONE: SignalTypeFormat[Any] = SignalTypeFormat("mqtt_discovery_done_{}") +MQTT_DISCOVERY_DONE: SignalTypeFormat[Any] = SignalTypeFormat( + "mqtt_discovery_done_{}_{}" +) TOPIC_BASE = "~" -MQTT_ORIGIN_INFO_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_SW_VERSION): cv.string, - vol.Optional(CONF_SUPPORT_URL): cv.configuration_url, - } - ), -) - class MQTTDiscoveryPayload(dict[str, Any]): """Class to hold and MQTT discovery payload and discovery data.""" @@ -111,21 +73,24 @@ class MQTTDiscoveryPayload(dict[str, Any]): def clear_discovery_hash(hass: HomeAssistant, discovery_hash: tuple[str, str]) -> None: """Clear entry from already discovered list.""" - get_mqtt_data(hass).discovery_already_discovered.remove(discovery_hash) + hass.data[DATA_MQTT].discovery_already_discovered.remove(discovery_hash) def set_discovery_hash(hass: HomeAssistant, discovery_hash: tuple[str, str]) -> None: """Add entry to already discovered list.""" - get_mqtt_data(hass).discovery_already_discovered.add(discovery_hash) + hass.data[DATA_MQTT].discovery_already_discovered.add(discovery_hash) @callback def async_log_discovery_origin_info( - message: str, discovery_payload: MQTTDiscoveryPayload + message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO ) -> None: """Log information about the discovery and origin.""" + if not _LOGGER.isEnabledFor(level): + # bail early if logging is disabled + return if CONF_ORIGIN not in discovery_payload: - _LOGGER.info(message) + _LOGGER.log(level, message) return origin_info: MqttOriginInfo = discovery_payload[CONF_ORIGIN] sw_version_log = "" @@ -134,7 +99,8 @@ def async_log_discovery_origin_info( support_url_log = "" if support_url := origin_info.get("support_url"): support_url_log = f", support URL: {support_url}" - _LOGGER.info( + _LOGGER.log( + level, "%s from external application %s%s%s", message, origin_info["name"], @@ -143,24 +109,94 @@ def async_log_discovery_origin_info( ) +@callback +def _replace_abbreviations( + payload: Any | dict[str, Any], + abbreviations: dict[str, str], + abbreviations_set: set[str], +) -> None: + """Replace abbreviations in an MQTT discovery payload.""" + if not isinstance(payload, dict): + return + for key in abbreviations_set.intersection(payload): + payload[abbreviations[key]] = payload.pop(key) + + +@callback +def _replace_all_abbreviations(discovery_payload: Any | dict[str, Any]) -> None: + """Replace all abbreviations in an MQTT discovery payload.""" + + _replace_abbreviations(discovery_payload, ABBREVIATIONS, ABBREVIATIONS_SET) + + if CONF_ORIGIN in discovery_payload: + _replace_abbreviations( + discovery_payload[CONF_ORIGIN], + ORIGIN_ABBREVIATIONS, + ORIGIN_ABBREVIATIONS_SET, + ) + + if CONF_DEVICE in discovery_payload: + _replace_abbreviations( + discovery_payload[CONF_DEVICE], + DEVICE_ABBREVIATIONS, + DEVICE_ABBREVIATIONS_SET, + ) + + if CONF_AVAILABILITY in discovery_payload: + for availability_conf in cv.ensure_list(discovery_payload[CONF_AVAILABILITY]): + _replace_abbreviations(availability_conf, ABBREVIATIONS, ABBREVIATIONS_SET) + + +@callback +def _replace_topic_base(discovery_payload: dict[str, Any]) -> None: + """Replace topic base in MQTT discovery data.""" + base = discovery_payload.pop(TOPIC_BASE) + for key, value in discovery_payload.items(): + if isinstance(value, str) and value: + if value[0] == TOPIC_BASE and key.endswith("topic"): + discovery_payload[key] = f"{base}{value[1:]}" + if value[-1] == TOPIC_BASE and key.endswith("topic"): + discovery_payload[key] = f"{value[:-1]}{base}" + if discovery_payload.get(CONF_AVAILABILITY): + for availability_conf in cv.ensure_list(discovery_payload[CONF_AVAILABILITY]): + if not isinstance(availability_conf, dict): + continue + if topic := str(availability_conf.get(CONF_TOPIC)): + if topic[0] == TOPIC_BASE: + availability_conf[CONF_TOPIC] = f"{base}{topic[1:]}" + if topic[-1] == TOPIC_BASE: + availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}" + + +@callback +def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool: + """Parse and validate origin info from a single component discovery payload.""" + if CONF_ORIGIN not in discovery_payload: + return True + try: + MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) + except Exception as exc: # noqa:BLE001 + _LOGGER.warning( + "Unable to parse origin information from discovery message: %s, got %s", + exc, + discovery_payload[CONF_ORIGIN], + ) + return False + return True + + async def async_start( # noqa: C901 hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry ) -> None: """Start MQTT Discovery.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] platform_setup_lock: dict[str, asyncio.Lock] = {} - async def _async_component_setup(discovery_payload: MQTTDiscoveryPayload) -> None: - """Perform component set up.""" + @callback + def _async_add_component(discovery_payload: MQTTDiscoveryPayload) -> None: + """Add a component from a discovery message.""" discovery_hash = discovery_payload.discovery_data[ATTR_DISCOVERY_HASH] component, discovery_id = discovery_hash - platform_setup_lock.setdefault(component, asyncio.Lock()) - async with platform_setup_lock[component]: - if component not in mqtt_data.platforms_loaded: - await async_forward_entry_setup_and_setup_discovery( - hass, config_entry, {component} - ) - # Add component message = f"Found new component: {component} {discovery_id}" async_log_discovery_origin_info(message, discovery_payload) mqtt_data.discovery_already_discovered.add(discovery_hash) @@ -168,16 +204,21 @@ async def async_start( # noqa: C901 hass, MQTT_DISCOVERY_NEW.format(component, "mqtt"), discovery_payload ) - mqtt_data.reload_dispatchers.append( - async_dispatcher_connect( - hass, MQTT_DISCOVERY_NEW_COMPONENT, _async_component_setup - ) - ) + async def _async_component_setup( + component: str, discovery_payload: MQTTDiscoveryPayload + ) -> None: + """Perform component set up.""" + async with platform_setup_lock.setdefault(component, asyncio.Lock()): + if component not in mqtt_data.platforms_loaded: + await async_forward_entry_setup_and_setup_discovery( + hass, config_entry, {component} + ) + _async_add_component(discovery_payload) @callback def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 """Process the received message.""" - mqtt_data.last_discovery = time.monotonic() + mqtt_data.last_discovery = msg.timestamp payload = msg.payload topic = msg.topic topic_trimmed = topic.replace(f"{discovery_topic}/", "", 1) @@ -207,67 +248,14 @@ async def async_start( # noqa: C901 except ValueError: _LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload) return + _replace_all_abbreviations(discovery_payload) + if not _valid_origin_info(discovery_payload): + return + if TOPIC_BASE in discovery_payload: + _replace_topic_base(discovery_payload) else: discovery_payload = MQTTDiscoveryPayload({}) - for key in list(discovery_payload): - abbreviated_key = key - key = ABBREVIATIONS.get(key, key) - discovery_payload[key] = discovery_payload.pop(abbreviated_key) - - if CONF_DEVICE in discovery_payload: - device = discovery_payload[CONF_DEVICE] - for key in list(device): - abbreviated_key = key - key = DEVICE_ABBREVIATIONS.get(key, key) - device[key] = device.pop(abbreviated_key) - - if CONF_ORIGIN in discovery_payload: - origin_info: dict[str, Any] = discovery_payload[CONF_ORIGIN] - try: - for key in list(origin_info): - abbreviated_key = key - key = ORIGIN_ABBREVIATIONS.get(key, key) - origin_info[key] = origin_info.pop(abbreviated_key) - MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) - except Exception: # pylint: disable=broad-except - _LOGGER.warning( - "Unable to parse origin information " - "from discovery message, got %s", - discovery_payload[CONF_ORIGIN], - ) - return - - if CONF_AVAILABILITY in discovery_payload: - for availability_conf in cv.ensure_list( - discovery_payload[CONF_AVAILABILITY] - ): - if isinstance(availability_conf, dict): - for key in list(availability_conf): - abbreviated_key = key - key = ABBREVIATIONS.get(key, key) - availability_conf[key] = availability_conf.pop(abbreviated_key) - - if TOPIC_BASE in discovery_payload: - base = discovery_payload.pop(TOPIC_BASE) - for key, value in discovery_payload.items(): - if isinstance(value, str) and value: - if value[0] == TOPIC_BASE and key.endswith("topic"): - discovery_payload[key] = f"{base}{value[1:]}" - if value[-1] == TOPIC_BASE and key.endswith("topic"): - discovery_payload[key] = f"{value[:-1]}{base}" - if discovery_payload.get(CONF_AVAILABILITY): - for availability_conf in cv.ensure_list( - discovery_payload[CONF_AVAILABILITY] - ): - if not isinstance(availability_conf, dict): - continue - if topic := str(availability_conf.get(CONF_TOPIC)): - if topic[0] == TOPIC_BASE: - availability_conf[CONF_TOPIC] = f"{base}{topic[1:]}" - if topic[-1] == TOPIC_BASE: - availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}" - # If present, the node_id will be included in the discovered object id discovery_id = f"{node_id} {object_id}" if node_id else object_id discovery_hash = (component, discovery_id) @@ -329,7 +317,7 @@ async def async_start( # noqa: C901 discovery_pending_discovered[discovery_hash] = { "unsub": async_dispatcher_connect( hass, - MQTT_DISCOVERY_DONE.format(discovery_hash), + MQTT_DISCOVERY_DONE.format(*discovery_hash), discovery_done, ), "pending": deque([]), @@ -337,94 +325,94 @@ async def async_start( # noqa: C901 if component not in mqtt_data.platforms_loaded and payload: # Load component first - async_dispatcher_send(hass, MQTT_DISCOVERY_NEW_COMPONENT, payload) + config_entry.async_create_task( + hass, _async_component_setup(component, payload) + ) elif already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" - async_log_discovery_origin_info(message, payload) + async_log_discovery_origin_info(message, payload, logging.DEBUG) async_dispatcher_send( - hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload + hass, MQTT_DISCOVERY_UPDATED.format(*discovery_hash), payload ) elif payload: - # Add component - message = f"Found new component: {component} {discovery_id}" - async_log_discovery_origin_info(message, payload) - mqtt_data.discovery_already_discovered.add(discovery_hash) - async_dispatcher_send( - hass, MQTT_DISCOVERY_NEW.format(component, "mqtt"), payload - ) + _async_add_component(payload) else: # Unhandled discovery message async_dispatcher_send( - hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None + hass, MQTT_DISCOVERY_DONE.format(*discovery_hash), None ) - discovery_topics = [ - f"{discovery_topic}/+/+/config", - f"{discovery_topic}/+/+/+/config", - ] - mqtt_data.discovery_unsubscribe = await asyncio.gather( - *( - mqtt.async_subscribe(hass, topic, async_discovery_message_received, 0) - for topic in discovery_topics + mqtt_data.discovery_unsubscribe = [ + mqtt.async_subscribe_internal( + hass, + topic, + async_discovery_message_received, + 0, + job_type=HassJobType.Callback, ) - ) + for topic in ( + f"{discovery_topic}/+/+/config", + f"{discovery_topic}/+/+/+/config", + ) + ] mqtt_data.last_discovery = time.monotonic() mqtt_integrations = await async_get_mqtt(hass) + integration_unsubscribe = mqtt_data.integration_unsubscribe - for integration, topics in mqtt_integrations.items(): + async def async_integration_message_received( + integration: str, msg: ReceiveMessage + ) -> None: + """Process the received message.""" + if TYPE_CHECKING: + assert mqtt_data.data_config_flow_lock + key = f"{integration}_{msg.subscribed_topic}" - async def async_integration_message_received( - integration: str, msg: ReceiveMessage - ) -> None: - """Process the received message.""" - if TYPE_CHECKING: - assert mqtt_data.data_config_flow_lock - key = f"{integration}_{msg.subscribed_topic}" + # Lock to prevent initiating many parallel config flows. + # Note: The lock is not intended to prevent a race, only for performance + async with mqtt_data.data_config_flow_lock: + # Already unsubscribed + if key not in integration_unsubscribe: + return - # Lock to prevent initiating many parallel config flows. - # Note: The lock is not intended to prevent a race, only for performance - async with mqtt_data.data_config_flow_lock: - # Already unsubscribed - if key not in mqtt_data.integration_unsubscribe: - return + data = MqttServiceInfo( + topic=msg.topic, + payload=msg.payload, + qos=msg.qos, + retain=msg.retain, + subscribed_topic=msg.subscribed_topic, + timestamp=msg.timestamp, + ) + result = await hass.config_entries.flow.async_init( + integration, context={"source": DOMAIN}, data=data + ) + if ( + result + and result["type"] == FlowResultType.ABORT + and result["reason"] + in ("already_configured", "single_instance_allowed") + ): + integration_unsubscribe.pop(key)() - data = MqttServiceInfo( - topic=msg.topic, - payload=msg.payload, - qos=msg.qos, - retain=msg.retain, - subscribed_topic=msg.subscribed_topic, - timestamp=msg.timestamp, - ) - result = await hass.config_entries.flow.async_init( - integration, context={"source": DOMAIN}, data=data - ) - if ( - result - and result["type"] == FlowResultType.ABORT - and result["reason"] - in ("already_configured", "single_instance_allowed") - ): - mqtt_data.integration_unsubscribe.pop(key)() - - mqtt_data.integration_unsubscribe.update( - { - f"{integration}_{topic}": await mqtt.async_subscribe( - hass, - topic, - functools.partial(async_integration_message_received, integration), - 0, - ) - for topic in topics - } - ) + integration_unsubscribe.update( + { + f"{integration}_{topic}": mqtt.async_subscribe_internal( + hass, + topic, + functools.partial(async_integration_message_received, integration), + 0, + job_type=HassJobType.Coroutinefunction, + ) + for integration, topics in mqtt_integrations.items() + for topic in topics + } + ) async def async_stop(hass: HomeAssistant) -> None: """Stop MQTT Discovery.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] for unsub in mqtt_data.discovery_unsubscribe: unsub() mqtt_data.discovery_unsubscribe = [] diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index c72791f3284..15b70b1b98d 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -24,27 +24,17 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import subscription from .config import MQTT_RO_SCHEMA -from .const import ( - CONF_ENCODING, - CONF_QOS, - CONF_STATE_TOPIC, - PAYLOAD_EMPTY_JSON, - PAYLOAD_NONE, -) -from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entity_entry_helper, -) +from .const import CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( + DATA_MQTT, MqttValueTemplate, MqttValueTemplateException, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data +from .schemas import MQTT_ENTITY_COMMON_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -84,7 +74,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttEvent, @@ -116,98 +106,84 @@ class MqttEvent(MqttEntity, EventEntity): self._config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _event_received(self, 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 + try: + payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + except MqttValueTemplateException as exc: + _LOGGER.warning(exc) + return + if ( + not payload + or payload is PayloadSentinel.DEFAULT + or payload in (PAYLOAD_NONE, PAYLOAD_EMPTY_JSON) + ): + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + try: + event_attributes = json_loads_object(payload) + event_type = str(event_attributes.pop(event.ATTR_EVENT_TYPE)) + _LOGGER.debug( + ( + "JSON event data detected after processing payload '%s' on" + " topic %s, type %s, attributes %s" + ), + payload, + msg.topic, + event_type, + event_attributes, + ) + except KeyError: + _LOGGER.warning( + ("`event_type` missing in JSON event payload, " " '%s' on topic %s"), + payload, + msg.topic, + ) + return + except JSON_DECODE_EXCEPTIONS: + _LOGGER.warning( + ( + "No valid JSON event payload detected, " + "value after processing payload" + " '%s' on topic %s" + ), + payload, + msg.topic, + ) + return + try: + self._trigger_event(event_type, event_attributes) + except ValueError: + _LOGGER.warning( + "Invalid event type %s for %s received on topic %s, payload %s", + event_type, + self.entity_id, + msg.topic, + payload, + ) + return + mqtt_data = self.hass.data[DATA_MQTT] + mqtt_data.state_write_requests.write_state_request(self) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - - @callback - @log_messages(self.hass, self.entity_id) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - 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 - try: - payload = self._template(msg.payload, PayloadSentinel.DEFAULT) - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - if ( - not payload - or payload is PayloadSentinel.DEFAULT - or payload in (PAYLOAD_NONE, PAYLOAD_EMPTY_JSON) - ): - _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, - msg.topic, - ) - return - try: - event_attributes = json_loads_object(payload) - event_type = str(event_attributes.pop(event.ATTR_EVENT_TYPE)) - _LOGGER.debug( - ( - "JSON event data detected after processing payload '%s' on" - " topic %s, type %s, attributes %s" - ), - payload, - msg.topic, - event_type, - event_attributes, - ) - except KeyError: - _LOGGER.warning( - ( - "`event_type` missing in JSON event payload, " - " '%s' on topic %s" - ), - payload, - msg.topic, - ) - return - except JSON_DECODE_EXCEPTIONS: - _LOGGER.warning( - ( - "No valid JSON event payload detected, " - "value after processing payload" - " '%s' on topic %s" - ), - payload, - msg.topic, - ) - return - try: - self._trigger_event(event_type, event_attributes) - except ValueError: - _LOGGER.warning( - "Invalid event type %s for %s received on topic %s, payload %s", - event_type, - self.entity_id, - msg.topic, - payload, - ) - return - 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], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) + self.add_subscription(CONF_STATE_TOPIC, self._event_received, None) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0fed4ab666e..1933b5e17b5 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -42,28 +42,19 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( - MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PublishPayloadType, ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" @@ -200,7 +191,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT fan through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttFan, @@ -338,145 +329,129 @@ class MqttFan(MqttEntity, FanEntity): for key, tpl in value_templates.items() } - def _prepare_subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + payload = self._value_templates[CONF_STATE](msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) + return + if payload == self._payload["STATE_ON"]: + self._attr_is_on = True + elif payload == self._payload["STATE_OFF"]: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None - def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: - """Add a topic to subscribe to.""" - if has_topic := self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - return has_topic - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message.""" - payload = self._value_templates[CONF_STATE](msg.payload) - if not payload: - _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) - return - if payload == self._payload["STATE_ON"]: - self._attr_is_on = True - elif payload == self._payload["STATE_OFF"]: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - - add_subscribe_topic(CONF_STATE_TOPIC, state_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_percentage"}) - def percentage_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the percentage.""" - rendered_percentage_payload = self._value_templates[ATTR_PERCENTAGE]( - msg.payload + @callback + def _percentage_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the percentage.""" + rendered_percentage_payload = self._value_templates[ATTR_PERCENTAGE]( + msg.payload + ) + if not rendered_percentage_payload: + _LOGGER.debug("Ignoring empty speed from '%s'", msg.topic) + return + if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: + self._attr_percentage = None + return + try: + percentage = ranged_value_to_percentage( + self._speed_range, int(rendered_percentage_payload) ) - if not rendered_percentage_payload: - _LOGGER.debug("Ignoring empty speed from '%s'", msg.topic) - return - if rendered_percentage_payload == self._payload["PERCENTAGE_RESET"]: - self._attr_percentage = None - return - try: - percentage = ranged_value_to_percentage( - self._speed_range, int(rendered_percentage_payload) - ) - except ValueError: - _LOGGER.warning( - ( - "'%s' received on topic %s. '%s' is not a valid speed within" - " the speed range" - ), - msg.payload, - msg.topic, - rendered_percentage_payload, - ) - return - if percentage < 0 or percentage > 100: - _LOGGER.warning( - ( - "'%s' received on topic %s. '%s' is not a valid speed within" - " the speed range" - ), - msg.payload, - msg.topic, - rendered_percentage_payload, - ) - return - self._attr_percentage = percentage + except ValueError: + _LOGGER.warning( + ( + "'%s' received on topic %s. '%s' is not a valid speed within" + " the speed range" + ), + msg.payload, + msg.topic, + rendered_percentage_payload, + ) + return + if percentage < 0 or percentage > 100: + _LOGGER.warning( + ( + "'%s' received on topic %s. '%s' is not a valid speed within" + " the speed range" + ), + msg.payload, + msg.topic, + rendered_percentage_payload, + ) + return + self._attr_percentage = percentage - add_subscribe_topic(CONF_PERCENTAGE_STATE_TOPIC, percentage_received) + @callback + def _preset_mode_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for preset mode.""" + preset_mode = str(self._value_templates[ATTR_PRESET_MODE](msg.payload)) + if preset_mode == self._payload["PRESET_MODE_RESET"]: + self._attr_preset_mode = None + return + if not preset_mode: + _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) + return + if not self.preset_modes or preset_mode not in self.preset_modes: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid preset mode", + msg.payload, + msg.topic, + preset_mode, + ) + return - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_preset_mode"}) - def preset_mode_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for preset mode.""" - preset_mode = str(self._value_templates[ATTR_PRESET_MODE](msg.payload)) - if preset_mode == self._payload["PRESET_MODE_RESET"]: - self._attr_preset_mode = None - return - if not preset_mode: - _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) - return - if not self.preset_modes or preset_mode not in self.preset_modes: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid preset mode", - msg.payload, - msg.topic, - preset_mode, - ) - return + self._attr_preset_mode = preset_mode - self._attr_preset_mode = preset_mode - - add_subscribe_topic(CONF_PRESET_MODE_STATE_TOPIC, preset_mode_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_oscillating"}) - def oscillation_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the oscillation.""" - payload = self._value_templates[ATTR_OSCILLATING](msg.payload) - if not payload: - _LOGGER.debug("Ignoring empty oscillation from '%s'", msg.topic) - return - if payload == self._payload["OSCILLATE_ON_PAYLOAD"]: - self._attr_oscillating = True - elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]: - self._attr_oscillating = False - - if add_subscribe_topic(CONF_OSCILLATION_STATE_TOPIC, oscillation_received): + @callback + def _oscillation_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the oscillation.""" + payload = self._value_templates[ATTR_OSCILLATING](msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty oscillation from '%s'", msg.topic) + return + if payload == self._payload["OSCILLATE_ON_PAYLOAD"]: + self._attr_oscillating = True + elif payload == self._payload["OSCILLATE_OFF_PAYLOAD"]: self._attr_oscillating = False - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_direction"}) - def direction_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the direction.""" - direction = self._value_templates[ATTR_DIRECTION](msg.payload) - if not direction: - _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) - return - self._attr_current_direction = str(direction) + @callback + def _direction_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the direction.""" + direction = self._value_templates[ATTR_DIRECTION](msg.payload) + if not direction: + _LOGGER.debug("Ignoring empty direction from '%s'", msg.topic) + return + self._attr_current_direction = str(direction) - add_subscribe_topic(CONF_DIRECTION_STATE_TOPIC, direction_received) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) + self.add_subscription( + CONF_PERCENTAGE_STATE_TOPIC, self._percentage_received, {"_attr_percentage"} + ) + self.add_subscription( + CONF_PRESET_MODE_STATE_TOPIC, + self._preset_mode_received, + {"_attr_preset_mode"}, + ) + if self.add_subscription( + CONF_OSCILLATION_STATE_TOPIC, + self._oscillation_received, + {"_attr_oscillating"}, + ): + self._attr_oscillating = False + self.add_subscription( + CONF_DIRECTION_STATE_TOPIC, + self._direction_received, + {"_attr_current_direction"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @property def is_on(self) -> bool | None: @@ -495,12 +470,8 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if percentage: await self.async_set_percentage(percentage) @@ -516,12 +487,8 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if self._optimistic: self._attr_is_on = False @@ -536,14 +503,9 @@ class MqttFan(MqttEntity, FanEntity): percentage_to_ranged_value(self._speed_range, percentage) ) mqtt_payload = self._command_templates[ATTR_PERCENTAGE](percentage_payload) - await self.async_publish( - self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_PERCENTAGE_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_percentage: self._attr_percentage = percentage self.async_write_ha_state() @@ -554,15 +516,9 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode) - - await self.async_publish( - self._topic[CONF_PRESET_MODE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_PRESET_MODE_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_preset_mode: self._attr_preset_mode = preset_mode self.async_write_ha_state() @@ -580,15 +536,9 @@ class MqttFan(MqttEntity, FanEntity): mqtt_payload = self._command_templates[ATTR_OSCILLATING]( self._payload["OSCILLATE_OFF_PAYLOAD"] ) - - await self.async_publish( - self._topic[CONF_OSCILLATION_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_OSCILLATION_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_oscillation: self._attr_oscillating = oscillating self.async_write_ha_state() @@ -599,15 +549,9 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[ATTR_DIRECTION](direction) - - await self.async_publish( - self._topic[CONF_DIRECTION_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_DIRECTION_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_direction: self._attr_current_direction = direction self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 7c9ba26389c..8f7eda21240 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -44,20 +44,11 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_HUMIDITY_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -65,6 +56,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic CONF_AVAILABLE_MODES_LIST = "modes" @@ -192,7 +184,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT humidifier through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttHumidifier, @@ -279,177 +271,150 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): for key, tpl in value_templates.items() } - def add_subscription( - self, - topics: dict[str, dict[str, Any]], - topic: str, - msg_callback: Callable[[ReceiveMessage], None], - ) -> None: - """Add a subscription.""" - qos: int = self._config[CONF_QOS] - if topic in self._topic and self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": qos, - "encoding": self._config[CONF_ENCODING] or None, - } + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + payload = self._value_templates[CONF_STATE](msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) + return + if payload == self._payload["STATE_ON"]: + self._attr_is_on = True + elif payload == self._payload["STATE_OFF"]: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None + @callback + def _action_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message.""" + action_payload = self._value_templates[ATTR_ACTION](msg.payload) + if not action_payload or action_payload == PAYLOAD_NONE: + _LOGGER.debug("Ignoring empty action from '%s'", msg.topic) + return + try: + self._attr_action = HumidifierAction(str(action_payload)) + except ValueError: + _LOGGER.error( + "'%s' received on topic %s. '%s' is not a valid action", + msg.payload, + msg.topic, + action_payload, + ) + return + + @callback + def _current_humidity_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the current humidity.""" + rendered_current_humidity_payload = self._value_templates[ + ATTR_CURRENT_HUMIDITY + ](msg.payload) + if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: + self._attr_current_humidity = None + return + if not rendered_current_humidity_payload: + _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) + return + try: + current_humidity = round(float(rendered_current_humidity_payload)) + except ValueError: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + if current_humidity < 0 or current_humidity > 100: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid humidity", + msg.payload, + msg.topic, + rendered_current_humidity_payload, + ) + return + self._attr_current_humidity = current_humidity + + @callback + def _target_humidity_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for the target humidity.""" + rendered_target_humidity_payload = self._value_templates[ATTR_HUMIDITY]( + msg.payload + ) + if not rendered_target_humidity_payload: + _LOGGER.debug("Ignoring empty target humidity from '%s'", msg.topic) + return + if rendered_target_humidity_payload == self._payload["HUMIDITY_RESET"]: + self._attr_target_humidity = None + return + try: + target_humidity = round(float(rendered_target_humidity_payload)) + except ValueError: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid target humidity", + msg.payload, + msg.topic, + rendered_target_humidity_payload, + ) + return + if ( + target_humidity < self._attr_min_humidity + or target_humidity > self._attr_max_humidity + ): + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid target humidity", + msg.payload, + msg.topic, + rendered_target_humidity_payload, + ) + return + self._attr_target_humidity = target_humidity + + @callback + def _mode_received(self, msg: ReceiveMessage) -> None: + """Handle new received MQTT message for mode.""" + mode = str(self._value_templates[ATTR_MODE](msg.payload)) + if mode == self._payload["MODE_RESET"]: + self._attr_mode = None + return + if not mode: + _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) + return + if not self.available_modes or mode not in self.available_modes: + _LOGGER.warning( + "'%s' received on topic %s. '%s' is not a valid mode", + msg.payload, + msg.topic, + mode, + ) + return + + self._attr_mode = mode + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message.""" - payload = self._value_templates[CONF_STATE](msg.payload) - if not payload: - _LOGGER.debug("Ignoring empty state from '%s'", msg.topic) - return - if payload == self._payload["STATE_ON"]: - self._attr_is_on = True - elif payload == self._payload["STATE_OFF"]: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - - self.add_subscription(topics, CONF_STATE_TOPIC, state_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_action"}) - def action_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message.""" - action_payload = self._value_templates[ATTR_ACTION](msg.payload) - if not action_payload or action_payload == PAYLOAD_NONE: - _LOGGER.debug("Ignoring empty action from '%s'", msg.topic) - return - try: - self._attr_action = HumidifierAction(str(action_payload)) - except ValueError: - _LOGGER.error( - "'%s' received on topic %s. '%s' is not a valid action", - msg.payload, - msg.topic, - action_payload, - ) - return - - self.add_subscription(topics, CONF_ACTION_TOPIC, action_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_humidity"}) - def current_humidity_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the current humidity.""" - rendered_current_humidity_payload = self._value_templates[ - ATTR_CURRENT_HUMIDITY - ](msg.payload) - if rendered_current_humidity_payload == self._payload["HUMIDITY_RESET"]: - self._attr_current_humidity = None - return - if not rendered_current_humidity_payload: - _LOGGER.debug("Ignoring empty current humidity from '%s'", msg.topic) - return - try: - current_humidity = round(float(rendered_current_humidity_payload)) - except ValueError: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid humidity", - msg.payload, - msg.topic, - rendered_current_humidity_payload, - ) - return - if current_humidity < 0 or current_humidity > 100: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid humidity", - msg.payload, - msg.topic, - rendered_current_humidity_payload, - ) - return - self._attr_current_humidity = current_humidity - + self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) self.add_subscription( - topics, CONF_CURRENT_HUMIDITY_TOPIC, current_humidity_received + CONF_ACTION_TOPIC, self._action_received, {"_attr_action"} ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_target_humidity"}) - def target_humidity_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for the target humidity.""" - rendered_target_humidity_payload = self._value_templates[ATTR_HUMIDITY]( - msg.payload - ) - if not rendered_target_humidity_payload: - _LOGGER.debug("Ignoring empty target humidity from '%s'", msg.topic) - return - if rendered_target_humidity_payload == self._payload["HUMIDITY_RESET"]: - self._attr_target_humidity = None - return - try: - target_humidity = round(float(rendered_target_humidity_payload)) - except ValueError: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid target humidity", - msg.payload, - msg.topic, - rendered_target_humidity_payload, - ) - return - if ( - target_humidity < self._attr_min_humidity - or target_humidity > self._attr_max_humidity - ): - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid target humidity", - msg.payload, - msg.topic, - rendered_target_humidity_payload, - ) - return - self._attr_target_humidity = target_humidity - self.add_subscription( - topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, target_humidity_received + CONF_CURRENT_HUMIDITY_TOPIC, + self._current_humidity_received, + {"_attr_current_humidity"}, ) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_mode"}) - def mode_received(msg: ReceiveMessage) -> None: - """Handle new received MQTT message for mode.""" - mode = str(self._value_templates[ATTR_MODE](msg.payload)) - if mode == self._payload["MODE_RESET"]: - self._attr_mode = None - return - if not mode: - _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) - return - if not self.available_modes or mode not in self.available_modes: - _LOGGER.warning( - "'%s' received on topic %s. '%s' is not a valid mode", - msg.payload, - msg.topic, - mode, - ) - return - - self._attr_mode = mode - - self.add_subscription(topics, CONF_MODE_STATE_TOPIC, mode_received) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_TARGET_HUMIDITY_STATE_TOPIC, + self._target_humidity_received, + {"_attr_target_humidity"}, + ) + self.add_subscription( + CONF_MODE_STATE_TOPIC, self._mode_received, {"_attr_mode"} ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the entity. @@ -457,12 +422,8 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if self._optimistic: self._attr_is_on = True @@ -474,12 +435,8 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if self._optimistic: self._attr_is_on = False @@ -491,14 +448,9 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[ATTR_HUMIDITY](humidity) - await self.async_publish( - self._topic[CONF_TARGET_HUMIDITY_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TARGET_HUMIDITY_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_target_humidity: self._attr_target_humidity = humidity self.async_write_ha_state() @@ -513,15 +465,9 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): return mqtt_payload = self._command_templates[ATTR_MODE](mode) - - await self.async_publish( - self._topic[CONF_MODE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_MODE_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_mode: self._attr_mode = mode self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index be3956cc972..d5930a1668a 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -25,20 +25,15 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_ENCODING, CONF_QOS -from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entity_entry_helper, -) +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( - MessageCallbackType, + DATA_MQTT, MqttValueTemplate, MqttValueTemplateException, ReceiveMessage, ) -from .util import get_mqtt_data, valid_subscribe_topic +from .schemas import MQTT_ENTITY_COMMON_SCHEMA +from .util import valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -88,7 +83,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT image through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttImage, @@ -145,80 +140,58 @@ class MqttImage(MqttEntity, ImageEntity): config.get(CONF_URL_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _image_data_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + try: + if CONF_IMAGE_ENCODING in self._config: + self._last_image = b64decode(msg.payload) + else: + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) + self._last_image = msg.payload + except (binascii.Error, ValueError, AssertionError) as err: + _LOGGER.error( + "Error processing image data received at topic %s: %s", + msg.topic, + err, + ) + self._last_image = None + self._attr_image_last_updated = dt_util.utcnow() + self.hass.data[DATA_MQTT].state_write_requests.write_state_request(self) + + @callback + def _image_from_url_request_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + try: + url = cv.url(self._url_template(msg.payload)) + self._attr_image_url = url + except MqttValueTemplateException as exc: + _LOGGER.warning(exc) + return + except vol.Invalid: + _LOGGER.error( + "Invalid image URL '%s' received at topic %s", + msg.payload, + msg.topic, + ) + self._attr_image_last_updated = dt_util.utcnow() + self._cached_image = None + self.hass.data[DATA_MQTT].state_write_requests.write_state_request(self) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - topics: dict[str, Any] = {} - - def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: - """Add a topic to subscribe to.""" - encoding: str | None - encoding = ( - None - if CONF_IMAGE_TOPIC in self._config - else self._config[CONF_ENCODING] or None - ) - if has_topic := self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": self._config[CONF_QOS], - "encoding": encoding, - } - return has_topic - - @callback - @log_messages(self.hass, self.entity_id) - def image_data_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - try: - if CONF_IMAGE_ENCODING in self._config: - self._last_image = b64decode(msg.payload) - else: - if TYPE_CHECKING: - assert isinstance(msg.payload, bytes) - self._last_image = msg.payload - except (binascii.Error, ValueError, AssertionError) as err: - _LOGGER.error( - "Error processing image data received at topic %s: %s", - msg.topic, - err, - ) - self._last_image = None - self._attr_image_last_updated = dt_util.utcnow() - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - - add_subscribe_topic(CONF_IMAGE_TOPIC, image_data_received) - - @callback - @log_messages(self.hass, self.entity_id) - def image_from_url_request_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - try: - url = cv.url(self._url_template(msg.payload)) - self._attr_image_url = url - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - except vol.Invalid: - _LOGGER.error( - "Invalid image URL '%s' received at topic %s", - msg.payload, - msg.topic, - ) - self._attr_image_last_updated = dt_util.utcnow() - self._cached_image = None - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - - add_subscribe_topic(CONF_URL_TOPIC, image_from_url_request_received) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_IMAGE_TOPIC, self._image_data_received, None, disable_encoding=True + ) + self.add_subscription( + CONF_URL_TOPIC, self._image_from_url_request_received, None ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_image(self) -> bytes | None: """Return bytes of image.""" diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index e6dc9125583..853ce743f12 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -24,20 +24,8 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import ( - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - DEFAULT_OPTIMISTIC, - 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 .const import CONF_RETAIN, DEFAULT_OPTIMISTIC, DEFAULT_RETAIN +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -45,6 +33,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -92,7 +81,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lawn mower through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttLawnMower, @@ -150,57 +139,45 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): config.get(CONF_START_MOWING_COMMAND_TEMPLATE), entity=self ).async_render + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = str(self._value_template(msg.payload)) + if not payload: + _LOGGER.debug( + "Invalid empty activity payload from topic %s, for entity %s", + msg.topic, + self.entity_id, + ) + return + if payload.lower() == "none": + self._attr_activity = None + return + + try: + self._attr_activity = LawnMowerActivity(payload) + except ValueError: + _LOGGER.error( + "Invalid activity for %s: '%s' (valid activities: %s)", + self.entity_id, + payload, + [option.value for option in LawnMowerActivity], + ) + return + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_activity"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload = str(self._value_template(msg.payload)) - if not payload: - _LOGGER.debug( - "Invalid empty activity payload from topic %s, for entity %s", - msg.topic, - self.entity_id, - ) - return - if payload.lower() == "none": - self._attr_activity = None - return - - try: - self._attr_activity = LawnMowerActivity(payload) - except ValueError: - _LOGGER.error( - "Invalid activity for %s: '%s' (valid activities: %s)", - self.entity_id, - payload, - [option.value for option in LawnMowerActivity], - ) - return - - if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_ACTIVITY_STATE_TOPIC, self._message_received, {"_attr_activity"} + ): # Force into optimistic mode. self._attr_assumed_state = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_ACTIVITY_STATE_TOPIC: { - "topic": self._config.get(CONF_ACTIVITY_STATE_TOPIC), - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._attr_assumed_state and ( last_state := await self.async_get_last_state() @@ -214,14 +191,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): if self._attr_assumed_state: self._attr_activity = activity self.async_write_ha_state() - - await self.async_publish( - self._command_topics[option], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._command_topics[option], payload) async def async_start_mowing(self) -> None: """Start or resume mowing.""" diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 29c5cc20d91..ac2d1ff14ee 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -70,7 +70,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lights through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, None, diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index bf0de319df0..565cf4d7132 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -46,17 +46,12 @@ from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change +from ..mixins import MqttEntity from ..models import ( - MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PayloadSentinel, @@ -65,6 +60,7 @@ from ..models import ( ReceivePayloadType, TemplateVarsType, ) +from ..schemas import MQTT_ENTITY_COMMON_SCHEMA from ..util import valid_publish_topic, valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA @@ -377,271 +373,238 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): attr: bool = getattr(self, f"_optimistic_{attribute}") return attr - def _prepare_subscribe_topics(self) -> None: # noqa: C901 - """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = self._value_templates[CONF_STATE_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.NONE + ) + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return - def add_topic(topic: str, msg_callback: MessageCallbackType) -> None: - """Add a topic.""" - if self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + if payload == self._payload["on"]: + self._attr_is_on = True + elif payload == self._payload["off"]: + self._attr_is_on = False + elif payload == PAYLOAD_NONE: + self._attr_is_on = None - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload = self._value_templates[CONF_STATE_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.NONE - ) - if not payload: - _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) - return + @callback + def _brightness_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for the brightness.""" + payload = self._value_templates[CONF_BRIGHTNESS_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty brightness message from '%s'", msg.topic) + return - if payload == self._payload["on"]: - self._attr_is_on = True - elif payload == self._payload["off"]: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None + device_value = float(payload) + if device_value == 0: + _LOGGER.debug("Ignoring zero brightness from '%s'", msg.topic) + return - if self._topic[CONF_STATE_TOPIC] is not None: - topics[CONF_STATE_TOPIC] = { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } + percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] + self._attr_brightness = min(round(percent_bright * 255), 255) - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_brightness"}) - def brightness_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for the brightness.""" - payload = self._value_templates[CONF_BRIGHTNESS_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty brightness message from '%s'", msg.topic) - return - - device_value = float(payload) - if device_value == 0: - _LOGGER.debug("Ignoring zero brightness from '%s'", msg.topic) - return - - percent_bright = device_value / self._config[CONF_BRIGHTNESS_SCALE] - self._attr_brightness = min(round(percent_bright * 255), 255) - - add_topic(CONF_BRIGHTNESS_STATE_TOPIC, brightness_received) - - @callback - def _rgbx_received( - msg: ReceiveMessage, - template: str, - color_mode: ColorMode, - convert_color: Callable[..., tuple[int, ...]], - ) -> tuple[int, ...] | None: - """Handle new MQTT messages for RGBW and RGBWW.""" - payload = self._value_templates[template]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: + @callback + def _rgbx_received( + self, + msg: ReceiveMessage, + template: str, + color_mode: ColorMode, + convert_color: Callable[..., tuple[int, ...]], + ) -> tuple[int, ...] | None: + """Process MQTT messages for RGBW and RGBWW.""" + payload = self._value_templates[template](msg.payload, PayloadSentinel.DEFAULT) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty %s message from '%s'", color_mode, msg.topic) + return None + color = tuple(int(val) for val in str(payload).split(",")) + if self._optimistic_color_mode: + self._attr_color_mode = color_mode + if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: + rgb = convert_color(*color) + brightness = max(rgb) + if brightness == 0: _LOGGER.debug( - "Ignoring empty %s message from '%s'", color_mode, msg.topic + "Ignoring %s message with zero rgb brightness from '%s'", + color_mode, + msg.topic, ) return None - color = tuple(int(val) for val in str(payload).split(",")) - if self._optimistic_color_mode: - self._attr_color_mode = color_mode - if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: - rgb = convert_color(*color) - brightness = max(rgb) - if brightness == 0: - _LOGGER.debug( - "Ignoring %s message with zero rgb brightness from '%s'", - color_mode, - msg.topic, - ) - return None - self._attr_brightness = brightness - # Normalize the color to 100% brightness - color = tuple( - min(round(channel / brightness * 255), 255) for channel in color - ) - return color + self._attr_brightness = brightness + # Normalize the color to 100% brightness + color = tuple( + min(round(channel / brightness * 255), 255) for channel in color + ) + return color - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"} + @callback + def _rgb_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for RGB.""" + rgb = self._rgbx_received( + msg, CONF_RGB_VALUE_TEMPLATE, ColorMode.RGB, lambda *x: x ) - def rgb_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for RGB.""" - rgb = _rgbx_received( - msg, CONF_RGB_VALUE_TEMPLATE, ColorMode.RGB, lambda *x: x - ) - if rgb is None: - return - self._attr_rgb_color = cast(tuple[int, int, int], rgb) + if rgb is None: + return + self._attr_rgb_color = cast(tuple[int, int, int], rgb) - add_topic(CONF_RGB_STATE_TOPIC, rgb_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"} + @callback + def _rgbw_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for RGBW.""" + rgbw = self._rgbx_received( + msg, + CONF_RGBW_VALUE_TEMPLATE, + ColorMode.RGBW, + color_util.color_rgbw_to_rgb, ) - def rgbw_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for RGBW.""" - rgbw = _rgbx_received( - msg, - CONF_RGBW_VALUE_TEMPLATE, - ColorMode.RGBW, - color_util.color_rgbw_to_rgb, - ) - if rgbw is None: - return - self._attr_rgbw_color = cast(tuple[int, int, int, int], rgbw) + if rgbw is None: + return + self._attr_rgbw_color = cast(tuple[int, int, int, int], rgbw) - add_topic(CONF_RGBW_STATE_TOPIC, rgbw_received) + @callback + def _rgbww_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for RGBWW.""" @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"} + def _converter( + r: int, g: int, b: int, cw: int, ww: int + ) -> tuple[int, int, int]: + min_kelvin = color_util.color_temperature_mired_to_kelvin(self.max_mireds) + max_kelvin = color_util.color_temperature_mired_to_kelvin(self.min_mireds) + return color_util.color_rgbww_to_rgb( + r, g, b, cw, ww, min_kelvin, max_kelvin + ) + + rgbww = self._rgbx_received( + msg, + CONF_RGBWW_VALUE_TEMPLATE, + ColorMode.RGBWW, + _converter, ) - def rgbww_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for RGBWW.""" + if rgbww is None: + return + self._attr_rgbww_color = cast(tuple[int, int, int, int, int], rgbww) - @callback - def _converter( - r: int, g: int, b: int, cw: int, ww: int - ) -> tuple[int, int, int]: - min_kelvin = color_util.color_temperature_mired_to_kelvin( - self.max_mireds - ) - max_kelvin = color_util.color_temperature_mired_to_kelvin( - self.min_mireds - ) - return color_util.color_rgbww_to_rgb( - r, g, b, cw, ww, min_kelvin, max_kelvin - ) + @callback + def _color_mode_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for color mode.""" + payload = self._value_templates[CONF_COLOR_MODE_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty color mode message from '%s'", msg.topic) + return - rgbww = _rgbx_received( - msg, - CONF_RGBWW_VALUE_TEMPLATE, - ColorMode.RGBWW, - _converter, - ) - if rgbww is None: - return - self._attr_rgbww_color = cast(tuple[int, int, int, int, int], rgbww) + self._attr_color_mode = ColorMode(str(payload)) - add_topic(CONF_RGBWW_STATE_TOPIC, rgbww_received) + @callback + def _color_temp_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for color temperature.""" + payload = self._value_templates[CONF_COLOR_TEMP_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty color temp message from '%s'", msg.topic) + return - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode"}) - def color_mode_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for color mode.""" - payload = self._value_templates[CONF_COLOR_MODE_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty color mode message from '%s'", msg.topic) - return + if self._optimistic_color_mode: + self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_color_temp = int(payload) - self._attr_color_mode = ColorMode(str(payload)) + @callback + def _effect_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for effect.""" + payload = self._value_templates[CONF_EFFECT_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty effect message from '%s'", msg.topic) + return - add_topic(CONF_COLOR_MODE_STATE_TOPIC, color_mode_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_color_temp"}) - def color_temp_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for color temperature.""" - payload = self._value_templates[CONF_COLOR_TEMP_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty color temp message from '%s'", msg.topic) - return + self._attr_effect = str(payload) + @callback + def _hs_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for hs color.""" + payload = self._value_templates[CONF_HS_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic) + return + try: + hs_color = tuple(float(val) for val in str(payload).split(",", 2)) if self._optimistic_color_mode: - self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = int(payload) + self._attr_color_mode = ColorMode.HS + self._attr_hs_color = cast(tuple[float, float], hs_color) + except ValueError: + _LOGGER.warning("Failed to parse hs state update: '%s'", payload) - add_topic(CONF_COLOR_TEMP_STATE_TOPIC, color_temp_received) + @callback + def _xy_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages for xy color.""" + payload = self._value_templates[CONF_XY_VALUE_TEMPLATE]( + msg.payload, PayloadSentinel.DEFAULT + ) + if payload is PayloadSentinel.DEFAULT or not payload: + _LOGGER.debug("Ignoring empty xy-color message from '%s'", msg.topic) + return - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_effect"}) - def effect_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for effect.""" - payload = self._value_templates[CONF_EFFECT_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty effect message from '%s'", msg.topic) - return + xy_color = tuple(float(val) for val in str(payload).split(",", 2)) + if self._optimistic_color_mode: + self._attr_color_mode = ColorMode.XY + self._attr_xy_color = cast(tuple[float, float], xy_color) - self._attr_effect = str(payload) - - add_topic(CONF_EFFECT_STATE_TOPIC, effect_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_hs_color"}) - def hs_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for hs color.""" - payload = self._value_templates[CONF_HS_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic) - return - try: - hs_color = tuple(float(val) for val in str(payload).split(",", 2)) - if self._optimistic_color_mode: - self._attr_color_mode = ColorMode.HS - self._attr_hs_color = cast(tuple[float, float], hs_color) - except ValueError: - _LOGGER.warning("Failed to parse hs state update: '%s'", payload) - - add_topic(CONF_HS_STATE_TOPIC, hs_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_color_mode", "_attr_xy_color"}) - def xy_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages for xy color.""" - payload = self._value_templates[CONF_XY_VALUE_TEMPLATE]( - msg.payload, PayloadSentinel.DEFAULT - ) - if payload is PayloadSentinel.DEFAULT or not payload: - _LOGGER.debug("Ignoring empty xy-color message from '%s'", msg.topic) - return - - xy_color = tuple(float(val) for val in str(payload).split(",", 2)) - if self._optimistic_color_mode: - self._attr_color_mode = ColorMode.XY - self._attr_xy_color = cast(tuple[float, float], xy_color) - - add_topic(CONF_XY_STATE_TOPIC, xy_received) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + @callback + def _prepare_subscribe_topics(self) -> None: # noqa: C901 + """(Re)Subscribe to topics.""" + self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) + self.add_subscription( + CONF_BRIGHTNESS_STATE_TOPIC, self._brightness_received, {"_attr_brightness"} + ) + self.add_subscription( + CONF_RGB_STATE_TOPIC, + self._rgb_received, + {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"}, + ) + self.add_subscription( + CONF_RGBW_STATE_TOPIC, + self._rgbw_received, + {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"}, + ) + self.add_subscription( + CONF_RGBWW_STATE_TOPIC, + self._rgbww_received, + {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"}, + ) + self.add_subscription( + CONF_COLOR_MODE_STATE_TOPIC, self._color_mode_received, {"_attr_color_mode"} + ) + self.add_subscription( + CONF_COLOR_TEMP_STATE_TOPIC, + self._color_temp_received, + {"_attr_color_mode", "_attr_color_temp"}, + ) + self.add_subscription( + CONF_EFFECT_STATE_TOPIC, self._effect_received, {"_attr_effect"} + ) + self.add_subscription( + CONF_HS_STATE_TOPIC, + self._hs_received, + {"_attr_color_mode", "_attr_hs_color"}, + ) + self.add_subscription( + CONF_XY_STATE_TOPIC, + self._xy_received, + {"_attr_color_mode", "_attr_xy_color"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) last_state = await self.async_get_last_state() def restore_state( @@ -678,13 +641,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): async def publish(topic: str, payload: PublishPayloadType) -> None: """Publish an MQTT message.""" - await self.async_publish( - str(self._topic[topic]), - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(str(self._topic[topic]), payload) def scale_rgbx( color: tuple[int, ...], @@ -889,12 +846,8 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): This method is a coroutine. """ - await self.async_publish( - str(self._topic[CONF_COMMAND_TOPIC]), - self._payload["off"], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + str(self._topic[CONF_COMMAND_TOPIC]), self._payload["off"] ) if self._optimistic: diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 6d3cd6328b8..1d3ad3a6ef0 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -60,15 +60,14 @@ from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, DOMAIN as MQTT_DOMAIN, ) -from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change +from ..mixins import MqttEntity from ..models import ReceiveMessage +from ..schemas import MQTT_ENTITY_COMMON_SCHEMA from ..util import valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( @@ -413,13 +412,88 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self.entity_id, ) + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + values = json_loads_object(msg.payload) + + if values["state"] == "ON": + self._attr_is_on = True + elif values["state"] == "OFF": + self._attr_is_on = False + elif values["state"] is None: + self._attr_is_on = None + + if ( + self._deprecated_color_handling + and color_supported(self.supported_color_modes) + and "color" in values + ): + # Deprecated color handling + if values["color"] is None: + self._attr_hs_color = None + else: + self._update_color(values) + + if not self._deprecated_color_handling and "color_mode" in values: + self._update_color(values) + + if brightness_supported(self.supported_color_modes): + try: + if brightness := values["brightness"]: + 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( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + + except KeyError: + pass + except (TypeError, ValueError): + _LOGGER.warning( + "Invalid brightness value '%s' received for entity %s", + values["brightness"], + self.entity_id, + ) + + if ( + self._deprecated_color_handling + and self.supported_color_modes + and ColorMode.COLOR_TEMP in self.supported_color_modes + ): + # Deprecated color handling + try: + if values["color_temp"] is None: + self._attr_color_temp = None + else: + self._attr_color_temp = int(values["color_temp"]) # type: ignore[arg-type] + except KeyError: + pass + except ValueError: + _LOGGER.warning( + "Invalid color temp value '%s' received for entity %s", + values["color_temp"], + self.entity_id, + ) + # Allow to switch back to color_temp + if "color" not in values: + self._attr_hs_color = None + + if self.supported_features and LightEntityFeature.EFFECT: + with suppress(KeyError): + self._attr_effect = cast(str, values["effect"]) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + self.add_subscription( + CONF_STATE_TOPIC, + self._state_received, { "_attr_brightness", "_attr_color_temp", @@ -433,98 +507,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): "color_mode", }, ) - def state_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - values = json_loads_object(msg.payload) - - if values["state"] == "ON": - self._attr_is_on = True - elif values["state"] == "OFF": - self._attr_is_on = False - elif values["state"] is None: - self._attr_is_on = None - - if ( - self._deprecated_color_handling - and color_supported(self.supported_color_modes) - and "color" in values - ): - # Deprecated color handling - if values["color"] is None: - self._attr_hs_color = None - else: - self._update_color(values) - - if not self._deprecated_color_handling and "color_mode" in values: - self._update_color(values) - - if brightness_supported(self.supported_color_modes): - try: - if brightness := values["brightness"]: - 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( - "Ignoring zero brightness value for entity %s", - self.entity_id, - ) - - except KeyError: - pass - except (TypeError, ValueError): - _LOGGER.warning( - "Invalid brightness value '%s' received for entity %s", - values["brightness"], - self.entity_id, - ) - - if ( - self._deprecated_color_handling - and self.supported_color_modes - and ColorMode.COLOR_TEMP in self.supported_color_modes - ): - # Deprecated color handling - try: - if values["color_temp"] is None: - self._attr_color_temp = None - else: - self._attr_color_temp = int(values["color_temp"]) # type: ignore[arg-type] - except KeyError: - pass - except ValueError: - _LOGGER.warning( - "Invalid color temp value '%s' received for entity %s", - values["color_temp"], - self.entity_id, - ) - # Allow to switch back to color_temp - if "color" not in values: - self._attr_hs_color = None - - if self.supported_features and LightEntityFeature.EFFECT: - with suppress(KeyError): - self._attr_effect = cast(str, values["effect"]) - - if self._topic[CONF_STATE_TOPIC] is not None: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) last_state = await self.async_get_last_state() if self._optimistic and last_state: @@ -733,12 +719,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_brightness = kwargs[ATTR_WHITE] should_update = True - await self.async_publish( - str(self._topic[CONF_COMMAND_TOPIC]), - json_dumps(message), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + str(self._topic[CONF_COMMAND_TOPIC]), json_dumps(message) ) if self._optimistic: @@ -758,12 +740,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) - await self.async_publish( - str(self._topic[CONF_COMMAND_TOPIC]), - json_dumps(message), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + str(self._topic[CONF_COMMAND_TOPIC]), json_dumps(message) ) if self._optimistic: diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 95f97f0a736..d414f219241 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -36,16 +36,8 @@ import homeassistant.util.color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA -from ..const import ( - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - CONF_STATE_TOPIC, - PAYLOAD_NONE, -) -from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, write_state_on_attr_change +from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE +from ..mixins import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, @@ -53,6 +45,7 @@ from ..models import ( ReceiveMessage, ReceivePayloadType, ) +from ..schemas import MQTT_ENTITY_COMMON_SCHEMA from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import MQTT_LIGHT_ATTRIBUTES_BLOCKED @@ -187,13 +180,79 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): # Support for ct + hs, prioritize hs self._attr_color_mode = ColorMode.HS if self.hs_color else ColorMode.COLOR_TEMP + @callback + def _state_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) + if state == STATE_ON: + self._attr_is_on = True + elif state == STATE_OFF: + self._attr_is_on = False + elif state == PAYLOAD_NONE: + self._attr_is_on = None + else: + _LOGGER.warning("Invalid state value received") + + if CONF_BRIGHTNESS_TEMPLATE in self._config: + try: + if brightness := int( + self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) + ): + self._attr_brightness = brightness + else: + _LOGGER.debug( + "Ignoring zero brightness value for entity %s", + self.entity_id, + ) + + except ValueError: + _LOGGER.warning("Invalid brightness value received from %s", msg.topic) + + if CONF_COLOR_TEMP_TEMPLATE in self._config: + try: + color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( + msg.payload + ) + self._attr_color_temp = ( + int(color_temp) if color_temp != "None" else None + ) + except ValueError: + _LOGGER.warning("Invalid color temperature value received") + + if ( + CONF_RED_TEMPLATE in self._config + and CONF_GREEN_TEMPLATE in self._config + and CONF_BLUE_TEMPLATE in self._config + ): + try: + red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) + green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) + blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) + if red == "None" and green == "None" and blue == "None": + self._attr_hs_color = None + else: + self._attr_hs_color = color_util.color_RGB_to_hs( + int(red), int(green), int(blue) + ) + self._update_color_mode() + except ValueError: + _LOGGER.warning("Invalid color value received") + + if CONF_EFFECT_TEMPLATE in self._config: + effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) + if ( + effect_list := self._config[CONF_EFFECT_LIST] + ) and effect in effect_list: + self._attr_effect = effect + else: + _LOGGER.warning("Unsupported effect value received") + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + self.add_subscription( + CONF_STATE_TOPIC, + self._state_received, { "_attr_brightness", "_attr_color_mode", @@ -203,91 +262,10 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): "_attr_is_on", }, ) - def state_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload) - if state == STATE_ON: - self._attr_is_on = True - elif state == STATE_OFF: - self._attr_is_on = False - elif state == PAYLOAD_NONE: - self._attr_is_on = None - else: - _LOGGER.warning("Invalid state value received") - - if CONF_BRIGHTNESS_TEMPLATE in self._config: - try: - if brightness := int( - self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload) - ): - self._attr_brightness = brightness - else: - _LOGGER.debug( - "Ignoring zero brightness value for entity %s", - self.entity_id, - ) - - except ValueError: - _LOGGER.warning( - "Invalid brightness value received from %s", msg.topic - ) - - if CONF_COLOR_TEMP_TEMPLATE in self._config: - try: - color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE]( - msg.payload - ) - self._attr_color_temp = ( - int(color_temp) if color_temp != "None" else None - ) - except ValueError: - _LOGGER.warning("Invalid color temperature value received") - - if ( - CONF_RED_TEMPLATE in self._config - and CONF_GREEN_TEMPLATE in self._config - and CONF_BLUE_TEMPLATE in self._config - ): - try: - red = self._value_templates[CONF_RED_TEMPLATE](msg.payload) - green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload) - blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload) - if red == "None" and green == "None" and blue == "None": - self._attr_hs_color = None - else: - self._attr_hs_color = color_util.color_RGB_to_hs( - int(red), int(green), int(blue) - ) - self._update_color_mode() - except ValueError: - _LOGGER.warning("Invalid color value received") - - if CONF_EFFECT_TEMPLATE in self._config: - effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload)) - if ( - effect_list := self._config[CONF_EFFECT_LIST] - ) and effect in effect_list: - self._attr_effect = effect - else: - _LOGGER.warning("Unsupported effect value received") - - if self._topics[CONF_STATE_TOPIC] is not None: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._topics[CONF_STATE_TOPIC], - "msg_callback": state_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) last_state = await self.async_get_last_state() if self._optimistic and last_state: @@ -363,12 +341,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if ATTR_TRANSITION in kwargs: values["transition"] = kwargs[ATTR_TRANSITION] - await self.async_publish( + await self.async_publish_with_config( str(self._topics[CONF_COMMAND_TOPIC]), self._command_templates[CONF_COMMAND_ON_TEMPLATE](None, values), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], ) if self._optimistic: @@ -386,12 +361,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if ATTR_TRANSITION in kwargs: values["transition"] = kwargs[ATTR_TRANSITION] - await self.async_publish( + await self.async_publish_with_config( str(self._topics[CONF_COMMAND_TOPIC]), self._command_templates[CONF_COMMAND_OFF_TEMPLATE](None, values), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], ) if self._optimistic: diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 79e02be9d4f..22b0e24b3c6 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import logging import re from typing import Any @@ -27,19 +28,12 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_PAYLOAD_RESET, - CONF_QOS, - CONF_RETAIN, + CONF_STATE_OPEN, + CONF_STATE_OPENING, CONF_STATE_TOPIC, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -47,6 +41,9 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA + +_LOGGER = logging.getLogger(__name__) CONF_CODE_FORMAT = "code_format" @@ -56,6 +53,7 @@ CONF_PAYLOAD_OPEN = "payload_open" CONF_STATE_LOCKED = "state_locked" CONF_STATE_LOCKING = "state_locking" + CONF_STATE_UNLOCKED = "state_unlocked" CONF_STATE_UNLOCKING = "state_unlocking" CONF_STATE_JAMMED = "state_jammed" @@ -67,6 +65,8 @@ DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_PAYLOAD_RESET = "None" DEFAULT_STATE_LOCKED = "LOCKED" DEFAULT_STATE_LOCKING = "LOCKING" +DEFAULT_STATE_OPEN = "OPEN" +DEFAULT_STATE_OPENING = "OPENING" DEFAULT_STATE_UNLOCKED = "UNLOCKED" DEFAULT_STATE_UNLOCKING = "UNLOCKING" DEFAULT_STATE_JAMMED = "JAMMED" @@ -90,6 +90,8 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_STATE_JAMMED, default=DEFAULT_STATE_JAMMED): cv.string, vol.Optional(CONF_STATE_LOCKED, default=DEFAULT_STATE_LOCKED): cv.string, vol.Optional(CONF_STATE_LOCKING, default=DEFAULT_STATE_LOCKING): cv.string, + vol.Optional(CONF_STATE_OPEN, default=DEFAULT_STATE_OPEN): cv.string, + vol.Optional(CONF_STATE_OPENING, default=DEFAULT_STATE_OPENING): cv.string, vol.Optional(CONF_STATE_UNLOCKED, default=DEFAULT_STATE_UNLOCKED): cv.string, vol.Optional(CONF_STATE_UNLOCKING, default=DEFAULT_STATE_UNLOCKING): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -102,6 +104,8 @@ STATE_CONFIG_KEYS = [ CONF_STATE_JAMMED, CONF_STATE_LOCKED, CONF_STATE_LOCKING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, CONF_STATE_UNLOCKED, CONF_STATE_UNLOCKING, ] @@ -113,7 +117,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lock through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttLock, @@ -174,57 +178,47 @@ class MqttLock(MqttEntity, LockEntity): self._valid_states = [config[state] for state in STATE_CONFIG_KEYS] + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new lock state messages.""" + payload = self._value_template(msg.payload) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + if payload == self._config[CONF_PAYLOAD_RESET]: + # Reset the state to `unknown` + self._attr_is_locked = None + elif payload in self._valid_states: + self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] + self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] + self._attr_is_open = payload == self._config[CONF_STATE_OPEN] + self._attr_is_opening = payload == self._config[CONF_STATE_OPENING] + self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] + self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - topics: dict[str, dict[str, Any]] = {} - qos: int = self._config[CONF_QOS] - encoding: str | None = self._config[CONF_ENCODING] or None - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + self.add_subscription( + CONF_STATE_TOPIC, + self._message_received, { "_attr_is_jammed", "_attr_is_locked", "_attr_is_locking", + "_attr_is_open", + "_attr_is_opening", "_attr_is_unlocking", }, ) - def message_received(msg: ReceiveMessage) -> None: - """Handle new lock state messages.""" - if (payload := self._value_template(msg.payload)) == self._config[ - CONF_PAYLOAD_RESET - ]: - # Reset the state to `unknown` - self._attr_is_locked = None - elif payload in self._valid_states: - self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED] - self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING] - self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] - self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] - - if self._config.get(CONF_STATE_TOPIC) is None: - # Force into optimistic mode. - self._optimistic = True - else: - topics[CONF_STATE_TOPIC] = { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": message_received, - CONF_QOS: qos, - CONF_ENCODING: encoding, - } - - 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) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_lock(self, **kwargs: Any) -> None: """Lock the device. @@ -235,13 +229,7 @@ class MqttLock(MqttEntity, LockEntity): ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None } payload = self._command_template(self._config[CONF_PAYLOAD_LOCK], tpl_vars) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that the lock has changed state. self._attr_is_locked = True @@ -256,13 +244,7 @@ class MqttLock(MqttEntity, LockEntity): ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None } payload = self._command_template(self._config[CONF_PAYLOAD_UNLOCK], tpl_vars) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that the lock has changed state. self._attr_is_locked = False @@ -277,14 +259,8 @@ class MqttLock(MqttEntity, LockEntity): ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None } payload = self._command_template(self._config[CONF_PAYLOAD_OPEN], tpl_vars) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that the lock unlocks when opened. - self._attr_is_locked = False + self._attr_is_open = True self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 63df7c71c09..55b76337db0 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,8 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine -import functools -from functools import partial, wraps +from functools import partial import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final @@ -30,12 +29,8 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, -) +from homeassistant.core import Event, HassJobType, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import ( DeviceEntry, DeviceInfo, @@ -45,17 +40,14 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import ( - ENTITY_CATEGORIES_SCHEMA, - Entity, - async_generate_entity_id, -) +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ( UNDEFINED, ConfigType, @@ -71,17 +63,26 @@ from .const import ( ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, + AVAILABILITY_ALL, + AVAILABILITY_ANY, CONF_AVAILABILITY, + CONF_AVAILABILITY_MODE, + CONF_AVAILABILITY_TEMPLATE, + CONF_AVAILABILITY_TOPIC, CONF_CONFIGURATION_URL, CONF_CONNECTIONS, - CONF_DEPRECATED_VIA_HUB, + CONF_ENABLED_BY_DEFAULT, CONF_ENCODING, CONF_HW_VERSION, CONF_IDENTIFIERS, + CONF_JSON_ATTRS_TEMPLATE, + CONF_JSON_ATTRS_TOPIC, CONF_MANUFACTURER, CONF_OBJECT_ID, - CONF_ORIGIN, + CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, + CONF_RETAIN, CONF_SCHEMA, CONF_SERIAL_NUMBER, CONF_SUGGESTED_AREA, @@ -89,23 +90,20 @@ from .const import ( CONF_TOPIC, CONF_VIA_DEVICE, DEFAULT_ENCODING, - DEFAULT_PAYLOAD_AVAILABLE, - DEFAULT_PAYLOAD_NOT_AVAILABLE, DOMAIN, - MQTT_CONNECTED, - MQTT_DISCONNECTED, + MQTT_CONNECTION_STATE, ) -from .debug_info import log_message, log_messages +from .debug_info import log_message from .discovery import ( MQTT_DISCOVERY_DONE, MQTT_DISCOVERY_NEW, MQTT_DISCOVERY_UPDATED, - MQTT_ORIGIN_INFO_SCHEMA, MQTTDiscoveryPayload, clear_discovery_hash, set_discovery_hash, ) from .models import ( + DATA_MQTT, MessageCallbackType, MqttValueTemplate, MqttValueTemplateException, @@ -115,28 +113,13 @@ from .models import ( from .subscription import ( EntitySubscription, async_prepare_subscribe_topics, - async_subscribe_topics, + async_subscribe_topics_internal, async_unsubscribe_topics, ) -from .util import get_mqtt_data, mqtt_config_entry_enabled, valid_subscribe_topic +from .util import mqtt_config_entry_enabled _LOGGER = logging.getLogger(__name__) -AVAILABILITY_ALL = "all" -AVAILABILITY_ANY = "any" -AVAILABILITY_LATEST = "latest" - -AVAILABILITY_MODES = [AVAILABILITY_ALL, AVAILABILITY_ANY, AVAILABILITY_LATEST] - -CONF_AVAILABILITY_MODE = "availability_mode" -CONF_AVAILABILITY_TEMPLATE = "availability_template" -CONF_AVAILABILITY_TOPIC = "availability_topic" -CONF_ENABLED_BY_DEFAULT = "enabled_by_default" -CONF_PAYLOAD_AVAILABLE = "payload_available" -CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" -CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" -CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" - MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", "available", @@ -156,96 +139,6 @@ MQTT_ATTRIBUTES_BLOCKED = { "unit_of_measurement", } -MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema( - { - vol.Exclusive(CONF_AVAILABILITY_TOPIC, "availability"): valid_subscribe_topic, - vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, - vol.Optional( - CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_NOT_AVAILABLE, default=DEFAULT_PAYLOAD_NOT_AVAILABLE - ): cv.string, - } -) - -MQTT_AVAILABILITY_LIST_SCHEMA = vol.Schema( - { - vol.Optional(CONF_AVAILABILITY_MODE, default=AVAILABILITY_LATEST): vol.All( - cv.string, vol.In(AVAILABILITY_MODES) - ), - vol.Exclusive(CONF_AVAILABILITY, "availability"): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_TOPIC): valid_subscribe_topic, - vol.Optional( - CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE - ): cv.string, - vol.Optional( - CONF_PAYLOAD_NOT_AVAILABLE, - default=DEFAULT_PAYLOAD_NOT_AVAILABLE, - ): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - } - ], - ), - } -) - -MQTT_AVAILABILITY_SCHEMA = MQTT_AVAILABILITY_SINGLE_SCHEMA.extend( - MQTT_AVAILABILITY_LIST_SCHEMA.schema -) - - -def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType: - """Validate that a device info entry has at least one identifying value.""" - if value.get(CONF_IDENTIFIERS) or value.get(CONF_CONNECTIONS): - return value - raise vol.Invalid( - "Device must have at least one identifying value in " - "'identifiers' and/or 'connections'" - ) - - -MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( - cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), - vol.Schema( - { - vol.Optional(CONF_IDENTIFIERS, default=list): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_CONNECTIONS, default=list): vol.All( - cv.ensure_list, [vol.All(vol.Length(2), [cv.string])] - ), - vol.Optional(CONF_MANUFACTURER): cv.string, - vol.Optional(CONF_MODEL): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_HW_VERSION): cv.string, - vol.Optional(CONF_SERIAL_NUMBER): cv.string, - vol.Optional(CONF_SW_VERSION): cv.string, - vol.Optional(CONF_VIA_DEVICE): cv.string, - vol.Optional(CONF_SUGGESTED_AREA): cv.string, - vol.Optional(CONF_CONFIGURATION_URL): cv.configuration_url, - } - ), - validate_device_has_at_least_one_identifier, -) - -MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, - vol.Optional(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA, - vol.Optional(CONF_ENABLED_BY_DEFAULT, default=True): cv.boolean, - vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, - vol.Optional(CONF_ICON): cv.icon, - vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, - vol.Optional(CONF_OBJECT_ID): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - class SetupEntity(Protocol): """Protocol type for async_setup_entities.""" @@ -275,17 +168,20 @@ def async_handle_schema_error( ) -async def _async_discover( +def _handle_discovery_failure( hass: HomeAssistant, - domain: str, - setup: Callable[[MQTTDiscoveryPayload], None] | None, - async_setup: Callable[[MQTTDiscoveryPayload], Coroutine[Any, Any, None]] | None, discovery_payload: MQTTDiscoveryPayload, ) -> None: - """Discover and add an MQTT entity, automation or tag. + """Handle discovery failure.""" + discovery_hash = discovery_payload.discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(*discovery_hash), None) - setup is to be run in the event loop when there is nothing to be awaited. - """ + +def _verify_mqtt_config_entry_enabled_for_discovery( + hass: HomeAssistant, domain: str, discovery_payload: MQTTDiscoveryPayload +) -> bool: + """Verify MQTT config entry is enabled or log warning.""" if not mqtt_config_entry_enabled(hass): _LOGGER.warning( ( @@ -295,23 +191,8 @@ async def _async_discover( domain, discovery_payload, ) - return - discovery_data = discovery_payload.discovery_data - try: - if setup is not None: - setup(discovery_payload) - elif async_setup is not None: - await async_setup(discovery_payload) - except vol.Invalid as err: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) - async_handle_schema_error(discovery_payload, err) - except Exception: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) - raise + return False + return True class _SetupNonEntityHelperCallbackProtocol(Protocol): # pragma: no cover @@ -322,34 +203,45 @@ class _SetupNonEntityHelperCallbackProtocol(Protocol): # pragma: no cover ) -> None: ... -async def async_setup_non_entity_entry_helper( +@callback +def async_setup_non_entity_entry_helper( hass: HomeAssistant, domain: str, async_setup: _SetupNonEntityHelperCallbackProtocol, discovery_schema: vol.Schema, ) -> None: """Set up automation or tag creation dynamically through MQTT discovery.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] - async def async_setup_from_discovery( + async def _async_setup_non_entity_entry_from_discovery( discovery_payload: MQTTDiscoveryPayload, ) -> None: """Set up an MQTT entity, automation or tag from discovery.""" - config: ConfigType = discovery_schema(discovery_payload) - await async_setup(config, discovery_data=discovery_payload.discovery_data) + if not _verify_mqtt_config_entry_enabled_for_discovery( + hass, domain, discovery_payload + ): + return + try: + config: ConfigType = discovery_schema(discovery_payload) + await async_setup(config, discovery_data=discovery_payload.discovery_data) + except vol.Invalid as err: + _handle_discovery_failure(hass, discovery_payload) + async_handle_schema_error(discovery_payload, err) + except Exception: + _handle_discovery_failure(hass, discovery_payload) + raise mqtt_data.reload_dispatchers.append( async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), - functools.partial( - _async_discover, hass, domain, None, async_setup_from_discovery - ), + _async_setup_non_entity_entry_from_discovery, ) ) -async def async_setup_entity_entry_helper( +@callback +def async_setup_entity_entry_helper( hass: HomeAssistant, entry: ConfigEntry, entity_class: type[MqttEntity] | None, @@ -360,30 +252,39 @@ async def async_setup_entity_entry_helper( schema_class_mapping: dict[str, type[MqttEntity]] | None = None, ) -> None: """Set up entity creation dynamically through MQTT discovery.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] @callback - def async_setup_from_discovery( + def _async_setup_entity_entry_from_discovery( discovery_payload: MQTTDiscoveryPayload, ) -> None: """Set up an MQTT entity from discovery.""" nonlocal entity_class - config: DiscoveryInfoType = discovery_schema(discovery_payload) - if schema_class_mapping is not None: - entity_class = schema_class_mapping[config[CONF_SCHEMA]] - if TYPE_CHECKING: - assert entity_class is not None - async_add_entities( - [entity_class(hass, config, entry, discovery_payload.discovery_data)] - ) + if not _verify_mqtt_config_entry_enabled_for_discovery( + hass, domain, discovery_payload + ): + return + try: + config: DiscoveryInfoType = discovery_schema(discovery_payload) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None + async_add_entities( + [entity_class(hass, config, entry, discovery_payload.discovery_data)] + ) + except vol.Invalid as err: + _handle_discovery_failure(hass, discovery_payload) + async_handle_schema_error(discovery_payload, err) + except Exception: + _handle_discovery_failure(hass, discovery_payload) + raise mqtt_data.reload_dispatchers.append( async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), - functools.partial( - _async_discover, hass, domain, async_setup_from_discovery, None - ), + _async_setup_entity_entry_from_discovery, ) ) @@ -391,7 +292,7 @@ async def async_setup_entity_entry_helper( def _async_setup_entities() -> None: """Set up MQTT items from configuration.yaml.""" nonlocal entity_class - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] if not (config_yaml := mqtt_data.config): return yaml_configs: list[ConfigType] = [ @@ -465,49 +366,11 @@ def init_entity_id_from_config( ) -def write_state_on_attr_change( - entity: Entity, attributes: set[str] -) -> Callable[[MessageCallbackType], MessageCallbackType]: - """Wrap an MQTT message callback to track state attribute changes.""" - - def _attrs_have_changed(tracked_attrs: dict[str, Any]) -> bool: - """Return True if attributes on entity changed or if update is forced.""" - if not (write_state := (getattr(entity, "_attr_force_update", False))): - for attribute, last_value in tracked_attrs.items(): - if getattr(entity, attribute, UNDEFINED) != last_value: - write_state = True - break - - return write_state - - def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: - @wraps(msg_callback) - def wrapper(msg: ReceiveMessage) -> None: - """Track attributes for write state requests.""" - tracked_attrs: dict[str, Any] = { - attribute: getattr(entity, attribute, UNDEFINED) - for attribute in attributes - } - try: - msg_callback(msg) - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - if not _attrs_have_changed(tracked_attrs): - return - - mqtt_data = get_mqtt_data(entity.hass) - mqtt_data.state_write_requests.write_state_request(entity) - - return wrapper - - return _decorator - - -class MqttAttributes(Entity): +class MqttAttributesMixin(Entity): """Mixin used for platforms that support JSON attributes.""" _attributes_extra_blocked: frozenset[str] = frozenset() + _attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" @@ -518,7 +381,7 @@ class MqttAttributes(Entity): """Subscribe MQTT events.""" await super().async_added_to_hass() self._attributes_prepare_subscribe_topics() - await self._attributes_subscribe_topics() + self._attributes_subscribe_topics() def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" @@ -527,51 +390,37 @@ class MqttAttributes(Entity): async def attributes_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" - await self._attributes_subscribe_topics() + self._attributes_subscribe_topics() def _attributes_prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - attr_tpl = MqttValueTemplate( - self._attributes_config.get(CONF_JSON_ATTRS_TEMPLATE), entity=self - ).async_render_with_possible_json_value - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_extra_state_attributes"}) - def attributes_message_received(msg: ReceiveMessage) -> None: - """Update extra state attributes.""" - payload = attr_tpl(msg.payload) - try: - json_dict = json_loads(payload) if isinstance(payload, str) else None - if isinstance(json_dict, dict): - filtered_dict = { - k: v - for k, v in json_dict.items() - if k not in MQTT_ATTRIBUTES_BLOCKED - and k not in self._attributes_extra_blocked - } - self._attr_extra_state_attributes = filtered_dict - else: - _LOGGER.warning("JSON result was not a dictionary") - except ValueError: - _LOGGER.warning("Erroneous JSON: %s", payload) - + if template := self._attributes_config.get(CONF_JSON_ATTRS_TEMPLATE): + self._attr_tpl = MqttValueTemplate( + template, entity=self + ).async_render_with_possible_json_value self._attributes_sub_state = async_prepare_subscribe_topics( self.hass, self._attributes_sub_state, { CONF_JSON_ATTRS_TOPIC: { "topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), - "msg_callback": attributes_message_received, + "msg_callback": partial( + self._message_callback, # type: ignore[attr-defined] + self._attributes_message_received, + {"_attr_extra_state_attributes"}, + ), + "entity_id": self.entity_id, "qos": self._attributes_config.get(CONF_QOS), "encoding": self._attributes_config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) - async def _attributes_subscribe_topics(self) -> None: + @callback + def _attributes_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await async_subscribe_topics(self.hass, self._attributes_sub_state) + async_subscribe_topics_internal(self.hass, self._attributes_sub_state) async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" @@ -579,8 +428,30 @@ class MqttAttributes(Entity): self.hass, self._attributes_sub_state ) + @callback + def _attributes_message_received(self, msg: ReceiveMessage) -> None: + """Update extra state attributes.""" + payload = ( + self._attr_tpl(msg.payload) if self._attr_tpl is not None else msg.payload + ) + try: + json_dict = json_loads(payload) if isinstance(payload, str) else None + except ValueError: + _LOGGER.warning("Erroneous JSON: %s", payload) + else: + if isinstance(json_dict, dict): + filtered_dict = { + k: v + for k, v in json_dict.items() + if k not in MQTT_ATTRIBUTES_BLOCKED + and k not in self._attributes_extra_blocked + } + self._attr_extra_state_attributes = filtered_dict + else: + _LOGGER.warning("JSON result was not a dictionary") -class MqttAvailability(Entity): + +class MqttAvailabilityMixin(Entity): """Mixin used for platforms that report availability.""" def __init__(self, config: ConfigType) -> None: @@ -594,13 +465,12 @@ class MqttAvailability(Entity): """Subscribe MQTT events.""" await super().async_added_to_hass() self._availability_prepare_subscribe_topics() - await self._availability_subscribe_topics() - self.async_on_remove( - async_dispatcher_connect(self.hass, MQTT_CONNECTED, self.async_mqtt_connect) - ) + self._availability_subscribe_topics() self.async_on_remove( async_dispatcher_connect( - self.hass, MQTT_DISCONNECTED, self.async_mqtt_connect + self.hass, + MQTT_CONNECTION_STATE, + self.async_mqtt_connection_state_changed, ) ) @@ -611,7 +481,7 @@ class MqttAvailability(Entity): async def availability_discovery_update(self, config: DiscoveryInfoType) -> None: """Handle updated discovery message.""" - await self._availability_subscribe_topics() + self._availability_subscribe_topics() def _availability_setup_from_config(self, config: ConfigType) -> None: """(Re)Setup.""" @@ -633,39 +503,30 @@ class MqttAvailability(Entity): } for avail_topic_conf in self._avail_topics.values(): - avail_topic_conf[CONF_AVAILABILITY_TEMPLATE] = MqttValueTemplate( - avail_topic_conf[CONF_AVAILABILITY_TEMPLATE], - entity=self, - ).async_render_with_possible_json_value + if template := avail_topic_conf[CONF_AVAILABILITY_TEMPLATE]: + avail_topic_conf[CONF_AVAILABILITY_TEMPLATE] = MqttValueTemplate( + template, entity=self + ).async_render_with_possible_json_value self._avail_config = config def _availability_prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"available"}) - def availability_message_received(msg: ReceiveMessage) -> None: - """Handle a new received MQTT availability message.""" - topic = msg.topic - payload = self._avail_topics[topic][CONF_AVAILABILITY_TEMPLATE](msg.payload) - if payload == self._avail_topics[topic][CONF_PAYLOAD_AVAILABLE]: - self._available[topic] = True - self._available_latest = True - elif payload == self._avail_topics[topic][CONF_PAYLOAD_NOT_AVAILABLE]: - self._available[topic] = False - self._available_latest = False - self._available = { topic: (self._available.get(topic, False)) for topic in self._avail_topics } topics: dict[str, dict[str, Any]] = { f"availability_{topic}": { "topic": topic, - "msg_callback": availability_message_received, + "msg_callback": partial( + self._message_callback, # type: ignore[attr-defined] + self._availability_message_received, + {"available"}, + ), + "entity_id": self.entity_id, "qos": self._avail_config[CONF_QOS], "encoding": self._avail_config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } for topic in self._avail_topics } @@ -676,12 +537,28 @@ class MqttAvailability(Entity): topics, ) - async def _availability_subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - await async_subscribe_topics(self.hass, self._availability_sub_state) + @callback + def _availability_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT availability message.""" + topic = msg.topic + avail_topic = self._avail_topics[topic] + template = avail_topic[CONF_AVAILABILITY_TEMPLATE] + payload = template(msg.payload) if template else msg.payload + + if payload == avail_topic[CONF_PAYLOAD_AVAILABLE]: + self._available[topic] = True + self._available_latest = True + elif payload == avail_topic[CONF_PAYLOAD_NOT_AVAILABLE]: + self._available[topic] = False + self._available_latest = False @callback - def async_mqtt_connect(self) -> None: + def _availability_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + async_subscribe_topics_internal(self.hass, self._availability_sub_state) + + @callback + def async_mqtt_connection_state_changed(self, state: bool) -> None: """Update state on connection/disconnection to MQTT broker.""" if not self.hass.is_stopping: self.async_write_ha_state() @@ -695,7 +572,7 @@ class MqttAvailability(Entity): @property def available(self) -> bool: """Return if the device is available.""" - mqtt_data = get_mqtt_data(self.hass) + mqtt_data = self.hass.data[DATA_MQTT] client = mqtt_data.client if not client.connected and not self.hass.is_stopping: return False @@ -745,7 +622,7 @@ def get_discovery_hash(discovery_data: DiscoveryInfoType) -> tuple[str, str]: def send_discovery_done(hass: HomeAssistant, discovery_data: DiscoveryInfoType) -> None: """Acknowledge a discovery message has been handled.""" discovery_hash = get_discovery_hash(discovery_data) - async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) + async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(*discovery_hash), None) def stop_discovery_updates( @@ -770,7 +647,7 @@ async def async_remove_discovery_payload( after a restart of Home Assistant. """ discovery_topic = discovery_data[ATTR_DISCOVERY_TOPIC] - await async_publish(hass, discovery_topic, "", retain=True) + await async_publish(hass, discovery_topic, None, retain=True) async def async_clear_discovery_topic_if_entity_removed( @@ -784,7 +661,7 @@ async def async_clear_discovery_topic_if_entity_removed( await async_remove_discovery_payload(hass, discovery_data) -class MqttDiscoveryDeviceUpdate(ABC): +class MqttDiscoveryDeviceUpdateMixin(ABC): """Add support for auto discovery for platforms without an entity.""" def __init__( @@ -809,7 +686,7 @@ class MqttDiscoveryDeviceUpdate(ABC): discovery_hash = get_discovery_hash(discovery_data) self._remove_discovery_updated = async_dispatcher_connect( hass, - MQTT_DISCOVERY_UPDATED.format(discovery_hash), + MQTT_DISCOVERY_UPDATED.format(*discovery_hash), self.async_discovery_update, ) config_entry.async_on_unload(self._entry_unload) @@ -817,7 +694,7 @@ class MqttDiscoveryDeviceUpdate(ABC): self._remove_device_updated = async_track_device_registry_updated_event( hass, device_id, self._async_device_removed ) - _LOGGER.info( + _LOGGER.debug( "%s %s has been initialized", self.log_name, discovery_hash, @@ -837,7 +714,7 @@ class MqttDiscoveryDeviceUpdate(ABC): ) -> None: """Handle discovery update.""" discovery_hash = get_discovery_hash(self._discovery_data) - _LOGGER.info( + _LOGGER.debug( "Got update for %s with hash: %s '%s'", self.log_name, discovery_hash, @@ -847,8 +724,8 @@ class MqttDiscoveryDeviceUpdate(ABC): discovery_payload and discovery_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD] ): - _LOGGER.info( - "%s %s updating", + _LOGGER.debug( + "Updating %s with hash %s", self.log_name, discovery_hash, ) @@ -864,7 +741,7 @@ class MqttDiscoveryDeviceUpdate(ABC): ) await self._async_tear_down() send_discovery_done(self.hass, self._discovery_data) - _LOGGER.info( + _LOGGER.debug( "%s %s has been removed", self.log_name, discovery_hash, @@ -872,7 +749,7 @@ class MqttDiscoveryDeviceUpdate(ABC): else: # Normal update without change send_discovery_done(self.hass, self._discovery_data) - _LOGGER.info( + _LOGGER.debug( "%s %s no changes", self.log_name, discovery_hash, @@ -919,7 +796,7 @@ class MqttDiscoveryDeviceUpdate(ABC): """Handle the cleanup of platform specific parts, extend to the platform.""" -class MqttDiscoveryUpdate(Entity): +class MqttDiscoveryUpdateMixin(Entity): """Mixin used to handle updated discovery message for entity based platforms.""" def __init__( @@ -936,7 +813,7 @@ class MqttDiscoveryUpdate(Entity): self._removed_from_hass = False if discovery_data is None: return - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] self._registry_hooks = mqtt_data.discovery_registry_hooks discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH] if discovery_hash in self._registry_hooks: @@ -946,107 +823,99 @@ class MqttDiscoveryUpdate(Entity): """Subscribe to discovery updates.""" await super().async_added_to_hass() self._removed_from_hass = False - discovery_hash: tuple[str, str] | None = ( - self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None + if not self._discovery_data: + return + discovery_hash: tuple[str, str] = self._discovery_data[ATTR_DISCOVERY_HASH] + debug_info.add_entity_discovery_data( + self.hass, self._discovery_data, self.entity_id + ) + # Set in case the entity has been removed and is re-added, + # for example when changing entity_id + set_discovery_hash(self.hass, discovery_hash) + self._remove_discovery_updated = async_dispatcher_connect( + self.hass, + MQTT_DISCOVERY_UPDATED.format(*discovery_hash), + self._async_discovery_callback, ) - async def _async_remove_state_and_registry_entry( - self: MqttDiscoveryUpdate, - ) -> None: - """Remove entity's state and entity registry entry. + async def _async_remove_state_and_registry_entry( + self: MqttDiscoveryUpdateMixin, + ) -> None: + """Remove entity's state and entity registry entry. - Remove entity from entity registry if it is registered, - this also removes the state. If the entity is not in the entity - registry, just remove the state. - """ - entity_registry = er.async_get(self.hass) - if entity_entry := entity_registry.async_get(self.entity_id): - entity_registry.async_remove(self.entity_id) - await cleanup_device_registry( - self.hass, entity_entry.device_id, entity_entry.config_entry_id - ) - else: - await self.async_remove(force_remove=True) + Remove entity from entity registry if it is registered, + this also removes the state. If the entity is not in the entity + registry, just remove the state. + """ + entity_registry = er.async_get(self.hass) + if entity_entry := entity_registry.async_get(self.entity_id): + entity_registry.async_remove(self.entity_id) + await cleanup_device_registry( + self.hass, entity_entry.device_id, entity_entry.config_entry_id + ) + else: + await self.async_remove(force_remove=True) - async def _async_process_discovery_update( - payload: MQTTDiscoveryPayload, - discovery_update: Callable[ - [MQTTDiscoveryPayload], Coroutine[Any, Any, None] - ], - discovery_data: DiscoveryInfoType, - ) -> None: - """Process discovery update.""" - try: - await discovery_update(payload) - finally: - send_discovery_done(self.hass, discovery_data) - - async def _async_process_discovery_update_and_remove( - payload: MQTTDiscoveryPayload, discovery_data: DiscoveryInfoType - ) -> None: - """Process discovery update and remove entity.""" - self._cleanup_discovery_on_remove() - await _async_remove_state_and_registry_entry(self) + async def _async_process_discovery_update( + self, + payload: MQTTDiscoveryPayload, + discovery_update: Callable[[MQTTDiscoveryPayload], Coroutine[Any, Any, None]], + discovery_data: DiscoveryInfoType, + ) -> None: + """Process discovery update.""" + try: + await discovery_update(payload) + finally: send_discovery_done(self.hass, discovery_data) - @callback - def discovery_callback(payload: MQTTDiscoveryPayload) -> None: - """Handle discovery update. + async def _async_process_discovery_update_and_remove(self) -> None: + """Process discovery update and remove entity.""" + if TYPE_CHECKING: + assert self._discovery_data + self._cleanup_discovery_on_remove() + await self._async_remove_state_and_registry_entry() + send_discovery_done(self.hass, self._discovery_data) - If the payload has changed we will create a task to - do the discovery update. + @callback + def _async_discovery_callback(self, payload: MQTTDiscoveryPayload) -> None: + """Handle discovery update. - As this callback can fire when nothing has changed, this - is a normal function to avoid task creation until it is needed. - """ - _LOGGER.debug( - "Got update for entity with hash: %s '%s'", - discovery_hash, - payload, + If the payload has changed we will create a task to + do the discovery update. + + As this callback can fire when nothing has changed, this + is a normal function to avoid task creation until it is needed. + """ + if TYPE_CHECKING: + assert self._discovery_data + discovery_hash: tuple[str, str] = self._discovery_data[ATTR_DISCOVERY_HASH] + _LOGGER.debug( + "Got update for entity with hash: %s '%s'", + discovery_hash, + payload, + ) + old_payload: DiscoveryInfoType + old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] + debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) + if not payload: + # Empty payload: Remove component + _LOGGER.info("Removing component: %s", self.entity_id) + self.hass.async_create_task( + self._async_process_discovery_update_and_remove() ) - if TYPE_CHECKING: - assert self._discovery_data - old_payload: DiscoveryInfoType - old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] - debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) - if not payload: - # Empty payload: Remove component - _LOGGER.info("Removing component: %s", self.entity_id) + elif self._discovery_update: + if old_payload != payload: + # Non-empty, changed payload: Notify component + _LOGGER.info("Updating component: %s", self.entity_id) self.hass.async_create_task( - _async_process_discovery_update_and_remove( - payload, self._discovery_data - ), - eager_start=False, - ) - elif self._discovery_update: - if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: - # Non-empty, changed payload: Notify component - _LOGGER.info("Updating component: %s", self.entity_id) - self.hass.async_create_task( - _async_process_discovery_update( - payload, self._discovery_update, self._discovery_data - ), - eager_start=False, + self._async_process_discovery_update( + payload, self._discovery_update, self._discovery_data ) - else: - # Non-empty, unchanged payload: Ignore to avoid changing states - _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) - send_discovery_done(self.hass, self._discovery_data) - - if discovery_hash: - if TYPE_CHECKING: - assert self._discovery_data is not None - debug_info.add_entity_discovery_data( - self.hass, self._discovery_data, self.entity_id - ) - # Set in case the entity has been removed and is re-added, - # for example when changing entity_id - set_discovery_hash(self.hass, discovery_hash) - self._remove_discovery_updated = async_dispatcher_connect( - self.hass, - MQTT_DISCOVERY_UPDATED.format(discovery_hash), - discovery_callback, - ) + ) + else: + # Non-empty, unchanged payload: Ignore to avoid changing states + _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) + send_discovery_done(self.hass, self._discovery_data) async def async_removed_from_registry(self) -> None: """Clear retained discovery topic in broker.""" @@ -1059,6 +928,15 @@ class MqttDiscoveryUpdate(Entity): # rediscovered after a restart await async_remove_discovery_payload(self.hass, self._discovery_data) + @final + async def add_to_platform_finish(self) -> None: + """Finish adding entity to platform.""" + await super().add_to_platform_finish() + # Only send the discovery done after the entity is fully added + # and the state is written to the state machine. + if self._discovery_data is not None: + send_discovery_done(self.hass, self._discovery_data) + @callback def add_to_platform_abort(self) -> None: """Abort adding an entity to a platform.""" @@ -1166,13 +1044,14 @@ class MqttEntityDeviceInfo(Entity): class MqttEntity( - MqttAttributes, - MqttAvailability, - MqttDiscoveryUpdate, + MqttAttributesMixin, + MqttAvailabilityMixin, + MqttDiscoveryUpdateMixin, MqttEntityDeviceInfo, ): """Representation of an MQTT entity.""" + _attr_force_update = False _attr_has_entity_name = True _attr_should_poll = False _default_name: str | None @@ -1191,6 +1070,7 @@ class MqttEntity( self._attr_unique_id = config.get(CONF_UNIQUE_ID) self._sub_state: dict[str, EntitySubscription] = {} self._discovery = discovery_data is not None + self._subscriptions: dict[str, dict[str, Any]] # Load config self._setup_from_config(self._config) @@ -1200,9 +1080,11 @@ class MqttEntity( self._init_entity_id() # Initialize mixin classes - MqttAttributes.__init__(self, config) - MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, hass, discovery_data, self.discovery_update) + MqttAttributesMixin.__init__(self, config) + MqttAvailabilityMixin.__init__(self, config) + MqttDiscoveryUpdateMixin.__init__( + self, hass, discovery_data, self.discovery_update + ) MqttEntityDeviceInfo.__init__(self, config.get(CONF_DEVICE), config_entry) def _init_entity_id(self) -> None: @@ -1215,11 +1097,16 @@ class MqttEntity( async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" await super().async_added_to_hass() + self._subscriptions = {} self._prepare_subscribe_topics() + if self._subscriptions: + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + self._subscriptions, + ) await self._subscribe_topics() await self.mqtt_async_added_to_hass() - if self._discovery_data is not None: - send_discovery_done(self.hass, self._discovery_data) async def mqtt_async_added_to_hass(self) -> None: """Call before the discovery message is acknowledged. @@ -1242,7 +1129,14 @@ class MqttEntity( self.attributes_prepare_discovery_update(config) self.availability_prepare_discovery_update(config) self.device_info_discovery_update(config) + self._subscriptions = {} self._prepare_subscribe_topics() + if self._subscriptions: + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + self._subscriptions, + ) # Finalize MQTT subscriptions await self.attributes_discovery_update(config) @@ -1255,9 +1149,9 @@ class MqttEntity( self._sub_state = subscription.async_unsubscribe_topics( self.hass, self._sub_state ) - await MqttAttributes.async_will_remove_from_hass(self) - await MqttAvailability.async_will_remove_from_hass(self) - await MqttDiscoveryUpdate.async_will_remove_from_hass(self) + await MqttAttributesMixin.async_will_remove_from_hass(self) + await MqttAvailabilityMixin.async_will_remove_from_hass(self) + await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self) debug_info.remove_entity_data(self.hass, self.entity_id) async def async_publish( @@ -1279,6 +1173,18 @@ class MqttEntity( encoding, ) + async def async_publish_with_config( + self, topic: str, payload: PublishPayloadType + ) -> None: + """Publish payload to a topic using config.""" + await self.async_publish( + topic, + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + @staticmethod @abstractmethod def config_schema() -> vol.Schema: @@ -1320,6 +1226,7 @@ class MqttEntity( """(Re)Setup the entity.""" @abstractmethod + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -1327,6 +1234,76 @@ class MqttEntity( async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" + @callback + def _attrs_have_changed( + self, attrs_snapshot: tuple[tuple[str, Any | UndefinedType], ...] + ) -> bool: + """Return True if attributes on entity changed or if update is forced.""" + if self._attr_force_update: + return True + for attribute, last_value in attrs_snapshot: + if getattr(self, attribute, UNDEFINED) != last_value: + return True + return False + + @callback + def _message_callback( + self, + msg_callback: MessageCallbackType, + attributes: set[str] | None, + msg: ReceiveMessage, + ) -> None: + """Process the message callback.""" + if attributes is not None: + attrs_snapshot: tuple[tuple[str, Any | UndefinedType], ...] = tuple( + (attribute, getattr(self, attribute, UNDEFINED)) + for attribute in attributes + ) + mqtt_data = self.hass.data[DATA_MQTT] + messages = mqtt_data.debug_info_entities[self.entity_id]["subscriptions"][ + msg.subscribed_topic + ]["messages"] + if msg not in messages: + messages.append(msg) + + try: + msg_callback(msg) + except MqttValueTemplateException as exc: + _LOGGER.warning(exc) + return + + if attributes is not None and self._attrs_have_changed(attrs_snapshot): + mqtt_data.state_write_requests.write_state_request(self) + + def add_subscription( + self, + state_topic_config_key: str, + msg_callback: Callable[[ReceiveMessage], None], + tracked_attributes: set[str] | None, + disable_encoding: bool = False, + ) -> bool: + """Add a subscription.""" + qos: int = self._config[CONF_QOS] + encoding: str | None = None + if not disable_encoding: + encoding = self._config[CONF_ENCODING] or None + if ( + state_topic_config_key in self._config + and self._config[state_topic_config_key] is not None + ): + self._subscriptions[state_topic_config_key] = { + "topic": self._config[state_topic_config_key], + "msg_callback": partial( + self._message_callback, msg_callback, tracked_attributes + ), + "entity_id": self.entity_id, + "qos": qos, + "encoding": encoding, + "job_type": HassJobType.Callback, + } + return True + return False + def update_device( hass: HomeAssistant, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 17640c3e733..f26ed196663 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -5,7 +5,7 @@ from __future__ import annotations from ast import literal_eval import asyncio from collections import deque -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum import logging @@ -20,6 +20,7 @@ from homeassistant.helpers import template from homeassistant.helpers.entity import Entity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType +from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: from paho.mqtt.client import MQTTMessage @@ -44,7 +45,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_THIS = "this" -PublishPayloadType = str | bytes | int | float | None +type PublishPayloadType = str | bytes | int | float | None @dataclass @@ -57,7 +58,10 @@ class PublishMessage: retain: bool -@dataclass(slots=True, frozen=True) +# eq=False so we use the id() of the object for comparison +# since client will only generate one instance of this object +# per messages/subscribed_topic. +@dataclass(slots=True, frozen=True, eq=False) class ReceiveMessage: """MQTT Message received.""" @@ -69,8 +73,7 @@ class ReceiveMessage: timestamp: float -AsyncMessageCallbackType = Callable[[ReceiveMessage], Coroutine[Any, Any, None]] -MessageCallbackType = Callable[[ReceiveMessage], None] +type MessageCallbackType = Callable[[ReceiveMessage], None] class SubscriptionDebugInfo(TypedDict): @@ -372,14 +375,14 @@ class EntityTopicState: def process_write_state_requests(self, msg: MQTTMessage) -> None: """Process the write state requests.""" while self.subscribe_calls: - _, entity = self.subscribe_calls.popitem() + entity_id, entity = self.subscribe_calls.popitem() try: entity.async_write_ha_state() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( - "Exception raised when updating state of %s, topic: " + "Exception raised while updating state of %s, topic: " "'%s' with payload: %s", - entity.entity_id, + entity_id, msg.topic, msg.payload, ) @@ -419,3 +422,7 @@ class MqttData: state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) + + +DATA_MQTT: HassKey[MqttData] = HassKey("mqtt") +DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available") diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 07ab0050b45..581660b6ecf 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -8,25 +8,16 @@ from homeassistant.components import notify from homeassistant.components.notify import NotifyEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +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 .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, -) -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entity_entry_helper, -) +from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic DEFAULT_NAME = "MQTT notify" @@ -49,7 +40,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT notify through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttNotify, @@ -77,6 +68,7 @@ class MqttNotify(MqttEntity, NotifyEntity): config.get(CONF_COMMAND_TEMPLATE), entity=self ).async_render + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -86,10 +78,4 @@ class MqttNotify(MqttEntity, NotifyEntity): async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message.""" payload = self._command_template(message) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 88730d6e7a2..50a4f398c7d 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -35,19 +35,10 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_PAYLOAD_RESET, - CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -55,6 +46,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -118,7 +110,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT number through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttNumber, @@ -165,64 +157,52 @@ class MqttNumber(MqttEntity, RestoreNumber): self._attr_native_step = config[CONF_STEP] self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + num_value: int | float | None + payload = str(self._value_template(msg.payload)) + if not payload.strip(): + _LOGGER.debug("Ignoring empty state update from '%s'", msg.topic) + return + try: + if payload == self._config[CONF_PAYLOAD_RESET]: + num_value = None + elif payload.isnumeric(): + num_value = int(payload) + else: + num_value = float(payload) + except ValueError: + _LOGGER.warning("Payload '%s' is not a Number", msg.payload) + return + + if num_value is not None and ( + num_value < self.min_value or num_value > self.max_value + ): + _LOGGER.error( + "Invalid value for %s: %s (range %s - %s)", + self.entity_id, + num_value, + self.min_value, + self.max_value, + ) + return + + self._attr_native_value = num_value + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_native_value"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - num_value: int | float | None - payload = str(self._value_template(msg.payload)) - if not payload.strip(): - _LOGGER.debug("Ignoring empty state update from '%s'", msg.topic) - return - try: - if payload == self._config[CONF_PAYLOAD_RESET]: - num_value = None - elif payload.isnumeric(): - num_value = int(payload) - else: - num_value = float(payload) - except ValueError: - _LOGGER.warning("Payload '%s' is not a Number", msg.payload) - return - - if num_value is not None and ( - num_value < self.min_value or num_value > self.max_value - ): - _LOGGER.error( - "Invalid value for %s: %s (range %s - %s)", - self.entity_id, - num_value, - self.min_value, - self.max_value, - ) - return - - self._attr_native_value = num_value - - if self._config.get(CONF_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_STATE_TOPIC, self._message_received, {"_attr_native_value"} + ): # Force into optimistic mode. self._attr_assumed_state = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._attr_assumed_state and ( last_number_data := await self.async_get_last_number_data() @@ -240,11 +220,4 @@ class MqttNumber(MqttEntity, RestoreNumber): if self._attr_assumed_state: self._attr_native_value = current_number self.async_write_ha_state() - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index a5ba2700e80..994a77d3abb 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -10,18 +10,15 @@ from homeassistant.components import scene from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON -from homeassistant.core import HomeAssistant +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 .config import MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entity_entry_helper, -) +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN +from .mixins import MqttEntity, async_setup_entity_entry_helper +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic DEFAULT_NAME = "MQTT Scene" @@ -47,7 +44,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT scene through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttScene, @@ -75,6 +72,7 @@ class MqttScene( def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -86,10 +84,6 @@ class MqttScene( This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_ON], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON] ) diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py new file mode 100644 index 00000000000..bbc0194a1a5 --- /dev/null +++ b/homeassistant/components/mqtt/schemas.py @@ -0,0 +1,150 @@ +"""Shared schemas for MQTT discovery and YAML config items.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + CONF_DEVICE, + CONF_ENTITY_CATEGORY, + CONF_ICON, + CONF_MODEL, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_VALUE_TEMPLATE, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA +from homeassistant.helpers.typing import ConfigType + +from .const import ( + AVAILABILITY_LATEST, + AVAILABILITY_MODES, + CONF_AVAILABILITY, + CONF_AVAILABILITY_MODE, + CONF_AVAILABILITY_TEMPLATE, + CONF_AVAILABILITY_TOPIC, + CONF_CONFIGURATION_URL, + CONF_CONNECTIONS, + CONF_DEPRECATED_VIA_HUB, + CONF_ENABLED_BY_DEFAULT, + CONF_HW_VERSION, + CONF_IDENTIFIERS, + CONF_JSON_ATTRS_TEMPLATE, + CONF_JSON_ATTRS_TOPIC, + CONF_MANUFACTURER, + CONF_OBJECT_ID, + CONF_ORIGIN, + CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, + CONF_SERIAL_NUMBER, + CONF_SUGGESTED_AREA, + CONF_SUPPORT_URL, + CONF_SW_VERSION, + CONF_TOPIC, + CONF_VIA_DEVICE, + DEFAULT_PAYLOAD_AVAILABLE, + DEFAULT_PAYLOAD_NOT_AVAILABLE, +) +from .util import valid_subscribe_topic + +MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema( + { + vol.Exclusive(CONF_AVAILABILITY_TOPIC, "availability"): valid_subscribe_topic, + vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, + vol.Optional( + CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE + ): cv.string, + vol.Optional( + CONF_PAYLOAD_NOT_AVAILABLE, default=DEFAULT_PAYLOAD_NOT_AVAILABLE + ): cv.string, + } +) + +MQTT_AVAILABILITY_LIST_SCHEMA = vol.Schema( + { + vol.Optional(CONF_AVAILABILITY_MODE, default=AVAILABILITY_LATEST): vol.All( + cv.string, vol.In(AVAILABILITY_MODES) + ), + vol.Exclusive(CONF_AVAILABILITY, "availability"): vol.All( + cv.ensure_list, + [ + { + vol.Required(CONF_TOPIC): valid_subscribe_topic, + vol.Optional( + CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE + ): cv.string, + vol.Optional( + CONF_PAYLOAD_NOT_AVAILABLE, + default=DEFAULT_PAYLOAD_NOT_AVAILABLE, + ): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } + ], + ), + } +) + +MQTT_AVAILABILITY_SCHEMA = MQTT_AVAILABILITY_SINGLE_SCHEMA.extend( + MQTT_AVAILABILITY_LIST_SCHEMA.schema +) + + +def validate_device_has_at_least_one_identifier(value: ConfigType) -> ConfigType: + """Validate that a device info entry has at least one identifying value.""" + if value.get(CONF_IDENTIFIERS) or value.get(CONF_CONNECTIONS): + return value + raise vol.Invalid( + "Device must have at least one identifying value in " + "'identifiers' and/or 'connections'" + ) + + +MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( + cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE), + vol.Schema( + { + vol.Optional(CONF_IDENTIFIERS, default=list): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_CONNECTIONS, default=list): vol.All( + cv.ensure_list, [vol.All(vol.Length(2), [cv.string])] + ), + vol.Optional(CONF_MANUFACTURER): cv.string, + vol.Optional(CONF_MODEL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HW_VERSION): cv.string, + vol.Optional(CONF_SERIAL_NUMBER): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_VIA_DEVICE): cv.string, + vol.Optional(CONF_SUGGESTED_AREA): cv.string, + vol.Optional(CONF_CONFIGURATION_URL): cv.configuration_url, + } + ), + validate_device_has_at_least_one_identifier, +) + + +MQTT_ORIGIN_INFO_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_SUPPORT_URL): cv.configuration_url, + } + ), +) + +MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA, + vol.Optional(CONF_ENABLED_BY_DEFAULT, default=True): cv.boolean, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template, + vol.Optional(CONF_OBJECT_ID): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index af09f5c0202..ea0a0886082 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -19,21 +19,8 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - CONF_STATE_TOPIC, -) -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 .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -41,6 +28,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -73,7 +61,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT select through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttSelect, @@ -113,49 +101,44 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = str(self._value_template(msg.payload)) + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + if payload.lower() == "none": + self._attr_current_option = None + return + + if payload not in self.options: + _LOGGER.error( + "Invalid option for %s: '%s' (valid options: %s)", + self.entity_id, + payload, + self.options, + ) + return + self._attr_current_option = payload + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_option"}) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - payload = str(self._value_template(msg.payload)) - if payload.lower() == "none": - self._attr_current_option = None - return - - if payload not in self.options: - _LOGGER.error( - "Invalid option for %s: '%s' (valid options: %s)", - self.entity_id, - payload, - self.options, - ) - return - self._attr_current_option = payload - - if self._config.get(CONF_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_STATE_TOPIC, self._message_received, {"_attr_current_option"} + ): # Force into optimistic mode. self._attr_assumed_state = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - }, - ) + return async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._attr_assumed_state and ( last_state := await self.async_get_last_state() @@ -168,11 +151,4 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 5457011d122..043bc9a5c0e 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Any import voluptuous as vol @@ -39,21 +38,16 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, PAYLOAD_NONE -from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttAvailability, - MqttEntity, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .const import CONF_STATE_TOPIC, PAYLOAD_NONE +from .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import ( MqttValueTemplate, PayloadSentinel, ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA +from .util import check_state_too_long _LOGGER = logging.getLogger(__name__) @@ -116,7 +110,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT sensor through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttSensor, @@ -137,8 +131,12 @@ class MqttSensor(MqttEntity, RestoreSensor): _expiration_trigger: CALLBACK_TYPE | None = None _expire_after: int | None _expired: bool | None - _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] - _last_reset_template: Callable[[ReceivePayloadType], ReceivePayloadType] + _template: ( + Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] | None + ) = None + _last_reset_template: Callable[[ReceivePayloadType], ReceivePayloadType] | None = ( + None + ) async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" @@ -178,8 +176,7 @@ class MqttSensor(MqttEntity, RestoreSensor): ) async def async_will_remove_from_hass(self) -> None: - """Remove exprire triggers.""" - # Clean up expire triggers + """Remove expire triggers.""" if self._expiration_trigger: _LOGGER.debug("Clean up expire after trigger for %s", self.entity_id) self._expiration_trigger() @@ -208,103 +205,107 @@ class MqttSensor(MqttEntity, RestoreSensor): else: self._expired = None - self._template = MqttValueTemplate( - self._config.get(CONF_VALUE_TEMPLATE), entity=self - ).async_render_with_possible_json_value - self._last_reset_template = MqttValueTemplate( - self._config.get(CONF_LAST_RESET_VALUE_TEMPLATE), entity=self - ).async_render_with_possible_json_value + if value_template := config.get(CONF_VALUE_TEMPLATE): + self._template = MqttValueTemplate( + value_template, entity=self + ).async_render_with_possible_json_value + if last_reset_template := config.get(CONF_LAST_RESET_VALUE_TEMPLATE): + self._last_reset_template = MqttValueTemplate( + last_reset_template, entity=self + ).async_render_with_possible_json_value + @callback + def _update_state(self, msg: ReceiveMessage) -> None: + # auto-expire enabled? + if self._expire_after is not None and self._expire_after > 0: + # When self._expire_after is set, and we receive a message, assume + # device is not expired since it has to be to receive the message + self._expired = False + + # Reset old trigger + if self._expiration_trigger: + self._expiration_trigger() + + # Set new trigger + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired + ) + + if template := self._template: + payload = template(msg.payload, PayloadSentinel.DEFAULT) + else: + payload = msg.payload + if payload is PayloadSentinel.DEFAULT: + return + if not isinstance(payload, str): + _LOGGER.warning( + "Invalid undecoded state message '%s' received from '%s'", + payload, + msg.topic, + ) + return + if self._numeric_state_expected: + if payload == "": + _LOGGER.debug("Ignore empty state from '%s'", msg.topic) + elif payload == PAYLOAD_NONE: + self._attr_native_value = None + else: + self._attr_native_value = payload + return + if self.device_class in { + None, + SensorDeviceClass.ENUM, + } and not check_state_too_long(_LOGGER, payload, self.entity_id, msg): + self._attr_native_value = payload + return + try: + if (payload_datetime := dt_util.parse_datetime(payload)) is None: + raise ValueError + except ValueError: + _LOGGER.warning("Invalid state message '%s' from '%s'", payload, msg.topic) + self._attr_native_value = None + return + if self.device_class == SensorDeviceClass.DATE: + self._attr_native_value = payload_datetime.date() + return + self._attr_native_value = payload_datetime + + @callback + def _update_last_reset(self, msg: ReceiveMessage) -> None: + template = self._last_reset_template + payload = msg.payload if template is None else template(msg.payload) + if not payload: + _LOGGER.debug("Ignoring empty last_reset message from '%s'", msg.topic) + return + try: + last_reset = dt_util.parse_datetime(str(payload)) + if last_reset is None: + raise ValueError + self._attr_last_reset = last_reset + except ValueError: + _LOGGER.warning( + "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic + ) + + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + self._update_state(msg) + if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: + self._update_last_reset(msg) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - - def _update_state(msg: ReceiveMessage) -> None: - # auto-expire enabled? - if self._expire_after is not None and self._expire_after > 0: - # When self._expire_after is set, and we receive a message, assume - # device is not expired since it has to be to receive the message - self._expired = False - - # Reset old trigger - if self._expiration_trigger: - self._expiration_trigger() - - # Set new trigger - self._expiration_trigger = async_call_later( - self.hass, self._expire_after, self._value_is_expired - ) - - payload = self._template(msg.payload, PayloadSentinel.DEFAULT) - if payload is PayloadSentinel.DEFAULT: - return - new_value = str(payload) - if self._numeric_state_expected: - if new_value == "": - _LOGGER.debug("Ignore empty state from '%s'", msg.topic) - elif new_value == PAYLOAD_NONE: - self._attr_native_value = None - else: - self._attr_native_value = new_value - return - if self.device_class in {None, SensorDeviceClass.ENUM}: - self._attr_native_value = new_value - return - try: - if (payload_datetime := dt_util.parse_datetime(new_value)) is None: - raise ValueError - except ValueError: - _LOGGER.warning( - "Invalid state message '%s' from '%s'", msg.payload, msg.topic - ) - self._attr_native_value = None - return - if self.device_class == SensorDeviceClass.DATE: - self._attr_native_value = payload_datetime.date() - return - self._attr_native_value = payload_datetime - - def _update_last_reset(msg: ReceiveMessage) -> None: - payload = self._last_reset_template(msg.payload) - - if not payload: - _LOGGER.debug("Ignoring empty last_reset message from '%s'", msg.topic) - return - try: - last_reset = dt_util.parse_datetime(str(payload)) - if last_reset is None: - raise ValueError - self._attr_last_reset = last_reset - except ValueError: - _LOGGER.warning( - "Invalid last_reset message '%s' from '%s'", msg.payload, msg.topic - ) - - @callback - @write_state_on_attr_change( - self, {"_attr_native_value", "_attr_last_reset", "_expired"} - ) - @log_messages(self.hass, self.entity_id) - def message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT messages.""" - _update_state(msg) - if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: - _update_last_reset(msg) - - topics["state_topic"] = { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_native_value", "_attr_last_reset", "_expired"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @callback def _value_is_expired(self, *_: datetime) -> None: @@ -317,6 +318,6 @@ class MqttSensor(MqttEntity, RestoreSensor): def available(self) -> bool: """Return true if the device is available and value has not expired.""" # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - return MqttAvailability.available.fget(self) and ( # type: ignore[attr-defined] + return MqttAvailabilityMixin.available.fget(self) and ( # type: ignore[attr-defined] self._expire_after is None or not self._expired ) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index e360416db7c..49645f7b1b4 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -40,21 +40,12 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -62,6 +53,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA DEFAULT_NAME = "MQTT Siren" DEFAULT_PAYLOAD_ON = "ON" @@ -122,7 +114,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT siren through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttSiren, @@ -205,92 +197,82 @@ class MqttSiren(MqttEntity, SirenEntity): entity=self, ).async_render_with_possible_json_value - def _prepare_subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on", "_extra_attributes"}) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT state messages.""" - payload = self._value_template(msg.payload) - if not payload or payload == PAYLOAD_EMPTY_JSON: + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload = self._value_template(msg.payload) + if not payload or payload == PAYLOAD_EMPTY_JSON: + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + json_payload: dict[str, Any] = {} + if payload in [self._state_on, self._state_off, PAYLOAD_NONE]: + json_payload = {STATE: payload} + else: + try: + json_payload = json_loads_object(payload) _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, + ( + "JSON payload detected after processing payload '%s' on" + " topic %s" + ), + json_payload, + msg.topic, + ) + except JSON_DECODE_EXCEPTIONS: + _LOGGER.warning( + ( + "No valid (JSON) payload detected after processing payload" + " '%s' on topic %s" + ), + json_payload, msg.topic, ) return - json_payload: dict[str, Any] = {} - if payload in [self._state_on, self._state_off, PAYLOAD_NONE]: - json_payload = {STATE: payload} - else: - try: - json_payload = json_loads_object(payload) - _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), - json_payload, - msg.topic, - ) - except JSON_DECODE_EXCEPTIONS: - _LOGGER.warning( - ( - "No valid (JSON) payload detected after processing payload" - " '%s' on topic %s" - ), - json_payload, - msg.topic, - ) - return - if STATE in json_payload: - if json_payload[STATE] == self._state_on: - self._attr_is_on = True - if json_payload[STATE] == self._state_off: - self._attr_is_on = False - if json_payload[STATE] == PAYLOAD_NONE: - self._attr_is_on = None - del json_payload[STATE] + if STATE in json_payload: + if json_payload[STATE] == self._state_on: + self._attr_is_on = True + if json_payload[STATE] == self._state_off: + self._attr_is_on = False + if json_payload[STATE] == PAYLOAD_NONE: + self._attr_is_on = None + del json_payload[STATE] - if json_payload: - # process attributes - try: - params: SirenTurnOnServiceParameters - params = vol.All(TURN_ON_SCHEMA)(json_payload) - except vol.MultipleInvalid as invalid_siren_parameters: - _LOGGER.warning( - "Unable to update siren state attributes from payload '%s': %s", - json_payload, - invalid_siren_parameters, - ) - return - # To be able to track changes to self._extra_attributes we assign - # a fresh copy to make the original tracked reference immutable. - self._extra_attributes = dict(self._extra_attributes) - self._update(process_turn_on_params(self, params)) + if json_payload: + # process attributes + try: + params: SirenTurnOnServiceParameters + params = vol.All(TURN_ON_SCHEMA)(json_payload) + except vol.MultipleInvalid as invalid_siren_parameters: + _LOGGER.warning( + "Unable to update siren state attributes from payload '%s': %s", + json_payload, + invalid_siren_parameters, + ) + return + # To be able to track changes to self._extra_attributes we assign + # a fresh copy to make the original tracked reference immutable. + self._extra_attributes = dict(self._extra_attributes) + self._update(process_turn_on_params(self, params)) - if self._config.get(CONF_STATE_TOPIC) is None: + @callback + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + if not self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_is_on", "_extra_attributes"}, + ): # Force into optimistic mode. self._optimistic = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_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, - } - }, - ) + return async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -320,13 +302,7 @@ class MqttSiren(MqttEntity, SirenEntity): else: payload = json_dumps(template_variables) if payload and str(payload) != PAYLOAD_NONE: - await self.async_publish( - self._config[topic], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[topic], payload) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on. diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fc5f0bc4970..6034197aec7 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -71,7 +71,7 @@ }, "reauth_confirm": { "title": "Re-authentication required with the MQTT broker", - "description": "The MQTT broker reported an authentication error. Please confirm the brokers correct usernname and password.", + "description": "The MQTT broker reported an authentication error. Please confirm the brokers correct username and password.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 14f2999fa9c..3f3f67970f3 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -2,30 +2,32 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Any -import attr +from homeassistant.core import HassJobType, HomeAssistant, callback -from homeassistant.core import HomeAssistant - -from .. import mqtt from . import debug_info +from .client import async_subscribe_internal from .const import DEFAULT_QOS from .models import MessageCallbackType -@attr.s(slots=True) +@dataclass(slots=True, kw_only=True) class EntitySubscription: """Class to hold data about an active entity topic subscription.""" - hass: HomeAssistant = attr.ib() - topic: str | None = attr.ib() - message_callback: MessageCallbackType = attr.ib() - subscribe_task: Coroutine[Any, Any, Callable[[], None]] | None = attr.ib() - unsubscribe_callback: Callable[[], None] | None = attr.ib() - qos: int = attr.ib(default=0) - encoding: str = attr.ib(default="utf-8") + hass: HomeAssistant + topic: str | None + message_callback: MessageCallbackType + should_subscribe: bool | None + unsubscribe_callback: Callable[[], None] | None + qos: int = 0 + encoding: str = "utf-8" + entity_id: str | None + job_type: HassJobType | None def resubscribe_if_necessary( self, hass: HomeAssistant, other: EntitySubscription | None @@ -40,26 +42,30 @@ class EntitySubscription: if other is not None and other.unsubscribe_callback is not None: other.unsubscribe_callback() # Clear debug data if it exists - debug_info.remove_subscription( - self.hass, other.message_callback, str(other.topic) - ) + debug_info.remove_subscription(self.hass, str(other.topic), other.entity_id) if self.topic is None: # We were asked to remove the subscription or not to create it return # Prepare debug data - debug_info.add_subscription(self.hass, self.message_callback, self.topic) + debug_info.add_subscription(self.hass, self.topic, self.entity_id) - self.subscribe_task = mqtt.async_subscribe( - hass, self.topic, self.message_callback, self.qos, self.encoding - ) + self.should_subscribe = True - async def subscribe(self) -> None: + @callback + def subscribe(self) -> None: """Subscribe to a topic.""" - if not self.subscribe_task: + if not self.should_subscribe or not self.topic: return - self.unsubscribe_callback = await self.subscribe_task + self.unsubscribe_callback = async_subscribe_internal( + self.hass, + self.topic, + self.message_callback, + self.qos, + self.encoding, + self.job_type, + ) def _should_resubscribe(self, other: EntitySubscription | None) -> bool: """Check if we should re-subscribe to the topic using the old state.""" @@ -77,10 +83,11 @@ class EntitySubscription: ) +@callback def async_prepare_subscribe_topics( hass: HomeAssistant, new_state: dict[str, EntitySubscription] | None, - topics: dict[str, Any], + topics: dict[str, dict[str, Any]], ) -> dict[str, EntitySubscription]: """Prepare (re)subscribe to a set of MQTT topics. @@ -99,13 +106,15 @@ def async_prepare_subscribe_topics( for key, value in topics.items(): # Extract the new requested subscription requested = EntitySubscription( - topic=value.get("topic", None), - message_callback=value.get("msg_callback", None), + topic=value.get("topic"), + message_callback=value["msg_callback"], unsubscribe_callback=None, qos=value.get("qos", DEFAULT_QOS), encoding=value.get("encoding", "utf-8"), hass=hass, - subscribe_task=None, + should_subscribe=None, + entity_id=value.get("entity_id"), + job_type=value.get("job_type"), ) # Get the current subscription state current = current_subscriptions.pop(key, None) @@ -118,7 +127,9 @@ def async_prepare_subscribe_topics( remaining.unsubscribe_callback() # Clear debug data if it exists debug_info.remove_subscription( - hass, remaining.message_callback, str(remaining.topic) + hass, + str(remaining.topic), + remaining.entity_id, ) return new_state @@ -129,12 +140,29 @@ async def async_subscribe_topics( sub_state: dict[str, EntitySubscription], ) -> None: """(Re)Subscribe to a set of MQTT topics.""" + async_subscribe_topics_internal(hass, sub_state) + + +@callback +def async_subscribe_topics_internal( + hass: HomeAssistant, + sub_state: dict[str, EntitySubscription], +) -> None: + """(Re)Subscribe to a set of MQTT topics. + + This function is internal to the MQTT integration and should not be called + from outside the integration. + """ for sub in sub_state.values(): - await sub.subscribe() + sub.subscribe() -def async_unsubscribe_topics( - hass: HomeAssistant, sub_state: dict[str, EntitySubscription] | None -) -> dict[str, EntitySubscription]: - """Unsubscribe from all MQTT topics managed by async_subscribe_topics.""" - return async_prepare_subscribe_topics(hass, sub_state, {}) +if TYPE_CHECKING: + + def async_unsubscribe_topics( + hass: HomeAssistant, sub_state: dict[str, EntitySubscription] | None + ) -> dict[str, EntitySubscription]: + """Unsubscribe from all MQTT topics managed by async_subscribe_topics.""" + + +async_unsubscribe_topics = partial(async_prepare_subscribe_topics, topics={}) diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 8be42a9ed19..0ba4c003078 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -28,22 +28,10 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import ( - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - CONF_STATE_TOPIC, - PAYLOAD_NONE, -) -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 .const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA DEFAULT_NAME = "MQTT Switch" DEFAULT_PAYLOAD_ON = "ON" @@ -72,7 +60,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT switch through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttSwitch, @@ -90,8 +78,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): _entity_id_format = switch.ENTITY_ID_FORMAT _optimistic: bool - _state_on: str - _state_off: str + _is_on_map: dict[str | bytes, bool | None] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] @staticmethod @@ -102,58 +89,40 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" self._attr_device_class = config.get(CONF_DEVICE_CLASS) - state_on: str | None = config.get(CONF_STATE_ON) - self._state_on = state_on if state_on else config[CONF_PAYLOAD_ON] - state_off: str | None = config.get(CONF_STATE_OFF) - self._state_off = state_off if state_off else config[CONF_PAYLOAD_OFF] - + self._is_on_map = { + state_on if state_on else config[CONF_PAYLOAD_ON]: True, + state_off if state_off else config[CONF_PAYLOAD_OFF]: False, + PAYLOAD_NONE: None, + } self._optimistic = ( config[CONF_OPTIMISTIC] or config.get(CONF_STATE_TOPIC) is None ) self._attr_assumed_state = bool(self._optimistic) - self._value_template = MqttValueTemplate( self._config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + if (payload := self._value_template(msg.payload)) in self._is_on_map: + self._attr_is_on = self._is_on_map[payload] + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle new MQTT state messages.""" - payload = self._value_template(msg.payload) - if payload == self._state_on: - self._attr_is_on = True - elif payload == self._state_off: - self._attr_is_on = False - elif payload == PAYLOAD_NONE: - self._attr_is_on = None - - if self._config.get(CONF_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_STATE_TOPIC, self._state_message_received, {"_attr_is_on"} + ): # Force into optimistic mode. self._optimistic = True - else: - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_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, - } - }, - ) + return async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) if self._optimistic and (last_state := await self.async_get_last_state()): self._attr_is_on = last_state.state == STATE_ON @@ -163,12 +132,8 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_ON], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON] ) if self._optimistic: # Optimistically assume that switch has changed state. @@ -180,12 +145,8 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_OFF], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_OFF] ) if self._optimistic: # Optimistically assume that switch has changed state. diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 42f6915fc91..22263a07499 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE -from homeassistant.core import HomeAssistant +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -20,21 +20,22 @@ from .config import MQTT_BASE_SCHEMA from .const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_TOPIC from .discovery import MQTTDiscoveryPayload from .mixins import ( - MQTT_ENTITY_DEVICE_INFO_SCHEMA, - MqttDiscoveryDeviceUpdate, + MqttDiscoveryDeviceUpdateMixin, async_handle_schema_error, async_setup_non_entity_entry_helper, send_discovery_done, update_device, ) from .models import ( + DATA_MQTT, MqttValueTemplate, MqttValueTemplateException, ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_DEVICE_INFO_SCHEMA from .subscription import EntitySubscription -from .util import get_mqtt_data, valid_subscribe_topic +from .util import valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -56,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N """Set up MQTT tag scanner dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_tag, hass, config_entry=config_entry) - await async_setup_non_entity_entry_helper(hass, TAG, setup, DISCOVERY_SCHEMA) + async_setup_non_entity_entry_helper(hass, TAG, setup, DISCOVERY_SCHEMA) async def _async_setup_tag( @@ -70,7 +71,7 @@ async def _async_setup_tag( discovery_id = discovery_hash[1] device_id = update_device(hass, config_entry, config) - if device_id is not None and device_id not in (tags := get_mqtt_data(hass).tags): + if device_id is not None and device_id not in (tags := hass.data[DATA_MQTT].tags): tags[device_id] = {} tag_scanner = MQTTTagScanner( @@ -91,12 +92,12 @@ async def _async_setup_tag( def async_has_tags(hass: HomeAssistant, device_id: str) -> bool: """Device has tag scanners.""" - if device_id not in (tags := get_mqtt_data(hass).tags): + if device_id not in (tags := hass.data[DATA_MQTT].tags): return False return tags[device_id] != {} -class MQTTTagScanner(MqttDiscoveryDeviceUpdate): +class MQTTTagScanner(MqttDiscoveryDeviceUpdateMixin): """MQTT Tag scanner.""" _value_template: Callable[[ReceivePayloadType, str], ReceivePayloadType] @@ -121,7 +122,7 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): hass=self.hass, ).async_render_with_possible_json_value - MqttDiscoveryDeviceUpdate.__init__( + MqttDiscoveryDeviceUpdateMixin.__init__( self, hass, discovery_data, device_id, config_entry, LOG_NAME ) @@ -141,32 +142,36 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): update_device(self.hass, self._config_entry, config) await self.subscribe_topics() + @callback + def _async_tag_scanned(self, msg: ReceiveMessage) -> None: + """Handle new tag scanned.""" + try: + tag_id = str(self._value_template(msg.payload, "")).strip() + except MqttValueTemplateException as exc: + _LOGGER.warning(exc) + return + if not tag_id: # No output from template, ignore + return + + self.hass.async_create_task( + tag.async_scan_tag(self.hass, tag_id, self.device_id) + ) + async def subscribe_topics(self) -> None: """Subscribe to MQTT topics.""" - - async def tag_scanned(msg: ReceiveMessage) -> None: - try: - tag_id = str(self._value_template(msg.payload, "")).strip() - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - if not tag_id: # No output from template, ignore - return - - await tag.async_scan_tag(self.hass, tag_id, self.device_id) - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, { "state_topic": { "topic": self._config[CONF_TOPIC], - "msg_callback": tag_scanned, + "msg_callback": self._async_tag_scanned, "qos": self._config[CONF_QOS], + "job_type": HassJobType.Callback, } }, ) - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_tear_down(self) -> None: """Cleanup tag scanner.""" @@ -176,4 +181,4 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): self.hass, self._sub_state ) if self.device_id: - get_mqtt_data(self.hass).tags[self.device_id].pop(discovery_id) + del self.hass.data[DATA_MQTT].tags[self.device_id][discovery_id] diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index e5786dbe94d..73adaa2cb0c 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -26,29 +26,17 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - CONF_STATE_TOPIC, -) -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 .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC +from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( - MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PublishPayloadType, ReceiveMessage, ReceivePayloadType, ) +from .schemas import MQTT_ENTITY_COMMON_SCHEMA +from .util import check_state_too_long _LOGGER = logging.getLogger(__name__) @@ -108,7 +96,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT text through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttTextEntity, @@ -159,50 +147,31 @@ class MqttTextEntity(MqttEntity, TextEntity): self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None self._attr_assumed_state = bool(self._optimistic) + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = str(self._value_template(msg.payload)) + if check_state_too_long(_LOGGER, payload, self.entity_id, msg): + return + self._attr_native_value = payload + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - def add_subscription( - topics: dict[str, Any], topic: str, msg_callback: MessageCallbackType - ) -> None: - if self._config.get(topic) is not None: - topics[topic] = { - "topic": self._config[topic], - "msg_callback": msg_callback, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_native_value"}) - def handle_state_message_received(msg: ReceiveMessage) -> None: - """Handle receiving state message via MQTT.""" - payload = str(self._value_template(msg.payload)) - self._attr_native_value = payload - - add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, + {"_attr_native_value"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_set_value(self, value: str) -> None: """Change the text.""" payload = self._command_template(value) - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index 7aa798a7a3c..91ac404a07a 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -10,7 +10,13 @@ from typing import Any import voluptuous as vol from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM, CONF_VALUE_TEMPLATE -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HassJobType, + HomeAssistant, + callback, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.template import Template from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo @@ -99,6 +105,11 @@ async def async_attach_trigger( "Attaching MQTT trigger for topic: '%s', payload: '%s'", topic, wanted_payload ) - return await mqtt.async_subscribe( - hass, topic, mqtt_automation_listener, encoding=encoding, qos=qos + return mqtt.async_subscribe_internal( + hass, + topic, + mqtt_automation_listener, + encoding=encoding, + qos=qos, + job_type=HassJobType.Callback, ) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 0171e8eee2d..eecd7b967de 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -24,22 +24,10 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import subscription from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA -from .const import ( - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - CONF_STATE_TOPIC, - PAYLOAD_EMPTY_JSON, -) -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 MessageCallbackType, MqttValueTemplate, ReceiveMessage +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON +from .mixins import MqttEntity, async_setup_entity_entry_helper +from .models import MqttValueTemplate, ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -92,7 +80,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT update entity through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttUpdate, @@ -141,25 +129,85 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): ).async_render_with_possible_json_value, } + @callback + def _handle_state_message_received(self, msg: ReceiveMessage) -> None: + """Handle receiving state message via MQTT.""" + payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) + + if not payload or payload == PAYLOAD_EMPTY_JSON: + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + + json_payload: _MqttUpdatePayloadType = {} + try: + rendered_json_payload = json_loads(payload) + if isinstance(rendered_json_payload, dict): + _LOGGER.debug( + ( + "JSON payload detected after processing payload '%s' on" + " topic %s" + ), + rendered_json_payload, + msg.topic, + ) + json_payload = cast(_MqttUpdatePayloadType, rendered_json_payload) + else: + _LOGGER.debug( + ( + "Non-dictionary JSON payload detected after processing" + " payload '%s' on topic %s" + ), + payload, + msg.topic, + ) + json_payload = {"installed_version": str(payload)} + except JSON_DECODE_EXCEPTIONS: + _LOGGER.debug( + ( + "No valid (JSON) payload detected after processing payload '%s'" + " on topic %s" + ), + payload, + msg.topic, + ) + json_payload["installed_version"] = str(payload) + + if "installed_version" in json_payload: + self._attr_installed_version = json_payload["installed_version"] + + if "latest_version" in json_payload: + self._attr_latest_version = json_payload["latest_version"] + + if "title" in json_payload: + self._attr_title = json_payload["title"] + + if "release_summary" in json_payload: + self._attr_release_summary = json_payload["release_summary"] + + if "release_url" in json_payload: + self._attr_release_url = json_payload["release_url"] + + if "entity_picture" in json_payload: + self._entity_picture = json_payload["entity_picture"] + + @callback + def _handle_latest_version_received(self, msg: ReceiveMessage) -> None: + """Handle receiving latest version via MQTT.""" + latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) + + if isinstance(latest_version, str) and latest_version != "": + self._attr_latest_version = latest_version + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - def add_subscription( - topics: dict[str, Any], topic: str, msg_callback: MessageCallbackType - ) -> None: - if self._config.get(topic) is not None: - topics[topic] = { - "topic": self._config[topic], - "msg_callback": msg_callback, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, + self.add_subscription( + CONF_STATE_TOPIC, + self._handle_state_message_received, { "_attr_installed_version", "_attr_latest_version", @@ -169,107 +217,22 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): "_entity_picture", }, ) - def handle_state_message_received(msg: ReceiveMessage) -> None: - """Handle receiving state message via MQTT.""" - payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload) - - if not payload or payload == PAYLOAD_EMPTY_JSON: - _LOGGER.debug( - "Ignoring empty payload '%s' after rendering for topic %s", - payload, - msg.topic, - ) - return - - json_payload: _MqttUpdatePayloadType = {} - try: - rendered_json_payload = json_loads(payload) - if isinstance(rendered_json_payload, dict): - _LOGGER.debug( - ( - "JSON payload detected after processing payload '%s' on" - " topic %s" - ), - rendered_json_payload, - msg.topic, - ) - json_payload = cast(_MqttUpdatePayloadType, rendered_json_payload) - else: - _LOGGER.debug( - ( - "Non-dictionary JSON payload detected after processing" - " payload '%s' on topic %s" - ), - payload, - msg.topic, - ) - json_payload = {"installed_version": str(payload)} - except JSON_DECODE_EXCEPTIONS: - _LOGGER.debug( - ( - "No valid (JSON) payload detected after processing payload '%s'" - " on topic %s" - ), - payload, - msg.topic, - ) - json_payload["installed_version"] = str(payload) - - if "installed_version" in json_payload: - self._attr_installed_version = json_payload["installed_version"] - - if "latest_version" in json_payload: - self._attr_latest_version = json_payload["latest_version"] - - if "title" in json_payload: - self._attr_title = json_payload["title"] - - if "release_summary" in json_payload: - self._attr_release_summary = json_payload["release_summary"] - - if "release_url" in json_payload: - self._attr_release_url = json_payload["release_url"] - - if "entity_picture" in json_payload: - self._entity_picture = json_payload["entity_picture"] - - add_subscription(topics, CONF_STATE_TOPIC, handle_state_message_received) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_latest_version"}) - def handle_latest_version_received(msg: ReceiveMessage) -> None: - """Handle receiving latest version via MQTT.""" - latest_version = self._templates[CONF_LATEST_VERSION_TEMPLATE](msg.payload) - - if isinstance(latest_version, str) and latest_version != "": - self._attr_latest_version = latest_version - - add_subscription( - topics, CONF_LATEST_VERSION_TOPIC, handle_latest_version_received - ) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_LATEST_VERSION_TOPIC, + self._handle_latest_version_received, + {"_attr_latest_version"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Update the current value.""" payload = self._config[CONF_PAYLOAD_INSTALL] - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) @property def supported_features(self) -> UpdateEntityFeature: diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index ab21ab56f1b..256bad71ba6 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +from functools import lru_cache +import logging import os from pathlib import Path import tempfile @@ -11,7 +13,7 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import MAX_LENGTH_STATE_STATE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.typing import ConfigType @@ -25,14 +27,12 @@ from .const import ( CONF_CERTIFICATE, CONF_CLIENT_CERT, CONF_CLIENT_KEY, - DATA_MQTT, - DATA_MQTT_AVAILABLE, DEFAULT_ENCODING, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, ) -from .models import MqttData +from .models import DATA_MQTT, DATA_MQTT_AVAILABLE, ReceiveMessage AVAILABILITY_TIMEOUT = 30.0 @@ -47,10 +47,13 @@ def platforms_from_config(config: list[ConfigType]) -> set[Platform | str]: async def async_forward_entry_setup_and_setup_discovery( - hass: HomeAssistant, config_entry: ConfigEntry, platforms: set[Platform | str] + hass: HomeAssistant, + config_entry: ConfigEntry, + platforms: set[Platform | str], + late: bool = False, ) -> None: """Forward the config entry setup to the platforms and set up discovery.""" - mqtt_data = get_mqtt_data(hass) + mqtt_data = hass.data[DATA_MQTT] platforms_loaded = mqtt_data.platforms_loaded new_platforms: set[Platform | str] = platforms - platforms_loaded tasks: list[asyncio.Task] = [] @@ -84,9 +87,13 @@ async def async_forward_entry_setup_and_setup_discovery( def mqtt_config_entry_enabled(hass: HomeAssistant) -> bool | None: """Return true when the MQTT config entry is enabled.""" - if not bool(hass.config_entries.async_entries(DOMAIN)): - return None - return not bool(hass.config_entries.async_entries(DOMAIN)[0].disabled_by) + # If the mqtt client is connected, skip the expensive config + # entry check as its roughly two orders of magnitude faster. + return ( + DATA_MQTT in hass.data and hass.data[DATA_MQTT].client.connected + ) or hass.config_entries.async_has_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: @@ -110,8 +117,6 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: hass.data[DATA_MQTT_AVAILABLE] = state_reached_future else: state_reached_future = hass.data[DATA_MQTT_AVAILABLE] - if state_reached_future.done(): - return state_reached_future.result() try: async with asyncio.timeout(AVAILABILITY_TIMEOUT): @@ -122,7 +127,16 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: def valid_topic(topic: Any) -> str: - """Validate that this is a valid topic name/filter.""" + """Validate that this is a valid topic name/filter. + + This function is not cached and is not expected to be called + directly outside of this module. It is not marked as protected + only because its tested directly in test_util.py. + + If it gets used outside of valid_subscribe_topic and + valid_publish_topic, it may need an lru_cache decorator or + an lru_cache decorator on the function where its used. + """ validated_topic = cv.string(topic) try: raw_validated_topic = validated_topic.encode("utf-8") @@ -134,30 +148,32 @@ def valid_topic(topic: Any) -> str: raise vol.Invalid( "MQTT topic name/filter must not be longer than 65535 encoded bytes." ) - if "\0" in validated_topic: - raise vol.Invalid("MQTT topic name/filter must not contain null character.") - if any(char <= "\u001f" for char in validated_topic): - raise vol.Invalid("MQTT topic name/filter must not contain control characters.") - if any("\u007f" <= char <= "\u009f" for char in validated_topic): - raise vol.Invalid("MQTT topic name/filter must not contain control characters.") - if any("\ufdd0" <= char <= "\ufdef" for char in validated_topic): - raise vol.Invalid("MQTT topic name/filter must not contain non-characters.") - if any((ord(char) & 0xFFFF) in (0xFFFE, 0xFFFF) for char in validated_topic): - raise vol.Invalid("MQTT topic name/filter must not contain noncharacters.") + + for char in validated_topic: + if char == "\0": + raise vol.Invalid("MQTT topic name/filter must not contain null character.") + if char <= "\u001f" or "\u007f" <= char <= "\u009f": + raise vol.Invalid( + "MQTT topic name/filter must not contain control characters." + ) + if "\ufdd0" <= char <= "\ufdef" or (ord(char) & 0xFFFF) in (0xFFFE, 0xFFFF): + raise vol.Invalid("MQTT topic name/filter must not contain non-characters.") return validated_topic +@lru_cache def valid_subscribe_topic(topic: Any) -> str: """Validate that we can subscribe using this MQTT topic.""" validated_topic = valid_topic(topic) - for i in (i for i, c in enumerate(validated_topic) if c == "+"): - if (i > 0 and validated_topic[i - 1] != "/") or ( - i < len(validated_topic) - 1 and validated_topic[i + 1] != "/" - ): - raise vol.Invalid( - "Single-level wildcard must occupy an entire level of the filter" - ) + if "+" in validated_topic: + for i in (i for i, c in enumerate(validated_topic) if c == "+"): + if (i > 0 and validated_topic[i - 1] != "/") or ( + i < len(validated_topic) - 1 and validated_topic[i + 1] != "/" + ): + raise vol.Invalid( + "Single-level wildcard must occupy an entire level of the filter" + ) index = validated_topic.find("#") if index != -1: @@ -184,6 +200,7 @@ def valid_subscribe_topic_template(value: Any) -> template.Template: return tpl +@lru_cache def valid_publish_topic(topic: Any) -> str: """Validate that we can publish using this MQTT topic.""" validated_topic = valid_topic(topic) @@ -216,12 +233,6 @@ def valid_birth_will(config: ConfigType) -> ConfigType: return config -def get_mqtt_data(hass: HomeAssistant) -> MqttData: - """Return typed MqttData from hass.data[DATA_MQTT].""" - mqtt_data: MqttData = hass.data[DATA_MQTT] - return mqtt_data - - async def async_create_certificate_temp_files( hass: HomeAssistant, config: ConfigType ) -> None: @@ -252,6 +263,28 @@ async def async_create_certificate_temp_files( await hass.async_add_executor_job(_create_temp_dir_and_files) +def check_state_too_long( + logger: logging.Logger, proposed_state: str, entity_id: str, msg: ReceiveMessage +) -> bool: + """Check if the processed state is too long and log warning.""" + if (state_length := len(proposed_state)) > MAX_LENGTH_STATE_STATE: + logger.warning( + "Cannot update state for entity %s after processing " + "payload on topic %s. The requested state (%s) exceeds " + "the maximum allowed length (%s). Fall back to " + "%s, failed state: %s", + entity_id, + msg.topic, + state_length, + MAX_LENGTH_STATE_STATE, + STATE_UNKNOWN, + proposed_state[:8192], + ) + return True + + return False + + def get_file_path(option: str, default: str | None = None) -> str | None: """Get file path of a certificate file.""" temp_dir = Path(tempfile.gettempdir()) / TEMP_DIR_NAME diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 96c0871e27b..eac3556a28b 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -42,21 +42,14 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_RETAIN, CONF_SCHEMA, CONF_STATE_TOPIC, DOMAIN, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic LEGACY = "legacy" @@ -177,6 +170,15 @@ def _fail_legacy_config(discovery: bool) -> Callable[[ConfigType], ConfigType]: ) if discovery: + _LOGGER.warning( + "The `schema` option is deprecated for MQTT %s, but " + "it was used in a discovery payload. Please contact the maintainer " + "of the integration or service that supplies the config, and suggest " + "to remove the option. Got %s at discovery topic %s", + vacuum.DOMAIN, + config, + getattr(config, "discovery_data")["discovery_topic"], + ) return config translation_key = "deprecation_mqtt_schema_vacuum_yaml" @@ -243,7 +245,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT vacuum through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttStateVacuum, @@ -322,53 +324,38 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0) self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0))) + @callback + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle state MQTT message.""" + payload = json_loads_object(msg.payload) + if STATE in payload and ( + (state := payload[STATE]) in POSSIBLE_STATES or state is None + ): + self._attr_state = ( + POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None + ) + del payload[STATE] + self._update_state_attributes(payload) + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change( - self, {"_attr_battery_level", "_attr_fan_speed", "_attr_state"} - ) - def state_message_received(msg: ReceiveMessage) -> None: - """Handle state MQTT message.""" - payload = json_loads_object(msg.payload) - if STATE in payload and ( - (state := payload[STATE]) in POSSIBLE_STATES or state is None - ): - self._attr_state = ( - POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None - ) - del payload[STATE] - self._update_state_attributes(payload) - - if state_topic := self._config.get(CONF_STATE_TOPIC): - topics["state_position_topic"] = { - "topic": 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 + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_battery_level", "_attr_fan_speed", "_attr_state"}, ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: """Publish a command.""" if self._command_topic is None: return - - await self.async_publish( - self._command_topic, - self._payloads[_FEATURE_PAYLOADS[feature]], - qos=self._config[CONF_QOS], - retain=self._config[CONF_RETAIN], - encoding=self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._command_topic, self._payloads[_FEATURE_PAYLOADS[feature]] ) self.async_write_ha_state() @@ -404,13 +391,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): or (fan_speed not in self.fan_speed_list) ): return - await self.async_publish( - self._set_fan_speed_topic, - fan_speed, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._set_fan_speed_topic, fan_speed) async def async_send_command( self, @@ -430,10 +411,4 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): payload = json_dumps(message) else: payload = command - await self.async_publish( - self._send_command_topic, - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._send_command_topic, payload) diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 241d6748280..f3c76462269 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -40,13 +40,11 @@ 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, @@ -59,15 +57,11 @@ from .const import ( DEFAULT_POSITION_CLOSED, DEFAULT_POSITION_OPEN, DEFAULT_RETAIN, + PAYLOAD_NONE, ) -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 .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -146,7 +140,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT valve through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttValve, @@ -220,13 +214,16 @@ class MqttValve(MqttEntity, ValveEntity): self._attr_supported_features = supported_features @callback - def _update_state(self, state: str) -> None: + def _update_state(self, state: str | None) -> 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 + if state is None: + self._attr_is_closed = None + else: + self._attr_is_closed = state == STATE_CLOSED @callback def _process_binary_valve_update( @@ -242,7 +239,9 @@ class MqttValve(MqttEntity, ValveEntity): state = STATE_OPEN elif state_payload == self._config[CONF_STATE_CLOSED]: state = STATE_CLOSED - if state is None: + elif state_payload == PAYLOAD_NONE: + state = None + else: _LOGGER.warning( "Payload received on topic '%s' is not one of " "[open, closed, opening, closing], got: %s", @@ -263,6 +262,9 @@ class MqttValve(MqttEntity, ValveEntity): state = STATE_OPENING elif state_payload == self._config[CONF_STATE_CLOSING]: state = STATE_CLOSING + elif state_payload == PAYLOAD_NONE: + self._attr_current_valve_position = None + return if state is None or position_payload != state_payload: try: percentage_payload = ranged_value_to_percentage( @@ -293,14 +295,51 @@ class MqttValve(MqttEntity, ValveEntity): return self._update_state(state) + @callback + def _state_message_received(self, 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) + + @callback 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, + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, { "_attr_current_valve_position", "_attr_is_closed", @@ -308,61 +347,10 @@ class MqttValve(MqttEntity, ValveEntity): "_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) + subscription.async_subscribe_topics_internal(self.hass, self._sub_state) async def async_open_valve(self) -> None: """Move the valve up. @@ -372,13 +360,7 @@ class MqttValve(MqttEntity, ValveEntity): 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], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that valve has changed state. self._update_state(STATE_OPEN) @@ -392,13 +374,7 @@ class MqttValve(MqttEntity, ValveEntity): 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], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that valve has changed state. self._update_state(STATE_CLOSED) @@ -410,13 +386,7 @@ class MqttValve(MqttEntity, ValveEntity): 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], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) async def async_set_valve_position(self, position: int) -> None: """Move the valve to a specific position.""" @@ -430,13 +400,8 @@ class MqttValve(MqttEntity, ValveEntity): "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], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], rendered_position ) if self._optimistic: self._update_state( diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 09db5fc33e7..ac3c8aacc92 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -63,14 +63,11 @@ from .const import ( CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, + PAYLOAD_NONE, ) -from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - async_setup_entity_entry_helper, - write_state_on_attr_change, -) +from .mixins import async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -170,7 +167,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT water heater device through YAML and through MQTT discovery.""" - await async_setup_entity_entry_helper( + async_setup_entity_entry_helper( hass, config_entry, MqttWaterHeater, @@ -260,39 +257,41 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): self._attr_supported_features = support + @callback + def _handle_current_mode_received(self, msg: ReceiveMessage) -> None: + """Handle receiving operation mode via MQTT.""" + + payload = self.render_template(msg, CONF_MODE_STATE_TEMPLATE) + + if not payload.strip(): # No output from template, ignore + _LOGGER.debug( + "Ignoring empty payload '%s' for current operation " + "after rendering for topic %s", + payload, + msg.topic, + ) + return + + if payload == PAYLOAD_NONE: + self._attr_current_operation = None + elif payload not in self._config[CONF_MODE_LIST]: + _LOGGER.warning("Invalid %s mode: %s", CONF_MODE_LIST, payload) + else: + if TYPE_CHECKING: + assert isinstance(payload, str) + self._attr_current_operation = payload + + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - - @callback - def handle_mode_received( - msg: ReceiveMessage, template_name: str, attr: str, mode_list: str - ) -> None: - """Handle receiving listed mode via MQTT.""" - payload = self.render_template(msg, template_name) - - if payload not in self._config[mode_list]: - _LOGGER.error("Invalid %s mode: %s", mode_list, payload) - else: - setattr(self, attr, payload) - - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_current_operation"}) - def handle_current_mode_received(msg: ReceiveMessage) -> None: - """Handle receiving operation mode via MQTT.""" - handle_mode_received( - msg, - CONF_MODE_STATE_TEMPLATE, - "_attr_current_operation", - CONF_MODE_LIST, - ) - + # add subscriptions for WaterHeaterEntity self.add_subscription( - topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + CONF_MODE_STATE_TOPIC, + self._handle_current_mode_received, + {"_attr_current_operation"}, ) - - self.prepare_subscribe_topics(topics) + # add subscriptions for MqttTemperatureControlEntity + self.prepare_subscribe_topics() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py index 0ffcc11c97e..c16f8879a7b 100644 --- a/homeassistant/components/mullvad/config_flow.py +++ b/homeassistant/components/mullvad/config_flow.py @@ -24,7 +24,7 @@ class MullvadConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(MullvadAPI) except MullvadAPIError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" else: return self.async_create_entry(title="Mullvad VPN", data=user_input) diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index 2399cdc063e..ef03df39968 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -60,7 +60,7 @@ class MuteSyncConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" else: return self.async_create_entry( diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 699190a087c..ed18b890a24 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -110,7 +110,7 @@ def setup_mysensors_platform( device_class: type[MySensorsChildEntity] | Mapping[SensorType, type[MySensorsChildEntity]], device_args: ( - None | tuple + tuple | None ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, ) -> list[MySensorsChildEntity] | None: diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 3885a2d7a0e..a65b46616d3 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -19,7 +19,7 @@ CONF_TOPIC_IN_PREFIX: Final = "topic_in_prefix" CONF_TOPIC_OUT_PREFIX: Final = "topic_out_prefix" CONF_VERSION: Final = "version" CONF_GATEWAY_TYPE: Final = "gateway_type" -ConfGatewayType = Literal["Serial", "TCP", "MQTT"] +type ConfGatewayType = Literal["Serial", "TCP", "MQTT"] CONF_GATEWAY_TYPE_SERIAL: ConfGatewayType = "Serial" CONF_GATEWAY_TYPE_TCP: ConfGatewayType = "TCP" CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT" @@ -55,16 +55,16 @@ class NodeDiscoveryInfo(TypedDict): SERVICE_SEND_IR_CODE: Final = "send_ir_code" -SensorType = str +type SensorType = str # S_DOOR, S_MOTION, S_SMOKE, ... -ValueType = str +type ValueType = str # V_TRIPPED, V_ARMED, V_STATUS, V_PERCENTAGE, ... -GatewayId = str +type GatewayId = str # a unique id generated by config_flow.py and stored in the ConfigEntry as the entry id. -DevId = tuple[GatewayId, int, int, int] +type DevId = tuple[GatewayId, int, int, int] # describes the backend of a hass entity. # Contents are: GatewayId, node_id, child_id, v_type as int # diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 537bf575af0..a6a91c12a81 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -15,12 +15,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONDUCTIVITY, DEGREE, LIGHT_LUX, PERCENTAGE, Platform, UnitOfApparentPower, + UnitOfConductivity, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -191,7 +191,7 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "V_EC": SensorEntityDescription( key="V_EC", - native_unit_of_measurement=CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS, ), "V_VAR": SensorEntityDescription( key="V_VAR", diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index 42bb9007789..a8307cf8c6c 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, device_registry as dr, ) +from homeassistant.helpers.device_registry import DeviceEntry from .api import AsyncConfigEntryAuth from .const import DOMAIN, OAUTH2_SCOPES @@ -29,10 +30,13 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] +type MyUplinkConfigEntry = ConfigEntry[MyUplinkDataCoordinator] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: MyUplinkConfigEntry +) -> bool: """Set up myUplink from a config entry.""" - hass.data.setdefault(DOMAIN, {}) implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -58,7 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b api = MyUplinkAPI(auth) coordinator = MyUplinkDataCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator # Update device registry create_devices(hass, config_entry, coordinator) @@ -70,10 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @callback @@ -96,3 +97,14 @@ def create_devices( sw_version=device.firmwareCurrent, serial_number=device.product_serial_number, ) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: MyUplinkConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove myuplink config entry from a device.""" + + myuplink_data = config_entry.runtime_data + return not device_entry.identifiers.intersection( + (DOMAIN, device_id) for device_id in myuplink_data.data.devices + ) diff --git a/homeassistant/components/myuplink/application_credentials.py b/homeassistant/components/myuplink/application_credentials.py index fe3cd22f037..a083418ec3a 100644 --- a/homeassistant/components/myuplink/application_credentials.py +++ b/homeassistant/components/myuplink/application_credentials.py @@ -3,7 +3,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -12,3 +12,12 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN, ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "more_info_url": f"https://www.home-assistant.io/integrations/{DOMAIN}/", + "create_creds_url": "https://dev.myuplink.com/apps", + "callback_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index 6b7ec66a7b4..1478ed9c8b0 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -1,19 +1,18 @@ """Binary sensors for myUplink.""" -from myuplink import DevicePoint +from myuplink import DeviceConnectionState, DevicePoint from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkDataCoordinator -from .const import DOMAIN -from .entity import MyUplinkEntity +from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .entity import MyUplinkEntity, MyUplinkSystemEntity from .helpers import find_matching_platform CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = { @@ -25,6 +24,17 @@ CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] }, } +CONNECTED_BINARY_SENSOR_DESCRIPTION = BinarySensorEntityDescription( + key="connected_state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) + +ALARM_BINARY_SENSOR_DESCRIPTION = BinarySensorEntityDescription( + key="has_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="alarm", +) + def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription | None: """Get description for a device point. @@ -39,14 +49,14 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MyUplinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up myUplink binary_sensor.""" entities: list[BinarySensorEntity] = [] - coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data - # Setup device point sensors + # Setup device point bound sensors for device_id, point_data in coordinator.data.points.items(): for point_id, device_point in point_data.items(): if find_matching_platform(device_point) == Platform.BINARY_SENSOR: @@ -61,11 +71,37 @@ async def async_setup_entry( unique_id_suffix=point_id, ) ) + + # Setup device bound sensors + entities.extend( + MyUplinkDeviceBinarySensor( + coordinator=coordinator, + device_id=device.id, + entity_description=CONNECTED_BINARY_SENSOR_DESCRIPTION, + unique_id_suffix="connection_state", + ) + for system in coordinator.data.systems + for device in system.devices + ) + + # Setup system bound sensors + for system in coordinator.data.systems: + device_id = system.devices[0].id + entities.append( + MyUplinkSystemBinarySensor( + coordinator=coordinator, + device_id=device_id, + system_id=system.id, + entity_description=ALARM_BINARY_SENSOR_DESCRIPTION, + unique_id_suffix="has_alarm", + ) + ) + async_add_entities(entities) class MyUplinkDevicePointBinarySensor(MyUplinkEntity, BinarySensorEntity): - """Representation of a myUplink device point binary sensor.""" + """Representation of a myUplink device point bound binary sensor.""" def __init__( self, @@ -94,3 +130,73 @@ class MyUplinkDevicePointBinarySensor(MyUplinkEntity, BinarySensorEntity): """Binary sensor state value.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] return int(device_point.value) != 0 + + @property + def available(self) -> bool: + """Return device data availability.""" + return super().available and ( + self.coordinator.data.devices[self.device_id].connectionState + == DeviceConnectionState.Connected + ) + + +class MyUplinkDeviceBinarySensor(MyUplinkEntity, BinarySensorEntity): + """Representation of a myUplink device bound binary sensor.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + entity_description: BinarySensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the binary_sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + if entity_description is not None: + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Binary sensor state value.""" + return ( + self.coordinator.data.devices[self.device_id].connectionState + == DeviceConnectionState.Connected + ) + + +class MyUplinkSystemBinarySensor(MyUplinkSystemEntity, BinarySensorEntity): + """Representation of a myUplink system bound binary sensor.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + system_id: str, + device_id: str, + entity_description: BinarySensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the binary_sensor.""" + super().__init__( + coordinator=coordinator, + system_id=system_id, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + if entity_description is not None: + self.entity_description = entity_description + + @property + def is_on(self) -> bool | None: + """Binary sensor state value.""" + retval = None + for system in self.coordinator.data.systems: + if system.id == self.system_id: + retval = system.has_alarm + break + return retval diff --git a/homeassistant/components/myuplink/diagnostics.py b/homeassistant/components/myuplink/diagnostics.py index 15b643ffd92..5e26cf273b4 100644 --- a/homeassistant/components/myuplink/diagnostics.py +++ b/homeassistant/components/myuplink/diagnostics.py @@ -4,25 +4,22 @@ from __future__ import annotations from typing import Any -from myuplink import MyUplinkAPI - from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import MyUplinkConfigEntry TO_REDACT = {"access_token", "refresh_token", "serialNumber"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: MyUplinkConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry. Pick up fresh data from API and dump it. """ - api: MyUplinkAPI = hass.data[DOMAIN][config_entry.entry_id].api + api = config_entry.runtime_data.api myuplink_data = {} myuplink_data["my_systems"] = await api.async_get_systems_json() myuplink_data["my_systems"]["devices"] = [] diff --git a/homeassistant/components/myuplink/entity.py b/homeassistant/components/myuplink/entity.py index 351ba6bfc92..58a8d5d56c5 100644 --- a/homeassistant/components/myuplink/entity.py +++ b/homeassistant/components/myuplink/entity.py @@ -8,7 +8,7 @@ from .coordinator import MyUplinkDataCoordinator class MyUplinkEntity(CoordinatorEntity[MyUplinkDataCoordinator]): - """Representation of a sensor.""" + """Representation of myuplink entity.""" _attr_has_entity_name = True @@ -18,7 +18,7 @@ class MyUplinkEntity(CoordinatorEntity[MyUplinkDataCoordinator]): device_id: str, unique_id_suffix: str, ) -> None: - """Initialize the sensor.""" + """Initialize the entity.""" super().__init__(coordinator=coordinator) # Internal properties @@ -27,3 +27,27 @@ class MyUplinkEntity(CoordinatorEntity[MyUplinkDataCoordinator]): # Basic values self._attr_unique_id = f"{device_id}-{unique_id_suffix}" self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) + + +class MyUplinkSystemEntity(MyUplinkEntity): + """Representation of a system bound entity.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + system_id: str, + device_id: str, + unique_id_suffix: str, + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.system_id = system_id + + # Basic values + self._attr_unique_id = f"{system_id}-{unique_id_suffix}" diff --git a/homeassistant/components/myuplink/icons.json b/homeassistant/components/myuplink/icons.json index 580b83b1b15..4b96a1a3381 100644 --- a/homeassistant/components/myuplink/icons.json +++ b/homeassistant/components/myuplink/icons.json @@ -26,6 +26,9 @@ "priority": { "default": "mdi:priority-high" }, + "rpm": { + "default": "mdi:rotate-right" + }, "status_compressor": { "default": "mdi:heat-pump-outline" } diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index 89d6658d368..7c63a8ec8a2 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -4,14 +4,12 @@ from aiohttp import ClientError from myuplink import DevicePoint from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkDataCoordinator -from .const import DOMAIN +from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity @@ -55,12 +53,12 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MyUplinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up myUplink number.""" entities: list[NumberEntity] = [] - coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Setup device point number entities for device_id, point_data in coordinator.data.points.items(): diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 6cde6b6b071..e7c8054e304 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -8,8 +8,8 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + REVOLUTIONS_PER_MINUTE, Platform, UnitOfElectricCurrent, UnitOfEnergy, @@ -24,8 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MyUplinkDataCoordinator -from .const import DOMAIN +from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity @@ -54,6 +53,13 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, ), + "days": SensorEntityDescription( + key="days", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.DAYS, + suggested_display_precision=0, + ), "h": SensorEntityDescription( key="hours", device_class=SensorDeviceClass.DURATION, @@ -61,6 +67,13 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=UnitOfTime.HOURS, suggested_display_precision=1, ), + "hrs": SensorEntityDescription( + key="hours_hrs", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_display_precision=1, + ), "Hz": SensorEntityDescription( key="hertz", device_class=SensorDeviceClass.FREQUENCY, @@ -86,6 +99,27 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, ), + "min": SensorEntityDescription( + key="minutes", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_display_precision=0, + ), + "Pa": SensorEntityDescription( + key="pressure_pa", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.PA, + suggested_display_precision=0, + ), + "rpm": SensorEntityDescription( + key="rpm", + translation_key="rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + suggested_display_precision=0, + ), "s": SensorEntityDescription( key="seconds", device_class=SensorDeviceClass.DURATION, @@ -93,6 +127,13 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=UnitOfTime.SECONDS, suggested_display_precision=0, ), + "sec": SensorEntityDescription( + key="seconds_sec", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + ), } MARKER_FOR_UNKNOWN_VALUE = -32768 @@ -144,13 +185,13 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MyUplinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up myUplink sensor.""" entities: list[SensorEntity] = [] - coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Setup device point sensors for device_id, point_data in coordinator.data.points.items(): @@ -160,6 +201,11 @@ async def async_setup_entry( if find_matching_platform(device_point) == Platform.SENSOR: description = get_description(device_point) entity_class = MyUplinkDevicePointSensor + # Ignore sensors without a description that provide non-numeric values + if description is None and not isinstance( + device_point.value, (int, float) + ): + continue if ( description is not None and description.device_class == SensorDeviceClass.ENUM diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 2efc0d05b34..30cfefe5e18 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or click **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url\n\n" + }, "config": { "step": { "pick_implementation": { @@ -25,5 +28,12 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "entity": { + "binary_sensor": { + "alarm": { + "name": "Alarm" + } + } } } diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index 11dca1e2ac0..1589701fcbc 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -6,14 +6,12 @@ import aiohttp from myuplink import DevicePoint from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkDataCoordinator -from .const import DOMAIN +from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity @@ -44,12 +42,12 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MyUplinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up myUplink switch.""" entities: list[SwitchEntity] = [] - coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Setup device point switches for device_id, point_data in coordinator.data.points.items(): diff --git a/homeassistant/components/myuplink/update.py b/homeassistant/components/myuplink/update.py index 6a38741a562..9e94de0a503 100644 --- a/homeassistant/components/myuplink/update.py +++ b/homeassistant/components/myuplink/update.py @@ -5,12 +5,10 @@ from homeassistant.components.update import ( UpdateEntity, UpdateEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkDataCoordinator -from .const import DOMAIN +from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity UPDATE_DESCRIPTION = UpdateEntityDescription( @@ -21,11 +19,11 @@ UPDATE_DESCRIPTION = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MyUplinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entity.""" - coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( MyUplinkDeviceUpdate( diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 436838d27a0..624415adb12 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BUTTON, Platform.SENSOR] -NAMConfigEntry = ConfigEntry[NAMDataUpdateCoordinator] +type NAMConfigEntry = ConfigEntry[NAMDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index efdc8f2514b..d3fec1ddbc2 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -2,11 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Mapping from dataclasses import dataclass import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp.client_exceptions import ClientConnectorError from nettigo_air_monitor import ( @@ -50,8 +49,7 @@ async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: options = ConnectionOptions(host) nam = await NettigoAirMonitor.create(websession, options) - async with asyncio.timeout(10): - mac = await nam.async_get_mac_address() + mac = await nam.async_get_mac_address() return NamConfig(mac, nam.auth_enabled) @@ -66,8 +64,7 @@ async def async_check_credentials( nam = await NettigoAirMonitor.create(websession, options) - async with asyncio.timeout(10): - await nam.async_check_credentials() + await nam.async_check_credentials() class NAMFlowHandler(ConfigFlow, domain=DOMAIN): @@ -96,7 +93,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotGetMacError: return self.async_abort(reason="device_unsupported") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -130,7 +127,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -227,3 +224,48 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=AUTH_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + if TYPE_CHECKING: + assert entry is not None + + self.host = entry.data[CONF_HOST] + self.entry = entry + + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors = {} + + if user_input is not None: + try: + config = await async_get_config(self.hass, user_input[CONF_HOST]) + except (ApiError, ClientConnectorError, TimeoutError): + errors["base"] = "cannot_connect" + else: + if format_mac(config.mac_address) != self.entry.unique_id: + return self.async_abort(reason="another_device") + + data = {**self.entry.data, CONF_HOST: user_input[CONF_HOST]} + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self.host): str, + } + ), + description_placeholders={"device_name": self.entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 66718b01c3f..4b7b50b309a 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -20,6 +20,7 @@ ATTR_BMP280_PRESSURE: Final = "bmp280_pressure" ATTR_BMP280_TEMPERATURE: Final = "bmp280_temperature" ATTR_DHT22_HUMIDITY: Final = "dht22_humidity" ATTR_DHT22_TEMPERATURE: Final = "dht22_temperature" +ATTR_DS18B20_TEMPERATURE: Final = "ds18b20_temperature" ATTR_HECA_HUMIDITY: Final = "heca_humidity" ATTR_HECA_TEMPERATURE: Final = "heca_temperature" ATTR_MHZ14A_CARBON_DIOXIDE: Final = "mhz14a_carbon_dioxide" @@ -46,7 +47,7 @@ ATTR_SPS30_P2: Final = f"{ATTR_SPS30}{SUFFIX_P2}" ATTR_SPS30_P4: Final = f"{ATTR_SPS30}{SUFFIX_P4}" ATTR_UPTIME: Final = "uptime" -DEFAULT_UPDATE_INTERVAL: Final = timedelta(minutes=6) +DEFAULT_UPDATE_INTERVAL: Final = timedelta(minutes=4) DOMAIN: Final = "nam" MANUFACTURER: Final = "Nettigo" diff --git a/homeassistant/components/nam/coordinator.py b/homeassistant/components/nam/coordinator.py index ec99b3dfb17..5019f0e3a1d 100644 --- a/homeassistant/components/nam/coordinator.py +++ b/homeassistant/components/nam/coordinator.py @@ -1,15 +1,14 @@ """The Nettigo Air Monitor coordinator.""" -import asyncio import logging -from aiohttp.client_exceptions import ClientConnectorError from nettigo_air_monitor import ( ApiError, InvalidSensorDataError, NAMSensors, NettigoAirMonitor, ) +from tenacity import RetryError from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -47,11 +46,10 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): async def _async_update_data(self) -> NAMSensors: """Update data via library.""" try: - async with asyncio.timeout(10): - data = await self.nam.async_update() + data = await self.nam.async_update() # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. - except (ApiError, ClientConnectorError, InvalidSensorDataError) as error: + except (ApiError, InvalidSensorDataError, RetryError) as error: raise UpdateFailed(error) from error return data diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index d4638cbdbbe..3b6dba65325 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==3.0.1"], + "requirements": ["nettigo-air-monitor==3.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 0f4647d071f..27fae62be8a 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -43,6 +43,7 @@ from .const import ( ATTR_BMP280_TEMPERATURE, ATTR_DHT22_HUMIDITY, ATTR_DHT22_TEMPERATURE, + ATTR_DS18B20_TEMPERATURE, ATTR_HECA_HUMIDITY, ATTR_HECA_TEMPERATURE, ATTR_MHZ14A_CARBON_DIOXIDE, @@ -145,6 +146,15 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value=lambda sensors: sensors.bmp280_temperature, ), + NAMSensorEntityDescription( + key=ATTR_DS18B20_TEMPERATURE, + translation_key="ds18b20_temperature", + suggested_display_precision=1, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda sensors: sensors.ds18b20_temperature, + ), NAMSensorEntityDescription( key=ATTR_HECA_HUMIDITY, translation_key="heca_humidity", diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 83a40d87f76..c4921ec52f9 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -27,6 +27,15 @@ }, "confirm_discovery": { "description": "Do you want to set up Nettigo Air Monitor at {host}?" + }, + "reconfigure_confirm": { + "description": "Update configuration for {device_name}.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::nam::config::step::user::data_description::host%]" + } } }, "error": { @@ -38,7 +47,9 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "device_unsupported": "The device is unsupported.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "another_device": "The IP address/hostname of another Nettigo Air Monitor was used." } }, "entity": { @@ -64,6 +75,9 @@ "bmp280_temperature": { "name": "BMP280 temperature" }, + "ds18b20_temperature": { + "name": "DS18B20 temperature" + }, "heca_humidity": { "name": "HECA humidity" }, diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 9e368353774..4a34c2843aa 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -3,18 +3,10 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass -from datetime import timedelta +from contextlib import suppress import logging -from aionanoleaf import ( - EffectsEvent, - InvalidToken, - Nanoleaf, - StateEvent, - TouchEvent, - Unavailable, -) +from aionanoleaf import EffectsEvent, Nanoleaf, StateEvent, TouchEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -25,49 +17,28 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN, NANOLEAF_EVENT, TOUCH_GESTURE_TRIGGER_MAP, TOUCH_MODELS +from .coordinator import NanoleafCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BUTTON, Platform.LIGHT] +PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.LIGHT] -@dataclass -class NanoleafEntryData: - """Class for sharing data within the Nanoleaf integration.""" - - device: Nanoleaf - coordinator: DataUpdateCoordinator[None] - event_listener: asyncio.Task +type NanoleafConfigEntry = ConfigEntry[NanoleafCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NanoleafConfigEntry) -> bool: """Set up Nanoleaf from a config entry.""" nanoleaf = Nanoleaf( async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN] ) - async def async_get_state() -> None: - """Get the state of the device.""" - try: - await nanoleaf.get_info() - except Unavailable as err: - raise UpdateFailed from err - except InvalidToken as err: - raise ConfigEntryAuthFailed from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=entry.title, - update_interval=timedelta(minutes=1), - update_method=async_get_state, - ) + coordinator = NanoleafCoordinator(hass, nanoleaf) await coordinator.async_config_entry_first_refresh() @@ -95,6 +66,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: NANOLEAF_EVENT, {CONF_DEVICE_ID: device_entry.id, CONF_TYPE: gesture_type}, ) + async_dispatcher_send( + hass, f"nanoleaf_gesture_{nanoleaf.serial_no}", gesture_type + ) event_listener = asyncio.create_task( nanoleaf.listen_events( @@ -104,18 +78,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = NanoleafEntryData( - nanoleaf, coordinator, event_listener - ) + async def _cancel_listener() -> None: + event_listener.cancel() + with suppress(asyncio.CancelledError): + await event_listener + + entry.async_on_unload(_cancel_listener) + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NanoleafConfigEntry) -> bool: """Unload a config entry.""" - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - entry_data: NanoleafEntryData = hass.data[DOMAIN].pop(entry.entry_id) - entry_data.event_listener.cancel() - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nanoleaf/button.py b/homeassistant/components/nanoleaf/button.py index 950dc2a591a..34d0f4f5076 100644 --- a/homeassistant/components/nanoleaf/button.py +++ b/homeassistant/components/nanoleaf/button.py @@ -1,27 +1,22 @@ """Support for Nanoleaf buttons.""" -from aionanoleaf import Nanoleaf - from homeassistant.components.button import ButtonDeviceClass, ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import NanoleafEntryData -from .const import DOMAIN +from . import NanoleafConfigEntry +from .coordinator import NanoleafCoordinator from .entity import NanoleafEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NanoleafConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Nanoleaf button.""" - entry_data: NanoleafEntryData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [NanoleafIdentifyButton(entry_data.device, entry_data.coordinator)] - ) + async_add_entities([NanoleafIdentifyButton(entry.runtime_data)]) class NanoleafIdentifyButton(NanoleafEntity, ButtonEntity): @@ -30,12 +25,10 @@ class NanoleafIdentifyButton(NanoleafEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY - def __init__( - self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] - ) -> None: + def __init__(self, coordinator: NanoleafCoordinator) -> None: """Initialize the Nanoleaf button.""" - super().__init__(nanoleaf, coordinator) - self._attr_unique_id = f"{nanoleaf.serial_no}_identify" + super().__init__(coordinator) + self._attr_unique_id = f"{self._nanoleaf.serial_no}_identify" async def async_press(self) -> None: """Identify the Nanoleaf.""" diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index ff25a25caf4..080b8131b1d 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -67,7 +67,7 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): ) except Unauthorized: pass - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error connecting to Nanoleaf") return self.async_show_form( step_id="user", @@ -173,7 +173,7 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): ) except Unavailable: return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error authorizing Nanoleaf") return self.async_show_form(step_id="link", errors={"base": "unknown"}) @@ -200,7 +200,7 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") except InvalidToken: return self.async_abort(reason="invalid_token") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Unknown error connecting with Nanoleaf at %s", self.nanoleaf.host ) diff --git a/homeassistant/components/nanoleaf/coordinator.py b/homeassistant/components/nanoleaf/coordinator.py new file mode 100644 index 00000000000..e080afc492e --- /dev/null +++ b/homeassistant/components/nanoleaf/coordinator.py @@ -0,0 +1,31 @@ +"""Define the Nanoleaf data coordinator.""" + +from datetime import timedelta +import logging + +from aionanoleaf import InvalidToken, Nanoleaf, Unavailable + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class NanoleafCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Nanoleaf data.""" + + def __init__(self, hass: HomeAssistant, nanoleaf: Nanoleaf) -> None: + """Initialize the Nanoleaf data coordinator.""" + super().__init__( + hass, _LOGGER, name="Nanoleaf", update_interval=timedelta(minutes=1) + ) + self.nanoleaf = nanoleaf + + async def _async_update_data(self) -> None: + try: + await self.nanoleaf.get_info() + except Unavailable as err: + raise UpdateFailed from err + except InvalidToken as err: + raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/nanoleaf/diagnostics.py b/homeassistant/components/nanoleaf/diagnostics.py index 57f385e5039..6f8691905ef 100644 --- a/homeassistant/components/nanoleaf/diagnostics.py +++ b/homeassistant/components/nanoleaf/diagnostics.py @@ -4,22 +4,19 @@ from __future__ import annotations from typing import Any -from aionanoleaf import Nanoleaf - from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import NanoleafConfigEntry async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NanoleafConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - device: Nanoleaf = hass.data[DOMAIN][config_entry.entry_id].device + device = config_entry.runtime_data.nanoleaf return { "info": async_redact_data(config_entry.as_dict(), (CONF_TOKEN, "title")), diff --git a/homeassistant/components/nanoleaf/entity.py b/homeassistant/components/nanoleaf/entity.py index 73d635a46a1..ffe4a098022 100644 --- a/homeassistant/components/nanoleaf/entity.py +++ b/homeassistant/components/nanoleaf/entity.py @@ -1,27 +1,21 @@ """Base class for Nanoleaf entity.""" -from aionanoleaf import Nanoleaf - from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import NanoleafCoordinator from .const import DOMAIN -class NanoleafEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): +class NanoleafEntity(CoordinatorEntity[NanoleafCoordinator]): """Representation of a Nanoleaf entity.""" _attr_has_entity_name = True - def __init__( - self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] - ) -> None: + def __init__(self, coordinator: NanoleafCoordinator) -> None: """Initialize a Nanoleaf entity.""" super().__init__(coordinator) - self._nanoleaf = nanoleaf + self._nanoleaf = nanoleaf = coordinator.nanoleaf self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, nanoleaf.serial_no)}, manufacturer=nanoleaf.manufacturer, diff --git a/homeassistant/components/nanoleaf/event.py b/homeassistant/components/nanoleaf/event.py new file mode 100644 index 00000000000..5763c2aa595 --- /dev/null +++ b/homeassistant/components/nanoleaf/event.py @@ -0,0 +1,55 @@ +"""Support for Nanoleaf event entity.""" + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import NanoleafConfigEntry, NanoleafCoordinator +from .const import TOUCH_MODELS +from .entity import NanoleafEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NanoleafConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Nanoleaf event.""" + coordinator = entry.runtime_data + if coordinator.nanoleaf.model in TOUCH_MODELS: + async_add_entities([NanoleafGestureEvent(coordinator)]) + + +class NanoleafGestureEvent(NanoleafEntity, EventEntity): + """Representation of a Nanoleaf event entity.""" + + _attr_event_types = [ + "swipe_up", + "swipe_down", + "swipe_left", + "swipe_right", + ] + _attr_translation_key = "touch" + + def __init__(self, coordinator: NanoleafCoordinator) -> None: + """Initialize the Nanoleaf event entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{self._nanoleaf.serial_no}_gesture" + + async def async_added_to_hass(self) -> None: + """Subscribe to Nanoleaf events.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"nanoleaf_gesture_{self._nanoleaf.serial_no}", + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self, gesture: str) -> None: + """Handle the event.""" + self._trigger_event(gesture) + self.async_write_ha_state() diff --git a/homeassistant/components/nanoleaf/icons.json b/homeassistant/components/nanoleaf/icons.json index 3f4ebf9ed9f..bedfc2f0718 100644 --- a/homeassistant/components/nanoleaf/icons.json +++ b/homeassistant/components/nanoleaf/icons.json @@ -1,5 +1,10 @@ { "entity": { + "event": { + "touch": { + "default": "mdi:gesture" + } + }, "light": { "light": { "default": "mdi:triangle-outline" diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index b80048307bb..19d817b9999 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -5,8 +5,6 @@ from __future__ import annotations import math from typing import Any -from aionanoleaf import Nanoleaf - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -17,17 +15,15 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired, color_temperature_mired_to_kelvin as mired_to_kelvin, ) -from . import NanoleafEntryData -from .const import DOMAIN +from . import NanoleafConfigEntry +from .coordinator import NanoleafCoordinator from .entity import NanoleafEntity RESERVED_EFFECTS = ("*Solid*", "*Static*", "*Dynamic*") @@ -35,11 +31,12 @@ DEFAULT_NAME = "Nanoleaf" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NanoleafConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Nanoleaf light.""" - entry_data: NanoleafEntryData = hass.data[DOMAIN][entry.entry_id] - async_add_entities([NanoleafLight(entry_data.device, entry_data.coordinator)]) + async_add_entities([NanoleafLight(entry.runtime_data)]) class NanoleafLight(NanoleafEntity, LightEntity): @@ -50,14 +47,14 @@ class NanoleafLight(NanoleafEntity, LightEntity): _attr_name = None _attr_translation_key = "light" - def __init__( - self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] - ) -> None: + def __init__(self, coordinator: NanoleafCoordinator) -> None: """Initialize the Nanoleaf light.""" - super().__init__(nanoleaf, coordinator) - self._attr_unique_id = nanoleaf.serial_no - self._attr_min_mireds = math.ceil(1000000 / nanoleaf.color_temperature_max) - self._attr_max_mireds = kelvin_to_mired(nanoleaf.color_temperature_min) + super().__init__(coordinator) + self._attr_unique_id = self._nanoleaf.serial_no + self._attr_min_mireds = math.ceil( + 1000000 / self._nanoleaf.color_temperature_max + ) + self._attr_max_mireds = kelvin_to_mired(self._nanoleaf.color_temperature_min) @property def brightness(self) -> int: diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 3afb086d1a6..4b4c026260d 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -1,7 +1,7 @@ { "domain": "nanoleaf", "name": "Nanoleaf", - "codeowners": ["@milanmeu"], + "codeowners": ["@milanmeu", "@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", "homekit": { diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 13e7c9a11a3..40cd7294ec3 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -30,10 +30,27 @@ }, "device_automation": { "trigger_type": { - "swipe_up": "Swipe Up", - "swipe_down": "Swipe Down", - "swipe_left": "Swipe Left", - "swipe_right": "Swipe Right" + "swipe_up": "[%key:component::nanoleaf::entity::event::touch::state_attributes::event_type::state::swipe_up%]", + "swipe_down": "[%key:component::nanoleaf::entity::event::touch::state_attributes::event_type::state::swipe_down%]", + "swipe_left": "[%key:component::nanoleaf::entity::event::touch::state_attributes::event_type::state::swipe_left%]", + "swipe_right": "[%key:component::nanoleaf::entity::event::touch::state_attributes::event_type::state::swipe_right%]" + } + }, + "entity": { + "event": { + "touch": { + "name": "Touch gesture", + "state_attributes": { + "event_type": { + "state": { + "swipe_up": "Swipe up", + "swipe_down": "Swipe down", + "swipe_left": "Swipe left", + "swipe_right": "Swipe right" + } + } + } + } } } } diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 55727289181..33828e65019 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -131,7 +131,7 @@ class NSDepartureSensor(SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" if not self._trips: - return + return None if self._trips[0].trip_parts: route = [self._trips[0].departure] diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 2835dee9056..e44c06ecc85 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -6,8 +6,11 @@ import logging from nessclient import ArmingMode, ArmingState, Client -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -51,10 +54,10 @@ async def async_setup_platform( async_add_entities([device]) -class NessAlarmPanel(alarm.AlarmControlPanelEntity): +class NessAlarmPanel(AlarmControlPanelEntity): """Representation of a Ness alarm panel.""" - _attr_code_format = alarm.CodeFormat.NUMBER + _attr_code_format = CodeFormat.NUMBER _attr_should_poll = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 383521452d0..bdec44a3c85 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -34,9 +34,10 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_SENSORS, CONF_STRUCTURE, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -196,13 +197,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_config_reload() -> None: await hass.config_entries.async_reload(entry.entry_id) - callback = SignalUpdateCallback(hass, async_config_reload) - subscriber.set_update_callback(callback.async_handle_event) + update_callback = SignalUpdateCallback(hass, async_config_reload) + subscriber.set_update_callback(update_callback.async_handle_event) try: await subscriber.start_async() except AuthException as err: raise ConfigEntryAuthFailed( - f"Subscriber authentication error: {str(err)}" + f"Subscriber authentication error: {err!s}" ) from err except ConfigurationException as err: _LOGGER.error("Configuration error: %s", err) @@ -210,13 +211,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False except SubscriberException as err: subscriber.stop_async() - raise ConfigEntryNotReady(f"Subscriber error: {str(err)}") from err + raise ConfigEntryNotReady(f"Subscriber error: {err!s}") from err try: device_manager = await subscriber.async_get_device_manager() except ApiException as err: subscriber.stop_async() - raise ConfigEntryNotReady(f"Device manager error: {str(err)}") from err + raise ConfigEntryNotReady(f"Device manager error: {err!s}") from err + + @callback + def on_hass_stop(_: Event) -> None: + """Close connection when hass stops.""" + subscriber.stop_async() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + ) hass.data[DOMAIN][entry.entry_id] = { DATA_SUBSCRIBER: subscriber, diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 8c9ca4bec96..3ef26747115 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -57,7 +57,7 @@ class AsyncConfigEntryAuth(AbstractAuth): # even when it is expired to fully hand off this responsibility and # know it is working at startup (then if not, fail loudly). token = self._oauth_session.token - creds = Credentials( + creds = Credentials( # type: ignore[no-untyped-call] token=token["access_token"], refresh_token=token["refresh_token"], token_uri=OAUTH2_TOKEN, @@ -92,7 +92,7 @@ class AccessTokenAuthImpl(AbstractAuth): async def async_get_creds(self) -> Credentials: """Return an OAuth credential for Pub/Sub Subscriber.""" - return Credentials( + return Credentials( # type: ignore[no-untyped-call] token=self._access_token, token_uri=OAUTH2_TOKEN, scopes=SDM_SCOPES, diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 411389f9fb2..03fb641d0e5 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -10,7 +10,6 @@ from google_nest_sdm.device_traits import FanTrait, TemperatureTrait from google_nest_sdm.exceptions import ApiException from google_nest_sdm.thermostat_traits import ( ThermostatEcoTrait, - ThermostatHeatCoolTrait, ThermostatHvacTrait, ThermostatModeTrait, ThermostatTemperatureSetpointTrait, @@ -173,7 +172,7 @@ class ThermostatEntity(ClimateEntity): @property def _target_temperature_trait( self, - ) -> ThermostatHeatCoolTrait | None: + ) -> ThermostatEcoTrait | ThermostatTemperatureSetpointTrait | None: """Return the correct trait with a target temp depending on mode.""" if ( self.preset_mode == PRESET_ECO diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 7b5f5d2c5fb..29ae9f6a08e 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -20,7 +20,7 @@ from google_nest_sdm.exceptions import ( ConfigurationException, SubscriberException, ) -from google_nest_sdm.structure import InfoTrait, Structure +from google_nest_sdm.structure import Structure import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry, ConfigFlowResult @@ -72,9 +72,9 @@ def _generate_subscription_id(cloud_project_id: str) -> str: def generate_config_title(structures: Iterable[Structure]) -> str | None: """Pick a user friendly config title based on the Google Home name(s).""" names: list[str] = [ - trait.custom_name + structure.info.custom_name for structure in structures - if (trait := structure.traits.get(InfoTrait.NAME)) and trait.custom_name + if structure.info and structure.info.custom_name ] if not names: return None diff --git a/homeassistant/components/nest/events.py b/homeassistant/components/nest/events.py index 752ab0e5069..76a5069f563 100644 --- a/homeassistant/components/nest/events.py +++ b/homeassistant/components/nest/events.py @@ -44,25 +44,26 @@ EVENT_CAMERA_SOUND = "camera_sound" # that support these traits will generate Pub/Sub event messages in # the EVENT_NAME_MAP DEVICE_TRAIT_TRIGGER_MAP = { - DoorbellChimeTrait.NAME: EVENT_DOORBELL_CHIME, - CameraMotionTrait.NAME: EVENT_CAMERA_MOTION, - CameraPersonTrait.NAME: EVENT_CAMERA_PERSON, - CameraSoundTrait.NAME: EVENT_CAMERA_SOUND, + DoorbellChimeTrait.NAME.value: EVENT_DOORBELL_CHIME, + CameraMotionTrait.NAME.value: EVENT_CAMERA_MOTION, + CameraPersonTrait.NAME.value: EVENT_CAMERA_PERSON, + CameraSoundTrait.NAME.value: EVENT_CAMERA_SOUND, } + # Mapping of incoming SDM Pub/Sub event message types to the home assistant # event type to fire. EVENT_NAME_MAP = { - DoorbellChimeEvent.NAME: EVENT_DOORBELL_CHIME, - CameraMotionEvent.NAME: EVENT_CAMERA_MOTION, - CameraPersonEvent.NAME: EVENT_CAMERA_PERSON, - CameraSoundEvent.NAME: EVENT_CAMERA_SOUND, + DoorbellChimeEvent.NAME.value: EVENT_DOORBELL_CHIME, + CameraMotionEvent.NAME.value: EVENT_CAMERA_MOTION, + CameraPersonEvent.NAME.value: EVENT_CAMERA_PERSON, + CameraSoundEvent.NAME.value: EVENT_CAMERA_SOUND, } # Names for event types shown in the media source MEDIA_SOURCE_EVENT_TITLE_MAP = { - DoorbellChimeEvent.NAME: "Doorbell", - CameraMotionEvent.NAME: "Motion", - CameraPersonEvent.NAME: "Person", - CameraSoundEvent.NAME: "Sound", + DoorbellChimeEvent.NAME.value: "Doorbell", + CameraMotionEvent.NAME.value: "Motion", + CameraPersonEvent.NAME.value: "Person", + CameraSoundEvent.NAME.value: "Sound", } diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 354066e2d87..d3ba571e65a 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==3.0.4"] + "requirements": ["google-nest-sdm==4.0.5"] } diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 7d99ef9d32c..c762666e041 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -33,10 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import ( - DeviceInfo, - async_entries_for_config_entry, -) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -459,7 +456,7 @@ async def async_setup_entry( """Retrieve Netatmo public weather entities.""" entities = { device.name: device.id - for device in async_entries_for_config_entry( + for device in dr.async_entries_for_config_entry( device_registry, entry.entry_id ) if device.model == "Public Weather station" diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 8c54cb96b3d..1846d1f7992 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,29 +1,17 @@ """Support for Netgear LTE modems.""" -from datetime import timedelta +from typing import Any from aiohttp.cookiejar import CookieJar -import attr import eternalegypt -import voluptuous as vol +from eternalegypt.eternalegypt import SMS -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState -from homeassistant.const import ( - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_PASSWORD, - CONF_RECIPIENT, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform +from homeassistant.core import 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 .const import ( @@ -31,18 +19,13 @@ from .const import ( ATTR_HOST, ATTR_MESSAGE, ATTR_SMS_ID, - CONF_BINARY_SENSOR, - CONF_NOTIFY, - CONF_SENSOR, DATA_HASS_CONFIG, - DISPATCHER_NETGEAR_LTE, + DATA_SESSION, DOMAIN, - LOGGER, ) +from .coordinator import NetgearLTEDataUpdateCoordinator from .services import async_setup_services -SCAN_INTERVAL = timedelta(seconds=10) - EVENT_SMS = "netgear_lte_sms" ALL_SENSORS = [ @@ -67,181 +50,63 @@ ALL_BINARY_SENSORS = [ "mobile_connected", ] - -NOTIFY_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DOMAIN): cv.string, - vol.Optional(CONF_RECIPIENT, default=[]): vol.All(cv.ensure_list, [cv.string]), - } -) - -SENSOR_SCHEMA = vol.Schema( - { - 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=["mobile_connected"]): vol.All( - cv.ensure_list, [vol.In(ALL_BINARY_SENSORS)] - ) - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NOTIFY, default={}): vol.All( - cv.ensure_list, [NOTIFY_SCHEMA] - ), - vol.Optional(CONF_SENSOR, default={}): SENSOR_SCHEMA, - vol.Optional( - CONF_BINARY_SENSOR, default={} - ): BINARY_SENSOR_SCHEMA, - } - ) - ], - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR, ] +type NetgearLTEConfigEntry = ConfigEntry[NetgearLTEDataUpdateCoordinator] - -@attr.s -class ModemData: - """Class for modem state.""" - - hass = attr.ib() - host = attr.ib() - modem = attr.ib() - - data = attr.ib(init=False, default=None) - connected = attr.ib(init=False, default=True) - - async def async_update(self): - """Call the API to update the data.""" - - try: - self.data = await self.modem.information() - if not self.connected: - LOGGER.warning("Connected to %s", self.host) - self.connected = True - except eternalegypt.Error: - if self.connected: - LOGGER.warning("Lost connection to %s", self.host) - self.connected = False - self.data = None - - async_dispatcher_send(self.hass, DISPATCHER_NETGEAR_LTE) - - -@attr.s -class LTEData: - """Shared state.""" - - websession = attr.ib() - modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict) - - def get_modem_data(self, config): - """Get modem_data for the host in config.""" - if config[CONF_HOST] is not None: - return self.modem_data.get(config[CONF_HOST]) - if len(self.modem_data) != 1: - return None - return next(iter(self.modem_data.values())) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" hass.data[DATA_HASS_CONFIG] = config - if lte_config := config.get(DOMAIN): - hass.async_create_task(import_yaml(hass, lte_config)) - return True -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", - }, - ) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool: """Set up Netgear LTE from a config entry.""" host = entry.data[CONF_HOST] password = entry.data[CONF_PASSWORD] - if not (data := hass.data.get(DOMAIN)) or data.websession.closed: - websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + data: dict[str, Any] = hass.data.setdefault(DOMAIN, {}) + if not (session := data.get(DATA_SESSION)) or session.closed: + session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + modem = eternalegypt.Modem(hostname=host, websession=session) - hass.data[DOMAIN] = LTEData(websession) + try: + await modem.login(password=password) + except eternalegypt.Error as ex: + raise ConfigEntryNotReady("Cannot connect/authenticate") from ex - modem = eternalegypt.Modem(hostname=host, websession=hass.data[DOMAIN].websession) - modem_data = ModemData(hass, host, modem) + def fire_sms_event(sms: SMS) -> None: + """Send an SMS event.""" + data = { + ATTR_HOST: modem.hostname, + ATTR_SMS_ID: sms.id, + ATTR_FROM: sms.sender, + ATTR_MESSAGE: sms.message, + } + hass.bus.async_fire(EVENT_SMS, data) - await _login(hass, modem_data, password) + await modem.add_sms_listener(fire_sms_event) - async def _update(now): - """Periodic update.""" - await modem_data.async_update() + coordinator = NetgearLTEDataUpdateCoordinator(hass, modem) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator - update_unsub = async_track_time_interval(hass, _update, SCAN_INTERVAL) + await async_setup_services(hass, modem) - 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 discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_NAME: entry.title, "modem": modem}, + hass.data[DATA_HASS_CONFIG], + ) await hass.config_entries.async_forward_entry_setups( entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] @@ -250,7 +115,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) loaded_entries = [ @@ -260,89 +125,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] if len(loaded_entries) == 1: hass.data.pop(DOMAIN, None) + for service_name in hass.services.async_services()[DOMAIN]: + hass.services.async_remove(DOMAIN, service_name) return unload_ok - - -async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None: - """Log in and complete setup.""" - 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.""" - data = { - ATTR_HOST: modem_data.host, - ATTR_SMS_ID: sms.id, - ATTR_FROM: sms.sender, - ATTR_MESSAGE: sms.message, - } - hass.bus.async_fire(EVENT_SMS, data) - - await modem_data.modem.add_sms_listener(fire_sms_event) - - await modem_data.async_update() - hass.data[DOMAIN].modem_data[modem_data.host] = modem_data - - -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(' ', '_')}" - }, - ) - - 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 43a9c1bd260..280d240b90f 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -7,12 +7,11 @@ from homeassistant.components.binary_sensor import ( 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 . import NetgearLTEConfigEntry from .entity import LTEEntity BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( @@ -38,13 +37,13 @@ BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NetgearLTEConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Netgear LTE binary sensor.""" - modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - async_add_entities( - NetgearLTEBinarySensor(entry, modem_data, sensor) for sensor in BINARY_SENSORS + NetgearLTEBinarySensor(entry, description) for description in BINARY_SENSORS ) @@ -54,4 +53,4 @@ class NetgearLTEBinarySensor(LTEEntity, BinarySensorEntity): @property def is_on(self): """Return true if the binary sensor is on.""" - return getattr(self.modem_data.data, self.entity_description.key) + return getattr(self.coordinator.data, self.entity_description.key) diff --git a/homeassistant/components/netgear_lte/config_flow.py b/homeassistant/components/netgear_lte/config_flow.py index fe411f79699..0b8f68246ca 100644 --- a/homeassistant/components/netgear_lte/config_flow.py +++ b/homeassistant/components/netgear_lte/config_flow.py @@ -20,22 +20,6 @@ from .const import DEFAULT_HOST, DOMAIN, LOGGER, MANUFACTURER class NetgearLTEFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Netgear LTE.""" - async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult: - """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 ) -> ConfigFlowResult: diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py index 69a96c289e8..1b8a96319c2 100644 --- a/homeassistant/components/netgear_lte/const.py +++ b/homeassistant/components/netgear_lte/const.py @@ -16,9 +16,9 @@ CONF_NOTIFY: Final = "notify" CONF_SENSOR: Final = "sensor" DATA_HASS_CONFIG = "netgear_lte_hass_config" +DATA_SESSION = "session" # 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"] diff --git a/homeassistant/components/netgear_lte/coordinator.py b/homeassistant/components/netgear_lte/coordinator.py new file mode 100644 index 00000000000..afd0cb743bf --- /dev/null +++ b/homeassistant/components/netgear_lte/coordinator.py @@ -0,0 +1,43 @@ +"""Data update coordinator for the Netgear LTE integration.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from eternalegypt.eternalegypt import Error, Information, Modem + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import NetgearLTEConfigEntry + + +class NetgearLTEDataUpdateCoordinator(DataUpdateCoordinator[Information]): + """Data update coordinator for the Netgear LTE integration.""" + + config_entry: NetgearLTEConfigEntry + + def __init__( + self, + hass: HomeAssistant, + modem: Modem, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.modem = modem + + async def _async_update_data(self) -> Information: + """Get the latest data.""" + try: + return await self.modem.information() + except Error as ex: + raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py index 0ec16ceff9d..3353da6dc77 100644 --- a/homeassistant/components/netgear_lte/entity.py +++ b/homeassistant/components/netgear_lte/entity.py @@ -1,54 +1,36 @@ """Entity representing a Netgear LTE entity.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ModemData -from .const import DISPATCHER_NETGEAR_LTE, DOMAIN, MANUFACTURER +from . import NetgearLTEConfigEntry +from .const import DOMAIN, MANUFACTURER +from .coordinator import NetgearLTEDataUpdateCoordinator -class LTEEntity(Entity): +class LTEEntity(CoordinatorEntity[NetgearLTEDataUpdateCoordinator]): """Base LTE entity.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, - config_entry: ConfigEntry, - modem_data: ModemData, + entry: NetgearLTEConfigEntry, description: EntityDescription, ) -> None: """Initialize a Netgear LTE entity.""" + super().__init__(entry.runtime_data) self.entity_description = description - self.modem_data = modem_data - self._attr_unique_id = f"{description.key}_{modem_data.data.serial_number}" + data = entry.runtime_data.data + self._attr_unique_id = f"{description.key}_{data.serial_number}" self._attr_device_info = DeviceInfo( - configuration_url=f"http://{config_entry.data[CONF_HOST]}", - identifiers={(DOMAIN, modem_data.data.serial_number)}, + configuration_url=f"http://{entry.data[CONF_HOST]}", + identifiers={(DOMAIN, data.serial_number)}, manufacturer=MANUFACTURER, - model=modem_data.data.items["general.model"], - serial_number=modem_data.data.serial_number, - sw_version=modem_data.data.items["general.fwversion"], - hw_version=modem_data.data.items["general.hwversion"], + model=data.items["general.model"], + serial_number=data.serial_number, + sw_version=data.items["general.fwversion"], + hw_version=data.items["general.hwversion"], ) - - async def async_added_to_hass(self) -> None: - """Register callback.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state - ) - ) - - async def async_update(self) -> None: - """Force update of state.""" - await self.modem_data.async_update() - - @property - def available(self) -> bool: - """Return the availability of the sensor.""" - return self.modem_data.data is not None diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index 97ba402dc35..763581b9cad 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -2,15 +2,17 @@ from __future__ import annotations -import attr +from typing import Any + import eternalegypt +from eternalegypt.eternalegypt import Modem from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_NOTIFY, DOMAIN, LOGGER +from .const import CONF_NOTIFY, LOGGER async def async_get_service( @@ -22,21 +24,25 @@ async def async_get_service( if discovery_info is None: return None - return NetgearNotifyService(hass, discovery_info) + return NetgearNotifyService(config, discovery_info) -@attr.s class NetgearNotifyService(BaseNotificationService): """Implementation of a notification service.""" - hass = attr.ib() - config = attr.ib() + def __init__( + self, + config: ConfigType, + discovery_info: dict[str, Any], + ) -> None: + """Initialize the service.""" + self.config = config + self.modem: Modem = discovery_info["modem"] async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - modem_data = self.hass.data[DOMAIN].get_modem_data(self.config) - if not modem_data: + if not self.modem.token: LOGGER.error("Modem not ready") return if not (targets := kwargs.get(ATTR_TARGET)): @@ -50,6 +56,6 @@ class NetgearNotifyService(BaseNotificationService): for target in targets: try: - await modem_data.modem.sms(target, message) + await self.modem.sms(target, message) except eternalegypt.Error: LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 62b4796f068..73e5de7eaeb 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -5,12 +5,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from eternalegypt.eternalegypt import Information + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -21,8 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import ModemData -from .const import DOMAIN +from . import NetgearLTEConfigEntry from .entity import LTEEntity @@ -30,7 +30,7 @@ from .entity import LTEEntity class NetgearLTESensorEntityDescription(SensorEntityDescription): """Class describing Netgear LTE entities.""" - value_fn: Callable[[ModemData], StateType] | None = None + value_fn: Callable[[Information], StateType] | None = None SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( @@ -38,13 +38,13 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( key="sms", translation_key="sms", native_unit_of_measurement="unread", - value_fn=lambda modem_data: sum(1 for x in modem_data.data.sms if x.unread), + value_fn=lambda data: sum(1 for x in data.sms if x.unread), ), NetgearLTESensorEntityDescription( key="sms_total", translation_key="sms_total", native_unit_of_measurement="messages", - value_fn=lambda modem_data: len(modem_data.data.sms), + value_fn=lambda data: len(data.sms), ), NetgearLTESensorEntityDescription( key="usage", @@ -54,7 +54,7 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.BYTES, suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, suggested_display_precision=1, - value_fn=lambda modem_data: modem_data.data.usage, + value_fn=lambda data: data.usage, ), NetgearLTESensorEntityDescription( key="radio_quality", @@ -125,14 +125,12 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NetgearLTEConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Netgear LTE sensor.""" - modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - - async_add_entities( - NetgearLTESensor(entry, modem_data, sensor) for sensor in SENSORS - ) + async_add_entities(NetgearLTESensor(entry, description) for description in SENSORS) class NetgearLTESensor(LTEEntity, SensorEntity): @@ -144,5 +142,5 @@ class NetgearLTESensor(LTEEntity, SensorEntity): 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.entity_description.key) + return self.entity_description.value_fn(self.coordinator.data) + return getattr(self.coordinator.data, self.entity_description.key) diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py index 02000820119..77ed1b91f31 100644 --- a/homeassistant/components/netgear_lte/services.py +++ b/homeassistant/components/netgear_lte/services.py @@ -1,10 +1,8 @@ """Services for the Netgear LTE integration.""" -from typing import TYPE_CHECKING - +from eternalegypt.eternalegypt import Modem import voluptuous as vol -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv @@ -19,9 +17,6 @@ from .const import ( LOGGER, ) -if TYPE_CHECKING: - from . import LTEData, ModemData - SERVICE_DELETE_SMS = "delete_sms" SERVICE_SET_OPTION = "set_option" SERVICE_CONNECT_LTE = "connect_lte" @@ -50,31 +45,29 @@ CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) -async def async_setup_services(hass: HomeAssistant) -> None: +async def async_setup_services(hass: HomeAssistant, modem: Modem) -> None: """Set up services for Netgear LTE integration.""" async def service_handler(call: ServiceCall) -> None: """Apply a service.""" host = call.data.get(ATTR_HOST) - data: LTEData = hass.data[DOMAIN] - modem_data: ModemData = data.get_modem_data({CONF_HOST: host}) - if not modem_data: + if not modem.token: LOGGER.error("%s: host %s unavailable", call.service, host) return if call.service == SERVICE_DELETE_SMS: for sms_id in call.data[ATTR_SMS_ID]: - await modem_data.modem.delete_sms(sms_id) + await modem.delete_sms(sms_id) elif call.service == SERVICE_SET_OPTION: if failover := call.data.get(ATTR_FAILOVER): - await modem_data.modem.set_failover_mode(failover) + await modem.set_failover_mode(failover) if autoconnect := call.data.get(ATTR_AUTOCONNECT): - await modem_data.modem.set_autoconnect_mode(autoconnect) + await modem.set_autoconnect_mode(autoconnect) elif call.service == SERVICE_CONNECT_LTE: - await modem_data.modem.connect_lte() + await modem.connect_lte() elif call.service == SERVICE_DISCONNECT_LTE: - await modem_data.modem.disconnect_lte() + await modem.disconnect_lte() service_schemas = { SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json index 5719d693d15..0b1446b33ca 100644 --- a/homeassistant/components/netgear_lte/strings.json +++ b/homeassistant/components/netgear_lte/strings.json @@ -17,16 +17,6 @@ "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", diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py index 55c3c2f5ead..88f4c1f913e 100644 --- a/homeassistant/components/network/util.py +++ b/homeassistant/components/network/util.py @@ -85,7 +85,7 @@ def _reset_enabled_adapters(adapters: list[Adapter]) -> None: def _ifaddr_adapter_to_ha( - adapter: ifaddr.Adapter, next_hop_address: None | IPv4Address | IPv6Address + adapter: ifaddr.Adapter, next_hop_address: IPv4Address | IPv6Address | None ) -> Adapter: """Convert an ifaddr adapter to ha.""" ip_v4s: list[IPv4ConfiguredAddress] = [] @@ -144,7 +144,7 @@ def async_get_source_ip(target_ip: str) -> str | None: try: test_sock.connect((target_ip, 1)) return cast(str, test_sock.getsockname()[0]) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.debug( ( "The system could not auto detect the source ip for %s on your" diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 78c0bc88ef7..7d09f710828 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -388,12 +388,12 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): async def async_turn_off(self) -> None: """Turn off the zone.""" - await self.async_set_hvac_mode(OPERATION_MODE_OFF) + await self.async_set_hvac_mode(HVACMode.OFF) self._signal_zone_update() async def async_turn_on(self) -> None: """Turn on the zone.""" - await self.async_set_hvac_mode(OPERATION_MODE_AUTO) + await self.async_set_hvac_mode(HVACMode.AUTO) self._signal_zone_update() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py index 5af4ff52fbb..6d1f4af043b 100644 --- a/homeassistant/components/nexia/config_flow.py +++ b/homeassistant/components/nexia/config_flow.py @@ -91,7 +91,7 @@ class NexiaConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 209a618ec3d..9e328e8e58d 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -30,7 +30,7 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) _LOGGER = logging.getLogger(__name__) -NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] +type NextcloudConfigEntry = ConfigEntry[NextcloudDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: NextcloudConfigEntry) -> bool: diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index f76e8755734..f11611007c2 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -49,7 +49,7 @@ from .coordinator import ( NextDnsUpdateCoordinator, ) -NextDnsConfigEntry = ConfigEntry["NextDnsData"] +type NextDnsConfigEntry = ConfigEntry[NextDnsData] @dataclass diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 28fd50af2dc..4955bbb4cad 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -45,7 +45,7 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" else: return await self.async_step_profiles() diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index 83621c63789..ccb882509f6 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -54,7 +54,7 @@ class NFAndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): except ConnectError: _LOGGER.error("Error connecting to device at %s", host) return "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return "unknown" return None diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 746ed26687d..d933d5a5ab0 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,7 +113,12 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): self._coil_current = _get(climate.current) self._coil_setpoint_heat = _get(climate.setpoint_heat) - self._coil_setpoint_cool = _get(climate.setpoint_cool) + self._coil_setpoint_cool: Coil | None + try: + self._coil_setpoint_cool = _get(climate.setpoint_cool) + except CoilNotFoundException: + self._coil_setpoint_cool = None + self._attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT] self._coil_prio = _get(unit.prio) self._coil_mixing_valve_state = _get(climate.mixing_valve_state) if climate.active_accessory is None: @@ -147,8 +153,10 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): self._attr_hvac_mode = mode setpoint_heat = _get_float(self._coil_setpoint_heat) - setpoint_cool = _get_float(self._coil_setpoint_cool) - + if self._coil_setpoint_cool: + setpoint_cool = _get_float(self._coil_setpoint_cool) + else: + setpoint_cool = None if mode == HVACMode.HEAT_COOL: self._attr_target_temperature = None self._attr_target_temperature_low = setpoint_heat @@ -207,11 +215,16 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): self._coil_setpoint_heat, temperature ) elif hvac_mode == HVACMode.COOL: - await coordinator.async_write_coil( - self._coil_setpoint_cool, temperature - ) + if self._coil_setpoint_cool: + await coordinator.async_write_coil( + self._coil_setpoint_cool, temperature + ) + else: + raise ServiceValidationError( + f"{hvac_mode} mode not supported for {self.name}" + ) else: - raise ValueError( + raise ServiceValidationError( "'set_temperature' requires 'hvac_mode' when passing" " 'temperature' and 'hvac_mode' is not already set to" " 'heat' or 'cool'" @@ -220,7 +233,10 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): if (temperature := kwargs.get(ATTR_TARGET_TEMP_LOW)) is not None: await coordinator.async_write_coil(self._coil_setpoint_heat, temperature) - if (temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None: + if ( + self._coil_setpoint_cool + and (temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None + ): await coordinator.async_write_coil(self._coil_setpoint_cool, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -243,4 +259,6 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): ) await coordinator.async_write_coil(self._coil_use_room_sensor, "OFF") else: - raise ValueError(f"{hvac_mode} mode not supported for {self.name}") + raise ServiceValidationError( + f"{hvac_mode} mode not supported for {self.name}" + ) diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 913ebd6b00c..2d47d570f21 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -193,7 +193,7 @@ class NibeHeatPumpConfigFlow(ConfigFlow, domain=DOMAIN): except FieldError as exception: LOGGER.debug("Validation error %s", exception) errors[exception.field] = exception.error - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -219,7 +219,7 @@ class NibeHeatPumpConfigFlow(ConfigFlow, domain=DOMAIN): except FieldError as exception: LOGGER.exception("Validation error") errors[exception.field] = exception.error - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index fc212faee71..0f1fabe4249 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -7,7 +7,7 @@ from collections import defaultdict from collections.abc import Callable, Iterable from datetime import date, timedelta from functools import cached_property -from typing import Any, Generic, TypeVar +from typing import Any from nibe.coil import Coil, CoilData from nibe.connection import Connection @@ -26,13 +26,8 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN, LOGGER -_DataTypeT = TypeVar("_DataTypeT") -_ContextTypeT = TypeVar("_ContextTypeT") - -class ContextCoordinator( - Generic[_DataTypeT, _ContextTypeT], DataUpdateCoordinator[_DataTypeT] -): +class ContextCoordinator[_DataTypeT, _ContextTypeT](DataUpdateCoordinator[_DataTypeT]): """Update coordinator with context adjustments.""" @cached_property diff --git a/homeassistant/components/nightscout/config_flow.py b/homeassistant/components/nightscout/config_flow.py index 6d2a0e6c385..0c0e8b296cd 100644 --- a/homeassistant/components/nightscout/config_flow.py +++ b/homeassistant/components/nightscout/config_flow.py @@ -56,7 +56,7 @@ class NightscoutConfigFlow(ConfigFlow, domain=DOMAIN): info = await _validate_input(user_input) except InputValidationError as error: errors["base"] = error.base - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 6554bf5eeec..27a9cc22549 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -67,7 +67,7 @@ class NikoHomeControlLight(LightEntity): self._attr_is_on = light.is_on self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} - if light._state["type"] == 2: + if light._state["type"] == 2: # noqa: SLF001 self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 3b8b290d6c8..3a665bfe987 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -14,12 +14,9 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.core import callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get, -) from .const import ( _LOGGER, @@ -116,7 +113,7 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN): ) except ApiError: errors["base"] = "cannot_connect" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.exception("Unexpected exception: %s", err) return self.async_abort(reason="unknown") @@ -195,7 +192,7 @@ class OptionsFlowHandler(OptionsFlow): ) except ApiError: errors["base"] = "cannot_connect" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.exception("Unexpected exception: %s", err) return self.async_abort(reason="unknown") @@ -213,9 +210,9 @@ class OptionsFlowHandler(OptionsFlow): user_input, self._all_region_codes_sorted ) - entity_registry = async_get(self.hass) + entity_registry = er.async_get(self.hass) - entries = async_entries_for_config_entry( + entries = er.async_entries_for_config_entry( entity_registry, self.config_entry.entry_id ) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index f9d2ce2e3da..5b777205c8d 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ip=ip_address, discover=discover, synchronous=False, - timezone=dt_util.DEFAULT_TIME_ZONE, + timezone=dt_util.get_default_time_zone(), ) await hub.connect() diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index ce4f778993c..1fc7836ecd8 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -41,6 +41,7 @@ from .legacy import ( # noqa: F401 async_setup_legacy, check_templates_warn, ) +from .repairs import migrate_notify_issue # noqa: F401 # mypy: disallow-any-generics diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 2f6984e36f1..b3871d858e8 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -117,7 +117,7 @@ def async_setup_legacy( ) return - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Error setting up platform %s", integration_name) return diff --git a/homeassistant/components/notify/manifest.json b/homeassistant/components/notify/manifest.json index 1c48af7dfcc..62b69bb2df2 100644 --- a/homeassistant/components/notify/manifest.json +++ b/homeassistant/components/notify/manifest.json @@ -2,6 +2,7 @@ "domain": "notify", "name": "Notifications", "codeowners": ["@home-assistant/core"], + "dependencies": ["repairs"], "documentation": "https://www.home-assistant.io/integrations/notify", "integration_type": "entity", "quality_scale": "internal" diff --git a/homeassistant/components/notify/repairs.py b/homeassistant/components/notify/repairs.py new file mode 100644 index 00000000000..d188f07c2ed --- /dev/null +++ b/homeassistant/components/notify/repairs.py @@ -0,0 +1,64 @@ +"""Repairs support for notify integration.""" + +from __future__ import annotations + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs.issue_handler import ConfirmRepairFlow +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN + + +@callback +def migrate_notify_issue( + hass: HomeAssistant, + domain: str, + integration_title: str, + breaks_in_ha_version: str, + service_name: str | None = None, +) -> None: + """Ensure an issue is registered.""" + if service_name is not None: + ir.async_create_issue( + hass, + DOMAIN, + f"migrate_notify_{domain}_{service_name}", + breaks_in_ha_version=breaks_in_ha_version, + issue_domain=domain, + is_fixable=True, + is_persistent=True, + translation_key="migrate_notify_service", + translation_placeholders={ + "domain": domain, + "integration_title": integration_title, + "service_name": service_name, + }, + severity=ir.IssueSeverity.WARNING, + ) + return + ir.async_create_issue( + hass, + DOMAIN, + f"migrate_notify_{domain}", + breaks_in_ha_version=breaks_in_ha_version, + issue_domain=domain, + is_fixable=True, + is_persistent=True, + translation_key="migrate_notify", + translation_placeholders={ + "domain": domain, + "integration_title": integration_title, + }, + severity=ir.IssueSeverity.WARNING, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + assert issue_id.startswith("migrate_notify_") + return ConfirmRepairFlow() diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index f6ac8c848f1..947b192c4cd 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -60,5 +60,29 @@ } } } + }, + "issues": { + "migrate_notify": { + "title": "Migration of {integration_title} notify service", + "fix_flow": { + "step": { + "confirm": { + "description": "The {integration_title} `notify` service(s) are migrated. A new `notify` entity is available now to replace each legacy `notify` service.\n\nUpdate any automations to use the new `notify.send_message` service exposed with this new entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Migrate legacy {integration_title} notify service for domain `{domain}`" + } + } + } + }, + "migrate_notify_service": { + "title": "Legacy service `notify.{service_name}` stll being used", + "fix_flow": { + "step": { + "confirm": { + "description": "The {integration_title} `notify.{service_name}` service is migrated, but it seems the old `notify` service is still being used.\n\nA new `notify` entity is available now to replace each legacy `notify` service.\n\nUpdate any automations or scripts to use the new `notify.send_message` service exposed with this new entity. When this is done, select Submit and restart Home Assistant.", + "title": "Migrate legacy {integration_title} notify service for domain `{domain}`" + } + } + } + } } } diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 9a65f922fd9..c803992c2e2 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -51,7 +51,7 @@ async def async_validate_credentials( except NotionError as err: LOGGER.error("Unknown Notion error while validation credentials: %s", err) errors["base"] = "unknown" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.exception("Unknown error while validation credentials: %s", err) errors["base"] = "unknown" diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index c8accd6ab73..8eeee1f3f95 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to login to nuheat: %s", ex) return False raise ConfigEntryNotReady from ex - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 _LOGGER.error("Failed to login to nuheat: %s", ex) return False diff --git a/homeassistant/components/nuheat/config_flow.py b/homeassistant/components/nuheat/config_flow.py index a75b65abccd..a5d34f7ae6c 100644 --- a/homeassistant/components/nuheat/config_flow.py +++ b/homeassistant/components/nuheat/config_flow.py @@ -76,7 +76,7 @@ class NuHeatConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except InvalidThermostat: errors["base"] = "invalid_thermostat" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index cbd7af3ecec..2b9035e730f 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,12 +3,9 @@ from __future__ import annotations import asyncio -from collections import defaultdict from dataclasses import dataclass -from datetime import timedelta from http import HTTPStatus import logging -from typing import Generic, TypeVar from aiohttp import web from pynuki import NukiBridge, NukiLock, NukiOpener @@ -27,28 +24,18 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed -from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN, ERROR_STATES +from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN +from .coordinator import NukiCoordinator from .helpers import NukiWebhookException, parse_id -_NukiDeviceT = TypeVar("_NukiDeviceT", bound=NukiDevice) - _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] -UPDATE_INTERVAL = timedelta(seconds=30) @dataclass(slots=True) @@ -281,86 +268,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class NukiCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """Data Update Coordinator for the Nuki integration.""" - - def __init__(self, hass, bridge, locks, openers): - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name="nuki devices", - # Polling interval. Will only be polled if there are subscribers. - update_interval=UPDATE_INTERVAL, - ) - self.bridge = bridge - self.locks = locks - self.openers = openers - - @property - def bridge_id(self): - """Return the parsed id of the Nuki bridge.""" - return parse_id(self.bridge.info()["ids"]["hardwareId"]) - - async def _async_update_data(self) -> None: - """Fetch data from Nuki bridge.""" - try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(10): - events = await self.hass.async_add_executor_job( - self.update_devices, self.locks + self.openers - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - - ent_reg = er.async_get(self.hass) - for event, device_ids in events.items(): - for device_id in device_ids: - entity_id = ent_reg.async_get_entity_id( - Platform.LOCK, DOMAIN, device_id - ) - event_data = { - "entity_id": entity_id, - "type": event, - } - self.hass.bus.async_fire("nuki_event", event_data) - - def update_devices(self, devices: list[NukiDevice]) -> dict[str, set[str]]: - """Update the Nuki devices. - - Returns: - A dict with the events to be fired. The event type is the key and the device ids are the value - - """ - - events: dict[str, set[str]] = defaultdict(set) - - for device in devices: - for level in (False, True): - try: - if isinstance(device, NukiOpener): - last_ring_action_state = device.ring_action_state - - device.update(level) - - if not last_ring_action_state and device.ring_action_state: - events["ring"].add(device.nuki_id) - else: - device.update(level) - except RequestException: - continue - - if device.state not in ERROR_STATES: - break - - return events - - -class NukiEntity(CoordinatorEntity[NukiCoordinator], Generic[_NukiDeviceT]): +class NukiEntity[_NukiDeviceT: NukiDevice](CoordinatorEntity[NukiCoordinator]): """An entity using CoordinatorEntity. The CoordinatorEntity class provides: diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 4a3e96f68a5..286395e1ff3 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -118,7 +118,7 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -156,7 +156,7 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/nuki/coordinator.py b/homeassistant/components/nuki/coordinator.py new file mode 100644 index 00000000000..114b4aee4c9 --- /dev/null +++ b/homeassistant/components/nuki/coordinator.py @@ -0,0 +1,110 @@ +"""Coordinator for the nuki component.""" + +from __future__ import annotations + +import asyncio +from collections import defaultdict +from datetime import timedelta +import logging + +from pynuki import NukiBridge, NukiLock, NukiOpener +from pynuki.bridge import InvalidCredentialsException +from pynuki.device import NukiDevice +from requests.exceptions import RequestException + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, ERROR_STATES +from .helpers import parse_id + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=30) + + +class NukiCoordinator(DataUpdateCoordinator[None]): + """Data Update Coordinator for the Nuki integration.""" + + def __init__( + self, + hass: HomeAssistant, + bridge: NukiBridge, + locks: list[NukiLock], + openers: list[NukiOpener], + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="nuki devices", + # Polling interval. Will only be polled if there are subscribers. + update_interval=UPDATE_INTERVAL, + ) + self.bridge = bridge + self.locks = locks + self.openers = openers + + @property + def bridge_id(self): + """Return the parsed id of the Nuki bridge.""" + return parse_id(self.bridge.info()["ids"]["hardwareId"]) + + async def _async_update_data(self) -> None: + """Fetch data from Nuki bridge.""" + try: + # Note: TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with asyncio.timeout(10): + events = await self.hass.async_add_executor_job( + self.update_devices, self.locks + self.openers + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + + ent_reg = er.async_get(self.hass) + for event, device_ids in events.items(): + for device_id in device_ids: + entity_id = ent_reg.async_get_entity_id( + Platform.LOCK, DOMAIN, device_id + ) + event_data = { + "entity_id": entity_id, + "type": event, + } + self.hass.bus.async_fire("nuki_event", event_data) + + def update_devices(self, devices: list[NukiDevice]) -> dict[str, set[str]]: + """Update the Nuki devices. + + Returns: + A dict with the events to be fired. The event type is the key and the device ids are the value + + """ + + events: dict[str, set[str]] = defaultdict(set) + + for device in devices: + for level in (False, True): + try: + if isinstance(device, NukiOpener): + last_ring_action_state = device.ring_action_state + + device.update(level) + + if not last_ring_action_state and device.ring_action_state: + events["ring"].add(device.nuki_id) + else: + device.update(level) + except RequestException: + continue + + if device.state not in ERROR_STATES: + break + + return events diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index d63bfaf6757..5a8734d5df7 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any, TypeVar +from typing import Any from pynuki import NukiLock, NukiOpener from pynuki.constants import MODE_OPENER_CONTINUOUS @@ -28,8 +28,6 @@ from .const import ( ) from .helpers import CannotConnect -_NukiDeviceT = TypeVar("_NukiDeviceT", bound=NukiDevice) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -64,7 +62,7 @@ async def async_setup_entry( ) -class NukiDeviceEntity(NukiEntity[_NukiDeviceT], LockEntity): +class NukiDeviceEntity[_NukiDeviceT: NukiDevice](NukiEntity[_NukiDeviceT], LockEntity): """Representation of a Nuki device.""" _attr_has_entity_name = True diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index e5b307f5e57..77dde242b7e 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -15,8 +15,13 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature -from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + async_get_hass_or_none, + callback, +) +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -213,10 +218,9 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): "value", ) ): - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() - report_issue = async_suggest_report_issue(hass, module=cls.__module__) + report_issue = async_suggest_report_issue( + async_get_hass_or_none(), module=cls.__module__ + ) _LOGGER.warning( ( "%s::%s is overriding deprecated methods on an instance of " diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index f279ffb72a8..6343c3a599f 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -18,6 +18,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -120,6 +121,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `ppm` (parts per million) """ + CONDUCTIVITY = "conductivity" + """Conductivity. + + Unit of measurement: `S/cm`, `mS/cm`, `µS/cm` + """ + CURRENT = "current" """Current. @@ -424,6 +431,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.BATTERY: {PERCENTAGE}, NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, + NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), NumberDeviceClass.CURRENT: set(UnitOfElectricCurrent), NumberDeviceClass.DATA_RATE: set(UnitOfDataRate), NumberDeviceClass.DATA_SIZE: set(UnitOfInformation), diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 502b2b4affd..d6932286469 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -3,6 +3,9 @@ "device_automation": { "action_type": { "set_value": "Set value for {entity_name}" + }, + "extra_fields": { + "value": "[%key:common::device_automation::extra_fields::value%]" } }, "entity_component": { diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 640dbb1416a..3825db92983 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -36,7 +36,7 @@ NUT_FAKE_SERIAL = ["unknown", "blank"] _LOGGER = logging.getLogger(__name__) -NutConfigEntry = ConfigEntry["NutRuntimeData"] +type NutConfigEntry = ConfigEntry[NutRuntimeData] @dataclass diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index f0126ba4894..d0a2da124a6 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -183,7 +183,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders["error"] = str(ex) except AbortFlow: raise - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors[CONF_BASE] = "unknown" return info, errors, description_placeholders diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 840d4d917f7..2e643d7dbc6 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass import datetime import logging -from pynws import SimpleNWS, call_with_retry +from pynws import NwsNoDataError, SimpleNWS, call_with_retry from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform @@ -14,20 +15,26 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator -from homeassistant.util.dt import utcnow +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) -from .const import CONF_STATION, DOMAIN, UPDATE_TIME_PERIOD +from .const import ( + CONF_STATION, + DEBOUNCE_TIME, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + RETRY_INTERVAL, + RETRY_STOP, +) +from .coordinator import NWSObservationDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=10) -RETRY_INTERVAL = datetime.timedelta(minutes=1) -RETRY_STOP = datetime.timedelta(minutes=10) - -DEBOUNCE_TIME = 10 * 60 # in seconds +type NWSConfigEntry = ConfigEntry[NWSData] def base_unique_id(latitude: float, longitude: float) -> str: @@ -40,12 +47,12 @@ class NWSData: """Data for the National Weather Service integration.""" api: SimpleNWS - coordinator_observation: TimestampDataUpdateCoordinator[None] + coordinator_observation: NWSObservationDataUpdateCoordinator coordinator_forecast: TimestampDataUpdateCoordinator[None] coordinator_forecast_hourly: TimestampDataUpdateCoordinator[None] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool: """Set up a National Weather Service entry.""" latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] @@ -58,47 +65,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nws_data = SimpleNWS(latitude, longitude, api_key, client_session) await nws_data.set_station(station) - async def update_observation() -> None: - """Retrieve recent observations.""" - await call_with_retry( - nws_data.update_observation, - RETRY_INTERVAL, - RETRY_STOP, - start_time=utcnow() - UPDATE_TIME_PERIOD, - ) + def async_setup_update_forecast( + retry_interval: datetime.timedelta | float, + retry_stop: datetime.timedelta | float, + ) -> Callable[[], Awaitable[None]]: + async def update_forecast() -> None: + """Retrieve forecast.""" + try: + await call_with_retry( + nws_data.update_forecast, + retry_interval, + retry_stop, + retry_no_data=True, + ) + except NwsNoDataError as err: + raise UpdateFailed("No data returned.") from err - async def update_forecast() -> None: - """Retrieve twice-daily forecsat.""" - await call_with_retry( - nws_data.update_forecast, - RETRY_INTERVAL, - RETRY_STOP, - ) + return update_forecast - async def update_forecast_hourly() -> None: - """Retrieve hourly forecast.""" - await call_with_retry( - nws_data.update_forecast_hourly, - RETRY_INTERVAL, - RETRY_STOP, - ) + def async_setup_update_forecast_hourly( + retry_interval: datetime.timedelta | float, + retry_stop: datetime.timedelta | float, + ) -> Callable[[], Awaitable[None]]: + async def update_forecast_hourly() -> None: + """Retrieve forecast hourly.""" + try: + await call_with_retry( + nws_data.update_forecast_hourly, + retry_interval, + retry_stop, + retry_no_data=True, + ) + except NwsNoDataError as err: + raise UpdateFailed("No data returned.") from err - coordinator_observation = TimestampDataUpdateCoordinator( + return update_forecast_hourly + + coordinator_observation = NWSObservationDataUpdateCoordinator( hass, - _LOGGER, - name=f"NWS observation station {station}", - update_method=update_observation, - update_interval=DEFAULT_SCAN_INTERVAL, - request_refresh_debouncer=debounce.Debouncer( - hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True - ), + nws_data, ) + # Don't use retries in setup coordinator_forecast = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"NWS forecast station {station}", - update_method=update_forecast, + update_method=async_setup_update_forecast(0, 0), update_interval=DEFAULT_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True @@ -109,14 +122,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, _LOGGER, name=f"NWS forecast hourly station {station}", - update_method=update_forecast_hourly, + update_method=async_setup_update_forecast_hourly(0, 0), update_interval=DEFAULT_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True ), ) - nws_hass_data = hass.data.setdefault(DOMAIN, {}) - nws_hass_data[entry.entry_id] = NWSData( + entry.runtime_data = NWSData( nws_data, coordinator_observation, coordinator_forecast, @@ -128,19 +140,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator_forecast.async_refresh() await coordinator_forecast_hourly.async_refresh() + # Use retries + coordinator_forecast.update_method = async_setup_update_forecast( + RETRY_INTERVAL, RETRY_STOP + ) + coordinator_forecast_hourly.update_method = async_setup_update_forecast_hourly( + RETRY_INTERVAL, RETRY_STOP + ) + 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, entry: NWSConfigEntry) -> 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) - if len(hass.data[DOMAIN]) == 0: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def device_info(latitude: float, longitude: float) -> DeviceInfo: diff --git a/homeassistant/components/nws/config_flow.py b/homeassistant/components/nws/config_flow.py index 37d5bb5bf82..22a4adf3d85 100644 --- a/homeassistant/components/nws/config_flow.py +++ b/homeassistant/components/nws/config_flow.py @@ -66,7 +66,7 @@ class NWSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=info["title"], data=user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 3de874b5c10..ba3a22e5818 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -76,7 +76,12 @@ CONDITION_CLASSES: dict[str, list[str]] = { DAYNIGHT = "daynight" HOURLY = "hourly" -OBSERVATION_VALID_TIME = timedelta(minutes=20) +OBSERVATION_VALID_TIME = timedelta(minutes=60) FORECAST_VALID_TIME = timedelta(minutes=45) # A lot of stations update once hourly plus some wiggle room UPDATE_TIME_PERIOD = timedelta(minutes=70) + +DEBOUNCE_TIME = 10 * 60 # in seconds +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) +RETRY_INTERVAL = timedelta(minutes=1) +RETRY_STOP = timedelta(minutes=10) diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py new file mode 100644 index 00000000000..104b1812c67 --- /dev/null +++ b/homeassistant/components/nws/coordinator.py @@ -0,0 +1,93 @@ +"""The NWS coordinator.""" + +from datetime import datetime +import logging + +from aiohttp import ClientResponseError +from pynws import NwsNoDataError, SimpleNWS, call_with_retry + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import debounce +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.util.dt import utcnow + +from .const import ( + DEBOUNCE_TIME, + DEFAULT_SCAN_INTERVAL, + OBSERVATION_VALID_TIME, + RETRY_INTERVAL, + RETRY_STOP, + UPDATE_TIME_PERIOD, +) + +_LOGGER = logging.getLogger(__name__) + + +class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): + """Class to manage fetching NWS observation data.""" + + def __init__( + self, + hass: HomeAssistant, + nws: SimpleNWS, + ) -> None: + """Initialize.""" + self.nws = nws + self.last_api_success_time: datetime | None = None + self.initialized: bool = False + + super().__init__( + hass, + _LOGGER, + name=f"NWS observation station {nws.station}", + update_interval=DEFAULT_SCAN_INTERVAL, + request_refresh_debouncer=debounce.Debouncer( + hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True + ), + ) + + async def _async_update_data(self) -> None: + """Update data via library.""" + if not self.initialized: + await self._async_first_update_data() + else: + await self._async_subsequent_update_data() + + async def _async_first_update_data(self): + """Update data without retries first.""" + try: + await self.nws.update_observation( + raise_no_data=True, + start_time=utcnow() - UPDATE_TIME_PERIOD, + ) + except (NwsNoDataError, ClientResponseError) as err: + raise UpdateFailed(err) from err + else: + self.last_api_success_time = utcnow() + finally: + self.initialized = True + + async def _async_subsequent_update_data(self) -> None: + """Update data with retries and caching data over multiple failed rounds.""" + try: + await call_with_retry( + self.nws.update_observation, + RETRY_INTERVAL, + RETRY_STOP, + retry_no_data=True, + start_time=utcnow() - UPDATE_TIME_PERIOD, + ) + except (NwsNoDataError, ClientResponseError) as err: + if not self.last_api_success_time or ( + utcnow() - self.last_api_success_time > OBSERVATION_VALID_TIME + ): + raise UpdateFailed(err) from err + _LOGGER.debug( + "NWS observation update failed, but data still valid. Last success: %s", + self.last_api_success_time, + ) + else: + self.last_api_success_time = utcnow() diff --git a/homeassistant/components/nws/diagnostics.py b/homeassistant/components/nws/diagnostics.py new file mode 100644 index 00000000000..230991d04df --- /dev/null +++ b/homeassistant/components/nws/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics support for NWS.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from . import NWSConfigEntry +from .const import CONF_STATION + +CONFIG_TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATION} +OBSERVATION_TO_REDACT = {"station"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: NWSConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + nws_data = config_entry.runtime_data.api + + return { + "info": async_redact_data(config_entry.data, CONFIG_TO_REDACT), + "observation": async_redact_data(nws_data.observation, OBSERVATION_TO_REDACT), + "forecast": nws_data.forecast, + "forecast_hourly": nws_data.forecast_hourly, + } diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index f68d76ee95b..d11a0e62bcf 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws[retry]==1.7.0"] + "requirements": ["pynws[retry]==1.8.2"] } diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 447c2dc5cf8..872e1588244 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -29,7 +28,6 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, TimestampDataUpdateCoordinator, ) -from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import ( DistanceConverter, PressureConverter, @@ -37,8 +35,8 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import NWSData, base_unique_id, device_info -from .const import ATTRIBUTION, CONF_STATION, DOMAIN, OBSERVATION_VALID_TIME +from . import NWSConfigEntry, NWSData, base_unique_id, device_info +from .const import ATTRIBUTION, CONF_STATION PARALLEL_UPDATES = 0 @@ -143,10 +141,10 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" - nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] + nws_data = entry.runtime_data station = entry.data[CONF_STATION] async_add_entities( @@ -226,15 +224,3 @@ class NWSSensor(CoordinatorEntity[TimestampDataUpdateCoordinator[None]], SensorE if unit_of_measurement == PERCENTAGE: return round(value) return value - - @property - def available(self) -> bool: - """Return if state is available.""" - if self.coordinator.last_update_success_time: - last_success_time = ( - utcnow() - self.coordinator.last_update_success_time - < OBSERVATION_VALID_TIME - ) - else: - last_success_time = False - return self.coordinator.last_update_success or last_success_time diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index f25998f1504..9ae1f9f7ff9 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import partial from types import MappingProxyType -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -23,7 +23,6 @@ from homeassistant.components.weather import ( Forecast, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, @@ -38,7 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter -from . import NWSData, base_unique_id, device_info +from . import NWSConfigEntry, NWSData, base_unique_id, device_info from .const import ( ATTR_FORECAST_DETAILED_DESCRIPTION, ATTRIBUTION, @@ -79,11 +78,11 @@ def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) -> async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" entity_registry = er.async_get(hass) - nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] + nws_data = entry.runtime_data # Remove hourly entity from legacy config entries if entity_id := entity_registry.async_get_entity_id( @@ -157,6 +156,8 @@ class NWSWeather(CoordinatorWeatherEntity[TimestampDataUpdateCoordinator[None]]) for forecast_type in ("twice_daily", "hourly"): if (coordinator := self.forecast_coordinators[forecast_type]) is None: continue + if TYPE_CHECKING: + forecast_type = cast(Literal["twice_daily", "hourly"], forecast_type) self.unsub_forecast[forecast_type] = coordinator.async_add_listener( partial(self._handle_forecast_update, forecast_type) ) diff --git a/homeassistant/components/nx584/alarm_control_panel.py b/homeassistant/components/nx584/alarm_control_panel.py index d29ac0388ca..2e306de5908 100644 --- a/homeassistant/components/nx584/alarm_control_panel.py +++ b/homeassistant/components/nx584/alarm_control_panel.py @@ -9,10 +9,11 @@ from nx584 import client import requests import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + AlarmControlPanelEntity, AlarmControlPanelEntityFeature, + CodeFormat, ) from homeassistant.const import ( CONF_HOST, @@ -90,15 +91,16 @@ async def async_setup_platform( ) -class NX584Alarm(alarm.AlarmControlPanelEntity): +class NX584Alarm(AlarmControlPanelEntity): """Representation of a NX584-based alarm panel.""" - _attr_code_format = alarm.CodeFormat.NUMBER + _attr_code_format = CodeFormat.NUMBER _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_code_arm_required = False def __init__(self, name: str, alarm_client: client.Client, url: str) -> None: """Init the nx584 alarm panel.""" diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 627051a4d65..429b517fce4 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -134,8 +134,7 @@ class NX584Watcher(threading.Thread): zone = event["zone"] if not (zone_sensor := self._zone_sensors.get(zone)): return - # pylint: disable-next=protected-access - zone_sensor._zone["state"] = event["zone_state"] + zone_sensor._zone["state"] = event["zone_state"] # noqa: SLF001 zone_sensor.schedule_update_ha_state() def _process_events(self, events): diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index 2c549e4ed24..47d35f32f9f 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -63,7 +63,7 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job(_validate_input, user_input) except NZBGetAPIException: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index f99a151292d..32f5fa88fff 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -82,7 +82,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): raise err from None except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" if errors: @@ -120,7 +120,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): except OctoprintException: _LOGGER.exception("Failed to get an application key") return self.async_show_progress_done(next_step_id="auth_failed") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Failed to get an application key") return self.async_show_progress_done(next_step_id="auth_failed") finally: diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index e192aeb1fca..48904d53413 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -93,7 +93,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): } except (TimeoutError, httpx.ConnectError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index cbec719780a..fa7a3c3797e 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -9,6 +9,7 @@ from typing import Literal import ollama from homeassistant.components import assist_pipeline, conversation +from homeassistant.components.conversation import trace from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL @@ -138,6 +139,11 @@ class OllamaConversationEntity( ollama.Message(role=MessageRole.USER.value, content=user_input.text) ) + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + {"messages": message_history.messages}, + ) + # Get response try: response = await client.chat( diff --git a/homeassistant/components/omnilogic/__init__.py b/homeassistant/components/omnilogic/__init__.py index d9966290986..19dffc1a051 100644 --- a/homeassistant/components/omnilogic/__init__.py +++ b/homeassistant/components/omnilogic/__init__.py @@ -10,7 +10,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .common import OmniLogicUpdateCoordinator from .const import ( CONF_SCAN_INTERVAL, COORDINATOR, @@ -18,6 +17,7 @@ from .const import ( DOMAIN, OMNI_API, ) +from .coordinator import OmniLogicUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index 0484c889ba3..13b9803409c 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -1,75 +1,12 @@ """Common classes and elements for Omnilogic Integration.""" -from datetime import timedelta -import logging from typing import Any -from omnilogic import OmniLogic, OmniLogicException - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ALL_ITEM_KINDS, DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching update data from single endpoint.""" - - def __init__( - self, - hass: HomeAssistant, - api: OmniLogic, - name: str, - config_entry: ConfigEntry, - polling_interval: int, - ) -> None: - """Initialize the global Omnilogic data updater.""" - self.api = api - self.config_entry = config_entry - - super().__init__( - hass=hass, - logger=_LOGGER, - name=name, - update_interval=timedelta(seconds=polling_interval), - ) - - async def _async_update_data(self): - """Fetch data from OmniLogic.""" - try: - data = await self.api.get_telemetry_data() - - except OmniLogicException as error: - raise UpdateFailed(f"Error updating from OmniLogic: {error}") from error - - parsed_data = {} - - def get_item_data(item, item_kind, current_id, data): - """Get data per kind of Omnilogic API item.""" - if isinstance(item, list): - for single_item in item: - data = get_item_data(single_item, item_kind, current_id, data) - - if "systemId" in item: - system_id = item["systemId"] - current_id = (*current_id, item_kind, system_id) - data[current_id] = item - - for kind in ALL_ITEM_KINDS: - if kind in item: - data = get_item_data(item[kind], kind, current_id, data) - - return data - - return get_item_data(data, "Backyard", (), parsed_data) +from .const import DOMAIN +from .coordinator import OmniLogicUpdateCoordinator class OmniLogicEntity(CoordinatorEntity[OmniLogicUpdateCoordinator]): diff --git a/homeassistant/components/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py index 3f3acc3c100..229f458ceb4 100644 --- a/homeassistant/components/omnilogic/config_flow.py +++ b/homeassistant/components/omnilogic/config_flow.py @@ -53,7 +53,7 @@ class OmniLogicConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except OmniLogicException: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/omnilogic/coordinator.py b/homeassistant/components/omnilogic/coordinator.py new file mode 100644 index 00000000000..72d16f03328 --- /dev/null +++ b/homeassistant/components/omnilogic/coordinator.py @@ -0,0 +1,67 @@ +"""Coordinator for the Omnilogic Integration.""" + +from datetime import timedelta +import logging +from typing import Any + +from omnilogic import OmniLogic, OmniLogicException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ALL_ITEM_KINDS + +_LOGGER = logging.getLogger(__name__) + + +class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): + """Class to manage fetching update data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + api: OmniLogic, + name: str, + config_entry: ConfigEntry, + polling_interval: int, + ) -> None: + """Initialize the global Omnilogic data updater.""" + self.api = api + self.config_entry = config_entry + + super().__init__( + hass=hass, + logger=_LOGGER, + name=name, + update_interval=timedelta(seconds=polling_interval), + ) + + async def _async_update_data(self): + """Fetch data from OmniLogic.""" + try: + data = await self.api.get_telemetry_data() + + except OmniLogicException as error: + raise UpdateFailed(f"Error updating from OmniLogic: {error}") from error + + parsed_data = {} + + def get_item_data(item, item_kind, current_id, data): + """Get data per kind of Omnilogic API item.""" + if isinstance(item, list): + for single_item in item: + data = get_item_data(single_item, item_kind, current_id, data) + + if "systemId" in item: + system_id = item["systemId"] + current_id = (*current_id, item_kind, system_id) + data[current_id] = item + + for kind in ALL_ITEM_KINDS: + if kind in item: + data = get_item_data(item[kind], kind, current_id, data) + + return data + + return get_item_data(data, "Backyard", (), parsed_data) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 5eb5a5dd0c4..9def0d9825e 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -15,8 +15,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import OmniLogicEntity, OmniLogicUpdateCoordinator, check_guard +from .common import OmniLogicEntity, check_guard from .const import COORDINATOR, DEFAULT_PH_OFFSET, DOMAIN, PUMP_TYPES +from .coordinator import OmniLogicUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py index 9bdc59a14c8..388099f92e9 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -12,8 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import OmniLogicEntity, OmniLogicUpdateCoordinator, check_guard +from .common import OmniLogicEntity, check_guard from .const import COORDINATOR, DOMAIN, PUMP_TYPES +from .coordinator import OmniLogicUpdateCoordinator SERVICE_SET_SPEED = "set_pump_speed" OMNILOGIC_SWITCH_OFF = 7 diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py index e423ba08105..92cd037734e 100644 --- a/homeassistant/components/oncue/config_flow.py +++ b/homeassistant/components/oncue/config_flow.py @@ -71,7 +71,7 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except LoginFailedException: errors[CONF_PASSWORD] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return errors diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index aa541c470f1..fb78035c630 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -5,7 +5,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from . import api, config_flow +from .api import OndiloClient +from .config_flow import OndiloIcoOAuth2FlowHandler from .const import DOMAIN from .coordinator import OndiloIcoCoordinator from .oauth_impl import OndiloOauth2Implementation @@ -16,7 +17,7 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ondilo ICO from a config entry.""" - config_flow.OAuth2FlowHandler.async_register_implementation( + OndiloIcoOAuth2FlowHandler.async_register_implementation( hass, OndiloOauth2Implementation(hass), ) @@ -27,9 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - coordinator = OndiloIcoCoordinator( - hass, api.OndiloClient(hass, entry, implementation) - ) + coordinator = OndiloIcoCoordinator(hass, OndiloClient(hass, entry, implementation)) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ondilo_ico/api.py b/homeassistant/components/ondilo_ico/api.py index 621750c2f58..f6ab0baa576 100644 --- a/homeassistant/components/ondilo_ico/api.py +++ b/homeassistant/components/ondilo_ico/api.py @@ -2,7 +2,6 @@ from asyncio import run_coroutine_threadsafe import logging -from typing import Any from ondilo import Ondilo @@ -36,17 +35,3 @@ class OndiloClient(Ondilo): ).result() return self.session.token - - def get_all_pools_data(self) -> list[dict[str, Any]]: - """Fetch pools and add pool details and last measures to pool data.""" - - pools = self.get_pools() - for pool in pools: - _LOGGER.debug( - "Retrieving data for pool/spa: %s, id: %d", pool["name"], pool["id"] - ) - pool["ICO"] = self.get_ICO_details(pool["id"]) - pool["sensors"] = self.get_last_pool_measures(pool["id"]) - _LOGGER.debug("Retrieved the following sensors data: %s", pool["sensors"]) - - return pools diff --git a/homeassistant/components/ondilo_ico/config_flow.py b/homeassistant/components/ondilo_ico/config_flow.py index 5a0fe8c21a5..d65c1b15e2a 100644 --- a/homeassistant/components/ondilo_ico/config_flow.py +++ b/homeassistant/components/ondilo_ico/config_flow.py @@ -1,21 +1,23 @@ """Config flow for Ondilo ICO.""" import logging +from typing import Any -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import DOMAIN from .oauth_impl import OndiloOauth2Implementation -class OAuth2FlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN -): +class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Ondilo ICO OAuth2 authentication.""" DOMAIN = DOMAIN - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" await self.async_set_unique_id(DOMAIN) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index d3e9b4a4e11..9a98ce0037e 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -1,5 +1,6 @@ """Define an object to coordinate fetching Ondilo ICO data.""" +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -15,7 +16,16 @@ from .api import OndiloClient _LOGGER = logging.getLogger(__name__) -class OndiloIcoCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): +@dataclass +class OndiloIcoData: + """Class for storing the data.""" + + ico: dict[str, Any] + pool: dict[str, Any] + sensors: dict[str, Any] + + +class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): """Class to manage fetching Ondilo ICO data from API.""" def __init__(self, hass: HomeAssistant, api: OndiloClient) -> None: @@ -24,14 +34,41 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): hass, logger=_LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=5), + update_interval=timedelta(minutes=20), ) self.api = api - async def _async_update_data(self) -> list[dict[str, Any]]: + async def _async_update_data(self) -> dict[str, OndiloIcoData]: """Fetch data from API endpoint.""" try: - return await self.hass.async_add_executor_job(self.api.get_all_pools_data) + return await self.hass.async_add_executor_job(self._update_data) except OndiloError as err: + _LOGGER.exception("Error getting pools") raise UpdateFailed(f"Error communicating with API: {err}") from err + + def _update_data(self) -> dict[str, OndiloIcoData]: + """Fetch data from API endpoint.""" + res = {} + pools = self.api.get_pools() + _LOGGER.debug("Pools: %s", pools) + for pool in pools: + try: + ico = self.api.get_ICO_details(pool["id"]) + if not ico: + _LOGGER.debug( + "The pool id %s does not have any ICO attached", pool["id"] + ) + continue + sensors = self.api.get_last_pool_measures(pool["id"]) + except OndiloError: + _LOGGER.exception("Error communicating with API for %s", pool["id"]) + continue + res[pool["id"]] = OndiloIcoData( + ico=ico, + pool=pool, + sensors={sensor["data_type"]: sensor["value"] for sensor in sensors}, + ) + if not res: + raise UpdateFailed("No data available") + return res diff --git a/homeassistant/components/ondilo_ico/icons.json b/homeassistant/components/ondilo_ico/icons.json index 9319b747b28..20ef842ed4d 100644 --- a/homeassistant/components/ondilo_ico/icons.json +++ b/homeassistant/components/ondilo_ico/icons.json @@ -4,9 +4,6 @@ "oxydo_reduction_potential": { "default": "mdi:pool" }, - "ph": { - "default": "mdi:pool" - }, "tds": { "default": "mdi:pool" }, diff --git a/homeassistant/components/ondilo_ico/manifest.json b/homeassistant/components/ondilo_ico/manifest.json index 1d41eb04d86..2f522f1b77c 100644 --- a/homeassistant/components/ondilo_ico/manifest.json +++ b/homeassistant/components/ondilo_ico/manifest.json @@ -5,7 +5,8 @@ "config_flow": true, "dependencies": ["auth"], "documentation": "https://www.home-assistant.io/integrations/ondilo_ico", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["ondilo"], - "requirements": ["ondilo==0.4.0"] + "requirements": ["ondilo==0.5.0"] } diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 5f21fb6a909..66b07335663 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -18,10 +18,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OndiloIcoCoordinator +from .coordinator import OndiloIcoCoordinator, OndiloIcoData SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -38,7 +39,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="ph", - translation_key="ph", + device_class=SensorDeviceClass.PH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( @@ -76,11 +77,10 @@ async def async_setup_entry( coordinator: OndiloIcoCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - OndiloICO(coordinator, poolidx, description) - for poolidx, pool in enumerate(coordinator.data) - for sensor in pool["sensors"] + OndiloICO(coordinator, pool_id, description) + for pool_id, pool in coordinator.data.items() for description in SENSOR_TYPES - if description.key == sensor["data_type"] + if description.key in pool.sensors ) @@ -92,44 +92,31 @@ class OndiloICO(CoordinatorEntity[OndiloIcoCoordinator], SensorEntity): def __init__( self, coordinator: OndiloIcoCoordinator, - poolidx: int, + pool_id: str, description: SensorEntityDescription, ) -> None: """Initialize sensor entity with data from coordinator.""" super().__init__(coordinator) self.entity_description = description - self._poolid = self.coordinator.data[poolidx]["id"] + self._pool_id = pool_id - pooldata = self._pooldata() - self._attr_unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" + data = self.pool_data + self._attr_unique_id = f"{data.ico['serial_number']}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, pooldata["ICO"]["serial_number"])}, + identifiers={(DOMAIN, data.ico["serial_number"])}, manufacturer="Ondilo", model="ICO", - name=pooldata["name"], - sw_version=pooldata["ICO"]["sw_version"], - ) - - def _pooldata(self): - """Get pool data dict.""" - return next( - (pool for pool in self.coordinator.data if pool["id"] == self._poolid), - None, - ) - - def _devdata(self): - """Get device data dict.""" - return next( - ( - data_type - for data_type in self._pooldata()["sensors"] - if data_type["data_type"] == self.entity_description.key - ), - None, + name=data.pool["name"], + sw_version=data.ico["sw_version"], ) @property - def native_value(self): + def pool_data(self) -> OndiloIcoData: + """Get pool data.""" + return self.coordinator.data[self._pool_id] + + @property + def native_value(self) -> StateType: """Last value of the sensor.""" - return self._devdata()["value"] + return self.pool_data.sensors[self.entity_description.key] diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json index 26199b1bd75..360c0b124a7 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -22,9 +22,6 @@ "oxydo_reduction_potential": { "name": "Oxydo reduction potential" }, - "ph": { - "name": "pH" - }, "tds": { "name": "TDS" }, diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 73f3374ba97..3c4aac2cd7d 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -13,7 +13,7 @@ from .const import DOMAIN, PLATFORMS from .onewirehub import CannotConnect, OneWireHub _LOGGER = logging.getLogger(__name__) -OneWireConfigEntry = ConfigEntry[OneWireHub] +type OneWireConfigEntry = ConfigEntry[OneWireHub] async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool: diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 7575443c793..97e0b3e3631 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -341,7 +341,7 @@ class OnkyoDevice(MediaPlayerEntity): del self._attr_extra_state_attributes[ATTR_PRESET] self._attr_is_volume_muted = bool(mute_raw[1] == "on") - # AMP_VOL/MAX_RECEIVER_VOL*(MAX_VOL/100) + # AMP_VOL / (MAX_RECEIVER_VOL * (MAX_VOL / 100)) self._attr_volume_level = volume_raw[1] / ( self._receiver_max_volume * self._max_volume / 100 ) @@ -511,9 +511,9 @@ class OnkyoDeviceZone(OnkyoDevice): elif ATTR_PRESET in self._attr_extra_state_attributes: del self._attr_extra_state_attributes[ATTR_PRESET] if self._supports_volume: - # AMP_VOL/MAX_RECEIVER_VOL*(MAX_VOL/100) - self._attr_volume_level = ( - volume_raw[1] / self._receiver_max_volume * (self._max_volume / 100) + # AMP_VOL / (MAX_RECEIVER_VOL * (MAX_VOL / 100)) + self._attr_volume_level = volume_raw[1] / ( + self._receiver_max_volume * self._max_volume / 100 ) @property diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 5bd81f2bdea..36ae0e1bf18 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -67,7 +67,7 @@ def wsdiscovery() -> list[Service]: finally: discovery.stop() # Stop the threads started by WSDiscovery since otherwise there is a leak. - discovery._stopThreads() # pylint: disable=protected-access + discovery._stopThreads() # noqa: SLF001 async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index b427cbda2f8..f51b1b74686 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -251,13 +251,13 @@ class ONVIFDevice: LOGGER.debug("%s: Device time: %s", self.name, device_time) - tzone = dt_util.DEFAULT_TIME_ZONE + tzone = dt_util.get_default_time_zone() cdate = device_time.LocalDateTime if device_time.UTCDateTime: tzone = dt_util.UTC cdate = device_time.UTCDateTime elif device_time.TimeZone: - tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone + tzone = await dt_util.async_get_time_zone(device_time.TimeZone.TZ) or tzone if cdate is None: LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 9dcdba628e0..a8f1b7f702d 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -160,7 +160,7 @@ class EventManager: # # Our parser expects the topic to be # tns1:RuleEngine/CellMotionDetector/Motion - topic = msg.Topic._value_1.rstrip("/.") # pylint: disable=protected-access + topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001 if not (parser := PARSERS.get(topic)): if topic not in UNHANDLED_TOPICS: diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 29da0fee35f..c67cdceed54 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -23,7 +23,7 @@ VIDEO_SOURCE_MAPPING = { def extract_message(msg: Any) -> tuple[str, Any]: """Extract the message content and the topic.""" - return msg.Topic._value_1, msg.Message._value_1 # pylint: disable=protected-access + return msg.Topic._value_1, msg.Message._value_1 # noqa: SLF001 def _normalize_video_source(source: str) -> str: diff --git a/homeassistant/components/open_meteo/const.py b/homeassistant/components/open_meteo/const.py index e83fad9d59f..09ceba06b62 100644 --- a/homeassistant/components/open_meteo/const.py +++ b/homeassistant/components/open_meteo/const.py @@ -31,7 +31,7 @@ WMO_TO_HA_CONDITION_MAP = { 2: ATTR_CONDITION_PARTLYCLOUDY, # Partly cloudy 3: ATTR_CONDITION_CLOUDY, # Overcast 45: ATTR_CONDITION_FOG, # Fog - 48: ATTR_CONDITION_FOG, # Depositing rime fog + 48: ATTR_CONDITION_FOG, # Depositing rime fog # codespell:ignore rime 51: ATTR_CONDITION_RAINY, # Drizzle: Light intensity 53: ATTR_CONDITION_RAINY, # Drizzle: Moderate intensity 55: ATTR_CONDITION_RAINY, # Drizzle: Dense intensity diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 2a91f1b1b38..0ba7b53795b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal, cast + import openai import voluptuous as vol @@ -13,7 +15,11 @@ from homeassistant.core import ( ServiceResponse, SupportsResponse, ) -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import ( config_validation as cv, issue_registry as ir, @@ -27,13 +33,25 @@ SERVICE_GENERATE_IMAGE = "generate_image" PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" async def render_image(call: ServiceCall) -> ServiceResponse: """Render an image with dall-e.""" - client = hass.data[DOMAIN][call.data["config_entry"]] + entry_id = call.data["config_entry"] + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None or entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={"config_entry": entry_id}, + ) + + client: openai.AsyncClient = entry.runtime_data if call.data["size"] in ("256", "512", "1024"): ir.async_create_issue( @@ -51,6 +69,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: size = call.data["size"] + size = cast( + Literal["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"], + size, + ) # size is selector, so no need to check further + try: response = await client.images.generate( model="dall-e-3", @@ -90,7 +113,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: """Set up OpenAI Conversation from a config entry.""" client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY]) try: @@ -101,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except openai.OpenAIError as err: raise ConfigEntryNotReady(err) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -110,8 +133,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload OpenAI.""" - if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - return False - - hass.data[DOMAIN].pop(entry.entry_id) - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index fdbbbc554df..9a2b1b6fa79 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import types from types import MappingProxyType from typing import Any @@ -16,11 +15,15 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, TemplateSelector, ) @@ -28,14 +31,14 @@ from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, - DEFAULT_CHAT_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_PROMPT, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_P, DOMAIN, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, ) _LOGGER = logging.getLogger(__name__) @@ -46,15 +49,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) -DEFAULT_OPTIONS = types.MappingProxyType( - { - CONF_PROMPT: DEFAULT_PROMPT, - CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, - CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, - CONF_TOP_P: DEFAULT_TOP_P, - CONF_TEMPERATURE: DEFAULT_TEMPERATURE, - } -) +RECOMMENDED_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: @@ -88,11 +87,15 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except openai.AuthenticationError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title="OpenAI Conversation", data=user_input) + return self.async_create_entry( + title="ChatGPT", + data=user_input, + options=RECOMMENDED_OPTIONS, + ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors @@ -112,51 +115,101 @@ class OpenAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry + self.last_rendered_recommended = config_entry.options.get( + CONF_RECOMMENDED, False + ) async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + if user_input is not None: - return self.async_create_entry(title="OpenAI Conversation", data=user_input) - schema = openai_config_option_schema(self.config_entry.options) + if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if user_input[CONF_LLM_HASS_API] == "none": + user_input.pop(CONF_LLM_HASS_API) + return self.async_create_entry(title="", data=user_input) + + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } + + schema = openai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", data_schema=vol.Schema(schema), ) -def openai_config_option_schema(options: MappingProxyType[str, Any]) -> dict: +def openai_config_option_schema( + hass: HomeAssistant, + options: dict[str, Any] | MappingProxyType[str, Any], +) -> dict: """Return a schema for OpenAI completion options.""" - if not options: - options = DEFAULT_OPTIONS - return { + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label="No control", + value="none", + ) + ] + hass_apis.extend( + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ) + + schema = { vol.Optional( CONF_PROMPT, - description={"suggested_value": options[CONF_PROMPT]}, - default=DEFAULT_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, ): TemplateSelector(), vol.Optional( - CONF_CHAT_MODEL, - description={ - # New key in HA 2023.4 - "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - }, - default=DEFAULT_CHAT_MODEL, - ): str, - vol.Optional( - CONF_MAX_TOKENS, - description={"suggested_value": options[CONF_MAX_TOKENS]}, - default=DEFAULT_MAX_TOKENS, - ): int, - vol.Optional( - CONF_TOP_P, - description={"suggested_value": options[CONF_TOP_P]}, - default=DEFAULT_TOP_P, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), - vol.Optional( - CONF_TEMPERATURE, - description={"suggested_value": options[CONF_TEMPERATURE]}, - default=DEFAULT_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + CONF_LLM_HASS_API, + description={"suggested_value": options.get(CONF_LLM_HASS_API)}, + default="none", + ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, } + + if options.get(CONF_RECOMMENDED): + return schema + + schema.update( + { + vol.Optional( + CONF_CHAT_MODEL, + description={"suggested_value": options.get(CONF_CHAT_MODEL)}, + default=RECOMMENDED_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options.get(CONF_MAX_TOKENS)}, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options.get(CONF_TOP_P)}, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options.get(CONF_TEMPERATURE)}, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + } + ) + return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index f992849f9b1..f362f4278a1 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -4,33 +4,14 @@ import logging DOMAIN = "openai_conversation" LOGGER = logging.getLogger(__package__) + +CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" -DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. - -An overview of the areas and the devices in this smart home: -{%- for area in areas() %} - {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area) -%} - {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} - {%- if not area_info.printed %} - -{{ area_name(area) }}: - {%- set area_info.printed = true %} - {%- endif %} -- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} - {%- endif %} - {%- endfor %} -{%- endfor %} - -Answer the user's questions about the world truthfully. - -If the user wants to control a device, reject the request and suggest using the Home Assistant app. -""" CONF_CHAT_MODEL = "chat_model" -DEFAULT_CHAT_MODEL = "gpt-3.5-turbo" +RECOMMENDED_CHAT_MODEL = "gpt-4o" CONF_MAX_TOKENS = "max_tokens" -DEFAULT_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 150 CONF_TOP_P = "top_p" -DEFAULT_TOP_P = 1 +RECOMMENDED_TOP_P = 1.0 CONF_TEMPERATURE = "temperature" -DEFAULT_TEMPERATURE = 0.5 +RECOMMENDED_TEMPERATURE = 1.0 diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 39549af3b88..d0b3ef8f895 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,37 +1,56 @@ """Conversation support for OpenAI.""" +import json from typing import Literal import openai +from openai._types import NOT_GIVEN +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionToolParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition +import voluptuous as vol +from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import MATCH_ALL +from homeassistant.components.conversation import trace +from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import intent, template +from homeassistant.exceptions import HomeAssistantError, TemplateError +from homeassistant.helpers import device_registry as dr, intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import ulid +from . import OpenAIConfigEntry from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, - DEFAULT_CHAT_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_PROMPT, - DEFAULT_TEMPERATURE, - DEFAULT_TOP_P, DOMAIN, LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, ) +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenAIConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up conversation entities.""" @@ -39,19 +58,34 @@ async def async_setup_entry( async_add_entities([agent]) +def _format_tool(tool: llm.Tool) -> ChatCompletionToolParam: + """Format tool specification.""" + tool_spec = FunctionDefinition(name=tool.name, parameters=convert(tool.parameters)) + if tool.description: + tool_spec["description"] = tool.description + return ChatCompletionToolParam(type="function", function=tool_spec) + + class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): """OpenAI conversation agent.""" _attr_has_entity_name = True + _attr_name = None - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: OpenAIConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[dict]] = {} - self._attr_name = entry.title + self.history: dict[str, list[ChatCompletionMessageParam]] = {} self._attr_unique_id = entry.entry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) @property def supported_languages(self) -> list[str] | Literal["*"]: @@ -75,72 +109,202 @@ class OpenAIConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) - max_tokens = self.entry.options.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) - top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) - temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) + options = self.entry.options + intent_response = intent.IntentResponse(language=user_input.language) + llm_api: llm.APIInstance | None = None + tools: list[ChatCompletionToolParam] | None = None + user_name: str | None = None + llm_context = llm.LLMContext( + platform=DOMAIN, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=conversation.DOMAIN, + device_id=user_input.device_id, + ) - if user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - messages = self.history[conversation_id] - else: - conversation_id = ulid.ulid_now() + if options.get(CONF_LLM_HASS_API): try: - prompt = self._async_generate_prompt(raw_prompt) - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) + llm_api = await llm.async_get_api( + self.hass, + options[CONF_LLM_HASS_API], + llm_context, + ) + except HomeAssistantError as err: + LOGGER.error("Error getting LLM API: %s", err) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", + f"Error preparing LLM API: {err}", ) return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=user_input.conversation_id ) - messages = [{"role": "system", "content": prompt}] + tools = [_format_tool(tool) for tool in llm_api.tools] - messages.append({"role": "user", "content": user_input.text}) + if user_input.conversation_id is None: + conversation_id = ulid.ulid_now() + messages = [] - LOGGER.debug("Prompt for %s: %s", model, messages) + elif user_input.conversation_id in self.history: + conversation_id = user_input.conversation_id + messages = self.history[conversation_id] - client = self.hass.data[DOMAIN][self.entry.entry_id] + else: + # Conversation IDs are ULIDs. We generate a new one if not provided. + # If an old OLID is passed in, we will generate a new one to indicate + # a new conversation was started. If the user picks their own, they + # want to track a conversation and we respect it. + try: + ulid.ulid_to_bytes(user_input.conversation_id) + conversation_id = ulid.ulid_now() + except ValueError: + conversation_id = user_input.conversation_id + + messages = [] + + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name try: - result = await client.chat.completions.create( - model=model, - messages=messages, - max_tokens=max_tokens, - top_p=top_p, - temperature=temperature, - user=conversation_id, + if llm_api: + api_prompt = llm_api.api_prompt + else: + api_prompt = llm.async_render_no_api_prompt(self.hass) + + prompt = "\n".join( + ( + template.Template( + llm.BASE_PROMPT + + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ), + api_prompt, + ) ) - except openai.OpenAIError as err: + + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to OpenAI: {err}", + f"Sorry, I had a problem with my template: {err}", ) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) - LOGGER.debug("Response %s", result) - response = result.choices[0].message.model_dump(include={"role", "content"}) - messages.append(response) + # Create a copy of the variable because we attach it to the trace + messages = [ + ChatCompletionSystemMessageParam(role="system", content=prompt), + *messages[1:], + ChatCompletionUserMessageParam(role="user", content=user_input.text), + ] + + LOGGER.debug("Prompt: %s", messages) + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages} + ) + + client = self.entry.runtime_data + + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + result = await client.chat.completions.create( + model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + messages=messages, + tools=tools or NOT_GIVEN, + max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + user=conversation_id, + ) + except openai.OpenAIError as err: + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem talking to OpenAI: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + LOGGER.debug("Response %s", result) + response = result.choices[0].message + + def message_convert( + message: ChatCompletionMessage, + ) -> ChatCompletionMessageParam: + """Convert from class to TypedDict.""" + tool_calls: list[ChatCompletionMessageToolCallParam] = [] + if message.tool_calls: + tool_calls = [ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=tool_call.function.arguments, + name=tool_call.function.name, + ), + type=tool_call.type, + ) + for tool_call in message.tool_calls + ] + param = ChatCompletionAssistantMessageParam( + role=message.role, + content=message.content, + ) + if tool_calls: + param["tool_calls"] = tool_calls + return param + + messages.append(message_convert(response)) + tool_calls = response.tool_calls + + if not tool_calls or not llm_api: + break + + for tool_call in tool_calls: + tool_input = llm.ToolInput( + tool_name=tool_call.function.name, + tool_args=json.loads(tool_call.function.arguments), + ) + LOGGER.debug( + "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args + ) + + try: + tool_response = await llm_api.async_call_tool(tool_input) + except (HomeAssistantError, vol.Invalid) as e: + tool_response = {"error": type(e).__name__} + if str(e): + tool_response["error_text"] = str(e) + + LOGGER.debug("Tool response: %s", tool_response) + messages.append( + ChatCompletionToolMessageParam( + role="tool", + tool_call_id=tool_call.id, + content=json.dumps(tool_response), + ) + ) + self.history[conversation_id] = messages intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response["content"]) + intent_response.async_set_speech(response.content or "") return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) - - def _async_generate_prompt(self, raw_prompt: str) -> str: - """Generate a prompt for the user.""" - return template.Template(raw_prompt, self.hass).async_render( - { - "ha_name": self.hass.config.location_name, - }, - parse_result=False, - ) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index b71c84e2081..480712574c4 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -1,12 +1,12 @@ { "domain": "openai_conversation", "name": "OpenAI Conversation", - "after_dependencies": ["assist_pipeline"], + "after_dependencies": ["assist_pipeline", "intent"], "codeowners": ["@balloob"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.3.8"] + "requirements": ["openai==1.3.8", "voluptuous-openapi==0.0.4"] } diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 1a7d5a03c65..c5d42eb9521 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -17,11 +17,16 @@ "step": { "init": { "data": { - "prompt": "Prompt Template", - "model": "Completion Model", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", - "top_p": "Top P" + "top_p": "Top P", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template." } } } @@ -55,6 +60,11 @@ } } }, + "exceptions": { + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + } + }, "issues": { "image_size_deprecated_format": { "title": "Deprecated size format for image generation service", diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index 2fc0acea78d..df83690d2e3 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -84,7 +84,7 @@ class OpenExchangeRatesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except TimeoutError: errors["base"] = "timeout_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index adc96ee0946..12c2f96d7e4 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -2,22 +2,15 @@ from __future__ import annotations -from datetime import timedelta -import logging -from typing import Any - import opengarage from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_DEVICE_KEY, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import OpenGarageDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.SENSOR] @@ -49,32 +42,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching Opengarage data.""" - - def __init__( - self, - hass: HomeAssistant, - *, - open_garage_connection: opengarage.OpenGarage, - ) -> None: - """Initialize global Opengarage data updater.""" - self.open_garage_connection = open_garage_connection - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=5), - ) - - async def _async_update_data(self) -> dict[str, Any]: - """Fetch data.""" - data = await self.open_garage_connection.update_state() - if data is None: - raise update_coordinator.UpdateFailed( - "Unable to connect to OpenGarage device" - ) - return data diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index 2eca670b990..55cacfb5f90 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OpenGarageDataUpdateCoordinator from .const import DOMAIN +from .coordinator import OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py index f3a31d1b050..9f93e0fa716 100644 --- a/homeassistant/components/opengarage/button.py +++ b/homeassistant/components/opengarage/button.py @@ -18,8 +18,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OpenGarageDataUpdateCoordinator from .const import DOMAIN +from .coordinator import OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity diff --git a/homeassistant/components/opengarage/config_flow.py b/homeassistant/components/opengarage/config_flow.py index 0b86c563783..e4576ae4b70 100644 --- a/homeassistant/components/opengarage/config_flow.py +++ b/homeassistant/components/opengarage/config_flow.py @@ -75,7 +75,7 @@ class OpenGarageConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/opengarage/coordinator.py b/homeassistant/components/opengarage/coordinator.py new file mode 100644 index 00000000000..d35dc22d288 --- /dev/null +++ b/homeassistant/components/opengarage/coordinator.py @@ -0,0 +1,46 @@ +"""The OpenGarage integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +import opengarage + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching Opengarage data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + open_garage_connection: opengarage.OpenGarage, + ) -> None: + """Initialize global Opengarage data updater.""" + self.open_garage_connection = open_garage_connection + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data.""" + data = await self.open_garage_connection.update_state() + if data is None: + raise update_coordinator.UpdateFailed( + "Unable to connect to OpenGarage device" + ) + return data diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 69338ad4b90..a165fcc4785 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -15,8 +15,8 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OpenGarageDataUpdateCoordinator from .const import DOMAIN +from .coordinator import OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/opengarage/entity.py b/homeassistant/components/opengarage/entity.py index 4bf63567fe3..60f7b323469 100644 --- a/homeassistant/components/opengarage/entity.py +++ b/homeassistant/components/opengarage/entity.py @@ -7,7 +7,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN, OpenGarageDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import OpenGarageDataUpdateCoordinator class OpenGarageEntity(CoordinatorEntity[OpenGarageDataUpdateCoordinator]): diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index 39b431157ab..003e0e0fa5a 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -22,8 +22,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OpenGarageDataUpdateCoordinator from .const import DOMAIN +from .coordinator import OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 12e5ed992c2..c9143c977ce 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine import functools import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate import aiohttp from async_upnp_client.client import UpnpError @@ -28,10 +28,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN -_OpenhomeDeviceT = TypeVar("_OpenhomeDeviceT", bound="OpenhomeDevice") -_R = TypeVar("_R") -_P = ParamSpec("_P") - SUPPORT_OPENHOME = ( MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.TURN_OFF @@ -65,13 +61,13 @@ async def async_setup_entry( ) -_FuncType = Callable[Concatenate[_OpenhomeDeviceT, _P], Awaitable[_R]] -_ReturnFuncType = Callable[ - Concatenate[_OpenhomeDeviceT, _P], Coroutine[Any, Any, _R | None] +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]] +type _ReturnFuncType[_T, **_P, _R] = Callable[ + Concatenate[_T, _P], Coroutine[Any, Any, _R | None] ] -def catch_request_errors() -> ( +def catch_request_errors[_OpenhomeDeviceT: OpenhomeDevice, **_P, _R]() -> ( Callable[ [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] ] diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index ca37b7baaef..46cc6f3daa0 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -36,6 +36,8 @@ from .const import ( ATTR_DHW_OVRD, ATTR_GW_ID, ATTR_LEVEL, + ATTR_TRANSP_ARG, + ATTR_TRANSP_CMD, CONF_CLIMATE, CONF_FLOOR_TEMP, CONF_PRECISION, @@ -46,6 +48,7 @@ from .const import ( DATA_OPENTHERM_GW, DOMAIN, SERVICE_RESET_GATEWAY, + SERVICE_SEND_TRANSP_CMD, SERVICE_SET_CH_OVRD, SERVICE_SET_CLOCK, SERVICE_SET_CONTROL_SETPOINT, @@ -254,6 +257,19 @@ def register_services(hass: HomeAssistant) -> None: ), } ) + service_send_transp_cmd_schema = vol.Schema( + { + vol.Required(ATTR_GW_ID): vol.All( + cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS]) + ), + vol.Required(ATTR_TRANSP_CMD): vol.All( + cv.string, vol.Length(min=2, max=2), vol.Coerce(str.upper) + ), + vol.Required(ATTR_TRANSP_ARG): vol.All( + cv.string, vol.Length(min=1, max=12) + ), + } + ) async def reset_gateway(call: ServiceCall) -> None: """Reset the OpenTherm Gateway.""" @@ -377,6 +393,20 @@ def register_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SET_SB_TEMP, set_setback_temp, service_set_sb_temp_schema ) + async def send_transparent_cmd(call: ServiceCall) -> None: + """Send a transparent OpenTherm Gateway command.""" + gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] + transp_cmd = call.data[ATTR_TRANSP_CMD] + transp_arg = call.data[ATTR_TRANSP_ARG] + await gw_dev.gateway.send_transparent_command(transp_cmd, transp_arg) + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_TRANSP_CMD, + send_transparent_cmd, + service_send_transp_cmd_schema, + ) + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Cleanup and disconnect from gateway.""" diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index c020a82f08f..2d9f1687463 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -213,7 +213,7 @@ class OpenThermClimate(ClimateEntity): def current_temperature(self): """Return the current temperature.""" if self._current_temperature is None: - return + return None if self.floor_temp is True: if self.precision == PRECISION_HALVES: return int(2 * self._current_temperature) / 2 diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 74b856b4eaf..6b0a27aec92 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -19,6 +19,8 @@ ATTR_GW_ID = "gateway_id" ATTR_LEVEL = "level" ATTR_DHW_OVRD = "dhw_override" ATTR_CH_OVRD = "ch_override" +ATTR_TRANSP_CMD = "transp_cmd" +ATTR_TRANSP_ARG = "transp_arg" CONF_CLIMATE = "climate" CONF_FLOOR_TEMP = "floor_temperature" @@ -45,6 +47,7 @@ SERVICE_SET_LED_MODE = "set_led_mode" SERVICE_SET_MAX_MOD = "set_max_modulation" SERVICE_SET_OAT = "set_outside_temperature" SERVICE_SET_SB_TEMP = "set_setback_temperature" +SERVICE_SEND_TRANSP_CMD = "send_transparent_command" TRANSLATE_SOURCE = { gw_vars.BOILER: "Boiler", diff --git a/homeassistant/components/opentherm_gw/icons.json b/homeassistant/components/opentherm_gw/icons.json index 9d5d903aabc..13dbe0a70a1 100644 --- a/homeassistant/components/opentherm_gw/icons.json +++ b/homeassistant/components/opentherm_gw/icons.json @@ -10,6 +10,7 @@ "set_led_mode": "mdi:led-on", "set_max_modulation": "mdi:thermometer-lines", "set_outside_temperature": "mdi:thermometer-lines", - "set_setback_temperature": "mdi:thermometer-lines" + "set_setback_temperature": "mdi:thermometer-lines", + "send_transparent_command": "mdi:console" } } diff --git a/homeassistant/components/opentherm_gw/services.yaml b/homeassistant/components/opentherm_gw/services.yaml index d68624e0763..d521425d06b 100644 --- a/homeassistant/components/opentherm_gw/services.yaml +++ b/homeassistant/components/opentherm_gw/services.yaml @@ -181,3 +181,19 @@ set_setback_temperature: max: 30 step: 0.1 unit_of_measurement: "°" + +send_transparent_command: + fields: + gateway_id: + required: true + example: "opentherm_gateway" + selector: + text: + transp_cmd: + required: true + selector: + text: + transp_arg: + required: true + selector: + text: diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index a5b8395b56b..2ad34f8d659 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -190,6 +190,24 @@ "description": "The setback temperature to configure on the gateway." } } + }, + "send_transparent_command": { + "name": "Send transparent command", + "description": "Sends custom otgw commands (https://otgw.tclcode.com/firmware.html) through a transparent interface.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "transp_cmd": { + "name": "Command", + "description": "The command to be sent to the OpenTherm Gateway." + }, + "transp_arg": { + "name": "Argument", + "description": "The argument of the command to be sent to the OpenTherm Gateway." + } + } } } } diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index f740bf6c551..7aea6aafe20 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -4,10 +4,8 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import Any -from pyowm import OWM -from pyowm.utils.config import get_default_config +from pyopenweathermap import OWMClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,17 +18,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import ( - CONFIG_FLOW_VERSION, - FORECAST_MODE_FREE_DAILY, - FORECAST_MODE_ONECALL_DAILY, - PLATFORMS, -) -from .weather_update_coordinator import WeatherUpdateCoordinator +from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS +from .coordinator import WeatherUpdateCoordinator +from .repairs import async_create_issue, async_delete_issue +from .utils import build_data_and_options _LOGGER = logging.getLogger(__name__) -OpenweathermapConfigEntry = ConfigEntry["OpenweathermapData"] +type OpenweathermapConfigEntry = ConfigEntry[OpenweathermapData] @dataclass @@ -49,14 +44,17 @@ async def async_setup_entry( api_key = entry.data[CONF_API_KEY] latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) - forecast_mode = _get_config_value(entry, CONF_MODE) - language = _get_config_value(entry, CONF_LANGUAGE) + language = entry.options[CONF_LANGUAGE] + mode = entry.options[CONF_MODE] - config_dict = _get_owm_config(language) + if mode == OWM_MODE_V25: + async_create_issue(hass, entry.entry_id) + else: + async_delete_issue(hass, entry.entry_id) - owm = OWM(api_key, config_dict).weather_manager() + owm_client = OWMClient(api_key, mode, lang=language) weather_coordinator = WeatherUpdateCoordinator( - owm, latitude, longitude, forecast_mode, hass + owm_client, latitude, longitude, hass ) await weather_coordinator.async_config_entry_first_refresh() @@ -74,17 +72,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" config_entries = hass.config_entries data = entry.data + options = entry.options version = entry.version _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) - if version == 1: - if (mode := data[CONF_MODE]) == FORECAST_MODE_FREE_DAILY: - mode = FORECAST_MODE_ONECALL_DAILY - - new_data = {**data, CONF_MODE: mode} + if version < 5: + combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + new_data, new_options = build_data_and_options(combined_data) config_entries.async_update_entry( - entry, data=new_data, version=CONFIG_FLOW_VERSION + entry, + data=new_data, + options=new_options, + version=CONFIG_FLOW_VERSION, ) _LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION) @@ -102,16 +102,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def _get_config_value(config_entry: ConfigEntry, key: str) -> Any: - if config_entry.options: - return config_entry.options[key] - return config_entry.data[key] - - -def _get_owm_config(language: str) -> dict[str, Any]: - """Get OpenWeatherMap configuration and add language to it.""" - config_dict = get_default_config() - config_dict["language"] = language - return config_dict diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index cc4c71c2bd5..5fe06ea2dcd 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -2,11 +2,14 @@ from __future__ import annotations -from pyowm import OWM -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import ( CONF_API_KEY, CONF_LANGUAGE, @@ -20,13 +23,14 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONFIG_FLOW_VERSION, - DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DEFAULT_NAME, + DEFAULT_OWM_MODE, DOMAIN, - FORECAST_MODES, LANGUAGES, + OWM_MODES, ) +from .utils import build_data_and_options, validate_api_key class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): @@ -42,31 +46,27 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OpenWeatherMapOptionsFlow(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle a flow initialized by the user.""" errors = {} + description_placeholders = {} if user_input is not None: latitude = user_input[CONF_LATITUDE] longitude = user_input[CONF_LONGITUDE] + mode = user_input[CONF_MODE] await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - try: - api_online = await _is_owm_api_online( - self.hass, user_input[CONF_API_KEY], latitude, longitude - ) - if not api_online: - errors["base"] = "invalid_api_key" - except UnauthorizedError: - errors["base"] = "invalid_api_key" - except APIRequestError: - errors["base"] = "cannot_connect" + errors, description_placeholders = await validate_api_key( + user_input[CONF_API_KEY], mode + ) if not errors: + data, options = build_data_and_options(user_input) return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=user_input[CONF_NAME], data=data, options=options ) schema = vol.Schema( @@ -79,16 +79,19 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In( - FORECAST_MODES - ), + vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES), vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( LANGUAGES ), } ) - return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + description_placeholders=description_placeholders, + ) class OpenWeatherMapOptionsFlow(OptionsFlow): @@ -98,7 +101,7 @@ class OpenWeatherMapOptionsFlow(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: dict | None = None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -115,9 +118,9 @@ class OpenWeatherMapOptionsFlow(OptionsFlow): CONF_MODE, default=self.config_entry.options.get( CONF_MODE, - self.config_entry.data.get(CONF_MODE, DEFAULT_FORECAST_MODE), + self.config_entry.data.get(CONF_MODE, DEFAULT_OWM_MODE), ), - ): vol.In(FORECAST_MODES), + ): vol.In(OWM_MODES), vol.Optional( CONF_LANGUAGE, default=self.config_entry.options.get( @@ -127,8 +130,3 @@ class OpenWeatherMapOptionsFlow(OptionsFlow): ): vol.In(LANGUAGES), } ) - - -async def _is_owm_api_online(hass, api_key, lat, lon): - owm = OWM(api_key).weather_manager() - return await hass.async_add_executor_job(owm.weather_at_coords, lat, lon) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index cae21e8f054..6c9997fc061 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap" DEFAULT_LANGUAGE = "en" ATTRIBUTION = "Data provided by OpenWeatherMap" MANUFACTURER = "OpenWeather" -CONFIG_FLOW_VERSION = 2 +CONFIG_FLOW_VERSION = 5 ATTR_API_PRECIPITATION = "precipitation" ATTR_API_PRECIPITATION_KIND = "precipitation_kind" ATTR_API_DATETIME = "datetime" @@ -45,35 +45,23 @@ ATTR_API_SNOW = "snow" ATTR_API_UV_INDEX = "uv_index" ATTR_API_VISIBILITY_DISTANCE = "visibility_distance" ATTR_API_WEATHER_CODE = "weather_code" +ATTR_API_CLOUD_COVERAGE = "cloud_coverage" ATTR_API_FORECAST = "forecast" +ATTR_API_CURRENT = "current" +ATTR_API_HOURLY_FORECAST = "hourly_forecast" +ATTR_API_DAILY_FORECAST = "daily_forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -ATTR_API_FORECAST_CLOUDS = "clouds" -ATTR_API_FORECAST_CONDITION = "condition" -ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE = "feels_like_temperature" -ATTR_API_FORECAST_HUMIDITY = "humidity" -ATTR_API_FORECAST_PRECIPITATION = "precipitation" -ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" -ATTR_API_FORECAST_PRESSURE = "pressure" -ATTR_API_FORECAST_TEMP = "temperature" -ATTR_API_FORECAST_TEMP_LOW = "templow" -ATTR_API_FORECAST_TIME = "datetime" -ATTR_API_FORECAST_WIND_BEARING = "wind_bearing" -ATTR_API_FORECAST_WIND_SPEED = "wind_speed" - FORECAST_MODE_HOURLY = "hourly" FORECAST_MODE_DAILY = "daily" FORECAST_MODE_FREE_DAILY = "freedaily" FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly" FORECAST_MODE_ONECALL_DAILY = "onecall_daily" -FORECAST_MODES = [ - FORECAST_MODE_HOURLY, - FORECAST_MODE_DAILY, - FORECAST_MODE_ONECALL_HOURLY, - FORECAST_MODE_ONECALL_DAILY, -] -DEFAULT_FORECAST_MODE = FORECAST_MODE_HOURLY +OWM_MODE_V25 = "v2.5" +OWM_MODE_V30 = "v3.0" +OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25] +DEFAULT_OWM_MODE = OWM_MODE_V30 LANGUAGES = [ "af", diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py new file mode 100644 index 00000000000..0f99af5ad64 --- /dev/null +++ b/homeassistant/components/openweathermap/coordinator.py @@ -0,0 +1,203 @@ +"""Weather data coordinator for the OpenWeatherMap (OWM) service.""" + +from datetime import timedelta +import logging + +from pyopenweathermap import ( + CurrentWeather, + DailyWeatherForecast, + HourlyWeatherForecast, + OWMClient, + RequestError, + WeatherReport, +) + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_SUNNY, + Forecast, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import sun +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_API_CLOUDS, + ATTR_API_CONDITION, + ATTR_API_CURRENT, + ATTR_API_DAILY_FORECAST, + ATTR_API_DEW_POINT, + ATTR_API_FEELS_LIKE_TEMPERATURE, + ATTR_API_HOURLY_FORECAST, + ATTR_API_HUMIDITY, + ATTR_API_PRECIPITATION_KIND, + ATTR_API_PRESSURE, + ATTR_API_RAIN, + ATTR_API_SNOW, + ATTR_API_TEMPERATURE, + ATTR_API_UV_INDEX, + ATTR_API_VISIBILITY_DISTANCE, + ATTR_API_WEATHER, + ATTR_API_WEATHER_CODE, + ATTR_API_WIND_BEARING, + ATTR_API_WIND_GUST, + ATTR_API_WIND_SPEED, + CONDITION_MAP, + DOMAIN, + WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT, +) + +_LOGGER = logging.getLogger(__name__) + +WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) + + +class WeatherUpdateCoordinator(DataUpdateCoordinator): + """Weather data update coordinator.""" + + def __init__( + self, + owm_client: OWMClient, + latitude, + longitude, + hass: HomeAssistant, + ) -> None: + """Initialize coordinator.""" + self._owm_client = owm_client + self._latitude = latitude + self._longitude = longitude + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL + ) + + async def _async_update_data(self): + """Update the data.""" + try: + weather_report = await self._owm_client.get_weather( + self._latitude, self._longitude + ) + except RequestError as error: + raise UpdateFailed(error) from error + return self._convert_weather_response(weather_report) + + def _convert_weather_response(self, weather_report: WeatherReport): + """Format the weather response correctly.""" + _LOGGER.debug("OWM weather response: %s", weather_report) + + return { + ATTR_API_CURRENT: self._get_current_weather_data(weather_report.current), + ATTR_API_HOURLY_FORECAST: [ + self._get_hourly_forecast_weather_data(item) + for item in weather_report.hourly_forecast + ], + ATTR_API_DAILY_FORECAST: [ + self._get_daily_forecast_weather_data(item) + for item in weather_report.daily_forecast + ], + } + + def _get_current_weather_data(self, current_weather: CurrentWeather): + return { + ATTR_API_CONDITION: self._get_condition(current_weather.condition.id), + ATTR_API_TEMPERATURE: current_weather.temperature, + ATTR_API_FEELS_LIKE_TEMPERATURE: current_weather.feels_like, + ATTR_API_PRESSURE: current_weather.pressure, + ATTR_API_HUMIDITY: current_weather.humidity, + ATTR_API_DEW_POINT: current_weather.dew_point, + ATTR_API_CLOUDS: current_weather.cloud_coverage, + ATTR_API_WIND_SPEED: current_weather.wind_speed, + ATTR_API_WIND_GUST: current_weather.wind_gust, + ATTR_API_WIND_BEARING: current_weather.wind_bearing, + ATTR_API_WEATHER: current_weather.condition.description, + ATTR_API_WEATHER_CODE: current_weather.condition.id, + ATTR_API_UV_INDEX: current_weather.uv_index, + ATTR_API_VISIBILITY_DISTANCE: current_weather.visibility, + ATTR_API_RAIN: self._get_precipitation_value(current_weather.rain), + ATTR_API_SNOW: self._get_precipitation_value(current_weather.snow), + ATTR_API_PRECIPITATION_KIND: self._calc_precipitation_kind( + current_weather.rain, current_weather.snow + ), + } + + def _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast): + return Forecast( + datetime=forecast.date_time.isoformat(), + condition=self._get_condition(forecast.condition.id), + temperature=forecast.temperature, + native_apparent_temperature=forecast.feels_like, + pressure=forecast.pressure, + humidity=forecast.humidity, + native_dew_point=forecast.dew_point, + cloud_coverage=forecast.cloud_coverage, + wind_speed=forecast.wind_speed, + native_wind_gust_speed=forecast.wind_gust, + wind_bearing=forecast.wind_bearing, + uv_index=float(forecast.uv_index), + precipitation_probability=round(forecast.precipitation_probability * 100), + precipitation=self._calc_precipitation(forecast.rain, forecast.snow), + ) + + def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast): + return Forecast( + datetime=forecast.date_time.isoformat(), + condition=self._get_condition(forecast.condition.id), + temperature=forecast.temperature.max, + templow=forecast.temperature.min, + native_apparent_temperature=forecast.feels_like, + pressure=forecast.pressure, + humidity=forecast.humidity, + native_dew_point=forecast.dew_point, + cloud_coverage=forecast.cloud_coverage, + wind_speed=forecast.wind_speed, + native_wind_gust_speed=forecast.wind_gust, + wind_bearing=forecast.wind_bearing, + uv_index=float(forecast.uv_index), + precipitation_probability=round(forecast.precipitation_probability * 100), + precipitation=round(forecast.rain + forecast.snow, 2), + ) + + @staticmethod + def _calc_precipitation(rain, snow): + """Calculate the precipitation.""" + rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain) + snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow) + return round(rain_value + snow_value, 2) + + @staticmethod + def _calc_precipitation_kind(rain, snow): + """Determine the precipitation kind.""" + rain_value = WeatherUpdateCoordinator._get_precipitation_value(rain) + snow_value = WeatherUpdateCoordinator._get_precipitation_value(snow) + if rain_value != 0: + if snow_value != 0: + return "Snow and Rain" + return "Rain" + + if snow_value != 0: + return "Snow" + return "None" + + @staticmethod + def _get_precipitation_value(precipitation): + """Get precipitation value from weather data.""" + if "all" in precipitation: + return round(precipitation["all"], 2) + if "3h" in precipitation: + return round(precipitation["3h"], 2) + if "1h" in precipitation: + return round(precipitation["1h"], 2) + return 0 + + def _get_condition(self, weather_code, timestamp=None): + """Get weather condition from weather data.""" + if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: + if timestamp: + timestamp = dt_util.utc_from_timestamp(timestamp) + + if sun.is_up(self.hass, timestamp): + return ATTR_CONDITION_SUNNY + return ATTR_CONDITION_CLEAR_NIGHT + + return CONDITION_MAP.get(weather_code) diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index de2261a8024..e2c809cf385 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", - "loggers": ["geojson", "pyowm", "pysocks"], - "requirements": ["pyowm==3.2.0"] + "loggers": ["pyopenweathermap"], + "requirements": ["pyopenweathermap==0.0.9"] } diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py new file mode 100644 index 00000000000..c54484e1e1e --- /dev/null +++ b/homeassistant/components/openweathermap/repairs.py @@ -0,0 +1,87 @@ +"""Issues for OpenWeatherMap.""" + +from typing import cast + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_MODE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir + +from .const import DOMAIN, OWM_MODE_V30 +from .utils import validate_api_key + + +class DeprecatedV25RepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Create flow.""" + super().__init__() + self.entry = entry + + 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 self.async_show_form(step_id="migrate") + + async def async_step_migrate( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the migrate step of a fix flow.""" + errors, description_placeholders = {}, {} + new_options = {**self.entry.options, CONF_MODE: OWM_MODE_V30} + + errors, description_placeholders = await validate_api_key( + self.entry.data[CONF_API_KEY], OWM_MODE_V30 + ) + if not errors: + 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={}) + + return self.async_show_form( + step_id="migrate", + errors=errors, + description_placeholders=description_placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None], +) -> RepairsFlow: + """Create single repair flow.""" + entry_id = cast(str, data.get("entry_id")) + entry = hass.config_entries.async_get_entry(entry_id) + assert entry + return DeprecatedV25RepairFlow(entry) + + +def _get_issue_id(entry_id: str) -> str: + return f"deprecated_v25_{entry_id}" + + +@callback +def async_create_issue(hass: HomeAssistant, entry_id: str) -> None: + """Create issue for V2.5 deprecation.""" + ir.async_create_issue( + hass=hass, + domain=DOMAIN, + issue_id=_get_issue_id(entry_id), + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/integrations/openweathermap/", + translation_key="deprecated_v25", + data={"entry_id": entry_id}, + ) + + +@callback +def async_delete_issue(hass: HomeAssistant, entry_id: str) -> None: + """Remove issue for V2.5 deprecation.""" + ir.async_delete_issue(hass=hass, domain=DOMAIN, issue_id=_get_issue_id(entry_id)) diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 70b21324b46..89905e99ed9 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import datetime - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -15,7 +13,6 @@ from homeassistant.const import ( PERCENTAGE, UV_INDEX, UnitOfLength, - UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -26,22 +23,14 @@ 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 DataUpdateCoordinator -from homeassistant.util import dt as dt_util from . import OpenweathermapConfigEntry from .const import ( ATTR_API_CLOUDS, ATTR_API_CONDITION, + ATTR_API_CURRENT, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRESSURE, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, ATTR_API_HUMIDITY, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, @@ -59,7 +48,7 @@ from .const import ( DOMAIN, MANUFACTURER, ) -from .weather_update_coordinator import WeatherUpdateCoordinator +from .coordinator import WeatherUpdateCoordinator WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -160,62 +149,6 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Weather Code", ), ) -FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key=ATTR_API_FORECAST_CONDITION, - name="Condition", - ), - SensorEntityDescription( - key=ATTR_API_FORECAST_PRECIPITATION, - name="Precipitation", - device_class=SensorDeviceClass.PRECIPITATION, - native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, - ), - SensorEntityDescription( - key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - name="Precipitation probability", - native_unit_of_measurement=PERCENTAGE, - ), - SensorEntityDescription( - key=ATTR_API_FORECAST_PRESSURE, - name="Pressure", - native_unit_of_measurement=UnitOfPressure.HPA, - device_class=SensorDeviceClass.PRESSURE, - ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TEMP, - name="Temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TEMP_LOW, - name="Temperature Low", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - ), - SensorEntityDescription( - key=ATTR_API_FORECAST_TIME, - name="Time", - device_class=SensorDeviceClass.TIMESTAMP, - ), - SensorEntityDescription( - key=ATTR_API_WIND_BEARING, - name="Wind bearing", - native_unit_of_measurement=DEGREE, - ), - SensorEntityDescription( - key=ATTR_API_WIND_SPEED, - name="Wind speed", - native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, - device_class=SensorDeviceClass.WIND_SPEED, - ), - SensorEntityDescription( - key=ATTR_API_CLOUDS, - name="Cloud coverage", - native_unit_of_measurement=PERCENTAGE, - ), -) async def async_setup_entry( @@ -237,19 +170,6 @@ async def async_setup_entry( ) for description in WEATHER_SENSOR_TYPES ] - - entities.extend( - [ - OpenWeatherMapForecastSensor( - f"{name} Forecast", - f"{config_entry.unique_id}-forecast-{description.key}", - description, - weather_coordinator, - ) - for description in FORECAST_SENSOR_TYPES - ] - ) - async_add_entities(entities) @@ -313,35 +233,6 @@ class OpenWeatherMapSensor(AbstractOpenWeatherMapSensor): @property def native_value(self) -> StateType: """Return the state of the device.""" - return self._weather_coordinator.data.get(self.entity_description.key, None) - - -class OpenWeatherMapForecastSensor(AbstractOpenWeatherMapSensor): - """Implementation of an OpenWeatherMap this day forecast sensor.""" - - def __init__( - self, - name: str, - unique_id: str, - description: SensorEntityDescription, - weather_coordinator: WeatherUpdateCoordinator, - ) -> None: - """Initialize the sensor.""" - super().__init__(name, unique_id, description, weather_coordinator) - self._weather_coordinator = weather_coordinator - - @property - def native_value(self) -> StateType | datetime: - """Return the state of the device.""" - forecasts = self._weather_coordinator.data.get(ATTR_API_FORECAST) - if not forecasts: - return None - - value = forecasts[0].get(self.entity_description.key, None) - if ( - value - and self.entity_description.device_class is SensorDeviceClass.TIMESTAMP - ): - return dt_util.parse_datetime(value) - - return value + return self._weather_coordinator.data[ATTR_API_CURRENT].get( + self.entity_description.key + ) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index c53b685af91..46b5feab75c 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -5,7 +5,7 @@ }, "error": { "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "Failed to connect: {error}" }, "step": { "user": { @@ -30,5 +30,22 @@ } } } + }, + "issues": { + "deprecated_v25": { + "title": "OpenWeatherMap API V2.5 deprecated", + "fix_flow": { + "step": { + "migrate": { + "title": "OpenWeatherMap API V2.5 deprecated", + "description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integrations to v3.0.\n\nBefore the migration, you must have an active subscription (be aware that subscription activation can take up to 2h). After your subscription is activated, select **Submit** to migrate the integration to API V3.0. Read the documentation for more information." + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "cannot_connect": "Failed to connect: {error}" + } + } + } } } diff --git a/homeassistant/components/openweathermap/utils.py b/homeassistant/components/openweathermap/utils.py new file mode 100644 index 00000000000..7f2391b21a1 --- /dev/null +++ b/homeassistant/components/openweathermap/utils.py @@ -0,0 +1,40 @@ +"""Util functions for OpenWeatherMap.""" + +from typing import Any + +from pyopenweathermap import OWMClient, RequestError + +from homeassistant.const import CONF_LANGUAGE, CONF_MODE + +from .const import DEFAULT_LANGUAGE, DEFAULT_OWM_MODE + +OPTION_DEFAULTS = {CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: DEFAULT_OWM_MODE} + + +async def validate_api_key(api_key, mode): + """Validate API key.""" + api_key_valid = None + errors, description_placeholders = {}, {} + try: + owm_client = OWMClient(api_key, mode) + api_key_valid = await owm_client.validate_key() + except RequestError as error: + errors["base"] = "cannot_connect" + description_placeholders["error"] = str(error) + + if api_key_valid is False: + errors["base"] = "invalid_api_key" + + return errors, description_placeholders + + +def build_data_and_options( + combined_data: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Split combined data and options.""" + data = {k: v for k, v in combined_data.items() if k not in OPTION_DEFAULTS} + options = { + option: combined_data.get(option, default) + for option, default in OPTION_DEFAULTS.items() + } + return (data, options) diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 406b1c8ad4b..62b15218233 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -2,21 +2,7 @@ from __future__ import annotations -from typing import cast - from homeassistant.components.weather import ( - ATTR_FORECAST_CLOUD_COVERAGE, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_HUMIDITY, - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, Forecast, SingleCoordinatorWeatherEntity, WeatherEntityFeature, @@ -35,21 +21,11 @@ from . import OpenweathermapConfigEntry from .const import ( ATTR_API_CLOUDS, ATTR_API_CONDITION, + ATTR_API_CURRENT, + ATTR_API_DAILY_FORECAST, ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST, - ATTR_API_FORECAST_CLOUDS, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST_HUMIDITY, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRESSURE, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED, + ATTR_API_HOURLY_FORECAST, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, @@ -59,26 +35,9 @@ from .const import ( ATTRIBUTION, DEFAULT_NAME, DOMAIN, - FORECAST_MODE_DAILY, - FORECAST_MODE_ONECALL_DAILY, MANUFACTURER, ) -from .weather_update_coordinator import WeatherUpdateCoordinator - -FORECAST_MAP = { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRESSURE: ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_API_FORECAST_CLOUDS: ATTR_FORECAST_CLOUD_COVERAGE, - ATTR_API_FORECAST_HUMIDITY: ATTR_FORECAST_HUMIDITY, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: ATTR_FORECAST_NATIVE_APPARENT_TEMP, -} +from .coordinator import WeatherUpdateCoordinator async def async_setup_entry( @@ -124,84 +83,66 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) - if weather_coordinator.forecast_mode in ( - FORECAST_MODE_DAILY, - FORECAST_MODE_ONECALL_DAILY, - ): - self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY - else: # FORECAST_MODE_DAILY or FORECAST_MODE_ONECALL_HOURLY - self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY + self._attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) @property def condition(self) -> str | None: """Return the current condition.""" - return self.coordinator.data[ATTR_API_CONDITION] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CONDITION] @property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" - return self.coordinator.data[ATTR_API_CLOUDS] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CLOUDS] @property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature.""" - return self.coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_FEELS_LIKE_TEMPERATURE] @property def native_temperature(self) -> float | None: """Return the temperature.""" - return self.coordinator.data[ATTR_API_TEMPERATURE] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_TEMPERATURE] @property def native_pressure(self) -> float | None: """Return the pressure.""" - return self.coordinator.data[ATTR_API_PRESSURE] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_PRESSURE] @property def humidity(self) -> float | None: """Return the humidity.""" - return self.coordinator.data[ATTR_API_HUMIDITY] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_HUMIDITY] @property def native_dew_point(self) -> float | None: """Return the dew point.""" - return self.coordinator.data[ATTR_API_DEW_POINT] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_DEW_POINT] @property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed.""" - return self.coordinator.data[ATTR_API_WIND_GUST] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_GUST] @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" - return self.coordinator.data[ATTR_API_WIND_SPEED] + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_SPEED] @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" - return self.coordinator.data[ATTR_API_WIND_BEARING] - - @property - def _forecast(self) -> list[Forecast] | None: - """Return the forecast array.""" - api_forecasts = self.coordinator.data[ATTR_API_FORECAST] - forecasts = [ - { - ha_key: forecast[api_key] - for api_key, ha_key in FORECAST_MAP.items() - if api_key in forecast - } - for forecast in api_forecasts - ] - return cast(list[Forecast], forecasts) + return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_BEARING] @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" - return self._forecast + return self.coordinator.data[ATTR_API_DAILY_FORECAST] @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" - return self._forecast + return self.coordinator.data[ATTR_API_HOURLY_FORECAST] diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py deleted file mode 100644 index d54a7fa899f..00000000000 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Weather data coordinator for the OpenWeatherMap (OWM) service.""" - -import asyncio -from datetime import timedelta -import logging - -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError - -from homeassistant.components.weather import ( - ATTR_CONDITION_CLEAR_NIGHT, - ATTR_CONDITION_SUNNY, -) -from homeassistant.const import UnitOfTemperature -from homeassistant.helpers import sun -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util -from homeassistant.util.unit_conversion import TemperatureConverter - -from .const import ( - ATTR_API_CLOUDS, - ATTR_API_CONDITION, - ATTR_API_DEW_POINT, - ATTR_API_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST, - ATTR_API_FORECAST_CLOUDS, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE, - ATTR_API_FORECAST_HUMIDITY, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRESSURE, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED, - ATTR_API_HUMIDITY, - ATTR_API_PRECIPITATION_KIND, - ATTR_API_PRESSURE, - ATTR_API_RAIN, - ATTR_API_SNOW, - ATTR_API_TEMPERATURE, - ATTR_API_UV_INDEX, - ATTR_API_VISIBILITY_DISTANCE, - ATTR_API_WEATHER, - ATTR_API_WEATHER_CODE, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_GUST, - ATTR_API_WIND_SPEED, - CONDITION_MAP, - DOMAIN, - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, - FORECAST_MODE_ONECALL_DAILY, - FORECAST_MODE_ONECALL_HOURLY, - WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT, -) - -_LOGGER = logging.getLogger(__name__) - -WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) - - -class WeatherUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module - """Weather data update coordinator.""" - - def __init__(self, owm, latitude, longitude, forecast_mode, hass): - """Initialize coordinator.""" - self._owm_client = owm - self._latitude = latitude - self._longitude = longitude - self.forecast_mode = forecast_mode - self._forecast_limit = None - if forecast_mode == FORECAST_MODE_DAILY: - self._forecast_limit = 15 - - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL - ) - - async def _async_update_data(self): - """Update the data.""" - data = {} - async with asyncio.timeout(20): - try: - weather_response = await self._get_owm_weather() - data = self._convert_weather_response(weather_response) - except (APIRequestError, UnauthorizedError) as error: - raise UpdateFailed(error) from error - return data - - async def _get_owm_weather(self): - """Poll weather data from OWM.""" - if self.forecast_mode in ( - FORECAST_MODE_ONECALL_HOURLY, - FORECAST_MODE_ONECALL_DAILY, - ): - weather = await self.hass.async_add_executor_job( - self._owm_client.one_call, self._latitude, self._longitude - ) - else: - weather = await self.hass.async_add_executor_job( - self._get_legacy_weather_and_forecast - ) - - return weather - - def _get_legacy_weather_and_forecast(self): - """Get weather and forecast data from OWM.""" - interval = self._get_legacy_forecast_interval() - weather = self._owm_client.weather_at_coords(self._latitude, self._longitude) - forecast = self._owm_client.forecast_at_coords( - self._latitude, self._longitude, interval, self._forecast_limit - ) - return LegacyWeather(weather.weather, forecast.forecast.weathers) - - def _get_legacy_forecast_interval(self): - """Get the correct forecast interval depending on the forecast mode.""" - interval = "daily" - if self.forecast_mode == FORECAST_MODE_HOURLY: - interval = "3h" - return interval - - def _convert_weather_response(self, weather_response): - """Format the weather response correctly.""" - current_weather = weather_response.current - forecast_weather = self._get_forecast_from_weather_response(weather_response) - - return { - ATTR_API_TEMPERATURE: current_weather.temperature("celsius").get("temp"), - ATTR_API_FEELS_LIKE_TEMPERATURE: current_weather.temperature("celsius").get( - "feels_like" - ), - ATTR_API_DEW_POINT: self._fmt_dewpoint(current_weather.dewpoint), - ATTR_API_PRESSURE: current_weather.pressure.get("press"), - ATTR_API_HUMIDITY: current_weather.humidity, - ATTR_API_WIND_BEARING: current_weather.wind().get("deg"), - ATTR_API_WIND_GUST: current_weather.wind().get("gust"), - ATTR_API_WIND_SPEED: current_weather.wind().get("speed"), - ATTR_API_CLOUDS: current_weather.clouds, - ATTR_API_RAIN: self._get_rain(current_weather.rain), - ATTR_API_SNOW: self._get_snow(current_weather.snow), - ATTR_API_PRECIPITATION_KIND: self._calc_precipitation_kind( - current_weather.rain, current_weather.snow - ), - ATTR_API_WEATHER: current_weather.detailed_status, - ATTR_API_CONDITION: self._get_condition(current_weather.weather_code), - ATTR_API_UV_INDEX: current_weather.uvi, - ATTR_API_VISIBILITY_DISTANCE: current_weather.visibility_distance, - ATTR_API_WEATHER_CODE: current_weather.weather_code, - ATTR_API_FORECAST: forecast_weather, - } - - def _get_forecast_from_weather_response(self, weather_response): - """Extract the forecast data from the weather response.""" - forecast_arg = "forecast" - if self.forecast_mode == FORECAST_MODE_ONECALL_HOURLY: - forecast_arg = "forecast_hourly" - elif self.forecast_mode == FORECAST_MODE_ONECALL_DAILY: - forecast_arg = "forecast_daily" - return [ - self._convert_forecast(x) for x in getattr(weather_response, forecast_arg) - ] - - def _convert_forecast(self, entry): - """Convert the forecast data.""" - forecast = { - ATTR_API_FORECAST_TIME: dt_util.utc_from_timestamp( - entry.reference_time("unix") - ).isoformat(), - ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation( - entry.rain, entry.snow - ), - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ( - round(entry.precipitation_probability * 100) - ), - ATTR_API_FORECAST_PRESSURE: entry.pressure.get("press"), - ATTR_API_FORECAST_WIND_SPEED: entry.wind().get("speed"), - ATTR_API_FORECAST_WIND_BEARING: entry.wind().get("deg"), - ATTR_API_FORECAST_CONDITION: self._get_condition( - entry.weather_code, entry.reference_time("unix") - ), - ATTR_API_FORECAST_CLOUDS: entry.clouds, - ATTR_API_FORECAST_FEELS_LIKE_TEMPERATURE: entry.temperature("celsius").get( - "feels_like_day" - ), - ATTR_API_FORECAST_HUMIDITY: entry.humidity, - } - - temperature_dict = entry.temperature("celsius") - if "max" in temperature_dict and "min" in temperature_dict: - forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("max") - forecast[ATTR_API_FORECAST_TEMP_LOW] = entry.temperature("celsius").get( - "min" - ) - else: - forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("temp") - - return forecast - - @staticmethod - def _fmt_dewpoint(dewpoint): - """Format the dewpoint data.""" - if dewpoint is not None: - return round( - TemperatureConverter.convert( - dewpoint, UnitOfTemperature.KELVIN, UnitOfTemperature.CELSIUS - ), - 1, - ) - return None - - @staticmethod - def _get_rain(rain): - """Get rain data from weather data.""" - if "all" in rain: - return round(rain["all"], 2) - if "3h" in rain: - return round(rain["3h"], 2) - if "1h" in rain: - return round(rain["1h"], 2) - return 0 - - @staticmethod - def _get_snow(snow): - """Get snow data from weather data.""" - if snow: - if "all" in snow: - return round(snow["all"], 2) - if "3h" in snow: - return round(snow["3h"], 2) - if "1h" in snow: - return round(snow["1h"], 2) - return 0 - - @staticmethod - def _calc_precipitation(rain, snow): - """Calculate the precipitation.""" - rain_value = 0 - if WeatherUpdateCoordinator._get_rain(rain) != 0: - rain_value = WeatherUpdateCoordinator._get_rain(rain) - - snow_value = 0 - if WeatherUpdateCoordinator._get_snow(snow) != 0: - snow_value = WeatherUpdateCoordinator._get_snow(snow) - - return round(rain_value + snow_value, 2) - - @staticmethod - def _calc_precipitation_kind(rain, snow): - """Determine the precipitation kind.""" - if WeatherUpdateCoordinator._get_rain(rain) != 0: - if WeatherUpdateCoordinator._get_snow(snow) != 0: - return "Snow and Rain" - return "Rain" - - if WeatherUpdateCoordinator._get_snow(snow) != 0: - return "Snow" - return "None" - - def _get_condition(self, weather_code, timestamp=None): - """Get weather condition from weather data.""" - if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: - if timestamp: - timestamp = dt_util.utc_from_timestamp(timestamp) - - if sun.is_up(self.hass, timestamp): - return ATTR_CONDITION_SUNNY - return ATTR_CONDITION_CLEAR_NIGHT - - return CONDITION_MAP.get(weather_code) - - -class LegacyWeather: - """Class to harmonize weather data model for hourly, daily and One Call APIs.""" - - def __init__(self, current_weather, forecast): - """Initialize weather object.""" - self.current = current_weather - self.forecast = forecast diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 858d14dd832..bbd9315eaa3 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -17,7 +17,7 @@ from opower import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -161,4 +161,5 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema(schema), errors=errors, + description_placeholders={CONF_NAME: self.reauth_entry.title}, ) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 91e4fbc960c..d419fdcb043 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.4.4"] + "requirements": ["opower==0.4.7"] } diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index b6e52c1284d..328a2a1f98a 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -128,7 +128,9 @@ async def async_setup_entry( class OralBBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[str | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[str | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a OralB sensor.""" diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py index 20ff22cea23..ca6d52941f7 100644 --- a/homeassistant/components/osoenergy/__init__.py +++ b/homeassistant/components/osoenergy/__init__.py @@ -1,14 +1,9 @@ """Support for the OSO Energy devices and services.""" -from typing import Any, Generic, TypeVar +from typing import Any 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 @@ -16,24 +11,16 @@ 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.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity from .const import DOMAIN -_OSOEnergyT = TypeVar( - "_OSOEnergyT", - OSOEnergyBinarySensorData, - OSOEnergySensorData, - OSOEnergyWaterHeaterData, -) - -MANUFACTURER = "OSO Energy" PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.SENSOR, Platform.WATER_HEATER, ] PLATFORM_LOOKUP = { + Platform.BINARY_SENSOR: "binary_sensor", Platform.SENSOR: "sensor", Platform.WATER_HEATER: "water_heater", } @@ -75,20 +62,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class OSOEnergyEntity(Entity, Generic[_OSOEnergyT]): - """Initiate OSO Energy Base Class.""" - - _attr_has_entity_name = True - - def __init__(self, osoenergy: OSOEnergy, entity_data: _OSOEnergyT) -> None: - """Initialize the instance.""" - self.osoenergy = osoenergy - self.entity_data = entity_data - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entity_data.device_id)}, - manufacturer=MANUFACTURER, - model=entity_data.device_type, - name=entity_data.device_name, - ) diff --git a/homeassistant/components/osoenergy/binary_sensor.py b/homeassistant/components/osoenergy/binary_sensor.py new file mode 100644 index 00000000000..0cf0ac74d36 --- /dev/null +++ b/homeassistant/components/osoenergy/binary_sensor.py @@ -0,0 +1,91 @@ +"""Support for OSO Energy binary sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import OSOEnergyBinarySensorData + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import OSOEnergyEntity + + +@dataclass(frozen=True, kw_only=True) +class OSOEnergyBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing OSO Energy heater binary sensor entities.""" + + value_fn: Callable[[OSOEnergy], bool] + + +SENSOR_TYPES: dict[str, OSOEnergyBinarySensorEntityDescription] = { + "power_save": OSOEnergyBinarySensorEntityDescription( + key="power_save", + translation_key="power_save", + value_fn=lambda entity_data: entity_data.state, + ), + "extra_energy": OSOEnergyBinarySensorEntityDescription( + key="extra_energy", + translation_key="extra_energy", + value_fn=lambda entity_data: entity_data.state, + ), + "heater_state": OSOEnergyBinarySensorEntityDescription( + key="heating", + translation_key="heating", + value_fn=lambda entity_data: entity_data.state, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy binary sensor.""" + osoenergy: OSOEnergy = hass.data[DOMAIN][entry.entry_id] + entities = [ + OSOEnergyBinarySensor(osoenergy, sensor_type, dev) + for dev in osoenergy.session.device_list.get("binary_sensor", []) + if (sensor_type := SENSOR_TYPES.get(dev.osoEnergyType.lower())) + ] + + async_add_entities(entities, True) + + +class OSOEnergyBinarySensor( + OSOEnergyEntity[OSOEnergyBinarySensorData], BinarySensorEntity +): + """OSO Energy Sensor Entity.""" + + entity_description: OSOEnergyBinarySensorEntityDescription + + def __init__( + self, + instance: OSOEnergy, + description: OSOEnergyBinarySensorEntityDescription, + entity_data: OSOEnergyBinarySensorData, + ) -> None: + """Set up OSO Energy binary sensor.""" + super().__init__(instance, entity_data) + + device_id = entity_data.device_id + self._attr_unique_id = f"{device_id}_{description.key}" + self.entity_description = description + + @property + def is_on(self) -> bool | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.entity_data) + + async def async_update(self) -> None: + """Update all data for OSO Energy.""" + await self.osoenergy.session.update_data() + self.entity_data = await self.osoenergy.binary_sensor.get_sensor( + self.entity_data + ) diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py index ce0932571e5..e0afc5292ae 100644 --- a/homeassistant/components/osoenergy/config_flow.py +++ b/homeassistant/components/osoenergy/config_flow.py @@ -64,7 +64,7 @@ class OSOEnergyFlowHandler(ConfigFlow, domain=DOMAIN): 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 + except Exception: _LOGGER.exception("Unknown error occurred") return None diff --git a/homeassistant/components/osoenergy/entity.py b/homeassistant/components/osoenergy/entity.py new file mode 100644 index 00000000000..2a2210339d7 --- /dev/null +++ b/homeassistant/components/osoenergy/entity.py @@ -0,0 +1,38 @@ +"""Parent class for every OSO Energy device.""" + +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +MANUFACTURER = "OSO Energy" + + +class OSOEnergyEntity[ + _OSOEnergyT: ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, + ) +](Entity): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, entity_data: _OSOEnergyT) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.entity_data = entity_data + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entity_data.device_id)}, + manufacturer=MANUFACTURER, + model=entity_data.device_type, + name=entity_data.device_name, + ) diff --git a/homeassistant/components/osoenergy/icons.json b/homeassistant/components/osoenergy/icons.json new file mode 100644 index 00000000000..60b2d257b8a --- /dev/null +++ b/homeassistant/components/osoenergy/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "binary_sensor": { + "power_save": { + "default": "mdi:power-sleep" + }, + "extra_energy": { + "default": "mdi:white-balance-sunny" + }, + "heating": { + "default": "mdi:water-boiler" + } + } + } +} diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index d6813108242..c7b81177a2b 100644 --- a/homeassistant/components/osoenergy/manifest.json +++ b/homeassistant/components/osoenergy/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/osoenergy", "iot_class": "cloud_polling", - "requirements": ["pyosoenergyapi==1.1.3"] + "requirements": ["pyosoenergyapi==1.1.4"] } diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py index 0be6ad83281..40ec33e3e02 100644 --- a/homeassistant/components/osoenergy/sensor.py +++ b/homeassistant/components/osoenergy/sensor.py @@ -13,13 +13,18 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import OSOEnergyEntity from .const import DOMAIN +from .entity import OSOEnergyEntity @dataclass(frozen=True, kw_only=True) @@ -101,6 +106,34 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = { native_unit_of_measurement=UnitOfVolume.LITERS, value_fn=lambda entity_data: entity_data.state, ), + "temperature_top": OSOEnergySensorEntityDescription( + key="temperature_top", + translation_key="temperature_top", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda entity_data: entity_data.state, + ), + "temperature_mid": OSOEnergySensorEntityDescription( + key="temperature_mid", + translation_key="temperature_mid", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda entity_data: entity_data.state, + ), + "temperature_low": OSOEnergySensorEntityDescription( + key="temperature_low", + translation_key="temperature_low", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda entity_data: entity_data.state, + ), + "temperature_one": OSOEnergySensorEntityDescription( + key="temperature_one", + translation_key="temperature_one", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda entity_data: entity_data.state, + ), } diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index 5313f1d6565..a7963bfa436 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -25,6 +25,17 @@ } }, "entity": { + "binary_sensor": { + "power_save": { + "name": "Power save" + }, + "extra_energy": { + "name": "Extra energy" + }, + "heating": { + "name": "Heating" + } + }, "sensor": { "tapping_capacity": { "name": "Tapping capacity" @@ -66,6 +77,18 @@ }, "profile": { "name": "Profile local" + }, + "temperature_top": { + "name": "Temperature top" + }, + "temperature_mid": { + "name": "Temperature middle" + }, + "temperature_low": { + "name": "Temperature bottom" + }, + "temperature_one": { + "name": "Temperature one" } } } diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index b7fb2ba16e6..55229e42c2f 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -18,8 +18,8 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OSOEnergyEntity from .const import DOMAIN +from .entity import OSOEnergyEntity CURRENT_OPERATION_MAP: dict[str, Any] = { "default": { diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 4374412b8c1..16cf3b60e37 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -7,7 +7,7 @@ import dataclasses from functools import wraps import logging import random -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, cast import python_otbr_api from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser @@ -27,9 +27,6 @@ from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -_R = TypeVar("_R") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) INFO_URL_SKY_CONNECT = ( @@ -61,7 +58,7 @@ def generate_random_pan_id() -> int: return random.randint(0, 0xFFFE) -def _handle_otbr_error( +def _handle_otbr_error[**_P, _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/otp/__init__.py b/homeassistant/components/otp/__init__.py index bf80d41a92d..5b18301874a 100644 --- a/homeassistant/components/otp/__init__.py +++ b/homeassistant/components/otp/__init__.py @@ -1 +1,22 @@ -"""The otp component.""" +"""The One-Time Password (OTP) 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.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up One-Time Password (OTP) 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/otp/config_flow.py b/homeassistant/components/otp/config_flow.py new file mode 100644 index 00000000000..15d04c910ad --- /dev/null +++ b/homeassistant/components/otp/config_flow.py @@ -0,0 +1,140 @@ +"""Config flow for One-Time Password (OTP) integration.""" + +from __future__ import annotations + +import binascii +import logging +from typing import Any + +import pyotp +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CODE, CONF_NAME, CONF_TOKEN +from homeassistant.helpers.selector import ( + BooleanSelector, + BooleanSelectorConfig, + QrCodeSelector, + QrCodeSelectorConfig, + QrErrorCorrectionLevel, +) + +from .const import CONF_NEW_TOKEN, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_TOKEN): str, + vol.Optional(CONF_NEW_TOKEN): BooleanSelector(BooleanSelectorConfig()), + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + } +) + +STEP_CONFIRM_DATA_SCHEMA = vol.Schema({vol.Required(CONF_CODE): str}) + + +class TOTPConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for One-Time Password (OTP).""" + + VERSION = 1 + user_input: dict[str, Any] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + if user_input.get(CONF_TOKEN) and not user_input.get(CONF_NEW_TOKEN): + try: + await self.hass.async_add_executor_job( + pyotp.TOTP(user_input[CONF_TOKEN]).now + ) + except binascii.Error: + errors["base"] = "invalid_token" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_TOKEN]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + elif user_input.get(CONF_NEW_TOKEN): + user_input[CONF_TOKEN] = await self.hass.async_add_executor_job( + pyotp.random_base32 + ) + self.user_input = user_input + return await self.async_step_confirm() + else: + errors["base"] = "invalid_token" + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import config from yaml.""" + + await self.async_set_unique_id(import_info[CONF_TOKEN]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_info.get(CONF_NAME, DEFAULT_NAME), + data=import_info, + ) + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the confirmation step.""" + + errors: dict[str, str] = {} + + if user_input is not None: + if await self.hass.async_add_executor_job( + pyotp.TOTP(self.user_input[CONF_TOKEN]).verify, user_input["code"] + ): + return self.async_create_entry( + title=self.user_input[CONF_NAME], + data={ + CONF_NAME: self.user_input[CONF_NAME], + CONF_TOKEN: self.user_input[CONF_TOKEN], + }, + ) + + errors["base"] = "invalid_code" + + provisioning_uri = await self.hass.async_add_executor_job( + pyotp.TOTP(self.user_input[CONF_TOKEN]).provisioning_uri, + self.user_input[CONF_NAME], + "Home Assistant", + ) + data_schema = STEP_CONFIRM_DATA_SCHEMA.extend( + { + vol.Optional("qr_code"): QrCodeSelector( + config=QrCodeSelectorConfig( + data=provisioning_uri, + scale=6, + error_correction_level=QrErrorCorrectionLevel.QUARTILE, + ) + ) + } + ) + return self.async_show_form( + step_id="confirm", + data_schema=data_schema, + description_placeholders={ + "auth_app1": "[Google Authenticator](https://support.google.com/accounts/answer/1066447)", + "auth_app2": "[Authy](https://authy.com/)", + "code": self.user_input[CONF_TOKEN], + }, + errors=errors, + ) diff --git a/homeassistant/components/otp/const.py b/homeassistant/components/otp/const.py new file mode 100644 index 00000000000..6ccec165ec5 --- /dev/null +++ b/homeassistant/components/otp/const.py @@ -0,0 +1,5 @@ +"""Constants for the One-Time Password (OTP) integration.""" + +DOMAIN = "otp" +DEFAULT_NAME = "OTP Sensor" +CONF_NEW_TOKEN = "new_token" diff --git a/homeassistant/components/otp/icons.json b/homeassistant/components/otp/icons.json new file mode 100644 index 00000000000..1cab872e8f8 --- /dev/null +++ b/homeassistant/components/otp/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "token": { + "default": "mdi:lock-clock" + } + } + } +} diff --git a/homeassistant/components/otp/manifest.json b/homeassistant/components/otp/manifest.json index 758824f8772..f62f89cff40 100644 --- a/homeassistant/components/otp/manifest.json +++ b/homeassistant/components/otp/manifest.json @@ -2,6 +2,7 @@ "domain": "otp", "name": "One-Time Password (OTP)", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/otp", "iot_class": "local_polling", "loggers": ["pyotp"], diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index a9b4368d1e6..466fc994cdb 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -8,13 +8,15 @@ import pyotp import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -DEFAULT_NAME = "OTP Sensor" +from .const import DEFAULT_NAME, DOMAIN TIME_STEP = 30 # Default time step assumed by Google Authenticator @@ -34,46 +36,60 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the OTP sensor.""" - name = config.get(CONF_NAME) - token = config.get(CONF_TOKEN) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2025.1.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "One-Time Password (OTP)", + }, + ) + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) - async_add_entities([TOTPSensor(name, token)], True) + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the OTP sensor.""" + + async_add_entities( + [TOTPSensor(entry.data[CONF_NAME], entry.data[CONF_TOKEN], entry.entry_id)], + True, + ) # Only TOTP supported at the moment, HOTP might be added later class TOTPSensor(SensorEntity): """Representation of a TOTP sensor.""" - _attr_icon = "mdi:update" + _attr_translation_key = "token" _attr_should_poll = False + _attr_native_value: StateType = None + _next_expiration: float | None = None - def __init__(self, name, token): + def __init__(self, name: str, token: str, entry_id: str) -> None: """Initialize the sensor.""" - self._name = name + self._attr_name = name + self._attr_unique_id = entry_id self._otp = pyotp.TOTP(token) - self._state = None - self._next_expiration = None async def async_added_to_hass(self) -> None: """Handle when an entity is about to be added to Home Assistant.""" self._call_loop() @callback - def _call_loop(self): - self._state = self._otp.now() + def _call_loop(self) -> None: + self._attr_native_value = self._otp.now() self.async_write_ha_state() # Update must occur at even TIME_STEP, e.g. 12:00:00, 12:00:30, # 12:01:00, etc. in order to have synced time (see RFC6238) self._next_expiration = TIME_STEP - (time.time() % TIME_STEP) self.hass.loop.call_later(self._next_expiration, self._call_loop) - - @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._state diff --git a/homeassistant/components/otp/strings.json b/homeassistant/components/otp/strings.json new file mode 100644 index 00000000000..9152aeaa89e --- /dev/null +++ b/homeassistant/components/otp/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "token": "Authenticator token (OTP)", + "new_token": "Generate a new token?" + } + }, + "confirm": { + "title": "Verify One-Time Password (OTP)", + "description": "Before completing the setup of One-Time Password (OTP), confirm with a verification code. Scan the QR code with your authentication app. If you don't have one, we recommend either {auth_app1} or {auth_app2}.\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.", + "data": { + "code": "Verification code (OTP)" + } + } + }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_token": "Invalid token", + "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/ourgroceries/config_flow.py b/homeassistant/components/ourgroceries/config_flow.py index 98eae900db6..233ec381556 100644 --- a/homeassistant/components/ourgroceries/config_flow.py +++ b/homeassistant/components/ourgroceries/config_flow.py @@ -43,7 +43,7 @@ class OurGroceriesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidLoginException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index 72c99982a1b..151f91790cf 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -240,6 +240,7 @@ class OverkizAlarmControlPanel(OverkizDescriptiveEntity, AlarmControlPanelEntity """Representation of an Overkiz Alarm Control Panel.""" entity_description: OverkizAlarmDescription + _attr_code_arm_required = False def __init__( self, diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index eb79910d63f..79a8328f874 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -170,7 +170,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): # the Overkiz API server. Login will return unknown user. description_placeholders["unsupported_device"] = "Somfy Protect" errors["base"] = "unsupported_hardware" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" LOGGER.exception("Unknown error") else: @@ -253,7 +253,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN): # the Overkiz API server. Login will return unknown user. description_placeholders["unsupported_device"] = "Somfy Protect" errors["base"] = "unsupported_hardware" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" LOGGER.exception("Unknown error") else: diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index dc2f0df4783..a78eb160a28 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -19,7 +19,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.10"], + "requirements": ["pyoverkiz==1.13.11"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py index 201e76d4a76..8125e9f7a55 100644 --- a/homeassistant/components/p1_monitor/__init__.py +++ b/homeassistant/components/p1_monitor/__init__.py @@ -2,34 +2,13 @@ from __future__ import annotations -from typing import TypedDict - -from p1monitor import ( - P1Monitor, - P1MonitorConnectionError, - P1MonitorNoDataError, - Phases, - Settings, - SmartMeter, - WaterMeter, -) - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ( - DOMAIN, - LOGGER, - SCAN_INTERVAL, - SERVICE_PHASES, - SERVICE_SETTINGS, - SERVICE_SMARTMETER, - SERVICE_WATERMETER, -) +from .const import DOMAIN +from .coordinator import P1MonitorDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -57,55 +36,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class P1MonitorData(TypedDict): - """Class for defining data in dict.""" - - smartmeter: SmartMeter - phases: Phases - settings: Settings - watermeter: WaterMeter | None - - -class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching P1 Monitor data from single endpoint.""" - - config_entry: ConfigEntry - has_water_meter: bool | None = None - - def __init__( - self, - hass: HomeAssistant, - ) -> None: - """Initialize global P1 Monitor data updater.""" - super().__init__( - hass, - LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - self.p1monitor = P1Monitor( - self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) - ) - - async def _async_update_data(self) -> P1MonitorData: - """Fetch data from P1 Monitor.""" - data: P1MonitorData = { - SERVICE_SMARTMETER: await self.p1monitor.smartmeter(), - SERVICE_PHASES: await self.p1monitor.phases(), - SERVICE_SETTINGS: await self.p1monitor.settings(), - SERVICE_WATERMETER: None, - } - - if self.has_water_meter or self.has_water_meter is None: - try: - data[SERVICE_WATERMETER] = await self.p1monitor.watermeter() - self.has_water_meter = True - except (P1MonitorNoDataError, P1MonitorConnectionError): - LOGGER.debug("No water meter data received from P1 Monitor") - if self.has_water_meter is None: - self.has_water_meter = False - - return data diff --git a/homeassistant/components/p1_monitor/coordinator.py b/homeassistant/components/p1_monitor/coordinator.py new file mode 100644 index 00000000000..49844adf39b --- /dev/null +++ b/homeassistant/components/p1_monitor/coordinator.py @@ -0,0 +1,83 @@ +"""Coordinator for the P1 Monitor integration.""" + +from __future__ import annotations + +from typing import TypedDict + +from p1monitor import ( + P1Monitor, + P1MonitorConnectionError, + P1MonitorNoDataError, + Phases, + Settings, + SmartMeter, + WaterMeter, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DOMAIN, + LOGGER, + SCAN_INTERVAL, + SERVICE_PHASES, + SERVICE_SETTINGS, + SERVICE_SMARTMETER, + SERVICE_WATERMETER, +) + + +class P1MonitorData(TypedDict): + """Class for defining data in dict.""" + + smartmeter: SmartMeter + phases: Phases + settings: Settings + watermeter: WaterMeter | None + + +class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): + """Class to manage fetching P1 Monitor data from single endpoint.""" + + config_entry: ConfigEntry + has_water_meter: bool | None = None + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize global P1 Monitor data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.p1monitor = P1Monitor( + self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + + async def _async_update_data(self) -> P1MonitorData: + """Fetch data from P1 Monitor.""" + data: P1MonitorData = { + SERVICE_SMARTMETER: await self.p1monitor.smartmeter(), + SERVICE_PHASES: await self.p1monitor.phases(), + SERVICE_SETTINGS: await self.p1monitor.settings(), + SERVICE_WATERMETER: None, + } + + if self.has_water_meter or self.has_water_meter is None: + try: + data[SERVICE_WATERMETER] = await self.p1monitor.watermeter() + self.has_water_meter = True + except (P1MonitorNoDataError, P1MonitorConnectionError): + LOGGER.debug("No water meter data received from P1 Monitor") + if self.has_water_meter is None: + self.has_water_meter = False + + return data diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index b1b3bd2a506..5fb8cb472e8 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -10,7 +10,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import P1MonitorDataUpdateCoordinator from .const import ( DOMAIN, SERVICE_PHASES, @@ -18,6 +17,7 @@ from .const import ( SERVICE_SMARTMETER, SERVICE_WATERMETER, ) +from .coordinator import P1MonitorDataUpdateCoordinator if TYPE_CHECKING: from _typeshed import DataclassInstance diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index b97383bdae5..88f6d165f14 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -26,7 +26,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import P1MonitorDataUpdateCoordinator from .const import ( DOMAIN, SERVICE_PHASES, @@ -34,6 +33,7 @@ from .const import ( SERVICE_SMARTMETER, SERVICE_WATERMETER, ) +from .coordinator import P1MonitorDataUpdateCoordinator SENSORS_SMARTMETER: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 5c76a7e6900..b2f3bbba91a 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -177,7 +177,7 @@ class Remote: self._control = None self.state = STATE_OFF self.available = self._on_action is not None - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("An unknown error occurred") self._control = None self.state = STATE_OFF @@ -264,7 +264,7 @@ class Remote: self.available = self._on_action is not None await self.async_create_remote_control() return None - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("An unknown error occurred") self.state = STATE_OFF self.available = self._on_action is not None diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index 65a830c9b1a..9cb8fb5da83 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -60,7 +60,7 @@ class PanasonicVieraConfigFlow(ConfigFlow, domain=DOMAIN): except (URLError, SOAPError, OSError) as err: _LOGGER.error("Could not establish remote connection: %s", err) errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("An unknown error occurred") return self.async_abort(reason="unknown") @@ -118,7 +118,7 @@ class PanasonicVieraConfigFlow(ConfigFlow, domain=DOMAIN): except (URLError, OSError) as err: _LOGGER.error("The remote connection was lost: %s", err) return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") return self.async_abort(reason="unknown") @@ -142,7 +142,7 @@ class PanasonicVieraConfigFlow(ConfigFlow, domain=DOMAIN): except (URLError, SOAPError, OSError) as err: _LOGGER.error("The remote connection was lost: %s", err) return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index 168b045ff4d..12979f27793 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Outage Counter Setup county: str = entry.data[CONF_COUNTY] - async def async_update_outage_data() -> OutageResults: + async def async_update_outage_data() -> PECOCoordinatorData: """Fetch data from API.""" try: outages: OutageResults = ( @@ -65,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(f"Error parsing data: {err}") from err return data - coordinator = DataUpdateCoordinator( + outage_coordinator = DataUpdateCoordinator( hass, LOGGER, name="PECO Outage Count", @@ -73,9 +73,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL), ) - await coordinator.async_config_entry_first_refresh() + await outage_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"outage_count": coordinator} + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "outage_count": outage_coordinator + } if phone_number := entry.data.get(CONF_PHONE_NUMBER): # Smart Meter Setup] @@ -92,7 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(f"Error parsing data: {err}") from err return data - coordinator = DataUpdateCoordinator( + meter_coordinator = DataUpdateCoordinator( hass, LOGGER, name="PECO Smart Meter", @@ -100,9 +102,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL), ) - await coordinator.async_config_entry_first_refresh() + await meter_coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id]["smart_meter"] = coordinator + hass.data[DOMAIN][entry.entry_id]["smart_meter"] = meter_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/peco/manifest.json b/homeassistant/components/peco/manifest.json index dd0403d8041..698981e9361 100644 --- a/homeassistant/components/peco/manifest.json +++ b/homeassistant/components/peco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/peco", "iot_class": "cloud_polling", - "requirements": ["peco==0.0.29"] + "requirements": ["peco==0.0.30"] } diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 90f25b00518..2c465342493 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] +type PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) -> bool: diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 175a206b38f..0779140a091 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -24,7 +24,6 @@ from homeassistant.const import ( ATTR_NAME, CONF_ID, CONF_NAME, - CONF_TYPE, EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, STATE_HOME, @@ -54,7 +53,6 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import group as group_pre_import # noqa: F401 from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -307,6 +305,23 @@ class PersonStorageCollection(collection.DictStorageCollection): raise ValueError("User already taken") +class PersonStorageCollectionWebsocket(collection.DictStorageCollectionWebsocket): + """Class to expose storage collection management over websocket.""" + + def ws_list_item( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """List persons.""" + yaml, storage, _ = hass.data[DOMAIN] + connection.send_result( + msg[ATTR_ID], + {"storage": storage.async_items(), "config": yaml.async_items()}, + ) + + async def filter_yaml_data(hass: HomeAssistant, persons: list[dict]) -> list[dict]: """Validate YAML data that we can't validate via schema.""" filtered = [] @@ -370,11 +385,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = (yaml_collection, storage_collection, entity_component) - collection.DictStorageCollectionWebsocket( + PersonStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS - ).async_setup(hass, create_list=False) - - websocket_api.async_register_command(hass, ws_list_person) + ).async_setup(hass) async def _handle_user_removed(event: Event) -> None: """Handle a user being removed.""" @@ -570,19 +583,6 @@ class Person( self._attr_extra_state_attributes = data -@websocket_api.websocket_command({vol.Required(CONF_TYPE): "person/list"}) -def ws_list_person( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """List persons.""" - yaml, storage, _ = hass.data[DOMAIN] - connection.send_result( - msg[ATTR_ID], {"storage": storage.async_items(), "config": yaml.async_items()} - ) - - def _get_latest(prev: State | None, curr: State) -> State: """Get latest state.""" if prev is None or curr.last_updated > prev.last_updated: diff --git a/homeassistant/components/person/group.py b/homeassistant/components/person/group.py deleted file mode 100644 index 1c28887c2ca..00000000000 --- a/homeassistant/components/person/group.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_HOME, STATE_NOT_HOME -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states(DOMAIN, {STATE_HOME}, STATE_HOME, STATE_NOT_HOME) diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index e56d1cdc651..93f869e849d 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -2,18 +2,9 @@ from __future__ import annotations -import asyncio -from collections.abc import Mapping -from datetime import timedelta import logging -from typing import Any -from haphilipsjs import ( - AutenticationFailure, - ConnectionFailure, - GeneralFailure, - PhilipsTV, -) +from haphilipsjs import PhilipsTV from haphilipsjs.typing import SystemType from homeassistant.config_entries import ConfigEntry @@ -24,13 +15,10 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.core import HomeAssistant -from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN +from .const import CONF_SYSTEM +from .coordinator import PhilipsTVDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -42,8 +30,10 @@ PLATFORMS = [ LOGGER = logging.getLogger(__name__) +PhilipsTVConfigEntry = ConfigEntry[PhilipsTVDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: PhilipsTVConfigEntry) -> bool: """Set up Philips TV from a config entry.""" system: SystemType | None = entry.data.get(CONF_SYSTEM) @@ -62,8 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = {**entry.data, CONF_SYSTEM: actual_system} hass.config_entries.async_update_entry(entry, data=data) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -72,127 +61,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_entry(hass: HomeAssistant, entry: PhilipsTVConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PhilipsTVConfigEntry) -> 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 PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """Coordinator to update data.""" - - config_entry: ConfigEntry - - def __init__( - self, hass: HomeAssistant, api: PhilipsTV, options: Mapping[str, Any] - ) -> None: - """Set up the coordinator.""" - self.api = api - self.options = options - self._notify_future: asyncio.Task | None = None - - super().__init__( - hass, - LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=30), - request_refresh_debouncer=Debouncer( - hass, LOGGER, cooldown=2.0, immediate=False - ), - ) - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={ - (DOMAIN, self.unique_id), - }, - manufacturer="Philips", - model=self.system.get("model"), - name=self.system["name"], - sw_version=self.system.get("softwareversion"), - ) - - @property - def system(self) -> SystemType: - """Return the system descriptor.""" - if self.api.system: - return self.api.system - return self.config_entry.data[CONF_SYSTEM] - - @property - def unique_id(self) -> str: - """Return the system descriptor.""" - entry = self.config_entry - if entry.unique_id: - return entry.unique_id - assert entry.entry_id - return entry.entry_id - - @property - def _notify_wanted(self): - """Return if the notify feature should be active. - - We only run it when TV is considered fully on. When powerstate is in standby, the TV - will go in low power states and seemingly break the http server in odd ways. - """ - return ( - self.api.on - and self.api.powerstate == "On" - and self.api.notify_change_supported - and self.options.get(CONF_ALLOW_NOTIFY, False) - ) - - async def _notify_task(self): - while self._notify_wanted: - try: - res = await self.api.notifyChange(130) - except (ConnectionFailure, AutenticationFailure): - res = None - - if res: - self.async_set_updated_data(None) - elif res is None: - LOGGER.debug("Aborting notify due to unexpected return") - break - - @callback - def _async_notify_stop(self): - if self._notify_future: - self._notify_future.cancel() - self._notify_future = None - - @callback - def _async_notify_schedule(self): - if self._notify_future and not self._notify_future.done(): - return - - if self._notify_wanted: - self._notify_future = asyncio.create_task(self._notify_task()) - - @callback - def _unschedule_refresh(self) -> None: - """Remove data update.""" - super()._unschedule_refresh() - self._async_notify_stop() - - async def _async_update_data(self): - """Fetch the latest data from the source.""" - try: - await self.api.update() - self._async_notify_schedule() - except ConnectionFailure: - pass - except AutenticationFailure as exception: - raise ConfigEntryAuthFailed(str(exception)) from exception - except GeneralFailure as exception: - raise UpdateFailed(str(exception)) from exception + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index a21d1416192..6de814efd97 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -10,12 +10,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PhilipsTVDataUpdateCoordinator -from .const import DOMAIN +from . import PhilipsTVConfigEntry +from .coordinator import PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity @@ -42,13 +41,11 @@ DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PhilipsTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the configuration entry.""" - coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data if ( coordinator.api.json_feature_supported("recordings", "List") diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index ed0fce05f46..a73145f7c1c 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -169,7 +169,7 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): except ConnectionFailure as exc: LOGGER.error(exc) errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/philips_js/coordinator.py b/homeassistant/components/philips_js/coordinator.py new file mode 100644 index 00000000000..cae59fa5123 --- /dev/null +++ b/homeassistant/components/philips_js/coordinator.py @@ -0,0 +1,140 @@ +"""Coordinator for the Philips TV integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any + +from haphilipsjs import ( + AutenticationFailure, + ConnectionFailure, + GeneralFailure, + PhilipsTV, +) +from haphilipsjs.typing import SystemType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator to update data.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, api: PhilipsTV, options: Mapping[str, Any] + ) -> None: + """Set up the coordinator.""" + self.api = api + self.options = options + self._notify_future: asyncio.Task | None = None + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=2.0, immediate=False + ), + ) + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Philips", + model=self.system.get("model"), + name=self.system["name"], + sw_version=self.system.get("softwareversion"), + ) + + @property + def system(self) -> SystemType: + """Return the system descriptor.""" + if self.api.system: + return self.api.system + return self.config_entry.data[CONF_SYSTEM] + + @property + def unique_id(self) -> str: + """Return the system descriptor.""" + entry = self.config_entry + if entry.unique_id: + return entry.unique_id + assert entry.entry_id + return entry.entry_id + + @property + def _notify_wanted(self): + """Return if the notify feature should be active. + + We only run it when TV is considered fully on. When powerstate is in standby, the TV + will go in low power states and seemingly break the http server in odd ways. + """ + return ( + self.api.on + and self.api.powerstate == "On" + and self.api.notify_change_supported + and self.options.get(CONF_ALLOW_NOTIFY, False) + ) + + async def _notify_task(self): + while self._notify_wanted: + try: + res = await self.api.notifyChange(130) + except (ConnectionFailure, AutenticationFailure): + res = None + + if res: + self.async_set_updated_data(None) + elif res is None: + _LOGGER.debug("Aborting notify due to unexpected return") + break + + @callback + def _async_notify_stop(self): + if self._notify_future: + self._notify_future.cancel() + self._notify_future = None + + @callback + def _async_notify_schedule(self): + if self._notify_future and not self._notify_future.done(): + return + + if self._notify_wanted: + self._notify_future = asyncio.create_task(self._notify_task()) + + @callback + def _unschedule_refresh(self) -> None: + """Remove data update.""" + super()._unschedule_refresh() + self._async_notify_stop() + + async def _async_update_data(self): + """Fetch the latest data from the source.""" + try: + await self.api.update() + self._async_notify_schedule() + except ConnectionFailure: + pass + except AutenticationFailure as exception: + raise ConfigEntryAuthFailed(str(exception)) from exception + except GeneralFailure as exception: + raise UpdateFailed(str(exception)) from exception diff --git a/homeassistant/components/philips_js/diagnostics.py b/homeassistant/components/philips_js/diagnostics.py index 34cc71c9b94..625b77f6c25 100644 --- a/homeassistant/components/philips_js/diagnostics.py +++ b/homeassistant/components/philips_js/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import PhilipsTVDataUpdateCoordinator -from .const import DOMAIN +from . import PhilipsTVConfigEntry TO_REDACT = { "serialnumber_encrypted", @@ -24,10 +22,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PhilipsTVConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data api = coordinator.api return { diff --git a/homeassistant/components/philips_js/entity.py b/homeassistant/components/philips_js/entity.py index e0d97f940d0..8d8090318f9 100644 --- a/homeassistant/components/philips_js/entity.py +++ b/homeassistant/components/philips_js/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVDataUpdateCoordinator class PhilipsJsEntity(CoordinatorEntity[PhilipsTVDataUpdateCoordinator]): diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 6a91b872913..8e500592704 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -16,14 +16,13 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv -from . import PhilipsTVDataUpdateCoordinator -from .const import DOMAIN +from . import PhilipsTVConfigEntry +from .coordinator import PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity EFFECT_PARTITION = ": " @@ -35,11 +34,11 @@ EFFECT_EXPERT_STYLES = {"FOLLOW_AUDIO", "FOLLOW_COLOR", "Lounge light"} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PhilipsTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the configuration entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities([PhilipsTVLightEntity(coordinator)]) @@ -380,3 +379,12 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): self._update_from_coordinator() self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true if entity is available.""" + if not super().available: + return False + if not self.coordinator.api.on: + return False + return self.coordinator.api.powerstate == "On" diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index 4751e85d378..bba9a1a8762 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/philips_js", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.1.1"] + "requirements": ["ha-philipsjs==3.2.2"] } diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index c8b89d57854..bd8727ae9c1 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -16,14 +16,13 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from . import LOGGER as _LOGGER, PhilipsTVDataUpdateCoordinator -from .const import DOMAIN +from . import LOGGER as _LOGGER, PhilipsTVConfigEntry +from .coordinator import PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger @@ -49,11 +48,11 @@ def _inverted(data): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PhilipsTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the configuration entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( [ PhilipsTVMediaPlayer( diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py index 5972724c54b..f8d9cb0885d 100644 --- a/homeassistant/components/philips_js/remote.py +++ b/homeassistant/components/philips_js/remote.py @@ -12,24 +12,23 @@ from homeassistant.components.remote import ( DEFAULT_DELAY_SECS, RemoteEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from . import LOGGER, PhilipsTVDataUpdateCoordinator -from .const import DOMAIN +from . import LOGGER, PhilipsTVConfigEntry +from .coordinator import PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PhilipsTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the configuration entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities([PhilipsTVRemote(coordinator)]) diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index 697e7f2f060..b35b2ad4ff1 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -5,12 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PhilipsTVDataUpdateCoordinator -from .const import DOMAIN +from . import PhilipsTVConfigEntry +from .coordinator import PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity HUE_POWER_OFF = "Off" @@ -19,13 +18,11 @@ HUE_POWER_ON = "On" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PhilipsTVConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the configuration entry.""" - coordinator: PhilipsTVDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities([PhilipsTVScreenSwitch(coordinator)]) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 05d301b5250..ad36b664994 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -42,7 +42,7 @@ PLATFORMS = [ Platform.UPDATE, ] -PiHoleConfigEntry = ConfigEntry["PiHoleData"] +type PiHoleConfigEntry = ConfigEntry[PiHoleData] @dataclass @@ -60,7 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo use_tls = entry.data[CONF_SSL] verify_tls = entry.data[CONF_VERIFY_SSL] location = entry.data[CONF_LOCATION] - api_key = entry.data.get(CONF_API_KEY) + api_key = entry.data.get(CONF_API_KEY, "") # remove obsolet CONF_STATISTICS_ONLY from entry.data if CONF_STATISTICS_ONLY in entry.data: diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 9712286b554..3023b5309de 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -102,7 +102,7 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index f820daee54b..c01fc00a29e 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -76,7 +76,7 @@ async def handle_add_product( ) -def product_search(api_client: PicnicAPI, product_name: str | None) -> None | str: +def product_search(api_client: PicnicAPI, product_name: str | None) -> str | None: """Query the api client for the product name.""" if product_name is None: return None diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 1f1eee0c92a..21d5603e4c2 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -7,7 +7,7 @@ from datetime import timedelta import functools import logging import threading -from typing import Any, ParamSpec +from typing import Any from pilight import pilight import voluptuous as vol @@ -26,8 +26,6 @@ from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) CONF_SEND_DELAY = "send_delay" @@ -147,7 +145,7 @@ class CallRateDelayThrottle: self._next_ts = dt_util.utcnow() self._schedule = functools.partial(track_point_in_utc_time, hass) - def limited(self, method: Callable[_P, Any]) -> Callable[_P, None]: + def limited[**_P](self, method: Callable[_P, Any]) -> Callable[_P, None]: """Decorate to delay calls on a certain method.""" @functools.wraps(method) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index e75b36dc38d..f4a04caae5b 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -19,7 +19,7 @@ from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] @@ -28,7 +28,9 @@ class PingDomainData: """Dataclass to store privileged status.""" privileged: bool | None - coordinators: dict[str, PingUpdateCoordinator] + + +type PingConfigEntry = ConfigEntry[PingUpdateCoordinator] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -36,13 +38,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = PingDomainData( privileged=await _can_use_icmp_lib_with_privilege(), - coordinators={}, ) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool: """Set up Ping (ICMP) from a config entry.""" data: PingDomainData = hass.data[DOMAIN] @@ -60,7 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - data.coordinators[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -68,22 +69,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - # drop coordinator for config entry - hass.data[DOMAIN].coordinators.pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _can_use_icmp_lib_with_privilege() -> None | bool: +async def _can_use_icmp_lib_with_privilege() -> bool | None: """Verify we can create a raw socket.""" try: await async_ping("127.0.0.1", count=0, timeout=0, privileged=True) diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 35d4e218dce..93f4e0f3896 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -2,87 +2,32 @@ from __future__ import annotations -import logging from typing import Any -import voluptuous as vol - from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import PingDomainData -from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN +from . import PingConfigEntry +from .const import CONF_IMPORTED_BY from .coordinator import PingUpdateCoordinator from .entity import PingEntity -_LOGGER = logging.getLogger(__name__) - ATTR_ROUND_TRIP_TIME_AVG = "round_trip_time_avg" ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max" ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev" ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min" -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): vol.Range( - min=1, max=100 - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """YAML init: import via config flow.""" - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_IMPORTED_BY: "binary_sensor", **config}, - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Ping", - }, - ) - async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a Ping config entry.""" - - data: PingDomainData = hass.data[DOMAIN] - - async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) + async_add_entities([PingBinarySensor(entry, entry.runtime_data)]) class PingBinarySensor(PingEntity, BinarySensorEntity): diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 52600c379c4..9470b2134d4 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping import logging from typing import Any @@ -18,12 +17,12 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.util.network import is_ip_address -from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN +from .const import CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -61,27 +60,6 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_import( - self, import_info: Mapping[str, Any] - ) -> ConfigFlowResult: - """Import an entry.""" - - 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]) - - self._async_abort_entries_match({CONF_HOST: to_import[CONF_HOST]}) - return self.async_create_entry( - title=title, - data={CONF_IMPORTED_BY: import_info[CONF_IMPORTED_BY]}, - options=to_import, - ) - @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index b202c1c406e..ce7cc4522a0 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -3,135 +3,29 @@ 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, SourceType, ) -from homeassistant.components.device_tracker.legacy import ( - YAML_DEVICES, - remove_device_from_config, -) -from homeassistant.config import load_yaml_config_file -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_HOSTS, - CONF_NAME, - EVENT_HOMEASSISTANT_STARTED, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant -from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from 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 +from . import PingConfigEntry +from .const import CONF_IMPORTED_BY from .coordinator import PingUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOSTS): {cv.slug: cv.string}, - vol.Optional(CONF_PING_COUNT, default=1): cv.positive_int, - } -) - - -async def async_setup_scanner( - hass: HomeAssistant, - config: ConfigType, - async_see: AsyncSeeCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> bool: - """Legacy init: import via config flow.""" - - async def _run_import(_: Event) -> None: - """Delete devices from known_device.yaml and import them via config flow.""" - _LOGGER.debug( - "Home Assistant successfully started, importing ping device tracker config entries now" - ) - - devices: dict[str, dict[str, Any]] = {} - try: - devices = await hass.async_add_executor_job( - load_yaml_config_file, hass.config.path(YAML_DEVICES) - ) - except (FileNotFoundError, HomeAssistantError): - _LOGGER.debug( - "No valid known_devices.yaml found, " - "skip removal of devices from known_devices.yaml" - ) - - for dev_name, dev_host in config[CONF_HOSTS].items(): - if dev_name in devices: - await hass.async_add_executor_job( - remove_device_from_config, hass, dev_name - ) - _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) - - if not hass.states.async_available(f"device_tracker.{dev_name}"): - hass.states.async_remove(f"device_tracker.{dev_name}") - - # run import after everything has been cleaned up - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_IMPORTED_BY: "device_tracker", - CONF_NAME: dev_name, - CONF_HOST: dev_host, - CONF_PING_COUNT: config[CONF_PING_COUNT], - CONF_CONSIDER_HOME: config[CONF_CONSIDER_HOME], - }, - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Ping", - }, - ) - - # delay the import until after Home Assistant has started and everything has been initialized, - # as the legacy device tracker entities will be restored after the legacy device tracker platforms - # have been set up, so we can only remove the entities from the state machine then - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) - - return True - async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a Ping config entry.""" - - data: PingDomainData = hass.data[DOMAIN] - - async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) + async_add_entities([PingDeviceTracker(entry, entry.runtime_data)]) class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index f1fd8518d42..82ebf4532da 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -59,9 +59,17 @@ class PingDataICMPLib(PingData): privileged=self._privileged, ) except NameLookupError: + _LOGGER.debug("Error resolving host: %s", self.ip_address) self.is_alive = False return + _LOGGER.debug( + "async_ping returned: reachable=%s sent=%i received=%s", + data.is_alive, + data.packets_sent, + data.packets_received, + ) + self.is_alive = data.is_alive if not self.is_alive: self.data = None @@ -94,6 +102,10 @@ class PingDataSubProcess(PingData): async def async_ping(self) -> dict[str, Any] | None: """Send ICMP echo request and return details if success.""" + _LOGGER.debug( + "Pinging %s with: `%s`", self.ip_address, " ".join(self._ping_cmd) + ) + pinger = await asyncio.create_subprocess_exec( *self._ping_cmd, stdin=None, @@ -141,18 +153,20 @@ class PingDataSubProcess(PingData): assert match is not None rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() except TimeoutError: - _LOGGER.exception( - "Timed out running command: `%s`, after: %ss", - self._ping_cmd, + _LOGGER.debug( + "Timed out running command: `%s`, after: %s", + " ".join(self._ping_cmd), self._count + PING_TIMEOUT, ) + if pinger: with suppress(TypeError): await pinger.kill() # type: ignore[func-returns-value] del pinger return None - except AttributeError: + except AttributeError as err: + _LOGGER.debug("Error matching ping output: %s", err) return None return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py index 135087f4b5b..6e6c4cf2cde 100644 --- a/homeassistant/components/ping/sensor.py +++ b/homeassistant/components/ping/sensor.py @@ -14,8 +14,7 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PingDomainData -from .const import DOMAIN +from . import PingConfigEntry from .coordinator import PingResult, PingUpdateCoordinator from .entity import PingEntity @@ -77,11 +76,10 @@ SENSORS: tuple[PingSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Ping sensors from config entry.""" - data: PingDomainData = hass.data[DOMAIN] - coordinator = data.coordinators[entry.entry_id] + coordinator = entry.runtime_data async_add_entities( PingSensor(entry, description, coordinator) diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index c68e2c8ad75..fbf268b70d2 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -18,8 +18,6 @@ from pyplaato.plaato import ( ATTR_TEMP, ATTR_TEMP_UNIT, ATTR_VOLUME_UNIT, - Plaato, - PlaatoDeviceType, ) import voluptuous as vol @@ -30,15 +28,12 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_WEBHOOK_ID, - Platform, UnitOfTemperature, UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_DEVICE_NAME, @@ -55,6 +50,7 @@ from .const import ( SENSOR_DATA, UNDO_UPDATE_LISTENER, ) +from .coordinator import PlaatoCoordinator _LOGGER = logging.getLogger(__name__) @@ -194,7 +190,7 @@ async def handle_webhook(hass, webhook_id, request): data = WEBHOOK_SCHEMA(await request.json()) except vol.MultipleInvalid as error: _LOGGER.warning("An error occurred when parsing webhook data <%s>", error) - return + return None device_id = _device_id(data) sensor_data = PlaatoAirlock.from_web_hook(data) @@ -207,34 +203,3 @@ async def handle_webhook(hass, webhook_id, request): def _device_id(data): """Return name of device sensor.""" return f"{data.get(ATTR_DEVICE_NAME)}_{data.get(ATTR_DEVICE_ID)}" - - -class PlaatoCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching data from the API.""" - - def __init__( - self, - hass: HomeAssistant, - auth_token: str, - device_type: PlaatoDeviceType, - update_interval: timedelta, - ) -> None: - """Initialize.""" - self.api = Plaato(auth_token=auth_token) - self.hass = hass - self.device_type = device_type - self.platforms: list[Platform] = [] - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=update_interval, - ) - - async def _async_update_data(self): - """Update data via library.""" - return await self.api.get_data( - session=aiohttp_client.async_get_clientsession(self.hass), - device_type=self.device_type, - ) diff --git a/homeassistant/components/plaato/coordinator.py b/homeassistant/components/plaato/coordinator.py new file mode 100644 index 00000000000..8d21f17880a --- /dev/null +++ b/homeassistant/components/plaato/coordinator.py @@ -0,0 +1,46 @@ +"""Coordinator for Plaato devices.""" + +from datetime import timedelta +import logging + +from pyplaato.plaato import Plaato, PlaatoDeviceType + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class PlaatoCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + auth_token: str, + device_type: PlaatoDeviceType, + update_interval: timedelta, + ) -> None: + """Initialize.""" + self.api = Plaato(auth_token=auth_token) + self.hass = hass + self.device_type = device_type + self.platforms: list[Platform] = [] + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self): + """Update data via library.""" + return await self.api.get_data( + session=aiohttp_client.async_get_clientsession(self.hass), + device_type=self.device_type, + ) diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 4f35f9eb281..b549dee2887 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.components.recorder import get_instance, history from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, - CONDUCTIVITY, CONF_SENSORS, LIGHT_LUX, PERCENTAGE, @@ -18,6 +17,7 @@ from homeassistant.const import ( STATE_PROBLEM, STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfConductivity, UnitOfTemperature, ) from homeassistant.core import ( @@ -35,7 +35,6 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from . import group as group_pre_import # noqa: F401 from .const import ( ATTR_DICT_OF_UNITS_OF_MEASUREMENT, ATTR_MAX_BRIGHTNESS_HISTORY, @@ -149,7 +148,7 @@ class Plant(Entity): "max": CONF_MAX_MOISTURE, }, READING_CONDUCTIVITY: { - ATTR_UNIT_OF_MEASUREMENT: CONDUCTIVITY, + ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS, "min": CONF_MIN_CONDUCTIVITY, "max": CONF_MAX_CONDUCTIVITY, }, diff --git a/homeassistant/components/plant/group.py b/homeassistant/components/plant/group.py deleted file mode 100644 index abd24a2c23f..00000000000 --- a/homeassistant/components/plant/group.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_OK, STATE_PROBLEM -from homeassistant.core import HomeAssistant, callback - -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states(DOMAIN, {STATE_PROBLEM}, STATE_PROBLEM, STATE_OK) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index dabde0b0490..374067c94cd 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -216,7 +216,7 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): self.available_servers = available_servers.args[0] return await self.async_step_select_server() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error connecting to Plex server") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index ff0ab39b150..3393ed1ec81 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.12", + "PlexAPI==4.15.13", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 9184edeb3bd..e47e6145761 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -324,7 +324,7 @@ def library_section_payload(section): children_media_class = ITEM_TYPE_MEDIA_CLASS[section.TYPE] except KeyError as err: raise UnknownMediaType(f"Unknown type received: {section.TYPE}") from err - server_id = section._server.machineIdentifier # pylint: disable=protected-access + server_id = section._server.machineIdentifier # noqa: SLF001 return BrowseMedia( title=section.title, media_class=MediaClass.DIRECTORY, @@ -357,7 +357,7 @@ def hub_payload(hub): media_content_id = f"{hub.librarySectionID}/{hub.hubIdentifier}" else: media_content_id = f"server/{hub.hubIdentifier}" - server_id = hub._server.machineIdentifier # pylint: disable=protected-access + server_id = hub._server.machineIdentifier # noqa: SLF001 payload = { "title": hub.title, "media_class": MediaClass.DIRECTORY, @@ -371,7 +371,7 @@ def hub_payload(hub): def station_payload(station): """Create response payload for a music station.""" - server_id = station._server.machineIdentifier # pylint: disable=protected-access + server_id = station._server.machineIdentifier # noqa: SLF001 return BrowseMedia( title=station.title, media_class=ITEM_TYPE_MEDIA_CLASS[station.type], diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 21e52171fe8..1dd79ad27a5 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, cast import plexapi.exceptions import requests.exceptions @@ -46,14 +46,10 @@ from .helpers import get_plex_data, get_plex_server from .media_browser import browse_media from .services import process_plex_payload -_PlexMediaPlayerT = TypeVar("_PlexMediaPlayerT", bound="PlexMediaPlayer") -_R = TypeVar("_R") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) -def needs_session( +def needs_session[_PlexMediaPlayerT: PlexMediaPlayer, **_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/server.py b/homeassistant/components/plex/server.py index 584378d51f9..fbb98e8e19f 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -571,7 +571,7 @@ class PlexServer: @property def url_in_use(self): """Return URL used for connected Plex server.""" - return self._plex_server._baseurl # pylint: disable=protected-access + return self._plex_server._baseurl # noqa: SLF001 @property def option_ignore_new_shared_users(self): diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index 3140e518688..de2250ac72e 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -12,16 +12,18 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import PlugwiseDataUpdateCoordinator +type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> bool: """Set up Plugwise components from a config entry.""" await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) - coordinator = PlugwiseDataUpdateCoordinator(hass, entry) + coordinator = PlugwiseDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() migrate_sensor_entities(hass, coordinator) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -38,11 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> bool: """Unload the Plugwise components.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @callback @@ -59,6 +59,12 @@ def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None "-slave_boiler_state", "-secondary_boiler_state" ) } + if entry.domain == Platform.SENSOR and entry.unique_id.endswith( + "-relative_humidity" + ): + return { + "new_unique_id": entry.unique_id.replace("-relative_humidity", "-humidity") + } if entry.domain == Platform.SWITCH and entry.unique_id.endswith("-plug"): return {"new_unique_id": entry.unique_id.replace("-plug", "-relay")} diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 01ebc736dbe..ef1051fa7b2 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -12,12 +12,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import PlugwiseConfigEntry from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -78,30 +77,38 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: PlugwiseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smile binary_sensors from a config entry.""" - coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = entry.runtime_data - entities: list[PlugwiseBinarySensorEntity] = [] - for device_id, device in coordinator.data.devices.items(): - if not (binary_sensors := device.get("binary_sensors")): - continue - for description in BINARY_SENSORS: - if description.key not in binary_sensors: + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + entities: list[PlugwiseBinarySensorEntity] = [] + for device_id, device in coordinator.data.devices.items(): + if not (binary_sensors := device.get("binary_sensors")): continue + for description in BINARY_SENSORS: + if description.key not in binary_sensors: + continue - entities.append( - PlugwiseBinarySensorEntity( - coordinator, - device_id, - description, + entities.append( + PlugwiseBinarySensorEntity( + coordinator, + device_id, + description, + ) ) - ) - async_add_entities(entities) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 7820c86a242..006cfbe87da 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -13,12 +13,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import PlugwiseConfigEntry from .const import DOMAIN, MASTER_THERMOSTATS from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -27,16 +27,27 @@ from .util import plugwise_command async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: PlugwiseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smile Thermostats from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - PlugwiseClimateEntity(coordinator, device_id) - for device_id, device in coordinator.data.devices.items() - if device["dev_class"] in MASTER_THERMOSTATS - ) + coordinator = entry.runtime_data + + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + async_add_entities( + PlugwiseClimateEntity(coordinator, device_id) + for device_id, device in coordinator.data.devices.items() + if device["dev_class"] in MASTER_THERMOSTATS + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index 4c33e51788f..1e0f34007c9 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -106,7 +106,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: config_entry.data[CONF_PASSWORD], }, ) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 self._abort_if_unique_id_configured() else: self._abort_if_unique_id_configured( @@ -188,7 +188,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_BASE] = "response_error" except UnsupportedDeviceError: errors[CONF_BASE] = "unsupported" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors[CONF_BASE] = "unknown" else: await self.async_set_unique_id( diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 975ddae346a..ed8cb2d2002 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -37,19 +37,19 @@ ZEROCONF_MAP: Final[dict[str, str]] = { "stretch": "Stretch", } -NumberType = Literal[ +type NumberType = Literal[ "maximum_boiler_temperature", "max_dhw_temperature", "temperature_offset", ] -SelectType = Literal[ +type SelectType = Literal[ "select_dhw_mode", "select_gateway_mode", "select_regulation_mode", "select_schedule", ] -SelectOptionsType = Literal[ +type SelectOptionsType = Literal[ "dhw_modes", "gateway_modes", "regulation_modes", diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 15a0e8c4821..34d983510ed 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -15,11 +15,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import device_registry as dr 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 DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_USERNAME, DOMAIN, LOGGER +from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, LOGGER class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): @@ -27,7 +28,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): _connected: bool = False - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -45,21 +48,20 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): ) self.api = Smile( - host=entry.data[CONF_HOST], - username=entry.data.get(CONF_USERNAME, DEFAULT_USERNAME), - password=entry.data[CONF_PASSWORD], - port=entry.data.get(CONF_PORT, DEFAULT_PORT), + host=self.config_entry.data[CONF_HOST], + username=self.config_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME), + password=self.config_entry.data[CONF_PASSWORD], + port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT), timeout=30, websession=async_get_clientsession(hass, verify_ssl=False), ) + self.device_list: list[dr.DeviceEntry] = [] + self.new_devices: bool = False async def _connect(self) -> None: """Connect to the Plugwise Smile.""" self._connected = await self.api.connect() self.api.get_all_devices() - self.update_interval = DEFAULT_SCAN_INTERVAL.get( - str(self.api.smile_type), timedelta(seconds=60) - ) async def _async_update_data(self) -> PlugwiseData: """Fetch data from Plugwise.""" @@ -79,4 +81,13 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): raise ConfigEntryError("Device with unsupported firmware") from err except ConnectionFailedError as err: raise UpdateFailed("Failed to connect to the Plugwise Smile") from err + + device_reg = dr.async_get(self.hass) + device_list = dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ) + + self.new_devices = len(data.devices.keys()) - len(self.device_list) > 0 + self.device_list = device_list + return data diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index 44c0fa9a1da..9d15ea4fe28 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -4,18 +4,16 @@ 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 PlugwiseDataUpdateCoordinator +from . import PlugwiseConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PlugwiseConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "gateway": coordinator.data.gateway, "devices": coordinator.data.devices, diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ada7d2d2533..b1937ee219d 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==0.37.3"], + "requirements": ["plugwise==0.37.4.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 2bae113a73e..f00b9e38876 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -13,12 +13,12 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, NumberType +from . import PlugwiseConfigEntry +from .const import NumberType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -67,21 +67,28 @@ NUMBER_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: PlugwiseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Plugwise number platform.""" + coordinator = entry.runtime_data - coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return - async_add_entities( - PlugwiseNumberEntity(coordinator, device_id, description) - for device_id, device in coordinator.data.devices.items() - for description in NUMBER_TYPES - if description.key in device - ) + async_add_entities( + PlugwiseNumberEntity(coordinator, device_id, description) + for device_id, device in coordinator.data.devices.items() + for description in NUMBER_TYPES + if description.key in device + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 10718a818ff..88c97b9b9f3 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -8,12 +8,12 @@ from dataclasses import dataclass from plugwise import Smile from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, SelectOptionsType, SelectType +from . import PlugwiseConfigEntry +from .const import SelectOptionsType, SelectType from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -60,20 +60,28 @@ SELECT_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: PlugwiseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smile selector from a config entry.""" - coordinator: PlugwiseDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = entry.runtime_data - async_add_entities( - PlugwiseSelectEntity(coordinator, device_id, description) - for device_id, device in coordinator.data.devices.items() - for description in SELECT_TYPES - if description.options_key in device - ) + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + async_add_entities( + PlugwiseSelectEntity(coordinator, device_id, description) + for device_id, device in coordinator.data.devices.items() + for description in SELECT_TYPES + if description.options_key in device + ) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): @@ -91,13 +99,17 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): super().__init__(coordinator, device_id) self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" - self._attr_options = self.device[entity_description.options_key] @property def current_option(self) -> str: """Return the selected entity option to represent the entity state.""" return self.device[self.entity_description.key] + @property + def options(self) -> list[str]: + """Return the available select-options.""" + return self.device[self.entity_description.options_key] + async def async_select_option(self, option: str) -> None: """Change to the selected entity option.""" await self.entity_description.command( diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 2dfe97a06c5..147bab828a8 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, @@ -25,10 +24,10 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import PlugwiseConfigEntry from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -403,29 +402,39 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: PlugwiseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smile sensors from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry.runtime_data - entities: list[PlugwiseSensorEntity] = [] - for device_id, device in coordinator.data.devices.items(): - if not (sensors := device.get("sensors")): - continue - for description in SENSORS: - if description.key not in sensors: + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + entities: list[PlugwiseSensorEntity] = [] + for device_id, device in coordinator.data.devices.items(): + if not (sensors := device.get("sensors")): continue + for description in SENSORS: + if description.key not in sensors: + continue - entities.append( - PlugwiseSensorEntity( - coordinator, - device_id, - description, + entities.append( + PlugwiseSensorEntity( + coordinator, + device_id, + description, + ) ) - ) - async_add_entities(entities) + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity): diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 3c737e19a4a..3ed2d14b8dd 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -12,12 +12,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import PlugwiseConfigEntry from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -57,20 +56,34 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: PlugwiseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smile switches from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] - entities: list[PlugwiseSwitchEntity] = [] - for device_id, device in coordinator.data.devices.items(): - if not (switches := device.get("switches")): - continue - for description in SWITCHES: - if description.key not in switches: + coordinator = entry.runtime_data + + @callback + def _add_entities() -> None: + """Add Entities.""" + if not coordinator.new_devices: + return + + entities: list[PlugwiseSwitchEntity] = [] + for device_id, device in coordinator.data.devices.items(): + if not (switches := device.get("switches")): continue - entities.append(PlugwiseSwitchEntity(coordinator, device_id, description)) - async_add_entities(entities) + for description in SWITCHES: + if description.key not in switches: + continue + entities.append( + PlugwiseSwitchEntity(coordinator, device_id, description) + ) + + async_add_entities(entities) + + entry.async_on_unload(coordinator.async_add_listener(_add_entities)) + + _add_entities() class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): diff --git a/homeassistant/components/plugwise/util.py b/homeassistant/components/plugwise/util.py index df1069cbbc3..d998711f2b9 100644 --- a/homeassistant/components/plugwise/util.py +++ b/homeassistant/components/plugwise/util.py @@ -1,7 +1,7 @@ """Utilities for Plugwise.""" from collections.abc import Awaitable, Callable, Coroutine -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from plugwise.exceptions import PlugwiseException @@ -9,12 +9,8 @@ from homeassistant.exceptions import HomeAssistantError from .entity import PlugwiseEntity -_PlugwiseEntityT = TypeVar("_PlugwiseEntityT", bound=PlugwiseEntity) -_R = TypeVar("_R") -_P = ParamSpec("_P") - -def plugwise_command( +def plugwise_command[_PlugwiseEntityT: PlugwiseEntity, **_P, _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/__init__.py b/homeassistant/components/point/__init__.py index 9f0f6e6dc7c..138bc8be596 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -104,7 +104,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ConnectTimeout as err: _LOGGER.debug("Connection Timeout") raise ConfigEntryNotReady from err - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.error("Authentication Error") return False @@ -205,8 +205,8 @@ class MinutPointClient: config_entries_key = f"{platform}.{DOMAIN}" async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: if config_entries_key not in self._hass.data[CONFIG_ENTRY_IS_SETUP]: - await self._hass.config_entries.async_forward_entry_setup( - self._config_entry, platform + await self._hass.config_entries.async_forward_entry_setups( + self._config_entry, [platform] ) self._hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index b04742af06a..844d1eba553 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -55,6 +55,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): """The platform class required by Home Assistant.""" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_code_arm_required = False def __init__(self, point_client: MinutPointClient, home_id: str) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 8863ee8ed81..7a698925db6 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -72,14 +72,13 @@ class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity): super().__init__( point_client, device_id, - DEVICES[device_name].get("device_class"), + DEVICES[device_name].get("device_class", device_name), ) self._device_name = device_name self._async_unsub_hook_dispatcher_connect = None self._events = EVENTS[device_name] self._attr_unique_id = f"point.{device_id}-{device_name}" self._attr_icon = DEVICES[self._device_name].get("icon") - self._attr_name = f"{self._name} {device_name.capitalize()}" async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index acf4b3e6d34..279561b4e2b 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -98,7 +98,7 @@ class PointFlowHandler(ConfigFlow, domain=DOMAIN): url = await self._get_authorization_url() except TimeoutError: return self.async_abort(reason="authorize_url_timeout") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error generating auth url") return self.async_abort(reason="unknown_authorize_url_generation") return self.async_show_form( diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index 3c2a82dfb98..0e8d7068a4f 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/point", "iot_class": "cloud_polling", "loggers": ["pypoint"], - "quality_scale": "gold", + "quality_scale": "silver", "requirements": ["pypoint==2.3.2"] } diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 808d2300798..a4b6f7b60d8 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -9,16 +9,17 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DOMAIN from .coordinator import PoolSenseDataUpdateCoordinator +type PoolSenseConfigEntry = ConfigEntry[PoolSenseDataUpdateCoordinator] + PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PoolSenseConfigEntry) -> bool: """Set up PoolSense from a config entry.""" poolsense = PoolSense( @@ -32,21 +33,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Invalid authentication") return False - coordinator = PoolSenseDataUpdateCoordinator(hass, entry) + coordinator = PoolSenseDataUpdateCoordinator(hass, poolsense) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PoolSenseConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index 69c133c8c1e..7668845f318 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -7,12 +7,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import PoolSenseConfigEntry from .entity import PoolSenseEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -31,18 +29,16 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PoolSenseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data - entities = [ - PoolSenseBinarySensor(coordinator, config_entry.data[CONF_EMAIL], description) + async_add_entities( + PoolSenseBinarySensor(coordinator, description) for description in BINARY_SENSOR_TYPES - ] - - async_add_entities(entities, False) + ) class PoolSenseBinarySensor(PoolSenseEntity, BinarySensorEntity): diff --git a/homeassistant/components/poolsense/config_flow.py b/homeassistant/components/poolsense/config_flow.py index 915fa1c8d06..b40ccaddd7d 100644 --- a/homeassistant/components/poolsense/config_flow.py +++ b/homeassistant/components/poolsense/config_flow.py @@ -20,9 +20,6 @@ class PoolSenseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize PoolSense config flow.""" - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py index 8b6f99ed72b..d9e7e8468ff 100644 --- a/homeassistant/components/poolsense/coordinator.py +++ b/homeassistant/components/poolsense/coordinator.py @@ -1,46 +1,44 @@ """DataUpdateCoordinator for poolsense integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import TYPE_CHECKING from poolsense import PoolSense from poolsense.exceptions import PoolSenseError -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +if TYPE_CHECKING: + from . import PoolSenseConfigEntry + _LOGGER = logging.getLogger(__name__) class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, StateType]]): """Define an object to hold PoolSense data.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize.""" - self.poolsense = PoolSense( - aiohttp_client.async_get_clientsession(hass), - entry.data[CONF_EMAIL], - entry.data[CONF_PASSWORD], - ) - self.hass = hass + config_entry: PoolSenseConfigEntry + def __init__(self, hass: HomeAssistant, poolsense: PoolSense) -> None: + """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) + self.poolsense = poolsense + self.email = self.config_entry.data[CONF_EMAIL] async def _async_update_data(self) -> dict[str, StateType]: """Update data via library.""" - data = {} async with asyncio.timeout(10): try: - data = await self.poolsense.get_poolsense_data() + return await self.poolsense.get_poolsense_data() except PoolSenseError as error: _LOGGER.error("PoolSense query did not complete") raise UpdateFailed(error) from error - - return data diff --git a/homeassistant/components/poolsense/entity.py b/homeassistant/components/poolsense/entity.py index eaf2c4ab540..447c91ceb37 100644 --- a/homeassistant/components/poolsense/entity.py +++ b/homeassistant/components/poolsense/entity.py @@ -1,9 +1,10 @@ """Base entity for poolsense integration.""" +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION +from .const import ATTRIBUTION, DOMAIN from .coordinator import PoolSenseDataUpdateCoordinator @@ -11,15 +12,18 @@ class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]): """Implements a common class elements representing the PoolSense component.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, coordinator: PoolSenseDataUpdateCoordinator, - email: str, description: EntityDescription, ) -> None: - """Initialize poolsense sensor.""" + """Initialize poolsense entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_name = f"PoolSense {description.name}" - self._attr_unique_id = f"{email}-{description.key}" + self._attr_unique_id = f"{coordinator.email}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.email)}, + model="PoolSense", + ) diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index d40ee823664..8cfb982d33b 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -7,18 +7,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_EMAIL, - PERCENTAGE, - UnitOfElectricPotential, - UnitOfTemperature, -) +from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import PoolSenseConfigEntry from .entity import PoolSenseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -70,18 +64,15 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PoolSenseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data - entities = [ - PoolSenseSensor(coordinator, config_entry.data[CONF_EMAIL], description) - for description in SENSOR_TYPES - ] - - async_add_entities(entities, False) + async_add_entities( + PoolSenseSensor(coordinator, description) for description in SENSOR_TYPES + ) class PoolSenseSensor(PoolSenseEntity, SensorEntity): diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 7629d83d9d6..3e2a5fdfd2d 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -176,7 +176,7 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): except AccessDeniedError as ex: errors[CONF_PASSWORD] = "invalid_auth" description_placeholders = {"error": str(ex)} - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" description_placeholders = {"error": str(ex)} diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 4185e90ab7b..52bbbf2f33d 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/powerwall", "iot_class": "local_polling", "loggers": ["tesla_powerwall"], - "requirements": ["tesla-powerwall==0.5.1"] + "requirements": ["tesla-powerwall==0.5.2"] } diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 38189ecd6f3..7a52640fff7 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -36,24 +36,18 @@ _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" _ValueParamT = TypeVar("_ValueParamT") -_ValueT = TypeVar("_ValueT", bound=float | int | str) +_ValueT = TypeVar("_ValueT", bound=float | int | str | None) -@dataclass(frozen=True) -class PowerwallRequiredKeysMixin(Generic[_ValueParamT, _ValueT]): - """Mixin for required keys.""" - - value_fn: Callable[[_ValueParamT], _ValueT] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class PowerwallSensorEntityDescription( SensorEntityDescription, - PowerwallRequiredKeysMixin[_ValueParamT, _ValueT], Generic[_ValueParamT, _ValueT], ): """Describes Powerwall entity.""" + value_fn: Callable[[_ValueParamT], _ValueT] + def _get_meter_power(meter: MeterResponse) -> float: """Get the current value in kW.""" @@ -114,6 +108,21 @@ POWERWALL_INSTANT_SENSORS = ( ) +def _get_instant_voltage(battery: BatteryResponse) -> float | None: + """Get the current value in V.""" + return None if battery.v_out is None else round(battery.v_out, 1) + + +def _get_instant_frequency(battery: BatteryResponse) -> float | None: + """Get the current value in Hz.""" + return None if battery.f_out is None else round(battery.f_out, 1) + + +def _get_instant_current(battery: BatteryResponse) -> float | None: + """Get the current value in A.""" + return None if battery.i_out is None else round(battery.i_out, 1) + + BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ PowerwallSensorEntityDescription[BatteryResponse, int]( key="battery_capacity", @@ -126,16 +135,16 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ suggested_display_precision=1, value_fn=lambda battery_data: battery_data.capacity, ), - PowerwallSensorEntityDescription[BatteryResponse, float]( + PowerwallSensorEntityDescription[BatteryResponse, float | None]( key="battery_instant_voltage", translation_key="battery_instant_voltage", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - value_fn=lambda battery_data: round(battery_data.v_out, 1), + value_fn=_get_instant_voltage, ), - PowerwallSensorEntityDescription[BatteryResponse, float]( + PowerwallSensorEntityDescription[BatteryResponse, float | None]( key="instant_frequency", translation_key="instant_frequency", entity_category=EntityCategory.DIAGNOSTIC, @@ -143,9 +152,9 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ device_class=SensorDeviceClass.FREQUENCY, native_unit_of_measurement=UnitOfFrequency.HERTZ, entity_registry_enabled_default=False, - value_fn=lambda battery_data: round(battery_data.f_out, 1), + value_fn=_get_instant_frequency, ), - PowerwallSensorEntityDescription[BatteryResponse, float]( + PowerwallSensorEntityDescription[BatteryResponse, float | None]( key="instant_current", translation_key="instant_current", entity_category=EntityCategory.DIAGNOSTIC, @@ -153,9 +162,9 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, - value_fn=lambda battery_data: round(battery_data.i_out, 1), + value_fn=_get_instant_current, ), - PowerwallSensorEntityDescription[BatteryResponse, int]( + PowerwallSensorEntityDescription[BatteryResponse, int | None]( key="instant_power", translation_key="instant_power", entity_category=EntityCategory.DIAGNOSTIC, @@ -164,7 +173,7 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ native_unit_of_measurement=UnitOfPower.WATT, value_fn=lambda battery_data: battery_data.p_out, ), - PowerwallSensorEntityDescription[BatteryResponse, float]( + PowerwallSensorEntityDescription[BatteryResponse, float | None]( key="battery_export", translation_key="battery_export", entity_category=EntityCategory.DIAGNOSTIC, @@ -175,7 +184,7 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ suggested_display_precision=0, value_fn=lambda battery_data: battery_data.energy_discharged, ), - PowerwallSensorEntityDescription[BatteryResponse, float]( + PowerwallSensorEntityDescription[BatteryResponse, float | None]( key="battery_import", translation_key="battery_import", entity_category=EntityCategory.DIAGNOSTIC, @@ -403,6 +412,6 @@ class PowerWallBatterySensor(BatteryEntity, SensorEntity, Generic[_ValueT]): self._attr_unique_id = f"{self.base_unique_id}_{description.key}" @property - def native_value(self) -> float | int | str: + def native_value(self) -> float | int | str | None: """Get the current value.""" return self.entity_description.value_fn(self.battery_data) diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py index 69db399a454..3e7bafed748 100644 --- a/homeassistant/components/private_ble_device/coordinator.py +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -17,8 +17,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -UnavailableCallback = Callable[[bluetooth.BluetoothServiceInfoBleak], None] -Cancellable = Callable[[], None] +type UnavailableCallback = Callable[[bluetooth.BluetoothServiceInfoBleak], None] +type Cancellable = Callable[[], None] def async_last_service_info( diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index ceb3c3a998b..b9b833647df 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,7 +1,6 @@ """The profiler integration.""" import asyncio -from collections.abc import Generator import contextlib from contextlib import suppress from datetime import timedelta @@ -15,6 +14,7 @@ import traceback from typing import Any, cast from lru import LRU +from typing_extensions import Generator import voluptuous as vol from homeassistant.components import persistent_notification @@ -233,7 +233,7 @@ async def async_setup_entry( # noqa: C901 async def _async_dump_thread_frames(call: ServiceCall) -> None: """Log all thread frames.""" - frames = sys._current_frames() # pylint: disable=protected-access + frames = sys._current_frames() # noqa: SLF001 main_thread = threading.main_thread() for thread in threading.enumerate(): if thread == main_thread: @@ -505,7 +505,7 @@ def _safe_repr(obj: Any) -> str: """ try: return repr(obj) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return f"Failed to serialize {type(obj)}" @@ -586,7 +586,7 @@ def _log_object_sources( @contextlib.contextmanager -def _increase_repr_limit() -> Generator[None, None, None]: +def _increase_repr_limit() -> Generator[None]: """Increase the repr limit.""" arepr = reprlib.aRepr original_maxstring = arepr.maxstring diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 5a5d0de1a80..dbe12184a10 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -51,7 +51,7 @@ class ProgettiHWSWConfigFlow(ConfigFlow, domain=DOMAIN): relay_modes_schema = {} for i in range(1, int(self.s1_in["relay_count"]) + 1): - relay_modes_schema[vol.Required(f"relay_{str(i)}", default="bistable")] = ( + relay_modes_schema[vol.Required(f"relay_{i!s}", default="bistable")] = ( vol.In( { "bistable": "Bistable (ON/OFF Mode)", @@ -78,7 +78,7 @@ class ProgettiHWSWConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["base"] = "unknown" else: user_input.update(info) diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 88faa35e0a4..983a2383e99 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -49,7 +49,7 @@ async def async_setup_entry( ProgettihwswSwitch( coordinator, f"Relay #{i}", - setup_switch(board_api, i, config_entry.data[f"relay_{str(i)}"]), + setup_switch(board_api, i, config_entry.data[f"relay_{i!s}"]), ) for i in range(1, int(relay_count) + 1) ) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index c02cbeabd84..2159656f129 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -6,7 +6,7 @@ from collections.abc import Callable from contextlib import suppress import logging import string -from typing import Any, TypeVar, cast +from typing import Any, cast from aiohttp import web import prometheus_client @@ -61,7 +61,6 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter -_MetricBaseT = TypeVar("_MetricBaseT", bound=MetricWrapperBase) _LOGGER = logging.getLogger(__name__) API_ENDPOINT = "/api/prometheus" @@ -286,7 +285,7 @@ class PrometheusMetrics: except (ValueError, TypeError): pass - def _metric( + def _metric[_MetricBaseT: MetricWrapperBase]( self, metric: str, factory: type[_MetricBaseT], diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 61e7c73e3a5..ffedcf30770 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -7,8 +7,10 @@ import logging from pyprosegur.auth import Auth from pyprosegur.installation import Installation, Status -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, @@ -41,7 +43,7 @@ async def async_setup_entry( ) -class ProsegurAlarm(alarm.AlarmControlPanelEntity): +class ProsegurAlarm(AlarmControlPanelEntity): """Representation of a Prosegur alarm status.""" _attr_supported_features = ( diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index 911ae6104fd..82cf1d424c7 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -62,7 +62,7 @@ class ProsegurConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -127,7 +127,7 @@ class ProsegurConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 2fd463aa1b7..2d32926832a 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -45,7 +45,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -ProximityConfigEntry = ConfigEntry["ProximityDataUpdateCoordinator"] +type ProximityConfigEntry = ConfigEntry[ProximityDataUpdateCoordinator] @dataclass @@ -350,7 +350,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): if cast(int, nearest_distance_to) == int(distance_to): _LOGGER.debug("set equally close entity_data: %s", entity_data) proximity_data[ATTR_NEAREST] = ( - f"{proximity_data[ATTR_NEAREST]}, {str(entity_data[ATTR_NAME])}" + f"{proximity_data[ATTR_NEAREST]}, {entity_data[ATTR_NAME]!s}" ) return ProximityData(proximity_data, entities_data) diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 2ff4601466c..9d6096748dd 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -2,15 +2,8 @@ from __future__ import annotations -from abc import ABC, abstractmethod -import asyncio -from datetime import timedelta -import logging -from time import monotonic -from typing import TypeVar - -from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink -from pyprusalink.types import InvalidAuth, PrusaLinkError +from pyprusalink import PrusaLink +from pyprusalink.types import InvalidAuth from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,22 +13,23 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .config_flow import ConfigFlow from .const import DOMAIN +from .coordinator import ( + JobUpdateCoordinator, + LegacyStatusCoordinator, + PrusaLinkUpdateCoordinator, + StatusCoordinator, +) PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -129,79 +123,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) - - -class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): # pylint: disable=hass-enforce-coordinator-module - """Update coordinator for the printer.""" - - config_entry: ConfigEntry - expect_change_until = 0.0 - - def __init__(self, hass: HomeAssistant, api: PrusaLink) -> None: - """Initialize the update coordinator.""" - self.api = api - - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=self._get_update_interval(None) - ) - - async def _async_update_data(self) -> T: - """Update the data.""" - try: - async with asyncio.timeout(5): - data = await self._fetch_data() - except InvalidAuth: - raise UpdateFailed("Invalid authentication") from None - except PrusaLinkError as err: - raise UpdateFailed(str(err)) from err - - self.update_interval = self._get_update_interval(data) - return data - - @abstractmethod - async def _fetch_data(self) -> T: - """Fetch the actual data.""" - raise NotImplementedError - - @callback - def expect_change(self) -> None: - """Expect a change.""" - self.expect_change_until = monotonic() + 30 - - def _get_update_interval(self, data: T) -> timedelta: - """Get new update interval.""" - if self.expect_change_until > monotonic(): - return timedelta(seconds=5) - - return timedelta(seconds=30) - - -class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): # pylint: disable=hass-enforce-coordinator-module - """Printer update coordinator.""" - - async def _fetch_data(self) -> PrinterStatus: - """Fetch the printer data.""" - return await self.api.get_status() - - -class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): # pylint: disable=hass-enforce-coordinator-module - """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]): # pylint: disable=hass-enforce-coordinator-module - """Job update coordinator.""" - - async def _fetch_data(self) -> JobInfo: - """Fetch the printer data.""" - return await self.api.get_job() - - -class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module +class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): """Defines a base PrusaLink entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index d70356f04d1..0ad7e531d46 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -15,7 +15,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator +from . import PrusaLinkEntity +from .const import DOMAIN +from .coordinator import PrusaLinkUpdateCoordinator T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index cc625b7ef57..2185c5f3cf6 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -9,7 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, JobUpdateCoordinator, PrusaLinkEntity +from . import PrusaLinkEntity +from .const import DOMAIN +from .coordinator import JobUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index b0c7cf2f756..6fa72d6a5fd 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -113,7 +113,7 @@ class PrusaLinkConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "not_supported" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py new file mode 100644 index 00000000000..7d4526a8b45 --- /dev/null +++ b/homeassistant/components/prusalink/coordinator.py @@ -0,0 +1,93 @@ +"""Coordinators for the PrusaLink integration.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +import asyncio +from datetime import timedelta +import logging +from time import monotonic +from typing import TypeVar + +from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink +from pyprusalink.types import InvalidAuth, PrusaLinkError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) + + +class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): + """Update coordinator for the printer.""" + + config_entry: ConfigEntry + expect_change_until = 0.0 + + def __init__(self, hass: HomeAssistant, api: PrusaLink) -> None: + """Initialize the update coordinator.""" + self.api = api + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=self._get_update_interval(None) + ) + + async def _async_update_data(self) -> T: + """Update the data.""" + try: + async with asyncio.timeout(5): + data = await self._fetch_data() + except InvalidAuth: + raise UpdateFailed("Invalid authentication") from None + except PrusaLinkError as err: + raise UpdateFailed(str(err)) from err + + self.update_interval = self._get_update_interval(data) + return data + + @abstractmethod + async def _fetch_data(self) -> T: + """Fetch the actual data.""" + raise NotImplementedError + + @callback + def expect_change(self) -> None: + """Expect a change.""" + self.expect_change_until = monotonic() + 30 + + def _get_update_interval(self, data: T) -> timedelta: + """Get new update interval.""" + if self.expect_change_until > monotonic(): + return timedelta(seconds=5) + + return timedelta(seconds=30) + + +class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): + """Printer update coordinator.""" + + async def _fetch_data(self) -> PrinterStatus: + """Fetch the printer data.""" + return await self.api.get_status() + + +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]): + """Job update coordinator.""" + + async def _fetch_data(self) -> JobInfo: + """Fetch the printer data.""" + return await self.api.get_job() diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index e8d357726bc..80998d680d2 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -29,7 +29,9 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance -from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator +from . import PrusaLinkEntity +from .const import DOMAIN +from .coordinator import PrusaLinkUpdateCoordinator T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py index e018648e95e..459dc5c055c 100644 --- a/homeassistant/components/pure_energie/__init__.py +++ b/homeassistant/components/pure_energie/__init__.py @@ -2,18 +2,13 @@ from __future__ import annotations -from typing import NamedTuple - -from gridnet import Device, GridNet, SmartBridge - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN +from .coordinator import PureEnergieDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -39,39 +34,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class PureEnergieData(NamedTuple): - """Class for defining data in dict.""" - - device: Device - smartbridge: SmartBridge - - -class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching Pure Energie data from single eindpoint.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - ) -> None: - """Initialize global Pure Energie data updater.""" - super().__init__( - hass, - LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - self.gridnet = GridNet( - self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) - ) - - async def _async_update_data(self) -> PureEnergieData: - """Fetch data from SmartBridge.""" - return PureEnergieData( - device=await self.gridnet.device(), - smartbridge=await self.gridnet.smartbridge(), - ) diff --git a/homeassistant/components/pure_energie/coordinator.py b/homeassistant/components/pure_energie/coordinator.py new file mode 100644 index 00000000000..fdd848eb4c6 --- /dev/null +++ b/homeassistant/components/pure_energie/coordinator.py @@ -0,0 +1,51 @@ +"""Coordinator for the Pure Energie integration.""" + +from __future__ import annotations + +from typing import NamedTuple + +from gridnet import Device, GridNet, SmartBridge + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class PureEnergieData(NamedTuple): + """Class for defining data in dict.""" + + device: Device + smartbridge: SmartBridge + + +class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): + """Class to manage fetching Pure Energie data from single eindpoint.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize global Pure Energie data updater.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + self.gridnet = GridNet( + self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + + async def _async_update_data(self) -> PureEnergieData: + """Fetch data from SmartBridge.""" + return PureEnergieData( + device=await self.gridnet.device(), + smartbridge=await self.gridnet.smartbridge(), + ) diff --git a/homeassistant/components/pure_energie/diagnostics.py b/homeassistant/components/pure_energie/diagnostics.py index fb93b81a4fd..6e2b8ee7a35 100644 --- a/homeassistant/components/pure_energie/diagnostics.py +++ b/homeassistant/components/pure_energie/diagnostics.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import PureEnergieDataUpdateCoordinator from .const import DOMAIN +from .coordinator import PureEnergieDataUpdateCoordinator TO_REDACT = { CONF_HOST, diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 7f2c36bc4f6..85f4672a618 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -19,8 +19,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PureEnergieData, PureEnergieDataUpdateCoordinator from .const import DOMAIN +from .coordinator import PureEnergieData, PureEnergieDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 5ba88318a1c..050200f50d4 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -153,7 +153,7 @@ async def async_validate_api_key(hass: HomeAssistant, api_key: str) -> Validatio except PurpleAirError as err: LOGGER.error("PurpleAir error while checking API key: %s", err) errors["base"] = "unknown" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.exception("Unexpected exception while checking API key: %s", err) errors["base"] = "unknown" @@ -181,7 +181,7 @@ async def async_validate_coordinates( except PurpleAirError as err: LOGGER.error("PurpleAir error while getting nearby sensors: %s", err) errors["base"] = "unknown" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.exception("Unexpected exception while getting nearby sensors: %s", err) errors["base"] = "unknown" else: diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 6ef16ea29b6..a92f159d172 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -1,24 +1,15 @@ """The pvpc_hourly_pricing integration to collect Spain official electric prices.""" -from datetime import timedelta -import logging - -from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN, Platform 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 .const import ATTR_POWER, ATTR_POWER_P3, DOMAIN +from .coordinator import ElecPricesDataUpdateCoordinator from .helpers import get_enabled_sensor_keys -_LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -58,44 +49,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching Electricity prices data from API.""" - - def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] - ) -> None: - """Initialize.""" - self.api = PVPCData( - session=async_get_clientsession(hass), - tariff=entry.data[ATTR_TARIFF], - local_timezone=hass.config.time_zone, - 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) - ) - self._entry = entry - - @property - def entry_id(self) -> str: - """Return entry ID.""" - return self._entry.entry_id - - async def _async_update_data(self) -> EsiosApiData: - """Update electricity prices from the ESIOS API.""" - try: - api_data = await self.api.async_update_all(self.data, dt_util.utcnow()) - except BadApiTokenAuthError as exc: - raise ConfigEntryAuthFailed from exc - if ( - not api_data - or not api_data.sensors - or not any(api_data.availability.values()) - ): - raise UpdateFailed - return api_data diff --git a/homeassistant/components/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py new file mode 100644 index 00000000000..171e516abdc --- /dev/null +++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py @@ -0,0 +1,59 @@ +"""The pvpc_hourly_pricing integration to collect Spain official electric prices.""" + +from datetime import timedelta +import logging + +from aiopvpc import BadApiTokenAuthError, EsiosApiData, PVPCData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_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 homeassistant.util import dt as dt_util + +from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): + """Class to manage fetching Electricity prices data from API.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] + ) -> None: + """Initialize.""" + self.api = PVPCData( + session=async_get_clientsession(hass), + tariff=entry.data[ATTR_TARIFF], + local_timezone=hass.config.time_zone, + 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) + ) + self._entry = entry + + @property + def entry_id(self) -> str: + """Return entry ID.""" + return self._entry.entry_id + + async def _async_update_data(self) -> EsiosApiData: + """Update electricity prices from the ESIOS API.""" + try: + api_data = await self.api.async_update_all(self.data, dt_util.utcnow()) + except BadApiTokenAuthError as exc: + raise ConfigEntryAuthFailed from exc + if ( + not api_data + or not api_data.sensors + or not any(api_data.availability.values()) + ): + raise UpdateFailed + return api_data diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 246a8b65892..9d9fe5b9661 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -23,8 +23,8 @@ from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ElecPricesDataUpdateCoordinator from .const import DOMAIN +from .coordinator import ElecPricesDataUpdateCoordinator from .helpers import make_sensor_unique_id _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pyload/const.py b/homeassistant/components/pyload/const.py new file mode 100644 index 00000000000..a7d155d8b33 --- /dev/null +++ b/homeassistant/components/pyload/const.py @@ -0,0 +1,7 @@ +"""Constants for the pyLoad integration.""" + +DOMAIN = "pyload" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "pyLoad" +DEFAULT_PORT = 8000 diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 6cb641f6ead..90d750ff9b8 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -1,7 +1,10 @@ { "domain": "pyload", "name": "pyLoad", - "codeowners": [], + "codeowners": ["@tr4nt0r"], "documentation": "https://www.home-assistant.io/integrations/pyload", - "iot_class": "local_polling" + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["pyloadapi"], + "requirements": ["PyLoadAPI==1.1.0"] } diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index b7d4d1f461b..a005f848c37 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -3,9 +3,14 @@ from __future__ import annotations from datetime import timedelta +from enum import StrEnum import logging +from time import monotonic +from typing import Any -import requests +from aiohttp import CookieJar +from pyloadapi.api import PyLoadAPI +from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import voluptuous as vol from homeassistant.components.sensor import ( @@ -22,37 +27,44 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_USERNAME, - CONTENT_TYPE_JSON, UnitOfDataRate, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType + +from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = "localhost" -DEFAULT_NAME = "pyLoad" -DEFAULT_PORT = 8000 +SCAN_INTERVAL = timedelta(seconds=15) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) -SENSOR_TYPES = { - "speed": SensorEntityDescription( - key="speed", +class PyLoadSensorEntity(StrEnum): + """pyLoad Sensor Entities.""" + + SPEED = "speed" + + +SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=PyLoadSensorEntity.SPEED, name="Speed", - native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - ) -} + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + suggested_display_precision=1, + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_MONITORED_VARIABLES, default=["speed"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(PyLoadSensorEntity)] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, @@ -63,10 +75,10 @@ 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 pyLoad sensors.""" @@ -76,97 +88,94 @@ def setup_platform( name = config[CONF_NAME] username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - monitored_types = config[CONF_MONITORED_VARIABLES] - url = f"{protocol}://{host}:{port}/api/" + url = f"{protocol}://{host}:{port}/" + session = async_create_clientsession( + hass, + verify_ssl=False, + cookie_jar=CookieJar(unsafe=True), + ) + pyloadapi = PyLoadAPI(session, api_url=url, username=username, password=password) try: - pyloadapi = PyLoadAPI(api_url=url, username=username, password=password) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as conn_err: - _LOGGER.error("Error setting up pyLoad API: %s", conn_err) - return + await pyloadapi.login() + except CannotConnect as conn_err: + raise PlatformNotReady( + "Unable to connect and retrieve data from pyLoad API" + ) from conn_err + except ParserError as e: + raise PlatformNotReady("Unable to parse data from pyLoad API") from e + except InvalidAuth as e: + raise PlatformNotReady( + f"Authentication failed for {config[CONF_USERNAME]}, check your login credentials" + ) from e - devices = [] - for ng_type in monitored_types: - new_sensor = PyLoadSensor( - api=pyloadapi, sensor_type=SENSOR_TYPES[ng_type], client_name=name - ) - devices.append(new_sensor) - - add_entities(devices, True) + async_add_entities( + ( + PyLoadSensor( + api=pyloadapi, entity_description=description, client_name=name + ) + for description in SENSOR_DESCRIPTIONS + ), + True, + ) class PyLoadSensor(SensorEntity): """Representation of a pyLoad sensor.""" def __init__( - self, api: PyLoadAPI, sensor_type: SensorEntityDescription, client_name + self, api: PyLoadAPI, entity_description: SensorEntityDescription, client_name ) -> None: """Initialize a new pyLoad sensor.""" - self._attr_name = f"{client_name} {sensor_type.name}" - self.type = sensor_type.key + self._attr_name = f"{client_name} {entity_description.name}" + self.type = entity_description.key self.api = api - self.entity_description = sensor_type + self.entity_description = entity_description + self._attr_available = False + self.data: dict[str, Any] = {} - def update(self) -> None: + async def async_update(self) -> None: """Update state of sensor.""" + start = monotonic() try: - self.api.update() - except requests.exceptions.ConnectionError: - # Error calling the API, already logged in api.update() - return + status = await self.api.get_status() + except InvalidAuth: + _LOGGER.info("Authentication failed, trying to reauthenticate") + try: + await self.api.login() + except InvalidAuth: + _LOGGER.error( + "Authentication failed for %s, check your login credentials", + self.api.username, + ) + return + else: + _LOGGER.info( + "Unable to retrieve data due to cookie expiration " + "but re-authentication was successful" + ) + return + finally: + self._attr_available = False - if self.api.status is None: - _LOGGER.debug( - "Update of %s requested, but no status is available", self.name - ) + except CannotConnect: + _LOGGER.debug("Unable to connect and retrieve data from pyLoad API") + self._attr_available = False return - - if (value := self.api.status.get(self.type)) is None: - _LOGGER.warning("Unable to locate value for %s", self.type) + except ParserError: + _LOGGER.error("Unable to parse data from pyLoad API") + self._attr_available = False return - - if "speed" in self.type and value > 0: - # Convert download rate from Bytes/s to MBytes/s - self._attr_native_value = round(value / 2**20, 2) else: - self._attr_native_value = value - - -class PyLoadAPI: - """Simple wrapper for pyLoad's API.""" - - def __init__(self, api_url, username=None, password=None): - """Initialize pyLoad API and set headers needed later.""" - self.api_url = api_url - self.status = None - self.headers = {"Content-Type": CONTENT_TYPE_JSON} - - if username is not None and password is not None: - self.payload = {"username": username, "password": password} - self.login = requests.post(f"{api_url}login", data=self.payload, timeout=5) - self.update() - - def post(self): - """Send a POST request and return the response as a dict.""" - try: - response = requests.post( - f"{self.api_url}statusServer", - cookies=self.login.cookies, - headers=self.headers, - timeout=5, + self.data = status.to_dict() + _LOGGER.debug( + "Finished fetching pyload data in %.3f seconds", + monotonic() - start, ) - response.raise_for_status() - _LOGGER.debug("JSON Response: %s", response.json()) - return response.json() - except requests.exceptions.ConnectionError as conn_exc: - _LOGGER.error("Failed to update pyLoad status. Error: %s", conn_exc) - raise + self._attr_available = True - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update cached response.""" - self.status = self.post() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.data.get(self.entity_description.key) diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 89e9eb5a9eb..72e2f3a824b 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -200,7 +200,7 @@ def execute(hass, filename, source, data=None, return_response=False): _LOGGER.error( "Error loading script %s: %s", filename, ", ".join(compiled.errors) ) - return + return None if compiled.warnings: _LOGGER.warning( @@ -285,7 +285,7 @@ def execute(hass, filename, source, data=None, return_response=False): raise ServiceValidationError(f"Error executing script: {err}") from err logger.error("Error executing script: %s", err) return None - except Exception as err: # pylint: disable=broad-except + except Exception as err: if return_response: raise HomeAssistantError( f"Error executing script ({type(err).__name__}): {err}" diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 84f080c4d49..fb781dd1a0c 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -3,8 +3,7 @@ import logging from typing import Any -from qbittorrent.client import LoginRequired -from requests.exceptions import RequestException +from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -118,10 +117,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_PASSWORD], config_entry.data[CONF_VERIFY_SSL], ) - except LoginRequired as err: + except LoginFailed as err: raise ConfigEntryNotReady("Invalid credentials") from err - except RequestException as err: - raise ConfigEntryNotReady("Failed to connect") from err + except Forbidden403Error as err: + raise ConfigEntryNotReady("Fail to log in, banned user ?") from err + except APIConnectionError as exc: + raise ConfigEntryNotReady("Fail to connect to qBittorrent") from exc + coordinator = QBittorrentDataCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py index c17c842529b..fb9bde4805f 100644 --- a/homeassistant/components/qbittorrent/config_flow.py +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -5,8 +5,7 @@ from __future__ import annotations import logging from typing import Any -from qbittorrent.client import LoginRequired -from requests.exceptions import RequestException +from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -46,9 +45,9 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_PASSWORD], user_input[CONF_VERIFY_SSL], ) - except LoginRequired: + except (LoginFailed, Forbidden403Error): errors = {"base": "invalid_auth"} - except RequestException: + except APIConnectionError: errors = {"base": "cannot_connect"} else: return self.async_create_entry(title=DEFAULT_NAME, data=user_input) diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 850bcf15ca2..0ef36d2a954 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -4,10 +4,16 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any -from qbittorrent import Client -from qbittorrent.client import LoginRequired +from qbittorrentapi import ( + APIConnectionError, + Client, + Forbidden403Error, + LoginFailed, + SyncMainDataDictionary, + TorrentInfoList, +) +from qbittorrentapi.torrents import TorrentStatusesT from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -18,8 +24,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Coordinator for updating qBittorrent data.""" +class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): + """Coordinator for updating QBittorrent data.""" def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" @@ -39,22 +45,31 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(seconds=30), ) - async def _async_update_data(self) -> dict[str, Any]: - """Async method to update QBittorrent data.""" + async def _async_update_data(self) -> SyncMainDataDictionary: try: - return await self.hass.async_add_executor_job(self.client.sync_main_data) - except LoginRequired as exc: - raise HomeAssistantError(str(exc)) from exc - - async def get_torrents(self, torrent_filter: str) -> list[dict[str, Any]]: - """Async method to get QBittorrent torrents.""" - try: - torrents = await self.hass.async_add_executor_job( - lambda: self.client.torrents(filter=torrent_filter) - ) - except LoginRequired as exc: + return await self.hass.async_add_executor_job(self.client.sync_maindata) + except (LoginFailed, Forbidden403Error) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="login_error" ) from exc + except APIConnectionError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from exc + + async def get_torrents(self, torrent_filter: TorrentStatusesT) -> TorrentInfoList: + """Async method to get QBittorrent torrents.""" + try: + torrents = await self.hass.async_add_executor_job( + lambda: self.client.torrents_info(torrent_filter) + ) + except (LoginFailed, Forbidden403Error) as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="login_error" + ) from exc + except APIConnectionError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from exc return torrents diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py index bbe53765f8b..fac0a6033fa 100644 --- a/homeassistant/components/qbittorrent/helpers.py +++ b/homeassistant/components/qbittorrent/helpers.py @@ -1,17 +1,18 @@ """Helper functions for qBittorrent.""" from datetime import UTC, datetime -from typing import Any +from typing import Any, cast -from qbittorrent.client import Client +from qbittorrentapi import Client, TorrentDictionary, TorrentInfoList def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: """Create a qBittorrent client.""" - client = Client(url, verify=verify_ssl) - client.login(username, password) - # Get an arbitrary attribute to test if connection succeeds - client.get_alternative_speed_status() + + client = Client( + url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=verify_ssl + ) + client.auth_log_in(username, password) return client @@ -31,23 +32,24 @@ def format_unix_timestamp(timestamp) -> str: return dt_object.isoformat() -def format_progress(torrent) -> str: +def format_progress(torrent: TorrentDictionary) -> str: """Format the progress of a torrent.""" - progress = torrent["progress"] - progress = float(progress) * 100 + progress = cast(float, torrent["progress"]) * 100 return f"{progress:.2f}" -def format_torrents(torrents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: +def format_torrents( + torrents: TorrentInfoList, +) -> dict[str, dict[str, Any]]: """Format a list of torrents.""" value = {} for torrent in torrents: - value[torrent["name"]] = format_torrent(torrent) + value[str(torrent["name"])] = format_torrent(torrent) return value -def format_torrent(torrent) -> dict[str, Any]: +def format_torrent(torrent: TorrentDictionary) -> dict[str, Any]: """Format a single torrent.""" value = {} value["id"] = torrent["hash"] @@ -55,6 +57,7 @@ def format_torrent(torrent) -> dict[str, Any]: value["percent_done"] = format_progress(torrent) value["status"] = torrent["state"] value["eta"] = seconds_to_hhmmss(torrent["eta"]) - value["ratio"] = "{:.2f}".format(float(torrent["ratio"])) + ratio = cast(float, torrent["ratio"]) + value["ratio"] = f"{ratio:.2f}" return value diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index fb51f177081..bd9897aa6ba 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["python-qbittorrent==0.4.3"] + "requirements": ["qbittorrent-api==2024.2.59"] } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 84eac7d28cf..cd65fb766e4 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass import logging +from typing import Any, cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -35,8 +36,9 @@ SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents" 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"] + server_state = cast(Mapping, coordinator.data.get("server_state")) + upload = cast(int, server_state.get("up_info_speed")) + download = cast(int, server_state.get("dl_info_speed")) if upload > 0 and download > 0: return STATE_UP_DOWN @@ -47,6 +49,18 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str: return STATE_IDLE +def get_dl(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("dl_info_speed")) + + +def get_up(coordinator: QBittorrentDataCoordinator) -> int: + """Get current upload speed.""" + server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) + return cast(int, server_state.get("up_info_speed")) + + @dataclass(frozen=True, kw_only=True) class QBittorrentSensorEntityDescription(SensorEntityDescription): """Entity description class for qBittorent sensors.""" @@ -69,9 +83,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( 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"] - ), + value_fn=get_dl, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, @@ -80,9 +92,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( 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"] - ), + value_fn=get_up, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, @@ -165,16 +175,12 @@ def count_torrents_in_states( ) -> int: """Count the number of torrents in specified states.""" # When torrents are not in the returned data, there are none, return 0. - if "torrents" not in coordinator.data: + try: + torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents")) + if not states: + return len(torrents) + return len( + [torrent for torrent in torrents.values() if torrent.get("state") in states] + ) + except AttributeError: return 0 - - 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 5376e929429..948e9dca8e9 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -84,6 +84,9 @@ }, "login_error": { "message": "A login error occured. Please check you username and password." + }, + "cannot_connect": { + "message": "Can't connect to QBittorrent, please check your configuration." } } } diff --git a/homeassistant/components/qingping/binary_sensor.py b/homeassistant/components/qingping/binary_sensor.py index f4f81eac394..4c8c2b43425 100644 --- a/homeassistant/components/qingping/binary_sensor.py +++ b/homeassistant/components/qingping/binary_sensor.py @@ -94,7 +94,9 @@ async def async_setup_entry( class QingpingBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[bool | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[bool | None, SensorUpdate] + ], BinarySensorEntity, ): """Representation of a Qingping binary sensor.""" diff --git a/homeassistant/components/qingping/sensor.py b/homeassistant/components/qingping/sensor.py index e75c9b34f49..015df41f7bf 100644 --- a/homeassistant/components/qingping/sensor.py +++ b/homeassistant/components/qingping/sensor.py @@ -162,7 +162,9 @@ async def async_setup_entry( class QingpingBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a Qingping sensor.""" diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py index 3e0c524f59e..75f41a27f69 100644 --- a/homeassistant/components/qnap/config_flow.py +++ b/homeassistant/components/qnap/config_flow.py @@ -70,7 +70,7 @@ class QnapConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except TypeError: errors["base"] = "invalid_auth" - except Exception as error: # pylint: disable=broad-except + except Exception as error: # noqa: BLE001 _LOGGER.error(error) errors["base"] = "unknown" else: diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 6bf48995412..1bee69219b0 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -73,7 +73,7 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_host" except TimeoutConnect: errors["base"] = "timeout_connect" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.debug("Unexpected exception: %s", err) errors["base"] = "unknown" else: diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index e6248b2c93b..5a8b5856db7 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -20,6 +20,7 @@ from .const import ( KEY_DEVICE_ID, KEY_LOW, KEY_RAIN_SENSOR_TRIPPED, + KEY_REPLACE, KEY_REPORTED_STATE, KEY_STATE, KEY_STATUS, @@ -171,4 +172,7 @@ class RachioHoseTimerBattery(RachioHoseTimerEntity, BinarySensorEntity): data = self.coordinator.data[self.id] self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] - self._attr_is_on = self._static_attrs[KEY_BATTERY_STATUS] == KEY_LOW + self._attr_is_on = self._static_attrs[KEY_BATTERY_STATUS] in [ + KEY_LOW, + KEY_REPLACE, + ] diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index d0a311db60e..77fe20946b4 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -80,7 +80,7 @@ class RachioConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index b9b16c0cd87..891e92f55a1 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -58,6 +58,7 @@ KEY_CURRENT_STATUS = "lastWateringAction" KEY_DETECT_FLOW = "detectFlow" KEY_BATTERY_STATUS = "batteryStatus" KEY_LOW = "LOW" +KEY_REPLACE = "REPLACE" KEY_REASON = "reason" KEY_DEFAULT_RUNTIME = "defaultRuntimeSeconds" KEY_DURATION_SECONDS = "durationSeconds" diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 1a8dbe42904..8a35225b9b2 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -365,7 +365,7 @@ class RachioZone(RachioSwitch): def __str__(self): """Display the zone as a string.""" - return f'Rachio Zone "{self.name}" on {str(self._controller)}' + return f'Rachio Zone "{self.name}" on {self._controller!s}' @property def zone_id(self) -> str: diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index d3e44e6b7fc..1023bf10659 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, cast +from dataclasses import dataclass, fields +from typing import cast from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient @@ -34,9 +35,22 @@ from .coordinator import ( ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] +type RadarrConfigEntry = ConfigEntry[RadarrData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +@dataclass(kw_only=True, slots=True) +class RadarrData: + """Radarr data type.""" + + calendar: CalendarUpdateCoordinator + disk_space: DiskSpaceDataUpdateCoordinator + health: HealthDataUpdateCoordinator + movie: MoviesDataUpdateCoordinator + queue: QueueDataUpdateCoordinator + status: StatusDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bool: """Set up Radarr from a config entry.""" host_configuration = PyArrHostConfiguration( api_token=entry.data[CONF_API_KEY], @@ -47,27 +61,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, 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), - "queue": QueueDataUpdateCoordinator(hass, host_configuration, radarr), - "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), - } - for coordinator in coordinators.values(): + data = RadarrData( + 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), + queue=QueueDataUpdateCoordinator(hass, host_configuration, radarr), + status=StatusDataUpdateCoordinator(hass, host_configuration, radarr), + ) + for field in fields(data): + coordinator: RadarrDataUpdateCoordinator = getattr(data, field.name) + # Movie update can take a while depending on Radarr database size + if field.name == "movie": + entry.async_create_background_task( + hass, + coordinator.async_config_entry_first_refresh(), + "radarr.movie-coordinator-first-refresh", + ) + continue await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + entry.runtime_data = data 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, entry: RadarrConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator[T]]): diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index 4962ef81614..6c0468cff58 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -9,13 +9,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrEntity -from .const import DOMAIN, HEALTH_ISSUES +from . import RadarrConfigEntry, RadarrEntity +from .const import HEALTH_ISSUES BINARY_SENSOR_TYPE = BinarySensorEntityDescription( key="health", @@ -27,11 +26,11 @@ BINARY_SENSOR_TYPE = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RadarrConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Radarr sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id]["health"] + coordinator = entry.runtime_data.health async_add_entities([RadarrBinarySensor(coordinator, BINARY_SENSOR_TYPE)]) diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index ad5e1b8ffd9..4f866123a1a 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -5,13 +5,11 @@ 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 . import RadarrConfigEntry, RadarrEntity from .coordinator import CalendarUpdateCoordinator, RadarrEvent CALENDAR_TYPE = EntityDescription( @@ -21,10 +19,12 @@ CALENDAR_TYPE = EntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: RadarrConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Radarr calendar entity.""" - coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + coordinator = entry.runtime_data.calendar async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) diff --git a/homeassistant/components/radarr/config_flow.py b/homeassistant/components/radarr/config_flow.py index 81589c5fe30..3bf0796a9a8 100644 --- a/homeassistant/components/radarr/config_flow.py +++ b/homeassistant/components/radarr/config_flow.py @@ -11,11 +11,12 @@ from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import RadarrConfigEntry from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN @@ -23,10 +24,7 @@ class RadarrConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Radarr.""" VERSION = 1 - - def __init__(self) -> None: - """Initialize the flow.""" - self.entry: ConfigEntry | None = None + entry: RadarrConfigEntry | None = None async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: """Handle configuration by re-auth.""" diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 0580fdcc020..6e8a3d55d3e 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass from datetime import date, datetime, timedelta -from typing import Generic, TypeVar, cast +from typing import TYPE_CHECKING, Generic, TypeVar, cast from aiopyarr import ( Health, @@ -20,13 +20,15 @@ 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 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER +if TYPE_CHECKING: + from . import RadarrConfigEntry + T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) @@ -45,8 +47,8 @@ class RadarrEvent(CalendarEvent, RadarrEventMixIn): class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" - config_entry: ConfigEntry - update_interval = timedelta(seconds=30) + config_entry: RadarrConfigEntry + _update_interval = timedelta(seconds=30) def __init__( self, @@ -59,7 +61,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=self.update_interval, + update_interval=self._update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -133,7 +135,7 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): """Calendar update coordinator.""" - update_interval = timedelta(hours=1) + _update_interval = timedelta(hours=1) def __init__( self, diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index e6700fb3637..441c44de781 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -15,13 +15,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrEntity -from .const import DOMAIN +from . import RadarrConfigEntry, RadarrEntity from .coordinator import RadarrDataUpdateCoordinator, T @@ -117,16 +115,13 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: RadarrConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Radarr sensors based on a config entry.""" - coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ - entry.entry_id - ] entities: list[RadarrSensor[Any]] = [] for coordinator_type, description in SENSOR_TYPES.items(): - coordinator = coordinators[coordinator_type] + coordinator = getattr(entry.runtime_data, coordinator_type) if coordinator_type != "disk_space": entities.append(RadarrSensor(coordinator, description)) else: diff --git a/homeassistant/components/radio_browser/__init__.py b/homeassistant/components/radio_browser/__init__.py index d1c2db3543a..eff7796711f 100644 --- a/homeassistant/components/radio_browser/__init__.py +++ b/homeassistant/components/radio_browser/__init__.py @@ -11,10 +11,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +type RadioBrowserConfigEntry = ConfigEntry[RadioBrowser] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: RadioBrowserConfigEntry +) -> bool: """Set up Radio Browser from a config entry. This integration doesn't set up any entities, as it provides a media source @@ -28,11 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (DNSError, RadioBrowserError) as err: raise ConfigEntryNotReady("Could not connect to Radio Browser API") from err - hass.data[DOMAIN] = radios + entry.runtime_data = radios return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - del hass.data[DOMAIN] return True diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index 5bf0b7f491b..2f95acf407d 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -5,8 +5,9 @@ from __future__ import annotations import mimetypes from radios import FilterBy, Order, RadioBrowser, Station +from radios.radio_browser import pycountry -from homeassistant.components.media_player import BrowseError, MediaClass, MediaType +from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source.error import Unresolvable from homeassistant.components.media_source.models import ( BrowseMediaSource, @@ -14,9 +15,9 @@ from homeassistant.components.media_source.models import ( MediaSourceItem, PlayMedia, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from . import RadioBrowserConfigEntry from .const import DOMAIN CODEC_TO_MIMETYPE = { @@ -40,24 +41,21 @@ class RadioMediaSource(MediaSource): name = "Radio Browser" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: RadioBrowserConfigEntry) -> None: """Initialize RadioMediaSource.""" super().__init__(DOMAIN) self.hass = hass self.entry = entry @property - def radios(self) -> RadioBrowser | None: + def radios(self) -> RadioBrowser: """Return the radio browser.""" - return self.hass.data.get(DOMAIN) + return self.entry.runtime_data async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve selected Radio station to a streaming URL.""" radios = self.radios - if radios is None: - raise Unresolvable("Radio Browser not initialized") - station = await radios.station(uuid=item.identifier) if not station: raise Unresolvable("Radio station is no longer available") @@ -77,9 +75,6 @@ class RadioMediaSource(MediaSource): """Return media.""" radios = self.radios - if radios is None: - raise BrowseError("Radio Browser not initialized") - return BrowseMediaSource( domain=DOMAIN, identifier=None, @@ -151,6 +146,8 @@ class RadioMediaSource(MediaSource): # We show country in the root additionally, when there is no item if not item.identifier or category == "country": + # Trigger the lazy loading of the country database to happen inside the executor + await self.hass.async_add_executor_job(lambda: len(pycountry.countries)) countries = await radios.countries(order=Order.NAME) return [ BrowseMediaSource( diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index d5f1e4c076c..7b2eaba52c4 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Coroutine -from typing import Any, TypeVar +from typing import Any from urllib.error import URLError from radiotherm.validate import RadiothermTstatError @@ -20,10 +20,8 @@ from .util import async_set_time PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] -_T = TypeVar("_T") - -async def _async_call_or_raise_not_ready( +async def _async_call_or_raise_not_ready[_T]( coro: Coroutine[Any, Any, _T], host: str ) -> _T: """Call a coro or raise ConfigEntryNotReady.""" diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index a8de05d9963..e9904318ae9 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -94,7 +94,7 @@ class RadioThermConfigFlow(ConfigFlow, domain=DOMAIN): init_data = await validate_connection(self.hass, user_input[CONF_HOST]) except CannotConnect: errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 85906fa3fe3..42c1cce69d3 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -73,7 +73,7 @@ class RainBirdCalendarEntity( schedule = self.coordinator.data if not schedule: return None - cursor = schedule.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( + cursor = schedule.timeline_tz(dt_util.get_default_time_zone()).active_after( dt_util.now() ) program_event = next(cursor, None) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 44576db8a33..c1c814b05c4 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -120,12 +120,12 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) except TimeoutError as err: raise ConfigFlowError( - f"Timeout connecting to Rain Bird controller: {str(err)}", + f"Timeout connecting to Rain Bird controller: {err!s}", "timeout_connect", ) from err except RainbirdApiException as err: raise ConfigFlowError( - f"Error connecting to Rain Bird controller: {str(err)}", + f"Error connecting to Rain Bird controller: {err!s}", "cannot_connect", ) from err finally: diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 7823626f54c..2364b7b014f 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rainbird", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==4.0.2"] + "requirements": ["pyrainbird==6.0.1"] } diff --git a/homeassistant/components/rainforest_eagle/__init__.py b/homeassistant/components/rainforest_eagle/__init__.py index 67baa4dbd99..5be2e778c5d 100644 --- a/homeassistant/components/rainforest_eagle/__init__.py +++ b/homeassistant/components/rainforest_eagle/__init__.py @@ -6,15 +6,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from . import data from .const import DOMAIN +from .coordinator import EagleDataCoordinator PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Rainforest Eagle from a config entry.""" - coordinator = data.EagleDataCoordinator(hass, entry) + coordinator = EagleDataCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py index b48c1329695..867bc5886db 100644 --- a/homeassistant/components/rainforest_eagle/config_flow.py +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -10,8 +10,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE -from . import data from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN +from .data import CannotConnect, InvalidAuth, async_get_type _LOGGER = logging.getLogger(__name__) @@ -49,17 +49,17 @@ class RainforestEagleConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} try: - eagle_type, hardware_address = await data.async_get_type( + eagle_type, hardware_address = await async_get_type( self.hass, user_input[CONF_CLOUD_ID], user_input[CONF_INSTALL_CODE], user_input[CONF_HOST], ) - except data.CannotConnect: + except CannotConnect: errors["base"] = "cannot_connect" - except data.InvalidAuth: + except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/rainforest_eagle/coordinator.py b/homeassistant/components/rainforest_eagle/coordinator.py new file mode 100644 index 00000000000..9c714a291ee --- /dev/null +++ b/homeassistant/components/rainforest_eagle/coordinator.py @@ -0,0 +1,131 @@ +"""Rainforest data.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging + +import aioeagle +from eagle100 import Eagle as Eagle100Reader + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + TYPE_EAGLE_100, +) +from .data import UPDATE_100_ERRORS + +_LOGGER = logging.getLogger(__name__) + + +class EagleDataCoordinator(DataUpdateCoordinator): + """Get the latest data from the Eagle device.""" + + eagle100_reader: Eagle100Reader | None = None + eagle200_meter: aioeagle.ElectricMeter | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + self.entry = entry + if self.type == TYPE_EAGLE_100: + self.model = "EAGLE-100" + update_method = self._async_update_data_100 + else: + self.model = "EAGLE-200" + update_method = self._async_update_data_200 + + super().__init__( + hass, + _LOGGER, + name=entry.data[CONF_CLOUD_ID], + update_interval=timedelta(seconds=30), + update_method=update_method, + ) + + @property + def cloud_id(self): + """Return the cloud ID.""" + return self.entry.data[CONF_CLOUD_ID] + + @property + def type(self): + """Return entry type.""" + return self.entry.data[CONF_TYPE] + + @property + def hardware_address(self): + """Return hardware address of meter.""" + return self.entry.data[CONF_HARDWARE_ADDRESS] + + @property + def is_connected(self): + """Return if the hub is connected to the electric meter.""" + if self.eagle200_meter: + return self.eagle200_meter.is_connected + + return True + + async def _async_update_data_200(self): + """Get the latest data from the Eagle-200 device.""" + if (eagle200_meter := self.eagle200_meter) is None: + hub = aioeagle.EagleHub( + aiohttp_client.async_get_clientsession(self.hass), + self.cloud_id, + self.entry.data[CONF_INSTALL_CODE], + host=self.entry.data[CONF_HOST], + ) + eagle200_meter = aioeagle.ElectricMeter.create_instance( + hub, self.hardware_address + ) + is_connected = True + else: + is_connected = eagle200_meter.is_connected + + async with asyncio.timeout(30): + data = await eagle200_meter.get_device_query() + + if self.eagle200_meter is None: + self.eagle200_meter = eagle200_meter + elif is_connected and not eagle200_meter.is_connected: + _LOGGER.warning("Lost connection with electricity meter") + + _LOGGER.debug("API data: %s", data) + return {var["Name"]: var["Value"] for var in data.values()} + + async def _async_update_data_100(self): + """Get the latest data from the Eagle-100 device.""" + try: + data = await self.hass.async_add_executor_job(self._fetch_data_100) + except UPDATE_100_ERRORS as error: + raise UpdateFailed from error + + _LOGGER.debug("API data: %s", data) + return data + + def _fetch_data_100(self): + """Fetch and return the four sensor values in a dict.""" + if self.eagle100_reader is None: + self.eagle100_reader = Eagle100Reader( + self.cloud_id, + self.entry.data[CONF_INSTALL_CODE], + self.entry.data[CONF_HOST], + ) + + out = {} + + resp = self.eagle100_reader.get_instantaneous_demand()["InstantaneousDemand"] + out["zigbee:InstantaneousDemand"] = resp["Demand"] + + resp = self.eagle100_reader.get_current_summation()["CurrentSummation"] + out["zigbee:CurrentSummationDelivered"] = resp["SummationDelivered"] + out["zigbee:CurrentSummationReceived"] = resp["SummationReceived"] + + return out diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 879aa467d9b..bd2f63fc56a 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from datetime import timedelta import logging import aioeagle @@ -11,20 +10,10 @@ import aiohttp from eagle100 import Eagle as Eagle100Reader from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_TYPE -from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - CONF_CLOUD_ID, - CONF_HARDWARE_ADDRESS, - CONF_INSTALL_CODE, - TYPE_EAGLE_100, - TYPE_EAGLE_200, -) +from .const import TYPE_EAGLE_100, TYPE_EAGLE_200 _LOGGER = logging.getLogger(__name__) @@ -86,108 +75,3 @@ async def async_get_type(hass, cloud_id, install_code, host): return TYPE_EAGLE_100, None return None, None - - -class EagleDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module - """Get the latest data from the Eagle device.""" - - eagle100_reader: Eagle100Reader | None = None - eagle200_meter: aioeagle.ElectricMeter | None = None - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the data object.""" - self.entry = entry - if self.type == TYPE_EAGLE_100: - self.model = "EAGLE-100" - update_method = self._async_update_data_100 - else: - self.model = "EAGLE-200" - update_method = self._async_update_data_200 - - super().__init__( - hass, - _LOGGER, - name=entry.data[CONF_CLOUD_ID], - update_interval=timedelta(seconds=30), - update_method=update_method, - ) - - @property - def cloud_id(self): - """Return the cloud ID.""" - return self.entry.data[CONF_CLOUD_ID] - - @property - def type(self): - """Return entry type.""" - return self.entry.data[CONF_TYPE] - - @property - def hardware_address(self): - """Return hardware address of meter.""" - return self.entry.data[CONF_HARDWARE_ADDRESS] - - @property - def is_connected(self): - """Return if the hub is connected to the electric meter.""" - if self.eagle200_meter: - return self.eagle200_meter.is_connected - - return True - - async def _async_update_data_200(self): - """Get the latest data from the Eagle-200 device.""" - if (eagle200_meter := self.eagle200_meter) is None: - hub = aioeagle.EagleHub( - aiohttp_client.async_get_clientsession(self.hass), - self.cloud_id, - self.entry.data[CONF_INSTALL_CODE], - host=self.entry.data[CONF_HOST], - ) - eagle200_meter = aioeagle.ElectricMeter.create_instance( - hub, self.hardware_address - ) - is_connected = True - else: - is_connected = eagle200_meter.is_connected - - async with asyncio.timeout(30): - data = await eagle200_meter.get_device_query() - - if self.eagle200_meter is None: - self.eagle200_meter = eagle200_meter - elif is_connected and not eagle200_meter.is_connected: - _LOGGER.warning("Lost connection with electricity meter") - - _LOGGER.debug("API data: %s", data) - return {var["Name"]: var["Value"] for var in data.values()} - - async def _async_update_data_100(self): - """Get the latest data from the Eagle-100 device.""" - try: - data = await self.hass.async_add_executor_job(self._fetch_data_100) - except UPDATE_100_ERRORS as error: - raise UpdateFailed from error - - _LOGGER.debug("API data: %s", data) - return data - - def _fetch_data_100(self): - """Fetch and return the four sensor values in a dict.""" - if self.eagle100_reader is None: - self.eagle100_reader = Eagle100Reader( - self.cloud_id, - self.entry.data[CONF_INSTALL_CODE], - self.entry.data[CONF_HOST], - ) - - out = {} - - resp = self.eagle100_reader.get_instantaneous_demand()["InstantaneousDemand"] - out["zigbee:InstantaneousDemand"] = resp["Demand"] - - resp = self.eagle100_reader.get_current_summation()["CurrentSummation"] - out["zigbee:CurrentSummationDelivered"] = resp["SummationDelivered"] - out["zigbee:CurrentSummationReceived"] = resp["SummationReceived"] - - return out diff --git a/homeassistant/components/rainforest_eagle/diagnostics.py b/homeassistant/components/rainforest_eagle/diagnostics.py index 14c980bad7d..ec40f2515b1 100644 --- a/homeassistant/components/rainforest_eagle/diagnostics.py +++ b/homeassistant/components/rainforest_eagle/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import CONF_CLOUD_ID, CONF_INSTALL_CODE, DOMAIN -from .data import EagleDataCoordinator +from .coordinator import EagleDataCoordinator TO_REDACT = {CONF_CLOUD_ID, CONF_INSTALL_CODE} diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 27eae0e3e8e..8c4c5927998 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -17,7 +17,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .data import EagleDataCoordinator +from .coordinator import EagleDataCoordinator SENSORS = ( SensorEntityDescription( diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index bcd60875c70..0891d22b641 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -53,8 +53,8 @@ from .const import ( DOMAIN, LOGGER, ) +from .coordinator import RainMachineDataUpdateCoordinator from .model import RainMachineEntityDescription -from .util import RainMachineDataUpdateCoordinator DEFAULT_SSL = True diff --git a/homeassistant/components/rainmachine/coordinator.py b/homeassistant/components/rainmachine/coordinator.py new file mode 100644 index 00000000000..620bdb2da9b --- /dev/null +++ b/homeassistant/components/rainmachine/coordinator.py @@ -0,0 +1,101 @@ +"""Coordinator for the RainMachine integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from datetime import timedelta +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + +SIGNAL_REBOOT_COMPLETED = "rainmachine_reboot_completed_{0}" +SIGNAL_REBOOT_REQUESTED = "rainmachine_reboot_requested_{0}" + + +class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): + """Define an extended DataUpdateCoordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + *, + entry: ConfigEntry, + name: str, + api_category: str, + update_interval: timedelta, + update_method: Callable[[], Coroutine[Any, Any, dict]], + ) -> None: + """Initialize.""" + super().__init__( + hass, + LOGGER, + name=name, + update_interval=update_interval, + update_method=update_method, + always_update=False, + ) + + self._rebooting = False + self._signal_handler_unsubs: list[Callable[[], None]] = [] + self.config_entry = entry + self.signal_reboot_completed = SIGNAL_REBOOT_COMPLETED.format( + self.config_entry.entry_id + ) + self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( + self.config_entry.entry_id + ) + + @callback + def async_initialize(self) -> None: + """Initialize the coordinator.""" + + @callback + def async_reboot_completed() -> None: + """Respond to a reboot completed notification.""" + LOGGER.debug("%s responding to reboot complete", self.name) + self._rebooting = False + self.last_update_success = True + self.async_update_listeners() + + @callback + def async_reboot_requested() -> None: + """Respond to a reboot request.""" + LOGGER.debug("%s responding to reboot request", self.name) + self._rebooting = True + self.last_update_success = False + self.async_update_listeners() + + for signal, func in ( + (self.signal_reboot_completed, async_reboot_completed), + (self.signal_reboot_requested, async_reboot_requested), + ): + self._signal_handler_unsubs.append( + async_dispatcher_connect(self.hass, signal, func) + ) + + @callback + def async_check_reboot_complete() -> None: + """Check whether an active reboot has been completed.""" + if self._rebooting and self.last_update_success: + LOGGER.debug("%s discovered reboot complete", self.name) + async_dispatcher_send(self.hass, self.signal_reboot_completed) + + self.async_add_listener(async_check_reboot_complete) + + @callback + def async_teardown() -> None: + """Tear the coordinator down appropriately.""" + for unsub in self._signal_handler_unsubs: + unsub() + + self.config_entry.async_on_unload(async_teardown) diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index f7be08d71d3..328d5193e1e 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from datetime import datetime -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from regenmaschine.errors import RainMachineError import voluptuous as vol @@ -35,11 +35,11 @@ from .const import ( from .model import RainMachineEntityDescription from .util import RUN_STATE_MAP, key_exists +ATTR_ACTIVITY_TYPE = "activity_type" ATTR_AREA = "area" ATTR_CS_ON = "cs_on" ATTR_CURRENT_CYCLE = "current_cycle" ATTR_CYCLES = "cycles" -ATTR_ZONE_RUN_TIME = "zone_run_time_from_app" ATTR_DELAY = "delay" ATTR_DELAY_ON = "delay_on" ATTR_FIELD_CAPACITY = "field_capacity" @@ -55,6 +55,7 @@ ATTR_STATUS = "status" ATTR_SUN_EXPOSURE = "sun_exposure" ATTR_VEGETATION_TYPE = "vegetation_type" ATTR_ZONES = "zones" +ATTR_ZONE_RUN_TIME = "zone_run_time_from_app" DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] @@ -110,11 +111,7 @@ VEGETATION_MAP = { } -_T = TypeVar("_T", bound="RainMachineBaseSwitch") -_P = ParamSpec("_P") - - -def raise_on_request_error( +def raise_on_request_error[_T: RainMachineBaseSwitch, **_P]( func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a decorator to raise on a request error.""" @@ -142,6 +139,7 @@ class RainMachineSwitchDescription( class RainMachineActivitySwitchDescription(RainMachineSwitchDescription): """Describe a RainMachine activity (program/zone) switch.""" + kind: str uid: int @@ -215,6 +213,7 @@ async def async_setup_entry( key=f"{kind}_{uid}", name=name, api_category=api_category, + kind=kind, uid=uid, ), ) @@ -229,6 +228,7 @@ async def async_setup_entry( key=f"{kind}_{uid}_enabled", name=f"{name} enabled", api_category=api_category, + kind=kind, uid=uid, ), ) @@ -291,6 +291,19 @@ class RainMachineActivitySwitch(RainMachineBaseSwitch): _attr_icon = "mdi:water" entity_description: RainMachineActivitySwitchDescription + def __init__( + self, + entry: ConfigEntry, + data: RainMachineData, + description: RainMachineSwitchDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, data, description) + + self._attr_extra_state_attributes[ATTR_ACTIVITY_TYPE] = ( + self.entity_description.kind + ) + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off. @@ -339,6 +352,19 @@ class RainMachineEnabledSwitch(RainMachineBaseSwitch): _attr_icon = "mdi:cog" entity_description: RainMachineActivitySwitchDescription + def __init__( + self, + entry: ConfigEntry, + data: RainMachineData, + description: RainMachineSwitchDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, data, description) + + self._attr_extra_state_attributes[ATTR_ACTIVITY_TYPE] = ( + self.entity_description.kind + ) + @callback def update_from_latest_data(self) -> None: """Update the entity when new data is received.""" diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index 2848101eca1..f3823d21164 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -2,26 +2,17 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Iterable from dataclasses import dataclass -from datetime import timedelta from enum import StrEnum from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import LOGGER -SIGNAL_REBOOT_COMPLETED = "rainmachine_reboot_completed_{0}" -SIGNAL_REBOOT_REQUESTED = "rainmachine_reboot_requested_{0}" - class RunStates(StrEnum): """Define an enum for program/zone run states.""" @@ -84,84 +75,3 @@ def key_exists(data: dict[str, Any], search_key: str) -> bool: if isinstance(value, dict): return key_exists(value, search_key) return False - - -class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): # pylint: disable=hass-enforce-coordinator-module - """Define an extended DataUpdateCoordinator.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - *, - entry: ConfigEntry, - name: str, - api_category: str, - update_interval: timedelta, - update_method: Callable[..., Awaitable], - ) -> None: - """Initialize.""" - super().__init__( - hass, - LOGGER, - name=name, - update_interval=update_interval, - update_method=update_method, - always_update=False, - ) - - self._rebooting = False - self._signal_handler_unsubs: list[Callable[..., None]] = [] - self.config_entry = entry - self.signal_reboot_completed = SIGNAL_REBOOT_COMPLETED.format( - self.config_entry.entry_id - ) - self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( - self.config_entry.entry_id - ) - - @callback - def async_initialize(self) -> None: - """Initialize the coordinator.""" - - @callback - def async_reboot_completed() -> None: - """Respond to a reboot completed notification.""" - LOGGER.debug("%s responding to reboot complete", self.name) - self._rebooting = False - self.last_update_success = True - self.async_update_listeners() - - @callback - def async_reboot_requested() -> None: - """Respond to a reboot request.""" - LOGGER.debug("%s responding to reboot request", self.name) - self._rebooting = True - self.last_update_success = False - self.async_update_listeners() - - for signal, func in ( - (self.signal_reboot_completed, async_reboot_completed), - (self.signal_reboot_requested, async_reboot_requested), - ): - self._signal_handler_unsubs.append( - async_dispatcher_connect(self.hass, signal, func) - ) - - @callback - def async_check_reboot_complete() -> None: - """Check whether an active reboot has been completed.""" - if self._rebooting and self.last_update_success: - LOGGER.debug("%s discovered reboot complete", self.name) - async_dispatcher_send(self.hass, self.signal_reboot_completed) - - self.async_add_listener(async_check_reboot_complete) - - @callback - def async_teardown() -> None: - """Tear the coordinator down appropriately.""" - for unsub in self._signal_handler_unsubs: - unsub() - - self.config_entry.async_on_unload(async_teardown) diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index dc7d91603a5..fcbd77916a9 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -107,7 +107,7 @@ def _validate_unit(options: dict[str, Any]) -> None: and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units ): sorted_units = sorted( - [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], + [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], key=str.casefold, ) if len(sorted_units) == 1: diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py index d718bbc031a..fd88cbcb54c 100644 --- a/homeassistant/components/rapt_ble/sensor.py +++ b/homeassistant/components/rapt_ble/sensor.py @@ -115,7 +115,9 @@ async def async_setup_entry( class RAPTPillBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a RAPT Pill BLE sensor.""" diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 26b9f471b9e..f5e72912224 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -127,16 +127,15 @@ def is_entity_recorded(hass: HomeAssistant, entity_id: str) -> bool: Async friendly. """ - if DATA_INSTANCE not in hass.data: - return False instance = get_instance(hass) - return instance.entity_filter(entity_id) + return instance.entity_filter is None or instance.entity_filter(entity_id) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the recorder.""" conf = config[DOMAIN] - entity_filter = convert_include_exclude_filter(conf).get_filter() + _filter = convert_include_exclude_filter(conf) + entity_filter = None if _filter.empty_filter else _filter.get_filter() auto_purge = conf[CONF_AUTO_PURGE] auto_repack = conf[CONF_AUTO_REPACK] keep_days = conf[CONF_PURGE_KEEP_DAYS] @@ -165,6 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity_filter=entity_filter, exclude_event_types=exclude_event_types, ) + get_instance.cache_clear() instance.async_initialize() instance.async_register() instance.start() diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index 41be13312d0..1373f466bc2 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -55,7 +55,7 @@ def validate_table_schema_supports_utf8( schema_errors = _validate_table_schema_supports_utf8( instance, table_object, columns ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error when validating DB schema") _log_schema_errors(table_object, schema_errors) @@ -76,7 +76,7 @@ def validate_table_schema_has_correct_collation( schema_errors = _validate_table_schema_has_correct_collation( instance, table_object ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error when validating DB schema") _log_schema_errors(table_object, schema_errors) @@ -103,8 +103,7 @@ def _validate_table_schema_has_correct_collation( collate = ( dialect_kwargs.get("mysql_collate") or dialect_kwargs.get("mariadb_collate") - # pylint: disable-next=protected-access - or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] + or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001 ) if collate and collate != "utf8mb4_unicode_ci": _LOGGER.debug( @@ -159,7 +158,7 @@ def validate_db_schema_precision( return schema_errors try: schema_errors = _validate_db_schema_precision(instance, table_object) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error when validating DB schema") _log_schema_errors(table_object, schema_errors) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 1869bb32239..f2af5306ded 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,6 +1,9 @@ """Recorder constants.""" +from __future__ import annotations + from enum import StrEnum +from typing import TYPE_CHECKING from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -10,8 +13,15 @@ from homeassistant.const import ( EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401 ) from homeassistant.helpers.json import JSON_DUMP # noqa: F401 +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .core import Recorder # noqa: F401 + + +DATA_INSTANCE: HassKey[Recorder] = HassKey("recorder_instance") + -DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" MARIADB_URL_PREFIX = "mariadb://" MARIADB_PYMYSQL_URL_PREFIX = "mariadb+pymysql://" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 92d9baed771..a5eecf42f22 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -7,12 +7,13 @@ from collections.abc import Callable, Iterable from concurrent.futures import CancelledError import contextlib from datetime import datetime, timedelta +from functools import cached_property import logging import queue import sqlite3 import threading import time -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, cast import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select, update @@ -138,8 +139,6 @@ from .util import ( _LOGGER = logging.getLogger(__name__) -T = TypeVar("T") - DEFAULT_URL = "sqlite:///{hass_config_path}" # Controls how often we clean up @@ -179,7 +178,7 @@ class Recorder(threading.Thread): uri: str, db_max_retries: int, db_retry_wait: int, - entity_filter: Callable[[str], bool], + entity_filter: Callable[[str], bool] | None, exclude_event_types: set[EventType[Any] | str], ) -> None: """Initialize the recorder.""" @@ -187,6 +186,7 @@ class Recorder(threading.Thread): self.hass = hass self.thread_id: int | None = None + self.recorder_and_worker_thread_ids: set[int] = set() self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days @@ -259,7 +259,7 @@ class Recorder(threading.Thread): """Return the number of items in the recorder backlog.""" return self._queue.qsize() - @property + @cached_property def dialect_name(self) -> SupportedDialect | None: """Return the dialect the recorder uses.""" return self._dialect_name @@ -294,6 +294,7 @@ class Recorder(threading.Thread): def async_start_executor(self) -> None: """Start the executor.""" self._db_executor = DBInterruptibleThreadPoolExecutor( + self.recorder_and_worker_thread_ids, thread_name_prefix=DB_WORKER_PREFIX, max_workers=MAX_DB_EXECUTOR_WORKERS, shutdown_hook=self._shutdown_pool, @@ -317,7 +318,10 @@ class Recorder(threading.Thread): if event.event_type in exclude_event_types: return - if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: + if ( + entity_filter is None + or (entity_id := event.data.get(ATTR_ENTITY_ID)) is None + ): queue_put(event) return @@ -364,9 +368,9 @@ class Recorder(threading.Thread): self.queue_task(COMMIT_TASK) @callback - def async_add_executor_job( - self, target: Callable[..., T], *args: Any - ) -> asyncio.Future[T]: + def async_add_executor_job[_T]( + self, target: Callable[..., _T], *args: Any + ) -> asyncio.Future[_T]: """Add an executor job from within the event loop.""" return self.hass.loop.run_in_executor(self._db_executor, target, *args) @@ -700,7 +704,7 @@ class Recorder(threading.Thread): self.is_running = True try: self._run() - except Exception: # pylint: disable=broad-exception-caught + except Exception: _LOGGER.exception( "Recorder._run threw unexpected exception, recorder shutting down" ) @@ -717,7 +721,10 @@ class Recorder(threading.Thread): def _run(self) -> None: """Start processing events to save.""" - self.thread_id = threading.get_ident() + thread_id = threading.get_ident() + self.thread_id = thread_id + self.recorder_and_worker_thread_ids.add(thread_id) + setup_result = self._setup_recorder() if not setup_result: @@ -900,7 +907,7 @@ class Recorder(threading.Thread): _LOGGER.debug("Processing task: %s", task) try: self._process_one_task_or_event_or_recover(task) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error while processing event %s", task) def _process_one_task_or_event_or_recover(self, task: RecorderTask | Event) -> None: @@ -919,13 +926,15 @@ class Recorder(threading.Thread): assert isinstance(task, RecorderTask) if task.commit_before: self._commit_event_session_or_retry() - return task.run(self) + task.run(self) except exc.DatabaseError as err: if self._handle_database_error(err): return _LOGGER.exception("Unhandled database error while processing task %s", task) except SQLAlchemyError: _LOGGER.exception("SQLAlchemyError error processing task %s", task) + else: + return # Reset the session if an SQLAlchemyError (including DatabaseError) # happens to rollback and recover @@ -941,7 +950,7 @@ class Recorder(threading.Thread): return migration.initialize_database(self.get_session) except UnsupportedDialect: break - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error during connection setup: (retrying in %s seconds)", self.db_retry_wait, @@ -985,7 +994,7 @@ class Recorder(threading.Thread): return True _LOGGER.exception("Database error during schema migration") return False - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error during schema migration") return False else: @@ -1411,6 +1420,9 @@ class Recorder(threading.Thread): kwargs["pool_reset_on_return"] = None elif self.db_url.startswith(SQLITE_URL_PREFIX): kwargs["poolclass"] = RecorderPool + kwargs["recorder_and_worker_thread_ids"] = ( + self.recorder_and_worker_thread_ids + ) elif self.db_url.startswith( ( MARIADB_URL_PREFIX, @@ -1438,6 +1450,7 @@ class Recorder(threading.Thread): self.engine = create_engine(self.db_url, **kwargs, future=True) self._dialect_name = try_parse_enum(SupportedDialect, self.engine.dialect.name) + self.__dict__.pop("dialect_name", None) sqlalchemy_event.listen(self.engine, "connect", self._setup_recorder_connection) Base.metadata.create_all(self.engine) @@ -1473,7 +1486,7 @@ class Recorder(threading.Thread): self.recorder_runs_manager.end(self.event_session) try: self._commit_event_session_or_retry() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error saving the event session during shutdown") self.event_session.close() diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 186b873047b..ce463067824 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -35,7 +35,12 @@ from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship from sqlalchemy.types import TypeDecorator +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + MATCH_ALL, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, @@ -584,10 +589,27 @@ class StateAttributes(Base): if (state := event.data["new_state"]) is None: return b"{}" if state_info := state.state_info: + unrecorded_attributes = state_info["unrecorded_attributes"] exclude_attrs = { *ALL_DOMAIN_EXCLUDE_ATTRS, - *state_info["unrecorded_attributes"], + *unrecorded_attributes, } + if MATCH_ALL in unrecorded_attributes: + # Don't exclude device class, state class, unit of measurement + # or friendly name when using the MATCH_ALL exclude constant + _exclude_attributes = { + k: v + for k, v in state.attributes.items() + if k + not in ( + ATTR_DEVICE_CLASS, + ATTR_STATE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + ATTR_FRIENDLY_NAME, + ) + } + exclude_attrs.update(_exclude_attributes) + else: exclude_attrs = ALL_DOMAIN_EXCLUDE_ATTRS encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes diff --git a/homeassistant/components/recorder/executor.py b/homeassistant/components/recorder/executor.py index b17547499e8..8102c769ac1 100644 --- a/homeassistant/components/recorder/executor.py +++ b/homeassistant/components/recorder/executor.py @@ -12,9 +12,13 @@ from homeassistant.util.executor import InterruptibleThreadPoolExecutor def _worker_with_shutdown_hook( - shutdown_hook: Callable[[], None], *args: Any, **kwargs: Any + shutdown_hook: Callable[[], None], + recorder_and_worker_thread_ids: set[int], + *args: Any, + **kwargs: Any, ) -> None: """Create a worker that calls a function after its finished.""" + recorder_and_worker_thread_ids.add(threading.get_ident()) _worker(*args, **kwargs) shutdown_hook() @@ -22,9 +26,12 @@ def _worker_with_shutdown_hook( class DBInterruptibleThreadPoolExecutor(InterruptibleThreadPoolExecutor): """A database instance that will not deadlock on shutdown.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__( + self, recorder_and_worker_thread_ids: set[int], *args: Any, **kwargs: Any + ) -> None: """Init the executor with a shutdown hook support.""" self._shutdown_hook: Callable[[], None] = kwargs.pop("shutdown_hook") + self.recorder_and_worker_thread_ids = recorder_and_worker_thread_ids super().__init__(*args, **kwargs) def _adjust_thread_count(self) -> None: @@ -54,6 +61,7 @@ class DBInterruptibleThreadPoolExecutor(InterruptibleThreadPoolExecutor): target=_worker_with_shutdown_hook, args=( self._shutdown_hook, + self.recorder_and_worker_thread_ids, weakref.ref(self, weakref_cb), self._work_queue, self._initializer, diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 92f4c5d3902..509f0d2a067 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -198,7 +198,7 @@ class Filters: # - Otherwise, entity matches domain exclude: exclude # - Otherwise: include if self._excluded_domains or self._excluded_entity_globs: - return (not_(or_(*excludes)) | i_entities).self_group() # type: ignore[no-any-return, no-untyped-call] + return (not_(or_(*excludes)) | i_entities).self_group() # Case 6 - No Domain and/or glob includes or excludes # - Entity listed in entities include: include diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 96347a1f57b..b6acb6601ff 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -782,24 +782,30 @@ def _sorted_states_to_dict( if compressed_state_format: # Compressed state format uses the timestamp directly ent_results.extend( - { - attr_state: (prev_state := state), - attr_time: row[last_updated_ts_idx], - } - for row in group - if (state := row[state_idx]) != prev_state + [ + { + attr_state: (prev_state := state), + attr_time: row[last_updated_ts_idx], + } + for row in group + if (state := row[state_idx]) != prev_state + ] ) continue # Non-compressed state format returns an ISO formatted string _utc_from_timestamp = dt_util.utc_from_timestamp ent_results.extend( - { - attr_state: (prev_state := state), - attr_time: _utc_from_timestamp(row[last_updated_ts_idx]).isoformat(), - } - for row in group - if (state := row[state_idx]) != prev_state + [ + { + attr_state: (prev_state := state), + attr_time: _utc_from_timestamp( + row[last_updated_ts_idx] + ).isoformat(), + } + for row in group + if (state := row[state_idx]) != prev_state + ] ) if descending: diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index e5b20cfd3b0..febd1bb8c7c 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.29", + "SQLAlchemy==2.0.31", "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 8724846def5..561b446f493 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -183,7 +183,7 @@ def get_schema_version(session_maker: Callable[[], Session]) -> int | None: try: with session_scope(session=session_maker(), read_only=True) as session: return _get_schema_version(session) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error when determining DB schema version") return None @@ -1788,7 +1788,7 @@ def initialize_database(session_maker: Callable[[], Session]) -> bool: with session_scope(session=session_maker()) as session: return _initialize_database(session) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error when initialise database") return False diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index ca70b856d76..139522a3d20 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -74,7 +74,7 @@ class LazyState(State): def last_changed(self) -> datetime: # type: ignore[override] """Last changed datetime.""" return dt_util.utc_from_timestamp( - self._last_changed_ts or self._last_updated_ts + self._last_changed_ts or self._last_updated_ts # type: ignore[arg-type] ) @cached_property @@ -86,7 +86,7 @@ class LazyState(State): def last_reported(self) -> datetime: # type: ignore[override] """Last reported datetime.""" return dt_util.utc_from_timestamp( - self._last_reported_ts or self._last_updated_ts + self._last_reported_ts or self._last_updated_ts # type: ignore[arg-type] ) @cached_property diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index ec7aa5bdcb6..dcb19ddf044 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -1,5 +1,8 @@ """A pool for sqlite connections.""" +from __future__ import annotations + +import asyncio import logging import threading import traceback @@ -14,9 +17,7 @@ from sqlalchemy.pool import ( ) from homeassistant.helpers.frame import report -from homeassistant.util.loop import check_loop - -from .const import DB_WORKER_PREFIX +from homeassistant.util.loop import raise_for_blocking_call _LOGGER = logging.getLogger(__name__) @@ -31,7 +32,7 @@ ADVISE_MSG = ( ) -class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] +class RecorderPool(SingletonThreadPool, NullPool): """A hybrid of NullPool and SingletonThreadPool. When called from the creating thread or db executor acts like SingletonThreadPool @@ -39,29 +40,44 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] """ def __init__( # pylint: disable=super-init-not-called - self, *args: Any, **kw: Any + self, + creator: Any, + recorder_and_worker_thread_ids: set[int] | None = None, + **kw: Any, ) -> None: """Create the pool.""" kw["pool_size"] = POOL_SIZE - SingletonThreadPool.__init__(self, *args, **kw) + assert ( + recorder_and_worker_thread_ids is not None + ), "recorder_and_worker_thread_ids is required" + self.recorder_and_worker_thread_ids = recorder_and_worker_thread_ids + SingletonThreadPool.__init__(self, creator, **kw) - @property - def recorder_or_dbworker(self) -> bool: - """Check if the thread is a recorder or dbworker thread.""" - thread_name = threading.current_thread().name - return bool( - thread_name == "Recorder" or thread_name.startswith(DB_WORKER_PREFIX) + def recreate(self) -> RecorderPool: + """Recreate the pool.""" + self.logger.info("Pool recreating") + return self.__class__( + self._creator, + pool_size=self.size, + recycle=self._recycle, + echo=self.echo, + pre_ping=self._pre_ping, + logging_name=self._orig_logging_name, + reset_on_return=self._reset_on_return, + _dispatch=self.dispatch, + dialect=self._dialect, + recorder_and_worker_thread_ids=self.recorder_and_worker_thread_ids, ) def _do_return_conn(self, record: ConnectionPoolEntry) -> None: - if self.recorder_or_dbworker: + if threading.get_ident() in self.recorder_and_worker_thread_ids: return super()._do_return_conn(record) record.close() def shutdown(self) -> None: """Close the connection.""" if ( - self.recorder_or_dbworker + threading.get_ident() in self.recorder_and_worker_thread_ids and self._conn and hasattr(self._conn, "current") and (conn := self._conn.current()) @@ -70,18 +86,25 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] def dispose(self) -> None: """Dispose of the connection.""" - if self.recorder_or_dbworker: + if threading.get_ident() in self.recorder_and_worker_thread_ids: super().dispose() - def _do_get(self) -> ConnectionPoolEntry: - if self.recorder_or_dbworker: + def _do_get(self) -> ConnectionPoolEntry: # type: ignore[return] + if threading.get_ident() in self.recorder_and_worker_thread_ids: return super()._do_get() - check_loop( + try: + asyncio.get_running_loop() + except RuntimeError: + # Not in an event loop but not in the recorder or worker thread + # which is allowed but discouraged since its much slower + return self._do_get_db_connection_protected() + # In the event loop, raise an exception + raise_for_blocking_call( self._do_get_db_connection_protected, strict=True, advise_msg=ADVISE_MSG, ) - return self._do_get_db_connection_protected() + # raise_for_blocking_call will raise an exception def _do_get_db_connection_protected(self) -> ConnectionPoolEntry: report( @@ -93,7 +116,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] exclude_integrations={"recorder"}, error_if_core=False, ) - return NullPool._create_connection(self) + return NullPool._create_connection(self) # noqa: SLF001 class MutexPool(StaticPool): diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index c78f8a4a89d..d28e7e2a547 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -11,6 +11,8 @@ from typing import TYPE_CHECKING from sqlalchemy.orm.session import Session +from homeassistant.util.collection import chunked_or_all + from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( @@ -40,7 +42,7 @@ from .queries import ( find_statistics_runs_to_purge, ) from .repack import repack_database -from .util import chunked_or_all, retryable_database_job, session_scope +from .util import retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -643,7 +645,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: for (metadata_id, entity_id) in session.query( StatesMeta.metadata_id, StatesMeta.entity_id ).all() - if not entity_filter(entity_id) + if entity_filter and not entity_filter(entity_id) ] if excluded_metadata_ids: has_more_states_to_purge = _purge_filtered_states( @@ -763,7 +765,9 @@ def _purge_filtered_events( @retryable_database_job("purge_entity_data") def purge_entity_data( - instance: Recorder, entity_filter: Callable[[str], bool], purge_before: datetime + instance: Recorder, + entity_filter: Callable[[str], bool] | None, + purge_before: datetime, ) -> bool: """Purge states and events of specified entities.""" database_engine = instance.database_engine @@ -775,7 +779,7 @@ def purge_entity_data( for (metadata_id, entity_id) in session.query( StatesMeta.metadata_id, StatesMeta.entity_id ).all() - if entity_filter(entity_id) + if entity_filter and entity_filter(entity_id) ] _LOGGER.debug("Purging entity data for %s", selected_metadata_ids) if not selected_metadata_ids: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 572731a9fed..aeeb30816d7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,6 +28,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + ConductivityConverter, DataRateConverter, DistanceConverter, DurationConverter, @@ -126,6 +127,7 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, @@ -154,7 +156,7 @@ def mean(values: list[float]) -> float | None: This is a very simple version that only works with a non-empty list of floats. The built-in - statistics.mean is more robust but is is almost + statistics.mean is more robust but is almost an order of magnitude slower. """ return sum(values) / len(values) @@ -241,7 +243,8 @@ def _get_statistic_to_display_unit_converter( statistic_unit: str | None, state_unit: str | None, requested_units: dict[str, str] | None, -) -> Callable[[float | None], float | None] | None: + allow_none: bool = True, +) -> Callable[[float | None], float | None] | Callable[[float], float] | None: """Prepare a converter from the statistics unit to display unit.""" if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None: return None @@ -260,9 +263,11 @@ def _get_statistic_to_display_unit_converter( if display_unit == statistic_unit: return None - return converter.converter_factory_allow_none( - from_unit=statistic_unit, to_unit=display_unit - ) + if allow_none: + return converter.converter_factory_allow_none( + from_unit=statistic_unit, to_unit=display_unit + ) + return converter.converter_factory(from_unit=statistic_unit, to_unit=display_unit) def _get_display_to_statistic_unit_converter( @@ -392,7 +397,7 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: """ start_time = start.replace(minute=0) start_time_ts = start_time.timestamp() - end_time = start_time + timedelta(hours=1) + end_time = start_time + Statistics.duration end_time_ts = end_time.timestamp() # Compute last hour's average, min, max @@ -460,7 +465,9 @@ def compile_missing_statistics(instance: Recorder) -> bool: ) as session: # Find the newest statistics run, if any if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): - start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) + start = max( + start, process_timestamp(last_run) + StatisticsShortTerm.duration + ) periods_without_commit = 0 while start < last_period: @@ -529,7 +536,7 @@ def _compile_statistics( returns a set of modified statistic_ids if any were modified. """ assert start.tzinfo == dt_util.UTC, "start must be in UTC" - end = start + timedelta(minutes=5) + end = start + StatisticsShortTerm.duration statistics_meta_manager = instance.statistics_meta_manager modified_statistic_ids: set[str] = set() @@ -948,19 +955,20 @@ def reduce_day_ts_factory() -> ( ] ): """Return functions to match same day and day start end.""" - _boundries: tuple[float, float] = (0, 0) + _lower_bound: float = 0 + _upper_bound: float = 0 # We have to recreate _local_from_timestamp in the closure in case the timezone changes _local_from_timestamp = partial( - datetime.fromtimestamp, tz=dt_util.DEFAULT_TIME_ZONE + datetime.fromtimestamp, tz=dt_util.get_default_time_zone() ) def _same_day_ts(time1: float, time2: float) -> bool: """Return True if time1 and time2 are in the same date.""" - nonlocal _boundries - if not _boundries[0] <= time1 < _boundries[1]: - _boundries = _day_start_end_ts_cached(time1) - return _boundries[0] <= time2 < _boundries[1] + nonlocal _lower_bound, _upper_bound + if not _lower_bound <= time1 < _upper_bound: + _lower_bound, _upper_bound = _day_start_end_ts_cached(time1) + return _lower_bound <= time2 < _upper_bound def _day_start_end_ts(time: float) -> tuple[float, float]: """Return the start and end of the period (day) time is within.""" @@ -968,8 +976,8 @@ def reduce_day_ts_factory() -> ( hour=0, minute=0, second=0, microsecond=0 ) return ( - start_local.astimezone(dt_util.UTC).timestamp(), - (start_local + timedelta(days=1)).astimezone(dt_util.UTC).timestamp(), + start_local.timestamp(), + (start_local + timedelta(days=1)).timestamp(), ) # We create _day_start_end_ts_cached in the closure in case the timezone changes @@ -996,30 +1004,30 @@ def reduce_week_ts_factory() -> ( ] ): """Return functions to match same week and week start end.""" - _boundries: tuple[float, float] = (0, 0) + _lower_bound: float = 0 + _upper_bound: float = 0 # We have to recreate _local_from_timestamp in the closure in case the timezone changes _local_from_timestamp = partial( - datetime.fromtimestamp, tz=dt_util.DEFAULT_TIME_ZONE + datetime.fromtimestamp, tz=dt_util.get_default_time_zone() ) def _same_week_ts(time1: float, time2: float) -> bool: """Return True if time1 and time2 are in the same year and week.""" - nonlocal _boundries - if not _boundries[0] <= time1 < _boundries[1]: - _boundries = _week_start_end_ts_cached(time1) - return _boundries[0] <= time2 < _boundries[1] + nonlocal _lower_bound, _upper_bound + if not _lower_bound <= time1 < _upper_bound: + _lower_bound, _upper_bound = _week_start_end_ts_cached(time1) + return _lower_bound <= time2 < _upper_bound def _week_start_end_ts(time: float) -> tuple[float, float]: """Return the start and end of the period (week) time is within.""" - nonlocal _boundries time_local = _local_from_timestamp(time) start_local = time_local.replace( hour=0, minute=0, second=0, microsecond=0 ) - timedelta(days=time_local.weekday()) return ( - start_local.astimezone(dt_util.UTC).timestamp(), - (start_local + timedelta(days=7)).astimezone(dt_util.UTC).timestamp(), + start_local.timestamp(), + (start_local + timedelta(days=7)).timestamp(), ) # We create _week_start_end_ts_cached in the closure in case the timezone changes @@ -1054,19 +1062,20 @@ def reduce_month_ts_factory() -> ( ] ): """Return functions to match same month and month start end.""" - _boundries: tuple[float, float] = (0, 0) + _lower_bound: float = 0 + _upper_bound: float = 0 # We have to recreate _local_from_timestamp in the closure in case the timezone changes _local_from_timestamp = partial( - datetime.fromtimestamp, tz=dt_util.DEFAULT_TIME_ZONE + datetime.fromtimestamp, tz=dt_util.get_default_time_zone() ) def _same_month_ts(time1: float, time2: float) -> bool: """Return True if time1 and time2 are in the same year and month.""" - nonlocal _boundries - if not _boundries[0] <= time1 < _boundries[1]: - _boundries = _month_start_end_ts_cached(time1) - return _boundries[0] <= time2 < _boundries[1] + nonlocal _lower_bound, _upper_bound + if not _lower_bound <= time1 < _upper_bound: + _lower_bound, _upper_bound = _month_start_end_ts_cached(time1) + return _lower_bound <= time2 < _upper_bound def _month_start_end_ts(time: float) -> tuple[float, float]: """Return the start and end of the period (month) time is within.""" @@ -1074,10 +1083,7 @@ def reduce_month_ts_factory() -> ( day=1, hour=0, minute=0, second=0, microsecond=0 ) end_local = _find_month_end_time(start_local) - return ( - start_local.astimezone(dt_util.UTC).timestamp(), - end_local.astimezone(dt_util.UTC).timestamp(), - ) + return (start_local.timestamp(), end_local.timestamp()) # We create _month_start_end_ts_cached in the closure in case the timezone changes _month_start_end_ts_cached = lru_cache(maxsize=6)(_month_start_end_ts) @@ -1245,7 +1251,7 @@ def _first_statistic( table: type[StatisticsBase], metadata_id: int, ) -> datetime | None: - """Return the data of the oldest statistic row for a given metadata id.""" + """Return the date of the oldest statistic row for a given metadata id.""" stmt = lambda_stmt( lambda: select(table.start_ts) .filter(table.metadata_id == metadata_id) @@ -1257,12 +1263,30 @@ def _first_statistic( return None +def _last_statistic( + session: Session, + table: type[StatisticsBase], + metadata_id: int, +) -> datetime | None: + """Return the date of the newest statistic row for a given metadata id.""" + stmt = lambda_stmt( + lambda: select(table.start_ts) + .filter(table.metadata_id == metadata_id) + .order_by(table.start_ts.desc()) + .limit(1) + ) + if stats := cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)): + return dt_util.utc_from_timestamp(stats[0].start_ts) + return None + + def _get_oldest_sum_statistic( session: Session, head_start_time: datetime | None, main_start_time: datetime | None, tail_start_time: datetime | None, oldest_stat: datetime | None, + oldest_5_min_stat: datetime | None, tail_only: bool, metadata_id: int, ) -> float | None: @@ -1307,6 +1331,15 @@ def _get_oldest_sum_statistic( if ( head_start_time is not None + and oldest_5_min_stat is not None + and ( + # If we want stats older than the short term purge window, don't lookup + # the oldest sum in the short term table, as it would be prioritized + # over older LongTermStats. + (oldest_stat is None) + or (oldest_5_min_stat < oldest_stat) + or (oldest_5_min_stat <= head_start_time) + ) and ( oldest_sum := _get_oldest_sum_statistic_in_sub_period( session, head_start_time, StatisticsShortTerm, metadata_id @@ -1448,7 +1481,7 @@ def statistic_during_period( tail_only = ( start_time is not None and end_time is not None - and end_time - start_time < timedelta(hours=1) + and end_time - start_time < Statistics.duration ) # Calculate the head period @@ -1458,32 +1491,37 @@ def statistic_during_period( not tail_only and oldest_stat is not None and oldest_5_min_stat is not None - and oldest_5_min_stat - oldest_stat < timedelta(hours=1) + and oldest_5_min_stat - oldest_stat < Statistics.duration and (start_time is None or start_time < oldest_5_min_stat) ): # To improve accuracy of averaged for statistics which were added within # recorder's retention period. head_start_time = oldest_5_min_stat - head_end_time = oldest_5_min_stat.replace( - minute=0, second=0, microsecond=0 - ) + timedelta(hours=1) + head_end_time = ( + oldest_5_min_stat.replace(minute=0, second=0, microsecond=0) + + Statistics.duration + ) elif not tail_only and start_time is not None and start_time.minute: head_start_time = start_time - head_end_time = start_time.replace( - minute=0, second=0, microsecond=0 - ) + timedelta(hours=1) + head_end_time = ( + start_time.replace(minute=0, second=0, microsecond=0) + + Statistics.duration + ) # Calculate the tail period tail_start_time: datetime | None = None tail_end_time: datetime | None = None if end_time is None: - tail_start_time = now.replace(minute=0, second=0, microsecond=0) + tail_start_time = _last_statistic(session, Statistics, metadata_id) + if tail_start_time: + tail_start_time += Statistics.duration + else: + tail_start_time = now.replace(minute=0, second=0, microsecond=0) + elif tail_only: + tail_start_time = start_time + tail_end_time = end_time elif end_time.minute: - tail_start_time = ( - start_time - if tail_only - else end_time.replace(minute=0, second=0, microsecond=0) - ) + tail_start_time = end_time.replace(minute=0, second=0, microsecond=0) tail_end_time = end_time # Calculate the main period @@ -1518,6 +1556,7 @@ def statistic_during_period( main_start_time, tail_start_time, oldest_stat, + oldest_5_min_stat, tail_only, metadata_id, ) @@ -1730,13 +1769,11 @@ def _statistics_during_period_with_session( result = _sorted_statistics_to_dict( hass, - session, stats, statistic_ids, metadata, True, table, - start_time, units, types, ) @@ -1848,14 +1885,12 @@ def _get_last_statistics( # Return statistics combined with metadata return _sorted_statistics_to_dict( hass, - session, stats, statistic_ids, metadata, convert_units, table, None, - None, types, ) @@ -1963,14 +1998,12 @@ def get_latest_short_term_statistics_with_session( # Return statistics combined with metadata return _sorted_statistics_to_dict( hass, - session, stats, statistic_ids, metadata, False, StatisticsShortTerm, None, - None, types, ) @@ -2017,42 +2050,119 @@ def _statistics_at_time( return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) -def _fast_build_sum_list( - stats_list: list[Row], +def _build_sum_converted_stats( + db_rows: list[Row], + table_duration_seconds: float, + start_ts_idx: int, + sum_idx: int, + convert: Callable[[float | None], float | None] | Callable[[float], float], +) -> list[StatisticsRow]: + """Build a list of sum statistics.""" + return [ + { + "start": (start_ts := db_row[start_ts_idx]), + "end": start_ts + table_duration_seconds, + "sum": None if (v := db_row[sum_idx]) is None else convert(v), + } + for db_row in db_rows + ] + + +def _build_sum_stats( + db_rows: list[Row], table_duration_seconds: float, - convert: Callable | None, start_ts_idx: int, sum_idx: int, ) -> list[StatisticsRow]: """Build a list of sum statistics.""" - if convert: - return [ - { - "start": (start_ts := db_state[start_ts_idx]), - "end": start_ts + table_duration_seconds, - "sum": convert(db_state[sum_idx]), - } - for db_state in stats_list - ] return [ { - "start": (start_ts := db_state[start_ts_idx]), + "start": (start_ts := db_row[start_ts_idx]), "end": start_ts + table_duration_seconds, - "sum": db_state[sum_idx], + "sum": db_row[sum_idx], } - for db_state in stats_list + for db_row in db_rows ] +def _build_stats( + db_rows: list[Row], + table_duration_seconds: float, + start_ts_idx: int, + mean_idx: int | None, + min_idx: int | None, + max_idx: int | None, + last_reset_ts_idx: int | None, + state_idx: int | None, + sum_idx: int | None, +) -> list[StatisticsRow]: + """Build a list of statistics without unit conversion.""" + result: list[StatisticsRow] = [] + ent_results_append = result.append + for db_row in db_rows: + row: StatisticsRow = { + "start": (start_ts := db_row[start_ts_idx]), + "end": start_ts + table_duration_seconds, + } + if last_reset_ts_idx is not None: + row["last_reset"] = db_row[last_reset_ts_idx] + if mean_idx is not None: + row["mean"] = db_row[mean_idx] + if min_idx is not None: + row["min"] = db_row[min_idx] + if max_idx is not None: + row["max"] = db_row[max_idx] + if state_idx is not None: + row["state"] = db_row[state_idx] + if sum_idx is not None: + row["sum"] = db_row[sum_idx] + ent_results_append(row) + return result + + +def _build_converted_stats( + db_rows: list[Row], + table_duration_seconds: float, + start_ts_idx: int, + mean_idx: int | None, + min_idx: int | None, + max_idx: int | None, + last_reset_ts_idx: int | None, + state_idx: int | None, + sum_idx: int | None, + convert: Callable[[float | None], float | None] | Callable[[float], float], +) -> list[StatisticsRow]: + """Build a list of statistics with unit conversion.""" + result: list[StatisticsRow] = [] + ent_results_append = result.append + for db_row in db_rows: + row: StatisticsRow = { + "start": (start_ts := db_row[start_ts_idx]), + "end": start_ts + table_duration_seconds, + } + if last_reset_ts_idx is not None: + row["last_reset"] = db_row[last_reset_ts_idx] + if mean_idx is not None: + row["mean"] = None if (v := db_row[mean_idx]) is None else convert(v) + if min_idx is not None: + row["min"] = None if (v := db_row[min_idx]) is None else convert(v) + if max_idx is not None: + row["max"] = None if (v := db_row[max_idx]) is None else convert(v) + if state_idx is not None: + row["state"] = None if (v := db_row[state_idx]) is None else convert(v) + if sum_idx is not None: + row["sum"] = None if (v := db_row[sum_idx]) is None else convert(v) + ent_results_append(row) + return result + + def _sorted_statistics_to_dict( hass: HomeAssistant, - session: Session, stats: Sequence[Row[Any]], statistic_ids: set[str] | None, _metadata: dict[str, tuple[int, StatisticMetaData]], convert_units: bool, table: type[StatisticsBase], - start_time: datetime | None, units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> dict[str, list[StatisticsRow]]: @@ -2090,19 +2200,23 @@ def _sorted_statistics_to_dict( state_idx = field_map["state"] if "state" in types else None sum_idx = field_map["sum"] if "sum" in types else None sum_only = len(types) == 1 and sum_idx is not None + row_idxes = (mean_idx, min_idx, max_idx, last_reset_ts_idx, state_idx, sum_idx) # Append all statistic entries, and optionally do unit conversion table_duration_seconds = table.duration.total_seconds() - for meta_id, stats_list in stats_by_meta_id.items(): + for meta_id, db_rows in stats_by_meta_id.items(): metadata_by_id = metadata[meta_id] statistic_id = metadata_by_id["statistic_id"] if convert_units: state_unit = unit = metadata_by_id["unit_of_measurement"] if state := hass.states.get(statistic_id): state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) + convert = _get_statistic_to_display_unit_converter( + unit, state_unit, units, allow_none=False + ) else: convert = None + build_args = (db_rows, table_duration_seconds, start_ts_idx) if sum_only: # This function is extremely flexible and can handle all types of # statistics, but in practice we only ever use a few combinations. @@ -2110,53 +2224,16 @@ def _sorted_statistics_to_dict( # For energy, we only need sum statistics, so we can optimize # this path to avoid the overhead of the more generic function. assert sum_idx is not None - result[statistic_id] = _fast_build_sum_list( - stats_list, - table_duration_seconds, - convert, - start_ts_idx, - sum_idx, - ) - continue - - ent_results_append = result[statistic_id].append - # - # The below loop is a red hot path for energy, and every - # optimization counts in here. - # - # Specifically, we want to avoid function calls, - # attribute lookups, and dict lookups as much as possible. - # - for db_state in stats_list: - row: StatisticsRow = { - "start": (start_ts := db_state[start_ts_idx]), - "end": start_ts + table_duration_seconds, - } - if last_reset_ts_idx is not None: - row["last_reset"] = db_state[last_reset_ts_idx] if convert: - if mean_idx is not None: - row["mean"] = convert(db_state[mean_idx]) - if min_idx is not None: - row["min"] = convert(db_state[min_idx]) - if max_idx is not None: - row["max"] = convert(db_state[max_idx]) - if state_idx is not None: - row["state"] = convert(db_state[state_idx]) - if sum_idx is not None: - row["sum"] = convert(db_state[sum_idx]) + _stats = _build_sum_converted_stats(*build_args, sum_idx, convert) else: - if mean_idx is not None: - row["mean"] = db_state[mean_idx] - if min_idx is not None: - row["min"] = db_state[min_idx] - if max_idx is not None: - row["max"] = db_state[max_idx] - if state_idx is not None: - row["state"] = db_state[state_idx] - if sum_idx is not None: - row["sum"] = db_state[sum_idx] - ent_results_append(row) + _stats = _build_sum_stats(*build_args, sum_idx) + elif convert: + _stats = _build_converted_stats(*build_args, *row_idxes, convert) + else: + _stats = _build_stats(*build_args, *row_idxes) + + result[statistic_id] = _stats return result @@ -2198,9 +2275,14 @@ def _async_import_statistics( for statistic in statistics: start = statistic["start"] if start.tzinfo is None or start.tzinfo.utcoffset(start) is None: - raise HomeAssistantError("Naive timestamp") + raise HomeAssistantError( + "Naive timestamp: no or invalid timezone info provided" + ) if start.minute != 0 or start.second != 0 or start.microsecond != 0: - raise HomeAssistantError("Invalid timestamp") + raise HomeAssistantError( + "Invalid timestamp: timestamps must be from the top of the hour (minutes and seconds = 0)" + ) + statistic["start"] = dt_util.as_utc(start) if "last_reset" in statistic and statistic["last_reset"] is not None: diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index c064987ddcb..bc053562c14 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1,6 +1,8 @@ """Managers for each table.""" -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from lru import LRU @@ -9,15 +11,13 @@ from homeassistant.util.event_type import EventType if TYPE_CHECKING: from ..core import Recorder -_DataT = TypeVar("_DataT") - -class BaseTableManager(Generic[_DataT]): +class BaseTableManager[_DataT]: """Base class for table managers.""" - _id_map: "LRU[EventType[Any] | str, int]" + _id_map: LRU[EventType[Any] | str, int] - def __init__(self, recorder: "Recorder") -> None: + def __init__(self, recorder: Recorder) -> None: """Initialize the table manager. The table manager is responsible for managing the id mappings @@ -54,10 +54,10 @@ class BaseTableManager(Generic[_DataT]): self._pending.clear() -class BaseLRUTableManager(BaseTableManager[_DataT]): +class BaseLRUTableManager[_DataT](BaseTableManager[_DataT]): """Base class for LRU table managers.""" - def __init__(self, recorder: "Recorder", lru_size: int) -> None: + def __init__(self, recorder: Recorder, lru_size: int) -> None: """Initialize the LRU table manager. We keep track of the most recently used items diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index e8bb3f2300f..1d2fa580b3c 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -2,18 +2,19 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable import logging from typing import TYPE_CHECKING, cast from sqlalchemy.orm.session import Session from homeassistant.core import Event +from homeassistant.util.collection import chunked_or_all from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from ..db_schema import EventData from ..queries import get_shared_event_datas -from ..util import chunked, execute_stmt_lambda_element +from ..util import execute_stmt_lambda_element from . import BaseLRUTableManager if TYPE_CHECKING: @@ -86,7 +87,7 @@ class EventDataManager(BaseLRUTableManager[EventData]): return results | self._load_from_hashes(missing_hashes, session) def _load_from_hashes( - self, hashes: Iterable[int], session: Session + self, hashes: Collection[int], session: Session ) -> dict[str, int | None]: """Load the shared_datas to data_ids mapping into memory from a list of hashes. @@ -95,7 +96,7 @@ class EventDataManager(BaseLRUTableManager[EventData]): """ results: dict[str, int | None] = {} with session.no_autoflush: - for hashs_chunk in chunked(hashes, self.recorder.max_bind_vars): + for hashs_chunk in chunked_or_all(hashes, self.recorder.max_bind_vars): for data_id, shared_data in execute_stmt_lambda_element( session, get_shared_event_datas(hashs_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 73401e8df56..266c970fe1f 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,12 +9,13 @@ from lru import LRU from sqlalchemy.orm.session import Session from homeassistant.core import Event +from homeassistant.util.collection import chunked_or_all from homeassistant.util.event_type import EventType from ..db_schema import EventTypes from ..queries import find_event_type_ids from ..tasks import RefreshEventTypesTask -from ..util import chunked, execute_stmt_lambda_element +from ..util import execute_stmt_lambda_element from . import BaseLRUTableManager if TYPE_CHECKING: @@ -87,7 +88,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): return results with session.no_autoflush: - for missing_chunk in chunked(missing, self.recorder.max_bind_vars): + for missing_chunk in chunked_or_all(missing, self.recorder.max_bind_vars): for event_type_id, event_type in execute_stmt_lambda_element( session, find_event_type_ids(missing_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index ec975d310e9..5ed67b0504f 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -2,18 +2,19 @@ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable import logging from typing import TYPE_CHECKING, cast from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventStateChangedData +from homeassistant.util.collection import chunked_or_all from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from ..db_schema import StateAttributes from ..queries import get_shared_attributes -from ..util import chunked, execute_stmt_lambda_element +from ..util import execute_stmt_lambda_element from . import BaseLRUTableManager if TYPE_CHECKING: @@ -97,7 +98,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): return results | self._load_from_hashes(missing_hashes, session) def _load_from_hashes( - self, hashes: Iterable[int], session: Session + self, hashes: Collection[int], session: Session ) -> dict[str, int | None]: """Load the shared_attrs to attributes_ids mapping into memory from a list of hashes. @@ -106,7 +107,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): """ results: dict[str, int | None] = {} with session.no_autoflush: - for hashs_chunk in chunked(hashes, self.recorder.max_bind_vars): + for hashs_chunk in chunked_or_all(hashes, self.recorder.max_bind_vars): for attributes_id, shared_attrs in execute_stmt_lambda_element( session, get_shared_attributes(hashs_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 2c73dcf3a54..0ea2c7415b9 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -8,10 +8,11 @@ from typing import TYPE_CHECKING, cast from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventStateChangedData +from homeassistant.util.collection import chunked_or_all from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids -from ..util import chunked, execute_stmt_lambda_element +from ..util import execute_stmt_lambda_element from . import BaseLRUTableManager if TYPE_CHECKING: @@ -106,7 +107,7 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): update_cache = from_recorder or not self._did_first_load with session.no_autoflush: - for missing_chunk in chunked(missing, self.recorder.max_bind_vars): + for missing_chunk in chunked_or_all(missing, self.recorder.max_bind_vars): for metadata_id, entity_id in execute_stmt_lambda_element( session, find_states_metadata_ids(missing_chunk) ): diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 2d980c849e5..b4fe148a229 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -242,7 +242,7 @@ class WaitTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task.""" - instance._queue_watch.set() # pylint: disable=[protected-access] + instance._queue_watch.set() # noqa: SLF001 @dataclass(slots=True) @@ -255,7 +255,7 @@ class DatabaseLockTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task.""" - instance._lock_database(self) # pylint: disable=[protected-access] + instance._lock_database(self) # noqa: SLF001 @dataclass(slots=True) @@ -277,8 +277,7 @@ class KeepAliveTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task.""" - # pylint: disable-next=[protected-access] - instance._send_keep_alive() + instance._send_keep_alive() # noqa: SLF001 @dataclass(slots=True) @@ -289,8 +288,7 @@ class CommitTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task.""" - # pylint: disable-next=[protected-access] - instance._commit_event_session_or_retry() + instance._commit_event_session_or_retry() # noqa: SLF001 @dataclass(slots=True) @@ -333,7 +331,7 @@ class PostSchemaMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task.""" - instance._post_schema_migration( # pylint: disable=[protected-access] + instance._post_schema_migration( # noqa: SLF001 self.old_version, self.new_version ) @@ -357,7 +355,7 @@ class AdjustLRUSizeTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task to adjust the size.""" - instance._adjust_lru_size() # pylint: disable=[protected-access] + instance._adjust_lru_size() # noqa: SLF001 @dataclass(slots=True) @@ -369,7 +367,7 @@ class StatesContextIDMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Run context id migration task.""" if ( - not instance._migrate_states_context_ids() # pylint: disable=[protected-access] + not instance._migrate_states_context_ids() # noqa: SLF001 ): # Schedule a new migration task if this one didn't finish instance.queue_task(StatesContextIDMigrationTask()) @@ -384,7 +382,7 @@ class EventsContextIDMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Run context id migration task.""" if ( - not instance._migrate_events_context_ids() # pylint: disable=[protected-access] + not instance._migrate_events_context_ids() # noqa: SLF001 ): # Schedule a new migration task if this one didn't finish instance.queue_task(EventsContextIDMigrationTask()) @@ -401,7 +399,7 @@ class EventTypeIDMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Run event type id migration task.""" - if not instance._migrate_event_type_ids(): # pylint: disable=[protected-access] + if not instance._migrate_event_type_ids(): # noqa: SLF001 # Schedule a new migration task if this one didn't finish instance.queue_task(EventTypeIDMigrationTask()) @@ -417,7 +415,7 @@ class EntityIDMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Run entity_id migration task.""" - if not instance._migrate_entity_ids(): # pylint: disable=[protected-access] + if not instance._migrate_entity_ids(): # noqa: SLF001 # Schedule a new migration task if this one didn't finish instance.queue_task(EntityIDMigrationTask()) else: @@ -436,7 +434,7 @@ class EntityIDPostMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Run entity_id post migration task.""" if ( - not instance._post_migrate_entity_ids() # pylint: disable=[protected-access] + not instance._post_migrate_entity_ids() # noqa: SLF001 ): # Schedule a new migration task if this one didn't finish instance.queue_task(EntityIDPostMigrationTask()) @@ -453,7 +451,7 @@ class EventIdMigrationTask(RecorderTask): def run(self, instance: Recorder) -> None: """Clean up the legacy event_id index on states.""" - instance._cleanup_legacy_states_event_ids() # pylint: disable=[protected-access] + instance._cleanup_legacy_states_event_ids() # noqa: SLF001 @dataclass(slots=True) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index ad96833b1d7..b4ee90a8323 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -2,17 +2,15 @@ from __future__ import annotations -from collections.abc import Callable, Collection, Generator, Iterable, Sequence +from collections.abc import Callable, Sequence import contextlib from contextlib import contextmanager from datetime import date, datetime, timedelta import functools -from functools import partial -from itertools import islice import logging import os import time -from typing import TYPE_CHECKING, Any, Concatenate, NoReturn, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, NoReturn from awesomeversion import ( AwesomeVersion, @@ -27,6 +25,7 @@ from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.lambdas import StatementLambdaElement +from typing_extensions import Generator import voluptuous as vol from homeassistant.core import HomeAssistant, callback @@ -61,9 +60,6 @@ if TYPE_CHECKING: from . import Recorder -_RecorderT = TypeVar("_RecorderT", bound="Recorder") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) RETRIES = 3 @@ -123,7 +119,7 @@ def session_scope( session: Session | None = None, exception_filter: Callable[[Exception], bool] | None = None, read_only: bool = False, -) -> Generator[Session, None, None]: +) -> Generator[Session]: """Provide a transactional scope around a series of operations. read_only is used to indicate that the session is only used for reading @@ -139,10 +135,10 @@ def session_scope( need_rollback = False try: yield session - if session.get_transaction() and not read_only: + if not read_only and session.get_transaction(): need_rollback = True session.commit() - except Exception as err: # pylint: disable=broad-except + except Exception as err: _LOGGER.exception("Error executing query") if need_rollback: session.rollback() @@ -628,18 +624,20 @@ def _is_retryable_error(instance: Recorder, err: OperationalError) -> bool: ) -_FuncType = Callable[Concatenate[_RecorderT, _P], bool] +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] -def retryable_database_job( +def retryable_database_job[_RecorderT: Recorder, **_P]( description: str, -) -> Callable[[_FuncType[_RecorderT, _P]], _FuncType[_RecorderT, _P]]: +) -> Callable[[_FuncType[_RecorderT, _P, bool]], _FuncType[_RecorderT, _P, bool]]: """Try to execute a database job. The job should return True if it finished, and False if it needs to be rescheduled. """ - def decorator(job: _FuncType[_RecorderT, _P]) -> _FuncType[_RecorderT, _P]: + def decorator( + job: _FuncType[_RecorderT, _P, bool], + ) -> _FuncType[_RecorderT, _P, bool]: @functools.wraps(job) def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> bool: try: @@ -664,12 +662,9 @@ def retryable_database_job( return decorator -_WrappedFuncType = Callable[Concatenate[_RecorderT, _P], None] - - -def database_job_retry_wrapper( +def database_job_retry_wrapper[_RecorderT: Recorder, **_P]( description: str, attempts: int = 5 -) -> Callable[[_WrappedFuncType[_RecorderT, _P]], _WrappedFuncType[_RecorderT, _P]]: +) -> Callable[[_FuncType[_RecorderT, _P, None]], _FuncType[_RecorderT, _P, None]]: """Try to execute a database job multiple times. This wrapper handles InnoDB deadlocks and lock timeouts. @@ -679,8 +674,8 @@ def database_job_retry_wrapper( """ def decorator( - job: _WrappedFuncType[_RecorderT, _P], - ) -> _WrappedFuncType[_RecorderT, _P]: + job: _FuncType[_RecorderT, _P, None], + ) -> _FuncType[_RecorderT, _P, None]: @functools.wraps(job) def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: for attempt in range(attempts): @@ -720,7 +715,7 @@ def periodic_db_cleanups(instance: Recorder) -> None: @contextmanager -def write_lock_db_sqlite(instance: Recorder) -> Generator[None, None, None]: +def write_lock_db_sqlite(instance: Recorder) -> Generator[None]: """Lock database for writes.""" assert instance.engine is not None with instance.engine.connect() as connection: @@ -745,8 +740,7 @@ def async_migration_in_progress(hass: HomeAssistant) -> bool: """ if DATA_INSTANCE not in hass.data: return False - instance = get_instance(hass) - return instance.migration_in_progress + return hass.data[DATA_INSTANCE].migration_in_progress def async_migration_is_live(hass: HomeAssistant) -> bool: @@ -757,8 +751,7 @@ def async_migration_is_live(hass: HomeAssistant) -> bool: """ if DATA_INSTANCE not in hass.data: return False - instance: Recorder = hass.data[DATA_INSTANCE] - return instance.migration_is_live + return hass.data[DATA_INSTANCE].migration_is_live def second_sunday(year: int, month: int) -> date: @@ -777,10 +770,10 @@ def is_second_sunday(date_time: datetime) -> bool: return bool(second_sunday(date_time.year, date_time.month).day == date_time.day) +@functools.lru_cache(maxsize=1) def get_instance(hass: HomeAssistant) -> Recorder: """Get the recorder instance.""" - instance: Recorder = hass.data[DATA_INSTANCE] - return instance + return hass.data[DATA_INSTANCE] PERIOD_SCHEMA = vol.Schema( @@ -863,36 +856,6 @@ def resolve_period( return (start_time, end_time) -def take(take_num: int, iterable: Iterable) -> list[Any]: - """Return first n items of the iterable as a list. - - From itertools recipes - """ - return list(islice(iterable, take_num)) - - -def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: - """Break *iterable* into lists of length *n*. - - From more-itertools - """ - return iter(partial(take, chunked_num, iter(iterable)), []) - - -def chunked_or_all(iterable: Collection[Any], chunked_num: int) -> Iterable[Any]: - """Break *collection* into iterables of length *n*. - - Returns the collection if its length is less than *n*. - - Unlike chunked, this function requires a collection so it can - determine the length of the collection and return the collection - if it is less than *n*. - """ - if len(iterable) <= chunked_num: - return (iterable,) - return chunked(iterable, chunked_num) - - def get_index_by_name(session: Session, table_name: str, index_name: str) -> str | None: """Get an index by name.""" connection = session.connection() diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 58c362df62e..195d3d3efb0 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -48,6 +48,7 @@ from .util import PERIOD_SCHEMA, get_instance, resolve_period UNIT_SCHEMA = vol.Schema( { + vol.Optional("conductivity"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS), @@ -160,14 +161,13 @@ def _ws_get_statistics_during_period( units, types, ) - for statistic_id in result: - for item in result[statistic_id]: - if (start := item.get("start")) is not None: - item["start"] = int(start * 1000) - if (end := item.get("end")) is not None: - item["end"] = int(end * 1000) - if (last_reset := item.get("last_reset")) is not None: - item["last_reset"] = int(last_reset * 1000) + include_last_reset = "last_reset" in types + for statistic_rows in result.values(): + for row in statistic_rows: + row["start"] = int(row["start"] * 1000) + row["end"] = int(row["end"] * 1000) + if include_last_reset and (last_reset := row["last_reset"]) is not None: + row["last_reset"] = int(last_reset * 1000) return json_bytes(messages.result_message(msg_id, result)) diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py index 666a17847c9..0f0c852b043 100644 --- a/homeassistant/components/refoss/__init__.py +++ b/homeassistant/components/refoss/__init__.py @@ -15,6 +15,7 @@ from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL from .util import refoss_discovery_server PLATFORMS: Final = [ + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py index 86e40fce43c..0542afe8afb 100644 --- a/homeassistant/components/refoss/const.py +++ b/homeassistant/components/refoss/const.py @@ -19,3 +19,14 @@ DOMAIN = "refoss" COORDINATOR = "coordinator" MAX_ERRORS = 2 + +CHANNEL_DISPLAY_NAME: dict[str, dict[int, str]] = { + "em06": { + 1: "A1", + 2: "B1", + 3: "C1", + 4: "A2", + 5: "B2", + 6: "C2", + } +} diff --git a/homeassistant/components/refoss/entity.py b/homeassistant/components/refoss/entity.py index 3032c32ed51..502101608ec 100644 --- a/homeassistant/components/refoss/entity.py +++ b/homeassistant/components/refoss/entity.py @@ -18,11 +18,6 @@ class RefossEntity(CoordinatorEntity[RefossDataUpdateCoordinator]): 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)}, diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json index 8e5b3864bcc..8b9b2d8cf11 100644 --- a/homeassistant/components/refoss/manifest.json +++ b/homeassistant/components/refoss/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/refoss", "iot_class": "local_polling", - "requirements": ["refoss-ha==1.2.0"] + "requirements": ["refoss-ha==1.2.1"] } diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py new file mode 100644 index 00000000000..9f5ee5d898a --- /dev/null +++ b/homeassistant/components/refoss/sensor.py @@ -0,0 +1,173 @@ +"""Support for refoss sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from refoss_ha.controller.electricity import ElectricityXMix + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .bridge import RefossDataUpdateCoordinator +from .const import ( + CHANNEL_DISPLAY_NAME, + COORDINATORS, + DISPATCH_DEVICE_DISCOVERED, + DOMAIN, +) +from .entity import RefossEntity + + +@dataclass(frozen=True, kw_only=True) +class RefossSensorEntityDescription(SensorEntityDescription): + """Describes Refoss sensor entity.""" + + subkey: str + fn: Callable[[float], float] = lambda x: x + + +SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { + "em06": ( + RefossSensorEntityDescription( + key="power", + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + subkey="power", + fn=lambda x: x / 1000.0, + ), + RefossSensorEntityDescription( + key="voltage", + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", + ), + RefossSensorEntityDescription( + key="current", + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + subkey="current", + ), + RefossSensorEntityDescription( + key="factor", + translation_key="power_factor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + subkey="factor", + ), + RefossSensorEntityDescription( + key="energy", + translation_key="this_month_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + subkey="mConsume", + fn=lambda x: x if x > 0 else 0, + ), + RefossSensorEntityDescription( + key="energy_returned", + translation_key="this_month_energy_returned", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_display_precision=2, + subkey="mConsume", + fn=lambda x: abs(x) if x < 0 else 0, + ), + ), +} + + +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: RefossDataUpdateCoordinator) -> None: + """Register the device.""" + device = coordinator.device + + if not isinstance(device, ElectricityXMix): + return + descriptions: tuple[RefossSensorEntityDescription, ...] = SENSORS.get( + device.device_type, () + ) + + async_add_entities( + RefossSensor( + coordinator=coordinator, + channel=channel, + description=description, + ) + for channel in device.channels + for description in descriptions + ) + + 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 RefossSensor(RefossEntity, SensorEntity): + """Refoss Sensor Device.""" + + entity_description: RefossSensorEntityDescription + + def __init__( + self, + coordinator: RefossDataUpdateCoordinator, + channel: int, + description: RefossSensorEntityDescription, + ) -> None: + """Init Refoss sensor.""" + super().__init__(coordinator, channel) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}{description.key}" + device_type = coordinator.device.device_type + channel_name = CHANNEL_DISPLAY_NAME[device_type][channel] + self._attr_translation_placeholders = {"channel_name": channel_name} + + @property + def native_value(self) -> StateType: + """Return the native value.""" + value = self.coordinator.device.get_value( + self.channel_id, self.entity_description.subkey + ) + if value is None: + return None + return self.entity_description.fn(value) diff --git a/homeassistant/components/refoss/strings.json b/homeassistant/components/refoss/strings.json index ad8f0f41ae7..67b4e4a8335 100644 --- a/homeassistant/components/refoss/strings.json +++ b/homeassistant/components/refoss/strings.json @@ -9,5 +9,27 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "sensor": { + "power": { + "name": "{channel_name} power" + }, + "voltage": { + "name": "{channel_name} voltage" + }, + "current": { + "name": "{channel_name} current" + }, + "power_factor": { + "name": "{channel_name} power factor" + }, + "this_month_energy": { + "name": "{channel_name} this month energy" + }, + "this_month_energy_returned": { + "name": "{channel_name} this month energy returned" + } + } } } diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py index c51f166059e..0f5aba0cfc4 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .bridge import RefossDataUpdateCoordinator from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import RefossEntity @@ -48,6 +49,15 @@ async def async_setup_entry( class RefossSwitch(RefossEntity, SwitchEntity): """Refoss Switch Device.""" + def __init__( + self, + coordinator: RefossDataUpdateCoordinator, + channel: int, + ) -> None: + """Init Refoss switch.""" + super().__init__(coordinator, channel) + self._attr_name = str(channel) + @property def is_on(self) -> bool | None: """Return true if switch is on.""" diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 3d1654960a7..425a12d5c4d 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -137,7 +137,7 @@ def _register_new_account( configurator.request_done(hass, request_id) - request_id = configurator.async_request_config( + request_id = configurator.request_config( hass, f"{DOMAIN} - {account_name}", callback=register_account_callback, diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 1751225f987..48bab1f5c8b 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import CONF_LOCALE, DOMAIN, PLATFORMS @@ -15,7 +15,7 @@ from .renault_hub import RenaultHub from .services import setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -RenaultConfigEntry = ConfigEntry[RenaultHub] +type RenaultConfigEntry = ConfigEntry[RenaultHub] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -56,3 +56,12 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: RenaultConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return not device_entry.identifiers.intersection( + (DOMAIN, vin) for vin in config_entry.runtime_data.vehicles + ) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 2041499b711..7ebc77b8e77 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -81,7 +81,7 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( key="hvac_status", coordinator="hvac_status", on_key="hvacStatus", - on_value="on", + on_value=2, translation_key="hvac_status", ), RenaultBinarySensorEntityDescription( diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 9891c838950..8407893011c 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.2.2"] + "requirements": ["renault-api==0.2.3"] } diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 59e1826ce1b..d5c4f78126c 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, cast from renault_api.exceptions import RenaultException from renault_api.kamereon import models @@ -22,13 +22,11 @@ from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator LOGGER = logging.getLogger(__name__) -_T = TypeVar("_T") -_P = ParamSpec("_P") -def with_error_wrapping( - func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_T]], -) -> Callable[Concatenate[RenaultVehicleProxy, _P], Coroutine[Any, Any, _T]]: +def with_error_wrapping[**_P, _R]( + func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_R]], +) -> Callable[Concatenate[RenaultVehicleProxy, _P], Coroutine[Any, Any, _R]]: """Catch Renault errors.""" @wraps(func) @@ -36,7 +34,7 @@ def with_error_wrapping( self: RenaultVehicleProxy, *args: _P.args, **kwargs: _P.kwargs, - ) -> _T: + ) -> _R: """Catch RenaultException errors and raise HomeAssistantError.""" try: return await func(self, *args, **kwargs) diff --git a/homeassistant/components/renson/config_flow.py b/homeassistant/components/renson/config_flow.py index ec380f5a513..311317bb397 100644 --- a/homeassistant/components/renson/config_flow.py +++ b/homeassistant/components/renson/config_flow.py @@ -55,7 +55,7 @@ class RensonConfigFlow(ConfigFlow, domain=DOMAIN): info = await self.validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 3196dbf3ad7..27bd504e9bb 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -6,17 +6,16 @@ import asyncio from dataclasses import dataclass from datetime import timedelta import logging -from typing import Literal from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError -from reolink_aio.software_version import NewSoftwareVersion 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.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -47,9 +46,7 @@ class ReolinkData: host: ReolinkHost device_coordinator: DataUpdateCoordinator[None] - firmware_coordinator: DataUpdateCoordinator[ - str | Literal[False] | NewSoftwareVersion - ] + firmware_coordinator: DataUpdateCoordinator[None] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -67,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) as err: await host.stop() raise ConfigEntryNotReady( - f"Error while trying to setup {host.api.host}:{host.api.port}: {str(err)}" + f"Error while trying to setup {host.api.host}:{host.api.port}: {err!s}" ) from err except Exception: await host.stop() @@ -85,6 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await host.update_states() except CredentialsInvalidError as err: + await host.stop() raise ConfigEntryAuthFailed(err) from err except ReolinkError as err: raise UpdateFailed(str(err)) from err @@ -92,16 +90,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() - async def async_check_firmware_update() -> ( - str | Literal[False] | NewSoftwareVersion - ): + async def async_check_firmware_update() -> None: """Check for firmware updates.""" - if not host.api.supported(None, "update"): - return False - async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: - return await host.api.check_new_firmware() + await host.api.check_new_firmware(host.firmware_ch_list) except ReolinkError as err: if starting: _LOGGER.debug( @@ -109,7 +102,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b "from %s, possibly internet access is blocked", host.api.nvr_name, ) - return False + return raise UpdateFailed( f"Error checking Reolink firmware update from {host.api.nvr_name}, " @@ -149,15 +142,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b firmware_coordinator=firmware_coordinator, ) + # first migrate and then cleanup, otherwise entities lost + migrate_entity_ids(hass, config_entry.entry_id, host) 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( @@ -187,6 +175,24 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +def get_device_uid_and_ch( + device: dr.DeviceEntry, host: ReolinkHost +) -> tuple[list[str], int | None]: + """Get the channel and the split device_uid from a reolink DeviceEntry.""" + device_uid = [ + dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN + ][0] + + if len(device_uid) < 2: + # NVR itself + ch = None + elif device_uid[1].startswith("ch") and len(device_uid[1]) <= 5: + ch = int(device_uid[1][2:]) + else: + ch = host.api.channel_for_uid(device_uid[1]) + return (device_uid, ch) + + def cleanup_disconnected_cams( hass: HomeAssistant, config_entry_id: str, host: ReolinkHost ) -> None: @@ -197,17 +203,10 @@ def cleanup_disconnected_cams( 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] + (device_uid, ch) = get_device_uid_and_ch(device, host) + if ch is None: + continue # Do not consider the NVR itself - 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: @@ -233,3 +232,60 @@ def cleanup_disconnected_cams( # clean device registry and associated entities device_reg.async_remove_device(device.id) + + +def migrate_entity_ids( + hass: HomeAssistant, config_entry_id: str, host: ReolinkHost +) -> None: + """Migrate entity IDs if needed.""" + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + ch_device_ids = {} + for device in devices: + (device_uid, ch) = get_device_uid_and_ch(device, host) + + if host.api.supported(None, "UID") and device_uid[0] != host.unique_id: + if ch is None: + new_device_id = f"{host.unique_id}" + else: + new_device_id = f"{host.unique_id}_{device_uid[1]}" + new_identifiers = {(DOMAIN, new_device_id)} + device_reg.async_update_device(device.id, new_identifiers=new_identifiers) + + if ch is None: + continue # Do not consider the NVR itself + + ch_device_ids[device.id] = ch + if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch): + if host.api.supported(None, "UID"): + new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}" + else: + new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}" + new_identifiers = {(DOMAIN, new_device_id)} + device_reg.async_update_device(device.id, new_identifiers=new_identifiers) + + entity_reg = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_reg, config_entry_id) + for entity in entities: + # Can be removed in HA 2025.1.0 + if entity.domain == "update" and entity.unique_id in [ + host.unique_id, + format_mac(host.api.mac_address), + ]: + entity_reg.async_update_entity( + entity.entity_id, new_unique_id=f"{host.unique_id}_firmware" + ) + continue + + if host.api.supported(None, "UID") and not entity.unique_id.startswith( + host.unique_id + ): + new_id = f"{host.unique_id}_{entity.unique_id.split("_", 1)[1]}" + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) + + if entity.device_id in ch_device_ids: + ch = ch_device_ids[entity.device_id] + id_parts = entity.unique_id.split("_", 2) + if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch): + new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}" + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index fe80177da12..d19987c3bc6 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -21,6 +21,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.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -40,7 +41,7 @@ class ReolinkBinarySensorEntityDescription( value: Callable[[Host, int], bool] -BINARY_SENSORS = ( +BINARY_PUSH_SENSORS = ( ReolinkBinarySensorEntityDescription( key="motion", device_class=BinarySensorDeviceClass.MOTION, @@ -93,6 +94,17 @@ BINARY_SENSORS = ( ), ) +BINARY_SENSORS = ( + ReolinkBinarySensorEntityDescription( + key="sleep", + cmd_key="GetChannelstatus", + translation_key="sleep", + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda api, ch: api.sleeping(ch), + supported=lambda api, ch: api.supported(ch, "sleep"), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -104,6 +116,13 @@ async def async_setup_entry( entities: list[ReolinkBinarySensorEntity] = [] for channel in reolink_data.host.api.channels: + entities.extend( + [ + ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description) + for entity_description in BINARY_PUSH_SENSORS + if entity_description.supported(reolink_data.host.api, channel) + ] + ) entities.extend( [ ReolinkBinarySensorEntity(reolink_data, channel, entity_description) @@ -116,7 +135,7 @@ async def async_setup_entry( class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEntity): - """Base binary-sensor class for Reolink IP camera motion sensors.""" + """Base binary-sensor class for Reolink IP camera.""" entity_description: ReolinkBinarySensorEntityDescription @@ -142,6 +161,10 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt """State of the sensor.""" return self.entity_description.value(self._host.api, self._channel) + +class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity): + """Binary-sensor class for Reolink IP camera motion sensors.""" + async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index a2c396e7ef5..4adac1a96d8 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -116,7 +116,6 @@ async def async_setup_entry( class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM entity_description: ReolinkCameraEntityDescription def __init__( @@ -130,6 +129,9 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): ReolinkChannelCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) + if "snapshots" not in entity_description.stream: + self._attr_supported_features = CameraEntityFeature.STREAM + if self._host.api.model in DUAL_LENS_MODELS: self._attr_translation_key = ( f"{entity_description.translation_key}_lens_{self._channel}" diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index b62a7b7f709..d8caff9f120 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from .const import CONF_USE_HTTPS, DOMAIN @@ -60,7 +60,24 @@ class ReolinkOptionsFlowHandler(OptionsFlow): vol.Required( CONF_PROTOCOL, default=self.config_entry.options[CONF_PROTOCOL], - ): vol.In(["rtsp", "rtmp", "flv"]), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict( + value="rtsp", + label="RTSP", + ), + selector.SelectOptionDict( + value="rtmp", + label="RTMP", + ), + selector.SelectOptionDict( + value="flv", + label="FLV", + ), + ], + ), + ), } ), ) @@ -200,7 +217,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): except (ReolinkError, ReolinkException) as err: placeholders["error"] = str(err) errors[CONF_HOST] = "cannot_connect" - except Exception as err: # pylint: disable=broad-except + except Exception as err: _LOGGER.exception("Unexpected exception") placeholders["error"] = str(err) errors[CONF_HOST] = "unknown" @@ -211,8 +228,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https + mac_address = format_mac(host.api.mac_address) existing_entry = await self.async_set_unique_id( - host.unique_id, raise_on_progress=False + mac_address, raise_on_progress=False ) if existing_entry and self._reauth: if self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 5c13bccf58d..b06ddcd458f 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -23,7 +23,9 @@ async def async_get_config_entry_diagnostics( for ch in api.channels: IPC_cam[ch] = {} IPC_cam[ch]["model"] = api.camera_model(ch) + IPC_cam[ch]["hardware version"] = api.camera_hardware_version(ch) IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) + IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) return { "model": api.model, @@ -42,6 +44,8 @@ async def async_get_config_entry_diagnostics( "stream channels": api.stream_channels, "IPC cams": IPC_cam, "capabilities": api.capabilities, + "cmd list": host.update_cmd, + "firmware ch list": host.firmware_ch_list, "api versions": api.checked_api_versions, "abilities": api.abilities, } diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index e02fd931f66..cf582c69e2d 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -4,7 +4,6 @@ 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, Host @@ -18,8 +17,6 @@ from homeassistant.helpers.update_coordinator import ( from . import ReolinkData from .const import DOMAIN -_T = TypeVar("_T") - @dataclass(frozen=True, kw_only=True) class ReolinkChannelEntityDescription(EntityDescription): @@ -37,20 +34,28 @@ class ReolinkHostEntityDescription(EntityDescription): supported: Callable[[Host], bool] = lambda api: True -class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): - """Parent class for Reolink entities.""" +class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): + """Parent class for entities that control the Reolink NVR itself, without a channel. + + A camera connected directly to HomeAssistant without using a NVR is in the reolink API + basically a NVR with a single channel that has the camera connected to that channel. + """ _attr_has_entity_name = True + entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription def __init__( self, reolink_data: ReolinkData, - coordinator: DataUpdateCoordinator[_T], + coordinator: DataUpdateCoordinator[None] | None = None, ) -> None: - """Initialize ReolinkBaseCoordinatorEntity.""" + """Initialize ReolinkHostCoordinatorEntity.""" + if coordinator is None: + coordinator = reolink_data.device_coordinator super().__init__(coordinator) self._host = reolink_data.host + self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" http_s = "https" if self._host.api.use_https else "http" self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" @@ -71,30 +76,25 @@ class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]) """Return True if entity is available.""" return self._host.api.session_active and super().available - -class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): - """Parent class for entities that control the Reolink NVR itself, without a channel. - - A camera connected directly to HomeAssistant without using a NVR is in the reolink API - 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) + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_register_update_cmd(cmd_key) + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_unregister_update_cmd(cmd_key) + + await super().async_will_remove_from_hass() + + async def async_update(self) -> None: + """Force full update from the generic entity update service.""" + self._host.last_wake = 0 + await super().async_update() class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): @@ -106,26 +106,52 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): self, reolink_data: ReolinkData, channel: int, + coordinator: DataUpdateCoordinator[None] | None = None, ) -> None: """Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" - super().__init__(reolink_data) + super().__init__(reolink_data, coordinator) self._channel = channel - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{self.entity_description.key}" - ) + if self._host.api.supported(channel, "UID"): + self._attr_unique_id = f"{self._host.unique_id}_{self._host.api.camera_uid(channel)}_{self.entity_description.key}" + else: + 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: dev_ch = 0 if self._host.api.is_nvr: + if self._host.api.supported(dev_ch, "UID"): + dev_id = f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}" + else: + dev_id = f"{self._host.unique_id}_ch{dev_ch}" + self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._host.unique_id}_ch{dev_ch}")}, + identifiers={(DOMAIN, dev_id)}, via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), manufacturer=self._host.api.manufacturer, + hw_version=self._host.api.camera_hardware_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch), + serial_number=self._host.api.camera_uid(dev_ch), configuration_url=self._conf_url, ) + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_register_update_cmd(cmd_key, self._channel) + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_unregister_update_cmd(cmd_key, self._channel) + + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 4f5487a6a04..c69a80ce972 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +from collections import defaultdict from collections.abc import Mapping import logging +from time import time from typing import Any, Literal import aiohttp @@ -21,7 +23,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -39,6 +41,10 @@ POLL_INTERVAL_NO_PUSH = 5 LONG_POLL_COOLDOWN = 0.75 LONG_POLL_ERROR_COOLDOWN = 30 +# Conserve battery by not waking the battery cameras each minute during normal update +# Most props are cached in the Home Hub and updated, but some are skipped +BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds + _LOGGER = logging.getLogger(__name__) @@ -67,7 +73,11 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) - self.update_cmd_list: list[str] = [] + self.last_wake: float = 0 + self.update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict( + lambda: defaultdict(int) + ) + self.firmware_ch_list: list[int | None] = [] self.webhook_id: str | None = None self._onvif_push_supported: bool = True @@ -84,6 +94,20 @@ class ReolinkHost: self._long_poll_task: asyncio.Task | None = None self._lost_subscription: bool = False + @callback + def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: + """Register the command to update the state.""" + self.update_cmd[cmd][channel] += 1 + + @callback + def async_unregister_update_cmd(self, cmd: str, channel: int | None = None) -> None: + """Unregister the command to update the state.""" + self.update_cmd[cmd][channel] -= 1 + if not self.update_cmd[cmd][channel]: + del self.update_cmd[cmd][channel] + if not self.update_cmd[cmd]: + del self.update_cmd[cmd] + @property def unique_id(self) -> str: """Create the unique ID, base for all entities.""" @@ -167,7 +191,10 @@ class ReolinkHost: else: ir.async_delete_issue(self._hass, DOMAIN, "enable_port") - self._unique_id = format_mac(self._api.mac_address) + if self._api.supported(None, "UID"): + self._unique_id = self._api.uid + else: + self._unique_id = format_mac(self._api.mac_address) if self._onvif_push_supported: try: @@ -213,25 +240,35 @@ class ReolinkHost: self._async_check_onvif_long_poll, ) - if self._api.sw_version_update_required: - ir.async_create_issue( - self._hass, - DOMAIN, - "firmware_update", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="firmware_update", - translation_placeholders={ - "required_firmware": self._api.sw_version_required.version_string, - "current_firmware": self._api.sw_version, - "model": self._api.model, - "hw_version": self._api.hardware_version, - "name": self._api.nvr_name, - "download_link": "https://reolink.com/download-center/", - }, - ) - else: - ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + ch_list: list[int | None] = [None] + if self._api.is_nvr: + ch_list.extend(self._api.channels) + for ch in ch_list: + if not self._api.supported(ch, "firmware"): + continue + + key = ch if ch is not None else "host" + if self._api.camera_sw_version_update_required(ch): + ir.async_create_issue( + self._hass, + DOMAIN, + f"firmware_update_{key}", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="firmware_update", + translation_placeholders={ + "required_firmware": self._api.camera_sw_version_required( + ch + ).version_string, + "current_firmware": self._api.camera_sw_version(ch), + "model": self._api.camera_model(ch), + "hw_version": self._api.camera_hardware_version(ch), + "name": self._api.camera_name(ch), + "download_link": "https://reolink.com/download-center/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, f"firmware_update_{key}") async def _async_check_onvif(self, *_) -> None: """Check the ONVIF subscription.""" @@ -320,7 +357,13 @@ 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(cmd_list=self.update_cmd_list) + wake = False + if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL: + # wake the battery cameras for a complete update + wake = True + self.last_wake = time() + + await self._api.get_states(cmd_list=self.update_cmd, wake=wake) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" @@ -652,7 +695,7 @@ class ReolinkHost: message = data.decode("utf-8") channels = await self._api.ONVIF_event_callback(message) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error processing ONVIF event for Reolink %s", self._api.nvr_name ) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index fcf88fb6726..a4620bd95d5 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -42,6 +42,12 @@ "state": { "on": "mdi:motion-sensor" } + }, + "sleep": { + "default": "mdi:sleep-off", + "state": { + "on": "mdi:sleep" + } } }, "button": { @@ -103,6 +109,9 @@ "motion_sensitivity": { "default": "mdi:motion-sensor" }, + "pir_sensitivity": { + "default": "mdi:motion-sensor" + }, "ai_face_sensitivity": { "default": "mdi:face-recognition" }, @@ -200,6 +209,12 @@ "ptz_pan_position": { "default": "mdi:pan" }, + "battery_temperature": { + "default": "mdi:thermometer" + }, + "battery_state": { + "default": "mdi:battery-charging" + }, "wifi_signal": { "default": "mdi:wifi" }, @@ -249,6 +264,9 @@ "record": { "default": "mdi:record-rec" }, + "manual_record": { + "default": "mdi:record-rec" + }, "buzzer": { "default": "mdi:room-service" }, @@ -257,6 +275,12 @@ }, "hdr": { "default": "mdi:hdr" + }, + "pir_enabled": { + "default": "mdi:motion-sensor" + }, + "pir_reduce_alarm": { + "default": "mdi:motion-sensor" } } }, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 81d11e2fd0a..172a43a91b3 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.9"] + "requirements": ["reolink-aio==0.9.3"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index c22a0fc28e7..7a77e482f56 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -34,7 +34,15 @@ async def async_get_media_source(hass: HomeAssistant) -> ReolinkVODMediaSource: def res_name(stream: str) -> str: """Return the user friendly name for a stream.""" - return "High res." if stream == "main" else "Low res." + match stream: + case "main": + return "High res." + case "autotrack_sub": + return "Autotrack low res." + case "autotrack_main": + return "Autotrack high res." + case _: + return "Low res." class ReolinkVODMediaSource(MediaSource): @@ -59,19 +67,31 @@ class ReolinkVODMediaSource(MediaSource): data: dict[str, ReolinkData] = self.hass.data[DOMAIN] host = data[config_entry_id].host - vod_type = VodRequestType.RTMP - if host.api.is_nvr: - vod_type = VodRequestType.FLV + def get_vod_type() -> VodRequestType: + if filename.endswith(".mp4"): + return VodRequestType.PLAYBACK + if host.api.is_nvr: + return VodRequestType.FLV + return VodRequestType.RTMP + + vod_type = get_vod_type() 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" + url_log = url + if "&user=" in url_log: + url_log = f"{url_log.split('&user=')[0]}&user=xxxxx&password=xxxxx" + elif "&token=" in url_log: + url_log = f"{url_log.split('&token=')[0]}&token=xxxxx" _LOGGER.debug( "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log ) + if mime_type == "video/mp4": + return PlayMedia(url, mime_type) + stream = create_stream(self.hass, url, {}, DynamicStreamSettings()) stream.add_provider("hls", timeout=3600) stream_url: str = stream.endpoint_url("hls") @@ -144,10 +164,14 @@ class ReolinkVODMediaSource(MediaSource): continue device = device_reg.async_get(entity.device_id) - ch = entity.unique_id.split("_")[1] - if ch in channels or device is None: + ch_id = entity.unique_id.split("_")[1] + if ch_id in channels or device is None: continue - channels.append(ch) + channels.append(ch_id) + + ch: int | str = ch_id + if len(ch_id) > 3: + ch = host.api.channel_for_uid(ch_id) if ( host.api.api_version("recReplay", int(ch)) < 1 @@ -198,9 +222,6 @@ class ReolinkVODMediaSource(MediaSource): "playback only possible using sub stream", host.api.camera_name(channel), ) - return await self._async_generate_camera_days( - config_entry_id, channel, "sub" - ) children = [ BrowseMediaSource( @@ -212,16 +233,49 @@ class ReolinkVODMediaSource(MediaSource): can_play=False, can_expand=True, ), - BrowseMediaSource( - domain=DOMAIN, - identifier=f"RES|{config_entry_id}|{channel}|main", - media_class=MediaClass.CHANNEL, - media_content_type=MediaType.PLAYLIST, - title="High resolution", - can_play=False, - can_expand=True, - ), ] + if main_enc != "h265": + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|main", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="High resolution", + can_play=False, + can_expand=True, + ), + ) + + if host.api.supported(channel, "autotrack_stream"): + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="Autotrack low resolution", + can_play=False, + can_expand=True, + ), + ) + if main_enc != "h265": + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|autotrack_main", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="Autotrack high resolution", + can_play=False, + can_expand=True, + ), + ) + + if len(children) == 1: + return await self._async_generate_camera_days( + config_entry_id, channel, "sub" + ) return BrowseMediaSource( domain=DOMAIN, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index c4623c49c91..a4ea89c5b26 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -116,6 +116,18 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.md_sensitivity(ch), method=lambda api, ch, value: api.set_md_sensitivity(ch, int(value)), ), + ReolinkNumberEntityDescription( + key="pir_sensitivity", + cmd_key="GetPirInfo", + translation_key="pir_sensitivity", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "PIR"), + value=lambda api, ch: api.pir_sensitivity(ch), + method=lambda api, ch, value: api.set_pir(ch, sensitivity=int(value)), + ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", cmd_key="GetAiAlarm", diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 13757e7bb22..907cc90b8af 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -109,12 +109,14 @@ SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="status_led", cmd_key="GetPowerLed", - translation_key="status_led", + translation_key="doorbell_led", entity_category=EntityCategory.CONFIG, - get_options=[state.name for state in StatusLedEnum], + get_options=lambda api, ch: api.doorbell_led_list(ch), supported=lambda api, ch: api.supported(ch, "doorbell_led"), value=lambda api, ch: StatusLedEnum(api.doorbell_led(ch)).name, - method=lambda api, ch, name: api.set_status_led(ch, StatusLedEnum[name].value), + method=lambda api, ch, name: ( + api.set_status_led(ch, StatusLedEnum[name].value, doorbell=True) + ), ), ) diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 36363beaf80..419270a7082 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -8,14 +8,16 @@ from datetime import date, datetime from decimal import Decimal from reolink_aio.api import Host +from reolink_aio.enums import BatteryEnum from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -37,7 +39,7 @@ class ReolinkSensorEntityDescription( ): """A class that describes sensor entities for a camera channel.""" - value: Callable[[Host, int], int | float] + value: Callable[[Host, int], StateType] @dataclass(frozen=True, kw_only=True) @@ -47,7 +49,7 @@ class ReolinkHostSensorEntityDescription( ): """A class that describes host sensor entities.""" - value: Callable[[Host], int | None] + value: Callable[[Host], StateType] SENSORS = ( @@ -60,6 +62,39 @@ SENSORS = ( value=lambda api, ch: api.ptz_pan_position(ch), supported=lambda api, ch: api.supported(ch, "ptz_position"), ), + ReolinkSensorEntityDescription( + key="battery_percent", + cmd_key="GetBatteryInfo", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda api, ch: api.battery_percentage(ch), + supported=lambda api, ch: api.supported(ch, "battery"), + ), + ReolinkSensorEntityDescription( + key="battery_temperature", + cmd_key="GetBatteryInfo", + translation_key="battery_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda api, ch: api.battery_temperature(ch), + supported=lambda api, ch: api.supported(ch, "battery"), + ), + ReolinkSensorEntityDescription( + key="battery_state", + cmd_key="GetBatteryInfo", + translation_key="battery_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[state.name for state in BatteryEnum], + value=lambda api, ch: BatteryEnum(api.battery_status(ch)).name, + supported=lambda api, ch: api.supported(ch, "battery"), + ), ) HOST_SENSORS = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index ec81893d846..aa141818ec6 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -86,73 +86,160 @@ "entity": { "binary_sensor": { "face": { - "name": "Face" + "name": "Face", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "person": { - "name": "Person" + "name": "Person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "vehicle": { - "name": "Vehicle" + "name": "Vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "pet": { - "name": "Pet" + "name": "Pet", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "animal": { - "name": "Animal" + "name": "Animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "visitor": { "name": "Visitor" }, "package": { - "name": "Package" + "name": "Package", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "motion_lens_0": { - "name": "Motion lens 0" + "name": "Motion lens 0", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "face_lens_0": { - "name": "Face lens 0" + "name": "Face lens 0", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "person_lens_0": { - "name": "Person lens 0" + "name": "Person lens 0", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "vehicle_lens_0": { - "name": "Vehicle lens 0" + "name": "Vehicle lens 0", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "pet_lens_0": { - "name": "Pet lens 0" + "name": "Pet lens 0", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "animal_lens_0": { - "name": "Animal lens 0" + "name": "Animal lens 0", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "visitor_lens_0": { "name": "Visitor lens 0" }, "package_lens_0": { - "name": "Package lens 0" + "name": "Package lens 0", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "motion_lens_1": { - "name": "Motion lens 1" + "name": "Motion lens 1", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "face_lens_1": { - "name": "Face lens 1" + "name": "Face lens 1", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "person_lens_1": { - "name": "Person lens 1" + "name": "Person lens 1", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "vehicle_lens_1": { - "name": "Vehicle lens 1" + "name": "Vehicle lens 1", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "pet_lens_1": { - "name": "Pet lens 1" + "name": "Pet lens 1", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "animal_lens_1": { - "name": "Animal lens 1" + "name": "Animal lens 1", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "visitor_lens_1": { "name": "Visitor lens 1" }, "package_lens_1": { - "name": "Package lens 1" + "name": "Package lens 1", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "sleep": { + "name": "Sleep status", + "state": { + "off": "Awake", + "on": "Sleeping" + } } }, "button": { @@ -270,6 +357,9 @@ "motion_sensitivity": { "name": "Motion sensitivity" }, + "pir_sensitivity": { + "name": "PIR sensitivity" + }, "ai_face_sensitivity": { "name": "AI face sensitivity" }, @@ -380,8 +470,8 @@ "pantiltfirst": "Pan/tilt first" } }, - "status_led": { - "name": "Status LED", + "doorbell_led": { + "name": "Doorbell LED", "state": { "stayoff": "Stay off", "auto": "Auto", @@ -397,6 +487,17 @@ "ptz_pan_position": { "name": "PTZ pan position" }, + "battery_temperature": { + "name": "Battery temperature" + }, + "battery_state": { + "name": "Battery state", + "state": { + "discharging": "Discharging", + "charging": "Charging", + "chargecomplete": "Charge complete" + } + }, "hdd_storage": { "name": "HDD {hdd_index} storage" }, @@ -443,6 +544,9 @@ "record": { "name": "Record" }, + "manual_record": { + "name": "Manual record" + }, "buzzer": { "name": "Buzzer on event" }, @@ -451,6 +555,12 @@ }, "hdr": { "name": "HDR" + }, + "pir_enabled": { + "name": "PIR enabled" + }, + "pir_reduce_alarm": { + "name": "PIR reduce false alarm" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index adda97debb4..9dfce88f93a 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -146,6 +146,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.recording_enabled(ch), method=lambda api, ch, value: api.set_recording(ch, value), ), + ReolinkSwitchEntityDescription( + key="manual_record", + cmd_key="GetManualRec", + translation_key="manual_record", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "manual_record"), + value=lambda api, ch: api.manual_record_enabled(ch), + method=lambda api, ch, value: api.set_manual_record(ch, value), + ), ReolinkSwitchEntityDescription( key="buzzer", cmd_key="GetBuzzerAlarmV20", @@ -174,6 +183,26 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.HDR_on(ch) is True, method=lambda api, ch, value: api.set_HDR(ch, value), ), + ReolinkSwitchEntityDescription( + key="pir_enabled", + cmd_key="GetPirInfo", + translation_key="pir_enabled", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "PIR"), + value=lambda api, ch: api.pir_enabled(ch) is True, + method=lambda api, ch, value: api.set_pir(ch, enable=value), + ), + ReolinkSwitchEntityDescription( + key="pir_reduce_alarm", + cmd_key="GetPirInfo", + translation_key="pir_reduce_alarm", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "PIR"), + value=lambda api, ch: api.pir_reduce_alarm(ch) is True, + method=lambda api, ch, value: api.set_pir(ch, reduce_alarm=value), + ), ) NVR_SWITCH_ENTITIES = ( @@ -301,8 +330,6 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): self.entity_description = entity_description super().__init__(reolink_data) - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" - @property def is_on(self) -> bool: """Return true if switch is on.""" diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 41933ae2efc..da3dafe0130 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -2,9 +2,9 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime -import logging -from typing import Any, Literal +from typing import Any from reolink_aio.exceptions import ReolinkError from reolink_aio.software_version import NewSoftwareVersion @@ -12,6 +12,7 @@ from reolink_aio.software_version import NewSoftwareVersion from homeassistant.components.update import ( UpdateDeviceClass, UpdateEntity, + UpdateEntityDescription, UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -22,13 +23,49 @@ from homeassistant.helpers.event import async_call_later from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkBaseCoordinatorEntity - -LOGGER = logging.getLogger(__name__) +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) POLL_AFTER_INSTALL = 120 +@dataclass(frozen=True, kw_only=True) +class ReolinkUpdateEntityDescription( + UpdateEntityDescription, + ReolinkChannelEntityDescription, +): + """A class that describes update entities.""" + + +@dataclass(frozen=True, kw_only=True) +class ReolinkHostUpdateEntityDescription( + UpdateEntityDescription, + ReolinkHostEntityDescription, +): + """A class that describes host update entities.""" + + +UPDATE_ENTITIES = ( + ReolinkUpdateEntityDescription( + key="firmware", + supported=lambda api, ch: api.supported(ch, "firmware"), + device_class=UpdateDeviceClass.FIRMWARE, + ), +) + +HOST_UPDATE_ENTITIES = ( + ReolinkHostUpdateEntityDescription( + key="firmware", + supported=lambda api: api.supported(None, "firmware"), + device_class=UpdateDeviceClass.FIRMWARE, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -36,26 +73,133 @@ async def async_setup_entry( ) -> None: """Set up update entities for Reolink component.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([ReolinkUpdateEntity(reolink_data)]) + + entities: list[ReolinkUpdateEntity | ReolinkHostUpdateEntity] = [ + ReolinkUpdateEntity(reolink_data, channel, entity_description) + for entity_description in UPDATE_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + [ + ReolinkHostUpdateEntity(reolink_data, entity_description) + for entity_description in HOST_UPDATE_ENTITIES + if entity_description.supported(reolink_data.host.api) + ] + ) + async_add_entities(entities) class ReolinkUpdateEntity( - ReolinkBaseCoordinatorEntity[str | Literal[False] | NewSoftwareVersion], + ReolinkChannelCoordinatorEntity, UpdateEntity, ): - """Update entity for a Netgear device.""" + """Base update entity class for Reolink IP cameras.""" - _attr_device_class = UpdateDeviceClass.FIRMWARE + entity_description: ReolinkUpdateEntityDescription _attr_release_url = "https://reolink.com/download-center/" def __init__( self, reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkUpdateEntityDescription, ) -> None: - """Initialize a Netgear device.""" - super().__init__(reolink_data, reolink_data.firmware_coordinator) + """Initialize Reolink update entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel, reolink_data.firmware_coordinator) + self._cancel_update: CALLBACK_TYPE | None = None - self._attr_unique_id = f"{self._host.unique_id}" + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + return self._host.api.camera_sw_version(self._channel) + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + new_firmware = self._host.api.firmware_update_available(self._channel) + if not new_firmware: + return self.installed_version + + if isinstance(new_firmware, str): + return new_firmware + + return new_firmware.version_string + + @property + def supported_features(self) -> UpdateEntityFeature: + """Flag supported features.""" + supported_features = UpdateEntityFeature.INSTALL + new_firmware = self._host.api.firmware_update_available(self._channel) + if isinstance(new_firmware, NewSoftwareVersion): + supported_features |= UpdateEntityFeature.RELEASE_NOTES + return supported_features + + async def async_release_notes(self) -> str | None: + """Return the release notes.""" + new_firmware = self._host.api.firmware_update_available(self._channel) + if not isinstance(new_firmware, NewSoftwareVersion): + return None + + return ( + "If the install button fails, download this" + f" [firmware zip file]({new_firmware.download_url})." + " Then, follow the installation guide (PDF in the zip file).\n\n" + f"## Release notes\n\n{new_firmware.release_notes}" + ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + try: + await self._host.api.update_firmware(self._channel) + except ReolinkError as err: + raise HomeAssistantError( + f"Error trying to update Reolink firmware: {err}" + ) from err + finally: + self.async_write_ha_state() + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + self._host.firmware_ch_list.append(self._channel) + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._channel in self._host.firmware_ch_list: + self._host.firmware_ch_list.remove(self._channel) + if self._cancel_update is not None: + self._cancel_update() + + +class ReolinkHostUpdateEntity( + ReolinkHostCoordinatorEntity, + UpdateEntity, +): + """Update entity class for Reolink Host.""" + + entity_description: ReolinkHostUpdateEntityDescription + _attr_release_url = "https://reolink.com/download-center/" + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostUpdateEntityDescription, + ) -> None: + """Initialize Reolink update entity.""" + self.entity_description = entity_description + super().__init__(reolink_data, reolink_data.firmware_coordinator) self._cancel_update: CALLBACK_TYPE | None = None @property @@ -66,32 +210,35 @@ class ReolinkUpdateEntity( @property def latest_version(self) -> str | None: """Latest version available for install.""" - if not self.coordinator.data: + new_firmware = self._host.api.firmware_update_available() + if not new_firmware: return self.installed_version - if isinstance(self.coordinator.data, str): - return self.coordinator.data + if isinstance(new_firmware, str): + return new_firmware - return self.coordinator.data.version_string + return new_firmware.version_string @property def supported_features(self) -> UpdateEntityFeature: """Flag supported features.""" supported_features = UpdateEntityFeature.INSTALL - if isinstance(self.coordinator.data, NewSoftwareVersion): + new_firmware = self._host.api.firmware_update_available() + if isinstance(new_firmware, NewSoftwareVersion): supported_features |= UpdateEntityFeature.RELEASE_NOTES return supported_features async def async_release_notes(self) -> str | None: """Return the release notes.""" - if not isinstance(self.coordinator.data, NewSoftwareVersion): + new_firmware = self._host.api.firmware_update_available() + if not isinstance(new_firmware, NewSoftwareVersion): return None return ( "If the install button fails, download this" - f" [firmware zip file]({self.coordinator.data.download_url})." + f" [firmware zip file]({new_firmware.download_url})." " Then, follow the installation guide (PDF in the zip file).\n\n" - f"## Release notes\n\n{self.coordinator.data.release_notes}" + f"## Release notes\n\n{new_firmware.release_notes}" ) async def async_install( @@ -114,8 +261,15 @@ class ReolinkUpdateEntity( """Request update.""" await self.async_update() + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + self._host.firmware_ch_list.append(None) + async def async_will_remove_from_hass(self) -> None: """Entity removed.""" await super().async_will_remove_from_hass() + if None in self._host.firmware_ch_list: + self._host.firmware_ch_list.remove(None) if self._cancel_update is not None: self._cancel_update() diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index 8a170b1de8d..38dcea1668d 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -9,13 +9,10 @@ import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from homeassistant.helpers.issue_registry import ( - async_delete_issue, - async_get as async_get_issue_registry, -) from .const import DOMAIN from .models import RepairsFlow, RepairsProtocol @@ -37,7 +34,7 @@ class ConfirmRepairFlow(RepairsFlow): if user_input is not None: return self.async_create_entry(data={}) - issue_registry = async_get_issue_registry(self.hass) + issue_registry = ir.async_get(self.hass) description_placeholders = None if issue := issue_registry.async_get_issue(self.handler, self.issue_id): description_placeholders = issue.translation_placeholders @@ -63,7 +60,7 @@ class RepairsFlowManager(data_entry_flow.FlowManager): assert data and "issue_id" in data issue_id = data["issue_id"] - issue_registry = async_get_issue_registry(self.hass) + issue_registry = ir.async_get(self.hass) issue = issue_registry.async_get_issue(handler_key, issue_id) if issue is None or not issue.is_fixable: raise data_entry_flow.UnknownStep @@ -87,7 +84,7 @@ class RepairsFlowManager(data_entry_flow.FlowManager): ) -> data_entry_flow.FlowResult: """Complete a fix flow.""" if result.get("type") != data_entry_flow.FlowResultType.ABORT: - async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"]) + ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"]) if "result" not in result: result["result"] = None return result diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index af5f82e49d4..4875a8f6cfa 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -15,14 +15,11 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.decorators import require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, ) -from homeassistant.helpers.issue_registry import ( - async_get as async_get_issue_registry, - async_ignore_issue, -) from .const import DOMAIN @@ -50,7 +47,7 @@ def ws_get_issue_data( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Fix an issue.""" - issue_registry = async_get_issue_registry(hass) + issue_registry = ir.async_get(hass) if not (issue := issue_registry.async_get_issue(msg["domain"], msg["issue_id"])): connection.send_error( msg["id"], @@ -74,7 +71,7 @@ def ws_ignore_issue( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Fix an issue.""" - async_ignore_issue(hass, msg["domain"], msg["issue_id"], msg["ignore"]) + ir.async_ignore_issue(hass, msg["domain"], msg["issue_id"], msg["ignore"]) connection.send_result(msg["id"]) @@ -89,7 +86,7 @@ def ws_list_issues( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of issues.""" - issue_registry = async_get_issue_registry(hass) + issue_registry = ir.async_get(hass) issues = [ { "breaks_in_ha_version": issue.breaks_in_ha_version, diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 0568203a91c..5aafd727178 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import ssl +from xml.parsers.expat import ExpatError import voluptuous as vol @@ -149,24 +150,31 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): self._attr_is_on = False return - response = self.rest.data + try: + response = self.rest.data_without_xml() + except ExpatError as err: + self._attr_is_on = False + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON: %s", err + ) + return raw_value = response - if self._value_template is not None: + if response is not None and self._value_template is not None: response = self._value_template.async_render_with_possible_json_value( - self.rest.data, False + response, False ) try: - self._attr_is_on = bool(int(response)) + self._attr_is_on = bool(int(str(response))) except ValueError: self._attr_is_on = { "true": True, "on": True, "open": True, "yes": True, - }.get(response.lower(), False) + }.get(str(response).lower(), False) self._process_manual_data(raw_value) self.async_write_ha_state() diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 4c9667e7651..e198202ae57 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import ssl -from xml.parsers.expat import ExpatError import httpx import xmltodict @@ -79,14 +78,8 @@ class RestData: and (content_type := headers.get("content-type")) and content_type.startswith(XML_MIME_TYPES) ): - try: - value = json_dumps(xmltodict.parse(value)) - except ExpatError: - _LOGGER.warning( - "REST xml result could not be parsed and converted to JSON" - ) - else: - _LOGGER.debug("JSON converted from XML: %s", value) + value = json_dumps(xmltodict.parse(value)) + _LOGGER.debug("JSON converted from XML: %s", value) return value async def async_update(self, log_errors: bool = True) -> None: diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 199ab3721c3..810d286d147 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import ssl from typing import Any +from xml.parsers.expat import ExpatError import voluptuous as vol @@ -159,7 +160,13 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): def _update_from_rest_data(self) -> None: """Update state from the rest data.""" - value = self.rest.data_without_xml() + try: + value = self.rest.data_without_xml() + except ExpatError as err: + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON: %s", err + ) + value = self.rest.data if self._json_attrs: self._attr_extra_state_attributes = parse_json_attributes( diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c43e23cf068..b6945c5ce98 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -200,6 +200,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) from err except aiohttp.ClientError as err: + _LOGGER.error("Error fetching data: %s", err) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="client_error", diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index fb339f4ba5a..f3466aa704d 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, TypeVarTuple, cast +from typing import Any, NamedTuple, cast import RFXtrx as rfxtrxmod import voluptuous as vol @@ -55,8 +55,6 @@ DEFAULT_OFF_DELAY = 2.0 SIGNAL_EVENT = f"{DOMAIN}_event" CONNECT_TIMEOUT = 30.0 -_Ts = TypeVarTuple("_Ts") - _LOGGER = logging.getLogger(__name__) @@ -573,7 +571,7 @@ class RfxtrxCommandEntity(RfxtrxEntity): """Initialzie a switch or light device.""" super().__init__(device, device_id, event=event) - async def _async_send( + async def _async_send[*_Ts]( self, fun: Callable[[rfxtrxmod.PySerialTransport, *_Ts], None], *args: *_Ts ) -> None: rfx_object: rfxtrxmod.Connect = self.hass.data[DOMAIN][DATA_RFXOBJECT] diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 4762017c5bc..6239105580d 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -70,7 +70,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_2fa() except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -126,7 +126,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_2fa() except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index a10f9317bab..1a52fc78988 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -3,7 +3,6 @@ from asyncio import TaskGroup from collections.abc import Callable import logging -from typing import TypeVar, TypeVarTuple from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout @@ -15,11 +14,8 @@ from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -_R = TypeVar("_R") -_Ts = TypeVarTuple("_Ts") - -async def _call_api( +async def _call_api[*_Ts, _R]( hass: HomeAssistant, target: Callable[[*_Ts], _R], *args: *_Ts, msg_suffix: str = "" ) -> _R: try: diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 65ccbb8ece4..a4275815450 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,7 +1,7 @@ """Base class for Ring entity.""" from collections.abc import Callable -from typing import Any, Concatenate, Generic, ParamSpec, cast +from typing import Any, Concatenate, Generic, cast from ring_doorbell import ( AuthenticationError, @@ -26,12 +26,9 @@ _RingCoordinatorT = TypeVar( "_RingCoordinatorT", bound=(RingDataCoordinator | RingNotificationsCoordinator), ) -_RingBaseEntityT = TypeVar("_RingBaseEntityT", bound="RingBaseEntity[Any, Any]") -_R = TypeVar("_R") -_P = ParamSpec("_P") -def exception_wrap( +def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R]( func: Callable[Concatenate[_RingBaseEntityT, _P], _R], ) -> Callable[Concatenate[_RingBaseEntityT, _P], _R]: """Define a wrapper to catch exceptions and raise HomeAssistant errors.""" diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index d25579343c8..7255c724e3f 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -4,19 +4,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, field -from datetime import timedelta import logging from typing import Any -from pyrisco import ( - CannotConnectError, - OperationError, - RiscoCloud, - RiscoLocal, - UnauthorizedError, -) -from pyrisco.cloud.alarm import Alarm -from pyrisco.cloud.event import Event +from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedError from pyrisco.common import Partition, System, Zone from homeassistant.config_entries import ConfigEntry @@ -34,8 +25,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_CONCURRENCY, @@ -47,6 +36,7 @@ from .const import ( SYSTEM_UPDATE_SIGNAL, TYPE_LOCAL, ) +from .coordinator import RiscoDataUpdateCoordinator, RiscoEventsDataUpdateCoordinator PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, @@ -54,8 +44,6 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, ] -LAST_EVENT_STORAGE_VERSION = 1 -LAST_EVENT_TIMESTAMP_KEY = "last_event_timestamp" _LOGGER = logging.getLogger(__name__) @@ -102,6 +90,9 @@ async def _async_setup_local_entry(hass: HomeAssistant, entry: ConfigEntry) -> b async def _error(error: Exception) -> None: _LOGGER.error("Error in Risco library", exc_info=error) + if isinstance(error, ConnectionResetError) and not hass.is_stopping: + _LOGGER.debug("Disconnected from panel. Reloading integration") + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) entry.async_on_unload(risco.add_error_handler(_error)) @@ -190,63 +181,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching risco data.""" - - def __init__( - self, hass: HomeAssistant, risco: RiscoCloud, scan_interval: int - ) -> None: - """Initialize global risco data updater.""" - self.risco = risco - interval = timedelta(seconds=scan_interval) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=interval, - ) - - async def _async_update_data(self) -> Alarm: - """Fetch data from risco.""" - try: - return await self.risco.get_state() - except (CannotConnectError, UnauthorizedError, OperationError) as error: - raise UpdateFailed(error) from error - - -class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching risco data.""" - - def __init__( - self, hass: HomeAssistant, risco: RiscoCloud, eid: str, scan_interval: int - ) -> None: - """Initialize global risco data updater.""" - self.risco = risco - self._store = Store[dict[str, Any]]( - hass, LAST_EVENT_STORAGE_VERSION, f"risco_{eid}_last_event_timestamp" - ) - interval = timedelta(seconds=scan_interval) - super().__init__( - hass, - _LOGGER, - name=f"{DOMAIN}_events", - update_interval=interval, - ) - - async def _async_update_data(self) -> list[Event]: - """Fetch data from risco.""" - last_store = await self._store.async_load() or {} - last_timestamp = last_store.get( - LAST_EVENT_TIMESTAMP_KEY, "2020-01-01T00:00:00Z" - ) - try: - events = await self.risco.get_events(last_timestamp, 10) - except (CannotConnectError, UnauthorizedError, OperationError) as error: - raise UpdateFailed(error) from error - - if len(events) > 0: - await self._store.async_save({LAST_EVENT_TIMESTAMP_KEY: events[0].time}) - - return events diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index 580842e78ad..08dee936d37 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LocalData, RiscoDataUpdateCoordinator, is_local +from . import LocalData, is_local from .const import ( CONF_CODE_ARM_REQUIRED, CONF_CODE_DISARM_REQUIRED, @@ -42,6 +42,7 @@ from .const import ( RISCO_GROUPS, RISCO_PARTIAL_ARM, ) +from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index afb65ee226f..a7ca0129b06 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -21,8 +21,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LocalData, RiscoDataUpdateCoordinator, is_local +from . import LocalData, is_local from .const import DATA_COORDINATOR, DOMAIN, SYSTEM_UPDATE_SIGNAL +from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity SYSTEM_ENTITY_DESCRIPTIONS = [ diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index 21761e23d09..735880df09b 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -159,7 +159,7 @@ class RiscoConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except UnauthorizedError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -197,7 +197,7 @@ class RiscoConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except UnauthorizedError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/risco/coordinator.py b/homeassistant/components/risco/coordinator.py new file mode 100644 index 00000000000..8430b6a6172 --- /dev/null +++ b/homeassistant/components/risco/coordinator.py @@ -0,0 +1,81 @@ +"""Coordinator for the Risco integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from pyrisco import CannotConnectError, OperationError, RiscoCloud, UnauthorizedError +from pyrisco.cloud.alarm import Alarm +from pyrisco.cloud.event import Event + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +LAST_EVENT_STORAGE_VERSION = 1 +LAST_EVENT_TIMESTAMP_KEY = "last_event_timestamp" +_LOGGER = logging.getLogger(__name__) + + +class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): + """Class to manage fetching risco data.""" + + def __init__( + self, hass: HomeAssistant, risco: RiscoCloud, scan_interval: int + ) -> None: + """Initialize global risco data updater.""" + self.risco = risco + interval = timedelta(seconds=scan_interval) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + ) + + async def _async_update_data(self) -> Alarm: + """Fetch data from risco.""" + try: + return await self.risco.get_state() + except (CannotConnectError, UnauthorizedError, OperationError) as error: + raise UpdateFailed(error) from error + + +class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): + """Class to manage fetching risco data.""" + + def __init__( + self, hass: HomeAssistant, risco: RiscoCloud, eid: str, scan_interval: int + ) -> None: + """Initialize global risco data updater.""" + self.risco = risco + self._store = Store[dict[str, Any]]( + hass, LAST_EVENT_STORAGE_VERSION, f"risco_{eid}_last_event_timestamp" + ) + interval = timedelta(seconds=scan_interval) + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_events", + update_interval=interval, + ) + + async def _async_update_data(self) -> list[Event]: + """Fetch data from risco.""" + last_store = await self._store.async_load() or {} + last_timestamp = last_store.get( + LAST_EVENT_TIMESTAMP_KEY, "2020-01-01T00:00:00Z" + ) + try: + events = await self.risco.get_events(last_timestamp, 10) + except (CannotConnectError, UnauthorizedError, OperationError) as error: + raise UpdateFailed(error) from error + + if len(events) > 0: + await self._store.async_save({LAST_EVENT_TIMESTAMP_KEY: events[0].time}) + + return events diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index b3a3cdd1d4d..f448f60f4d9 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -13,8 +13,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RiscoDataUpdateCoordinator, zone_update_signal +from . import zone_update_signal from .const import DOMAIN +from .coordinator import RiscoDataUpdateCoordinator def zone_unique_id(risco: RiscoCloud, zone_id: int) -> str: diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 22e73a10d6d..372d8e0c629 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyrisco"], "quality_scale": "platinum", - "requirements": ["pyrisco==0.6.1"] + "requirements": ["pyrisco==0.6.4"] } diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index 8f97c76c879..c1495512e62 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -17,8 +17,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import RiscoEventsDataUpdateCoordinator, is_local +from . import is_local from .const import DOMAIN, EVENTS_COORDINATOR +from .coordinator import RiscoEventsDataUpdateCoordinator from .entity import zone_unique_id CATEGORIES = { @@ -114,7 +115,7 @@ class RiscoSensor(CoordinatorEntity[RiscoEventsDataUpdateCoordinator], SensorEnt return None if res := dt_util.parse_datetime(self._event.time): - return res.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + return res.replace(tzinfo=dt_util.get_default_time_zone()) return None @property diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index c43b55b0233..8bad2c6c15e 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -12,8 +12,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LocalData, RiscoDataUpdateCoordinator, is_local +from . import LocalData, is_local from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py index 7bff52fb864..4f108d9bc22 100644 --- a/homeassistant/components/rituals_perfume_genie/config_flow.py +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -48,7 +48,7 @@ class RitualsPerfumeGenieConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except AuthenticationException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 12a884dba48..d7ce0e0f5ec 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -9,6 +9,7 @@ import logging from typing import Any from roborock import HomeDataRoom, RoborockException, RoborockInvalidCredentials +from roborock.code_mappings import RoborockCategory from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, UserData from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.web_api import RoborockApiClient @@ -96,6 +97,7 @@ def build_setup_functions( hass, user_data, device, product_info[device.product_id], home_data_rooms ) for device in device_map.values() + if product_info[device.product_id].category == RoborockCategory.VACUUM ] diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 5715aba3bba..c7347178612 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -72,7 +72,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): except RoborockException: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown_roborock" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return errors @@ -95,7 +95,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): except RoborockException: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown_roborock" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 0646f8ee083..42c0f9ba347 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==2.0.0", + "python-roborock==2.3.0", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 1ac37f10eb9..09affe4369b 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -40,7 +40,7 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.CHANNELS, ] -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str | None] +type GetBrowseImageUrlType = Callable[[str, str, str | None], str | None] def get_thumbnail_url_full( diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 07c1afae9e2..7757cc53e1c 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -75,7 +75,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Roku Error", exc_info=True) errors["base"] = ERROR_CANNOT_CONNECT return self._show_form(errors) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error trying to connect") return self.async_abort(reason=ERROR_UNKNOWN) @@ -100,7 +100,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): except RokuError: _LOGGER.debug("Roku Error", exc_info=True) return self.async_abort(reason=ERROR_CANNOT_CONNECT) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error trying to connect") return self.async_abort(reason=ERROR_UNKNOWN) @@ -134,7 +134,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): except RokuError: _LOGGER.debug("Roku Error", exc_info=True) return self.async_abort(reason=ERROR_CANNOT_CONNECT) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error trying to connect") return self.async_abort(reason=ERROR_UNKNOWN) diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index fc68e82c2d8..ad8bee63b6f 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError @@ -12,11 +12,10 @@ from homeassistant.exceptions import HomeAssistantError from .entity import RokuEntity -_RokuEntityT = TypeVar("_RokuEntityT", bound=RokuEntity) -_P = ParamSpec("_P") - -_FuncType = Callable[Concatenate[_RokuEntityT, _P], Awaitable[Any]] -_ReturnFuncType = Callable[Concatenate[_RokuEntityT, _P], Coroutine[Any, Any, None]] +type _FuncType[_T, **_P] = Callable[Concatenate[_T, _P], Awaitable[Any]] +type _ReturnFuncType[_T, **_P] = Callable[ + Concatenate[_T, _P], Coroutine[Any, Any, None] +] def format_channel_name(channel_number: str, channel_name: str | None = None) -> str: @@ -27,7 +26,7 @@ def format_channel_name(channel_number: str, channel_name: str | None = None) -> return channel_number -def roku_exception_handler( +def roku_exception_handler[_RokuEntityT: RokuEntity, **_P]( ignore_timeout: bool = False, ) -> Callable[[_FuncType[_RokuEntityT, _P]], _ReturnFuncType[_RokuEntityT, _P]]: """Decorate Roku calls to handle Roku exceptions.""" diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index ce4513fb316..fa9823de172 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.19.2"], + "requirements": ["rokuecp==0.19.3"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index d00010aa3e9..f811a2afe03 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -83,7 +83,7 @@ async def async_connect_or_timeout( _LOGGER.debug("Initialize connection to vacuum") await hass.async_add_executor_job(roomba.connect) while not roomba.roomba_connected or name is None: - # Waiting for connection and check datas ready + # Waiting for connection and check data is ready name = roomba_reported_state(roomba).get("name", None) if name: break diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index 2dc0bf71cd4..f555cc52dd1 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -166,7 +166,7 @@ class RoonConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index 073b58160f6..7bc6ea27dd9 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -47,7 +47,7 @@ class RoonEventEntity(EventEntity): """Representation of a Roon Event entity.""" _attr_device_class = EventDeviceClass.BUTTON - _attr_event_types = ["volume_up", "volume_down"] + _attr_event_types = ["volume_up", "volume_down", "mute_toggle"] _attr_translation_key = "volume" def __init__(self, server, player_data): @@ -77,15 +77,17 @@ class RoonEventEntity(EventEntity): ) -> None: """Callbacks from the roon api with volume request.""" - if event != "set_volume": + if event == "set_mute": + event = "mute_toggle" + elif event == "set_volume": + if value > 0: + event = "volume_up" + else: + event = "volume_down" + else: _LOGGER.debug("Received unsupported roon volume event %s", event) return - if value > 0: - event = "volume_up" - else: - event = "volume_down" - self._trigger_event(event) self.schedule_update_ha_state() diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json index a95c6908312..853bcc6c585 100644 --- a/homeassistant/components/roon/strings.json +++ b/homeassistant/components/roon/strings.json @@ -29,7 +29,8 @@ "event_type": { "state": { "volume_up": "Volume up", - "volume_down": "Volume down" + "volume_down": "Volume down", + "mute_toggle": "Mute toggle" } } } diff --git a/homeassistant/components/rova/coordinator.py b/homeassistant/components/rova/coordinator.py index ef411be19e8..ecd91cad823 100644 --- a/homeassistant/components/rova/coordinator.py +++ b/homeassistant/components/rova/coordinator.py @@ -10,6 +10,8 @@ from homeassistant.util.dt import get_time_zone from .const import DOMAIN, LOGGER +EUROPE_AMSTERDAM_ZONE_INFO = get_time_zone("Europe/Amsterdam") + class RovaCoordinator(DataUpdateCoordinator[dict[str, datetime]]): """Class to manage fetching Rova data.""" @@ -33,7 +35,7 @@ class RovaCoordinator(DataUpdateCoordinator[dict[str, datetime]]): for item in items: date = datetime.strptime(item["Date"], "%Y-%m-%dT%H:%M:%S").replace( - tzinfo=get_time_zone("Europe/Amsterdam") + tzinfo=EUROPE_AMSTERDAM_ZONE_INFO ) code = item["GarbageTypeCode"].lower() if code not in data: diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py index 1a75b8ae139..d2f27e4ef05 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -78,7 +78,7 @@ class RuckusUnleashedConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py index 825f57b2cf2..c22f100e87a 100644 --- a/homeassistant/components/ruuvi_gateway/config_flow.py +++ b/homeassistant/components/ruuvi_gateway/config_flow.py @@ -59,7 +59,7 @@ class RuuviConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return (None, errors) diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index a098c263c5d..ef287753ed4 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -142,7 +142,9 @@ async def async_setup_entry( class RuuvitagBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a Ruuvitag BLE sensor.""" diff --git a/homeassistant/components/rympro/config_flow.py b/homeassistant/components/rympro/config_flow.py index f30e47f09a1..be35c48ac5b 100644 --- a/homeassistant/components/rympro/config_flow.py +++ b/homeassistant/components/rympro/config_flow.py @@ -67,7 +67,7 @@ class RymproConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except UnauthorizedError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index e14ac9af71f..046e778f05b 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.7"] + "requirements": ["pyrympro==0.0.8"] } diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index ebb9284a7f2..a827e9a36a4 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -20,8 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.typing import ConfigType @@ -121,7 +120,7 @@ def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) def update_device_identifiers(hass: HomeAssistant, entry: ConfigEntry): """Update device identifiers to new identifiers.""" - device_registry = async_get(hass) + device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DOMAIN)}) if device_entry and entry.entry_id in device_entry.config_entries: new_identifiers = {(DOMAIN, entry.entry_id)} diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 538bd2475dd..992c86d5d7e 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -10,7 +10,7 @@ from urllib.parse import urlparse import getmac from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -49,12 +49,13 @@ from .const import ( UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) +from .coordinator import SamsungTVDataUpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -SamsungTVConfigEntry = ConfigEntry[SamsungTVBridge] +SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator] @callback @@ -135,6 +136,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> ) bridge = await _async_create_bridge_with_updated_data(hass, entry) + @callback + def _access_denied() -> None: + """Access denied callback.""" + LOGGER.debug("Access denied in getting remote object") + hass.create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + ) + + bridge.register_reauth_callback(_access_denied) + # Ensure updates get saved against the config_entry @callback def _update_config_entry(updates: Mapping[str, Any]) -> None: @@ -143,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> bridge.register_update_config_entry_callback(_update_config_entry) - async def stop_bridge(event: Event) -> None: + async def stop_bridge(event: Event | None = None) -> None: """Stop SamsungTV bridge connection.""" LOGGER.debug("Stopping SamsungTVBridge %s", bridge.host) await bridge.async_close_remote() @@ -151,6 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) ) + entry.async_on_unload(stop_bridge) await _async_update_ssdp_locations(hass, entry) @@ -161,7 +180,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> entry.async_on_unload(debounced_reloader.async_shutdown) entry.async_on_unload(entry.add_update_listener(debounced_reloader.async_call)) - entry.runtime_data = bridge + coordinator = SamsungTVDataUpdateCoordinator(hass, bridge) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -252,19 +273,15 @@ async def _async_create_bridge_with_updated_data( async def async_unload_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - bridge = entry.runtime_data - LOGGER.debug("Stopping SamsungTVBridge %s", bridge.host) - await bridge.async_close_remote() - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" version = config_entry.version + minor_version = config_entry.minor_version - LOGGER.debug("Migrating from version %s", version) + LOGGER.debug("Migrating from version %s.%s", version, minor_version) # 1 -> 2: Unique ID format changed, so delete and re-import: if version == 1: @@ -277,6 +294,14 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> version = 2 hass.config_entries.async_update_entry(config_entry, version=2) - LOGGER.debug("Migration to version %s successful", version) + if version == 2: + if minor_version < 2: + # Cleanup invalid MAC addresses - see #103512 + # Reverted due to device registry collisions - see #119082 / #119249 + + minor_version = 2 + hass.config_entries.async_update_entry(config_entry, minor_version=2) + + LOGGER.debug("Migration to version %s.%s successful", version, minor_version) return True diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 817437ef4d6..059c6682857 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -8,7 +8,7 @@ from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from collections.abc import Callable, Iterable, Mapping import contextlib from datetime import datetime, timedelta -from typing import Any, Generic, TypeVar, cast +from typing import Any, cast from samsungctl import Remote from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse @@ -85,9 +85,6 @@ ENCRYPTED_MODEL_USES_POWER = {"JU6400", "JU641D"} REST_EXCEPTIONS = (HttpApiError, AsyncioTimeoutError, ResponseError) -_RemoteT = TypeVar("_RemoteT", SamsungTVWSAsyncRemote, SamsungTVEncryptedWSAsyncRemote) -_CommandT = TypeVar("_CommandT", SamsungTVCommand, SamsungTVEncryptedCommand) - def mac_from_device_info(info: dict[str, Any]) -> str | None: """Extract the mac address from the device info.""" @@ -168,6 +165,7 @@ class SamsungTVBridge(ABC): self.host = host self.token: str | None = None self.session_id: str | None = None + self.auth_failed: bool = False self._reauth_callback: CALLBACK_TYPE | None = None self._update_config_entry: Callable[[Mapping[str, Any]], None] | None = None self._app_list_callback: Callable[[dict[str, str]], None] | None = None @@ -327,6 +325,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge): """Try to gather infos of this device.""" return None + def _notify_reauth_callback(self) -> None: + """Notify access denied callback.""" + if self._reauth_callback is not None: + self.hass.loop.call_soon_threadsafe(self._reauth_callback) + def _get_remote(self) -> Remote: """Create or return a remote control instance.""" if self._remote is None: @@ -338,6 +341,7 @@ class SamsungTVLegacyBridge(SamsungTVBridge): # A removed auth will lead to socket timeout because waiting # for auth popup is just an open socket except AccessDenied: + self.auth_failed = True self._notify_reauth_callback() raise except (ConnectionClosed, OSError): @@ -393,7 +397,10 @@ class SamsungTVLegacyBridge(SamsungTVBridge): LOGGER.debug("Could not establish connection") -class SamsungTVWSBaseBridge(SamsungTVBridge, Generic[_RemoteT, _CommandT]): +class SamsungTVWSBaseBridge[ + _RemoteT: (SamsungTVWSAsyncRemote, SamsungTVEncryptedWSAsyncRemote), + _CommandT: (SamsungTVCommand, SamsungTVEncryptedCommand), +](SamsungTVBridge): """The Bridge for WebSocket TVs (v1/v2).""" def __init__( @@ -607,6 +614,7 @@ class SamsungTVWSBridge( self.host, repr(err), ) + self.auth_failed = True self._notify_reauth_callback() self._remote = None except ConnectionClosedError as err: diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 4845fb4fb74..e89c5e59b0e 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -101,6 +101,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Samsung TV config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize flow.""" diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py new file mode 100644 index 00000000000..92d8dc8fa84 --- /dev/null +++ b/homeassistant/components/samsungtv/coordinator.py @@ -0,0 +1,50 @@ +"""Coordinator for the SamsungTV integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from datetime import timedelta +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .bridge import SamsungTVBridge +from .const import DOMAIN, LOGGER + +SCAN_INTERVAL = 10 + + +class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Coordinator for the SamsungTV integration.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, bridge: SamsungTVBridge) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + + self.bridge = bridge + self.is_on: bool | None = False + self.async_extra_update: Callable[[], Coroutine[Any, Any, None]] | None = None + + async def _async_update_data(self) -> None: + """Fetch data from SamsungTV bridge.""" + if self.bridge.auth_failed or self.hass.is_stopping: + return + old_state = self.is_on + if self.bridge.power_off_in_progress: + self.is_on = False + else: + self.is_on = await self.bridge.async_is_on() + if self.is_on != old_state: + LOGGER.debug("TV %s state updated to %s", self.bridge.host, self.is_on) + + if self.async_extra_update: + await self.async_extra_update() diff --git a/homeassistant/components/samsungtv/diagnostics.py b/homeassistant/components/samsungtv/diagnostics.py index a0da9a59261..ebca8d2543b 100644 --- a/homeassistant/components/samsungtv/diagnostics.py +++ b/homeassistant/components/samsungtv/diagnostics.py @@ -18,8 +18,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: SamsungTVConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - bridge = entry.runtime_data + coordinator = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "device_info": await bridge.async_device_info(), + "device_info": await coordinator.bridge.async_device_info(), } diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index ee2f50716eb..030eaf98d9b 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -2,31 +2,40 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from wakeonlan import send_magic_packet + from homeassistant.const import ( ATTR_CONNECTIONS, ATTR_IDENTIFIERS, + CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from homeassistant.helpers.trigger import PluggableAction +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .bridge import SamsungTVBridge -from .const import CONF_MANUFACTURER, DOMAIN +from .const import CONF_MANUFACTURER, DOMAIN, LOGGER +from .coordinator import SamsungTVDataUpdateCoordinator +from .triggers.turn_on import async_get_turn_on_trigger -class SamsungTVEntity(Entity): +class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity): """Defines a base SamsungTV entity.""" _attr_has_entity_name = True - def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> None: + def __init__(self, *, coordinator: SamsungTVDataUpdateCoordinator) -> None: """Initialize the SamsungTV entity.""" - self._bridge = bridge - self._mac = config_entry.data.get(CONF_MAC) + super().__init__(coordinator) + self._bridge = coordinator.bridge + config_entry = coordinator.config_entry + self._mac: str | None = config_entry.data.get(CONF_MAC) + self._host: str | None = config_entry.data.get(CONF_HOST) # Fallback for legacy models that doesn't have a API to retrieve MAC or SerialNumber self._attr_unique_id = config_entry.unique_id or config_entry.entry_id self._attr_device_info = DeviceInfo( @@ -40,3 +49,61 @@ class SamsungTVEntity(Entity): self._attr_device_info[ATTR_CONNECTIONS] = { (dr.CONNECTION_NETWORK_MAC, self._mac) } + self._turn_on_action = PluggableAction(self.async_write_ha_state) + + @property + def available(self) -> bool: + """Return the availability of the device.""" + if self._bridge.auth_failed: + return False + return ( + self.coordinator.is_on + or bool(self._turn_on_action) + or self._mac is not None + or self._bridge.power_off_in_progress + ) + + async def async_added_to_hass(self) -> None: + """Connect and subscribe to dispatcher signals and state updates.""" + await super().async_added_to_hass() + + if (entry := self.registry_entry) and entry.device_id: + self.async_on_remove( + self._turn_on_action.async_register( + self.hass, async_get_turn_on_trigger(entry.device_id) + ) + ) + + def _wake_on_lan(self) -> None: + """Wake the device via wake on lan.""" + send_magic_packet(self._mac, ip_address=self._host) + # If the ip address changed since we last saw the device + # broadcast a packet as well + send_magic_packet(self._mac) + + async def _async_turn_off(self) -> None: + """Turn the device off.""" + await self._bridge.async_power_off() + await self.coordinator.async_refresh() + + async def _async_turn_on(self) -> None: + """Turn the remote on.""" + if self._turn_on_action: + LOGGER.debug("Attempting to turn on %s via automation", self.entity_id) + await self._turn_on_action.async_run(self.hass, self._context) + elif self._mac: + LOGGER.info( + "Attempting to turn on %s via Wake-On-Lan; if this does not work, " + "please ensure that Wake-On-Lan is available for your device or use " + "a turn_on automation", + self.entity_id, + ) + await self.hass.async_add_executor_job(self._wake_on_lan) + else: + LOGGER.error( + "Unable to turn on %s, as it does not have an automation configured", + self.entity_id, + ) + raise HomeAssistantError( + f"Entity {self.entity_id} does not support this service." + ) diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py index f7d49f5e8cc..4e8dd00d486 100644 --- a/homeassistant/components/samsungtv/helpers.py +++ b/homeassistant/components/samsungtv/helpers.py @@ -7,6 +7,7 @@ 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 . import SamsungTVConfigEntry from .bridge import SamsungTVBridge from .const import DOMAIN @@ -53,15 +54,11 @@ def async_get_client_by_device_entry( Raises ValueError if client is not found. """ + entry: SamsungTVConfigEntry | None for config_entry_id in device.config_entries: entry = hass.config_entries.async_get_entry(config_entry_id) - if ( - entry - and entry.state == ConfigEntryState.LOADED - and hasattr(entry, "runtime_data") - and isinstance(entry.runtime_data, SamsungTVBridge) - ): - return entry.runtime_data + if entry and entry.domain == DOMAIN and entry.state is ConfigEntryState.LOADED: + return entry.runtime_data.bridge raise ValueError( f"Device {device.id} is not from an existing {DOMAIN} config entry" diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index f227684c016..960b69f71e3 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -20,7 +20,6 @@ from async_upnp_client.exceptions import ( from async_upnp_client.profiles.dlna import DmrDevice from async_upnp_client.utils import async_get_local_ip import voluptuous as vol -from wakeonlan import send_magic_packet from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -29,20 +28,17 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.trigger import PluggableAction from homeassistant.util.async_ import create_eager_task from . import SamsungTVConfigEntry -from .bridge import SamsungTVBridge, SamsungTVWSBridge -from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, LOGGER +from .bridge import SamsungTVWSBridge +from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, LOGGER +from .coordinator import SamsungTVDataUpdateCoordinator from .entity import SamsungTVEntity -from .triggers.turn_on import async_get_turn_on_trigger SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -71,8 +67,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Samsung TV from a config entry.""" - bridge = entry.runtime_data - async_add_entities([SamsungTVDevice(bridge, entry)], True) + coordinator = entry.runtime_data + async_add_entities([SamsungTVDevice(coordinator)]) class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): @@ -82,19 +78,12 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): _attr_name = None _attr_device_class = MediaPlayerDeviceClass.TV - def __init__( - self, - bridge: SamsungTVBridge, - config_entry: ConfigEntry, - ) -> None: + def __init__(self, coordinator: SamsungTVDataUpdateCoordinator) -> None: """Initialize the Samsung device.""" - super().__init__(bridge=bridge, config_entry=config_entry) - self._config_entry = config_entry - self._host: str | None = config_entry.data[CONF_HOST] - self._ssdp_rendering_control_location: str | None = config_entry.data.get( - CONF_SSDP_RENDERING_CONTROL_LOCATION + super().__init__(coordinator=coordinator) + self._ssdp_rendering_control_location: str | None = ( + coordinator.config_entry.data.get(CONF_SSDP_RENDERING_CONTROL_LOCATION) ) - self._turn_on = PluggableAction(self.async_write_ha_state) # Assume that the TV is in Play mode self._playing: bool = True @@ -111,8 +100,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET - self._auth_failed = False - self._bridge.register_reauth_callback(self.access_denied) self._bridge.register_app_list_callback(self._app_list_callback) self._dmr_device: DmrDevice | None = None @@ -123,7 +110,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Flag media player features that are supported.""" # `turn_on` triggers are not yet registered during initialisation, # so this property needs to be dynamic - if self._turn_on: + if self._turn_on_action: return self._attr_supported_features | MediaPlayerEntityFeature.TURN_ON return self._attr_supported_features @@ -138,42 +125,35 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): self._update_sources() self._app_list_event.set() - def access_denied(self) -> None: - """Access denied callback.""" - LOGGER.debug("Access denied in getting remote object") - self._auth_failed = True - self.hass.create_task( - self.hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": SOURCE_REAUTH, - "entry_id": self._config_entry.entry_id, - }, - data=self._config_entry.data, - ) - ) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._async_extra_update() + self.coordinator.async_extra_update = self._async_extra_update + if self.coordinator.is_on: + self._attr_state = MediaPlayerState.ON + self._update_from_upnp() + else: + self._attr_state = MediaPlayerState.OFF async def async_will_remove_from_hass(self) -> None: """Handle removal.""" + self.coordinator.async_extra_update = None await self._async_shutdown_dmr() - async def async_update(self) -> None: - """Update state of device.""" - if self._auth_failed or self.hass.is_stopping: - return - old_state = self._attr_state - if self._bridge.power_off_in_progress: - self._attr_state = MediaPlayerState.OFF + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + if self.coordinator.is_on: + self._attr_state = MediaPlayerState.ON + self._update_from_upnp() else: - self._attr_state = ( - MediaPlayerState.ON - if await self._bridge.async_is_on() - else MediaPlayerState.OFF - ) - if self._attr_state != old_state: - LOGGER.debug("TV %s state updated to %s", self._host, self.state) + self._attr_state = MediaPlayerState.OFF + self.async_write_ha_state() - if self._attr_state != MediaPlayerState.ON: + async def _async_extra_update(self) -> None: + """Update state of device.""" + if not self.coordinator.is_on: if self._dmr_device and self._dmr_device.is_subscribed: await self._dmr_device.async_unsubscribe_services() return @@ -191,8 +171,6 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): if startup_tasks: await asyncio.gather(*startup_tasks) - self._update_from_upnp() - @callback def _update_from_upnp(self) -> bool: # Upnp events can affect other attributes that we currently do not track @@ -319,32 +297,9 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): return await self._bridge.async_send_keys(keys) - @property - def available(self) -> bool: - """Return the availability of the device.""" - if self._auth_failed: - return False - return ( - self.state == MediaPlayerState.ON - or bool(self._turn_on) - or self._mac is not None - or self._bridge.power_off_in_progress - ) - - async def async_added_to_hass(self) -> None: - """Connect and subscribe to dispatcher signals and state updates.""" - await super().async_added_to_hass() - - if (entry := self.registry_entry) and entry.device_id: - self.async_on_remove( - self._turn_on.async_register( - self.hass, async_get_turn_on_trigger(entry.device_id) - ) - ) - async def async_turn_off(self) -> None: """Turn off media player.""" - await self._bridge.async_power_off() + await super()._async_turn_off() async def async_set_volume_level(self, volume: float) -> None: """Set volume level on the media player.""" @@ -416,19 +371,9 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): keys=[f"KEY_{digit}" for digit in media_id] + ["KEY_ENTER"] ) - def _wake_on_lan(self) -> None: - """Wake the device via wake on lan.""" - send_magic_packet(self._mac, ip_address=self._host) - # If the ip address changed since we last saw the device - # broadcast a packet as well - send_magic_packet(self._mac) - async def async_turn_on(self) -> None: """Turn the media player on.""" - if self._turn_on: - await self._turn_on.async_run(self.hass, self._context) - elif self._mac: - await self.hass.async_add_executor_job(self._wake_on_lan) + await super()._async_turn_on() async def async_select_source(self, source: str) -> None: """Select input source.""" diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index c65bf17240b..afbac341226 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SamsungTVConfigEntry @@ -20,19 +20,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Samsung TV from a config entry.""" - bridge = entry.runtime_data - async_add_entities([SamsungTVRemote(bridge=bridge, config_entry=entry)]) + coordinator = entry.runtime_data + async_add_entities([SamsungTVRemote(coordinator=coordinator)]) class SamsungTVRemote(SamsungTVEntity, RemoteEntity): """Device that sends commands to a SamsungTV.""" _attr_name = None - _attr_should_poll = False + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._attr_is_on = self.coordinator.is_on + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self._bridge.async_power_off() + await super()._async_turn_off() async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device. @@ -49,3 +54,7 @@ class SamsungTVRemote(SamsungTVEntity, RemoteEntity): for _ in range(num_repeats): await self._bridge.async_send_keys(command_list) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the remote on.""" + await super()._async_turn_on() diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index bce2c2c6a5d..f9e261b25b1 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -8,8 +8,11 @@ import logging from satel_integra.satel_integra import AlarmState -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -59,10 +62,10 @@ async def async_setup_platform( async_add_entities(devices) -class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): +class SatelIntegraAlarmPanel(AlarmControlPanelEntity): """Representation of an AlarmDecoder-based alarm panel.""" - _attr_code_format = alarm.CodeFormat.NUMBER + _attr_code_format = CodeFormat.NUMBER _attr_should_poll = False _attr_state: str | None _attr_supported_features = ( diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index b668ced326c..209b6c38cda 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -41,7 +41,7 @@ async def async_setup_platform( zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] device = SatelIntegraBinarySensor( - controller, zone_num, zone_name, zone_type, SIGNAL_ZONES_UPDATED + controller, zone_num, zone_name, zone_type, CONF_ZONES, SIGNAL_ZONES_UPDATED ) devices.append(device) @@ -51,7 +51,12 @@ async def async_setup_platform( zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] device = SatelIntegraBinarySensor( - controller, zone_num, zone_name, zone_type, SIGNAL_OUTPUTS_UPDATED + controller, + zone_num, + zone_name, + zone_type, + CONF_OUTPUTS, + SIGNAL_OUTPUTS_UPDATED, ) devices.append(device) @@ -64,10 +69,17 @@ class SatelIntegraBinarySensor(BinarySensorEntity): _attr_should_poll = False def __init__( - self, controller, device_number, device_name, zone_type, react_to_signal + self, + controller, + device_number, + device_name, + zone_type, + sensor_type, + react_to_signal, ): """Initialize the binary_sensor.""" self._device_number = device_number + self._attr_unique_id = f"satel_{sensor_type}_{device_number}" self._name = device_name self._zone_type = zone_type self._state = 0 diff --git a/homeassistant/components/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py index 217cacedc41..a6104702396 100644 --- a/homeassistant/components/schlage/config_flow.py +++ b/homeassistant/components/schlage/config_flow.py @@ -104,7 +104,7 @@ def _authenticate(username: str, password: str) -> tuple[str | None, dict[str, s auth.authenticate() except NotAuthorizedError: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unknown error") errors["base"] = "unknown" else: diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 23b36ddae0b..c6dfc443bb8 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==2024.2.0"] + "requirements": ["pyschlage==2024.6.0"] } diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 3906f5cf306..16220d5c567 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -31,6 +31,8 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import ScrapeCoordinator +type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator] + SENSOR_SCHEMA = vol.Schema( { **TEMPLATE_SENSOR_BASE_SCHEMA.schema, @@ -90,7 +92,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool: """Set up Scrape from a config entry.""" rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(entry.options)) @@ -102,7 +104,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DEFAULT_SCAN_INTERVAL, ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -112,11 +114,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Scrape config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 61d58ea7bc5..ceaf1e63a9d 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, CONF_DEVICE_CLASS, @@ -34,6 +33,7 @@ from homeassistant.helpers.trigger_template_entity import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import ScrapeConfigEntry from .const import CONF_INDEX, CONF_SELECT, DOMAIN from .coordinator import ScrapeCoordinator @@ -94,12 +94,14 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ScrapeConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Scrape sensor entry.""" entities: list = [] - coordinator: ScrapeCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data config = dict(entry.options) for sensor in config["sensor"]: sensor_config: ConfigType = vol.Schema( diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index 31e8468240f..a40b5415fe3 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.util import slugify -ScreenLogicDataPath = tuple[str | int, ...] +type ScreenLogicDataPath = tuple[str | int, ...] DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 76640339040..ca75f5fadce 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -5,12 +5,14 @@ import logging from screenlogicpy.const.common import ScreenLogicCommunicationError, ScreenLogicError from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.number import ( DOMAIN, NumberEntity, NumberEntityDescription, + NumberMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -20,7 +22,12 @@ 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, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, +) from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) @@ -36,6 +43,45 @@ class ScreenLogicNumberDescription( """Describes a ScreenLogic number entity.""" +@dataclass(frozen=True, kw_only=True) +class ScreenLogicPushNumberDescription( + ScreenLogicNumberDescription, + ScreenLogicPushEntityDescription, +): + """Describes a ScreenLogic push number entity.""" + + +SUPPORTED_INTELLICHEM_NUMBERS = [ + ScreenLogicPushNumberDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CALCIUM_HARDNESS, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + ), + ScreenLogicPushNumberDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.CYA, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + ), + ScreenLogicPushNumberDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.TOTAL_ALKALINITY, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + ), + ScreenLogicPushNumberDescription( + subscription_code=CODE.CHEMISTRY_CHANGED, + data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), + key=VALUE.SALT_TDS_PPM, + entity_category=EntityCategory.CONFIG, + mode=NumberMode.BOX, + ), +] + SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( data_root=(DEVICE.SCG, GROUP.CONFIGURATION), @@ -62,6 +108,19 @@ async def async_setup_entry( ] gateway = coordinator.gateway + for chem_number_description in SUPPORTED_INTELLICHEM_NUMBERS: + chem_number_data_path = ( + *chem_number_description.data_root, + chem_number_description.key, + ) + if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: + cleanup_excluded_entity(coordinator, DOMAIN, chem_number_data_path) + continue + if gateway.get_data(*chem_number_data_path): + entities.append( + ScreenLogicChemistryNumber(coordinator, chem_number_description) + ) + for scg_number_description in SUPPORTED_SCG_NUMBERS: scg_number_data_path = ( *scg_number_description.data_root, @@ -115,6 +174,31 @@ class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): raise NotImplementedError +class ScreenLogicPushNumber(ScreenLogicPushEntity, ScreenLogicNumber): + """Base class to preresent a ScreenLogic Push Number entity.""" + + entity_description: ScreenLogicPushNumberDescription + + +class ScreenLogicChemistryNumber(ScreenLogicPushNumber): + """Class to represent a ScreenLogic Chemistry Number entity.""" + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + + # Current API requires int values for the currently supported numbers. + value = int(value) + + try: + await self.gateway.async_set_chem_data(**{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() + + class ScreenLogicSCGNumber(ScreenLogicNumber): """Class to represent a ScreenLoigic SCG Number entity.""" diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index e4fc86a6b5f..1a09f3c738a 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -136,11 +136,13 @@ SUPPORTED_INTELLICHEM_SENSORS = [ subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.CALCIUM_HARDNESS, + entity_registry_enabled_default=False, # Superseded by number entity ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.CYA, + entity_registry_enabled_default=False, # Superseded by number entity ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -156,11 +158,13 @@ SUPPORTED_INTELLICHEM_SENSORS = [ subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.TOTAL_ALKALINITY, + entity_registry_enabled_default=False, # Superseded by number entity ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), key=VALUE.SALT_TDS_PPM, + entity_registry_enabled_default=False, # Superseded by number entity ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index f83aed68590..f19a48fea33 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -609,6 +609,15 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): ) coro = self._async_run(variables, context) if wait: + # If we are executing in parallel, we need to copy the script stack so + # that if this script is called in parallel, it will not be seen in the + # stack of the other parallel calls and hit the disallowed recursion + # check as each parallel call would otherwise be appending to the same + # stack. We do not wipe the stack in this case because we still want to + # be able to detect if there is a disallowed recursion. + if script_stack := script_stack_cv.get(): + script_stack_cv.set(script_stack.copy()) + script_result = await coro return script_result.service_response if script_result else None @@ -708,7 +717,7 @@ def websocket_config( if script is None: connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" ) return diff --git a/homeassistant/components/scsgate/__init__.py b/homeassistant/components/scsgate/__init__.py index 7f00f8abe84..db96ccb688a 100644 --- a/homeassistant/components/scsgate/__init__.py +++ b/homeassistant/components/scsgate/__init__.py @@ -37,7 +37,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: scsgate = SCSGate(device=device, logger=_LOGGER) scsgate.start() - except Exception as exception: # pylint: disable=broad-except + except Exception as exception: # noqa: BLE001 _LOGGER.error("Cannot setup SCSGate component: %s", exception) return False @@ -94,7 +94,7 @@ class SCSGate: try: self._devices[message.entity].process_event(message) - except Exception as exception: # pylint: disable=broad-except + except Exception as exception: # noqa: BLE001 msg = f"Exception while processing event: {exception}" self._logger.error(msg) else: diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 9c9d1136b99..02c1765133a 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -13,6 +13,13 @@ }, "condition_type": { "selected_option": "Current {entity_name} selected option" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]", + "to": "[%key:common::device_automation::extra_fields::to%]", + "cycle": "Cycle", + "from": "From", + "option": "Option" } }, "entity_component": { diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index 9d909730f5a..28408c0cb7d 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -1,7 +1,9 @@ """Support for monitoring a Sense energy sensor.""" +from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from sense_energy import ( ASyncSenseable, @@ -21,24 +23,20 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ACTIVE_UPDATE_RATE, - DOMAIN, SENSE_CONNECT_EXCEPTIONS, - SENSE_DATA, SENSE_DEVICE_UPDATE, - SENSE_DEVICES_DATA, - SENSE_DISCOVERED_DEVICES_DATA, SENSE_TIMEOUT_EXCEPTIONS, - SENSE_TRENDS_COORDINATOR, SENSE_WEBSOCKET_EXCEPTIONS, ) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +type SenseConfigEntry = ConfigEntry[SenseData] class SenseDevicesData: @@ -57,7 +55,17 @@ class SenseDevicesData: return self._data_by_device.get(sense_device_id) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +@dataclass(kw_only=True, slots=True) +class SenseData: + """Sense data type.""" + + data: ASyncSenseable + device_data: SenseDevicesData + trends: DataUpdateCoordinator[None] + discovered: list[dict[str, Any]] + + +async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> bool: """Set up Sense from a config entry.""" entry_data = entry.data @@ -91,7 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SENSE_CONNECT_EXCEPTIONS as err: raise ConfigEntryNotReady(str(err)) from err - sense_devices_data = SenseDevicesData() try: sense_discovered_devices = await gateway.get_discovered_device_data() await gateway.update_realtime() @@ -109,6 +116,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (SenseAuthenticationException, SenseMFARequiredException) as err: _LOGGER.warning("Sense authentication expired") raise ConfigEntryAuthFailed(err) from err + except SENSE_CONNECT_EXCEPTIONS as err: + raise UpdateFailed(err) from err trends_coordinator: DataUpdateCoordinator[None] = DataUpdateCoordinator( hass, @@ -130,12 +139,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "sense.trends-coordinator-refresh", ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - SENSE_DATA: gateway, - SENSE_DEVICES_DATA: sense_devices_data, - SENSE_TRENDS_COORDINATOR: trends_coordinator, - SENSE_DISCOVERED_DEVICES_DATA: sense_discovered_devices, - } + entry.runtime_data = SenseData( + data=gateway, + device_data=SenseDevicesData(), + trends=trends_coordinator, + discovered=sense_discovered_devices, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -150,7 +159,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = gateway.get_realtime() if "devices" in data: - sense_devices_data.set_devices_data(data["devices"]) + entry.runtime_data.device_data.set_devices_data(data["devices"]) async_dispatcher_send(hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}") remove_update_callback = async_track_time_interval( @@ -171,9 +180,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 7dde4c029b1..5640dd19961 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -6,40 +6,29 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTRIBUTION, - DOMAIN, - MDI_ICONS, - SENSE_DATA, - SENSE_DEVICE_UPDATE, - SENSE_DEVICES_DATA, - SENSE_DISCOVERED_DEVICES_DATA, -) +from . import SenseConfigEntry +from .const import ATTRIBUTION, DOMAIN, MDI_ICONS, SENSE_DEVICE_UPDATE _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SenseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Sense binary sensor.""" - data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] - sense_devices_data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DEVICES_DATA] - sense_monitor_id = data.sense_monitor_id + sense_monitor_id = config_entry.runtime_data.data.sense_monitor_id - sense_devices = hass.data[DOMAIN][config_entry.entry_id][ - SENSE_DISCOVERED_DEVICES_DATA - ] + sense_devices = config_entry.runtime_data.discovered + device_data = config_entry.runtime_data.device_data devices = [ - SenseDevice(sense_devices_data, device, sense_monitor_id) + SenseDevice(device_data, device, sense_monitor_id) for device in sense_devices if device["tags"]["DeviceListAllowed"] == "true" ] diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py index e5880675d2b..25c6898aec8 100644 --- a/homeassistant/components/sense/config_flow.py +++ b/homeassistant/components/sense/config_flow.py @@ -81,7 +81,7 @@ class SenseConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except SenseAuthenticationException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -98,7 +98,7 @@ class SenseConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except SenseAuthenticationException: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index 3ad35ff345d..5e944c18d8d 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -12,11 +12,7 @@ DOMAIN = "sense" DEFAULT_TIMEOUT = 30 ACTIVE_UPDATE_RATE = 60 DEFAULT_NAME = "Sense" -SENSE_DATA = "sense_data" SENSE_DEVICE_UPDATE = "sense_devices_update" -SENSE_DEVICES_DATA = "sense_devices_data" -SENSE_DISCOVERED_DEVICES_DATA = "sense_discovered_devices" -SENSE_TRENDS_COORDINATOR = "sense_trends_coordinator" ACTIVE_NAME = "Energy" ACTIVE_TYPE = "active" diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 199bae43701..129b1262fd0 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -5,7 +5,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfElectricPotential, @@ -18,6 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import SenseConfigEntry from .const import ( ACTIVE_NAME, ACTIVE_TYPE, @@ -34,11 +34,7 @@ from .const import ( PRODUCTION_NAME, PRODUCTION_PCT_ID, PRODUCTION_PCT_NAME, - SENSE_DATA, SENSE_DEVICE_UPDATE, - SENSE_DEVICES_DATA, - SENSE_DISCOVERED_DEVICES_DATA, - SENSE_TRENDS_COORDINATOR, SOLAR_POWERED_ID, SOLAR_POWERED_NAME, TO_GRID_ID, @@ -87,26 +83,23 @@ def sense_to_mdi(sense_icon): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SenseConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Sense sensor.""" - base_data = hass.data[DOMAIN][config_entry.entry_id] - data = base_data[SENSE_DATA] - sense_devices_data = base_data[SENSE_DEVICES_DATA] - trends_coordinator = base_data[SENSE_TRENDS_COORDINATOR] + data = config_entry.runtime_data.data + trends_coordinator = config_entry.runtime_data.trends # Request only in case it takes longer # than 60s await trends_coordinator.async_request_refresh() sense_monitor_id = data.sense_monitor_id - sense_devices = hass.data[DOMAIN][config_entry.entry_id][ - SENSE_DISCOVERED_DEVICES_DATA - ] + sense_devices = config_entry.runtime_data.discovered + device_data = config_entry.runtime_data.device_data entities: list[SensorEntity] = [ - SenseEnergyDevice(sense_devices_data, device, sense_monitor_id) + SenseEnergyDevice(device_data, device, sense_monitor_id) for device in sense_devices if device["tags"]["DeviceListAllowed"] == "true" ] diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 5a7e09f539e..b2b6ac15958 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -15,7 +15,7 @@ from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import SensiboDataUpdateCoordinator from .util import NoDevicesError, NoUsernameError, async_validate_api -SensiboConfigEntry = ConfigEntry["SensiboDataUpdateCoordinator"] +type SensiboConfigEntry = ConfigEntry[SensiboDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool: diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 97ef4dffca7..b13a5f82111 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate from pysensibo.model import MotionSensor, SensiboDevice @@ -15,11 +15,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER, SENSIBO_ERRORS, TIMEOUT from .coordinator import SensiboDataUpdateCoordinator -_T = TypeVar("_T", bound="SensiboDeviceBaseEntity") -_P = ParamSpec("_P") - -def async_handle_api_call( +def async_handle_api_call[_T: SensiboDeviceBaseEntity, **_P]( function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: """Decorate api calls.""" diff --git a/homeassistant/components/sensirion_ble/sensor.py b/homeassistant/components/sensirion_ble/sensor.py index 2ca5a524c8f..a7254fd3609 100644 --- a/homeassistant/components/sensirion_ble/sensor.py +++ b/homeassistant/components/sensirion_ble/sensor.py @@ -122,7 +122,9 @@ async def async_setup_entry( class SensirionBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a Sensirion BLE sensor.""" diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index ffe324fc8c4..8d81df6431f 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -68,7 +68,6 @@ from homeassistant.helpers.typing import UNDEFINED, ConfigType, StateType, Undef from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from . import group as group_pre_import # noqa: F401 from .const import ( # noqa: F401 _DEPRECATED_STATE_CLASS_MEASUREMENT, _DEPRECATED_STATE_CLASS_TOTAL, @@ -384,15 +383,9 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): if not self._invalid_suggested_unit_of_measurement_reported: self._invalid_suggested_unit_of_measurement_reported = True - report_issue = self._suggest_report_issue() - # This should raise in Home Assistant Core 2024.5 - _LOGGER.warning( - ( - "%s sets an invalid suggested_unit_of_measurement. Please %s. " - "This warning will become an error in Home Assistant Core 2024.5" - ), - type(self), - report_issue, + raise ValueError( + f"Entity {type(self)} suggest an incorrect " + f"unit of measurement: {suggested_unit_of_measurement}." ) return False @@ -787,10 +780,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): display_precision = max(0, display_precision + ratio_log) sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) - if ( - "suggested_display_precision" in sensor_options - and sensor_options["suggested_display_precision"] == display_precision - ): + if "suggested_display_precision" not in sensor_options: + if display_precision is None: + return + elif sensor_options["suggested_display_precision"] == display_precision: return registry = er.async_get(self.hass) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index cc89908f00d..5acf2ecef23 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -18,6 +18,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -46,6 +47,7 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, + ConductivityConverter, DataRateConverter, DistanceConverter, DurationConverter, @@ -137,6 +139,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `ppm` (parts per million) """ + CONDUCTIVITY = "conductivity" + """Conductivity. + + Unit of measurement: `S/cm`, `mS/cm`, `µS/cm` + """ + CURRENT = "current" """Current. @@ -485,6 +493,7 @@ STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, + SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.DATA_RATE: DataRateConverter, SensorDeviceClass.DATA_SIZE: InformationConverter, @@ -517,6 +526,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.BATTERY: {PERCENTAGE}, SensorDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, + SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), SensorDeviceClass.CURRENT: set(UnitOfElectricCurrent), SensorDeviceClass.DATA_RATE: set(UnitOfDataRate), SensorDeviceClass.DATA_SIZE: set(UnitOfInformation), @@ -591,6 +601,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CO: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CO2: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.CONDUCTIVITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CURRENT: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.DATA_RATE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.DATA_SIZE: set(SensorStateClass), diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index fb605d9419c..21258db2ac5 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -41,6 +41,7 @@ CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure" CONF_IS_BATTERY_LEVEL = "is_battery_level" CONF_IS_CO = "is_carbon_monoxide" CONF_IS_CO2 = "is_carbon_dioxide" +CONF_IS_CONDUCTIVITY = "is_conductivity" CONF_IS_CURRENT = "is_current" CONF_IS_DATA_RATE = "is_data_rate" CONF_IS_DATA_SIZE = "is_data_size" @@ -90,6 +91,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}], SensorDeviceClass.CO: [{CONF_TYPE: CONF_IS_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_IS_CO2}], + SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_IS_CONDUCTIVITY}], SensorDeviceClass.CURRENT: [{CONF_TYPE: CONF_IS_CURRENT}], SensorDeviceClass.DATA_RATE: [{CONF_TYPE: CONF_IS_DATA_RATE}], SensorDeviceClass.DATA_SIZE: [{CONF_TYPE: CONF_IS_DATA_SIZE}], @@ -153,6 +155,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_BATTERY_LEVEL, CONF_IS_CO, CONF_IS_CO2, + CONF_IS_CONDUCTIVITY, CONF_IS_CURRENT, CONF_IS_DATA_RATE, CONF_IS_DATA_SIZE, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index b46f6260285..0ffc42127bc 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -40,6 +40,7 @@ CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" CONF_BATTERY_LEVEL = "battery_level" CONF_CO = "carbon_monoxide" CONF_CO2 = "carbon_dioxide" +CONF_CONDUCTIVITY = "conductivity" CONF_CURRENT = "current" CONF_DATA_RATE = "data_rate" CONF_DATA_SIZE = "data_size" @@ -89,6 +90,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}], SensorDeviceClass.CO: [{CONF_TYPE: CONF_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_CO2}], + SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_CONDUCTIVITY}], SensorDeviceClass.CURRENT: [{CONF_TYPE: CONF_CURRENT}], SensorDeviceClass.DATA_RATE: [{CONF_TYPE: CONF_DATA_RATE}], SensorDeviceClass.DATA_SIZE: [{CONF_TYPE: CONF_DATA_SIZE}], @@ -153,6 +155,7 @@ TRIGGER_SCHEMA = vol.All( CONF_BATTERY_LEVEL, CONF_CO, CONF_CO2, + CONF_CONDUCTIVITY, CONF_CURRENT, CONF_DATA_RATE, CONF_DATA_SIZE, diff --git a/homeassistant/components/sensor/group.py b/homeassistant/components/sensor/group.py deleted file mode 100644 index 2bc4a122fdc..00000000000 --- a/homeassistant/components/sensor/group.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.core import HomeAssistant, callback - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - -from .const import DOMAIN - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.exclude_domain(DOMAIN) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 26bb4f4376b..940592d7b08 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -80,6 +80,7 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]: # We check for state class first before calling the filter # function as the filter function is much more expensive # than checking the state class + entity_filter = instance.entity_filter return [ state for state in hass.states.all(DOMAIN) @@ -88,7 +89,7 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]: type(state_class) is SensorStateClass or try_parse_enum(SensorStateClass, state_class) ) - and instance.entity_filter(state.entity_id) + and (not entity_filter or entity_filter(state.entity_id)) ] @@ -680,6 +681,7 @@ def validate_statistics( sensor_entity_ids = {i.entity_id for i in sensor_states} sensor_statistic_ids = set(metadatas) instance = get_instance(hass) + entity_filter = instance.entity_filter for state in sensor_states: entity_id = state.entity_id @@ -689,7 +691,7 @@ def validate_statistics( state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if metadata := metadatas.get(entity_id): - if not instance.entity_filter(state.entity_id): + if entity_filter and not entity_filter(state.entity_id): # Sensor was previously recorded, but no longer is validation_result[entity_id].append( statistics.ValidationIssue( @@ -739,7 +741,7 @@ def validate_statistics( ) ) elif state_class is not None: - if not instance.entity_filter(state.entity_id): + if entity_filter and not entity_filter(state.entity_id): # Sensor is not recorded validation_result[entity_id].append( statistics.ValidationIssue( diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index fad1086c034..fc85f4b05a9 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -8,6 +8,7 @@ "is_battery_level": "Current {entity_name} battery level", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level", + "is_conductivity": "Current {entity_name} conductivity", "is_current": "Current {entity_name} current", "is_data_rate": "Current {entity_name} data rate", "is_data_size": "Current {entity_name} data size", @@ -57,6 +58,7 @@ "battery_level": "{entity_name} battery level changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", + "conductivity": "{entity_name} conductivity changes", "current": "{entity_name} current changes", "data_rate": "{entity_name} data rate changes", "data_size": "{entity_name} data size changes", @@ -98,6 +100,11 @@ "water": "{entity_name} water changes", "weight": "{entity_name} weight changes", "wind_speed": "{entity_name} wind speed changes" + }, + "extra_fields": { + "above": "[%key:common::device_automation::extra_fields::above%]", + "below": "[%key:common::device_automation::extra_fields::below%]", + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { @@ -148,6 +155,9 @@ "carbon_dioxide": { "name": "Carbon dioxide" }, + "conductivity": { + "name": "Conductivity" + }, "current": { "name": "Current" }, diff --git a/homeassistant/components/sensorpro/sensor.py b/homeassistant/components/sensorpro/sensor.py index 536a3c6b775..b972aac04fb 100644 --- a/homeassistant/components/sensorpro/sensor.py +++ b/homeassistant/components/sensorpro/sensor.py @@ -127,7 +127,9 @@ async def async_setup_entry( class SensorProBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a SensorPro sensor.""" diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 20d97a32415..541af23783f 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -117,7 +117,9 @@ async def async_setup_entry( class SensorPushBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a sensorpush ble sensor.""" diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index dcbcc59a749..8c042621db6 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -21,6 +21,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform, inst from homeassistant.helpers.event import async_call_later from homeassistant.helpers.system_info import async_get_system_info from homeassistant.loader import Integration, async_get_custom_components +from homeassistant.setup import SetupPhases, async_pause_setup from .const import ( CONF_DSN, @@ -41,7 +42,6 @@ from .const import ( CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - LOGGER_INFO_REGEX = re.compile(r"^(\w+)\.?(\w+)?\.?(\w+)?\.?(\w+)?(?:\..*)?$") @@ -81,23 +81,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), } - sentry_sdk.init( - dsn=entry.data[CONF_DSN], - environment=entry.options.get(CONF_ENVIRONMENT), - integrations=[sentry_logging, AioHttpIntegration(), SqlalchemyIntegration()], - release=current_version, - before_send=lambda event, hint: process_before_send( - hass, - entry.options, - channel, - huuid, - system_info, - custom_components, - event, - hint, - ), - **tracing, - ) + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # sentry_sdk.init imports modules based on the selected integrations + def _init_sdk(): + """Initialize the Sentry SDK.""" + sentry_sdk.init( + dsn=entry.data[CONF_DSN], + environment=entry.options.get(CONF_ENVIRONMENT), + integrations=[ + sentry_logging, + AioHttpIntegration(), + SqlalchemyIntegration(), + ], + release=current_version, + before_send=lambda event, hint: process_before_send( + hass, + entry.options, + channel, + huuid, + system_info, + custom_components, + event, + hint, + ), + **tracing, + ) + + await hass.async_add_import_executor_job(_init_sdk) async def update_system_info(now): nonlocal system_info diff --git a/homeassistant/components/sentry/config_flow.py b/homeassistant/components/sentry/config_flow.py index b10409caf38..59cd1f3f0e9 100644 --- a/homeassistant/components/sentry/config_flow.py +++ b/homeassistant/components/sentry/config_flow.py @@ -64,7 +64,7 @@ class SentryConfigFlow(ConfigFlow, domain=DOMAIN): Dsn(user_input["dsn"]) except BadDsn: errors["base"] = "bad_dsn" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index d40b485bf89..bd4dfae4571 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -30,7 +30,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.CLIMATE] -SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]] +type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except RequestError as err: raise ConfigEntryNotReady from err - coordinator = SENZDataUpdateCoordinator( + coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator( hass, _LOGGER, name=account.username, diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 40c9c8d58d1..6d89c4c0a76 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -1,9 +1,13 @@ """The seventeentrack component.""" +from typing import Final + from py17track import Client as SeventeenTrackClient from py17track.errors import SeventeenTrackError +from py17track.package import PACKAGE_STATUS_MAP +import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_LOCATION, @@ -17,8 +21,8 @@ from homeassistant.core import ( ServiceResponse, SupportsResponse, ) -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -39,6 +43,27 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(ATTR_PACKAGE_STATE): selector.SelectSelector( + selector.SelectSelectorConfig( + multiple=True, + options=[ + value.lower().replace(" ", "_") + for value in PACKAGE_STATUS_MAP.values() + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key=ATTR_PACKAGE_STATE, + ) + ), + } +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the 17Track component.""" @@ -47,6 +72,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get packages from 17Track.""" config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] package_states = call.data.get(ATTR_PACKAGE_STATE, []) + + entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) + + if not entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry_id": config_entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry_id": entry.title, + }, + ) + seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ config_entry_id ] @@ -75,6 +120,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, SERVICE_GET_PACKAGES, get_packages, + schema=SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) return True diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index 626af29e856..cad04fca8b9 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -18,6 +18,14 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } }, + "exceptions": { + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry_id}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry_id} is not loaded." + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 7ddcb16c9f8..b299af33513 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, TypeVar from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo @@ -24,11 +23,9 @@ from .const import DOMAIN from .coordinator import SFRDataUpdateCoordinator from .models import DomainData -_T = TypeVar("_T") - @dataclass(frozen=True, kw_only=True) -class SFRBoxBinarySensorEntityDescription(BinarySensorEntityDescription, Generic[_T]): +class SFRBoxBinarySensorEntityDescription[_T](BinarySensorEntityDescription): """Description for SFR Box binary sensors.""" value_fn: Callable[[_T], bool | None] @@ -87,7 +84,7 @@ async def async_setup_entry( async_add_entities(entities) -class SFRBoxBinarySensor( +class SFRBoxBinarySensor[_T]( CoordinatorEntity[SFRDataUpdateCoordinator[_T]], BinarySensorEntity ): """SFR Box sensor.""" diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index 6dc91149d86..f6d3100d692 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError @@ -26,13 +26,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .models import DomainData -_T = TypeVar("_T") -_P = ParamSpec("_P") - -def with_error_wrapping( - func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_T]], -) -> Callable[Concatenate[SFRBoxButton, _P], Coroutine[Any, Any, _T]]: +def with_error_wrapping[**_P, _R]( + func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_R]], +) -> Callable[Concatenate[SFRBoxButton, _P], Coroutine[Any, Any, _R]]: """Catch SFR errors.""" @wraps(func) @@ -40,7 +37,7 @@ def with_error_wrapping( self: SFRBoxButton, *args: _P.args, **kwargs: _P.kwargs, - ) -> _T: + ) -> _R: """Catch SFRBoxError errors and raise HomeAssistantError.""" try: return await func(self, *args, **kwargs) diff --git a/homeassistant/components/sfr_box/coordinator.py b/homeassistant/components/sfr_box/coordinator.py index 08698edd74a..af3195723f4 100644 --- a/homeassistant/components/sfr_box/coordinator.py +++ b/homeassistant/components/sfr_box/coordinator.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Coroutine from datetime import timedelta import logging -from typing import Any, TypeVar +from typing import Any from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError @@ -14,10 +14,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) _SCAN_INTERVAL = timedelta(minutes=1) -_T = TypeVar("_T") - -class SFRDataUpdateCoordinator(DataUpdateCoordinator[_T]): +class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Coordinator to manage data updates.""" def __init__( @@ -25,14 +23,14 @@ class SFRDataUpdateCoordinator(DataUpdateCoordinator[_T]): hass: HomeAssistant, box: SFRBox, name: str, - method: Callable[[SFRBox], Coroutine[Any, Any, _T]], + method: Callable[[SFRBox], Coroutine[Any, Any, _DataT]], ) -> None: """Initialize coordinator.""" self.box = box self._method = method super().__init__(hass, _LOGGER, name=name, update_interval=_SCAN_INTERVAL) - async def _async_update_data(self) -> _T: + async def _async_update_data(self) -> _DataT: """Update data.""" try: return await self._method(self.box) diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index c0c964cd153..b5aca834af5 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -28,27 +28,19 @@ async def async_get_config_entry_diagnostics( }, "data": { "dsl": async_redact_data( - dataclasses.asdict( - await data.system.box.dsl_get_info() # type:ignore [call-overload] - ), + dataclasses.asdict(await data.system.box.dsl_get_info()), TO_REDACT, ), "ftth": async_redact_data( - dataclasses.asdict( - await data.system.box.ftth_get_info() # type:ignore [call-overload] - ), + dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT, ), "system": async_redact_data( - dataclasses.asdict( - await data.system.box.system_get_info() # type:ignore [call-overload] - ), + dataclasses.asdict(await data.system.box.system_get_info()), TO_REDACT, ), "wan": async_redact_data( - dataclasses.asdict( - await data.system.box.wan_get_info() # type:ignore [call-overload] - ), + dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT, ), }, diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 403ec762768..d19ff82b393 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, TypeVar from sfrbox_api.models import DslInfo, SystemInfo, WanInfo @@ -30,11 +29,9 @@ from .const import DOMAIN from .coordinator import SFRDataUpdateCoordinator from .models import DomainData -_T = TypeVar("_T") - @dataclass(frozen=True, kw_only=True) -class SFRBoxSensorEntityDescription(SensorEntityDescription, Generic[_T]): +class SFRBoxSensorEntityDescription[_T](SensorEntityDescription): """Description for SFR Box sensors.""" value_fn: Callable[[_T], StateType] @@ -229,7 +226,7 @@ async def async_setup_entry( async_add_entities(entities) -class SFRBoxSensor(CoordinatorEntity[SFRDataUpdateCoordinator[_T]], SensorEntity): +class SFRBoxSensor[_T](CoordinatorEntity[SFRDataUpdateCoordinator[_T]], SensorEntity): """SFR Box sensor.""" entity_description: SFRBoxSensorEntityDescription[_T] diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index a29a2b2e773..e560bb77b57 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -25,7 +25,7 @@ from .const import ( SHARKIQ_REGION_DEFAULT, SHARKIQ_REGION_EUROPE, ) -from .update_coordinator import SharkIqUpdateCoordinator +from .coordinator import SharkIqUpdateCoordinator class CannotConnect(exceptions.HomeAssistantError): diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/coordinator.py similarity index 96% rename from homeassistant/components/sharkiq/update_coordinator.py rename to homeassistant/components/sharkiq/coordinator.py index 01550024e9e..381f6ca1a7d 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL -class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): # pylint: disable=hass-enforce-coordinator-module +class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): """Define a wrapper class to update Shark IQ data.""" def __init__( diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index c1648332975..63d4f6af48b 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -43,7 +43,7 @@ }, "exceptions": { "invalid_room": { - "message": "The room { room } is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization." + "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization." } }, "services": { diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index d028b0b8b87..8f0547980c3 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK -from .update_coordinator import SharkIqUpdateCoordinator +from .coordinator import SharkIqUpdateCoordinator OPERATING_STATE_MAP = { OperatingModes.PAUSE: STATE_PAUSED, @@ -212,6 +212,7 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum """Clean specific rooms.""" rooms_to_clean = [] valid_rooms = self.available_rooms or [] + rooms = [room.replace("_", " ").title() for room in rooms] for room in rooms: if room in valid_rooms: rooms_to_clean.append(room) @@ -262,7 +263,10 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum @property def available_rooms(self) -> list | None: """Return a list of rooms available to clean.""" - return self.sharkiq.get_room_list() + room_list = self.sharkiq.get_property_value(Properties.ROBOT_ROOM_LIST) + if room_list: + return room_list.split(":")[1:] + return [] @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index c2c384e39aa..842dc74ea5a 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -99,8 +99,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: with suppress(TypeError): process.kill() # https://bugs.python.org/issue43884 - # pylint: disable-next=protected-access - process._transport.close() # type: ignore[attr-defined] + process._transport.close() # type: ignore[attr-defined] # noqa: SLF001 del process raise HomeAssistantError( diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 2c6a2e4caad..184b7c8bb6b 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib from typing import Final from aioshelly.block_device import BlockDevice @@ -19,14 +18,13 @@ import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - async_get as dr_async_get, - format_mac, +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, ) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import ConfigType from .const import ( @@ -56,9 +54,10 @@ from .utils import ( get_ws_context, ) -BLOCK_PLATFORMS: Final = [ +PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.COVER, Platform.EVENT, Platform.LIGHT, @@ -72,17 +71,7 @@ BLOCK_SLEEPING_PLATFORMS: Final = [ Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR, -] -RPC_PLATFORMS: Final = [ - Platform.BINARY_SENSOR, - Platform.BUTTON, - Platform.CLIMATE, - Platform.COVER, - Platform.EVENT, - Platform.LIGHT, - Platform.SENSOR, Platform.SWITCH, - Platform.UPDATE, ] RPC_SLEEPING_PLATFORMS: Final = [ Platform.BINARY_SENSOR, @@ -123,8 +112,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bo ) return False - entry.runtime_data = ShellyEntryData() - if get_device_entry_gen(entry) in RPC_GENERATIONS: return await _async_setup_rpc_entry(hass, entry) @@ -150,18 +137,18 @@ async def _async_setup_block_entry( options, ) - dev_reg = dr_async_get(hass) + dev_reg = dr.async_get(hass) device_entry = None if entry.unique_id is not None: device_entry = dev_reg.async_get_device( - connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, ) # https://github.com/home-assistant/core/pull/48076 if device_entry and entry.entry_id not in device_entry.config_entries: device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - shelly_entry_data = entry.runtime_data + runtime_data = entry.runtime_data = ShellyEntryData(BLOCK_SLEEPING_PLATFORMS) # Some old firmware have a wrong sleep period hardcoded value. # Following code block will force the right value for affected devices @@ -182,6 +169,7 @@ async def _async_setup_block_entry( if sleep_period == 0: # Not a sleeping device, finish setup LOGGER.debug("Setting up online block device %s", entry.title) + runtime_data.platforms = PLATFORMS try: await device.initialize() if not device.firmware_supported: @@ -192,24 +180,26 @@ async def _async_setup_block_entry( except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err - shelly_entry_data.block = ShellyBlockCoordinator(hass, entry, device) - shelly_entry_data.block.async_setup() - shelly_entry_data.rest = ShellyRestCoordinator(hass, device, entry) - await hass.config_entries.async_forward_entry_setups(entry, BLOCK_PLATFORMS) + runtime_data.block = ShellyBlockCoordinator(hass, entry, device) + runtime_data.block.async_setup() + runtime_data.rest = ShellyRestCoordinator(hass, device, entry) + await hass.config_entries.async_forward_entry_setups( + entry, runtime_data.platforms + ) elif sleep_period is None or device_entry is None: # Need to get sleep info or first time sleeping device setup, wait for device LOGGER.debug( "Setup for device %s will resume when device is online", entry.title ) - shelly_entry_data.block = ShellyBlockCoordinator(hass, entry, device) - shelly_entry_data.block.async_setup(BLOCK_SLEEPING_PLATFORMS) + runtime_data.block = ShellyBlockCoordinator(hass, entry, device) + runtime_data.block.async_setup(runtime_data.platforms) else: # Restore sensors for sleeping device LOGGER.debug("Setting up offline block device %s", entry.title) - shelly_entry_data.block = ShellyBlockCoordinator(hass, entry, device) - shelly_entry_data.block.async_setup() + runtime_data.block = ShellyBlockCoordinator(hass, entry, device) + runtime_data.block.async_setup() await hass.config_entries.async_forward_entry_setups( - entry, BLOCK_SLEEPING_PLATFORMS + entry, runtime_data.platforms ) ir.async_delete_issue( @@ -236,22 +226,23 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) options, ) - dev_reg = dr_async_get(hass) + dev_reg = dr.async_get(hass) device_entry = None if entry.unique_id is not None: device_entry = dev_reg.async_get_device( - connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, ) # https://github.com/home-assistant/core/pull/48076 if device_entry and entry.entry_id not in device_entry.config_entries: device_entry = None sleep_period = entry.data.get(CONF_SLEEP_PERIOD) - shelly_entry_data = entry.runtime_data + runtime_data = entry.runtime_data = ShellyEntryData(RPC_SLEEPING_PLATFORMS) if sleep_period == 0: # Not a sleeping device, finish setup LOGGER.debug("Setting up online RPC device %s", entry.title) + runtime_data.platforms = PLATFORMS try: await device.initialize() if not device.firmware_supported: @@ -262,24 +253,26 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err - shelly_entry_data.rpc = ShellyRpcCoordinator(hass, entry, device) - shelly_entry_data.rpc.async_setup() - shelly_entry_data.rpc_poll = ShellyRpcPollingCoordinator(hass, entry, device) - await hass.config_entries.async_forward_entry_setups(entry, RPC_PLATFORMS) + runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device) + runtime_data.rpc.async_setup() + runtime_data.rpc_poll = ShellyRpcPollingCoordinator(hass, entry, device) + await hass.config_entries.async_forward_entry_setups( + entry, runtime_data.platforms + ) elif sleep_period is None or device_entry is None: # Need to get sleep info or first time sleeping device setup, wait for device LOGGER.debug( "Setup for device %s will resume when device is online", entry.title ) - shelly_entry_data.rpc = ShellyRpcCoordinator(hass, entry, device) - shelly_entry_data.rpc.async_setup(RPC_SLEEPING_PLATFORMS) + runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device) + runtime_data.rpc.async_setup(runtime_data.platforms) else: # Restore sensors for sleeping device LOGGER.debug("Setting up offline RPC device %s", entry.title) - shelly_entry_data.rpc = ShellyRpcCoordinator(hass, entry, device) - shelly_entry_data.rpc.async_setup() + runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device) + runtime_data.rpc.async_setup() await hass.config_entries.async_forward_entry_setups( - entry, RPC_SLEEPING_PLATFORMS + entry, runtime_data.platforms ) ir.async_delete_issue( @@ -290,27 +283,6 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool: """Unload a config entry.""" - shelly_entry_data = entry.runtime_data - - platforms = RPC_SLEEPING_PLATFORMS - if not entry.data.get(CONF_SLEEP_PERIOD): - platforms = RPC_PLATFORMS - - if get_device_entry_gen(entry) in RPC_GENERATIONS: - if unload_ok := await hass.config_entries.async_unload_platforms( - entry, platforms - ): - if shelly_entry_data.rpc: - with contextlib.suppress(DeviceConnectionError): - # If the device is restarting or has gone offline before - # the ping/pong timeout happens, the shutdown command - # will fail, but we don't care since we are unloading - # and if we setup again, we will fix anything that is - # in an inconsistent state at that time. - await shelly_entry_data.rpc.shutdown() - - return unload_ok - # delete push update issue if it exists LOGGER.debug( "Deleting issue %s", PUSH_UPDATE_ISSUE_ID.format(unique=entry.unique_id) @@ -319,14 +291,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> b hass, DOMAIN, PUSH_UPDATE_ISSUE_ID.format(unique=entry.unique_id) ) - platforms = BLOCK_SLEEPING_PLATFORMS + runtime_data = entry.runtime_data - if not entry.data.get(CONF_SLEEP_PERIOD): - shelly_entry_data.rest = None - platforms = BLOCK_PLATFORMS + if runtime_data.rpc: + await runtime_data.rpc.shutdown() - if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): - if shelly_entry_data.block: - shelly_entry_data.block.shutdown() + if runtime_data.block: + await runtime_data.block.shutdown() - return unload_ok + return await hass.config_entries.async_unload_platforms( + entry, runtime_data.platforms + ) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 8c1b1c4ef43..f1e2f8ef885 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass from functools import partial -from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Final from aioshelly.const import RPC_GENERATIONS @@ -26,13 +26,11 @@ from .const import LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .utils import get_device_entry_gen -_ShellyCoordinatorT = TypeVar( - "_ShellyCoordinatorT", bound=ShellyBlockCoordinator | ShellyRpcCoordinator -) - @dataclass(frozen=True, kw_only=True) -class ShellyButtonDescription(ButtonEntityDescription, Generic[_ShellyCoordinatorT]): +class ShellyButtonDescription[ + _ShellyCoordinatorT: ShellyBlockCoordinator | ShellyRpcCoordinator +](ButtonEntityDescription): """Class to describe a Button entity.""" press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 6a3f6605a8c..ab1e58583d9 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -21,14 +21,10 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import ( - RegistryEntry, - async_entries_for_config_entry, - async_get as er_async_get, -) +from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_conversion import TemperatureConverter @@ -104,8 +100,8 @@ def async_restore_climate_entities( ) -> None: """Restore sleeping climate devices.""" - ent_reg = er_async_get(hass) - entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) + ent_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) for entry in entries: if entry.domain != CLIMATE_DOMAIN: @@ -319,7 +315,7 @@ class BlockSleepingClimate( self.coordinator.last_update_success = False raise HomeAssistantError( f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {repr(err)}" + f" {err!r}" ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() @@ -472,6 +468,10 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL] else: self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + self._humidity_key: str | None = None + # Check if there is a corresponding humidity key for the thermostat ID + if (humidity_key := f"humidity:{id_}") in self.coordinator.device.status: + self._humidity_key = humidity_key @property def target_temperature(self) -> float | None: @@ -483,6 +483,14 @@ class RpcClimate(ShellyRpcEntity, ClimateEntity): """Return current temperature.""" return cast(float, self.status["current_C"]) + @property + def current_humidity(self) -> float | None: + """Return current humidity.""" + if self._humidity_key is None: + return None + + return cast(float, self.coordinator.device.status[self._humidity_key]["rh"]) + @property def hvac_mode(self) -> HVACMode: """HVAC current mode.""" diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 46cea4e49a4..c044d032170 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info @@ -122,7 +122,7 @@ async def validate_input( options, ) await block_device.initialize() - block_device.shutdown() + await block_device.shutdown() return { "title": block_device.name, CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), @@ -155,7 +155,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): self.info = await self._async_get_info(host, port) except DeviceConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -174,7 +174,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CustomPortNotSupported: errors["base"] = "custom_port_not_supported" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -211,7 +211,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except DeviceConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -256,6 +256,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): if ( current_entry := await self.async_set_unique_id(mac) ) and current_entry.data.get(CONF_HOST) == host: + LOGGER.debug("async_reconnect_soon: host: %s, mac: %s", host, mac) await async_reconnect_soon(self.hass, current_entry) if host == INTERNAL_WIFI_AP_IP: # If the device is broadcasting the internal wifi ap ip @@ -391,6 +392,60 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + if TYPE_CHECKING: + assert entry is not None + + self.host = entry.data[CONF_HOST] + self.port = entry.data.get(CONF_PORT, DEFAULT_HTTP_PORT) + self.entry = entry + + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors = {} + + if TYPE_CHECKING: + assert self.entry is not None + + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input.get(CONF_PORT, DEFAULT_HTTP_PORT) + try: + info = await self._async_get_info(host, port) + except DeviceConnectionError: + errors["base"] = "cannot_connect" + except CustomPortNotSupported: + errors["base"] = "custom_port_not_supported" + else: + if info[CONF_MAC] != self.entry.unique_id: + return self.async_abort(reason="another_device") + + data = {**self.entry.data, CONF_HOST: host, CONF_PORT: port} + self.hass.config_entries.async_update_entry(self.entry, data=data) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self.host): str, + vol.Required(CONF_PORT, default=self.port): vol.Coerce(int), + } + ), + description_placeholders={"device_name": self.entry.title}, + errors=errors, + ) + async def _async_get_info(self, host: str, port: int) -> dict[str, Any]: """Get info from shelly device.""" return await get_info(async_get_clientsession(self.hass), host, port=port) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 70dc60c4ad9..fcc7cc44af9 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -45,6 +45,11 @@ RGBW_MODELS: Final = ( MODEL_RGBW2, ) +MOTION_MODELS: Final = ( + MODEL_MOTION, + MODEL_MOTION_2, +) + MODELS_SUPPORTING_LIGHT_TRANSITION: Final = ( MODEL_DUO, MODEL_BULB_RGBW, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 9ca0d19c574..f15eca51413 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta -from typing import Any, Generic, TypeVar, cast +from typing import Any, cast from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType @@ -22,12 +22,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - async_get as dr_async_get, -) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .bluetooth import async_connect_scanner @@ -63,7 +60,6 @@ from .const import ( ) from .utils import ( async_create_issue_unsupported_firmware, - async_shutdown_device, get_block_device_sleep_period, get_device_entry_gen, get_http_port, @@ -71,23 +67,24 @@ from .utils import ( update_device_fw_info, ) -_DeviceT = TypeVar("_DeviceT", bound="BlockDevice|RpcDevice") - @dataclass class ShellyEntryData: """Class for sharing data within a given config entry.""" + platforms: list[Platform] block: ShellyBlockCoordinator | None = None rest: ShellyRestCoordinator | None = None rpc: ShellyRpcCoordinator | None = None rpc_poll: ShellyRpcPollingCoordinator | None = None -ShellyConfigEntry = ConfigEntry[ShellyEntryData] +type ShellyConfigEntry = ConfigEntry[ShellyEntryData] -class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): +class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( + DataUpdateCoordinator[None] +): """Coordinator for a Shelly device.""" def __init__( @@ -115,6 +112,10 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): ) entry.async_on_unload(self._debounced_reload.async_shutdown) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + ) + @property def model(self) -> str: """Model of the device.""" @@ -138,7 +139,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" self._pending_platforms = pending_platforms - dev_reg = dr_async_get(self.hass) + dev_reg = dr.async_get(self.hass) device_entry = dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, name=self.name, @@ -151,6 +152,15 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): ) self.device_id = device_entry.id + async def shutdown(self) -> None: + """Shutdown the coordinator.""" + await self.device.shutdown() + + async def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + LOGGER.debug("Stopping RPC device coordinator for %s", self.name) + await self.shutdown() + async def _async_device_connect_task(self) -> bool: """Connect to a Shelly device task.""" LOGGER.debug("Connecting to Shelly Device - %s", self.name) @@ -206,7 +216,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): # not running disconnect events since we have auth error # and won't be able to send commands to the device self.last_update_success = False - await async_shutdown_device(self.device) + await self.shutdown() self.entry.async_start_reauth(self.hass) @@ -237,9 +247,6 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): entry.async_on_unload( self.async_add_listener(self._async_device_updates_handler) ) - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) - ) @callback def async_subscribe_input_events( @@ -353,7 +360,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): try: await self.device.update() except DeviceConnectionError as err: - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + raise UpdateFailed(f"Error fetching data: {err!r}") from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -362,6 +369,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self, device_: BlockDevice, update_type: BlockUpdateType ) -> None: """Handle device update.""" + LOGGER.debug("Shelly %s handle update, type: %s", self.name, update_type) if update_type is BlockUpdateType.ONLINE: self.entry.async_create_background_task( self.hass, @@ -406,16 +414,6 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): super().async_setup(pending_platforms) self.device.subscribe_updates(self._async_handle_update) - def shutdown(self) -> None: - """Shutdown the coordinator.""" - self.device.shutdown() - - @callback - def _handle_ha_stop(self, _event: Event) -> None: - """Handle Home Assistant stopping.""" - LOGGER.debug("Stopping block device coordinator for %s", self.name) - self.shutdown() - class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): """Coordinator for a Shelly REST device.""" @@ -444,7 +442,7 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): return await self.device.update_shelly() except DeviceConnectionError as err: - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + raise UpdateFailed(f"Error fetching data: {err!r}") from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: @@ -472,9 +470,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) - ) entry.async_on_unload(entry.add_update_listener(self._async_update_listener)) def update_sleep_period(self) -> bool: @@ -590,13 +585,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): raise UpdateFailed( f"Sleeping device did not update within {self.sleep_period} seconds interval" ) - if self.device.connected: - return - if not await self._async_device_connect_task(): - raise UpdateFailed("Device reconnect error") + async with self._connection_lock: + if self.device.connected: # Already connected + return - async def _async_disconnected(self) -> None: + if not await self._async_device_connect_task(): + raise UpdateFailed("Device reconnect error") + + async def _async_disconnected(self, reconnect: bool) -> None: """Handle device disconnected.""" # Sleeping devices send data and disconnect # There are no disconnect events for sleeping devices @@ -608,8 +605,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): return self.connected = False self._async_run_disconnected_events() - # Try to reconnect right away if hass is not stopping - if not self.hass.is_stopping: + # Try to reconnect right away if triggered by disconnect event + if reconnect: await self.async_request_refresh() @callback @@ -629,7 +626,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.connected: # Already connected return self.connected = True - await self._async_run_connected_events() + try: + await self._async_run_connected_events() + except DeviceConnectionError as err: + LOGGER.error( + "Error running connected events for device %s: %s", self.name, err + ) + self.last_update_success = False async def _async_run_connected_events(self) -> None: """Run connected events. @@ -661,6 +664,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self, device_: RpcDevice, update_type: RpcUpdateType ) -> None: """Handle device update.""" + LOGGER.debug("Shelly %s handle update, type: %s", self.name, update_type) if update_type is RpcUpdateType.ONLINE: self.entry.async_create_background_task( self.hass, @@ -676,7 +680,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): elif update_type is RpcUpdateType.DISCONNECTED: self.entry.async_create_background_task( self.hass, - self._async_disconnected(), + self._async_disconnected(True), "rpc device disconnected", eager_start=True, ) @@ -702,16 +706,19 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.device.connected: try: await async_stop_scanner(self.device) + await super().shutdown() except InvalidAuthError: - await self.async_shutdown_device_and_start_reauth() + self.entry.async_start_reauth(self.hass) return - await self.device.shutdown() - await self._async_disconnected() - - async def _handle_ha_stop(self, _event: Event) -> None: - """Handle Home Assistant stopping.""" - LOGGER.debug("Stopping RPC device coordinator for %s", self.name) - await self.shutdown() + except DeviceConnectionError as err: + # If the device is restarting or has gone offline before + # the ping/pong timeout happens, the shutdown command + # will fail, but we don't care since we are unloading + # and if we setup again, we will fix anything that is + # in an inconsistent state at that time. + LOGGER.debug("Error during shutdown for device %s: %s", self.name, err) + return + await self._async_disconnected(False) class ShellyRpcPollingCoordinator(ShellyCoordinatorBase[RpcDevice]): @@ -732,7 +739,7 @@ class ShellyRpcPollingCoordinator(ShellyCoordinatorBase[RpcDevice]): try: await self.device.update_status() except (DeviceConnectionError, RpcCallError) as err: - raise UpdateFailed(f"Device disconnected: {repr(err)}") from err + raise UpdateFailed(f"Device disconnected: {err!r}") from err except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() @@ -741,13 +748,14 @@ def get_block_coordinator_by_device_id( hass: HomeAssistant, device_id: str ) -> ShellyBlockCoordinator | None: """Get a Shelly block device coordinator for the given device id.""" - dev_reg = dr_async_get(hass) + dev_reg = dr.async_get(hass) if device := dev_reg.async_get(device_id): for config_entry in device.config_entries: entry = hass.config_entries.async_get_entry(config_entry) if ( entry - and entry.state == ConfigEntryState.LOADED + and entry.state is ConfigEntryState.LOADED + and hasattr(entry, "runtime_data") and isinstance(entry.runtime_data, ShellyEntryData) and (coordinator := entry.runtime_data.block) ): @@ -760,13 +768,14 @@ def get_rpc_coordinator_by_device_id( hass: HomeAssistant, device_id: str ) -> ShellyRpcCoordinator | None: """Get a Shelly RPC device coordinator for the given device id.""" - dev_reg = dr_async_get(hass) + dev_reg = dr.async_get(hass) if device := dev_reg.async_get(device_id): for config_entry in device.config_entries: entry = hass.config_entries.async_get_entry(config_entry) if ( entry - and entry.state == ConfigEntryState.LOADED + and entry.state is ConfigEntryState.LOADED + and hasattr(entry, "runtime_data") and isinstance(entry.runtime_data, ShellyEntryData) and (coordinator := entry.runtime_data.rpc) ): diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index b9f48bfd24d..e1530a669a1 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -11,14 +11,11 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import ( - RegistryEntry, - async_entries_for_config_entry, - async_get as er_async_get, -) +from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,8 +109,8 @@ def async_restore_block_attribute_entities( """Restore block attributes entities.""" entities = [] - ent_reg = er_async_get(hass) - entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) + ent_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) domain = sensor_class.__module__.split(".")[-1] @@ -221,8 +218,8 @@ def async_restore_rpc_attribute_entities( """Restore block attributes entities.""" entities = [] - ent_reg = er_async_get(hass) - entries = async_entries_for_config_entry(ent_reg, config_entry.entry_id) + ent_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) domain = sensor_class.__module__.split(".")[-1] @@ -340,7 +337,7 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): self.coordinator.last_update_success = False raise HomeAssistantError( f"Setting state for entity {self.name} failed, state: {kwargs}, error:" - f" {repr(err)}" + f" {err!r}" ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() @@ -388,12 +385,12 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): self.coordinator.last_update_success = False raise HomeAssistantError( f"Call RPC for {self.name} connection error, method: {method}, params:" - f" {params}, error: {repr(err)}" + f" {params}, error: {err!r}" ) from err except RpcCallError as err: raise HomeAssistantError( f"Call RPC for {self.name} request error, method: {method}, params:" - f" {params}, error: {repr(err)}" + f" {params}, error: {err!r}" ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 08971713ced..b1b00e40c66 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==9.0.0"], + "requirements": ["aioshelly==10.0.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index f7630ef09b3..afc508dd94f 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -122,7 +122,7 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber): self.coordinator.last_update_success = False raise HomeAssistantError( f"Setting state for entity {self.name} failed, state: {params}, error:" - f" {repr(err)}" + f" {err!r}" ) from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 7dea45c0c1f..743c7c7ff01 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -989,6 +989,24 @@ RPC_SENSORS: Final = { or status[key]["counts"].get("xtotal") is None ), ), + "counter_frequency": RpcSensorDescription( + key="input", + sub_key="counts", + name="Pulse counter frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + value=lambda status, _: status["freq"], + removal_condition=lambda config, status, key: (config[key]["enable"] is False), + ), + "counter_frequency_value": RpcSensorDescription( + key="input", + sub_key="counts", + name="Pulse counter frequency value", + value=lambda status, _: status["xfreq"], + removal_condition=lambda config, status, key: ( + config[key]["enable"] is False or status[key]["counts"].get("xfreq") is None + ), + ), } diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index cee27e9ca07..3a71874f2dd 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -27,6 +27,17 @@ }, "confirm_discovery": { "description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device." + }, + "reconfigure_confirm": { + "description": "Update configuration for {device_name}.\n\nBefore setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "[%key:component::shelly::config::step::user::data_description::host%]", + "port": "[%key:component::shelly::config::step::user::data_description::port%]" + } } }, "error": { @@ -39,7 +50,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again." + "reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used." } }, "device_automation": { diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 70b6754608b..09ee133589b 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -6,34 +6,23 @@ 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, - MODEL_WALL_DISPLAY, - RPC_GENERATIONS, -) +from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS -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.core import HomeAssistant, callback +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN, GAS_VALVE_OPEN_STATES +from .const import CONF_SLEEP_PERIOD, MOTION_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, - ShellyBlockAttributeEntity, ShellyBlockEntity, ShellyRpcEntity, - async_setup_block_attribute_entities, + ShellySleepingBlockAttributeEntity, + async_setup_entry_attribute_entities, ) from .utils import ( async_remove_shelly_entity, @@ -51,13 +40,10 @@ 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, +MOTION_SWITCH = BlockSwitchDescription( + key="sensor|motionActive", + name="Motion detection", + entity_category=EntityCategory.CONFIG, ) @@ -83,17 +69,20 @@ def async_setup_block_entry( coordinator = config_entry.runtime_data.block assert coordinator - # Add Shelly Gas Valve as a switch - if coordinator.model == MODEL_GAS: - async_setup_block_attribute_entities( + # Add Shelly Motion as a switch + if coordinator.model in MOTION_MODELS: + async_setup_entry_attribute_entities( hass, + config_entry, async_add_entities, - coordinator, - {("valve", "valve"): GAS_VALVE_SWITCH}, - BlockValveSwitch, + {("sensor", "motionActive"): MOTION_SWITCH}, + BlockSleepingMotionSwitch, ) return + if config_entry.data[CONF_SLEEP_PERIOD]: + return + # In roller mode the relay blocks exist but do not contain required info if ( coordinator.model in [MODEL_2, MODEL_25] @@ -165,97 +154,52 @@ def async_setup_rpc_entry( async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) -class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): - """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. - """ +class BlockSleepingMotionSwitch( + ShellySleepingBlockAttributeEntity, RestoreEntity, SwitchEntity +): + """Entity that controls Motion Sensor on Block based Shelly devices.""" entity_description: BlockSwitchDescription - _attr_translation_key = "valve_switch" + _attr_translation_key = "motion_switch" def __init__( self, coordinator: ShellyBlockCoordinator, - block: Block, + block: Block | None, attribute: str, description: BlockSwitchDescription, + entry: RegistryEntry | None = None, ) -> None: - """Initialize valve.""" - super().__init__(coordinator, block, attribute, description) - self.control_result: dict[str, Any] | None = None + """Initialize the sleeping sensor.""" + super().__init__(coordinator, block, attribute, description, entry) + self.last_state: State | None = None @property - def is_on(self) -> bool: - """If valve is open.""" - if self.control_result: - return self.control_result["state"] in GAS_VALVE_OPEN_STATES + def is_on(self) -> bool | None: + """If motion is active.""" + if self.block is not None: + return bool(self.block.motionActive) - return self.attribute_value in GAS_VALVE_OPEN_STATES + if self.last_state is None: + return None + + return self.last_state.state == STATE_ON 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") + """Activate switch.""" + await self.coordinator.device.set_shelly_motion_detection(True) 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") + """Deactivate switch.""" + await self.coordinator.device.set_shelly_motion_detection(False) self.async_write_ha_state() async def async_added_to_hass(self) -> None: - """Set up a listener when this entity is added to HA.""" + """Handle entity which will be added.""" 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.""" - self.control_result = None - - super()._update_callback() + if (last_state := await self.async_get_last_state()) is not None: + self.last_state = last_state class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index a9673187408..0678da44472 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -197,7 +197,7 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): try: result = await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError(f"Error starting OTA update: {repr(err)}") from err + raise HomeAssistantError(f"Error starting OTA update: {err!r}") from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: @@ -286,11 +286,9 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): try: await self.coordinator.device.trigger_ota_update(beta=beta) except DeviceConnectionError as err: - raise HomeAssistantError( - f"OTA update connection error: {repr(err)}" - ) from err + raise HomeAssistantError(f"OTA update connection error: {err!r}") from err except RpcCallError as err: - raise HomeAssistantError(f"OTA update request error: {repr(err)}") from err + raise HomeAssistantError(f"OTA update request error: {err!r}") from err except InvalidAuthError: await self.coordinator.async_shutdown_device_and_start_reauth() else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index b7cb2f1476a..bcd5a859538 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -28,13 +28,13 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import issue_registry as ir, singleton -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - async_get as dr_async_get, - format_mac, +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, + singleton, ) -from homeassistant.helpers.entity_registry import async_get as er_async_get +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.util.dt import utcnow from .const import ( @@ -60,7 +60,7 @@ def async_remove_shelly_entity( hass: HomeAssistant, domain: str, unique_id: str ) -> None: """Remove a Shelly entity.""" - entity_reg = er_async_get(hass) + entity_reg = er.async_get(hass) entity_id = entity_reg.async_get_entity_id(domain, DOMAIN, unique_id) if entity_id: LOGGER.debug("Removing entity: %s", entity_id) @@ -410,10 +410,10 @@ def update_device_fw_info( """Update the firmware version information in the device registry.""" assert entry.unique_id - dev_reg = dr_async_get(hass) + dev_reg = dr.async_get(hass) if device := dev_reg.async_get_device( identifiers={(DOMAIN, entry.entry_id)}, - connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + connections={(CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, ): if device.sw_version == shellydevice.firmware_version: return @@ -482,20 +482,12 @@ def get_http_port(data: MappingProxyType[str, Any]) -> int: return cast(int, data.get(CONF_PORT, DEFAULT_HTTP_PORT)) -async def async_shutdown_device(device: BlockDevice | RpcDevice) -> None: - """Shutdown a Shelly device.""" - if isinstance(device, RpcDevice): - await device.shutdown() - if isinstance(device, BlockDevice): - device.shutdown() - - @callback def async_remove_shelly_rpc_entities( hass: HomeAssistant, domain: str, mac: str, keys: list[str] ) -> None: """Remove RPC based Shelly entity.""" - entity_reg = er_async_get(hass) + entity_reg = er.async_get(hass) for key in keys: if entity_id := entity_reg.async_get_entity_id(domain, DOMAIN, f"{mac}-{key}"): LOGGER.debug("Removing entity: %s", entity_id) diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index 83c1f577439..ea6feaabe69 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -23,7 +23,7 @@ from .entity import ( ShellyBlockAttributeEntity, async_setup_block_attribute_entities, ) -from .utils import get_device_entry_gen +from .utils import async_remove_shelly_entity, get_device_entry_gen @dataclass(kw_only=True, frozen=True) @@ -67,6 +67,9 @@ def async_setup_block_entry( {("valve", "valve"): GAS_VALVE}, BlockShellyValve, ) + # Remove deprecated switch entity for gas valve + unique_id = f"{coordinator.mac}-valve_0-valve" + async_remove_shelly_entity(hass, "switch", unique_id) class BlockShellyValve(ShellyBlockAttributeEntity, ValveEntity): diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 1176192bdcd..20d3078228c 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -582,12 +582,12 @@ def websocket_handle_reorder( except NoMatchingShoppingListItem: connection.send_error( msg_id, - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, "One or more item id(s) not found.", ) return except vol.Invalid as err: - connection.send_error(msg_id, websocket_api.const.ERR_INVALID_FORMAT, f"{err}") + connection.send_error(msg_id, websocket_api.ERR_INVALID_FORMAT, f"{err}") return connection.send_result(msg_id) diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index 70a70467cbd..d45085be5fa 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -22,7 +22,9 @@ class AddItemIntent(intent.IntentHandler): """Handle AddItem intents.""" intent_type = INTENT_ADD_ITEM + description = "Adds an item to the shopping list" slot_schema = {"item": cv.string} + platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" @@ -39,7 +41,9 @@ class ListTopItemsIntent(intent.IntentHandler): """Handle AddItem intents.""" intent_type = INTENT_LAST_ITEMS + description = "List the top five items on the shopping list" slot_schema = {"item": cv.string} + platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 8c995da542a..42ce81cbfc1 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -64,8 +64,8 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription( "OS": STATE_ALARM_DISARMED, "NC": STATE_ALARM_ARMED_NIGHT, "NL": STATE_ALARM_ARMED_NIGHT, - "NE": STATE_ALARM_ARMED_CUSTOM_BYPASS, - "NF": STATE_ALARM_ARMED_CUSTOM_BYPASS, + "NE": STATE_ALARM_ARMED_NIGHT, + "NF": STATE_ALARM_ARMED_NIGHT, "BR": PREVIOUS_STATE, }, ) diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index 4329154b069..cb451133d41 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -77,7 +77,7 @@ def validate_input(data: dict[str, Any]) -> dict[str, str] | None: return {"base": "invalid_account_format"} except InvalidAccountLengthError: return {"base": "invalid_account_length"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception from SIAAccount") return {"base": "unknown"} if not 1 <= data[CONF_PING_INTERVAL] <= 1440: diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json index 058b01535ea..217109bfa2c 100644 --- a/homeassistant/components/signal_messenger/manifest.json +++ b/homeassistant/components/signal_messenger/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/signal_messenger", "iot_class": "cloud_push", "loggers": ["pysignalclirestapi"], - "requirements": ["pysignalclirestapi==0.3.23"] + "requirements": ["pysignalclirestapi==0.3.24"] } diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 9c8846b2767..b93e5bb43e2 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -27,18 +27,32 @@ CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES = 52428800 ATTR_FILENAMES = "attachments" ATTR_URLS = "urls" ATTR_VERIFY_SSL = "verify_ssl" +ATTR_TEXTMODE = "text_mode" -DATA_FILENAMES_SCHEMA = vol.Schema({vol.Required(ATTR_FILENAMES): [cv.string]}) +TEXTMODE_OPTIONS = ["normal", "styled"] + +DATA_FILENAMES_SCHEMA = vol.Schema( + { + vol.Required(ATTR_FILENAMES): [cv.string], + vol.Optional(ATTR_TEXTMODE, default="normal"): vol.In(TEXTMODE_OPTIONS), + } +) DATA_URLS_SCHEMA = vol.Schema( { vol.Required(ATTR_URLS): [cv.url], vol.Optional(ATTR_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(ATTR_TEXTMODE, default="normal"): vol.In(TEXTMODE_OPTIONS), } ) DATA_SCHEMA = vol.Any( None, + vol.Schema( + { + vol.Optional(ATTR_TEXTMODE, default="normal"): vol.In(TEXTMODE_OPTIONS), + } + ), DATA_FILENAMES_SCHEMA, DATA_URLS_SCHEMA, ) @@ -100,10 +114,13 @@ class SignalNotificationService(BaseNotificationService): attachments_as_bytes = self.get_attachments_as_bytes( data, CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES, self._hass ) - try: self._signal_cli_rest_api.send_message( - message, self._recp_nrs, filenames, attachments_as_bytes + message, + self._recp_nrs, + filenames, + attachments_as_bytes, + text_mode="normal" if data is None else data.get(ATTR_TEXTMODE), ) except SignalCliRestApiError as ex: _LOGGER.error("%s", ex) @@ -116,7 +133,6 @@ class SignalNotificationService(BaseNotificationService): data = DATA_FILENAMES_SCHEMA(data) except vol.Invalid: return None - return data[ATTR_FILENAMES] @staticmethod @@ -130,7 +146,6 @@ class SignalNotificationService(BaseNotificationService): data = DATA_URLS_SCHEMA(data) except vol.Invalid: return None - urls = data[ATTR_URLS] attachments_as_bytes: list[bytearray] = [] diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index cdeb6910aa5..29f53eafffb 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -503,7 +503,7 @@ class SimpliSafe: raise except WebsocketError as err: LOGGER.error("Failed to connect to websocket: %s", err) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.error("Unknown exception while connecting to websocket: %s", err) LOGGER.info("Reconnecting to websocket") diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 731400e67d5..28ebd246623 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -26,11 +26,9 @@ from simplipy.websocket import ( from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, - CodeFormat, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMING, @@ -124,11 +122,12 @@ async def async_setup_entry( class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): """Representation of a SimpliSafe alarm.""" + _attr_code_arm_required = False + _attr_name = None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) - _attr_name = None def __init__(self, simplisafe: SimpliSafe, system: SystemType) -> None: """Initialize the SimpliSafe alarm.""" @@ -138,30 +137,9 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): additional_websocket_events=WEBSOCKET_EVENTS_TO_LISTEN_FOR, ) - if code := self._simplisafe.entry.options.get(CONF_CODE): - if code.isdigit(): - self._attr_code_format = CodeFormat.NUMBER - else: - self._attr_code_format = CodeFormat.TEXT - self._last_event = None - self._set_state_from_system_data() - @callback - def _is_code_valid(self, code: str | None, state: str) -> bool: - """Validate that a code matches the required one.""" - if not self._simplisafe.entry.options.get(CONF_CODE): - return True - - if not code or code != self._simplisafe.entry.options[CONF_CODE]: - LOGGER.warning( - "Incorrect alarm code entered (target state: %s): %s", state, code - ) - return False - - return True - @callback def _set_state_from_system_data(self) -> None: """Set the state based on the latest REST API data.""" @@ -176,9 +154,6 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._is_code_valid(code, STATE_ALARM_DISARMED): - return - try: await self._system.async_set_off() except SimplipyError as err: @@ -191,9 +166,6 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if not self._is_code_valid(code, STATE_ALARM_ARMED_HOME): - return - try: await self._system.async_set_home() except SimplipyError as err: @@ -206,9 +178,6 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if not self._is_code_valid(code, STATE_ALARM_ARMED_AWAY): - return - try: await self._system.async_set_away() except SimplipyError as err: diff --git a/homeassistant/components/simplisafe/typing.py b/homeassistant/components/simplisafe/typing.py index 5651a3072b9..712cc59903d 100644 --- a/homeassistant/components/simplisafe/typing.py +++ b/homeassistant/components/simplisafe/typing.py @@ -3,4 +3,4 @@ from simplipy.system.v2 import SystemV2 from simplipy.system.v3 import SystemV3 -SystemType = SystemV2 | SystemV3 +type SystemType = SystemV2 | SystemV3 diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 94a3e270cb3..257ea2e92fa 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -159,8 +159,7 @@ class Monitor(threading.Thread, SensorEntity): ) if SKIP_HANDLE_LOOKUP: # HACK: inject handle mapping collected offline - # pylint: disable-next=protected-access - device._characteristics[UUID(BLE_TEMP_UUID)] = cached_char + device._characteristics[UUID(BLE_TEMP_UUID)] = cached_char # noqa: SLF001 # Magic: writing this makes device happy device.char_write_handle(0x1B, bytearray([255]), False) device.subscribe(BLE_TEMP_UUID, self._update) diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py index 26602e81882..385f3dc39d7 100644 --- a/homeassistant/components/skybell/config_flow.py +++ b/homeassistant/components/skybell/config_flow.py @@ -100,6 +100,6 @@ class SkybellFlowHandler(ConfigFlow, domain=DOMAIN): return None, "invalid_auth" except exceptions.SkybellException: return None, "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return None, "unknown" return skybell.user_id, None diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py index 03f3683e5a9..7f6d7288606 100644 --- a/homeassistant/components/slack/config_flow.py +++ b/homeassistant/components/slack/config_flow.py @@ -68,7 +68,7 @@ class SlackFlowHandler(ConfigFlow, domain=DOMAIN): if ex.response["error"] == "invalid_auth": return "invalid_auth", None return "cannot_connect", None - except Exception: # pylint:disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return "unknown", None return None, info diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index 3ffd736ccda..829e3a00e6f 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -1,7 +1,6 @@ """Entity for the SleepIQ integration.""" from abc import abstractmethod -from typing import TypeVar from asyncsleepiq import SleepIQBed, SleepIQSleeper @@ -14,10 +13,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ENTITY_TYPES, ICON_OCCUPIED from .coordinator import SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator -_SleepIQCoordinatorT = TypeVar( - "_SleepIQCoordinatorT", - bound=SleepIQDataUpdateCoordinator | SleepIQPauseUpdateCoordinator, -) +type _DataCoordinatorType = SleepIQDataUpdateCoordinator | SleepIQPauseUpdateCoordinator def device_from_bed(bed: SleepIQBed) -> DeviceInfo: @@ -47,7 +43,9 @@ class SleepIQEntity(Entity): self._attr_device_info = device_from_bed(bed) -class SleepIQBedEntity(CoordinatorEntity[_SleepIQCoordinatorT]): +class SleepIQBedEntity[_SleepIQCoordinatorT: _DataCoordinatorType]( + CoordinatorEntity[_SleepIQCoordinatorT] +): """Implementation of a SleepIQ sensor.""" _attr_icon = ICON_OCCUPIED @@ -75,7 +73,9 @@ class SleepIQBedEntity(CoordinatorEntity[_SleepIQCoordinatorT]): """Update sensor attributes.""" -class SleepIQSleeperEntity(SleepIQBedEntity[_SleepIQCoordinatorT]): +class SleepIQSleeperEntity[_SleepIQCoordinatorT: _DataCoordinatorType]( + SleepIQBedEntity[_SleepIQCoordinatorT] +): """Implementation of a SleepIQ sensor.""" _attr_icon = ICON_OCCUPIED diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index dcf1084f161..3bfb66c4849 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -71,7 +71,7 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except pysma.exceptions.SmaReadException: errors["base"] = "cannot_retrieve_device_info" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index f2fab31caaa..bbe1361b795 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -63,7 +63,7 @@ class SMTConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 4c767cbfa30..c3929ababc1 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -56,6 +56,7 @@ OPERATING_STATE_TO_ACTION = { "pending cool": HVACAction.COOLING, "pending heat": HVACAction.HEATING, "vent economizer": HVACAction.FAN, + "wind": HVACAction.FAN, } AC_MODE_TO_STATE = { @@ -67,6 +68,7 @@ AC_MODE_TO_STATE = { "heat": HVACMode.HEAT, "heatClean": HVACMode.HEAT, "fanOnly": HVACMode.FAN_ONLY, + "wind": HVACMode.FAN_ONLY, } STATE_TO_AC_MODE = { HVACMode.HEAT_COOL: "auto", @@ -87,7 +89,7 @@ FAN_OSCILLATION_TO_SWING = { value: key for key, value in SWING_TO_FAN_OSCILLATION.items() } - +WIND = "wind" WINDFREE = "windFree" UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} @@ -390,11 +392,17 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): # Turn on the device if it's off before setting mode. if not self._device.status.switch: tasks.append(self._device.switch_on(set_status=True)) - tasks.append( - self._device.set_air_conditioner_mode( - STATE_TO_AC_MODE[hvac_mode], set_status=True - ) - ) + + mode = STATE_TO_AC_MODE[hvac_mode] + # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind" + # The conversion make the mode change working + # The conversion is made only for device that wrongly has capability "wind" instead "fan_only" + if hvac_mode == HVACMode.FAN_ONLY: + supported_modes = self._device.status.supported_ac_modes + if WIND in supported_modes: + mode = WIND + + tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True)) await asyncio.gather(*tasks) # State is set optimistically in the command above, therefore update # the entity state ahead of receiving the confirming push updates diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 85f350b8fb3..2ecc3375026 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -159,7 +159,7 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_pat(errors) - except Exception: # pylint:disable=broad-except + except Exception: errors["base"] = "app_setup_error" _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_pat(errors) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 89e5071051c..be313248eaf 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -2,7 +2,7 @@ "domain": "smartthings", "name": "SmartThings", "after_dependencies": ["cloud"], - "codeowners": ["@andrewsayre"], + "codeowners": [], "config_flow": true, "dependencies": ["webhook"], "dhcp": [ diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 13315c30031..2a61be3dc75 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections import namedtuple from collections.abc import Sequence +from typing import NamedTuple from pysmartthings import Attribute, Capability from pysmartthings.device import DeviceEntity @@ -34,9 +34,17 @@ from homeassistant.util import dt as dt_util from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN -Map = namedtuple( - "Map", "attribute name default_unit device_class state_class entity_category" -) + +class Map(NamedTuple): + """Tuple for mapping Smartthings capabilities to Home Assistant sensors.""" + + attribute: str + name: str + default_unit: str | None + device_class: SensorDeviceClass | None + state_class: SensorStateClass | None + entity_category: EntityCategory | None + CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { Capability.activity_lighting_mode: [ @@ -629,8 +637,8 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): device: DeviceEntity, attribute: str, name: str, - default_unit: str, - device_class: SensorDeviceClass, + default_unit: str | None, + device_class: SensorDeviceClass | None, state_class: str | None, entity_category: EntityCategory | None, ) -> None: diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 1c18a39b1e6..e2593dd7b10 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -326,7 +326,7 @@ async def smartapp_sync_subscriptions( _LOGGER.debug( "Created subscription for '%s' under app '%s'", target, installed_app_id ) - except Exception as error: # pylint:disable=broad-except + except Exception as error: # noqa: BLE001 _LOGGER.error( "Failed to create subscription for '%s' under app '%s': %s", target, @@ -345,7 +345,7 @@ async def smartapp_sync_subscriptions( sub.capability, installed_app_id, ) - except Exception as error: # pylint:disable=broad-except + except Exception as error: # noqa: BLE001 _LOGGER.error( "Failed to remove subscription for '%s' under app '%s': %s", sub.capability, diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index bf069f4b26a..3d5642a2784 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -13,6 +13,7 @@ from smhi import Smhi from smhi.smhi_lib import SmhiForecast, SmhiForecastException from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, ATTR_CONDITION_FOG, @@ -55,11 +56,11 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, slugify +from homeassistant.util import Throttle, dt as dt_util, slugify from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT @@ -189,6 +190,10 @@ class SmhiWeather(WeatherEntity): self._attr_native_wind_gust_speed = self._forecast_daily[0].wind_gust self._attr_cloud_coverage = self._forecast_daily[0].cloudiness self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0].symbol) + if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( + self.hass + ): + self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT await self.async_update_listeners(("daily", "hourly")) async def retry_update(self, _: datetime) -> None: @@ -206,6 +211,10 @@ class SmhiWeather(WeatherEntity): for forecast in forecast_data[1:]: condition = CONDITION_MAP.get(forecast.symbol) + if condition == ATTR_CONDITION_SUNNY and not sun.is_up( + self.hass, forecast.valid_time.replace(tzinfo=dt_util.UTC) + ): + condition = ATTR_CONDITION_CLEAR_NIGHT data.append( { diff --git a/homeassistant/components/sms/config_flow.py b/homeassistant/components/sms/config_flow.py index ff509bbbb97..aec9674da9d 100644 --- a/homeassistant/components/sms/config_flow.py +++ b/homeassistant/components/sms/config_flow.py @@ -66,7 +66,7 @@ class SMSFlowHandler(ConfigFlow, domain=DOMAIN): imei = await get_imei_from_config(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 1ed1f66570f..60962f198b2 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -174,7 +174,7 @@ class Gateway: """Get the model of the modem.""" model = await self._worker.get_model_async() if not model or not model[0]: - return + return None display = model[0] # Identification model if model[1]: # Real model display = f"{display} ({model[1]})" @@ -184,7 +184,7 @@ class Gateway: """Get the firmware information of the modem.""" firmware = await self._worker.get_firmware_async() if not firmware or not firmware[0]: - return + return None display = firmware[0] # Version if firmware[1]: # Date display = f"{display} ({firmware[1]})" diff --git a/homeassistant/components/snmp/__init__.py b/homeassistant/components/snmp/__init__.py index a4c922877f3..4a049ee1553 100644 --- a/homeassistant/components/snmp/__init__.py +++ b/homeassistant/components/snmp/__init__.py @@ -1 +1,5 @@ """The snmp component.""" + +from .util import async_get_snmp_engine + +__all__ = ["async_get_snmp_engine"] diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index a1a91116f0f..d336838117f 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -4,14 +4,11 @@ from __future__ import annotations import binascii import logging +from typing import TYPE_CHECKING from pysnmp.error import PySnmpError from pysnmp.hlapi.asyncio import ( CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, Udp6TransportTarget, UdpTransportTarget, UsmUserData, @@ -43,6 +40,7 @@ from .const import ( DEFAULT_VERSION, SNMP_VERSIONS, ) +from .util import RequestArgsType, async_create_request_cmd_args _LOGGER = logging.getLogger(__name__) @@ -62,7 +60,7 @@ async def async_get_scanner( ) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" scanner = SnmpScanner(config[DOMAIN]) - await scanner.async_init() + await scanner.async_init(hass) return scanner if scanner.success_init else None @@ -99,33 +97,29 @@ class SnmpScanner(DeviceScanner): if not privkey: privproto = "none" - request_args = [ - SnmpEngine(), - UsmUserData( - community, - authKey=authkey or None, - privKey=privkey or None, - authProtocol=authproto, - privProtocol=privproto, - ), - target, - ContextData(), - ] + self._auth_data = UsmUserData( + community, + authKey=authkey or None, + privKey=privkey or None, + authProtocol=authproto, + privProtocol=privproto, + ) else: - request_args = [ - SnmpEngine(), - CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]), - target, - ContextData(), - ] + self._auth_data = CommunityData( + community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION] + ) - self.request_args = request_args + self._target = target + self.request_args: RequestArgsType | None = None self.baseoid = baseoid self.last_results = [] self.success_init = False - async def async_init(self): + async def async_init(self, hass: HomeAssistant) -> None: """Make a one-off read to check if the target device is reachable and readable.""" + self.request_args = await async_create_request_cmd_args( + hass, self._auth_data, self._target, self.baseoid + ) data = await self.async_get_snmp_data() self.success_init = data is not None @@ -156,25 +150,31 @@ class SnmpScanner(DeviceScanner): async def async_get_snmp_data(self): """Fetch MAC addresses from access point via SNMP.""" devices = [] + if TYPE_CHECKING: + assert self.request_args is not None + engine, auth_data, target, context_data, object_type = self.request_args walker = bulkWalkCmd( - *self.request_args, + engine, + auth_data, + target, + context_data, 0, 50, - ObjectType(ObjectIdentity(self.baseoid)), + object_type, lexicographicMode=False, ) async for errindication, errstatus, errindex, res in walker: if errindication: _LOGGER.error("SNMPLIB error: %s", errindication) - return + return None if errstatus: _LOGGER.error( "SNMP error: %s at %s", errstatus.prettyPrint(), errindex and res[int(errindex) - 1][0] or "?", ) - return + return None for _oid, value in res: if not isEndOfMib(res): diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 972b9131935..0e5b215dcd4 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -11,10 +11,6 @@ from pysnmp.error import PySnmpError import pysnmp.hlapi.asyncio as hlapi from pysnmp.hlapi.asyncio import ( CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, Udp6TransportTarget, UdpTransportTarget, UsmUserData, @@ -71,6 +67,7 @@ from .const import ( MAP_PRIV_PROTOCOLS, SNMP_VERSIONS, ) +from .util import async_create_request_cmd_args _LOGGER = logging.getLogger(__name__) @@ -119,7 +116,7 @@ async def async_setup_platform( host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) - baseoid = config.get(CONF_BASEOID) + baseoid: str = config[CONF_BASEOID] version = config[CONF_VERSION] username = config.get(CONF_USERNAME) authkey = config.get(CONF_AUTH_KEY) @@ -145,27 +142,18 @@ async def async_setup_platform( authproto = "none" if not privkey: privproto = "none" - - request_args = [ - SnmpEngine(), - UsmUserData( - username, - authKey=authkey or None, - privKey=privkey or None, - authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), - privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), - ), - target, - ContextData(), - ] + auth_data = UsmUserData( + username, + authKey=authkey or None, + privKey=privkey or None, + authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), + privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), + ) else: - request_args = [ - SnmpEngine(), - CommunityData(community, mpModel=SNMP_VERSIONS[version]), - target, - ContextData(), - ] - get_result = await getCmd(*request_args, ObjectType(ObjectIdentity(baseoid))) + auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) + + request_args = await async_create_request_cmd_args(hass, auth_data, target, baseoid) + get_result = await getCmd(*request_args) errindication, _, _, _ = get_result if errindication and not accept_errors: @@ -244,9 +232,7 @@ class SnmpData: async def async_update(self): """Get the latest data from the remote SNMP capable host.""" - get_result = await getCmd( - *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) - ) + get_result = await getCmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: @@ -289,8 +275,7 @@ class SnmpData: try: decoded_value, _ = decoder.decode(bytes(value)) return str(decoded_value) - # pylint: disable=broad-except - except Exception as decode_exception: + except Exception as decode_exception: # noqa: BLE001 _LOGGER.error( "SNMP error in decoding opaque type: %s", decode_exception ) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index a447cdc8e9c..02a94aeb8c1 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -8,10 +8,8 @@ from typing import Any import pysnmp.hlapi.asyncio as hlapi from pysnmp.hlapi.asyncio import ( CommunityData, - ContextData, ObjectIdentity, ObjectType, - SnmpEngine, UdpTransportTarget, UsmUserData, getCmd, @@ -67,6 +65,12 @@ from .const import ( MAP_PRIV_PROTOCOLS, SNMP_VERSIONS, ) +from .util import ( + CommandArgsType, + RequestArgsType, + async_create_command_cmd_args, + async_create_request_cmd_args, +) _LOGGER = logging.getLogger(__name__) @@ -128,23 +132,45 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP switch.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + name: str = config[CONF_NAME] + host: str = config[CONF_HOST] + port: int = config[CONF_PORT] community = config.get(CONF_COMMUNITY) - baseoid = config.get(CONF_BASEOID) - command_oid = config.get(CONF_COMMAND_OID) - command_payload_on = config.get(CONF_COMMAND_PAYLOAD_ON) - command_payload_off = config.get(CONF_COMMAND_PAYLOAD_OFF) - version = config.get(CONF_VERSION) + baseoid: str = config[CONF_BASEOID] + command_oid: str | None = config.get(CONF_COMMAND_OID) + command_payload_on: str | None = config.get(CONF_COMMAND_PAYLOAD_ON) + command_payload_off: str | None = config.get(CONF_COMMAND_PAYLOAD_OFF) + version: str = config[CONF_VERSION] username = config.get(CONF_USERNAME) authkey = config.get(CONF_AUTH_KEY) - authproto = config.get(CONF_AUTH_PROTOCOL) + authproto: str = config[CONF_AUTH_PROTOCOL] privkey = config.get(CONF_PRIV_KEY) - privproto = config.get(CONF_PRIV_PROTOCOL) - payload_on = config.get(CONF_PAYLOAD_ON) - payload_off = config.get(CONF_PAYLOAD_OFF) - vartype = config.get(CONF_VARTYPE) + privproto: str = config[CONF_PRIV_PROTOCOL] + payload_on: str = config[CONF_PAYLOAD_ON] + payload_off: str = config[CONF_PAYLOAD_OFF] + vartype: str = config[CONF_VARTYPE] + + if version == "3": + if not authkey: + authproto = "none" + if not privkey: + privproto = "none" + + auth_data = UsmUserData( + username, + authKey=authkey or None, + privKey=privkey or None, + authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), + privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), + ) + else: + auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) + + transport = UdpTransportTarget((host, port)) + request_args = await async_create_request_cmd_args( + hass, auth_data, transport, baseoid + ) + command_args = await async_create_command_cmd_args(hass, auth_data, transport) async_add_entities( [ @@ -152,20 +178,15 @@ async def async_setup_platform( name, host, port, - community, baseoid, command_oid, - version, - username, - authkey, - authproto, - privkey, - privproto, payload_on, payload_off, command_payload_on, command_payload_off, vartype, + request_args, + command_args, ) ], True, @@ -177,27 +198,22 @@ class SnmpSwitch(SwitchEntity): def __init__( self, - name, - host, - port, - community, - baseoid, - commandoid, - version, - username, - authkey, - authproto, - privkey, - privproto, - payload_on, - payload_off, - command_payload_on, - command_payload_off, - vartype, - ): + name: str, + host: str, + port: int, + baseoid: str, + commandoid: str | None, + payload_on: str, + payload_off: str, + command_payload_on: str | None, + command_payload_off: str | None, + vartype: str, + request_args: RequestArgsType, + command_args: CommandArgsType, + ) -> None: """Initialize the switch.""" - self._name = name + self._attr_name = name self._baseoid = baseoid self._vartype = vartype @@ -206,35 +222,12 @@ class SnmpSwitch(SwitchEntity): self._command_payload_on = command_payload_on or payload_on self._command_payload_off = command_payload_off or payload_off - self._state = None + self._state: bool | None = None self._payload_on = payload_on self._payload_off = payload_off - - if version == "3": - if not authkey: - authproto = "none" - if not privkey: - privproto = "none" - - self._request_args = [ - SnmpEngine(), - UsmUserData( - username, - authKey=authkey or None, - privKey=privkey or None, - authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), - privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), - ), - UdpTransportTarget((host, port)), - ContextData(), - ] - else: - self._request_args = [ - SnmpEngine(), - CommunityData(community, mpModel=SNMP_VERSIONS[version]), - UdpTransportTarget((host, port)), - ContextData(), - ] + self._target = UdpTransportTarget((host, port)) + self._request_args = request_args + self._command_args = command_args async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" @@ -245,7 +238,7 @@ class SnmpSwitch(SwitchEntity): """Turn off the switch.""" await self._execute_command(self._command_payload_off) - async def _execute_command(self, command): + async def _execute_command(self, command: str) -> None: # User did not set vartype and command is not a digit if self._vartype == "none" and not self._command_payload_on.isdigit(): await self._set(command) @@ -259,9 +252,7 @@ class SnmpSwitch(SwitchEntity): async def async_update(self) -> None: """Update the state.""" - get_result = await getCmd( - *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) - ) + get_result = await getCmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication: @@ -286,16 +277,12 @@ class SnmpSwitch(SwitchEntity): self._state = None @property - def name(self): - """Return the switch's name.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if switch is on; False if off. None if unknown.""" return self._state - async def _set(self, value): + async def _set(self, value: Any) -> None: + """Set the state of the switch.""" await setCmd( - *self._request_args, ObjectType(ObjectIdentity(self._commandoid), value) + *self._command_args, ObjectType(ObjectIdentity(self._commandoid), value) ) diff --git a/homeassistant/components/snmp/util.py b/homeassistant/components/snmp/util.py new file mode 100644 index 00000000000..dd3e9a6b6d2 --- /dev/null +++ b/homeassistant/components/snmp/util.py @@ -0,0 +1,98 @@ +"""Support for displaying collected data over SNMP.""" + +from __future__ import annotations + +import logging + +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, +) +from pysnmp.hlapi.asyncio.cmdgen import lcd, vbProcessor +from pysnmp.smi.builder import MibBuilder + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.singleton import singleton + +DATA_SNMP_ENGINE = "snmp_engine" + +_LOGGER = logging.getLogger(__name__) + +type CommandArgsType = tuple[ + SnmpEngine, + UsmUserData | CommunityData, + UdpTransportTarget | Udp6TransportTarget, + ContextData, +] + + +type RequestArgsType = tuple[ + SnmpEngine, + UsmUserData | CommunityData, + UdpTransportTarget | Udp6TransportTarget, + ContextData, + ObjectType, +] + + +async def async_create_command_cmd_args( + hass: HomeAssistant, + auth_data: UsmUserData | CommunityData, + target: UdpTransportTarget | Udp6TransportTarget, +) -> CommandArgsType: + """Create command arguments. + + The ObjectType needs to be created dynamically by the caller. + """ + engine = await async_get_snmp_engine(hass) + return (engine, auth_data, target, ContextData()) + + +async def async_create_request_cmd_args( + hass: HomeAssistant, + auth_data: UsmUserData | CommunityData, + target: UdpTransportTarget | Udp6TransportTarget, + object_id: str, +) -> RequestArgsType: + """Create request arguments. + + The same ObjectType is used for all requests. + """ + engine, auth_data, target, context_data = await async_create_command_cmd_args( + hass, auth_data, target + ) + object_type = ObjectType(ObjectIdentity(object_id)) + return (engine, auth_data, target, context_data, object_type) + + +@singleton(DATA_SNMP_ENGINE) +async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: + """Get the SNMP engine.""" + engine = await hass.async_add_executor_job(_get_snmp_engine) + + @callback + def _async_shutdown_listener(ev: Event) -> None: + _LOGGER.debug("Unconfiguring SNMP engine") + lcd.unconfigure(engine, None) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_listener) + return engine + + +def _get_snmp_engine() -> SnmpEngine: + """Return a cached instance of SnmpEngine.""" + engine = SnmpEngine() + mib_controller = vbProcessor.getMibViewController(engine) + # Actually load the MIBs from disk so we do + # not do it in the event loop + builder: MibBuilder = mib_controller.mibBuilder + if "PYSNMP-MIB" not in builder.mibSymbols: + builder.loadModules() + return engine diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 2799d303a19..ae009410692 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -232,7 +232,9 @@ def setup_platform( # Changing inverter temperature unit. inverter_temp_description = SENSOR_TYPE_INVERTER_TEMPERATURE - if status.inverters.primary.temperature.units.farenheit: + if ( + status.inverters.primary.temperature.units.farenheit # codespell:ignore farenheit + ): inverter_temp_description = dataclasses.replace( inverter_temp_description, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index d2a3c50295c..6975a420732 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,12 +1,17 @@ """Solar-Log integration.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .const import DOMAIN from .coordinator import SolarlogData +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.SENSOR] @@ -22,3 +27,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +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: + if config_entry.minor_version < 2: + # migrate old entity unique id + entity_reg = er.async_get(hass) + entities: list[er.RegistryEntry] = er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + + for entity in entities: + if "time" in entity.unique_id: + new_uid = entity.unique_id.replace("time", "last_updated") + _LOGGER.debug( + "migrate unique id '%s' to '%s'", entity.unique_id, new_uid + ) + entity_reg.async_update_entity( + entity.entity_id, new_unique_id=new_uid + ) + + # migrate config_entry + new = {**config_entry.data} + new["extended_data"] = False + + hass.config_entries.async_update_entry( + config_entry, data=new, minor_version=2, version=1 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index 40343b5ac12..eb0971e0d92 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -1,13 +1,14 @@ """Config flow for solarlog integration.""" import logging +from typing import TYPE_CHECKING, Any from urllib.parse import ParseResult, urlparse -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog +from solarlog_cli.solarlog_connector import SolarLogConnector +from solarlog_cli.solarlog_exceptions import SolarLogConnectionError, SolarLogError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -29,6 +30,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for solarlog.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -40,37 +42,44 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): return True return False + def _parse_url(self, host: str) -> str: + """Return parsed host url.""" + url = urlparse(host, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + return url.geturl() + async def _test_connection(self, host): """Check if we can connect to the Solar-Log device.""" + solarlog = SolarLogConnector(host) try: - await self.hass.async_add_executor_job(SolarLog, host) - except (OSError, HTTPError, Timeout): - self._errors[CONF_HOST] = "cannot_connect" - _LOGGER.error( - "Could not connect to Solar-Log device at %s, check host ip address", - host, - ) + await solarlog.test_connection() + except SolarLogConnectionError: + self._errors = {CONF_HOST: "cannot_connect"} return False + except SolarLogError: # pylint: disable=broad-except + self._errors = {CONF_HOST: "unknown"} + return False + finally: + await solarlog.client.close() + return True - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Step when user initializes a integration.""" self._errors = {} if user_input is not None: # set some defaults in case we need to return to the form - name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) - host_entry = user_input.get(CONF_HOST, DEFAULT_HOST) + user_input[CONF_NAME] = slugify(user_input[CONF_NAME]) + user_input[CONF_HOST] = self._parse_url(user_input[CONF_HOST]) - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - host = url.geturl() - - if self._host_in_configuration_exists(host): + if self._host_in_configuration_exists(user_input[CONF_HOST]): self._errors[CONF_HOST] = "already_configured" - elif await self._test_connection(host): - return self.async_create_entry(title=name, data={CONF_HOST: host}) + elif await self._test_connection(user_input[CONF_HOST]): + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) else: user_input = {} user_input[CONF_NAME] = DEFAULT_NAME @@ -86,21 +95,53 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required( CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) ): str, + vol.Required("extended_data", default=False): bool, } ), errors=self._errors, ) - async def async_step_import(self, user_input=None): + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Import a config entry.""" - host_entry = user_input.get(CONF_HOST, DEFAULT_HOST) - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - host = url.geturl() + user_input = { + CONF_HOST: DEFAULT_HOST, + CONF_NAME: DEFAULT_NAME, + "extended_data": False, + **user_input, + } - if self._host_in_configuration_exists(host): + user_input[CONF_HOST] = self._parse_url(user_input[CONF_HOST]) + + if self._host_in_configuration_exists(user_input[CONF_HOST]): return self.async_abort(reason="already_configured") + return await self.async_step_user(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + + if TYPE_CHECKING: + assert entry is not None + + if user_input is not None: + return self.async_update_reload_and_abort( + entry, + reason="reconfigure_successful", + data={**entry.data, **user_input}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required( + "extended_data", default=entry.data["extended_data"] + ): bool, + } + ), + ) diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 6af7c96302d..794e556add5 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -4,12 +4,16 @@ from datetime import timedelta import logging from urllib.parse import ParseResult, urlparse -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog +from solarlog_cli.solarlog_connector import SolarLogConnector +from solarlog_cli.solarlog_exceptions import ( + SolarLogConnectionError, + SolarLogUpdateError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import update_coordinator _LOGGER = logging.getLogger(__name__) @@ -34,24 +38,23 @@ class SolarlogData(update_coordinator.DataUpdateCoordinator): self.name = entry.title self.host = url.geturl() - async def _async_update_data(self): - """Update the data from the SolarLog device.""" - try: - data = await self.hass.async_add_executor_job(SolarLog, self.host) - except (OSError, Timeout, HTTPError) as err: - raise update_coordinator.UpdateFailed(err) from err + extended_data = entry.data["extended_data"] - if data.time.year == 1999: - raise update_coordinator.UpdateFailed( - "Invalid data returned (can happen after Solarlog restart)." - ) - - self.logger.debug( - ( - "Connection to Solarlog successful. Retrieving latest Solarlog update" - " of %s" - ), - data.time, + self.solarlog = SolarLogConnector( + self.host, extended_data, hass.config.time_zone ) + async def _async_update_data(self): + """Update the data from the SolarLog device.""" + _LOGGER.debug("Start data update") + + try: + data = await self.solarlog.update_data() + except SolarLogConnectionError as err: + raise ConfigEntryNotReady(err) from err + except SolarLogUpdateError as err: + raise update_coordinator.UpdateFailed(err) from err + + _LOGGER.debug("Data successfully updated") + return data diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 78075123996..0878d652f43 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -1,10 +1,10 @@ { "domain": "solarlog", "name": "Solar-Log", - "codeowners": ["@Ernst79"], + "codeowners": ["@Ernst79", "@dontinelli"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/solarlog", "iot_class": "local_polling", - "loggers": ["sunwatcher"], - "requirements": ["sunwatcher==0.2.1"] + "loggers": ["solarlog_cli"], + "requirements": ["solarlog_cli==0.1.5"] } diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index dcb4afcb863..a0d6d4bc540 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -21,7 +21,6 @@ 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 homeassistant.util.dt import as_local from . import SolarlogData from .const import DOMAIN @@ -36,10 +35,9 @@ class SolarLogSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( SolarLogSensorEntityDescription( - key="time", + key="last_updated", translation_key="last_update", device_class=SensorDeviceClass.TIMESTAMP, - value=as_local, ), SolarLogSensorEntityDescription( key="power_ac", @@ -148,6 +146,13 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, value=lambda value: round(value / 1000, 3), ), + SolarLogSensorEntityDescription( + key="self_consumption_year", + translation_key="self_consumption_year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), SolarLogSensorEntityDescription( key="total_power", translation_key="total_power", @@ -231,7 +236,8 @@ class SolarlogSensor(CoordinatorEntity[SolarlogData], SensorEntity): @property def native_value(self): """Return the native sensor value.""" - raw_attr = getattr(self.coordinator.data, self.entity_description.key) + raw_attr = self.coordinator.data.get(self.entity_description.key) + if self.entity_description.value: return self.entity_description.value(raw_attr) return raw_attr diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 5f5e2ae7a5f..f5f5e064294 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -5,19 +5,28 @@ "title": "Define your Solar-Log connection", "data": { "host": "[%key:common::config_flow::data::host%]", - "name": "The prefix to be used for your Solar-Log sensors" + "name": "The prefix to be used for your Solar-Log sensors", + "extended_data": "Get additional data from Solar-Log. Extended data is only accessible, if no password is set for the Solar-Log. Use at your own risk!" }, "data_description": { "host": "The hostname or IP address of your Solar-Log device." } + }, + "reconfigure": { + "title": "Configure SolarLog", + "data": { + "extended_data": "[%key:component::solarlog::config::step::user::data::extended_data%]" + } } }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "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%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { @@ -70,6 +79,9 @@ "consumption_total": { "name": "Consumption total" }, + "self_consumption_year": { + "name": "Self-consumption year" + }, "total_power": { "name": "Installed peak power" }, diff --git a/homeassistant/components/solax/__init__.py b/homeassistant/components/solax/__init__.py index b5e15043cec..253f3b55e0a 100644 --- a/homeassistant/components/solax/__init__.py +++ b/homeassistant/components/solax/__init__.py @@ -1,18 +1,39 @@ """The solax component.""" -from solax import real_time_api +from dataclasses import dataclass +from datetime import timedelta +import logging + +from solax import InverterResponse, RealTimeAPI, real_time_api +from solax.inverter import InverterError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import UpdateFailed -from .const import DOMAIN +from .coordinator import SolaxDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +SCAN_INTERVAL = timedelta(seconds=30) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass(slots=True) +class SolaxData: + """Class for storing solax data.""" + + api: RealTimeAPI + coordinator: SolaxDataUpdateCoordinator + + +type SolaxConfigEntry = ConfigEntry[SolaxData] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: SolaxConfigEntry) -> bool: """Set up the sensors from a ConfigEntry.""" try: @@ -21,19 +42,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PORT], entry.data[CONF_PASSWORD], ) - await api.get_data() except Exception as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api + async def _async_update() -> InverterResponse: + try: + return await api.get_data() + except InverterError as err: + raise UpdateFailed from err + + coordinator = SolaxDataUpdateCoordinator( + hass, + logger=_LOGGER, + name=f"solax {entry.title}", + update_interval=SCAN_INTERVAL, + update_method=_async_update, + ) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = SolaxData(api=api, coordinator=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, entry: SolaxConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/solax/config_flow.py b/homeassistant/components/solax/config_flow.py index 4055f1c46ae..e6c60667869 100644 --- a/homeassistant/components/solax/config_flow.py +++ b/homeassistant/components/solax/config_flow.py @@ -56,7 +56,7 @@ class SolaxConfigFlow(ConfigFlow, domain=DOMAIN): serial_number = await validate_api(user_input) except (ConnectionError, DiscoveryError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/solax/coordinator.py b/homeassistant/components/solax/coordinator.py new file mode 100644 index 00000000000..9dd4dfb109f --- /dev/null +++ b/homeassistant/components/solax/coordinator.py @@ -0,0 +1,9 @@ +"""Constants for the solax integration.""" + +from solax import InverterResponse + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +class SolaxDataUpdateCoordinator(DataUpdateCoordinator[InverterResponse]): + """DataUpdateCoordinator for solax.""" diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index be81dd65e89..2ca246a4e77 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solax", "iot_class": "local_polling", "loggers": ["solax"], - "requirements": ["solax==3.1.0"] + "requirements": ["solax==3.1.1"] } diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index a8c09bdc880..6ca0bac0c38 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -2,11 +2,6 @@ from __future__ import annotations -import asyncio -from datetime import timedelta - -from solax import RealTimeAPI -from solax.inverter import InverterError from solax.units import Units from homeassistant.components.sensor import ( @@ -15,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfElectricCurrent, @@ -26,15 +20,15 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import SolaxConfigEntry from .const import DOMAIN, MANUFACTURER +from .coordinator import SolaxDataUpdateCoordinator DEFAULT_PORT = 80 -SCAN_INTERVAL = timedelta(seconds=30) SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = { @@ -94,28 +88,23 @@ SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SolaxConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Entry setup.""" - api: RealTimeAPI = hass.data[DOMAIN][entry.entry_id] - resp = await api.get_data() + api = entry.runtime_data.api + coordinator = entry.runtime_data.coordinator + resp = coordinator.data serial = resp.serial_number version = resp.version - endpoint = RealTimeDataEndpoint(hass, api) - entry.async_create_background_task( - hass, endpoint.async_refresh(), f"solax {entry.title} initial refresh" - ) - entry.async_on_unload( - async_track_time_interval(hass, endpoint.async_refresh, SCAN_INTERVAL) - ) - devices = [] + entities: list[InverterSensorEntity] = [] for sensor, (idx, measurement) in api.inverter.sensor_map().items(): description = SENSOR_DESCRIPTIONS[(measurement.unit, measurement.is_monotonic)] uid = f"{serial}-{idx}" - devices.append( - Inverter( + entities.append( + InverterSensorEntity( + coordinator, api.inverter.manufacturer, uid, serial, @@ -126,57 +115,28 @@ async def async_setup_entry( description.device_class, ) ) - endpoint.sensors = devices - async_add_entities(devices) + async_add_entities(entities) -class RealTimeDataEndpoint: - """Representation of a Sensor.""" - - def __init__(self, hass: HomeAssistant, api: RealTimeAPI) -> None: - """Initialize the sensor.""" - self.hass = hass - self.api = api - self.ready = asyncio.Event() - self.sensors: list[Inverter] = [] - - async def async_refresh(self, now=None): - """Fetch new state data for the sensor. - - This is the only method that should fetch new data for Home Assistant. - """ - try: - api_response = await self.api.get_data() - self.ready.set() - except InverterError as err: - if now is not None: - self.ready.clear() - return - raise PlatformNotReady from err - data = api_response.data - for sensor in self.sensors: - if sensor.key in data: - sensor.value = data[sensor.key] - sensor.async_schedule_update_ha_state() - - -class Inverter(SensorEntity): +class InverterSensorEntity(CoordinatorEntity, SensorEntity): """Class for a sensor.""" _attr_should_poll = False def __init__( self, - manufacturer, - uid, - serial, - version, - key, - unit, - state_class=None, - device_class=None, - ): + coordinator: SolaxDataUpdateCoordinator, + manufacturer: str, + uid: str, + serial: str, + version: str, + key: str, + unit: str | None, + state_class: SensorStateClass | str | None, + device_class: SensorDeviceClass | None, + ) -> None: """Initialize an inverter sensor.""" + super().__init__(coordinator) self._attr_unique_id = uid self._attr_name = f"{manufacturer} {serial} {key}" self._attr_native_unit_of_measurement = unit @@ -189,9 +149,8 @@ class Inverter(SensorEntity): sw_version=version, ) self.key = key - self.value = None @property def native_value(self): """State of this inverter attribute.""" - return self.value + return self.coordinator.data.data[self.key] diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index cd282a9f276..7b14aaa3c81 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import logging -from typing import Any, TypeVar +from typing import Any from api.soma_api import SomaApi from requests import RequestException @@ -22,8 +22,6 @@ from homeassistant.helpers.typing import ConfigType from .const import API, DOMAIN, HOST, PORT from .utils import is_api_response_success -_SomaEntityT = TypeVar("_SomaEntityT", bound="SomaEntity") - _LOGGER = logging.getLogger(__name__) DEVICES = "devices" @@ -76,7 +74,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -def soma_api_call( +def soma_api_call[_SomaEntityT: SomaEntity]( api_call: Callable[[_SomaEntityT], Coroutine[Any, Any, dict]], ) -> Callable[[_SomaEntityT], Coroutine[Any, Any, dict]]: """Soma api call decorator.""" diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 6e68be45dff..a13f036210d 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -95,7 +95,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index 9e84d040ad1..84bae85571e 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -109,7 +109,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": "invalid_auth"} except ArrException: errors = {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index d3ce934ec51..9f828591a08 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -140,7 +140,12 @@ class SongpalEntity(MediaPlayerEntity): async def _get_sound_modes_info(self): """Get available sound modes and the active one.""" - settings = await self._dev.get_sound_settings("soundField") + for settings in await self._dev.get_sound_settings(): + if settings.target == "soundField": + break + else: + return None, {} + if isinstance(settings, Setting): settings = [settings] @@ -396,7 +401,7 @@ class SongpalEntity(MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn the device on.""" try: - return await self._dev.set_power(True) + await self._dev.set_power(True) except SongpalException as ex: if ex.code == ERROR_REQUEST_RETRY: _LOGGER.debug( @@ -408,7 +413,7 @@ class SongpalEntity(MediaPlayerEntity): async def async_turn_off(self) -> None: """Turn the device off.""" try: - return await self._dev.set_power(False) + await self._dev.set_power(False) except SongpalException as ex: if ex.code == ERROR_REQUEST_RETRY: _LOGGER.debug( diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 2070d37b1a4..8ced5a87b28 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload +from typing import TYPE_CHECKING, Any, Concatenate, overload from requests.exceptions import Timeout from soco import SoCo @@ -26,29 +26,26 @@ UID_POSTFIX = "01400" _LOGGER = logging.getLogger(__name__) -_T = TypeVar( - "_T", bound="SonosSpeaker | SonosMedia | SonosEntity | SonosHouseholdCoordinator" +type _SonosEntitiesType = ( + SonosSpeaker | SonosMedia | SonosEntity | SonosHouseholdCoordinator ) -_R = TypeVar("_R") -_P = ParamSpec("_P") - -_FuncType = Callable[Concatenate[_T, _P], _R] -_ReturnFuncType = Callable[Concatenate[_T, _P], _R | None] +type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] +type _ReturnFuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R | None] @overload -def soco_error( +def soco_error[_T: _SonosEntitiesType, **_P, _R]( errorcodes: None = ..., ) -> Callable[[_FuncType[_T, _P, _R]], _FuncType[_T, _P, _R]]: ... @overload -def soco_error( +def soco_error[_T: _SonosEntitiesType, **_P, _R]( errorcodes: list[str], ) -> Callable[[_FuncType[_T, _P, _R]], _ReturnFuncType[_T, _P, _R]]: ... -def soco_error( +def soco_error[_T: _SonosEntitiesType, **_P, _R]( errorcodes: list[str] | None = None, ) -> Callable[[_FuncType[_T, _P, _R]], _ReturnFuncType[_T, _P, _R]]: """Filter out specified UPnP errors and raise exceptions for service calls.""" @@ -103,7 +100,7 @@ def _find_target_identifier(instance: Any, fallback_soco: SoCo | None) -> str | if soco := getattr(instance, "soco", fallback_soco): # Holds a SoCo instance attribute # Only use attributes with no I/O - return soco._player_name or soco.ip_address # pylint: disable=protected-access + return soco._player_name or soco.ip_address # noqa: SLF001 return None diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index ec5ef90a0c1..d6c5eb298d8 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.30.3", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/media.py b/homeassistant/components/sonos/media.py index 1f5432c440b..6e8c629560b 100644 --- a/homeassistant/components/sonos/media.py +++ b/homeassistant/components/sonos/media.py @@ -44,7 +44,7 @@ DURATION_SECONDS = "duration_in_s" POSITION_SECONDS = "position_in_s" -def _timespan_secs(timespan: str | None) -> None | int: +def _timespan_secs(timespan: str | None) -> int | None: """Parse a time-span into number of seconds.""" if timespan in UNAVAILABLE_VALUES: return None diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index eeadd7db232..995d6cea08c 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -43,7 +43,33 @@ from .speaker import SonosMedia, SonosSpeaker _LOGGER = logging.getLogger(__name__) -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] +type GetBrowseImageUrlType = Callable[[str, str, str | None], str] + + +def fix_image_url(url: str) -> str: + """Update the image url to fully encode characters to allow image display in media_browser UI. + + Images whose file path contains characters such as ',()+ are not loaded without escaping them. + """ + + # Before parsing encode the plus sign; otherwise it'll be interpreted as a space. + original_url: str = urllib.parse.unquote(url).replace("+", "%2B") + parsed_url = urllib.parse.urlparse(original_url) + query_params = urllib.parse.parse_qsl(parsed_url.query) + new_url = urllib.parse.urlunsplit( + ( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + urllib.parse.urlencode( + query_params, quote_via=urllib.parse.quote, safe="/:" + ), + "", + ) + ) + if original_url != new_url: + _LOGGER.debug("fix_sonos_image_url original: %s new: %s", original_url, new_url) + return new_url def get_thumbnail_url_full( @@ -53,15 +79,17 @@ def get_thumbnail_url_full( media_content_type: str, media_content_id: str, media_image_id: str | None = None, + item: MusicServiceItem | None = None, ) -> str | None: """Get thumbnail URL.""" if is_internal: - item = get_media( - media.library, - media_content_id, - media_content_type, - ) - return urllib.parse.unquote(getattr(item, "album_art_uri", "")) + if not item: + item = get_media( + media.library, + media_content_id, + media_content_type, + ) + return fix_image_url(getattr(item, "album_art_uri", "")) return urllib.parse.unquote( get_browse_image_url( @@ -255,7 +283,7 @@ def item_payload(item: DidlObject, get_thumbnail_url=None) -> BrowseMedia: content_id = get_content_id(item) thumbnail = None if getattr(item, "album_art_uri", None): - thumbnail = get_thumbnail_url(media_class, content_id) + thumbnail = get_thumbnail_url(media_class, content_id, item=item) return BrowseMedia( title=item.title, diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 35c6be3fa6b..e9fbb152b7a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -39,7 +39,7 @@ from homeassistant.components.plex.services import process_plex_payload from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -432,7 +432,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): fav = [fav for fav in self.speaker.favorites if fav.title == name] if len(fav) != 1: - return + raise ServiceValidationError( + translation_domain=SONOS_DOMAIN, + translation_key="invalid_favorite", + translation_placeholders={ + "name": name, + }, + ) src = fav.pop() self._play_favorite(src) @@ -445,7 +451,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): MUSIC_SRC_RADIO, MUSIC_SRC_LINE_IN, ]: - soco.play_uri(uri, title=favorite.title) + soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT) else: soco.clear_queue() soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT) diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index f9e9fc8bee0..272218cc01e 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -28,7 +28,7 @@ LEVEL_TYPES = { "music_surround_level": (-15, 15), } -SocoFeatures = list[tuple[str, tuple[int, int]]] +type SocoFeatures = list[tuple[str, tuple[int, int]]] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index e2529ddfe94..d77100a2236 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -830,8 +830,10 @@ class SonosSpeaker: if "zone_player_uui_ds_in_group" not in event.variables: return self.event_stats.process(event) - self.hass.async_create_task( - self.create_update_groups_coro(event), eager_start=True + self.hass.async_create_background_task( + self.create_update_groups_coro(event), + name=f"sonos group update {self.zone_name}", + eager_start=True, ) def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine: diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 6f45195c46b..6521302b007 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -173,5 +173,10 @@ } } } + }, + "exceptions": { + "invalid_favorite": { + "message": "Could not find a Sonos favorite: {name}" + } } } diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index d7f783b550d..7e584ff5e63 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -6,8 +6,10 @@ from pyspcwebgw import SpcWebGateway from pyspcwebgw.area import Area from pyspcwebgw.const import AreaMode -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -51,7 +53,7 @@ async def async_setup_platform( async_add_entities([SpcAlarm(area=area, api=api) for area in api.areas.values()]) -class SpcAlarm(alarm.AlarmControlPanelEntity): +class SpcAlarm(AlarmControlPanelEntity): """Representation of the SPC alarm panel.""" _attr_should_poll = False @@ -60,6 +62,7 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False def __init__(self, area: Area, api: SpcWebGateway) -> None: """Initialize the SPC alarm panel.""" diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 19525ad9bfa..aed1cce33db 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -16,7 +16,7 @@ from .coordinator import SpeedTestDataCoordinator PLATFORMS = [Platform.SENSOR] -SpeedTestConfigEntry = ConfigEntry[SpeedTestDataCoordinator] +type SpeedTestConfigEntry = ConfigEntry[SpeedTestDataCoordinator] async def async_setup_entry( diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 8d5183a459d..becf90b04cd 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from typing import Any @@ -22,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .browse_media import async_browse_media from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES +from .models import HomeAssistantSpotifyData from .util import ( is_spotify_media_type, resolve_spotify_media_type, @@ -30,7 +30,6 @@ from .util import ( PLATFORMS = [Platform.MEDIA_PLAYER] - __all__ = [ "async_browse_media", "DOMAIN", @@ -40,17 +39,10 @@ __all__ = [ ] -@dataclass -class HomeAssistantSpotifyData: - """Spotify data stored in the Home Assistant data object.""" - - client: Spotify - current_user: dict[str, Any] - devices: DataUpdateCoordinator[list[dict[str, Any]]] - session: OAuth2Session +type SpotifyConfigEntry = ConfigEntry[HomeAssistantSpotifyData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: """Set up Spotify from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) @@ -100,8 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await device_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = HomeAssistantSpotifyData( + entry.runtime_data = HomeAssistantSpotifyData( client=spotify, current_user=current_user, devices=device_coordinator, @@ -117,6 +108,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Spotify config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index cc8f57be1bb..cff7cae5ebd 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES +from .models import HomeAssistantSpotifyData from .util import fetch_image_url BROWSE_LIMIT = 48 @@ -140,21 +141,21 @@ async def async_browse_media( # Check if caller is requesting the root nodes if media_content_type is None and media_content_id is None: - children = [] - for config_entry_id in hass.data[DOMAIN]: - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry is not None - children.append( - BrowseMedia( - title=config_entry.title, - media_class=MediaClass.APP, - media_content_id=f"{MEDIA_PLAYER_PREFIX}{config_entry_id}", - media_content_type=f"{MEDIA_PLAYER_PREFIX}library", - thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", - can_play=False, - can_expand=True, - ) + config_entries = hass.config_entries.async_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) + children = [ + BrowseMedia( + title=config_entry.title, + media_class=MediaClass.APP, + media_content_id=f"{MEDIA_PLAYER_PREFIX}{config_entry.entry_id}", + media_content_type=f"{MEDIA_PLAYER_PREFIX}library", + thumbnail="https://brands.home-assistant.io/_/spotify/logo.png", + can_play=False, + can_expand=True, ) + for config_entry in config_entries + ] return BrowseMedia( title="Spotify", media_class=MediaClass.APP, @@ -171,9 +172,15 @@ async def async_browse_media( # Check for config entry specifier, and extract Spotify URI parsed_url = yarl.URL(media_content_id) - if (info := hass.data[DOMAIN].get(parsed_url.host)) is None: + + if ( + parsed_url.host is None + or (entry := hass.config_entries.async_get_entry(parsed_url.host)) is None + or not isinstance(entry.runtime_data, HomeAssistantSpotifyData) + ): raise BrowseError("Invalid Spotify account specified") media_content_id = parsed_url.name + info = entry.runtime_data result = await async_browse_media_internal( hass, diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index 0c60959362d..58c7e612a35 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -40,7 +40,7 @@ class SpotifyFlowHandler( try: current_user = await self.hass.async_add_executor_job(spotify.current_user) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return self.async_abort(reason="connection_error") name = data["id"] = current_user["id"] diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 2e725e8d139..bd1bcdfd43e 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -6,7 +6,7 @@ from asyncio import run_coroutine_threadsafe from collections.abc import Callable from datetime import timedelta import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate import requests from spotipy import SpotifyException @@ -22,7 +22,6 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -30,15 +29,12 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow -from . import HomeAssistantSpotifyData +from . import SpotifyConfigEntry from .browse_media import async_browse_media_internal from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES, SPOTIFY_SCOPES +from .models import HomeAssistantSpotifyData from .util import fetch_image_url -_SpotifyMediaPlayerT = TypeVar("_SpotifyMediaPlayerT", bound="SpotifyMediaPlayer") -_R = TypeVar("_R") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) @@ -74,19 +70,19 @@ SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SpotifyConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Spotify based on a config entry.""" spotify = SpotifyMediaPlayer( - hass.data[DOMAIN][entry.entry_id], + entry.runtime_data, entry.data[CONF_ID], entry.title, ) async_add_entities([spotify], True) -def spotify_exception_handler( +def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R]( func: Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R], ) -> Callable[Concatenate[_SpotifyMediaPlayerT, _P], _R | None]: """Decorate Spotify calls to handle Spotify exception. @@ -98,7 +94,6 @@ def spotify_exception_handler( def wrapper( self: _SpotifyMediaPlayerT, *args: _P.args, **kwargs: _P.kwargs ) -> _R | None: - # pylint: disable=protected-access try: result = func(self, *args, **kwargs) except requests.RequestException: @@ -378,7 +373,8 @@ class SpotifyMediaPlayer(MediaPlayerEntity): raise ValueError( f"Media type {media_type} is not supported when enqueue is ADD" ) - return self.data.client.add_to_queue(media_id, kwargs.get("device_id")) + self.data.client.add_to_queue(media_id, kwargs.get("device_id")) + return self.data.client.start_playback(**kwargs) diff --git a/homeassistant/components/spotify/models.py b/homeassistant/components/spotify/models.py new file mode 100644 index 00000000000..bbec134d89d --- /dev/null +++ b/homeassistant/components/spotify/models.py @@ -0,0 +1,19 @@ +"""Models for use in Spotify integration.""" + +from dataclasses import dataclass +from typing import Any + +from spotipy import Spotify + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +@dataclass +class HomeAssistantSpotifyData: + """Spotify data stored in the Home Assistant data object.""" + + client: Spotify + current_user: dict[str, Any] + devices: DataUpdateCoordinator[list[dict[str, Any]]] + session: OAuth2Session diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 30d071f25af..dcb5f47829c 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.29", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.31", "sqlparse==0.5.0"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 68a6cb71f5b..f09f7ae95cf 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -30,6 +30,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP, + MATCH_ALL, ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import TemplateError @@ -307,6 +308,8 @@ def _generate_lambda_stmt(query: str) -> StatementLambdaElement: class SQLSensor(ManualTriggerSensorEntity): """Representation of an SQL sensor.""" + _unrecorded_attributes = frozenset({MATCH_ALL}) + def __init__( self, trigger_entity_config: ConfigType, @@ -369,7 +372,7 @@ class SQLSensor(ManualTriggerSensorEntity): ) sess.rollback() sess.close() - return + return None for res in result.mappings(): _LOGGER.debug("Query %s result in %s", self._query, res.items()) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index b3e2717d075..baaddbef0b6 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,4 +1,4 @@ -"""The Logitech Squeezebox integration.""" +"""The Squeezebox integration.""" import logging @@ -14,7 +14,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Logitech Squeezebox from a config entry.""" + """Set up Squeezebox from a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index effa4f2c970..9ccac13223b 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Logitech Squeezebox integration.""" +"""Config flow for Squeezebox integration.""" import asyncio from http import HTTPStatus @@ -13,9 +13,9 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers import entity_registry as er 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 CONF_HTTPS, DEFAULT_PORT, DOMAIN @@ -64,7 +64,7 @@ def _base_schema(discovery_info=None): class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Logitech Squeezebox.""" + """Handle a config flow for Squeezebox.""" VERSION = 1 @@ -122,7 +122,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): if server.http_status == HTTPStatus.UNAUTHORIZED: return "invalid_auth" return "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return "unknown" if "uuid" in status: @@ -199,7 +199,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Configuring dhcp player with unique id: %s", self.unique_id) - registry = async_get(self.hass) + registry = er.async_get(self.hass) if TYPE_CHECKING: assert self.unique_id diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 83ca3ff1b00..40bc8f36d22 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -1,6 +1,6 @@ { "domain": "squeezebox", - "name": "Squeezebox (Logitech Media Server)", + "name": "Squeezebox (Lyrion Music Server)", "codeowners": ["@rajlaud"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a3a404fe1ae..bf1ad1d77c4 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -1,4 +1,4 @@ -"""Support for interfacing to the Logitech SqueezeBox API.""" +"""Support for interfacing to the SqueezeBox API.""" from __future__ import annotations @@ -92,7 +92,7 @@ SQUEEZEBOX_MODE = { } -async def start_server_discovery(hass): +async def start_server_discovery(hass: HomeAssistant) -> None: """Start a server discovery task.""" def _discovered_server(server): @@ -110,8 +110,9 @@ async def start_server_discovery(hass): hass.data.setdefault(DOMAIN, {}) if DISCOVERY_TASK not in hass.data[DOMAIN]: _LOGGER.debug("Adding server discovery task for squeezebox") - hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_task( - async_discover(_discovered_server) + hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task( + async_discover(_discovered_server), + name="squeezebox server discovery", ) diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index fd232851e8a..899d35813aa 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -7,7 +7,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your Logitech Media Server." + "host": "The hostname or IP address of your Lyrion Music Server." } }, "edit": { @@ -39,11 +39,11 @@ "fields": { "command": { "name": "Command", - "description": "Command to pass to Logitech Media Server (p0 in the CLI documentation)." + "description": "Command to pass to Lyrion Music Server (p0 in the CLI documentation)." }, "parameters": { "name": "Parameters", - "description": "Array of additional parameters to pass to Logitech Media Server (p1, ..., pN in the CLI documentation).\n." + "description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation).\n." } } }, diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index 8ec53a20cc8..a91b1f46b40 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -78,7 +78,7 @@ class SRPEnergyConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidAuth: errors["base"] = "invalid_auth" return self._show_form(errors) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index 60f73fc27c6..e5a72457433 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -15,6 +15,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES, PHOENIX_TIME_ZONE TIMEOUT = 10 +PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): @@ -43,8 +44,7 @@ class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): """ LOGGER.debug("async_update_data enter") # Fetch srp_energy data - phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) - end_date = dt_util.now(phx_time_zone) + end_date = dt_util.now(PHOENIX_ZONE_INFO) start_date = end_date - timedelta(days=1) try: async with asyncio.timeout(TIMEOUT): diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 1678daf4059..7ca2f3e9318 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -126,7 +126,7 @@ class SsdpServiceInfo(BaseServiceInfo): SsdpChange = Enum("SsdpChange", "ALIVE BYEBYE UPDATE") -SsdpHassJobCallback = HassJob[ +type SsdpHassJobCallback = HassJob[ [SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None ] @@ -148,7 +148,7 @@ def _format_err(name: str, *args: Any) -> str: async def async_register_callback( hass: HomeAssistant, callback: Callable[[SsdpServiceInfo, SsdpChange], Coroutine[Any, Any, None] | None], - match_dict: None | dict[str, str] = None, + match_dict: dict[str, str] | None = None, ) -> Callable[[], None]: """Register to receive a callback on ssdp broadcast. @@ -234,7 +234,7 @@ def _async_process_callbacks( hass.async_run_hass_job( callback, discovery_info, ssdp_change, background=True ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Failed to callback info: %s", discovery_info) @@ -317,7 +317,7 @@ class Scanner: return list(self._device_tracker.devices.values()) async def async_register_callback( - self, callback: SsdpHassJobCallback, match_dict: None | dict[str, str] = None + self, callback: SsdpHassJobCallback, match_dict: dict[str, str] | None = None ) -> Callable[[], None]: """Register a callback.""" if match_dict is None: diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index d260ba3503e..6122ccbb3c2 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -74,7 +74,7 @@ class StarlineAccount: DATA_USER_ID: user_id, }, ) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.error("Error updating SLNet token: %s", err) def _update_data(self): diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index 402a94c46b0..c13586d0bc3 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -182,7 +182,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): self._auth.get_app_token, self._app_id, self._app_secret, self._app_code ) return self._async_form_auth_user(error) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.error("Error auth StarLine: %s", err) return self._async_form_auth_app(ERROR_AUTH_APP) @@ -216,7 +216,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): # pylint: disable=broad-exception-raised raise Exception(data) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.error("Error auth user: %s", err) return self._async_form_auth_user(ERROR_AUTH_USER) diff --git a/homeassistant/components/starlink/const.py b/homeassistant/components/starlink/const.py index e2f88c5e442..c1a7b1cfd2c 100644 --- a/homeassistant/components/starlink/const.py +++ b/homeassistant/components/starlink/const.py @@ -1,3 +1,5 @@ """Constants for the Starlink integration.""" DOMAIN = "starlink" + +ATTR_ALTITUDE = "altitude" diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 7a09b2f2dee..a891941fb8e 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -119,12 +119,16 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): async def async_set_sleep_duration(self, end: int) -> None: """Set Starlink system sleep schedule end time.""" + duration = end - self.data.sleep[0] + if duration < 0: + # If the duration pushed us into the next day, add one days worth to correct that. + duration += 1440 async with asyncio.timeout(4): try: await self.hass.async_add_executor_job( set_sleep_config, self.data.sleep[0], - end, + duration, self.data.sleep[2], self.channel_context, ) diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 84c0a4cac24..34769d687ff 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.config_entries import ConfigEntry @@ -9,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import ATTR_ALTITUDE, DOMAIN from .coordinator import StarlinkData from .entity import StarlinkEntity @@ -32,6 +33,7 @@ class StarlinkDeviceTrackerEntityDescription(EntityDescription): latitude_fn: Callable[[StarlinkData], float] longitude_fn: Callable[[StarlinkData], float] + altitude_fn: Callable[[StarlinkData], float] DEVICE_TRACKERS = [ @@ -41,6 +43,7 @@ DEVICE_TRACKERS = [ entity_registry_enabled_default=False, latitude_fn=lambda data: data.location["latitude"], longitude_fn=lambda data: data.location["longitude"], + altitude_fn=lambda data: data.location["altitude"], ), ] @@ -64,3 +67,10 @@ class StarlinkDeviceTrackerEntity(StarlinkEntity, TrackerEntity): def longitude(self) -> float | None: """Return longitude value of the device.""" return self.entity_description.longitude_fn(self.coordinator.data) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device specific attributes.""" + return { + ATTR_ALTITUDE: self.entity_description.altitude_fn(self.coordinator.data) + } diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 6475610564d..7395ec101ba 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -62,6 +62,8 @@ class StarlinkTimeEntity(StarlinkEntity, TimeEntity): def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time: hour = math.floor(utc_minutes / 60) + if hour > 23: + hour -= 24 minute = utc_minutes % 60 try: utc = datetime.now(UTC).replace( diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 713a8d3e894..fef10f7296f 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -285,6 +285,9 @@ async def async_setup_platform( class StatisticsSensor(SensorEntity): """Representation of a Statistics sensor.""" + _attr_should_poll = False + _attr_icon = ICON + def __init__( self, source_entity_id: str, @@ -298,9 +301,7 @@ class StatisticsSensor(SensorEntity): percentile: int, ) -> None: """Initialize the Statistics sensor.""" - self._attr_icon: str = ICON self._attr_name: str = name - self._attr_should_poll: bool = False self._attr_unique_id: str | None = unique_id self._source_entity_id: str = source_entity_id self.is_binary: bool = ( @@ -326,35 +327,37 @@ class StatisticsSensor(SensorEntity): self._update_listener: CALLBACK_TYPE | None = None + @callback + def _async_stats_sensor_state_listener( + self, + event: Event[EventStateChangedData], + ) -> None: + """Handle the sensor state changes.""" + if (new_state := event.data["new_state"]) is None: + return + self._add_state_to_queue(new_state) + self._async_purge_update_and_schedule() + self.async_write_ha_state() + + @callback + def _async_stats_sensor_startup(self, _: HomeAssistant) -> None: + """Add listener and get recorded state.""" + _LOGGER.debug("Startup for %s", self.entity_id) + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._source_entity_id], + self._async_stats_sensor_state_listener, + ) + ) + if "recorder" in self.hass.config.components: + self.hass.async_create_task(self._initialize_from_database()) + async def async_added_to_hass(self) -> None: """Register callbacks.""" - - @callback - def async_stats_sensor_state_listener( - event: Event[EventStateChangedData], - ) -> None: - """Handle the sensor state changes.""" - if (new_state := event.data["new_state"]) is None: - return - self._add_state_to_queue(new_state) - self.async_schedule_update_ha_state(True) - - async def async_stats_sensor_startup(_: HomeAssistant) -> None: - """Add listener and get recorded state.""" - _LOGGER.debug("Startup for %s", self.entity_id) - - self.async_on_remove( - async_track_state_change_event( - self.hass, - [self._source_entity_id], - async_stats_sensor_state_listener, - ) - ) - - if "recorder" in self.hass.config.components: - self.hass.async_create_task(self._initialize_from_database()) - - self.async_on_remove(async_at_start(self.hass, async_stats_sensor_startup)) + self.async_on_remove( + async_at_start(self.hass, self._async_stats_sensor_startup) + ) def _add_state_to_queue(self, new_state: State) -> None: """Add the state to the queue.""" @@ -499,7 +502,8 @@ class StatisticsSensor(SensorEntity): self.ages.popleft() self.states.popleft() - def _next_to_purge_timestamp(self) -> datetime | None: + @callback + def _async_next_to_purge_timestamp(self) -> datetime | None: """Find the timestamp when the next purge would occur.""" if self.ages and self._samples_max_age: if self.samples_keep_last and len(self.ages) == 1: @@ -521,6 +525,10 @@ class StatisticsSensor(SensorEntity): async def async_update(self) -> None: """Get the latest data and updates the states.""" + self._async_purge_update_and_schedule() + + def _async_purge_update_and_schedule(self) -> None: + """Purge old states, update the sensor and schedule the next update.""" _LOGGER.debug("%s: updating statistics", self.entity_id) if self._samples_max_age is not None: self._purge_old_states(self._samples_max_age) @@ -531,23 +539,28 @@ class StatisticsSensor(SensorEntity): # If max_age is set, ensure to update again after the defined interval. # By basing updates off the timestamps of sampled data we avoid updating # when none of the observed entities change. - if timestamp := self._next_to_purge_timestamp(): + if timestamp := self._async_next_to_purge_timestamp(): _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp) - if self._update_listener: - self._update_listener() - self._update_listener = None - - @callback - def _scheduled_update(now: datetime) -> None: - """Timer callback for sensor update.""" - _LOGGER.debug("%s: executing scheduled update", self.entity_id) - self.async_schedule_update_ha_state(True) - self._update_listener = None - + self._async_cancel_update_listener() self._update_listener = async_track_point_in_utc_time( - self.hass, _scheduled_update, timestamp + self.hass, self._async_scheduled_update, timestamp ) + @callback + def _async_cancel_update_listener(self) -> None: + """Cancel the scheduled update listener.""" + if self._update_listener: + self._update_listener() + self._update_listener = None + + @callback + def _async_scheduled_update(self, now: datetime) -> None: + """Timer callback for sensor update.""" + _LOGGER.debug("%s: executing scheduled update", self.entity_id) + self._async_cancel_update_listener() + self._async_purge_update_and_schedule() + self.async_write_ha_state() + def _fetch_states_from_database(self) -> list[State]: """Fetch the states from the database.""" _LOGGER.debug("%s: initializing values from the database", self.entity_id) @@ -589,8 +602,8 @@ class StatisticsSensor(SensorEntity): for state in reversed(states): self._add_state_to_queue(state) - self.async_schedule_update_ha_state(True) - + self._async_purge_update_and_schedule() + self.async_write_ha_state() _LOGGER.debug("%s: initializing from database completed", self.entity_id) def _update_attributes(self) -> None: diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 93b4a3eb370..6e45758fb94 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -6,24 +6,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool: """Set up Steam from a config entry.""" coordinator = SteamDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index bd38e79b133..4b99bf7738d 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -19,6 +18,7 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er +from . import SteamConfigEntry from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS # To avoid too long request URIs, the amount of ids to request is limited @@ -38,12 +38,12 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the flow.""" - self.entry: ConfigEntry | None = None + self.entry: SteamConfigEntry | None = None @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SteamConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" return SteamOptionsFlowHandler(config_entry) @@ -66,7 +66,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" if "403" in str(ex): errors["base"] = "invalid_auth" - except Exception as ex: # pylint:disable=broad-except + except Exception as ex: # noqa: BLE001 LOGGER.exception("Unknown exception: %s", ex) errors["base"] = "unknown" if not errors: @@ -127,7 +127,7 @@ def _batch_ids(ids: list[str]) -> Iterator[list[str]]: class SteamOptionsFlowHandler(OptionsFlow): """Handle Steam client options.""" - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: SteamConfigEntry) -> None: """Initialize options flow.""" self.entry = entry self.options = dict(entry.options) diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 847fd297247..6e7bdf4b91c 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -3,11 +3,11 @@ from __future__ import annotations from datetime import timedelta +from typing import TYPE_CHECKING import steam from steam.api import _interface_method as INTMethod -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -15,13 +15,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ACCOUNTS, DOMAIN, LOGGER +if TYPE_CHECKING: + from . import SteamConfigEntry + class SteamDataUpdateCoordinator( DataUpdateCoordinator[dict[str, dict[str, str | int]]] ): """Data update coordinator for the Steam integration.""" - config_entry: ConfigEntry + config_entry: SteamConfigEntry def __init__(self, hass: HomeAssistant) -> None: """Initialize the coordinator.""" diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 8e8b70eaeb9..058bb386383 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -7,15 +7,14 @@ from time import localtime, mktime from typing import cast from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -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 homeassistant.util.dt import utc_from_timestamp +from . import SteamConfigEntry from .const import ( CONF_ACCOUNTS, - DOMAIN, STEAM_API_URL, STEAM_HEADER_IMAGE_FILE, STEAM_ICON_URL, @@ -30,12 +29,12 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SteamConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Steam platform.""" async_add_entities( - SteamSensor(hass.data[DOMAIN][entry.entry_id], account) + SteamSensor(entry.runtime_data, account) for account in entry.options[CONF_ACCOUNTS] ) diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py index 9d2fa5c6c42..b5cb6527fa3 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -168,7 +168,7 @@ class SteamistConfigFlow(ConfigFlow, domain=DOMAIN): await Steamist(host, websession).async_get_status() except CONNECTION_EXCEPTIONS: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py index e611e07cd71..e0e3a8ba009 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from typing import TYPE_CHECKING +from typing_extensions import Generator + from homeassistant.exceptions import HomeAssistantError from .core import Orientation @@ -15,7 +16,7 @@ if TYPE_CHECKING: def find_box( mp4_bytes: bytes, target_type: bytes, box_start: int = 0 -) -> Generator[int, None, None]: +) -> Generator[int]: """Find location of first box (or sub box if box_start provided) of given type.""" if box_start == 0: index = 0 diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 956c93d01a0..4fd9b27d02f 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict, deque -from collections.abc import Callable, Generator, Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping import contextlib from dataclasses import fields import datetime @@ -13,6 +13,7 @@ from threading import Event from typing import Any, Self, cast import av +from typing_extensions import Generator from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -415,7 +416,7 @@ class PeekIterator(Iterator): self._next = self._iterator.__next__ return self._next() - def peek(self) -> Generator[av.Packet, None, None]: + def peek(self) -> Generator[av.Packet]: """Return items without consuming from the iterator.""" # Items consumed are added to a buffer for future calls to __next__ # or peek. First iterate over the buffer from previous calls to peek. @@ -592,7 +593,7 @@ def stream_worker( except av.AVError as ex: container.close() raise StreamWorkerError( - f"Error demuxing stream while finding first packet: {str(ex)}" + f"Error demuxing stream while finding first packet: {ex!s}" ) from ex muxer = StreamMuxer( @@ -617,7 +618,7 @@ def stream_worker( except StopIteration as ex: raise StreamEndedError("Stream ended; no additional packets") from ex except av.AVError as ex: - raise StreamWorkerError(f"Error demuxing stream: {str(ex)}") from ex + raise StreamWorkerError(f"Error demuxing stream: {ex!s}") from ex muxer.mux_packet(packet) diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 46acc443d2e..5eeb40630f8 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -3,17 +3,10 @@ from streamlabswater.streamlabswater import StreamlabsClient import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, -) -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import StreamlabsCoordinator @@ -26,17 +19,6 @@ AWAY_MODE_HOME = "home" CONF_LOCATION_ID = "location_id" ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"} -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_LOCATION_ID): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) SET_AWAY_MODE_SCHEMA = vol.Schema( { @@ -48,50 +30,6 @@ SET_AWAY_MODE_SCHEMA = vol.Schema( PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the streamlabs water integration.""" - - if DOMAIN not in config: - return True - - 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: - 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, - ) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up StreamLabs from a config entry.""" diff --git a/homeassistant/components/streamlabswater/config_flow.py b/homeassistant/components/streamlabswater/config_flow.py index 327e5dcdae3..e931a7cf3ba 100644 --- a/homeassistant/components/streamlabswater/config_flow.py +++ b/homeassistant/components/streamlabswater/config_flow.py @@ -41,7 +41,7 @@ class StreamlabsConfigFlow(ConfigFlow, domain=DOMAIN): await validate_input(self.hass, user_input[CONF_API_KEY]) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -57,19 +57,6 @@ class StreamlabsConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """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/strings.json b/homeassistant/components/streamlabswater/strings.json index 872a0d1f6ac..2cc543b9f2e 100644 --- a/homeassistant/components/streamlabswater/strings.json +++ b/homeassistant/components/streamlabswater/strings.json @@ -48,15 +48,5 @@ "name": "Yearly usage" } } - }, - "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 a 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 997835ef9f8..7bb0d84c289 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -86,7 +86,7 @@ def async_setup_legacy( provider.hass = hass providers[provider.name] = provider - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error setting up platform: %s", p_type) return diff --git a/homeassistant/components/subaru/diagnostics.py b/homeassistant/components/subaru/diagnostics.py index 726457aa341..5d95cd0464b 100644 --- a/homeassistant/components/subaru/diagnostics.py +++ b/homeassistant/components/subaru/diagnostics.py @@ -4,7 +4,13 @@ from __future__ import annotations from typing import Any -from subarulink.const import LATITUDE, LONGITUDE, ODOMETER, VEHICLE_NAME +from subarulink.const import ( + LATITUDE, + LONGITUDE, + ODOMETER, + RAW_API_FIELDS_TO_REDACT, + VEHICLE_NAME, +) from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -13,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN, ENTRY_COORDINATOR, VEHICLE_VIN +from .const import DOMAIN, ENTRY_CONTROLLER, ENTRY_COORDINATOR, VEHICLE_VIN CONFIG_FIELDS_TO_REDACT = [CONF_USERNAME, CONF_PASSWORD, CONF_PIN, CONF_DEVICE_ID] DATA_FIELDS_TO_REDACT = [VEHICLE_VIN, VEHICLE_NAME, LATITUDE, LONGITUDE, ODOMETER] @@ -39,7 +45,9 @@ async def async_get_device_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + entry = hass.data[DOMAIN][config_entry.entry_id] + coordinator = entry[ENTRY_COORDINATOR] + controller = entry[ENTRY_CONTROLLER] vin = next(iter(device.identifiers))[1] @@ -50,6 +58,9 @@ async def async_get_device_diagnostics( ), "options": async_redact_data(config_entry.options, []), "data": async_redact_data(info, DATA_FIELDS_TO_REDACT), + "raw_data": async_redact_data( + controller.get_raw_data(vin), RAW_API_FIELDS_TO_REDACT + ), } raise HomeAssistantError("Device not found") diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 0cffe2576d1..760e4ccd689 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.9"] + "requirements": ["subarulink==0.7.11"] } diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index bbb00a758dd..ba9b7d46b06 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import Any import subarulink.const as sc @@ -23,11 +23,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter -from homeassistant.util.unit_system import ( - LENGTH_UNITS, - PRESSURE_UNITS, - US_CUSTOMARY_SYSTEM, -) +from homeassistant.util.unit_system import METRIC_SYSTEM from . import get_device_info from .const import ( @@ -58,7 +54,7 @@ SAFETY_SENSORS = [ key=sc.ODOMETER, translation_key="odometer", device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, + native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.TOTAL_INCREASING, ), ] @@ -68,42 +64,42 @@ API_GEN_2_SENSORS = [ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, translation_key="average_fuel_consumption", - native_unit_of_measurement=FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, + native_unit_of_measurement=FUEL_CONSUMPTION_MILES_PER_GALLON, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.DIST_TO_EMPTY, translation_key="range", device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, + native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FL, translation_key="tire_pressure_front_left", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FR, translation_key="tire_pressure_front_right", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RL, translation_key="tire_pressure_rear_left", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RR, translation_key="tire_pressure_rear_right", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), ] @@ -205,32 +201,15 @@ class SubaruSensor( self._attr_unique_id = f"{self.vin}_{description.key}" @property - def native_value(self) -> None | int | float: + def native_value(self) -> int | float | None: """Return the state of the sensor.""" - vehicle_data = self.coordinator.data[self.vin] - current_value = vehicle_data[VEHICLE_STATUS].get(self.entity_description.key) - unit = self.entity_description.native_unit_of_measurement - unit_system = self.hass.config.units - - if current_value is None: - return None - - if unit in LENGTH_UNITS: - return round(unit_system.length(current_value, cast(str, unit)), 1) - - if unit in PRESSURE_UNITS and unit_system == US_CUSTOMARY_SYSTEM: - return round( - unit_system.pressure(current_value, cast(str, unit)), - 1, - ) + current_value = self.coordinator.data[self.vin][VEHICLE_STATUS].get( + self.entity_description.key + ) if ( - unit - in [ - FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, - FUEL_CONSUMPTION_MILES_PER_GALLON, - ] - and unit_system == US_CUSTOMARY_SYSTEM + self.entity_description.key == sc.AVG_FUEL_CONSUMPTION + and self.hass.config.units == METRIC_SYSTEM ): return round((100.0 * L_PER_GAL) / (KM_PER_MI * current_value), 1) @@ -239,23 +218,12 @@ class SubaruSensor( @property def native_unit_of_measurement(self) -> str | None: """Return the unit_of_measurement of the device.""" - unit = self.entity_description.native_unit_of_measurement - - if unit in LENGTH_UNITS: - return self.hass.config.units.length_unit - - if unit in PRESSURE_UNITS: - if self.hass.config.units == US_CUSTOMARY_SYSTEM: - return self.hass.config.units.pressure_unit - - if unit in [ - FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, - FUEL_CONSUMPTION_MILES_PER_GALLON, - ]: - if self.hass.config.units == US_CUSTOMARY_SYSTEM: - return FUEL_CONSUMPTION_MILES_PER_GALLON - - return unit + if ( + self.entity_description.key == sc.AVG_FUEL_CONSUMPTION + and self.hass.config.units == METRIC_SYSTEM + ): + return FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS + return self.entity_description.native_unit_of_measurement @property def available(self) -> bool: diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index f3bfda91c3c..28b211dc808 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -63,7 +63,7 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -75,21 +75,6 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """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.""" diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index f48e78bb153..5b00cbf2dc4 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -7,82 +7,20 @@ import logging from pysuez import SuezClient from pysuez.client import PySuezError -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume -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.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolume +from homeassistant.core import HomeAssistant 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 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) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_COUNTER_ID): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """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": "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, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index fd85565d297..f9abd70fc19 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -24,19 +24,5 @@ "name": "Water usage yesterday" } } - }, - "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 a 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/entity.py b/homeassistant/components/sun/entity.py index 291f56718a3..10d328afde7 100644 --- a/homeassistant/components/sun/entity.py +++ b/homeassistant/components/sun/entity.py @@ -31,7 +31,7 @@ from .const import ( STATE_BELOW_HORIZON, ) -SunConfigEntry = ConfigEntry["Sun"] +type SunConfigEntry = ConfigEntry[Sun] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json index 3e41d331e8c..bcf1ad9dae2 100644 --- a/homeassistant/components/sunweg/manifest.json +++ b/homeassistant/components/sunweg/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sunweg/", "iot_class": "cloud_polling", "loggers": ["sunweg"], - "requirements": ["sunweg==2.1.1"] + "requirements": ["sunweg==3.0.1"] } diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor_types/total.py index 5ae8be6dba3..2b94446a165 100644 --- a/homeassistant/components/sunweg/sensor_types/total.py +++ b/homeassistant/components/sunweg/sensor_types/total.py @@ -41,11 +41,6 @@ TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( 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", diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index b9e2bb6a410..e1f846d63a7 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -5,18 +5,15 @@ from __future__ import annotations from datetime import timedelta import logging -from surepy import Surepy, SurepyEntity -from surepy.enums import EntityType, Location, LockState +from surepy.enums import Location from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_FLAP_ID, @@ -26,8 +23,8 @@ from .const import ( DOMAIN, SERVICE_SET_LOCK_STATE, SERVICE_SET_PET_LOCATION, - SURE_API_TIMEOUT, ) +from .coordinator import SurePetcareDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -101,61 +98,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): # pylint: disable=hass-enforce-coordinator-module - """Handle Surepetcare data.""" - - def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: - """Initialize the data handler.""" - self.surepy = Surepy( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - auth_token=entry.data[CONF_TOKEN], - api_timeout=SURE_API_TIMEOUT, - session=async_get_clientsession(hass), - ) - self.lock_states_callbacks = { - LockState.UNLOCKED.name.lower(): self.surepy.sac.unlock, - LockState.LOCKED_IN.name.lower(): self.surepy.sac.lock_in, - LockState.LOCKED_OUT.name.lower(): self.surepy.sac.lock_out, - LockState.LOCKED_ALL.name.lower(): self.surepy.sac.lock, - } - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self) -> dict[int, SurepyEntity]: - """Get the latest data from Sure Petcare.""" - try: - return await self.surepy.get_entities(refresh=True) - except SurePetcareAuthenticationError as err: - raise ConfigEntryAuthFailed("Invalid username/password") from err - except SurePetcareError as err: - raise UpdateFailed(f"Unable to fetch data: {err}") from err - - async def handle_set_lock_state(self, call: ServiceCall) -> None: - """Call when setting the lock state.""" - flap_id = call.data[ATTR_FLAP_ID] - state = call.data[ATTR_LOCK_STATE] - await self.lock_states_callbacks[state](flap_id) - await self.async_request_refresh() - - def get_pets(self) -> dict[str, int]: - """Get pets.""" - pets = {} - for surepy_entity in self.data.values(): - if surepy_entity.type == EntityType.PET and surepy_entity.name: - pets[surepy_entity.name] = surepy_entity.id - return pets - - async def handle_set_pet_location(self, call: ServiceCall) -> None: - """Call when setting the pet location.""" - pet_name = call.data[ATTR_PET_NAME] - location = call.data[ATTR_LOCATION] - device_id = self.get_pets()[pet_name] - await self.surepy.sac.set_pet_location(device_id, Location[location.upper()]) - await self.async_request_refresh() diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 0c99985d514..b422e40ef2d 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SurePetcareDataCoordinator from .const import DOMAIN +from .coordinator import SurePetcareDataCoordinator from .entity import SurePetcareEntity diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index dc11631de81..6626b1d6dee 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -66,7 +66,7 @@ class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except SurePetcareError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -103,7 +103,7 @@ class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except SurePetcareError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/surepetcare/coordinator.py b/homeassistant/components/surepetcare/coordinator.py new file mode 100644 index 00000000000..a80e96ad185 --- /dev/null +++ b/homeassistant/components/surepetcare/coordinator.py @@ -0,0 +1,88 @@ +"""Coordinator for the surepetcare integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from surepy import Surepy, SurepyEntity +from surepy.enums import EntityType, Location, LockState +from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant, ServiceCall +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 ( + ATTR_FLAP_ID, + ATTR_LOCATION, + ATTR_LOCK_STATE, + ATTR_PET_NAME, + DOMAIN, + SURE_API_TIMEOUT, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=3) + + +class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): + """Handle Surepetcare data.""" + + def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: + """Initialize the data handler.""" + self.surepy = Surepy( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + auth_token=entry.data[CONF_TOKEN], + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(hass), + ) + self.lock_states_callbacks = { + LockState.UNLOCKED.name.lower(): self.surepy.sac.unlock, + LockState.LOCKED_IN.name.lower(): self.surepy.sac.lock_in, + LockState.LOCKED_OUT.name.lower(): self.surepy.sac.lock_out, + LockState.LOCKED_ALL.name.lower(): self.surepy.sac.lock, + } + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[int, SurepyEntity]: + """Get the latest data from Sure Petcare.""" + try: + return await self.surepy.get_entities(refresh=True) + except SurePetcareAuthenticationError as err: + raise ConfigEntryAuthFailed("Invalid username/password") from err + except SurePetcareError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + async def handle_set_lock_state(self, call: ServiceCall) -> None: + """Call when setting the lock state.""" + flap_id = call.data[ATTR_FLAP_ID] + state = call.data[ATTR_LOCK_STATE] + await self.lock_states_callbacks[state](flap_id) + await self.async_request_refresh() + + def get_pets(self) -> dict[str, int]: + """Get pets.""" + pets = {} + for surepy_entity in self.data.values(): + if surepy_entity.type == EntityType.PET and surepy_entity.name: + pets[surepy_entity.name] = surepy_entity.id + return pets + + async def handle_set_pet_location(self, call: ServiceCall) -> None: + """Call when setting the pet location.""" + pet_name = call.data[ATTR_PET_NAME] + location = call.data[ATTR_LOCATION] + device_id = self.get_pets()[pet_name] + await self.surepy.sac.set_pet_location(device_id, Location[location.upper()]) + await self.async_request_refresh() diff --git a/homeassistant/components/surepetcare/entity.py b/homeassistant/components/surepetcare/entity.py index 400f6a80ac9..312ae4730b0 100644 --- a/homeassistant/components/surepetcare/entity.py +++ b/homeassistant/components/surepetcare/entity.py @@ -10,8 +10,8 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SurePetcareDataCoordinator from .const import DOMAIN +from .coordinator import SurePetcareDataCoordinator class SurePetcareEntity(CoordinatorEntity[SurePetcareDataCoordinator]): diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py index b933cc40637..cd79e06c5c3 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -13,8 +13,8 @@ from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SurePetcareDataCoordinator from .const import DOMAIN +from .coordinator import SurePetcareDataCoordinator from .entity import SurePetcareEntity diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 3618ac7d163..b4e7c6203a3 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -14,8 +14,8 @@ from homeassistant.const import ATTR_VOLTAGE, PERCENTAGE, EntityCategory, UnitOf from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SurePetcareDataCoordinator from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW +from .coordinator import SurePetcareDataCoordinator from .entity import SurePetcareEntity diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 6c5de3c7883..bb852efd211 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -11,7 +11,6 @@ from opendata_transport.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -54,7 +53,7 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except OpendataTransportError: errors["base"] = "bad_config" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: @@ -69,33 +68,3 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders=PLACEHOLDERS, ) - - async def async_step_import(self, import_input: dict[str, Any]) -> ConfigFlowResult: - """Async import step to set up the connection.""" - await self.async_set_unique_id( - f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}" - ) - self._abort_if_unique_id_configured() - - 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/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index f477c04f6ec..844797e5dd5 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -8,48 +8,26 @@ from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING -import voluptuous as vol - from homeassistant import config_entries, core from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_NAME, UnitOfTime -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.const import UnitOfTime +from homeassistant.core import callback 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, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_DESTINATION, - CONF_START, - DEFAULT_NAME, - DOMAIN, - PLACEHOLDERS, - SENSOR_CONNECTIONS_COUNT, -) +from .const import DOMAIN, SENSOR_CONNECTIONS_COUNT from .coordinator import DataConnection, SwissPublicTransportDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=90) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DESTINATION): cv.string, - vol.Required(CONF_START): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - @dataclass(kw_only=True, frozen=True) class SwissPublicTransportSensorEntityDescription(SensorEntityDescription): @@ -118,50 +96,6 @@ async def async_setup_entry( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """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, - ) - - class SwissPublicTransportSensor( CoordinatorEntity[SwissPublicTransportDataUpdateCoordinator], SensorEntity ): diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index cddc732d3ed..4732bb0f527 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -46,19 +46,5 @@ "name": "Delay" } } - }, - "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 a 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/switchbee/button.py b/homeassistant/components/switchbee/button.py index 39be264992e..78b5c0e6888 100644 --- a/homeassistant/components/switchbee/button.py +++ b/homeassistant/components/switchbee/button.py @@ -35,5 +35,5 @@ class SwitchBeeButton(SwitchBeeEntity, ButtonEntity): await self.coordinator.api.set_state(self._device.id, ApiStateCommand.ON) except SwitchBeeError as exp: raise HomeAssistantError( - f"Failed to fire scenario {self.name}, {str(exp)}" + f"Failed to fire scenario {self.name}, {exp!s}" ) from exp diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 1fc5cfcba12..7ec0ad4d88b 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -181,7 +181,7 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate await self.coordinator.api.set_state(self._device.id, state) except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: raise HomeAssistantError( - f"Failed to set {self.name} state {state}, error: {str(exp)}" + f"Failed to set {self.name} state {state}, error: {exp!s}" ) from exp await self.coordinator.async_refresh() diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index 9b5139340b1..c8d3d58ee09 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -75,7 +75,7 @@ class SwitchBeeConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/switchbee/cover.py b/homeassistant/components/switchbee/cover.py index ac0de3622f1..02f3d7167e3 100644 --- a/homeassistant/components/switchbee/cover.py +++ b/homeassistant/components/switchbee/cover.py @@ -55,7 +55,7 @@ class SwitchBeeSomfyEntity(SwitchBeeDeviceEntity[SwitchBeeSomfy], CoverEntity): await self.coordinator.api.set_state(self._device.id, command) except (SwitchBeeError, SwitchBeeTokenError) as exp: raise HomeAssistantError( - f"Failed to fire {command} for {self.name}, {str(exp)}" + f"Failed to fire {command} for {self.name}, {exp!s}" ) from exp async def async_open_cover(self, **kwargs: Any) -> None: @@ -145,7 +145,7 @@ class SwitchBeeCoverEntity(SwitchBeeDeviceEntity[SwitchBeeShutter], CoverEntity) except (SwitchBeeError, SwitchBeeTokenError) as exp: raise HomeAssistantError( f"Failed to set {self.name} position to {kwargs[ATTR_POSITION]}, error:" - f" {str(exp)}" + f" {exp!s}" ) from exp self._get_coordinator_device().position = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index c601324b2a5..893f052c8a0 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -1,7 +1,7 @@ """Support for SwitchBee entity.""" import logging -from typing import Generic, TypeVar, cast +from typing import cast from switchbee import SWITCHBEE_BRAND from switchbee.device import DeviceType, SwitchBeeBaseDevice @@ -12,13 +12,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import SwitchBeeCoordinator -_DeviceTypeT = TypeVar("_DeviceTypeT", bound=SwitchBeeBaseDevice) - - _LOGGER = logging.getLogger(__name__) -class SwitchBeeEntity(CoordinatorEntity[SwitchBeeCoordinator], Generic[_DeviceTypeT]): +class SwitchBeeEntity[_DeviceTypeT: SwitchBeeBaseDevice]( + CoordinatorEntity[SwitchBeeCoordinator] +): """Representation of a Switchbee entity.""" _attr_has_entity_name = True @@ -35,7 +34,9 @@ class SwitchBeeEntity(CoordinatorEntity[SwitchBeeCoordinator], Generic[_DeviceTy self._attr_unique_id = f"{coordinator.unique_id}-{device.id}" -class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): +class SwitchBeeDeviceEntity[_DeviceTypeT: SwitchBeeBaseDevice]( + SwitchBeeEntity[_DeviceTypeT] +): """Representation of a Switchbee device entity.""" def __init__( diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py index 9d224370fa2..0daa6e204aa 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -100,7 +100,7 @@ class SwitchBeeLightEntity(SwitchBeeDeviceEntity[SwitchBeeDimmer], LightEntity): await self.coordinator.api.set_state(self._device.id, state) except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: raise HomeAssistantError( - f"Failed to set {self.name} state {state}, {str(exp)}" + f"Failed to set {self.name} state {state}, {exp!s}" ) from exp if not isinstance(state, int): @@ -120,7 +120,7 @@ class SwitchBeeLightEntity(SwitchBeeDeviceEntity[SwitchBeeDimmer], LightEntity): await self.coordinator.api.set_state(self._device.id, ApiStateCommand.OFF) except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: raise HomeAssistantError( - f"Failed to turn off {self._attr_name}, {str(exp)}" + f"Failed to turn off {self._attr_name}, {exp!s}" ) from exp # update the coordinator manually diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index d48a3e2e02a..c502e6f22f5 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, TypeVar +from typing import Any from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import ( @@ -23,16 +23,6 @@ from .const import DOMAIN from .coordinator import SwitchBeeCoordinator from .entity import SwitchBeeDeviceEntity -_DeviceTypeT = TypeVar( - "_DeviceTypeT", - bound=( - SwitchBeeTimedSwitch - | SwitchBeeGroupSwitch - | SwitchBeeSwitch - | SwitchBeeTimerSwitch - ), -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -55,7 +45,12 @@ async def async_setup_entry( ) -class SwitchBeeSwitchEntity(SwitchBeeDeviceEntity[_DeviceTypeT], SwitchEntity): +class SwitchBeeSwitchEntity[ + _DeviceTypeT: SwitchBeeTimedSwitch + | SwitchBeeGroupSwitch + | SwitchBeeSwitch + | SwitchBeeTimerSwitch +](SwitchBeeDeviceEntity[_DeviceTypeT], SwitchEntity): """Representation of a Switchbee switch.""" def __init__( @@ -102,7 +97,7 @@ class SwitchBeeSwitchEntity(SwitchBeeDeviceEntity[_DeviceTypeT], SwitchEntity): except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: await self.coordinator.async_refresh() raise HomeAssistantError( - f"Failed to set {self._attr_name} state {state}, {str(exp)}" + f"Failed to set {self._attr_name} state {state}, {exp!s}" ) from exp await self.coordinator.async_refresh() diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 6bad3c25142..7bf02ed37b6 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -50,11 +50,17 @@ PLATFORMS_BY_TYPE = { Platform.LOCK, Platform.SENSOR, ], + SupportedModels.LOCK_PRO.value: [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + ], SupportedModels.BLIND_TILT.value: [ Platform.COVER, Platform.BINARY_SENSOR, Platform.SENSOR, ], + SupportedModels.HUB2.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -65,6 +71,7 @@ CLASS_BY_DEVICE = { SupportedModels.LIGHT_STRIP.value: switchbot.SwitchbotLightStrip, SupportedModels.HUMIDIFIER.value: switchbot.SwitchbotHumidifier, SupportedModels.LOCK.value: switchbot.SwitchbotLock, + SupportedModels.LOCK_PRO.value: switchbot.SwitchbotLock, SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, } @@ -117,6 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key_id=entry.data.get(CONF_KEY_ID), encryption_key=entry.data.get(CONF_ENCRYPTION_KEY), retry_count=entry.options[CONF_RETRY_COUNT], + model=switchbot_model, ) except ValueError as error: raise ConfigEntryNotReady( diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 06b95c6f8aa..a1c947fd611 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -8,9 +8,9 @@ from typing import Any from switchbot import ( SwitchbotAccountConnectionError, SwitchBotAdvertisement, + SwitchbotApiError, SwitchbotAuthenticationError, SwitchbotLock, - SwitchbotModel, parse_advertisement_data, ) import voluptuous as vol @@ -33,6 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_ENCRYPTION_KEY, @@ -42,6 +43,7 @@ from .const import ( DEFAULT_RETRY_COUNT, DOMAIN, NON_CONNECTABLE_SUPPORTED_MODEL_TYPES, + SUPPORTED_LOCK_MODELS, SUPPORTED_MODEL_TYPES, ) @@ -107,7 +109,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): "name": data["modelFriendlyName"], "address": short_address(discovery_info.address), } - if model_name == SwitchbotModel.LOCK: + if model_name in SUPPORTED_LOCK_MODELS: return await self.async_step_lock_choose_method() if self._discovered_adv.data["isEncrypted"]: return await self.async_step_password() @@ -175,14 +177,19 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders = {} if user_input is not None: try: - key_details = await self.hass.async_add_executor_job( - SwitchbotLock.retrieve_encryption_key, + key_details = await SwitchbotLock.async_retrieve_encryption_key( + async_get_clientsession(self.hass), self._discovered_adv.address, user_input[CONF_USERNAME], user_input[CONF_PASSWORD], ) - except SwitchbotAccountConnectionError as ex: - raise AbortFlow("cannot_connect") from ex + except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex: + _LOGGER.debug( + "Failed to connect to SwitchBot API: %s", ex, exc_info=True + ) + raise AbortFlow( + "api_error", description_placeholders={"error_detail": str(ex)} + ) from ex except SwitchbotAuthenticationError as ex: _LOGGER.debug("Authentication failed: %s", ex, exc_info=True) errors = {"base": "auth_failed"} @@ -233,6 +240,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_adv.device, user_input[CONF_KEY_ID], user_input[CONF_ENCRYPTION_KEY], + model=self._discovered_adv.data["modelName"], ): errors = { "base": "encryption_key_invalid", @@ -298,7 +306,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: device_adv = self._discovered_advs[user_input[CONF_ADDRESS]] await self._async_set_device(device_adv) - if device_adv.data.get("modelName") == SwitchbotModel.LOCK: + if device_adv.data.get("modelName") in SUPPORTED_LOCK_MODELS: return await self.async_step_lock_choose_method() if device_adv.data["isEncrypted"]: return await self.async_step_password() @@ -310,7 +318,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): # or simply confirm it device_adv = list(self._discovered_advs.values())[0] await self._async_set_device(device_adv) - if device_adv.data.get("modelName") == SwitchbotModel.LOCK: + if device_adv.data.get("modelName") in SUPPORTED_LOCK_MODELS: return await self.async_step_lock_choose_method() if device_adv.data["isEncrypted"]: return await self.async_step_password() diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 9993bd95415..0a1ac01e530 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -26,7 +26,9 @@ class SupportedModels(StrEnum): MOTION = "motion" HUMIDIFIER = "humidifier" LOCK = "lock" + LOCK_PRO = "lock_pro" BLIND_TILT = "blind_tilt" + HUB2 = "hub2" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -38,7 +40,9 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.CEILING_LIGHT: SupportedModels.CEILING_LIGHT, SwitchbotModel.HUMIDIFIER: SupportedModels.HUMIDIFIER, SwitchbotModel.LOCK: SupportedModels.LOCK, + SwitchbotModel.LOCK_PRO: SupportedModels.LOCK_PRO, SwitchbotModel.BLIND_TILT: SupportedModels.BLIND_TILT, + SwitchbotModel.HUB2: SupportedModels.HUB2, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -52,6 +56,7 @@ SUPPORTED_MODEL_TYPES = ( CONNECTABLE_SUPPORTED_MODEL_TYPES | NON_CONNECTABLE_SUPPORTED_MODEL_TYPES ) +SUPPORTED_LOCK_MODELS = {SwitchbotModel.LOCK, SwitchbotModel.LOCK_PRO} HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { str(v): k for k, v in SUPPORTED_MODEL_TYPES.items() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 401d85e7376..dc858a688cb 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.45.0"] + "requirements": ["PySwitchbot==0.48.0"] } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 8eab1ec6f1a..a20b4939f8f 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -46,7 +46,7 @@ "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "No supported SwitchBot devices found in range; If the device is in range, ensure the scanner has active scanning enabled, as SwitchBot devices cannot be discovered with passive scans. Active scans can be disabled once the device is configured. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the SwitchBot device is present.", "unknown": "[%key:common::config_flow::error::unknown%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "api_error": "Error while communicating with SwitchBot API: {error_detail}", "switchbot_unsupported_type": "Unsupported Switchbot Type." } }, diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 744d513f521..c79ba41018f 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,4 +1,4 @@ -"""The SwitchBot via API integration.""" +"""SwitchBot via API integration.""" from asyncio import gather from dataclasses import dataclass, field @@ -15,7 +15,7 @@ from .const import DOMAIN from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] @dataclass @@ -24,6 +24,7 @@ class SwitchbotDevices: climates: list[Remote] = field(default_factory=list) switches: list[Device | Remote] = field(default_factory=list) + sensors: list[Device] = field(default_factory=list) @dataclass @@ -72,6 +73,14 @@ def make_device_data( devices_data.switches.append( prepare_device(hass, api, device, coordinators_by_id) ) + if isinstance(device, Device) and device.device_type in [ + "Meter", + "MeterPlus", + "WoIOSensor", + ]: + devices_data.sensors.append( + prepare_device(hass, api, device, coordinators_by_id) + ) return devices_data diff --git a/homeassistant/components/switchbot_cloud/config_flow.py b/homeassistant/components/switchbot_cloud/config_flow.py index c01699b8c5d..eafe823bc0b 100644 --- a/homeassistant/components/switchbot_cloud/config_flow.py +++ b/homeassistant/components/switchbot_cloud/config_flow.py @@ -40,7 +40,7 @@ class SwitchBotCloudConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index b90a2f3a2ec..66c84b63047 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -5,4 +5,8 @@ from typing import Final DOMAIN: Final = "switchbot_cloud" ENTRY_TITLE = "SwitchBot Cloud" -SCAN_INTERVAL = timedelta(seconds=600) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=600) + +SENSOR_KIND_TEMPERATURE = "temperature" +SENSOR_KIND_HUMIDITY = "humidity" +SENSOR_KIND_BATTERY = "battery" diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py index 4c12e03a6f2..0ebd04f7e5a 100644 --- a/homeassistant/components/switchbot_cloud/coordinator.py +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -9,11 +9,11 @@ from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, SCAN_INTERVAL +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = getLogger(__name__) -Status = dict[str, Any] | None +type Status = dict[str, Any] | None class SwitchBotCoordinator(DataUpdateCoordinator[Status]): @@ -21,7 +21,6 @@ class SwitchBotCoordinator(DataUpdateCoordinator[Status]): _api: SwitchBotAPI _device_id: str - _should_poll = False def __init__( self, hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote @@ -31,7 +30,7 @@ class SwitchBotCoordinator(DataUpdateCoordinator[Status]): hass, _LOGGER, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=DEFAULT_SCAN_INTERVAL, ) self._api = api self._device_id = device.device_id diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 2b50f39925f..e7a220bc42c 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -1,9 +1,10 @@ { "domain": "switchbot_cloud", "name": "SwitchBot Cloud", - "codeowners": ["@SeraphicRav"], + "codeowners": ["@SeraphicRav", "@laurence-presland"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], "requirements": ["switchbot-api==2.1.0"] diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py new file mode 100644 index 00000000000..ac612aea119 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -0,0 +1,83 @@ +"""Platform for sensor integration.""" + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator +from .entity import SwitchBotCloudEntity + +SENSOR_TYPE_TEMPERATURE = "temperature" +SENSOR_TYPE_HUMIDITY = "humidity" +SENSOR_TYPE_BATTERY = "battery" + +METER_PLUS_SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=SENSOR_TYPE_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + SensorEntityDescription( + key=SENSOR_TYPE_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key=SENSOR_TYPE_BATTERY, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + + async_add_entities( + SwitchBotCloudSensor(data.api, device, coordinator, description) + for device, coordinator in data.devices.sensors + for description in METER_PLUS_SENSOR_DESCRIPTIONS + ) + + +class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity): + """Representation of a SwitchBot Cloud sensor entity.""" + + def __init__( + self, + api: SwitchBotAPI, + device: Device, + coordinator: SwitchBotCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize SwitchBot Cloud sensor entity.""" + super().__init__(api, device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{device.device_id}_{description.key}" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.data: + return + self._attr_native_value = self.coordinator.data.get(self.entity_description.key) + self.async_write_ha_state() diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index b3315bac2ca..555ba951041 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -2,33 +2,18 @@ from __future__ import annotations -from datetime import timedelta import logging +from aioswitcher.bridge import SwitcherBridge from aioswitcher.device import SwitcherBase -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - update_coordinator, -) -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import device_registry as dr -from .const import ( - CONF_DEVICE_PASSWORD, - CONF_PHONE_ID, - DATA_DEVICE, - DATA_DISCOVERY, - DOMAIN, - MAX_UPDATE_INTERVAL_SEC, - SIGNAL_DEVICE_ADD, -) -from .utils import async_start_bridge, async_stop_bridge +from .const import DOMAIN +from .coordinator import SwitcherDataUpdateCoordinator PLATFORMS = [ Platform.BUTTON, @@ -40,51 +25,21 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PHONE_ID): cv.string, - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(CONF_DEVICE_PASSWORD): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) + +type SwitcherConfigEntry = ConfigEntry[dict[str, SwitcherDataUpdateCoordinator]] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the switcher component.""" - hass.data.setdefault(DOMAIN, {}) - - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={} - ) - ) - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> bool: """Set up Switcher from a config entry.""" - hass.data[DOMAIN][DATA_DEVICE] = {} @callback def on_device_data_callback(device: SwitcherBase) -> None: """Use as a callback for device data.""" + coordinators = entry.runtime_data + # Existing device update device data - if device.device_id in hass.data[DOMAIN][DATA_DEVICE]: - coordinator: SwitcherDataUpdateCoordinator = hass.data[DOMAIN][DATA_DEVICE][ - device.device_id - ] + if coordinator := coordinators.get(device.device_id): coordinator.async_set_updated_data(device) return @@ -98,24 +53,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device.device_type.hex_rep, ) - coordinator = hass.data[DOMAIN][DATA_DEVICE][device.device_id] = ( - SwitcherDataUpdateCoordinator(hass, entry, device) - ) + coordinator = SwitcherDataUpdateCoordinator(hass, entry, device) coordinator.async_setup() + coordinators[device.device_id] = coordinator # Must be ready before dispatcher is called await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - discovery_task = hass.data[DOMAIN].pop(DATA_DISCOVERY, None) - if discovery_task is not None: - discovered_devices = await discovery_task - for device in discovered_devices.values(): - on_device_data_callback(device) + entry.runtime_data = {} + bridge = SwitcherBridge(on_device_data_callback) + await bridge.start() - await async_start_bridge(hass, on_device_data_callback) + async def stop_bridge(event: Event | None = None) -> None: + await bridge.stop() - async def stop_bridge(event: Event) -> None: - await async_stop_bridge(hass) + entry.async_on_unload(stop_bridge) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) @@ -124,67 +76,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -class SwitcherDataUpdateCoordinator( - update_coordinator.DataUpdateCoordinator[SwitcherBase] -): # pylint: disable=hass-enforce-coordinator-module - """Switcher device data update coordinator.""" - - def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, device: SwitcherBase - ) -> None: - """Initialize the Switcher device coordinator.""" - super().__init__( - hass, - _LOGGER, - name=device.name, - update_interval=timedelta(seconds=MAX_UPDATE_INTERVAL_SEC), - ) - self.entry = entry - self.data = device - - async def _async_update_data(self) -> SwitcherBase: - """Mark device offline if no data.""" - raise update_coordinator.UpdateFailed( - f"Device {self.name} did not send update for" - f" {MAX_UPDATE_INTERVAL_SEC} seconds" - ) - - @property - def model(self) -> str: - """Switcher device model.""" - return self.data.device_type.value # type: ignore[no-any-return] - - @property - def device_id(self) -> str: - """Switcher device id.""" - return self.data.device_id # type: ignore[no-any-return] - - @property - def mac_address(self) -> str: - """Switcher device mac address.""" - return self.data.mac_address # type: ignore[no-any-return] - - @callback - def async_setup(self) -> None: - """Set up the coordinator.""" - dev_reg = dr.async_get(self.hass) - dev_reg.async_get_or_create( - config_entry_id=self.entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, - identifiers={(DOMAIN, self.device_id)}, - manufacturer="Switcher", - name=self.name, - model=self.model, - ) - async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADD, self) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> bool: """Unload a config entry.""" - await async_stop_bridge(hass) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(DATA_DEVICE) - return unload_ok +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: SwitcherConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return not device_entry.identifiers.intersection( + (DOMAIN, device_id) for device_id in config_entry.runtime_data + ) diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index b0e45f1374a..b770c48c11c 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -2,11 +2,13 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any, cast from aioswitcher.api import ( DeviceState, + SwitcherApi, SwitcherBaseResponse, SwitcherType2Api, ThermostatSwing, @@ -15,7 +17,6 @@ from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.device import DeviceCategory from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -25,8 +26,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDataUpdateCoordinator +from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD +from .coordinator import SwitcherDataUpdateCoordinator from .utils import get_breeze_remote_manager @@ -34,7 +36,10 @@ from .utils import get_breeze_remote_manager class SwitcherThermostatButtonEntityDescription(ButtonEntityDescription): """Class to describe a Switcher Thermostat Button entity.""" - press_fn: Callable[[SwitcherType2Api, SwitcherBreezeRemote], SwitcherBaseResponse] + press_fn: Callable[ + [SwitcherApi, SwitcherBreezeRemote], + Coroutine[Any, Any, SwitcherBaseResponse], + ] supported: Callable[[SwitcherBreezeRemote], bool] @@ -46,7 +51,7 @@ THERMOSTAT_BUTTONS = [ press_fn=lambda api, remote: api.control_breeze_device( remote, state=DeviceState.ON, update_state=True ), - supported=lambda remote: bool(remote.on_off_type), + supported=lambda _: True, ), SwitcherThermostatButtonEntityDescription( key="assume_off", @@ -55,7 +60,7 @@ THERMOSTAT_BUTTONS = [ press_fn=lambda api, remote: api.control_breeze_device( remote, state=DeviceState.OFF, update_state=True ), - supported=lambda remote: bool(remote.on_off_type), + supported=lambda _: True, ), SwitcherThermostatButtonEntityDescription( key="vertical_swing_on", @@ -78,16 +83,17 @@ THERMOSTAT_BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SwitcherConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Switcher button from config entry.""" async def async_add_buttons(coordinator: SwitcherDataUpdateCoordinator) -> None: """Get remote and add button from Switcher device.""" + data = cast(SwitcherBreezeRemote, coordinator.data) if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: remote: SwitcherBreezeRemote = await hass.async_add_executor_job( - get_breeze_remote_manager(hass).get_remote, coordinator.data.remote_id + get_breeze_remote_manager(hass).get_remote, data.remote_id ) async_add_entities( SwitcherThermostatButtonEntity(coordinator, description, remote) @@ -126,7 +132,7 @@ class SwitcherThermostatButtonEntity( async def async_press(self) -> None: """Press the button.""" - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index caf46ca8975..e6267e15305 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -9,6 +9,7 @@ from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.device import ( DeviceCategory, DeviceState, + SwitcherThermostat, ThermostatFanLevel, ThermostatMode, ThermostatSwing, @@ -25,7 +26,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -35,8 +35,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDataUpdateCoordinator +from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD +from .coordinator import SwitcherDataUpdateCoordinator from .utils import get_breeze_remote_manager DEVICE_MODE_TO_HA = { @@ -61,16 +62,17 @@ HA_TO_DEVICE_FAN = {value: key for key, value in DEVICE_FAN_TO_HA.items()} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SwitcherConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Switcher climate from config entry.""" async def async_add_climate(coordinator: SwitcherDataUpdateCoordinator) -> None: """Get remote and add climate from Switcher device.""" + data = cast(SwitcherThermostat, coordinator.data) if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: remote: SwitcherBreezeRemote = await hass.async_add_executor_job( - get_breeze_remote_manager(hass).get_remote, coordinator.data.remote_id + get_breeze_remote_manager(hass).get_remote, data.remote_id ) async_add_entities([SwitcherClimateEntity(coordinator, remote)]) @@ -133,13 +135,13 @@ class SwitcherClimateEntity( def _update_data(self, force_update: bool = False) -> None: """Update data from device.""" - data = self.coordinator.data + data = cast(SwitcherThermostat, self.coordinator.data) features = self._remote.modes_features[data.mode] if data.target_temperature == 0 and not force_update: return - self._attr_current_temperature = cast(float, data.temperature) + self._attr_current_temperature = data.temperature self._attr_target_temperature = float(data.target_temperature) self._attr_hvac_mode = HVACMode.OFF @@ -162,7 +164,7 @@ class SwitcherClimateEntity( async def _async_control_breeze_device(self, **kwargs: Any) -> None: """Call Switcher Control Breeze API.""" - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: @@ -185,9 +187,8 @@ class SwitcherClimateEntity( async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if not self._remote.modes_features[self.coordinator.data.mode][ - "temperature_control" - ]: + data = cast(SwitcherThermostat, self.coordinator.data) + if not self._remote.modes_features[data.mode]["temperature_control"]: raise HomeAssistantError( "Current mode doesn't support setting Target Temperature" ) @@ -199,7 +200,8 @@ class SwitcherClimateEntity( async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if not self._remote.modes_features[self.coordinator.data.mode]["fan_levels"]: + data = cast(SwitcherThermostat, self.coordinator.data) + if not self._remote.modes_features[data.mode]["fan_levels"]: raise HomeAssistantError("Current mode doesn't support setting Fan Mode") await self._async_control_breeze_device(fan_level=HA_TO_DEVICE_FAN[fan_mode]) @@ -215,7 +217,8 @@ class SwitcherClimateEntity( async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" - if not self._remote.modes_features[self.coordinator.data.mode]["swing"]: + data = cast(SwitcherThermostat, self.coordinator.data) + if not self._remote.modes_features[data.mode]["swing"]: raise HomeAssistantError("Current mode doesn't support setting Swing Mode") if swing_mode == SWING_VERTICAL: diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py index bd24481ce3f..31764ecf390 100644 --- a/homeassistant/components/switcher_kis/config_flow.py +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -2,49 +2,9 @@ from __future__ import annotations -from typing import Any +from homeassistant.helpers import config_entry_flow -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from .const import DOMAIN +from .utils import async_has_devices -from .const import DATA_DISCOVERY, DOMAIN -from .utils import async_discover_devices - - -class SwitcherFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle Switcher config flow.""" - - async def async_step_import( - self, import_config: dict[str, Any] - ) -> ConfigFlowResult: - """Handle a flow initiated by import.""" - if self._async_current_entries(True): - return self.async_abort(reason="single_instance_allowed") - - return self.async_create_entry(title="Switcher", data={}) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the start of the config flow.""" - if self._async_current_entries(True): - return self.async_abort(reason="single_instance_allowed") - - self.hass.data.setdefault(DOMAIN, {}) - if DATA_DISCOVERY not in self.hass.data[DOMAIN]: - self.hass.data[DOMAIN][DATA_DISCOVERY] = self.hass.async_create_task( - async_discover_devices() - ) - - return self.async_show_form(step_id="confirm") - - async def async_step_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle user-confirmation of the config flow.""" - discovered_devices = await self.hass.data[DOMAIN][DATA_DISCOVERY] - - if len(discovered_devices) == 0: - self.hass.data[DOMAIN].pop(DATA_DISCOVERY) - return self.async_abort(reason="no_devices_found") - - return self.async_create_entry(title="Switcher", data={}) +config_entry_flow.register_discovery_flow(DOMAIN, "Switcher", async_has_devices) diff --git a/homeassistant/components/switcher_kis/const.py b/homeassistant/components/switcher_kis/const.py index 248b7afbc81..9edc69e4946 100644 --- a/homeassistant/components/switcher_kis/const.py +++ b/homeassistant/components/switcher_kis/const.py @@ -2,13 +2,6 @@ DOMAIN = "switcher_kis" -CONF_DEVICE_PASSWORD = "device_password" -CONF_PHONE_ID = "phone_id" - -DATA_BRIDGE = "bridge" -DATA_DEVICE = "device" -DATA_DISCOVERY = "discovery" - DISCOVERY_TIME_SEC = 12 SIGNAL_DEVICE_ADD = "switcher_device_add" diff --git a/homeassistant/components/switcher_kis/coordinator.py b/homeassistant/components/switcher_kis/coordinator.py new file mode 100644 index 00000000000..1fdefda23a2 --- /dev/null +++ b/homeassistant/components/switcher_kis/coordinator.py @@ -0,0 +1,72 @@ +"""Coordinator for the Switcher integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioswitcher.device import SwitcherBase + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, update_coordinator +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DOMAIN, MAX_UPDATE_INTERVAL_SEC, SIGNAL_DEVICE_ADD + +_LOGGER = logging.getLogger(__name__) + + +class SwitcherDataUpdateCoordinator( + update_coordinator.DataUpdateCoordinator[SwitcherBase] +): + """Switcher device data update coordinator.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: SwitcherBase + ) -> None: + """Initialize the Switcher device coordinator.""" + super().__init__( + hass, + _LOGGER, + name=device.name, + update_interval=timedelta(seconds=MAX_UPDATE_INTERVAL_SEC), + ) + self.entry = entry + self.data = device + + async def _async_update_data(self) -> SwitcherBase: + """Mark device offline if no data.""" + raise update_coordinator.UpdateFailed( + f"Device {self.name} did not send update for" + f" {MAX_UPDATE_INTERVAL_SEC} seconds" + ) + + @property + def model(self) -> str: + """Switcher device model.""" + return self.data.device_type.value + + @property + def device_id(self) -> str: + """Switcher device id.""" + return self.data.device_id + + @property + def mac_address(self) -> str: + """Switcher device mac address.""" + return self.data.mac_address + + @callback + def async_setup(self) -> None: + """Set up the coordinator.""" + dev_reg = dr.async_get(self.hass) + dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, + identifiers={(DOMAIN, self.device_id)}, + manufacturer="Switcher", + name=self.name, + model=self.model, + ) + async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADD, self) diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 69ec501c4a7..258af3e1d5e 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter @@ -23,8 +23,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDataUpdateCoordinator from .const import SIGNAL_DEVICE_ADD +from .coordinator import SwitcherDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -84,7 +84,7 @@ class SwitcherCoverEntity( def _update_data(self) -> None: """Update data from device.""" - data: SwitcherShutter = self.coordinator.data + data = cast(SwitcherShutter, self.coordinator.data) self._attr_current_cover_position = data.position self._attr_is_closed = data.position == 0 self._attr_is_closing = data.direction == ShutterDirection.SHUTTER_DOWN @@ -93,7 +93,7 @@ class SwitcherCoverEntity( async def _async_call_api(self, api: str, *args: Any) -> None: """Call Switcher API.""" _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: diff --git a/homeassistant/components/switcher_kis/diagnostics.py b/homeassistant/components/switcher_kis/diagnostics.py index 441f45198a2..a81e3e25bb9 100644 --- a/homeassistant/components/switcher_kis/diagnostics.py +++ b/homeassistant/components/switcher_kis/diagnostics.py @@ -6,24 +6,23 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DATA_DEVICE, DOMAIN +from . import SwitcherConfigEntry TO_REDACT = {"device_id", "device_key", "ip_address", "mac_address"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SwitcherConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - devices = hass.data[DOMAIN][DATA_DEVICE] + coordinators = entry.runtime_data return async_redact_data( { "entry": entry.as_dict(), - "devices": [asdict(devices[d].data) for d in devices], + "devices": [asdict(coordinators[d].data) for d in coordinators], }, TO_REDACT, ) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 055c92cc2fa..52b218fce9c 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,5 +7,6 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==3.4.1"] + "requirements": ["aioswitcher==3.4.3"], + "single_config_entry": true } diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 88da03fecea..ee503dcda95 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -20,8 +20,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDataUpdateCoordinator from .const import SIGNAL_DEVICE_ADD +from .coordinator import SwitcherDataUpdateCoordinator POWER_SENSORS: list[SensorEntityDescription] = [ SensorEntityDescription( diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index b7c79f6dbc3..2280d6bc845 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -23,7 +23,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SwitcherDataUpdateCoordinator from .const import ( CONF_AUTO_OFF, CONF_TIMER_MINUTES, @@ -31,6 +30,7 @@ from .const import ( SERVICE_TURN_ON_WITH_TIMER_NAME, SIGNAL_DEVICE_ADD, ) +from .coordinator import SwitcherDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -111,7 +111,7 @@ class SwitcherBaseSwitchEntity( _LOGGER.debug( "Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args ) - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index d95c1122732..ad23d51e44d 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable import logging -from typing import Any from aioswitcher.api.remotes import SwitcherBreezeRemoteManager from aioswitcher.bridge import SwitcherBase, SwitcherBridge @@ -13,30 +11,12 @@ from aioswitcher.bridge import SwitcherBase, SwitcherBridge from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton -from .const import DATA_BRIDGE, DISCOVERY_TIME_SEC, DOMAIN +from .const import DISCOVERY_TIME_SEC _LOGGER = logging.getLogger(__name__) -async def async_start_bridge( - hass: HomeAssistant, on_device_callback: Callable[[SwitcherBase], Any] -) -> None: - """Start switcher UDP bridge.""" - bridge = hass.data[DOMAIN][DATA_BRIDGE] = SwitcherBridge(on_device_callback) - _LOGGER.debug("Starting Switcher bridge") - await bridge.start() - - -async def async_stop_bridge(hass: HomeAssistant) -> None: - """Stop switcher UDP bridge.""" - bridge: SwitcherBridge = hass.data[DOMAIN].get(DATA_BRIDGE) - if bridge is not None: - _LOGGER.debug("Stopping Switcher bridge") - await bridge.stop() - hass.data[DOMAIN].pop(DATA_BRIDGE) - - -async def async_discover_devices() -> dict[str, SwitcherBase]: +async def async_has_devices(hass: HomeAssistant) -> bool: """Discover Switcher devices.""" _LOGGER.debug("Starting discovery") discovered_devices = {} @@ -55,7 +35,7 @@ async def async_discover_devices() -> dict[str, SwitcherBase]: await bridge.stop() _LOGGER.debug("Finished discovery, discovered devices: %s", len(discovered_devices)) - return discovered_devices + return len(discovered_devices) > 0 @singleton.singleton("switcher_breeze_remote_manager") diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 1d03fd4f027..cbf17ec05b4 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -79,7 +79,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C camera_id ].is_enabled, ) - self.snapshot_quality = api._entry.options.get( + self.snapshot_quality = api._entry.options.get( # noqa: SLF001 CONF_SNAPSHOT_QUALITY, DEFAULT_SNAPSHOT_QUALITY ) super().__init__(api, coordinator, description) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 91c4cfc4ae2..e2023aa91a1 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -29,7 +29,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SSL, - CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -105,6 +104,11 @@ class SynoApi: except BaseException as err: if not self._login_future.done(): self._login_future.set_exception(err) + with suppress(BaseException): + # Clear the flag as its normal that nothing + # will wait for this future to be resolved + # if there are no concurrent login attempts + await self._login_future raise finally: self._login_future = None @@ -119,7 +123,7 @@ class SynoApi: self._entry.data[CONF_USERNAME], self._entry.data[CONF_PASSWORD], self._entry.data[CONF_SSL], - timeout=self._entry.options.get(CONF_TIMEOUT) or DEFAULT_TIMEOUT, + timeout=DEFAULT_TIMEOUT, device_token=self._entry.data.get(CONF_DEVICE_TOKEN), ) await self.async_login() diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index d6c0c6fe3e8..63ff804951c 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -34,7 +34,6 @@ from homeassistant.const import ( CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL, - CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -394,12 +393,6 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), ): cv.positive_int, - vol.Required( - CONF_TIMEOUT, - default=self.config_entry.options.get( - CONF_TIMEOUT, DEFAULT_TIMEOUT - ), - ): cv.positive_int, vol.Required( CONF_SNAPSHOT_QUALITY, default=self.config_entry.options.get( diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 35d3008b416..e6367458578 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,7 @@ from __future__ import annotations +from aiohttp import ClientTimeout from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, @@ -40,11 +41,13 @@ DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options DEFAULT_SCAN_INTERVAL = 15 # min -DEFAULT_TIMEOUT = 30 # sec +DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED ENTITY_UNIT_LOAD = "load" +SHARED_SUFFIX = "_shared" + # Signals SIGNAL_CAMERA_SOURCE_CHANGED = "synology_dsm.camera_stream_source_changed" diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 52a3e1de1eb..357de10b5b8 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from datetime import timedelta import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( @@ -28,19 +28,14 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) -_DataT = TypeVar("_DataT") -_T = TypeVar("_T", bound="SynologyDSMUpdateCoordinator") -_P = ParamSpec("_P") - - -def async_re_login_on_expired( - func: Callable[Concatenate[_T, _P], Awaitable[_DataT]], -) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _DataT]]: +def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( + func: Callable[Concatenate[_T, _P], Awaitable[_R]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: """Define a wrapper to re-login when expired.""" - async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _DataT: + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R: for attempts in range(2): try: return await func(self, *args, **kwargs) @@ -61,7 +56,7 @@ def async_re_login_on_expired( return _async_wrap -class SynologyDSMUpdateCoordinator(DataUpdateCoordinator[_DataT]): +class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" def __init__( diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index 42a8ab8d60f..b30955ae682 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -40,7 +40,7 @@ async def async_get_config_entry_diagnostics( "utilisation": {}, "is_system_loaded": True, "api_details": { - "fetching_entities": syno_api._fetching_entities, # pylint: disable=protected-access + "fetching_entities": syno_api._fetching_entities, # noqa: SLF001 }, } diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 1a2e07af9e1..d8800282c21 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, TypeVar +from typing import Any from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -16,8 +16,6 @@ from .coordinator import ( SynologyDSMUpdateCoordinator, ) -_CoordinatorT = TypeVar("_CoordinatorT", bound=SynologyDSMUpdateCoordinator[Any]) - @dataclass(frozen=True, kw_only=True) class SynologyDSMEntityDescription(EntityDescription): @@ -26,7 +24,9 @@ class SynologyDSMEntityDescription(EntityDescription): api_key: str -class SynologyDSMBaseEntity(CoordinatorEntity[_CoordinatorT]): +class SynologyDSMBaseEntity[_CoordinatorT: SynologyDSMUpdateCoordinator[Any]]( + CoordinatorEntity[_CoordinatorT] +): """Representation of a Synology NAS entry.""" entity_description: SynologyDSMEntityDescription diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index caecfcbd0c9..b1133fd61ad 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.4.2"], + "requirements": ["py-synologydsm-api==2.4.4"], "ssdp": [ { "manufacturer": "Synology", diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 4699a1a5c20..ace5733c222 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -21,13 +21,15 @@ from homeassistant.components.media_source import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import DOMAIN, SHARED_SUFFIX from .models import SynologyDSMData async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Synology media source.""" - entries = hass.config_entries.async_entries(DOMAIN) + entries = hass.config_entries.async_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) hass.http.register_view(SynologyDsmMediaView(hass)) return SynologyPhotosMediaSource(hass, entries) @@ -43,6 +45,7 @@ class SynologyPhotosMediaSourceIdentifier: self.album_id = None self.cache_key = None self.file_name = None + self.is_shared = False if parts: self.unique_id = parts[0] @@ -52,6 +55,9 @@ class SynologyPhotosMediaSourceIdentifier: self.cache_key = parts[2] if len(parts) > 3: self.file_name = parts[3] + if self.file_name.endswith(SHARED_SUFFIX): + self.is_shared = True + self.file_name = self.file_name.removesuffix(SHARED_SUFFIX) class SynologyPhotosMediaSource(MediaSource): @@ -158,10 +164,13 @@ class SynologyPhotosMediaSource(MediaSource): if isinstance(mime_type, str) and mime_type.startswith("image/"): # Force small small thumbnails album_item.thumbnail_size = "sm" + suffix = "" + if album_item.is_shared: + suffix = SHARED_SUFFIX ret.append( BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}/{identifier.album_id}/{album_item.thumbnail_cache_key}/{album_item.file_name}", + identifier=f"{identifier.unique_id}/{identifier.album_id}/{album_item.thumbnail_cache_key}/{album_item.file_name}{suffix}", media_class=MediaClass.IMAGE, media_content_type=mime_type, title=album_item.file_name, @@ -184,8 +193,11 @@ class SynologyPhotosMediaSource(MediaSource): mime_type, _ = mimetypes.guess_type(identifier.file_name) if not isinstance(mime_type, str): raise Unresolvable("No file extension") + suffix = "" + if identifier.is_shared: + suffix = SHARED_SUFFIX return PlayMedia( - f"/synology_dsm/{identifier.unique_id}/{identifier.cache_key}/{identifier.file_name}", + f"/synology_dsm/{identifier.unique_id}/{identifier.cache_key}/{identifier.file_name}{suffix}", mime_type, ) @@ -221,13 +233,14 @@ class SynologyDsmMediaView(http.HomeAssistantView): # location: {cache_key}/{filename} cache_key, file_name = location.split("/") image_id = int(cache_key.split("_")[0]) + if shared := file_name.endswith(SHARED_SUFFIX): + file_name = file_name.removesuffix(SHARED_SUFFIX) mime_type, _ = mimetypes.guess_type(file_name) if not isinstance(mime_type, str): raise web.HTTPNotFound diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] - assert diskstation.api.photos is not None - item = SynoPhotosItem(image_id, "", "", "", cache_key, "", False) + item = SynoPhotosItem(image_id, "", "", "", cache_key, "", shared) try: image = await diskstation.api.photos.download_item(item) except SynologyDSMException as exc: diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 03ef06dc914..a991d151959 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -43,6 +43,7 @@ from homeassistant.core import ( from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, + HomeAssistantError, ServiceValidationError, ) from homeassistant.helpers import ( @@ -108,14 +109,31 @@ async def async_setup_entry( supported = await version.check_supported() except AuthenticationException as exception: _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) - raise ConfigEntryAuthFailed from exception + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, + ) from exception except (ConnectionClosedException, ConnectionErrorException) as exception: raise ConfigEntryNotReady( - f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, ) from exception except TimeoutError as exception: raise ConfigEntryNotReady( - f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, ) from exception # If not supported, create an issue and raise ConfigEntryNotReady @@ -130,7 +148,12 @@ async def async_setup_entry( is_fixable=False, ) raise ConfigEntryNotReady( - "You are not running a supported version of System Bridge. Please update to the latest version." + translation_domain=DOMAIN, + translation_key="unsupported_version", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, ) coordinator = SystemBridgeDataUpdateCoordinator( @@ -143,14 +166,31 @@ async def async_setup_entry( await coordinator.async_get_data(MODULES) except AuthenticationException as exception: _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) - raise ConfigEntryAuthFailed from exception + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, + ) from exception except (ConnectionClosedException, ConnectionErrorException) as exception: raise ConfigEntryNotReady( - f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, ) from exception except TimeoutError as exception: raise ConfigEntryNotReady( - f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, ) from exception # Fetch initial data so we have data when entities subscribe @@ -168,7 +208,12 @@ async def async_setup_entry( await asyncio.sleep(1) except TimeoutError as exception: raise ConfigEntryNotReady( - f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={ + "title": entry.title, + "host": entry.data[CONF_HOST], + }, ) from exception hass.data.setdefault(DOMAIN, {}) @@ -208,8 +253,16 @@ async def async_setup_entry( if entry.entry_id in device_entry.config_entries ) except StopIteration as exception: - raise vol.Invalid(f"Could not find device {device}") from exception - raise vol.Invalid(f"Device {device} does not exist") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device}, + ) from exception + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": device}, + ) async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse: """Handle the get process by id service call.""" diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index ff24a2c730f..ab1eeb09611 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -115,7 +115,7 @@ async def _async_get_info( errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index f810c69a873..836e7361923 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -59,6 +59,8 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]) session=async_get_clientsession(hass), ) + self._host = entry.data[CONF_HOST] + super().__init__( hass, LOGGER, @@ -191,7 +193,14 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]) self.unsub = None self.last_update_success = False self.async_update_listeners() - raise ConfigEntryAuthFailed from exception + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + translation_placeholders={ + "title": self.title, + "host": self._host, + }, + ) from exception except ConnectionErrorException as exception: self.logger.warning( "Connection error occurred for %s. Will retry: %s", diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index 98a1fe4c08d..b5ceba9bd84 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -95,8 +95,26 @@ } }, "exceptions": { + "authentication_failed": { + "message": "Authentication failed for {title} ({host})" + }, + "connection_failed": { + "message": "A connection error occurred for {title} ({host})" + }, + "device_not_found": { + "message": "Could not find device {device}" + }, + "no_data_received": { + "message": "No data received from {host}" + }, "process_not_found": { "message": "Could not find process with id {id}." + }, + "timeout": { + "message": "A timeout occurred for {title} ({host})" + }, + "unsupported_version": { + "message": "You are not running a supported version of System Bridge for {title} ({host}). Please upgrade to the latest version" } }, "issues": { diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index bb050d5052e..ca1d4026ea9 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -89,7 +89,7 @@ async def get_integration_info( data = await registration.info_callback(hass) except TimeoutError: data = {"error": {"type": "failed", "error": "timeout"}} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error fetching info") data = {"error": {"type": "failed", "error": "unknown"}} diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index b7222b75b72..0749f87a67f 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -19,7 +19,7 @@ from homeassistant.core import Event, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -KeyType = tuple[str, tuple[str, int], tuple[str, int, str] | None] +type KeyType = tuple[str, tuple[str, int], tuple[str, int, str] | None] CONF_MAX_ENTRIES = "max_entries" CONF_FIRE_EVENT = "fire_event" @@ -106,7 +106,7 @@ def _figure_out_source( # and since this code is running in the event loop, we need to avoid # blocking I/O. - frame = sys._getframe(4) # pylint: disable=protected-access + frame = sys._getframe(4) # noqa: SLF001 # # We use _getframe with 4 to skip the following frames: # @@ -152,10 +152,10 @@ def _safe_get_message(record: logging.LogRecord) -> str: """ try: return record.getMessage() - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 try: return f"Bad logger message: {record.msg} ({record.args})" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return f"Bad logger message: {ex}" diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index a0053fb4953..3fbc9edec2a 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -SystemMonitorConfigEntry = ConfigEntry["SystemMonitorData"] +type SystemMonitorConfigEntry = ConfigEntry[SystemMonitorData] @dataclass diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py index 157ec54920b..aecd30765ff 100644 --- a/homeassistant/components/systemmonitor/binary_sensor.py +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -93,7 +93,7 @@ async def async_setup_entry( entry: SystemMonitorConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up System Montor binary sensors based on a config entry.""" + """Set up System Monitor binary sensors based on a config entry.""" coordinator = entry.runtime_data.coordinator async_add_entities( diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 924f63c8d1c..0ff882d89da 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -8,11 +8,9 @@ from typing import Any import voluptuous as vol from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import callback 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, @@ -53,37 +51,6 @@ async def validate_sensor_setup( 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(BINARY_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 @@ -112,10 +79,6 @@ async def get_suggested_value(handler: SchemaCommonFlowHandler) -> dict[str, Any CONFIG_FLOW = { "user": SchemaFlowFormStep(schema=vol.Schema({})), - "import": SchemaFlowFormStep( - schema=vol.Schema({}), - validate_user_input=validate_import_sensor_setup, - ), } OPTIONS_FLOW = { "init": SchemaFlowFormStep( diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 947f637c572..bad4c3be0b5 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -15,20 +15,15 @@ import time from typing import Any, Literal from psutil import NoSuchProcess -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 from homeassistant.const import ( - CONF_RESOURCES, - CONF_TYPE, PERCENTAGE, STATE_OFF, STATE_ON, @@ -39,11 +34,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -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, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -410,20 +404,6 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription] = { } -def check_required_arg(value: Any) -> Any: - """Validate that the required "arg" for the sensor types that need it are set.""" - for sensor in value: - sensor_type = sensor[CONF_TYPE] - sensor_arg = sensor.get(CONF_ARG) - - if sensor_arg is None and SENSOR_TYPES[sensor_type].mandatory_arg: - raise vol.RequiredFieldInvalid( - f"Mandatory 'arg' is missing for sensor type '{sensor_type}'." - ) - - 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 @@ -435,23 +415,6 @@ def check_legacy_resource(resource: str, resources: set[str]) -> bool: return False -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_RESOURCES, default={CONF_TYPE: "disk_use"}): vol.All( - cv.ensure_list, - [ - vol.Schema( - { - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional(CONF_ARG): cv.string, - } - ) - ], - check_required_arg, - ) - } -) - IO_COUNTER = { "network_out": 0, "network_in": 1, @@ -463,50 +426,12 @@ IO_COUNTER = { IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INET6} -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - 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: SystemMonitorConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up System Montor sensors based on a config entry.""" + """Set up System Monitor sensors based on a config entry.""" entities: list[SystemMonitorSensor] = [] legacy_resources: set[str] = set(entry.options.get("resources", [])) loaded_resources: set[str] = set() diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 8f69ccdaffb..be58c68be91 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -384,12 +384,15 @@ class TadoConnector: mode=None, fan_speed=None, swing=None, + fan_level=None, + vertical_swing=None, + horizontal_swing=None, ): """Set a zone overlay.""" _LOGGER.debug( ( "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s," - " type=%s, mode=%s fan_speed=%s swing=%s" + " type=%s, mode=%s fan_speed=%s swing=%s fan_level=%s vertical_swing=%s horizontal_swing=%s" ), zone_id, overlay_mode, @@ -399,6 +402,9 @@ class TadoConnector: mode, fan_speed, swing, + fan_level, + vertical_swing, + horizontal_swing, ) try: @@ -412,6 +418,9 @@ class TadoConnector: mode, fan_speed=fan_speed, swing=swing, + fan_level=fan_level, + vertical_swing=vertical_swing, + horizontal_swing=horizontal_swing, ) except RequestException as exc: diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 6d298a80e79..2698b6e1446 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -13,6 +13,10 @@ from homeassistant.components.climate import ( FAN_AUTO, PRESET_AWAY, PRESET_HOME, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -36,15 +40,13 @@ from .const import ( CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, - CONST_OVERLAY_TADO_DEFAULT, - CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_OPTIONS, - CONST_OVERLAY_TIMER, DATA, DOMAIN, HA_TERMINATION_DURATION, HA_TERMINATION_TYPE, HA_TO_TADO_FAN_MODE_MAP, + HA_TO_TADO_FAN_MODE_MAP_LEGACY, HA_TO_TADO_HVAC_MODE_MAP, HA_TO_TADO_SWING_MODE_MAP, ORDERED_KNOWN_TADO_MODES, @@ -54,11 +56,14 @@ from .const import ( SUPPORT_PRESET_MANUAL, TADO_DEFAULT_MAX_TEMP, TADO_DEFAULT_MIN_TEMP, + TADO_FAN_LEVELS, + TADO_FAN_SPEEDS, TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, TADO_MODES_WITH_NO_TEMP_SETTING, TADO_SWING_OFF, TADO_SWING_ON, TADO_TO_HA_FAN_MODE_MAP, + TADO_TO_HA_FAN_MODE_MAP_LEGACY, TADO_TO_HA_HVAC_MODE_MAP, TADO_TO_HA_OFFSET_MAP, TADO_TO_HA_SWING_MODE_MAP, @@ -67,6 +72,7 @@ from .const import ( TYPE_HEATING, ) from .entity import TadoZoneEntity +from .helper import decide_duration, decide_overlay_mode _LOGGER = logging.getLogger(__name__) @@ -149,6 +155,7 @@ def create_climate_entity( TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE], ] supported_fan_modes = None + supported_swing_modes = None heat_temperatures = None cool_temperatures = None @@ -159,10 +166,31 @@ def create_climate_entity( continue supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode]) - if capabilities[mode].get("swings"): + if ( + capabilities[mode].get("swings") + or capabilities[mode].get("verticalSwing") + or capabilities[mode].get("horizontalSwing") + ): support_flags |= ClimateEntityFeature.SWING_MODE + supported_swing_modes = [] + if capabilities[mode].get("swings"): + supported_swing_modes.append( + TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON] + ) + if capabilities[mode].get("verticalSwing"): + supported_swing_modes.append(SWING_VERTICAL) + if capabilities[mode].get("horizontalSwing"): + supported_swing_modes.append(SWING_HORIZONTAL) + if ( + SWING_HORIZONTAL in supported_swing_modes + and SWING_HORIZONTAL in supported_swing_modes + ): + supported_swing_modes.append(SWING_BOTH) + supported_swing_modes.append(TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF]) - if not capabilities[mode].get("fanSpeeds"): + if not capabilities[mode].get("fanSpeeds") and not capabilities[mode].get( + "fanLevel" + ): continue support_flags |= ClimateEntityFeature.FAN_MODE @@ -170,10 +198,16 @@ def create_climate_entity( if supported_fan_modes: continue - supported_fan_modes = [ - TADO_TO_HA_FAN_MODE_MAP[speed] - for speed in capabilities[mode]["fanSpeeds"] - ] + if capabilities[mode].get("fanSpeeds"): + supported_fan_modes = [ + TADO_TO_HA_FAN_MODE_MAP_LEGACY[speed] + for speed in capabilities[mode]["fanSpeeds"] + ] + else: + supported_fan_modes = [ + TADO_TO_HA_FAN_MODE_MAP[level] + for level in capabilities[mode]["fanLevel"] + ] cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"] else: @@ -221,6 +255,7 @@ def create_climate_entity( cool_max_temp, cool_step, supported_fan_modes, + supported_swing_modes, ) @@ -249,6 +284,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): cool_max_temp: float | None = None, cool_step: float | None = None, supported_fan_modes: list[str] | None = None, + supported_swing_modes: list[str] | None = None, ) -> None: """Initialize of Tado climate entity.""" self._tado = tado @@ -269,11 +305,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._cur_temp = None self._cur_humidity = None - if self.supported_features & ClimateEntityFeature.SWING_MODE: - self._attr_swing_modes = [ - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON], - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF], - ] + self._attr_swing_modes = supported_swing_modes self._heat_min_temp = heat_min_temp self._heat_max_temp = heat_max_temp @@ -289,6 +321,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_hvac_mode = CONST_MODE_OFF self._current_tado_hvac_action = HVACAction.OFF self._current_tado_swing_mode = TADO_SWING_OFF + self._current_tado_vertical_swing = TADO_SWING_OFF + self._current_tado_horizontal_swing = TADO_SWING_OFF self._tado_zone_data: PyTado.TadoZone = {} self._tado_geofence_data: dict[str, str] | None = None @@ -350,12 +384,20 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): def fan_mode(self) -> str | None: """Return the fan setting.""" if self._ac_device: - return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) + return TADO_TO_HA_FAN_MODE_MAP.get( + self._current_tado_fan_speed, + TADO_TO_HA_FAN_MODE_MAP_LEGACY.get( + self._current_tado_fan_speed, FAN_AUTO + ), + ) return None def set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + if self._current_tado_fan_speed in TADO_FAN_LEVELS: + self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + else: + self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) @property def preset_mode(self) -> str: @@ -478,7 +520,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def swing_mode(self) -> str | None: """Active swing mode for the device.""" - return TADO_TO_HA_SWING_MODE_MAP[self._current_tado_swing_mode] + swing_modes_tuple = ( + self._current_tado_swing_mode, + self._current_tado_vertical_swing, + self._current_tado_horizontal_swing, + ) + if swing_modes_tuple == (TADO_SWING_OFF, TADO_SWING_OFF, TADO_SWING_OFF): + return TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF] + if swing_modes_tuple == (TADO_SWING_ON, TADO_SWING_OFF, TADO_SWING_OFF): + return TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON] + if swing_modes_tuple == (TADO_SWING_OFF, TADO_SWING_ON, TADO_SWING_OFF): + return SWING_VERTICAL + if swing_modes_tuple == (TADO_SWING_OFF, TADO_SWING_OFF, TADO_SWING_ON): + return SWING_HORIZONTAL + if swing_modes_tuple == (TADO_SWING_OFF, TADO_SWING_ON, TADO_SWING_ON): + return SWING_BOTH + + return TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF] @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -494,7 +552,35 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): def set_swing_mode(self, swing_mode: str) -> None: """Set swing modes for the device.""" - self._control_hvac(swing_mode=HA_TO_TADO_SWING_MODE_MAP[swing_mode]) + vertical_swing = None + horizontal_swing = None + swing = None + if self._attr_swing_modes is None: + return + if ( + SWING_VERTICAL in self._attr_swing_modes + or SWING_HORIZONTAL in self._attr_swing_modes + ): + if swing_mode == SWING_VERTICAL: + vertical_swing = TADO_SWING_ON + elif swing_mode == SWING_HORIZONTAL: + horizontal_swing = TADO_SWING_ON + elif swing_mode == SWING_BOTH: + vertical_swing = TADO_SWING_ON + horizontal_swing = TADO_SWING_ON + elif swing_mode == SWING_OFF: + if SWING_VERTICAL in self._attr_swing_modes: + vertical_swing = TADO_SWING_OFF + if SWING_HORIZONTAL in self._attr_swing_modes: + horizontal_swing = TADO_SWING_OFF + else: + swing = HA_TO_TADO_SWING_MODE_MAP[swing_mode] + + self._control_hvac( + swing_mode=swing, + vertical_swing=vertical_swing, + horizontal_swing=horizontal_swing, + ) @callback def _async_update_zone_data(self) -> None: @@ -511,10 +597,22 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._tado_zone_temp_offset[attr] = self._tado.data["device"][ self._device_id ][TEMP_OFFSET][offset_key] - self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed + + self._current_tado_fan_speed = ( + self._tado_zone_data.current_fan_level + if self._tado_zone_data.current_fan_level is not None + else self._tado_zone_data.current_fan_speed + ) + self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode + self._current_tado_vertical_swing = ( + self._tado_zone_data.current_vertical_swing_mode + ) + self._current_tado_horizontal_swing = ( + self._tado_zone_data.current_horizontal_swing_mode + ) @callback def _async_update_zone_callback(self) -> None: @@ -558,6 +656,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): swing_mode: str | None = None, duration: int | None = None, overlay_mode: str | None = None, + vertical_swing: str | None = None, + horizontal_swing: str | None = None, ): """Send new target temperature to Tado.""" if hvac_mode: @@ -572,6 +672,12 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): if swing_mode: self._current_tado_swing_mode = swing_mode + if vertical_swing: + self._current_tado_vertical_swing = vertical_swing + + if horizontal_swing: + self._current_tado_horizontal_swing = horizontal_swing + self._normalize_target_temp_for_hvac_mode() # tado does not permit setting the fan speed to @@ -598,31 +704,18 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._tado.reset_zone_overlay(self.zone_id) return - # If user gave duration then overlay mode needs to be timer - if duration: - overlay_mode = CONST_OVERLAY_TIMER - # If no duration or timer set to fallback setting - if overlay_mode is None: - overlay_mode = ( - self._tado.fallback - if self._tado.fallback is not None - else CONST_OVERLAY_TADO_MODE - ) - # If default is Tado default then look it up - if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: - overlay_mode = ( - self._tado_zone_data.default_overlay_termination_type - if self._tado_zone_data.default_overlay_termination_type is not None - else CONST_OVERLAY_TADO_MODE - ) - # If we ended up with a timer but no duration, set a default duration - if overlay_mode == CONST_OVERLAY_TIMER and duration is None: - duration = ( - int(self._tado_zone_data.default_overlay_termination_duration) - if self._tado_zone_data.default_overlay_termination_duration is not None - else 3600 - ) - + overlay_mode = decide_overlay_mode( + tado=self._tado, + duration=duration, + overlay_mode=overlay_mode, + zone_id=self.zone_id, + ) + duration = decide_duration( + tado=self._tado, + duration=duration, + zone_id=self.zone_id, + overlay_mode=overlay_mode, + ) _LOGGER.debug( ( "Switching to %s for zone %s (%d) with temperature %s °C and duration" @@ -642,11 +735,24 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): temperature_to_send = None fan_speed = None + fan_level = None if self.supported_features & ClimateEntityFeature.FAN_MODE: - fan_speed = self._current_tado_fan_speed + if self._current_tado_fan_speed in TADO_FAN_LEVELS: + fan_level = self._current_tado_fan_speed + elif self._current_tado_fan_speed in TADO_FAN_SPEEDS: + fan_speed = self._current_tado_fan_speed swing = None - if self.supported_features & ClimateEntityFeature.SWING_MODE: - swing = self._current_tado_swing_mode + vertical_swing = None + horizontal_swing = None + if ( + self.supported_features & ClimateEntityFeature.SWING_MODE + ) and self._attr_swing_modes is not None: + if SWING_VERTICAL in self._attr_swing_modes: + vertical_swing = self._current_tado_vertical_swing + if SWING_HORIZONTAL in self._attr_swing_modes: + horizontal_swing = self._current_tado_horizontal_swing + if vertical_swing is None and horizontal_swing is None: + swing = self._current_tado_swing_mode self._tado.set_zone_overlay( zone_id=self.zone_id, @@ -657,4 +763,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): mode=self._current_tado_hvac_mode, fan_speed=fan_speed, # api defaults to not sending fanSpeed if None specified swing=swing, # api defaults to not sending swing if None specified + fan_level=fan_level, # api defaults to not sending fanLevel if fanSpeend not None + vertical_swing=vertical_swing, # api defaults to not sending verticalSwing if swing not None + horizontal_swing=horizontal_swing, # api defaults to not sending horizontalSwing if swing not None ) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index 2074b62b8d0..e52b87796f7 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -74,6 +74,7 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tado.""" VERSION = 1 + config_entry: ConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -89,7 +90,7 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoHomes: errors["base"] = "no_homes" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -159,6 +160,56 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): }, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + self.config_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors: dict[str, str] = {} + assert self.config_entry + + if user_input is not None: + user_input[CONF_USERNAME] = self.config_entry.data[CONF_USERNAME] + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except PyTado.exceptions.TadoWrongCredentialsException: + errors["base"] = "invalid_auth" + except NoHomes: + errors["base"] = "no_homes" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + return self.async_update_reload_and_abort( + self.config_entry, + data={**self.config_entry.data, **user_input}, + reason="reconfigure_successful", + ) + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + description_placeholders={ + CONF_USERNAME: self.config_entry.data[CONF_USERNAME] + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index c62352a6d95..a41003da95f 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -77,9 +77,13 @@ CONST_LINK_OFFLINE = "OFFLINE" CONST_FAN_OFF = "OFF" CONST_FAN_AUTO = "AUTO" -CONST_FAN_LOW = "LOW" -CONST_FAN_MIDDLE = "MIDDLE" -CONST_FAN_HIGH = "HIGH" +CONST_FAN_LOW_LEGACY = "LOW" +CONST_FAN_MIDDLE_LEGACY = "MIDDLE" +CONST_FAN_HIGH_LEGACY = "HIGH" + +CONST_FAN_LEVEL_1 = "LEVEL1" +CONST_FAN_LEVEL_2 = "LEVEL2" +CONST_FAN_LEVEL_3 = "LEVEL3" # When we change the temperature setting, we need an overlay mode @@ -139,20 +143,36 @@ HA_TO_TADO_HVAC_MODE_MAP = { HVACMode.FAN_ONLY: CONST_MODE_FAN, } +HA_TO_TADO_FAN_MODE_MAP_LEGACY = { + FAN_AUTO: CONST_FAN_AUTO, + FAN_OFF: CONST_FAN_OFF, + FAN_LOW: CONST_FAN_LOW_LEGACY, + FAN_MEDIUM: CONST_FAN_MIDDLE_LEGACY, + FAN_HIGH: CONST_FAN_HIGH_LEGACY, +} + HA_TO_TADO_FAN_MODE_MAP = { FAN_AUTO: CONST_FAN_AUTO, FAN_OFF: CONST_FAN_OFF, - FAN_LOW: CONST_FAN_LOW, - FAN_MEDIUM: CONST_FAN_MIDDLE, - FAN_HIGH: CONST_FAN_HIGH, + FAN_LOW: CONST_FAN_LEVEL_1, + FAN_MEDIUM: CONST_FAN_LEVEL_2, + FAN_HIGH: CONST_FAN_LEVEL_3, } TADO_TO_HA_HVAC_MODE_MAP = { value: key for key, value in HA_TO_TADO_HVAC_MODE_MAP.items() } +TADO_TO_HA_FAN_MODE_MAP_LEGACY = { + value: key for key, value in HA_TO_TADO_FAN_MODE_MAP_LEGACY.items() +} + TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP.items()} +TADO_FAN_SPEEDS = list(HA_TO_TADO_FAN_MODE_MAP_LEGACY.values()) + +TADO_FAN_LEVELS = list(HA_TO_TADO_FAN_MODE_MAP.values()) + DEFAULT_TADO_PRECISION = 0.1 # Constant for Auto Geolocation mode @@ -212,3 +232,5 @@ SERVICE_ADD_METER_READING = "add_meter_reading" CONF_CONFIG_ENTRY = "config_entry" CONF_READING = "reading" ATTR_MESSAGE = "message" + +WATER_HEATER_FALLBACK_REPAIR = "water_heater_fallback" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index dea92ae3890..d3996db7faf 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -7,6 +7,7 @@ import logging import voluptuous as vol from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, DeviceScanner, SourceType, @@ -16,6 +17,7 @@ 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 +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -78,9 +80,20 @@ async def async_setup_entry( ) -> None: """Set up the Tado device scannery entity.""" _LOGGER.debug("Setting up Tado device scanner entity") - tado = hass.data[DOMAIN][entry.entry_id][DATA] + tado: TadoConnector = hass.data[DOMAIN][entry.entry_id][DATA] tracked: set = set() + # Fix non-string unique_id for device trackers + # Can be removed in 2025.1 + entity_registry = er.async_get(hass) + for device_key in tado.data["mobile_device"]: + if entity_id := entity_registry.async_get_entity_id( + DEVICE_TRACKER_DOMAIN, DOMAIN, device_key + ): + entity_registry.async_update_entity( + entity_id, new_unique_id=str(device_key) + ) + @callback def update_devices() -> None: """Update the values of the devices.""" @@ -134,7 +147,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): ) -> None: """Initialize a Tado Device Tracker entity.""" super().__init__() - self._attr_unique_id = device_id + self._attr_unique_id = str(device_id) self._device_id = device_id self._device_name = device_name self._tado = tado diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py new file mode 100644 index 00000000000..efcd3e7c4ea --- /dev/null +++ b/homeassistant/components/tado/helper.py @@ -0,0 +1,51 @@ +"""Helper methods for Tado.""" + +from . import TadoConnector +from .const import ( + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TIMER, +) + + +def decide_overlay_mode( + tado: TadoConnector, + duration: int | None, + zone_id: int, + overlay_mode: str | None = None, +) -> str: + """Return correct overlay mode based on the action and defaults.""" + # If user gave duration then overlay mode needs to be timer + if duration: + return CONST_OVERLAY_TIMER + # If no duration or timer set to fallback setting + if overlay_mode is None: + overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE + # If default is Tado default then look it up + if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: + overlay_mode = ( + tado.data["zone"][zone_id].default_overlay_termination_type + or CONST_OVERLAY_TADO_MODE + ) + + return overlay_mode + + +def decide_duration( + tado: TadoConnector, + duration: int | None, + zone_id: int, + overlay_mode: str | None = None, +) -> None | int: + """Return correct duration based on the selected overlay mode/duration and tado config.""" + # If we ended up with a timer but no duration, set a default duration + # If we ended up with a timer but no duration, set a default duration + if overlay_mode == CONST_OVERLAY_TIMER and duration is None: + duration = ( + int(tado.data["zone"][zone_id].default_overlay_termination_duration) + if tado.data["zone"][zone_id].default_overlay_termination_duration + is not None + else 3600 + ) + + return duration diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 0f3288ba904..b0c00c888b7 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.17.4"] + "requirements": ["python-tado==0.17.6"] } diff --git a/homeassistant/components/tado/repairs.py b/homeassistant/components/tado/repairs.py new file mode 100644 index 00000000000..90e20c615f2 --- /dev/null +++ b/homeassistant/components/tado/repairs.py @@ -0,0 +1,33 @@ +"""Repair implementations.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .const import ( + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, + DOMAIN, + WATER_HEATER_FALLBACK_REPAIR, +) + + +def manage_water_heater_fallback_issue( + hass: HomeAssistant, + water_heater_names: list[str], + integration_overlay_fallback: str | None, +) -> None: + """Notify users about water heater respecting fallback setting.""" + if integration_overlay_fallback in ( + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_MANUAL, + ): + for water_heater_name in water_heater_names: + ir.async_create_issue( + hass=hass, + domain=DOMAIN, + issue_id=f"{WATER_HEATER_FALLBACK_REPAIR}_{water_heater_name}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=WATER_HEATER_FALLBACK_REPAIR, + ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 267cbbe6fee..d992befe112 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "step": { "user": { @@ -10,6 +11,16 @@ "username": "[%key:common::config_flow::data::username%]" }, "title": "Connect to your Tado account" + }, + "reconfigure_confirm": { + "title": "Reconfigure your Tado", + "description": "Reconfigure the entry, for your account: `{username}`.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the (new) password for Tado." + } } }, "error": { @@ -154,6 +165,10 @@ "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." + }, + "water_heater_fallback": { + "title": "Tado Water Heater entities now support fallback options", + "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options." } } } diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index f1257f097eb..1b3b811d231 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -32,6 +32,8 @@ from .const import ( TYPE_HOT_WATER, ) from .entity import TadoZoneEntity +from .helper import decide_duration, decide_overlay_mode +from .repairs import manage_water_heater_fallback_issue _LOGGER = logging.getLogger(__name__) @@ -79,8 +81,14 @@ async def async_setup_entry( async_add_entities(entities, True) + manage_water_heater_fallback_issue( + hass=hass, + water_heater_names=[e.zone_name for e in entities], + integration_overlay_fallback=tado.fallback, + ) -def _generate_entities(tado: TadoConnector) -> list[WaterHeaterEntity]: + +def _generate_entities(tado: TadoConnector) -> list: """Create all water heater entities.""" entities = [] @@ -277,13 +285,17 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) return - overlay_mode = CONST_OVERLAY_MANUAL - if duration: - overlay_mode = CONST_OVERLAY_TIMER - elif self._tado.fallback: - # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled - overlay_mode = CONST_OVERLAY_TADO_MODE - + overlay_mode = decide_overlay_mode( + tado=self._tado, + duration=duration, + zone_id=self.zone_id, + ) + duration = decide_duration( + tado=self._tado, + duration=duration, + zone_id=self.zone_id, + overlay_mode=overlay_mode, + ) _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s", self._current_tado_hvac_mode, diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 4fd20fff24b..af3d06cf2d4 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -2,41 +2,52 @@ from __future__ import annotations +from collections.abc import Callable import logging +from typing import TYPE_CHECKING, Any, final import uuid import voluptuous as vol -from homeassistant.const import CONF_NAME +from homeassistant.components import websocket_api +from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import collection +from homeassistant.helpers import collection, entity_registry as er import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass +from homeassistant.util import slugify import homeassistant.util.dt as dt_util +from homeassistant.util.hass_dict import HassKey -from .const import DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, TAG_ID +from .const import DEFAULT_NAME, DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, LOGGER, TAG_ID _LOGGER = logging.getLogger(__name__) LAST_SCANNED = "last_scanned" +LAST_SCANNED_BY_DEVICE_ID = "last_scanned_by_device_id" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -TAGS = "tags" +STORAGE_VERSION_MINOR = 3 + +TAG_DATA: HassKey[TagStorageCollection] = HassKey(DOMAIN) CREATE_FIELDS = { vol.Optional(TAG_ID): cv.string, vol.Optional(CONF_NAME): vol.All(str, vol.Length(min=1)), vol.Optional("description"): cv.string, vol.Optional(LAST_SCANNED): cv.datetime, + vol.Optional(DEVICE_ID): cv.string, } UPDATE_FIELDS = { vol.Optional(CONF_NAME): vol.All(str, vol.Length(min=1)), vol.Optional("description"): cv.string, vol.Optional(LAST_SCANNED): cv.datetime, + vol.Optional(DEVICE_ID): cv.string, } CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -62,53 +73,250 @@ class TagIDManager(collection.IDManager): return suggestion +def _create_entry( + entity_registry: er.EntityRegistry, tag_id: str, name: str | None +) -> er.RegistryEntry: + """Create an entity registry entry for a tag.""" + entry = entity_registry.async_get_or_create( + DOMAIN, + DOMAIN, + tag_id, + original_name=f"{DEFAULT_NAME} {tag_id}", + suggested_object_id=slugify(name) if name else tag_id, + ) + return entity_registry.async_update_entity(entry.entity_id, name=name) + + +class TagStore(Store[collection.SerializedStorageCollection]): + """Store tag data.""" + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, list[dict[str, Any]]], + ) -> dict: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1 and old_minor_version < 2: + entity_registry = er.async_get(self.hass) + # Version 1.2 moves name to entity registry + for tag in data["items"]: + # Copy name in tag store to the entity registry + _create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME)) + tag["migrated"] = True + if old_major_version == 1 and old_minor_version < 3: + # Version 1.3 removes tag_id from the store + for tag in data["items"]: + if TAG_ID not in tag: + continue + del tag[TAG_ID] + + if old_major_version > 1: + raise NotImplementedError + + return data + + class TagStorageCollection(collection.DictStorageCollection): """Tag collection stored in storage.""" CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS) + def __init__( + self, + store: TagStore, + id_manager: collection.IDManager | None = None, + ) -> None: + """Initialize the storage collection.""" + super().__init__(store, id_manager) + self.entity_registry = er.async_get(self.hass) + async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" data = self.CREATE_SCHEMA(data) if not data[TAG_ID]: data[TAG_ID] = str(uuid.uuid4()) + # Move tag id to id + data[CONF_ID] = data.pop(TAG_ID) # make last_scanned JSON serializeable if LAST_SCANNED in data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() + + # Create entity in entity_registry when creating the tag + # This is done early to store name only once in entity registry + _create_entry(self.entity_registry, data[CONF_ID], data.get(CONF_NAME)) return data @callback def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" - return info[TAG_ID] + return info[CONF_ID] async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" data = {**item, **self.UPDATE_SCHEMA(update_data)} + tag_id = item[CONF_ID] # make last_scanned JSON serializeable if LAST_SCANNED in update_data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() + if name := data.get(CONF_NAME): + if entity_id := self.entity_registry.async_get_entity_id( + DOMAIN, DOMAIN, tag_id + ): + self.entity_registry.async_update_entity(entity_id, name=name) + else: + raise collection.ItemNotFound(tag_id) + return data + def _serialize_item(self, item_id: str, item: dict) -> dict: + """Return the serialized representation of an item for storing. + + We don't store the name, it's stored in the entity registry. + """ + # Preserve the name of migrated entries to allow downgrading to 2024.5 + # without losing tag names. This can be removed in HA Core 2025.1. + migrated = item_id in self.data and "migrated" in self.data[item_id] + return {k: v for k, v in item.items() if k != CONF_NAME or migrated} + + +class TagDictStorageCollectionWebsocket( + collection.StorageCollectionWebsocket[TagStorageCollection] +): + """Class to expose tag storage collection management over websocket.""" + + def __init__( + self, + storage_collection: TagStorageCollection, + api_prefix: str, + model_name: str, + create_schema: ConfigType, + update_schema: ConfigType, + ) -> None: + """Initialize a websocket for tag.""" + super().__init__( + storage_collection, api_prefix, model_name, create_schema, update_schema + ) + self.entity_registry = er.async_get(storage_collection.hass) + + @callback + def ws_list_item( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """List items specifically for tag. + + Provides name from entity_registry instead of storage collection. + """ + tag_items = [] + for item in self.storage_collection.async_items(): + # Make a copy to avoid adding name to the stored entry + item = {k: v for k, v in item.items() if k != "migrated"} + if ( + entity_id := self.entity_registry.async_get_entity_id( + DOMAIN, DOMAIN, item[CONF_ID] + ) + ) and (entity := self.entity_registry.async_get(entity_id)): + item[CONF_NAME] = entity.name or entity.original_name + tag_items.append(item) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Listing tags %s", tag_items) + connection.send_result(msg["id"], tag_items) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Tag component.""" - hass.data[DOMAIN] = {} + component = EntityComponent[TagEntity](LOGGER, DOMAIN, hass) id_manager = TagIDManager() - hass.data[DOMAIN][TAGS] = storage_collection = TagStorageCollection( - Store(hass, STORAGE_VERSION, STORAGE_KEY), + hass.data[TAG_DATA] = storage_collection = TagStorageCollection( + TagStore( + hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR + ), id_manager, ) await storage_collection.async_load() - collection.DictStorageCollectionWebsocket( + TagDictStorageCollectionWebsocket( storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) + entity_registry = er.async_get(hass) + entity_update_handlers: dict[str, Callable[[str | None, str | None], None]] = {} + + async def tag_change_listener( + change_type: str, item_id: str, updated_config: dict + ) -> None: + """Tag storage change listener.""" + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "%s, item: %s, update: %s", change_type, item_id, updated_config + ) + if change_type == collection.CHANGE_ADDED: + # When tags are added to storage + entity = _create_entry(entity_registry, updated_config[CONF_ID], None) + if TYPE_CHECKING: + assert entity.original_name + await component.async_add_entities( + [ + TagEntity( + entity_update_handlers, + entity.name or entity.original_name, + updated_config[CONF_ID], + updated_config.get(LAST_SCANNED), + updated_config.get(DEVICE_ID), + ) + ] + ) + + elif change_type == collection.CHANGE_UPDATED: + # When tags are changed or updated in storage + if handler := entity_update_handlers.get(updated_config[CONF_ID]): + handler( + updated_config.get(DEVICE_ID), + updated_config.get(LAST_SCANNED), + ) + + # Deleted tags + elif change_type == collection.CHANGE_REMOVED: + # When tags are removed from storage + entity_id = entity_registry.async_get_entity_id( + DOMAIN, DOMAIN, updated_config[CONF_ID] + ) + if entity_id: + entity_registry.async_remove(entity_id) + + storage_collection.async_add_listener(tag_change_listener) + + entities: list[TagEntity] = [] + for tag in storage_collection.async_items(): + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Adding tag: %s", tag) + entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag[CONF_ID]) + if entity_id := entity_registry.async_get_entity_id( + DOMAIN, DOMAIN, tag[CONF_ID] + ): + entity = entity_registry.async_get(entity_id) + else: + entity = _create_entry(entity_registry, tag[CONF_ID], None) + if TYPE_CHECKING: + assert entity + assert entity.original_name + name = entity.name or entity.original_name + entities.append( + TagEntity( + entity_update_handlers, + name, + tag[CONF_ID], + tag.get(LAST_SCANNED), + tag.get(DEVICE_ID), + ) + ) + await component.async_add_entities(entities) + return True -@bind_hass async def async_scan_tag( hass: HomeAssistant, tag_id: str, @@ -119,12 +327,14 @@ async def async_scan_tag( if DOMAIN not in hass.config.components: raise HomeAssistantError("tag component has not been set up.") - helper = hass.data[DOMAIN][TAGS] + storage_collection = hass.data[TAG_DATA] + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag_id) - # Get name from helper, default value None if not present in data + # Get name from entity registry, default value None if not present tag_name = None - if tag_data := helper.data.get(tag_id): - tag_name = tag_data.get(CONF_NAME) + if entity_id and (entity := entity_registry.async_get(entity_id)): + tag_name = entity.name or entity.original_name hass.bus.async_fire( EVENT_TAG_SCANNED, @@ -132,8 +342,86 @@ async def async_scan_tag( context=context, ) - if tag_id in helper.data: - await helper.async_update_item(tag_id, {LAST_SCANNED: dt_util.utcnow()}) + extra_kwargs = {} + if device_id: + extra_kwargs[DEVICE_ID] = device_id + if tag_id in storage_collection.data: + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Updating tag %s with extra %s", tag_id, extra_kwargs) + await storage_collection.async_update_item( + tag_id, {LAST_SCANNED: dt_util.utcnow(), **extra_kwargs} + ) else: - await helper.async_create_item({TAG_ID: tag_id, LAST_SCANNED: dt_util.utcnow()}) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Creating tag %s with extra %s", tag_id, extra_kwargs) + await storage_collection.async_create_item( + {TAG_ID: tag_id, LAST_SCANNED: dt_util.utcnow(), **extra_kwargs} + ) _LOGGER.debug("Tag: %s scanned by device: %s", tag_id, device_id) + + +class TagEntity(Entity): + """Representation of a Tag entity.""" + + _unrecorded_attributes = frozenset({TAG_ID}) + _attr_translation_key = DOMAIN + _attr_should_poll = False + + def __init__( + self, + entity_update_handlers: dict[str, Callable[[str | None, str | None], None]], + name: str, + tag_id: str, + last_scanned: str | None, + device_id: str | None, + ) -> None: + """Initialize the Tag event.""" + self._entity_update_handlers = entity_update_handlers + self._attr_name = name + self._tag_id = tag_id + self._attr_unique_id = tag_id + self._last_device_id: str | None = device_id + self._last_scanned = last_scanned + + @callback + def async_handle_event( + self, device_id: str | None, last_scanned: str | None + ) -> None: + """Handle the Tag scan event.""" + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Tag %s scanned by device %s at %s, last scanned at %s", + self._tag_id, + device_id, + last_scanned, + self._last_scanned, + ) + self._last_device_id = device_id + self._last_scanned = last_scanned + self.async_write_ha_state() + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if ( + not self._last_scanned + or (last_scanned := dt_util.parse_datetime(self._last_scanned)) is None + ): + return None + return last_scanned.isoformat(timespec="milliseconds") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the sun.""" + return {TAG_ID: self._tag_id, LAST_SCANNED_BY_DEVICE_ID: self._last_device_id} + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + self._entity_update_handlers[self._tag_id] = self.async_handle_event + + async def async_will_remove_from_hass(self) -> None: + """Handle entity being removed.""" + await super().async_will_remove_from_hass() + del self._entity_update_handlers[self._tag_id] diff --git a/homeassistant/components/tag/const.py b/homeassistant/components/tag/const.py index ed74a1f0549..fd93e3ecac8 100644 --- a/homeassistant/components/tag/const.py +++ b/homeassistant/components/tag/const.py @@ -1,6 +1,10 @@ """Constants for the Tag integration.""" +import logging + DEVICE_ID = "device_id" DOMAIN = "tag" EVENT_TAG_SCANNED = "tag_scanned" TAG_ID = "tag_id" +DEFAULT_NAME = "Tag" +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tag/icons.json b/homeassistant/components/tag/icons.json new file mode 100644 index 00000000000..d9532aadf73 --- /dev/null +++ b/homeassistant/components/tag/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "tag": { + "tag": { + "default": "mdi:tag-outline" + } + } + } +} diff --git a/homeassistant/components/tag/strings.json b/homeassistant/components/tag/strings.json index ba680ba0d81..75cec1f9ef4 100644 --- a/homeassistant/components/tag/strings.json +++ b/homeassistant/components/tag/strings.json @@ -1,3 +1,17 @@ { - "title": "Tag" + "title": "Tag", + "entity": { + "tag": { + "tag": { + "state_attributes": { + "tag_id": { + "name": "Tag ID" + }, + "last_scanned_by_device_id": { + "name": "Last scanned by device ID" + } + } + } + } + } } diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 35c73cd0223..7803a7eb472 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -36,6 +36,12 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.update_available, ), + TailscaleBinarySensorEntityDescription( + key="key_expiry_disabled", + translation_key="key_expiry_disabled", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda device: device.key_expiry_disabled, + ), TailscaleBinarySensorEntityDescription( key="client_supports_hair_pinning", translation_key="client_supports_hair_pinning", diff --git a/homeassistant/components/tailscale/strings.json b/homeassistant/components/tailscale/strings.json index b110e53ee64..8d7fcc0c87b 100644 --- a/homeassistant/components/tailscale/strings.json +++ b/homeassistant/components/tailscale/strings.json @@ -29,6 +29,9 @@ "client": { "name": "Client" }, + "key_expiry_disabled": { + "name": "Key expiry disabled" + }, "client_supports_hair_pinning": { "name": "Supports hairpinning" }, diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 9bd3bb40be0..6f1a234e94a 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -9,16 +9,17 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN from .coordinator import TailwindDataUpdateCoordinator +from .typing import TailwindConfigEntry PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.NUMBER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TailwindConfigEntry) -> 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 + entry.runtime_data = 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 @@ -40,6 +41,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index e6a1aa67ae1..0ce0b4bd964 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -12,14 +12,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import TailwindDataUpdateCoordinator from .entity import TailwindDoorEntity +from .typing import TailwindConfigEntry @dataclass(kw_only=True, frozen=True) @@ -42,15 +40,14 @@ DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TailwindConfigEntry, 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) + TailwindDoorBinarySensorEntity(entry.runtime_data, door_id, description) for description in DESCRIPTIONS - for door_id in coordinator.data.doors + for door_id in entry.runtime_data.data.doors ) diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index 6073b8f7f58..2a675bbfdf7 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -13,15 +13,14 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TailwindDataUpdateCoordinator from .entity import TailwindEntity +from .typing import TailwindConfigEntry @dataclass(frozen=True, kw_only=True) @@ -43,14 +42,13 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TailwindConfigEntry, 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, + entry.runtime_data, description, ) for description in DESCRIPTIONS diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py index 7204e9c9202..1cb94625266 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -61,7 +61,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors[CONF_TOKEN] = "invalid_auth" except TailwindConnectionError: errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -127,7 +127,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors[CONF_TOKEN] = "invalid_auth" except TailwindConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -167,7 +167,7 @@ class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): errors[CONF_TOKEN] = "invalid_auth" except TailwindConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py index d7cbb248885..4d1b4af74c9 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -22,8 +22,6 @@ 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( diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index f54902dac4a..8fb0f313480 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -17,26 +17,24 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import TailwindDataUpdateCoordinator from .entity import TailwindDoorEntity +from .typing import TailwindConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TailwindConfigEntry, 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 + TailwindDoorCoverEntity(entry.runtime_data, door_id) + for door_id in entry.runtime_data.data.doors ) diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py index 970bb5174eb..5d681356647 100644 --- a/homeassistant/components/tailwind/diagnostics.py +++ b/homeassistant/components/tailwind/diagnostics.py @@ -4,16 +4,13 @@ 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 +from .typing import TailwindConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TailwindConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return coordinator.data.to_dict() + return entry.runtime_data.data.to_dict() diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index da115ab5603..2cc5f04fd16 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["gotailwind==0.2.2"], + "requirements": ["gotailwind==0.2.3"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index 63c01cf7e73..0ff1f444280 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -9,15 +9,14 @@ 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 +from .typing import TailwindConfigEntry @dataclass(frozen=True, kw_only=True) @@ -47,14 +46,13 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TailwindConfigEntry, 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, + entry.runtime_data, description, ) for description in DESCRIPTIONS diff --git a/homeassistant/components/tailwind/typing.py b/homeassistant/components/tailwind/typing.py new file mode 100644 index 00000000000..514a94a8e78 --- /dev/null +++ b/homeassistant/components/tailwind/typing.py @@ -0,0 +1,7 @@ +"""Typings for the Tailwind integration.""" + +from homeassistant.config_entries import ConfigEntry + +from .coordinator import TailwindDataUpdateCoordinator + +type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator] diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py index 2755157214e..8c597409c77 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from .const import API, CONF_REFRESH_TOKEN, COORDINATOR, DOMAIN -from .coordinator import Tami4EdgeWaterQualityCoordinator +from .coordinator import Tami4EdgeCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except exceptions.TokenRefreshFailedException as ex: raise ConfigEntryNotReady("Error connecting to API") from ex - coordinator = Tami4EdgeWaterQualityCoordinator(hass, api) + coordinator = Tami4EdgeCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py index 3f70d0a99ca..8c1edbfb60f 100644 --- a/homeassistant/components/tami4/config_flow.py +++ b/homeassistant/components/tami4/config_flow.py @@ -50,7 +50,7 @@ class Tami4ConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_phone" except exceptions.Tami4EdgeAPIException: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -78,12 +78,13 @@ class Tami4ConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except exceptions.Tami4EdgeAPIException: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: return self.async_create_entry( - title=api.device.name, data={CONF_REFRESH_TOKEN: refresh_token} + title=api.device_metadata.name, + data={CONF_REFRESH_TOKEN: refresh_token}, ) return self.async_show_form( diff --git a/homeassistant/components/tami4/coordinator.py b/homeassistant/components/tami4/coordinator.py index 78a3723a876..4764562bc34 100644 --- a/homeassistant/components/tami4/coordinator.py +++ b/homeassistant/components/tami4/coordinator.py @@ -17,27 +17,23 @@ _LOGGER = logging.getLogger(__name__) class FlattenedWaterQuality: """Flattened WaterQuality dataclass.""" - uv_last_replacement: date uv_upcoming_replacement: date - uv_status: str - filter_last_replacement: date + uv_installed: bool filter_upcoming_replacement: date - filter_status: str + filter_installed: bool filter_litters_passed: float def __init__(self, water_quality: WaterQuality) -> None: - """Flatten WaterQuality dataclass.""" + """Flattened WaterQuality dataclass.""" - self.uv_last_replacement = water_quality.uv.last_replacement self.uv_upcoming_replacement = water_quality.uv.upcoming_replacement - self.uv_status = water_quality.uv.status - self.filter_last_replacement = water_quality.filter.last_replacement + self.uv_installed = water_quality.uv.installed self.filter_upcoming_replacement = water_quality.filter.upcoming_replacement - self.filter_status = water_quality.filter.status + self.filter_installed = water_quality.filter.installed self.filter_litters_passed = water_quality.filter.milli_litters_passed / 1000 -class Tami4EdgeWaterQualityCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): +class Tami4EdgeCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): """Tami4Edge water quality coordinator.""" def __init__(self, hass: HomeAssistant, api: Tami4EdgeAPI) -> None: @@ -53,10 +49,8 @@ class Tami4EdgeWaterQualityCoordinator(DataUpdateCoordinator[FlattenedWaterQuali async def _async_update_data(self) -> FlattenedWaterQuality: """Fetch data from the API endpoint.""" try: - water_quality = await self.hass.async_add_executor_job( - self._api.get_water_quality - ) + device = await self.hass.async_add_executor_job(self._api.get_device) - return FlattenedWaterQuality(water_quality) + return FlattenedWaterQuality(device.water_quality) except exceptions.APIRequestFailedException as ex: raise UpdateFailed("Error communicating with API") from ex diff --git a/homeassistant/components/tami4/entity.py b/homeassistant/components/tami4/entity.py index d84cd82f39a..b99ca21663d 100644 --- a/homeassistant/components/tami4/entity.py +++ b/homeassistant/components/tami4/entity.py @@ -21,14 +21,14 @@ class Tami4EdgeBaseEntity(Entity): """Initialize the Tami4Edge.""" self._state = None self._api = api - device_id = api.device.psn + device_id = api.device_metadata.psn self.entity_description = entity_description self._attr_unique_id = f"{device_id}_{self.entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, manufacturer="Stratuss", - name=api.device.name, + name=api.device_metadata.name, model="Tami4", - sw_version=api.device.device_firmware, + sw_version=api.device_metadata.device_firmware, suggested_area="Kitchen", ) diff --git a/homeassistant/components/tami4/manifest.json b/homeassistant/components/tami4/manifest.json index 49cbf6fe1c6..e09970c341d 100644 --- a/homeassistant/components/tami4/manifest.json +++ b/homeassistant/components/tami4/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tami4", "iot_class": "cloud_polling", - "requirements": ["Tami4EdgeAPI==2.1"] + "requirements": ["Tami4EdgeAPI==3.0"] } diff --git a/homeassistant/components/tami4/sensor.py b/homeassistant/components/tami4/sensor.py index 3772ef0bccb..888acda9372 100644 --- a/homeassistant/components/tami4/sensor.py +++ b/homeassistant/components/tami4/sensor.py @@ -17,30 +17,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import API, COORDINATOR, DOMAIN -from .coordinator import Tami4EdgeWaterQualityCoordinator +from .coordinator import Tami4EdgeCoordinator from .entity import Tami4EdgeBaseEntity _LOGGER = logging.getLogger(__name__) ENTITY_DESCRIPTIONS = [ - SensorEntityDescription( - key="uv_last_replacement", - translation_key="uv_last_replacement", - device_class=SensorDeviceClass.DATE, - ), SensorEntityDescription( key="uv_upcoming_replacement", translation_key="uv_upcoming_replacement", device_class=SensorDeviceClass.DATE, ), SensorEntityDescription( - key="uv_status", - translation_key="uv_status", - ), - SensorEntityDescription( - key="filter_last_replacement", - translation_key="filter_last_replacement", - device_class=SensorDeviceClass.DATE, + key="uv_installed", + translation_key="uv_installed", ), SensorEntityDescription( key="filter_upcoming_replacement", @@ -48,8 +38,8 @@ ENTITY_DESCRIPTIONS = [ device_class=SensorDeviceClass.DATE, ), SensorEntityDescription( - key="filter_status", - translation_key="filter_status", + key="filter_installed", + translation_key="filter_installed", ), SensorEntityDescription( key="filter_litters_passed", @@ -67,7 +57,7 @@ async def async_setup_entry( """Perform the setup for Tami4Edge.""" data = hass.data[DOMAIN][entry.entry_id] api: Tami4EdgeAPI = data[API] - coordinator: Tami4EdgeWaterQualityCoordinator = data[COORDINATOR] + coordinator: Tami4EdgeCoordinator = data[COORDINATOR] async_add_entities( Tami4EdgeSensorEntity( @@ -81,14 +71,14 @@ async def async_setup_entry( class Tami4EdgeSensorEntity( Tami4EdgeBaseEntity, - CoordinatorEntity[Tami4EdgeWaterQualityCoordinator], + CoordinatorEntity[Tami4EdgeCoordinator], SensorEntity, ): """Representation of the entity.""" def __init__( self, - coordinator: Tami4EdgeWaterQualityCoordinator, + coordinator: Tami4EdgeCoordinator, api: Tami4EdgeAPI, entity_description: SensorEntityDescription, ) -> None: diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json index 79447d93e9e..406964a3bff 100644 --- a/homeassistant/components/tami4/strings.json +++ b/homeassistant/components/tami4/strings.json @@ -7,8 +7,8 @@ "uv_upcoming_replacement": { "name": "UV upcoming replacement" }, - "uv_status": { - "name": "UV status" + "uv_installed": { + "name": "UV installed" }, "filter_last_replacement": { "name": "Filter last replacement" @@ -16,8 +16,8 @@ "filter_upcoming_replacement": { "name": "Filter upcoming replacement" }, - "filter_status": { - "name": "Filter status" + "filter_installed": { + "name": "Filter installed" }, "filter_litters_passed": { "name": "Filter water passed" diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 4ce9fce7935..17e94f62fe9 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -28,7 +28,7 @@ from .const import CONF_FUEL_TYPES, CONF_STATIONS _LOGGER = logging.getLogger(__name__) -TankerkoenigConfigEntry = ConfigEntry["TankerkoenigDataUpdateCoordinator"] +type TankerkoenigConfigEntry = ConfigEntry[TankerkoenigDataUpdateCoordinator] class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInfo]]): diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index 271cfba9b79..f1acfa644bf 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -16,7 +16,7 @@ from hatasmota.models import TasmotaDeviceConfig from hatasmota.mqtt import TasmotaMQTTClient from homeassistant.components import mqtt -from homeassistant.components.mqtt.subscription import ( +from homeassistant.components.mqtt import ( async_prepare_subscribe_topics, async_subscribe_topics, async_unsubscribe_topics, @@ -24,11 +24,7 @@ from homeassistant.components.mqtt.subscription import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceRegistry, - async_entries_for_config_entry, -) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceRegistry from . import device_automation, discovery from .const import ( @@ -105,7 +101,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # detach device triggers device_registry = dr.async_get(hass) - devices = async_entries_for_config_entry(device_registry, entry.entry_id) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for device in devices: await device_automation.async_remove_automations(hass, device.id) diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index 5d70330dbdf..92fcbcc7fc4 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -45,7 +45,7 @@ TASMOTA_DISCOVERY_INSTANCE = "tasmota_discovery_instance" MQTT_TOPIC_URL = "https://tasmota.github.io/docs/Home-Assistant/#tasmota-integration" -SetupDeviceCallback = Callable[[TasmotaDeviceConfig, str], Awaitable[None]] +type SetupDeviceCallback = Callable[[TasmotaDeviceConfig, str], Awaitable[None]] def clear_discovery_hash( diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index b7fcf48cfdb..7d3efa4f283 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -16,9 +16,10 @@ from .const import DEFAULT_NAME, DOMAIN from .coordinator import TautulliDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +type TautulliConfigEntry = ConfigEntry[TautulliDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TautulliConfigEntry) -> bool: """Set up Tautulli from a config entry.""" host_configuration = PyTautulliHostConfiguration( api_token=entry.data[CONF_API_KEY], @@ -29,19 +30,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) - coordinator = TautulliDataUpdateCoordinator(hass, host_configuration, api_client) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = TautulliDataUpdateCoordinator( + hass, host_configuration, api_client + ) + await entry.runtime_data.async_config_entry_first_refresh() 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, entry: TautulliConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]): diff --git a/homeassistant/components/tautulli/coordinator.py b/homeassistant/components/tautulli/coordinator.py index be7dfce4e3a..f392ab8df03 100644 --- a/homeassistant/components/tautulli/coordinator.py +++ b/homeassistant/components/tautulli/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta +from typing import TYPE_CHECKING from pytautulli import ( PyTautulli, @@ -17,18 +18,20 @@ from pytautulli.exceptions import ( ) from pytautulli.models.host_configuration import PyTautulliHostConfiguration -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER +if TYPE_CHECKING: + from . import TautulliConfigEntry + class TautulliDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for the Tautulli integration.""" - config_entry: ConfigEntry + config_entry: TautulliConfigEntry def __init__( self, diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index f0d274bbe12..26b7c602de8 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -19,14 +19,14 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import TautulliEntity +from . import TautulliConfigEntry, TautulliEntity from .const import ATTR_TOP_USER, DOMAIN from .coordinator import TautulliDataUpdateCoordinator @@ -210,26 +210,28 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TautulliConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Tautulli sensor.""" - coordinator: TautulliDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities: list[TautulliSensor | TautulliSessionSensor] = [ TautulliSensor( - coordinator, + data, description, ) for description in SENSOR_TYPES ] - if coordinator.users: + if data.users: entities.extend( TautulliSessionSensor( - coordinator, + data, description, user, ) for description in SESSION_SENSOR_TYPES - for user in coordinator.users + for user in data.users if user.username != "Local" ) async_add_entities(entities) diff --git a/homeassistant/components/technove/helpers.py b/homeassistant/components/technove/helpers.py index 4d8bda38a25..a4aebf5f1fe 100644 --- a/homeassistant/components/technove/helpers.py +++ b/homeassistant/components/technove/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from technove import TechnoVEConnectionError, TechnoVEError @@ -11,11 +11,8 @@ from homeassistant.exceptions import HomeAssistantError from .entity import TechnoVEEntity -_TechnoVEEntityT = TypeVar("_TechnoVEEntityT", bound=TechnoVEEntity) -_P = ParamSpec("_P") - -def technove_exception_handler( +def technove_exception_handler[_TechnoVEEntityT: TechnoVEEntity, **_P]( func: Callable[Concatenate[_TechnoVEEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_TechnoVEEntityT, _P], Coroutine[Any, Any, None]]: """Decorate TechnoVE calls to handle TechnoVE exceptions. diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index 9468008ae8a..a1b87cf13a4 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -1,13 +1,28 @@ """Init the tedee component.""" +from collections.abc import Awaitable, Callable +from http import HTTPStatus import logging +from typing import Any +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response +from pytedee_async.exception import TedeeDataUpdateException, TedeeWebhookException + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import ( + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, + async_register as webhook_register, + async_unregister as webhook_unregister, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.network import get_url -from .const import DOMAIN +from .const import DOMAIN, NAME from .coordinator import TedeeApiCoordinator PLATFORMS = [ @@ -18,8 +33,10 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) +type TedeeConfigEntry = ConfigEntry[TedeeApiCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: TedeeConfigEntry) -> bool: """Integration setup.""" coordinator = TedeeApiCoordinator(hass) @@ -36,8 +53,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: serial_number=coordinator.bridge.serial, ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator + async def unregister_webhook(_: Any) -> None: + await coordinator.async_unregister_webhook() + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + async def register_webhook() -> None: + instance_url = get_url(hass, allow_ip=True, allow_external=False) + # first make sure we don't have leftover callbacks to the same instance + try: + await coordinator.tedee_client.cleanup_webhooks_by_host(instance_url) + except (TedeeDataUpdateException, TedeeWebhookException) as ex: + _LOGGER.warning("Failed to cleanup Tedee webhooks by host: %s", ex) + + webhook_url = webhook_generate_url( + hass, entry.data[CONF_WEBHOOK_ID], allow_external=False, allow_ip=True + ) + webhook_name = "Tedee" + if entry.title != NAME: + webhook_name = f"{NAME} {entry.title}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(coordinator), + allowed_methods=[METH_POST], + ) + _LOGGER.debug("Registered Tedee webhook at hass: %s", webhook_url) + + try: + await coordinator.async_register_webhook(webhook_url) + except TedeeWebhookException: + _LOGGER.exception("Failed to register Tedee webhook from bridge") + else: + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + entry.async_create_background_task( + hass, register_webhook(), "tedee_register_webhook" + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -45,10 +103,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) +def get_webhook_handler( + coordinator: TedeeApiCoordinator, +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" - return unload_ok + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http post calls to the path. + if not request.body_exists: + return HomeAssistantView.json( + result="No Body", status_code=HTTPStatus.BAD_REQUEST + ) + + body = await request.json() + try: + coordinator.webhook_received(body) + except TedeeWebhookException as ex: + return HomeAssistantView.json( + result=str(ex), status_code=HTTPStatus.BAD_REQUEST + ) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + version = config_entry.version + minor_version = config_entry.minor_version + + if version == 1 and minor_version == 1: + _LOGGER.debug( + "Migrating Tedee config entry from version %s.%s", version, minor_version + ) + data = {**config_entry.data, CONF_WEBHOOK_ID: webhook_generate_id()} + hass.config_entries.async_update_entry(config_entry, data=data, minor_version=2) + _LOGGER.debug("Migration to version 1.2 successful") + return True diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 645e25d4e85..98c70f32450 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -11,12 +11,11 @@ from homeassistant.components.binary_sensor import ( 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 . import TedeeConfigEntry from .entity import TedeeDescriptionEntity @@ -53,11 +52,11 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TedeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tedee sensor entity.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TedeeBinarySensorEntity(lock, coordinator, entity_description) diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 8465b332539..dacaea57176 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -13,8 +13,9 @@ from pytedee_async import ( ) import voluptuous as vol +from homeassistant.components.webhook import async_generate_id as webhook_generate_id from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN, NAME @@ -25,6 +26,9 @@ _LOGGER = logging.getLogger(__name__) class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Tedee.""" + VERSION = 1 + MINOR_VERSION = 2 + reauth_entry: ConfigEntry | None = None async def async_step_user( @@ -65,7 +69,10 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") await self.async_set_unique_id(local_bridge.serial) self._abort_if_unique_id_configured() - return self.async_create_entry(title=NAME, data=user_input) + return self.async_create_entry( + title=NAME, + data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()}, + ) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index 069a7893974..51dc6a57d90 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time +from typing import Any from pytedee_async import ( TedeeClient, @@ -11,6 +12,7 @@ from pytedee_async import ( TedeeDataUpdateException, TedeeLocalAuthException, TedeeLock, + TedeeWebhookException, ) from pytedee_async.bridge import TedeeBridge @@ -24,7 +26,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -SCAN_INTERVAL = timedelta(seconds=20) +SCAN_INTERVAL = timedelta(seconds=30) GET_LOCKS_INTERVAL_SECONDS = 3600 _LOGGER = logging.getLogger(__name__) @@ -54,6 +56,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() self.new_lock_callbacks: list[Callable[[int], None]] = [] + self.tedee_webhook_id: int | None = None @property def bridge(self) -> TedeeBridge: @@ -100,9 +103,28 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): except TedeeDataUpdateException as ex: _LOGGER.debug("Error while updating data: %s", str(ex)) - raise UpdateFailed(f"Error while updating data: {str(ex)}") from ex + raise UpdateFailed(f"Error while updating data: {ex!s}") from ex except (TedeeClientException, TimeoutError) as ex: - raise UpdateFailed(f"Querying API failed. Error: {str(ex)}") from ex + raise UpdateFailed(f"Querying API failed. Error: {ex!s}") from ex + + def webhook_received(self, message: dict[str, Any]) -> None: + """Handle webhook message.""" + self.tedee_client.parse_webhook_message(message) + self.async_set_updated_data(self.tedee_client.locks_dict) + + async def async_register_webhook(self, webhook_url: str) -> None: + """Register the webhook at the Tedee bridge.""" + self.tedee_webhook_id = await self.tedee_client.register_webhook(webhook_url) + + async def async_unregister_webhook(self) -> None: + """Unregister the webhook at the Tedee bridge.""" + if self.tedee_webhook_id is not None: + try: + await self.tedee_client.delete_webhook(self.tedee_webhook_id) + except TedeeWebhookException: + _LOGGER.exception("Failed to unregister Tedee webhook from bridge") + else: + _LOGGER.debug("Unregistered Tedee webhook") def _async_add_remove_locks(self) -> None: """Add new locks, remove non-existing locks.""" diff --git a/homeassistant/components/tedee/diagnostics.py b/homeassistant/components/tedee/diagnostics.py index b4fb1d279fa..633934db94d 100644 --- a/homeassistant/components/tedee/diagnostics.py +++ b/homeassistant/components/tedee/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import TedeeApiCoordinator +from . import TedeeConfigEntry TO_REDACT = { "lock_id", @@ -17,10 +15,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TedeeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: TedeeApiCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data # dict has sensitive info as key, redact manually data = { index: lock.to_dict() diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 1c47ff2a6c1..d11c873a94a 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -5,23 +5,22 @@ from typing import Any from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState from homeassistant.components.lock import LockEntity, LockEntityFeature -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 . import TedeeConfigEntry from .coordinator import TedeeApiCoordinator from .entity import TedeeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TedeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tedee lock entity.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[TedeeLockEntity] = [] for lock in coordinator.data.values(): @@ -65,6 +64,16 @@ class TedeeLockEntity(TedeeEntity, LockEntity): """Return true if lock is unlocking.""" return self._lock.state == TedeeLockState.UNLOCKING + @property + def is_open(self) -> bool: + """Return true if lock is open.""" + return self._lock.state == TedeeLockState.PULLED + + @property + def is_opening(self) -> bool: + """Return true if lock is opening.""" + return self._lock.state == TedeeLockState.PULLING + @property def is_locking(self) -> bool: """Return true if lock is locking.""" diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index db3a88f3113..24df4cff95c 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -3,9 +3,10 @@ "name": "Tedee", "codeowners": ["@patrickhilker", "@zweckj"], "config_flow": true, - "dependencies": ["http"], + "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "loggers": ["pytedee_async"], + "quality_scale": "platinum", "requirements": ["pytedee-async==0.2.17"] } diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index cd01e9d04be..c7d14af1f31 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -11,12 +11,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import TedeeConfigEntry from .entity import TedeeDescriptionEntity @@ -50,11 +49,11 @@ ENTITIES: tuple[TedeeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TedeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tedee sensor entity.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TedeeSensorEntity(lock, coordinator, entity_description) diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py index e543715d37c..16952868525 100644 --- a/homeassistant/components/telegram/notify.py +++ b/homeassistant/components/telegram/notify.py @@ -18,6 +18,7 @@ from homeassistant.components.telegram_bot import ( ATTR_DISABLE_NOTIF, ATTR_DISABLE_WEB_PREV, ATTR_MESSAGE_TAG, + ATTR_MESSAGE_THREAD_ID, ATTR_PARSER, ) from homeassistant.const import ATTR_LOCATION @@ -91,6 +92,11 @@ class TelegramNotificationService(BaseNotificationService): disable_web_page_preview = data[ATTR_DISABLE_WEB_PREV] service_data.update({ATTR_DISABLE_WEB_PREV: disable_web_page_preview}) + # Set message_thread_id + if data is not None and ATTR_MESSAGE_THREAD_ID in data: + message_thread_id = data[ATTR_MESSAGE_THREAD_ID] + service_data.update({ATTR_MESSAGE_THREAD_ID: message_thread_id}) + # Get keyboard info if data is not None and ATTR_KEYBOARD in data: keys = data.get(ATTR_KEYBOARD) @@ -108,21 +114,21 @@ class TelegramNotificationService(BaseNotificationService): for photo_data in photos: service_data.update(photo_data) self.hass.services.call(DOMAIN, "send_photo", service_data=service_data) - return + return None if data is not None and ATTR_VIDEO in data: videos = data.get(ATTR_VIDEO) videos = videos if isinstance(videos, list) else [videos] for video_data in videos: service_data.update(video_data) self.hass.services.call(DOMAIN, "send_video", service_data=service_data) - return + return None if data is not None and ATTR_VOICE in data: voices = data.get(ATTR_VOICE) voices = voices if isinstance(voices, list) else [voices] for voice_data in voices: service_data.update(voice_data) self.hass.services.call(DOMAIN, "send_voice", service_data=service_data) - return + return None if data is not None and ATTR_LOCATION in data: service_data.update(data.get(ATTR_LOCATION)) return self.hass.services.call( diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f672ae1547f..f37a84a83a6 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -36,7 +36,7 @@ from homeassistant.const import ( HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType @@ -90,6 +90,7 @@ ATTR_ANSWERS = "answers" ATTR_OPEN_PERIOD = "open_period" ATTR_IS_ANONYMOUS = "is_anonymous" ATTR_ALLOWS_MULTIPLE_ANSWERS = "allows_multiple_answers" +ATTR_MESSAGE_THREAD_ID = "message_thread_id" CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_PROXY_URL = "proxy_url" @@ -284,6 +285,14 @@ SERVICE_MAP = { } +def _read_file_as_bytesio(file_path: str) -> io.BytesIO: + """Read a file and return it as a BytesIO object.""" + with open(file_path, "rb") as file: + data = io.BytesIO(file.read()) + data.name = file_path + return data + + async def load_data( hass, url=None, @@ -342,7 +351,9 @@ async def load_data( ) elif filepath is not None: if hass.config.is_allowed_path(filepath): - return open(filepath, "rb") + return await hass.async_add_executor_job( + _read_file_as_bytesio, filepath + ) _LOGGER.warning("'%s' are not secure to load data from!", filepath) else: @@ -379,7 +390,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error("Failed to initialize Telegram bot %s", p_type) return False - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error setting up platform %s", p_type) return False @@ -426,7 +437,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) if msgtype == SERVICE_SEND_MESSAGE: - await notify_service.send_message(**kwargs) + await notify_service.send_message(context=service.context, **kwargs) elif msgtype in [ SERVICE_SEND_PHOTO, SERVICE_SEND_ANIMATION, @@ -434,19 +445,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_SEND_VOICE, SERVICE_SEND_DOCUMENT, ]: - await notify_service.send_file(msgtype, **kwargs) + await notify_service.send_file(msgtype, context=service.context, **kwargs) elif msgtype == SERVICE_SEND_STICKER: - await notify_service.send_sticker(**kwargs) + await notify_service.send_sticker(context=service.context, **kwargs) elif msgtype == SERVICE_SEND_LOCATION: - await notify_service.send_location(**kwargs) + await notify_service.send_location(context=service.context, **kwargs) elif msgtype == SERVICE_SEND_POLL: - await notify_service.send_poll(**kwargs) + await notify_service.send_poll(context=service.context, **kwargs) elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: - await notify_service.answer_callback_query(**kwargs) + await notify_service.answer_callback_query( + context=service.context, **kwargs + ) elif msgtype == SERVICE_DELETE_MESSAGE: - await notify_service.delete_message(**kwargs) + await notify_service.delete_message(context=service.context, **kwargs) else: - await notify_service.edit_message(msgtype, **kwargs) + await notify_service.edit_message( + msgtype, context=service.context, **kwargs + ) # Register notification services for service_notif, schema in SERVICE_MAP.items(): @@ -625,6 +640,7 @@ class TelegramNotificationService: ATTR_REPLYMARKUP: None, ATTR_TIMEOUT: None, ATTR_MESSAGE_TAG: None, + ATTR_MESSAGE_THREAD_ID: None, } if data is not None: if ATTR_PARSER in data: @@ -641,6 +657,8 @@ class TelegramNotificationService: params[ATTR_REPLY_TO_MSGID] = data[ATTR_REPLY_TO_MSGID] if ATTR_MESSAGE_TAG in data: params[ATTR_MESSAGE_TAG] = data[ATTR_MESSAGE_TAG] + if ATTR_MESSAGE_THREAD_ID in data: + params[ATTR_MESSAGE_THREAD_ID] = data[ATTR_MESSAGE_THREAD_ID] # Keyboards: if ATTR_KEYBOARD in data: keys = data.get(ATTR_KEYBOARD) @@ -663,7 +681,7 @@ class TelegramNotificationService: return params async def _send_msg( - self, func_send, msg_error, message_tag, *args_msg, **kwargs_msg + self, func_send, msg_error, message_tag, *args_msg, context=None, **kwargs_msg ): """Send one message.""" try: @@ -684,7 +702,13 @@ class TelegramNotificationService: } if message_tag is not None: event_data[ATTR_MESSAGE_TAG] = message_tag - self.hass.bus.async_fire(EVENT_TELEGRAM_SENT, event_data) + if kwargs_msg[ATTR_MESSAGE_THREAD_ID] is not None: + event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[ + ATTR_MESSAGE_THREAD_ID + ] + self.hass.bus.async_fire( + EVENT_TELEGRAM_SENT, event_data, context=context + ) elif not isinstance(out, bool): _LOGGER.warning( "Update last message: out_type:%s, out=%s", type(out), out @@ -696,7 +720,7 @@ class TelegramNotificationService: return None return out - async def send_message(self, message="", target=None, **kwargs): + async def send_message(self, message="", target=None, context=None, **kwargs): """Send a message to one or multiple pre-allowed chat IDs.""" title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message @@ -715,15 +739,22 @@ class TelegramNotificationService: reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) - async def delete_message(self, chat_id=None, **kwargs): + async def delete_message(self, chat_id=None, context=None, **kwargs): """Delete a previously sent message.""" chat_id = self._get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) deleted = await self._send_msg( - self.bot.delete_message, "Error deleting message", None, chat_id, message_id + self.bot.delete_message, + "Error deleting message", + None, + chat_id, + message_id, + context=context, ) # reduce message_id anyway: if self._last_message_id[chat_id] is not None: @@ -731,7 +762,7 @@ class TelegramNotificationService: self._last_message_id[chat_id] -= 1 return deleted - async def edit_message(self, type_edit, chat_id=None, **kwargs): + async def edit_message(self, type_edit, chat_id=None, context=None, **kwargs): """Edit a previously sent message.""" chat_id = self._get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) @@ -759,6 +790,7 @@ class TelegramNotificationService: disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV], reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], + context=context, ) if type_edit == SERVICE_EDIT_CAPTION: return await self._send_msg( @@ -772,6 +804,7 @@ class TelegramNotificationService: reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], + context=context, ) return await self._send_msg( @@ -783,10 +816,11 @@ class TelegramNotificationService: inline_message_id=inline_message_id, reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], + context=context, ) async def answer_callback_query( - self, message, callback_query_id, show_alert=False, **kwargs + self, message, callback_query_id, show_alert=False, context=None, **kwargs ): """Answer a callback originated with a press in an inline keyboard.""" params = self._get_msg_kwargs(kwargs) @@ -804,9 +838,12 @@ class TelegramNotificationService: text=message, show_alert=show_alert, read_timeout=params[ATTR_TIMEOUT], + context=context, ) - async def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs): + async def send_file( + self, file_type=SERVICE_SEND_PHOTO, target=None, context=None, **kwargs + ): """Send a photo, sticker, video, or document.""" params = self._get_msg_kwargs(kwargs) file_content = await load_data( @@ -836,6 +873,8 @@ class TelegramNotificationService: reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) elif file_type == SERVICE_SEND_STICKER: @@ -849,6 +888,8 @@ class TelegramNotificationService: reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) elif file_type == SERVICE_SEND_VIDEO: @@ -864,6 +905,8 @@ class TelegramNotificationService: reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) elif file_type == SERVICE_SEND_DOCUMENT: await self._send_msg( @@ -878,6 +921,8 @@ class TelegramNotificationService: reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) elif file_type == SERVICE_SEND_VOICE: await self._send_msg( @@ -891,6 +936,8 @@ class TelegramNotificationService: reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) elif file_type == SERVICE_SEND_ANIMATION: await self._send_msg( @@ -905,13 +952,15 @@ class TelegramNotificationService: reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], parse_mode=params[ATTR_PARSER], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) file_content.seek(0) else: _LOGGER.error("Can't send file with kwargs: %s", kwargs) - async def send_sticker(self, target=None, **kwargs): + async def send_sticker(self, target=None, context=None, **kwargs): """Send a sticker from a telegram sticker pack.""" params = self._get_msg_kwargs(kwargs) stickerid = kwargs.get(ATTR_STICKER_ID) @@ -927,11 +976,15 @@ class TelegramNotificationService: reply_to_message_id=params[ATTR_REPLY_TO_MSGID], reply_markup=params[ATTR_REPLYMARKUP], read_timeout=params[ATTR_TIMEOUT], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) else: await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) - async def send_location(self, latitude, longitude, target=None, **kwargs): + async def send_location( + self, latitude, longitude, target=None, context=None, **kwargs + ): """Send a location.""" latitude = float(latitude) longitude = float(longitude) @@ -950,6 +1003,8 @@ class TelegramNotificationService: disable_notification=params[ATTR_DISABLE_NOTIF], reply_to_message_id=params[ATTR_REPLY_TO_MSGID], read_timeout=params[ATTR_TIMEOUT], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) async def send_poll( @@ -959,6 +1014,7 @@ class TelegramNotificationService: is_anonymous, allows_multiple_answers, target=None, + context=None, **kwargs, ): """Send a poll.""" @@ -979,14 +1035,16 @@ class TelegramNotificationService: disable_notification=params[ATTR_DISABLE_NOTIF], reply_to_message_id=params[ATTR_REPLY_TO_MSGID], read_timeout=params[ATTR_TIMEOUT], + message_thread_id=params[ATTR_MESSAGE_THREAD_ID], + context=context, ) - async def leave_chat(self, chat_id=None): + async def leave_chat(self, chat_id=None, context=None): """Remove bot from chat.""" chat_id = self._get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) return await self._send_msg( - self.bot.leave_chat, "Error leaving chat", None, chat_id + self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context ) @@ -1019,8 +1077,10 @@ class BaseTelegramBotEntity: _LOGGER.warning("Unhandled update: %s", update) return True + event_context = Context() + _LOGGER.debug("Firing event %s: %s", event_type, event_data) - self.hass.bus.async_fire(event_type, event_data) + self.hass.bus.async_fire(event_type, event_data, context=event_context) return True @staticmethod diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index d2195c1d6ce..a09f4d8f79b 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -54,6 +54,10 @@ send_message: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_photo: fields: @@ -126,6 +130,10 @@ send_photo: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_sticker: fields: @@ -190,6 +198,10 @@ send_sticker: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_animation: fields: @@ -262,6 +274,10 @@ send_animation: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_video: fields: @@ -334,6 +350,10 @@ send_video: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_voice: fields: @@ -398,6 +418,10 @@ send_voice: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_document: fields: @@ -470,6 +494,10 @@ send_document: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_location: fields: @@ -520,6 +548,10 @@ send_location: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box send_poll: fields: @@ -564,6 +596,10 @@ send_poll: selector: number: mode: box + message_thread_id: + selector: + number: + mode: box edit_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index aad42081274..1a02543d4ab 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -47,6 +47,10 @@ "reply_to_message_id": { "name": "Reply to message id", "description": "Mark the message as a reply to a previous message." + }, + "message_thread_id": { + "name": "Message thread id", + "description": "Unique identifier for the target message thread (topic) of the forum; for forum supergroups only." } } }, @@ -113,6 +117,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, @@ -175,6 +183,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, @@ -241,6 +253,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, @@ -307,6 +323,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, @@ -369,6 +389,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, @@ -435,6 +459,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, @@ -477,6 +505,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, @@ -523,6 +555,10 @@ "reply_to_message_id": { "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" + }, + "message_thread_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_thread_id::description%]" } } }, diff --git a/homeassistant/components/tellduslive/__init__.py b/homeassistant/components/tellduslive/__init__.py index 92e61edec56..4f88b47b531 100644 --- a/homeassistant/components/tellduslive/__init__.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -180,8 +180,8 @@ class TelldusLiveClient: ) async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: if component not in self._hass.data[CONFIG_ENTRY_IS_SETUP]: - await self._hass.config_entries.async_forward_entry_setup( - self._config_entry, component + await self._hass.config_entries.async_forward_entry_setups( + self._config_entry, [component] ) self._hass.data[CONFIG_ENTRY_IS_SETUP].add(component) device_ids = [] diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 4537abcdece..6f1318ca61e 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -97,7 +97,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="unknown_authorize_url_generation") except TimeoutError: return self.async_abort(reason="authorize_url_timeout") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error generating auth url") return self.async_abort(reason="unknown_authorize_url_generation") diff --git a/homeassistant/components/tellduslive/const.py b/homeassistant/components/tellduslive/const.py index 3a24f6b033a..eee36879ba9 100644 --- a/homeassistant/components/tellduslive/const.py +++ b/homeassistant/components/tellduslive/const.py @@ -24,7 +24,6 @@ SCAN_INTERVAL = timedelta(minutes=1) ATTR_LAST_UPDATED = "time_last_updated" -SIGNAL_UPDATE_ENTITY = "tellduslive_update" TELLDUS_DISCOVERY_NEW = "telldus_new_{}_{}" CLOUD_NAME = "Cloud API" diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index 57c6ae9e7eb..de962041333 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -46,14 +46,14 @@ class TelldusLiveCover(TelldusLiveEntity, CoverEntity): def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self.device.down() - self._update_callback() + self.schedule_update_ha_state() def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.device.up() - self._update_callback() + self.schedule_update_ha_state() def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self.device.stop() - self._update_callback() + self.schedule_update_ha_state() diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py index 77a04fabd06..a71fcb685c0 100644 --- a/homeassistant/components/tellduslive/entry.py +++ b/homeassistant/components/tellduslive/entry.py @@ -11,7 +11,6 @@ from homeassistant.const import ( ATTR_MODEL, ATTR_VIA_DEVICE, ) -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 @@ -33,25 +32,16 @@ class TelldusLiveEntity(Entity): """Initialize the entity.""" self._id = device_id self._client = client - self._async_unsub_dispatcher_connect = None async def async_added_to_hass(self): """Call when entity is added to hass.""" _LOGGER.debug("Created device %s", self) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self.async_write_ha_state + ) ) - async def async_will_remove_from_hass(self): - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() - - @callback - def _update_callback(self): - """Return the property of the device might have changed.""" - self.async_write_ha_state() - @property def device_id(self): """Return the id of the device.""" diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 63af8a32527..101ccb0dab0 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -50,7 +50,7 @@ class TelldusLiveLight(TelldusLiveEntity, LightEntity): def changed(self): """Define a property of the device that might have changed.""" self._last_brightness = self.brightness - self._update_callback() + self.schedule_update_ha_state() @property def brightness(self): diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json index 7db4026f09a..929d502971f 100644 --- a/homeassistant/components/tellduslive/manifest.json +++ b/homeassistant/components/tellduslive/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tellduslive", "iot_class": "cloud_polling", - "quality_scale": "gold", + "quality_scale": "silver", "requirements": ["tellduslive==0.10.11"] } diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index c26a8dcf951..cd28a170442 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -45,9 +45,9 @@ class TelldusLiveSwitch(TelldusLiveEntity, SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self.device.turn_on() - self._update_callback() + self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.device.turn_off() - self._update_callback() + self.schedule_update_ha_state() diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index f881e61fb76..efa99342699 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -7,10 +7,13 @@ import logging from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIQUE_ID, SERVICE_RELOAD +from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID, SERVICE_RELOAD from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery +from homeassistant.helpers.device import ( + async_remove_stale_devices_links_keep_current_device, +) from homeassistant.helpers.reload import async_reload_integration_platforms from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -57,6 +60,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" + + async_remove_stale_devices_links_keep_current_device( + hass, + entry.entry_id, + entry.options.get(CONF_DEVICE_ID), + ) + await hass.config_entries.async_forward_entry_setups( entry, (entry.options["template_type"],) ) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 654dad94867..0fa588a78f1 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -22,6 +22,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, + CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, @@ -39,8 +40,9 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.helpers import selector, template import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later, async_track_point_in_utc_time @@ -86,6 +88,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Required(CONF_STATE): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) @@ -244,6 +247,10 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): self._delay_on_raw = config.get(CONF_DELAY_ON) self._delay_off = None self._delay_off_raw = config.get(CONF_DELAY_OFF) + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) async def async_added_to_hass(self) -> None: """Restore state.""" @@ -483,7 +490,7 @@ class AutoOffExtraStoredData(ExtraStoredData): def as_dict(self) -> dict[str, Any]: """Return a dict representation of additional data.""" - auto_off_time: datetime | None | dict[str, str] = self.auto_off_time + auto_off_time: datetime | dict[str, str] | None = self.auto_off_time if isinstance(auto_off_time, datetime): auto_off_time = { "__type": str(type(auto_off_time)), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index b1d11243469..5c28a68a8ae 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, @@ -95,6 +96,8 @@ def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: ), } + schema[vol.Optional(CONF_DEVICE_ID)] = selector.DeviceSelector() + return schema @@ -130,7 +133,7 @@ def _validate_unit(options: dict[str, Any]) -> None: and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units ): sorted_units = sorted( - [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], + [f"'{unit!s}'" if unit else "no unit of measurement" for unit in units], key=str.casefold, ) if len(sorted_units) == 1: @@ -153,7 +156,7 @@ def _validate_state_class(options: dict[str, Any]) -> None: and state_class not in state_classes ): sorted_state_classes = sorted( - [f"'{str(state_class)}'" for state_class in state_classes], + [f"'{state_class!s}'" for state_class in state_classes], key=str.casefold, ) if len(sorted_state_classes) == 0: @@ -344,7 +347,7 @@ def ws_start_preview( connection.send_message( { "id": msg["id"], - "type": websocket_api.const.TYPE_RESULT, + "type": websocket_api.TYPE_RESULT, "success": False, "error": {"code": "invalid_user_input", "message": errors}, } diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 3f9df4818fd..8259a6c12f0 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -14,6 +14,7 @@ from homeassistant.components.lock import ( LockEntity, ) from homeassistant.const import ( + ATTR_CODE, CONF_NAME, CONF_OPTIMISTIC, CONF_UNIQUE_ID, @@ -23,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import ServiceValidationError, TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script @@ -36,6 +37,7 @@ from .template_entity import ( rewrite_common_legacy_to_modern_conf, ) +CONF_CODE_FORMAT_TEMPLATE = "code_format_template" CONF_LOCK = "lock" CONF_UNLOCK = "unlock" @@ -48,6 +50,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -90,6 +93,9 @@ class TemplateLock(TemplateEntity, LockEntity): self._state_template = config.get(CONF_VALUE_TEMPLATE) self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) + self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) + self._code_format = None + self._code_format_template_error = None self._optimistic = config.get(CONF_OPTIMISTIC) self._attr_assumed_state = bool(self._optimistic) @@ -115,6 +121,7 @@ class TemplateLock(TemplateEntity, LockEntity): @callback def _update_state(self, result): + """Update the state from the template.""" super()._update_state(result) if isinstance(result, TemplateError): self._state = None @@ -130,24 +137,75 @@ class TemplateLock(TemplateEntity, LockEntity): self._state = None + @property + def code_format(self) -> str | None: + """Regex for code format or None if no code is required.""" + return self._code_format + @callback def _async_setup_templates(self) -> None: """Set up templates.""" self.add_template_attribute( "_state", self._state_template, None, self._update_state ) + if self._code_format_template: + self.add_template_attribute( + "_code_format_template", + self._code_format_template, + None, + self._update_code_format, + ) super()._async_setup_templates() + @callback + def _update_code_format(self, render: str | TemplateError | None): + """Update code format from the template.""" + if isinstance(render, TemplateError): + self._code_format = None + self._code_format_template_error = render + elif render in (None, "None", ""): + self._code_format = None + self._code_format_template_error = None + else: + self._code_format = render + self._code_format_template_error = None + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" + self._raise_template_error_if_available() + if self._optimistic: self._state = True self.async_write_ha_state() - await self.async_run_script(self._command_lock, context=self._context) + + tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} + + await self.async_run_script( + self._command_lock, run_variables=tpl_vars, context=self._context + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" + self._raise_template_error_if_available() + if self._optimistic: self._state = False self.async_write_ha_state() - await self.async_run_script(self._command_unlock, context=self._context) + + tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} + + await self.async_run_script( + self._command_unlock, run_variables=tpl_vars, context=self._context + ) + + def _raise_template_error_if_available(self): + if self._code_format_template_error is not None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="code_format_template_error", + translation_placeholders={ + "entity_id": self.entity_id, + "code_format_template": self._code_format_template.template, + "cause": str(self._code_format_template_error), + }, + ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index a341fdd5f87..6cb73a15632 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -25,6 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, + CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, @@ -40,7 +41,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA @@ -86,6 +88,7 @@ SENSOR_SCHEMA = vol.All( { vol.Required(CONF_STATE): cv.template, vol.Optional(ATTR_LAST_RESET): cv.template, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } ) .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) @@ -257,9 +260,13 @@ class SensorTemplate(TemplateEntity, SensorEntity): self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) self._template: template.Template = config[CONF_STATE] - self._attr_last_reset_template: None | template.Template = config.get( + self._attr_last_reset_template: template.Template | None = config.get( ATTR_LAST_RESET ) + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 6122f4c9db5..4a1377cbf0b 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -3,20 +3,28 @@ "step": { "binary_sensor": { "data": { + "device_id": "[%key:common::config_flow::data::device%]", "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", "name": "[%key:common::config_flow::data::name%]", "state": "[%key:component::template::config::step::sensor::data::state%]" }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, "title": "Template binary sensor" }, "sensor": { "data": { + "device_id": "[%key:common::config_flow::data::device%]", "device_class": "Device class", "name": "[%key:common::config_flow::data::name%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", "state": "State template", "unit_of_measurement": "Unit of measurement" }, + "data_description": { + "device_id": "Select a device to link to this entity." + }, "title": "Template sensor" }, "user": { @@ -33,17 +41,25 @@ "step": { "binary_sensor": { "data": { + "device_id": "[%key:common::config_flow::data::device%]", "state": "[%key:component::template::config::step::sensor::data::state%]" }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, "title": "[%key:component::template::config::step::binary_sensor::title%]" }, "sensor": { "data": { + "device_id": "[%key:common::config_flow::data::device%]", "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", "state": "[%key:component::template::config::step::sensor::data::state%]", "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, "title": "[%key:component::template::config::step::sensor::title%]" } } @@ -153,5 +169,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads template entities from the YAML-configuration." } + }, + "exceptions": { + "code_format_template_error": { + "message": "Error evaluating code format template \"{code_format_template}\" for {entity_id}: {cause}" + } } } diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index a03b0a1ada0..b5d2ab6fff3 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -189,7 +189,7 @@ class _TemplateAttribute: self, event: Event[EventStateChangedData] | None, template: Template, - last_result: str | None | TemplateError, + last_result: str | TemplateError | None, result: str | TemplateError, ) -> None: """Handle a template result event callback.""" @@ -438,7 +438,7 @@ class TemplateEntity(Entity): try: calculated_state = self._async_calculate_state() validate_state(calculated_state.state) - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # noqa: BLE001 self._preview_callback(None, None, None, str(err)) else: assert self._template_result_info @@ -464,8 +464,7 @@ class TemplateEntity(Entity): template_var_tup = TrackTemplate(template, variables) is_availability_template = False for attribute in attributes: - # pylint: disable-next=protected-access - if attribute._attribute == "_attr_available": + if attribute._attribute == "_attr_available": # noqa: SLF001 has_availability_template = True is_availability_template = True attribute.async_setup() @@ -535,7 +534,7 @@ class TemplateEntity(Entity): self._async_setup_templates() try: self._async_template_startup(None, log_template_error) - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # noqa: BLE001 preview_callback(None, None, None, str(err)) return self._call_on_remove_callbacks diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py index 44848cb1dfe..8390b26b182 100644 --- a/homeassistant/components/tesla_wall_connector/config_flow.py +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -104,7 +104,7 @@ class TeslaWallConnectorConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except WallConnectorError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 9cbe14982f2..077f70c5370 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -77,6 +77,24 @@ WALL_CONNECTOR_SENSORS = [ entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), + WallConnectorSensorDescription( + key="pcba_temp_c", + translation_key="pcba_temp_c", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].pcba_temp_c, 1), + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + WallConnectorSensorDescription( + key="mcu_temp_c", + translation_key="mcu_temp_c", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].mcu_temp_c, 1), + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), WallConnectorSensorDescription( key="grid_v", translation_key="grid_v", diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index ed1878caecb..1a03207a012 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -37,7 +37,7 @@ "not_connected": "Vehicle not connected", "connected": "Vehicle connected", "ready": "Ready to charge", - "negociating": "Negociating connection", + "negotiating": "Negotiating connection", "error": "Error", "charging_finished": "Charging finished", "waiting_car": "Waiting for car", @@ -51,6 +51,12 @@ "handle_temp_c": { "name": "Handle temperature" }, + "pcba_temp_c": { + "name": "PCB temperature" + }, + "mcu_temp_c": { + "name": "MCU temperature" + }, "grid_v": { "name": "Grid voltage" }, diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 45fd1eee327..21ea2915884 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -16,25 +16,43 @@ 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 homeassistant.helpers.device_registry import DeviceInfo -from .const import DOMAIN +from .const import DOMAIN, LOGGER, MODELS from .coordinator import ( - TeslemetryEnergyDataCoordinator, + TeslemetryEnergySiteInfoCoordinator, + TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, ) from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData -PLATFORMS: Final = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: Final = [ + 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, +] + +type TeslemetryConfigEntry = ConfigEntry[TeslemetryData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool: """Set up Teslemetry config.""" access_token = entry.data[CONF_ACCESS_TOKEN] + session = async_get_clientsession(hass) # Create API connection teslemetry = Teslemetry( - session=async_get_clientsession(hass), + session=session, access_token=access_token, ) try: @@ -52,51 +70,109 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: energysites: list[TeslemetryEnergyData] = [] for product in products: if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes: + # Remove the protobuff 'cached_data' that we do not use to save memory + product.pop("cached_data", None) vin = product["vin"] api = VehicleSpecific(teslemetry.vehicle, vin) - coordinator = TeslemetryVehicleDataCoordinator(hass, api) + coordinator = TeslemetryVehicleDataCoordinator(hass, api, product) + device = DeviceInfo( + identifiers={(DOMAIN, vin)}, + manufacturer="Tesla", + configuration_url="https://teslemetry.com/console", + name=product["display_name"], + model=MODELS.get(vin[3]), + serial_number=vin, + ) + vehicles.append( TeslemetryVehicleData( api=api, coordinator=coordinator, vin=vin, + device=device, ) ) elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: site_id = product["energy_site_id"] api = EnergySpecific(teslemetry.energy, site_id) + live_coordinator = TeslemetryEnergySiteLiveCoordinator(hass, api) + info_coordinator = TeslemetryEnergySiteInfoCoordinator(hass, api, product) + device = DeviceInfo( + identifiers={(DOMAIN, str(site_id))}, + manufacturer="Tesla", + configuration_url="https://teslemetry.com/console", + name=product.get("site_name", "Energy Site"), + serial_number=str(site_id), + ) + energysites.append( TeslemetryEnergyData( api=api, - coordinator=TeslemetryEnergyDataCoordinator(hass, api), + live_coordinator=live_coordinator, + info_coordinator=info_coordinator, id=site_id, - info=product, + device=device, ) ) - # Do all coordinator first refreshes simultaneously + # Run all first refreshes await asyncio.gather( *( vehicle.coordinator.async_config_entry_first_refresh() for vehicle in vehicles ), *( - energysite.coordinator.async_config_entry_first_refresh() + energysite.live_coordinator.async_config_entry_first_refresh() + for energysite in energysites + ), + *( + energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites ), ) + # Add energy device models + for energysite in energysites: + models = set() + for gateway in energysite.info_coordinator.data.get("components_gateways", []): + if gateway.get("part_name"): + models.add(gateway["part_name"]) + for battery in energysite.info_coordinator.data.get("components_batteries", []): + if battery.get("part_name"): + models.add(battery["part_name"]) + if models: + energysite.device["model"] = ", ".join(sorted(models)) + # Setup Platforms - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = TeslemetryData( - vehicles, energysites, scopes - ) + entry.runtime_data = TeslemetryData(vehicles, energysites, scopes) 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, entry: TeslemetryConfigEntry) -> bool: """Unload Teslemetry Config.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate config entry.""" + if config_entry.version > 1: + return False + + if config_entry.version == 1 and config_entry.minor_version < 2: + # Add unique_id to existing entry + teslemetry = Teslemetry( + session=async_get_clientsession(hass), + access_token=config_entry.data[CONF_ACCESS_TOKEN], + ) + try: + metadata = await teslemetry.metadata() + except TeslaFleetError as e: + LOGGER.error(e.message) + return False + + hass.config_entries.async_update_entry( + config_entry, unique_id=metadata["uid"], version=1, minor_version=2 + ) + return True diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py new file mode 100644 index 00000000000..e3f9a5716f6 --- /dev/null +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -0,0 +1,275 @@ +"""Binary Sensor platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from itertools import chain +from typing import cast + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import TeslemetryConfigEntry +from .const import TeslemetryState +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryEnergyLiveEntity, + TeslemetryVehicleEntity, +) +from .models import TeslemetryEnergyData, TeslemetryVehicleData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Teslemetry binary sensor entity.""" + + is_on: Callable[[StateType], bool] = bool + + +VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( + TeslemetryBinarySensorEntityDescription( + key="state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on=lambda x: x == TeslemetryState.ONLINE, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_state_battery_heater_on", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_state_charger_phases", + is_on=lambda x: cast(int, x) > 1, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_state_preconditioning_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="climate_state_is_preconditioning", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_state_scheduled_charging_pending", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_state_trip_charging", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="charge_state_conn_charge_cable", + is_on=lambda x: x != "", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + TeslemetryBinarySensorEntityDescription( + key="climate_state_cabin_overheat_protection_actively_cooling", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_dashcam_state", + device_class=BinarySensorDeviceClass.RUNNING, + is_on=lambda x: x == "Recording", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_is_user_present", + device_class=BinarySensorDeviceClass.PRESENCE, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_fd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_fp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_rd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_rp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_df", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_dr", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_pf", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TeslemetryBinarySensorEntityDescription( + key="vehicle_state_pr", + device_class=BinarySensorDeviceClass.DOOR, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription(key="backup_capable"), + BinarySensorEntityDescription(key="grid_services_active"), +) + + +ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="components_grid_services_enabled", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry binary sensor platform from a config entry.""" + + async_add_entities( + chain( + ( # Vehicles + TeslemetryVehicleBinarySensorEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( # Energy Site Live + TeslemetryEnergyLiveBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_LIVE_DESCRIPTIONS + if energysite.info_coordinator.data.get("components_battery") + ), + ( # Energy Site Info + TeslemetryEnergyInfoBinarySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if energysite.info_coordinator.data.get("components_battery") + ), + ) + ) + + +class TeslemetryVehicleBinarySensorEntity(TeslemetryVehicleEntity, BinarySensorEntity): + """Base class for Teslemetry vehicle binary sensors.""" + + entity_description: TeslemetryBinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the binary sensor.""" + + if self.coordinator.updated_once: + if self._value is None: + self._attr_available = False + self._attr_is_on = None + else: + self._attr_available = True + self._attr_is_on = self.entity_description.is_on(self._value) + else: + self._attr_is_on = None + + +class TeslemetryEnergyLiveBinarySensorEntity( + TeslemetryEnergyLiveEntity, BinarySensorEntity +): + """Base class for Teslemetry energy live binary sensors.""" + + entity_description: BinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryEnergyData, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the binary sensor.""" + self._attr_is_on = self._value + + +class TeslemetryEnergyInfoBinarySensorEntity( + TeslemetryEnergyInfoEntity, BinarySensorEntity +): + """Base class for Teslemetry energy info binary sensors.""" + + entity_description: BinarySensorEntityDescription + + def __init__( + self, + data: TeslemetryEnergyData, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the binary sensor.""" + self._attr_is_on = self._value diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py new file mode 100644 index 00000000000..a9bf3eddd6a --- /dev/null +++ b/homeassistant/components/teslemetry/button.py @@ -0,0 +1,90 @@ +"""Button platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from tesla_fleet_api.const import Scope + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .entity import TeslemetryVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslemetryVehicleData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryButtonEntityDescription(ButtonEntityDescription): + """Describes a Teslemetry Button entity.""" + + func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None + + +DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( + TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup + TeslemetryButtonEntityDescription( + key="flash_lights", func=lambda self: self.api.flash_lights() + ), + TeslemetryButtonEntityDescription( + key="honk", func=lambda self: self.api.honk_horn() + ), + TeslemetryButtonEntityDescription( + key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive() + ), + TeslemetryButtonEntityDescription( + key="boombox", func=lambda self: self.api.remote_boombox(0) + ), + TeslemetryButtonEntityDescription( + key="homelink", + func=lambda self: self.api.trigger_homelink( + lat=self.coordinator.data["drive_state_latitude"], + lon=self.coordinator.data["drive_state_longitude"], + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry Button platform from a config entry.""" + + async_add_entities( + TeslemetryButtonEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in DESCRIPTIONS + if Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + + +class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): + """Base class for Teslemetry buttons.""" + + entity_description: TeslemetryButtonEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryButtonEntityDescription, + ) -> None: + """Initialize the button.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + async def async_press(self) -> None: + """Press the button.""" + await self.wake_up_if_asleep() + if self.entity_description.func: + await handle_vehicle_command(self.entity_description.func(self)) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 4c1c05570ab..5b093b0c6f1 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -2,44 +2,69 @@ from __future__ import annotations -from typing import Any +from itertools import chain +from typing import Any, cast -from tesla_fleet_api.const import Scope +from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ClimateEntity, ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import TeslemetryConfigEntry from .const import DOMAIN, TeslemetryClimateSide -from .context import handle_command from .entity import TeslemetryVehicleEntity +from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData +DEFAULT_MIN_TEMP = 15 +DEFAULT_MAX_TEMP = 28 + +PARALLEL_UPDATES = 0 + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Teslemetry Climate platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER, data.scopes) - for vehicle in data.vehicles + chain( + ( + TeslemetryClimateEntity( + vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ) ) class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): - """Vehicle Location Climate Class.""" + """Telemetry vehicle climate entity.""" _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 = ( @@ -67,68 +92,65 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): side, ) - @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 + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + value = self.get("climate_state_is_climate_on") + if value is None: + self._attr_hvac_mode = None + elif value: + self._attr_hvac_mode = HVACMode.HEAT_COOL + else: + self._attr_hvac_mode = 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(f"climate_state_{self.key}_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") + self._attr_current_temperature = self.get("climate_state_inside_temp") + self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") + self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") + self._attr_min_temp = cast( + float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) + ) + self._attr_max_temp = cast( + float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) + ) async def async_turn_on(self) -> None: """Set the climate state to on.""" + self.raise_for_scope() - with handle_command(): - await self.wake_up_if_asleep() - await self.api.auto_conditioning_start() - self.set(("climate_state_is_climate_on", True)) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_start()) + + self._attr_hvac_mode = HVACMode.HEAT_COOL + self.async_write_ha_state() async def async_turn_off(self) -> None: """Set the climate state to off.""" + self.raise_for_scope() - with handle_command(): - await self.wake_up_if_asleep() - await self.api.auto_conditioning_stop() - self.set( - ("climate_state_is_climate_on", False), - ("climate_state_climate_keeper_mode", "off"), - ) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_stop()) + + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = self._attr_preset_modes[0] + self.async_write_ha_state() async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" - temp = kwargs[ATTR_TEMPERATURE] - with handle_command(): - await self.wake_up_if_asleep() - await self.api.set_temps( - driver_temp=temp, - passenger_temp=temp, - ) - self.set((f"climate_state_{self.key}_setting", temp)) + if temp := kwargs.get(ATTR_TEMPERATURE): + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.set_temps( + driver_temp=temp, + passenger_temp=temp, + ) + ) + self._attr_target_temperature = temp + + if mode := kwargs.get(ATTR_HVAC_MODE): + # Set HVAC mode will call write_ha_state + await self.async_set_hvac_mode(mode) + else: + self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the climate mode and state.""" @@ -139,18 +161,136 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the climate preset mode.""" - with handle_command(): - await self.wake_up_if_asleep() - await self.api.set_climate_keeper_mode( + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.set_climate_keeper_mode( climate_keeper_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], - ), ) + self._attr_preset_mode = preset_mode + if preset_mode == self._attr_preset_modes[0]: + self._attr_hvac_mode = HVACMode.OFF + else: + self._attr_hvac_mode = HVACMode.HEAT_COOL + self.async_write_ha_state() + + +COP_MODES = { + "Off": HVACMode.OFF, + "On": HVACMode.COOL, + "FanOnly": HVACMode.FAN_ONLY, +} + +COP_LEVELS = { + "Low": 30, + "Medium": 35, + "High": 40, +} + + +class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEntity): + """Telemetry vehicle cabin overheat protection entity.""" + + _attr_precision = PRECISION_WHOLE + _attr_target_temperature_step = 5 + _attr_min_temp = 30 + _attr_max_temp = 40 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = list(COP_MODES.values()) + _enable_turn_on_off_backwards_compatibility = False + _attr_entity_registry_enabled_default = False + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: Scope, + ) -> None: + """Initialize the climate.""" + + super().__init__(data, "climate_state_cabin_overheat_protection") + + # Supported Features + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + if self.get("vehicle_config_cop_user_set_temp_supported"): + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + + # Scopes + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = ClimateEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + if (state := self.get("climate_state_cabin_overheat_protection")) is None: + self._attr_hvac_mode = None + else: + self._attr_hvac_mode = COP_MODES.get(state) + + if (level := self.get("climate_state_cop_activation_temperature")) is None: + self._attr_target_temperature = None + else: + self._attr_target_temperature = COP_LEVELS.get(level) + + self._attr_current_temperature = self.get("climate_state_inside_temp") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + await self.async_set_hvac_mode(HVACMode.COOL) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + self.raise_for_scope() + + if not (temp := kwargs.get(ATTR_TEMPERATURE)): + return + + if temp == 30: + cop_mode = CabinOverheatProtectionTemp.LOW + elif temp == 35: + cop_mode = CabinOverheatProtectionTemp.MEDIUM + elif temp == 40: + cop_mode = CabinOverheatProtectionTemp.HIGH + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_cop_temp", + ) + + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) + self._attr_target_temperature = temp + + if mode := kwargs.get(ATTR_HVAC_MODE): + await self._async_set_cop(mode) + + self.async_write_ha_state() + + async def _async_set_cop(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + await handle_vehicle_command( + self.api.set_cabin_overheat_protection(on=False, fan_only=False) + ) + elif hvac_mode == HVACMode.COOL: + await handle_vehicle_command( + self.api.set_cabin_overheat_protection(on=True, fan_only=False) + ) + elif hvac_mode == HVACMode.FAN_ONLY: + await handle_vehicle_command( + self.api.set_cabin_overheat_protection(on=True, fan_only=True) + ) + + self._attr_hvac_mode = hvac_mode + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self._async_set_cop(hvac_mode) + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py index 5fb6ce56aed..73921986f44 100644 --- a/homeassistant/components/teslemetry/config_flow.py +++ b/homeassistant/components/teslemetry/config_flow.py @@ -31,6 +31,7 @@ class TeslemetryConfigFlow(ConfigFlow, domain=DOMAIN): """Config Teslemetry API connection.""" VERSION = 1 + MINOR_VERSION = 2 _entry: ConfigEntry | None = None async def async_auth(self, user_input: Mapping[str, Any]) -> dict[str, str]: @@ -40,7 +41,7 @@ class TeslemetryConfigFlow(ConfigFlow, domain=DOMAIN): access_token=user_input[CONF_ACCESS_TOKEN], ) try: - await teslemetry.test() + metadata = await teslemetry.metadata() except InvalidToken: return {CONF_ACCESS_TOKEN: "invalid_access_token"} except SubscriptionRequired: @@ -50,6 +51,8 @@ class TeslemetryConfigFlow(ConfigFlow, domain=DOMAIN): except TeslaFleetError as e: LOGGER.error(e) return {"base": "unknown"} + + await self.async_set_unique_id(metadata["uid"]) return {} async def async_step_user( diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index 0d9d129877f..0c2dc68e7c7 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -10,10 +10,10 @@ DOMAIN = "teslemetry" LOGGER = logging.getLogger(__package__) MODELS = { - "model3": "Model 3", - "modelx": "Model X", - "modely": "Model Y", - "models": "Model S", + "S": "Model S", + "3": "Model 3", + "X": "Model X", + "Y": "Model Y", } diff --git a/homeassistant/components/teslemetry/context.py b/homeassistant/components/teslemetry/context.py deleted file mode 100644 index 942f1ccdd4b..00000000000 --- a/homeassistant/components/teslemetry/context.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Teslemetry context managers.""" - -from contextlib import contextmanager - -from tesla_fleet_api.exceptions import TeslaFleetError - -from homeassistant.exceptions import HomeAssistantError - - -@contextmanager -def handle_command(): - """Handle wake up and errors.""" - try: - yield - except TeslaFleetError as e: - raise HomeAssistantError("Teslemetry command failed") from e diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index be34386a508..cc29bc8ad18 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -1,11 +1,12 @@ """Teslemetry Data Coordinator.""" -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import VehicleDataEndpoint from tesla_fleet_api.exceptions import ( + Forbidden, InvalidToken, SubscriptionRequired, TeslaFleetError, @@ -13,12 +14,16 @@ from tesla_fleet_api.exceptions import ( ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER, TeslemetryState -SYNC_INTERVAL = 60 +VEHICLE_INTERVAL = timedelta(seconds=30) +VEHICLE_WAIT = timedelta(minutes=15) +ENERGY_LIVE_INTERVAL = timedelta(seconds=30) +ENERGY_INFO_INTERVAL = timedelta(seconds=30) + ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, VehicleDataEndpoint.CLIMATE_STATE, @@ -29,50 +34,48 @@ ENDPOINTS = [ ] -class TeslemetryDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Base class for Teslemetry Data Coordinators.""" +def flatten(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(flatten(value, key)) + else: + result[key] = value + return result - name: str + +class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Teslemetry API.""" + + updated_once: bool + pre2021: bool + last_active: datetime def __init__( - self, hass: HomeAssistant, api: VehicleSpecific | EnergySpecific + self, hass: HomeAssistant, api: VehicleSpecific, product: dict ) -> None: """Initialize Teslemetry Vehicle Update Coordinator.""" super().__init__( hass, LOGGER, - name=self.name, - update_interval=timedelta(seconds=SYNC_INTERVAL), + name="Teslemetry Vehicle", + update_interval=VEHICLE_INTERVAL, ) self.api = api - - -class TeslemetryVehicleDataCoordinator(TeslemetryDataCoordinator): - """Class to manage fetching data from the Teslemetry API.""" - - name = "Teslemetry Vehicle" - - async def async_config_entry_first_refresh(self) -> None: - """Perform first refresh.""" - try: - response = await self.api.wake_up() - if response["response"]["state"] != TeslemetryState.ONLINE: - # The first refresh will fail, so retry later - raise ConfigEntryNotReady("Vehicle is not online") - except InvalidToken as e: - raise ConfigEntryAuthFailed from e - except SubscriptionRequired as e: - raise ConfigEntryAuthFailed from e - except TeslaFleetError as e: - # The first refresh will also fail, so retry later - raise ConfigEntryNotReady from e - await super().async_config_entry_first_refresh() + self.data = flatten(product) + self.updated_once = False + self.last_active = datetime.now() async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Teslemetry API.""" + self.update_interval = VEHICLE_INTERVAL + try: - data = await self.api.vehicle_data(endpoints=ENDPOINTS) + data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] except VehicleOffline: self.data["state"] = TeslemetryState.OFFLINE return self.data @@ -83,43 +86,86 @@ class TeslemetryVehicleDataCoordinator(TeslemetryDataCoordinator): except TeslaFleetError as e: raise UpdateFailed(e.message) from e - return self._flatten(data["response"]) + self.updated_once = True - 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)) + if self.api.pre2021 and data["state"] == TeslemetryState.ONLINE: + # Handle pre-2021 vehicles which cannot sleep by themselves + if ( + data["charge_state"].get("charging_state") == "Charging" + or data["vehicle_state"].get("is_user_present") + or data["vehicle_state"].get("sentry_mode") + ): + # Vehicle is active, reset timer + self.last_active = datetime.now() else: - result[key] = value - return result + elapsed = datetime.now() - self.last_active + if elapsed > timedelta(minutes=20): + # Vehicle didn't sleep, try again in 15 minutes + self.last_active = datetime.now() + elif elapsed > timedelta(minutes=15): + # Let vehicle go to sleep now + self.update_interval = VEHICLE_WAIT + + return flatten(data) -class TeslemetryEnergyDataCoordinator(TeslemetryDataCoordinator): - """Class to manage fetching data from the Teslemetry API.""" +class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site live status from the Teslemetry API.""" - name = "Teslemetry Energy Site" + updated_once: bool + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Teslemetry Energy Site Live coordinator.""" + super().__init__( + hass, + LOGGER, + name="Teslemetry Energy Site Live", + update_interval=ENERGY_LIVE_INTERVAL, + ) + self.api = api async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: - data = await self.api.live_status() - except InvalidToken as e: - raise ConfigEntryAuthFailed from e - except SubscriptionRequired as e: + data = (await self.api.live_status())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e # Convert Wall Connectors from array to dict - data["response"]["wall_connectors"] = { - wc["din"]: wc for wc in (data["response"].get("wall_connectors") or []) + data["wall_connectors"] = { + wc["din"]: wc for wc in (data.get("wall_connectors") or []) } - return data["response"] + return data + + +class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site info from the Teslemetry API.""" + + updated_once: bool + + def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None: + """Initialize Teslemetry Energy Info coordinator.""" + super().__init__( + hass, + LOGGER, + name="Teslemetry Energy Site Info", + update_interval=ENERGY_INFO_INTERVAL, + ) + self.api = api + self.data = product + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site data using Teslemetry API.""" + + try: + data = (await self.api.site_info())["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + return flatten(data) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py new file mode 100644 index 00000000000..44e84626eb2 --- /dev/null +++ b/homeassistant/components/teslemetry/cover.py @@ -0,0 +1,219 @@ +"""Cover platform for Teslemetry integration.""" + +from __future__ import annotations + +from itertools import chain +from typing import Any + +from tesla_fleet_api.const import Scope, Trunk, WindowCommand + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .entity import TeslemetryVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslemetryVehicleData + +OPEN = 1 +CLOSED = 0 + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry cover platform from a config entry.""" + + async_add_entities( + chain( + ( + TeslemetryWindowEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryChargePortEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryFrontTrunkEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryRearTrunkEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ) + ) + + +class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): + """Cover entity for current charge.""" + + _attr_device_class = CoverDeviceClass.WINDOW + + def __init__(self, data: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(data, "windows") + self.scoped = Scope.VEHICLE_CMDS in scopes + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + fd = self.get("vehicle_state_fd_window") + fp = self.get("vehicle_state_fp_window") + rd = self.get("vehicle_state_rd_window") + rp = self.get("vehicle_state_rp_window") + + # Any open set to open + if OPEN in (fd, fp, rd, rp): + self._attr_is_closed = False + # All closed set to closed + elif CLOSED == fd == fp == rd == rp: + self._attr_is_closed = True + # Otherwise, set to unknown + else: + self._attr_is_closed = None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Vent windows.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.VENT) + ) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.CLOSE) + ) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "charge_state_charge_port_door_open") + self.scoped = any( + scope in scopes + for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) + ) + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = not self._value + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open charge port.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close charge port.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_close()) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_ft") + + self.scoped = Scope.VEHICLE_CMDS in scopes + self._attr_supported_features = CoverEntityFeature.OPEN + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = self._value == CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open front trunk.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.FRONT)) + self._attr_is_closed = False + self.async_write_ha_state() + + +class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_rt") + + self.scoped = Scope.VEHICLE_CMDS in scopes + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + value = self._value + if value == CLOSED: + self._attr_is_closed = True + elif value == OPEN: + self._attr_is_closed = False + else: + self._attr_is_closed = None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open rear trunk.""" + if self.is_closed is not False: + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close rear trunk.""" + if self.is_closed is not True: + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) + self._attr_is_closed = True + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py new file mode 100644 index 00000000000..399d28533f1 --- /dev/null +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -0,0 +1,89 @@ +"""Device tracker platform for Teslemetry integration.""" + +from __future__ import annotations + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry device tracker platform from a config entry.""" + + async_add_entities( + klass(vehicle) + for klass in ( + TeslemetryDeviceTrackerLocationEntity, + TeslemetryDeviceTrackerRouteEntity, + ) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): + """Base class for Teslemetry tracker entities.""" + + lat_key: str + lon_key: str + + def __init__( + self, + vehicle: TeslemetryVehicleData, + ) -> None: + """Initialize the device tracker.""" + super().__init__(vehicle, self.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the device tracker.""" + + self._attr_available = ( + self.get(self.lat_key, False) is not None + and self.get(self.lon_key, False) is not None + ) + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.get(self.lat_key) + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.get(self.lon_key) + + @property + def source_type(self) -> SourceType: + """Return the source type of the device tracker.""" + return SourceType.GPS + + +class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity): + """Vehicle location device tracker class.""" + + key = "location" + lat_key = "drive_state_latitude" + lon_key = "drive_state_longitude" + + +class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): + """Vehicle navigation device tracker class.""" + + key = "route" + lat_key = "drive_state_active_route_latitude" + lon_key = "drive_state_active_route_longitude" + + @property + def location_name(self) -> str | None: + """Return a location name for the current location of the device.""" + return self.get("drive_state_active_route_destination") diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py index f8a8e6727a7..7e9c8a9a5b0 100644 --- a/homeassistant/components/teslemetry/diagnostics.py +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -5,10 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN +from . import TeslemetryConfigEntry VEHICLE_REDACT = [ "id", @@ -25,22 +24,32 @@ VEHICLE_REDACT = [ "drive_state_native_longitude", ] -ENERGY_REDACT = ["vin"] +ENERGY_LIVE_REDACT = ["vin"] +ENERGY_INFO_REDACT = ["installation_date"] async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, entry: TeslemetryConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" vehicles = [ - x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].vehicles + { + "data": async_redact_data(x.coordinator.data, VEHICLE_REDACT), + # Stream diag will go here when implemented + } + for x in entry.runtime_data.vehicles ] energysites = [ - x.coordinator.data for x in hass.data[DOMAIN][config_entry.entry_id].energysites + { + "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT), + "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), + } + for x in entry.runtime_data.energysites ] # Return only the relevant children return { - "vehicles": async_redact_data(vehicles, VEHICLE_REDACT), - "energysites": async_redact_data(energysites, ENERGY_REDACT), + "vehicles": vehicles, + "energysites": energysites, + "scopes": entry.runtime_data.scopes, } diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index d67a1bd1770..74c1fdd52b1 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -1,52 +1,112 @@ """Teslemetry parent entity class.""" -import asyncio +from abc import abstractmethod from typing import Any -from tesla_fleet_api.exceptions import TeslaFleetError +from tesla_fleet_api import EnergySpecific, VehicleSpecific -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MODELS, TeslemetryState +from .const import DOMAIN from .coordinator import ( - TeslemetryEnergyDataCoordinator, + TeslemetryEnergySiteInfoCoordinator, + TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, ) +from .helpers import wake_up_vehicle from .models import TeslemetryEnergyData, TeslemetryVehicleData -class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator]): - """Parent class for Teslemetry Vehicle Entities.""" +class TeslemetryEntity( + CoordinatorEntity[ + TeslemetryVehicleDataCoordinator + | TeslemetryEnergySiteLiveCoordinator + | TeslemetryEnergySiteInfoCoordinator + ] +): + """Parent class for all Teslemetry entities.""" _attr_has_entity_name = True def __init__( self, - vehicle: TeslemetryVehicleData, + coordinator: TeslemetryVehicleDataCoordinator + | TeslemetryEnergySiteLiveCoordinator + | TeslemetryEnergySiteInfoCoordinator, + api: VehicleSpecific | EnergySpecific, key: str, ) -> None: """Initialize common aspects of a Teslemetry entity.""" - super().__init__(vehicle.coordinator) + super().__init__(coordinator) + self.api = api self.key = key - self.api = vehicle.api - self._wakelock = vehicle.wakelock + self._attr_translation_key = self.key + self._async_update_attrs() - car_type = self.coordinator.data["vehicle_config_car_type"] + @property + def available(self) -> bool: + """Return if sensor is available.""" + return self.coordinator.last_update_success and self._attr_available - self._attr_translation_key = key - self._attr_unique_id = f"{vehicle.vin}-{key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, vehicle.vin)}, - manufacturer="Tesla", - configuration_url="https://teslemetry.com/console", - name=self.coordinator.data["vehicle_state_vehicle_name"], - model=MODELS.get(car_type, car_type), - sw_version=self.coordinator.data["vehicle_state_car_version"].split(" ")[0], - hw_version=self.coordinator.data["vehicle_config_driver_assist"], - serial_number=vehicle.vin, - ) + @property + def _value(self) -> Any | None: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(self.key) + + def get(self, key: str, default: Any | None = None) -> Any | None: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key, default) + + def get_number(self, key: str, default: float) -> float: + """Return a specific number from coordinator data.""" + if isinstance(value := self.coordinator.data.get(key), (int, float)): + return value + return default + + @property + def is_none(self) -> bool: + """Return if the value is a literal None.""" + return self.get(self.key, False) is None + + @property + def has(self) -> bool: + """Return True if a specific value is in coordinator data.""" + return self.key in self.coordinator.data + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + self.async_write_ha_state() + + @abstractmethod + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + def raise_for_scope(self): + """Raise an error if a scope is not available.""" + if not self.scoped: + raise ServiceValidationError("Missing required scope") + + +class TeslemetryVehicleEntity(TeslemetryEntity): + """Parent class for Teslemetry Vehicle entities.""" + + _last_update: int = 0 + + def __init__( + self, + data: TeslemetryVehicleData, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry entity.""" + + self._attr_unique_id = f"{data.vin}-{key}" + self.vehicle = data + + self._attr_device_info = data.device + super().__init__(data.coordinator, data.api, key) @property def _value(self) -> Any | None: @@ -55,97 +115,80 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator async def wake_up_if_asleep(self) -> None: """Wake up the vehicle if its asleep.""" - async with self._wakelock: - times = 0 - while self.coordinator.data["state"] != TeslemetryState.ONLINE: - try: - if times == 0: - cmd = await self.api.wake_up() - else: - cmd = await self.api.vehicle() - state = cmd["response"]["state"] - except TeslaFleetError as e: - raise HomeAssistantError(str(e)) from e - self.coordinator.data["state"] = state - if state != TeslemetryState.ONLINE: - times += 1 - if times >= 4: # Give up after 30 seconds total - raise HomeAssistantError("Could not wake up vehicle") - await asyncio.sleep(times * 5) - - 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) - - 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() - - def raise_for_scope(self): - """Raise an error if a scope is not available.""" - if not self.scoped: - raise ServiceValidationError("Missing required scope") + await wake_up_vehicle(self.vehicle) -class TeslemetryEnergyEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]): - """Parent class for Teslemetry Energy Entities.""" - - _attr_has_entity_name = True +class TeslemetryEnergyLiveEntity(TeslemetryEntity): + """Parent class for Teslemetry Energy Site Live entities.""" def __init__( self, - energysite: TeslemetryEnergyData, + data: TeslemetryEnergyData, key: str, ) -> None: - """Initialize common aspects of a Teslemetry entity.""" - super().__init__(energysite.coordinator) - self.key = key - self.api = energysite.api + """Initialize common aspects of a Teslemetry Energy Site Live entity.""" + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device - self._attr_translation_key = key - self._attr_unique_id = f"{energysite.id}-{key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(energysite.id))}, - manufacturer="Tesla", - configuration_url="https://teslemetry.com/console", - name=self.coordinator.data.get("site_name", "Energy Site"), - ) - - 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) + super().__init__(data.live_coordinator, data.api, key) -class TeslemetryWallConnectorEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]): +class TeslemetryEnergyInfoEntity(TeslemetryEntity): + """Parent class for Teslemetry Energy Site Info Entities.""" + + def __init__( + self, + data: TeslemetryEnergyData, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry Energy Site Info entity.""" + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device + + super().__init__(data.info_coordinator, data.api, key) + + +class TeslemetryWallConnectorEntity( + TeslemetryEntity, CoordinatorEntity[TeslemetryEnergySiteLiveCoordinator] +): """Parent class for Teslemetry Wall Connector Entities.""" _attr_has_entity_name = True def __init__( self, - energysite: TeslemetryEnergyData, + data: TeslemetryEnergyData, din: str, key: str, ) -> None: """Initialize common aspects of a Teslemetry entity.""" - super().__init__(energysite.coordinator) self.din = din - self.key = key + self._attr_unique_id = f"{data.id}-{din}-{key}" + + # Find the model from the info coordinator + model: str | None = None + for wc in data.info_coordinator.data.get("components_wall_connectors", []): + if wc["din"] == din: + model = wc.get("part_name") + break - self._attr_translation_key = key - self._attr_unique_id = f"{energysite.id}-{din}-{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, din)}, manufacturer="Tesla", configuration_url="https://teslemetry.com/console", name="Wall Connector", - via_device=(DOMAIN, str(energysite.id)), + via_device=(DOMAIN, str(data.id)), serial_number=din.split("-")[-1], + model=model, ) + super().__init__(data.live_coordinator, data.api, key) + @property def _value(self) -> int: """Return a specific wall connector value from coordinator data.""" - return self.coordinator.data["wall_connectors"][self.din].get(self.key) + return ( + self.coordinator.data.get("wall_connectors", {}) + .get(self.din, {}) + .get(self.key) + ) diff --git a/homeassistant/components/teslemetry/helpers.py b/homeassistant/components/teslemetry/helpers.py new file mode 100644 index 00000000000..a8cfa1051f1 --- /dev/null +++ b/homeassistant/components/teslemetry/helpers.py @@ -0,0 +1,63 @@ +"""Teslemetry helper functions.""" + +import asyncio +from typing import Any + +from tesla_fleet_api.exceptions import TeslaFleetError + +from homeassistant.exceptions import HomeAssistantError + +from .const import LOGGER, TeslemetryState + + +async def wake_up_vehicle(vehicle) -> None: + """Wake up a vehicle.""" + async with vehicle.wakelock: + times = 0 + while vehicle.coordinator.data["state"] != TeslemetryState.ONLINE: + try: + if times == 0: + cmd = await vehicle.api.wake_up() + else: + cmd = await vehicle.api.vehicle() + state = cmd["response"]["state"] + except TeslaFleetError as e: + raise HomeAssistantError(str(e)) from e + vehicle.coordinator.data["state"] = state + if state != TeslemetryState.ONLINE: + times += 1 + if times >= 4: # Give up after 30 seconds total + raise HomeAssistantError("Could not wake up vehicle") + await asyncio.sleep(times * 5) + + +async def handle_command(command) -> dict[str, Any]: + """Handle a command.""" + try: + result = await command + except TeslaFleetError as e: + raise HomeAssistantError(f"Teslemetry command failed, {e.message}") from e + LOGGER.debug("Command result: %s", result) + return result + + +async def handle_vehicle_command(command) -> dict[str, Any]: + """Handle a vehicle command.""" + result = await handle_command(command) + if (response := result.get("response")) is None: + if error := result.get("error"): + # No response with error + raise HomeAssistantError(error) + # No response without error (unexpected) + raise HomeAssistantError(f"Unknown response: {response}") + if (result := response.get("result")) is not True: + if reason := response.get("reason"): + if reason in ("already_set", "not_charging", "requested"): + # Reason is acceptable + return result + # Result of false with reason + raise HomeAssistantError(reason) + # Result of false without reason (unexpected) + raise HomeAssistantError("Command failed with no reason") + # Response with result of true + return result diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index b3b61831b0e..089a3bea548 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -1,5 +1,63 @@ { "entity": { + "binary_sensor": { + "climate_state_is_preconditioning": { + "state": { + "off": "mdi:hvac-off", + "on": "mdi:hvac" + } + }, + "vehicle_state_is_user_present": { + "state": { + "off": "mdi:account-remove-outline", + "on": "mdi:account" + } + }, + "vehicle_state_tpms_soft_warning_fl": { + "state": { + "off": "mdi:tire", + "on": "mdi:car-tire-alert" + } + }, + "vehicle_state_tpms_soft_warning_fr": { + "state": { + "off": "mdi:tire", + "on": "mdi:car-tire-alert" + } + }, + "vehicle_state_tpms_soft_warning_rl": { + "state": { + "off": "mdi:tire", + "on": "mdi:car-tire-alert" + } + }, + "vehicle_state_tpms_soft_warning_rr": { + "state": { + "off": "mdi:tire", + "on": "mdi:car-tire-alert" + } + } + }, + "button": { + "boombox": { + "default": "mdi:volume-high" + }, + "enable_keyless_driving": { + "default": "mdi:car-key" + }, + "flash_lights": { + "default": "mdi:flashlight" + }, + "homelink": { + "default": "mdi:garage" + }, + "honk": { + "default": "mdi:bullhorn" + }, + "wake": { + "default": "mdi:sleep-off" + } + }, "climate": { "driver_temp": { "state_attributes": { @@ -14,6 +72,94 @@ } } }, + "lock": { + "charge_state_charge_port_latch": { + "default": "mdi:ev-plug-tesla" + }, + "vehicle_state_locked": { + "state": { + "locked": "mdi:car-door-lock", + "unlocked": "mdi:car-door-lock-open" + } + }, + "vehicle_state_speed_limit_mode_active": { + "default": "mdi:car-speed-limiter" + } + }, + "select": { + "climate_state_seat_heater_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_rear_center": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_rear_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_rear_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_third_row_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_third_row_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + + "components_customer_preferred_export_rule": { + "default": "mdi:transmission-tower", + "state": { + "battery_ok": "mdi:battery-negative", + "never": "mdi:transmission-tower-off", + "pv_only": "mdi:solar-panel" + } + }, + "default_real_mode": { + "default": "mdi:home-battery", + "state": { + "autonomous": "mdi:auto-fix", + "backup": "mdi:battery-charging-100", + "self_consumption": "mdi:home-battery" + } + } + }, + "device_tracker": { + "location": { + "default": "mdi:map-marker" + }, + "route": { + "default": "mdi:routes" + } + }, + "cover": { + "charge_state_charge_port_door_open": { + "default": "mdi:ev-plug-ccs2" + } + }, "sensor": { "battery_power": { "default": "mdi:home-battery" @@ -75,6 +221,41 @@ "wall_connector_state": { "default": "mdi:ev-station" } + }, + "switch": { + "charge_state_user_charge_enable_request": { + "default": "mdi:ev-station" + }, + "climate_state_auto_seat_climate_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_auto_seat_climate_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_auto_steering_wheel_heat": { + "default": "mdi:steering" + }, + "climate_state_defrost_mode": { + "default": "mdi:snowflake-melt" + }, + "components_disallow_charge_from_grid_with_solar_installed": { + "state": { + "false": "mdi:transmission-tower", + "true": "mdi:solar-power" + } + }, + "vehicle_state_sentry_mode": { + "default": "mdi:shield-car" + }, + "vehicle_state_valet_mode": { + "default": "mdi:speedometer-slow" + } } } } diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py new file mode 100644 index 00000000000..e23747924f6 --- /dev/null +++ b/homeassistant/components/teslemetry/lock.py @@ -0,0 +1,103 @@ +"""Lock platform for Teslemetry integration.""" + +from __future__ import annotations + +from typing import Any + +from tesla_fleet_api.const import Scope + +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .const import DOMAIN +from .entity import TeslemetryVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslemetryVehicleData + +ENGAGED = "Engaged" + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry lock platform from a config entry.""" + + async_add_entities( + klass(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + for klass in ( + TeslemetryVehicleLockEntity, + TeslemetryCableLockEntity, + ) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryVehicleLockEntity(TeslemetryVehicleEntity, LockEntity): + """Lock entity for Teslemetry.""" + + def __init__(self, data: TeslemetryVehicleData, scoped: bool) -> None: + """Initialize the lock.""" + super().__init__(data, "vehicle_state_locked") + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._attr_is_locked = self._value + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the doors.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_lock()) + self._attr_is_locked = True + self.async_write_ha_state() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the doors.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_unlock()) + self._attr_is_locked = False + self.async_write_ha_state() + + +class TeslemetryCableLockEntity(TeslemetryVehicleEntity, LockEntity): + """Cable Lock entity for Teslemetry.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the lock.""" + super().__init__(data, "charge_state_charge_port_latch") + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + if self._value is None: + self._attr_is_locked = None + self._attr_is_locked = self._value == ENGAGED + + async def async_lock(self, **kwargs: Any) -> None: + """Charge cable Lock cannot be manually locked.""" + raise ServiceValidationError( + "Insert cable to lock", + translation_domain=DOMAIN, + translation_key="no_cable", + ) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock charge cable lock.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) + self._attr_is_locked = False + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7f3f1704f2d..2eb3e221855 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.4.9"] + "quality_scale": "platinum", + "requirements": ["tesla-fleet-api==0.6.1"] } diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py new file mode 100644 index 00000000000..b21ba0f733d --- /dev/null +++ b/homeassistant/components/teslemetry/media_player.py @@ -0,0 +1,154 @@ +"""Media player platform for Teslemetry integration.""" + +from __future__ import annotations + +from tesla_fleet_api.const import Scope + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .entity import TeslemetryVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslemetryVehicleData + +STATES = { + "Playing": MediaPlayerState.PLAYING, + "Paused": MediaPlayerState.PAUSED, + "Stopped": MediaPlayerState.IDLE, + "Off": MediaPlayerState.OFF, +} +VOLUME_MAX = 11.0 +VOLUME_STEP = 1.0 / 3 + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry Media platform from a config entry.""" + + async_add_entities( + TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): + """Vehicle media player class.""" + + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + ) + _volume_max: float = VOLUME_MAX + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the media player entity.""" + super().__init__(data, "media") + self.scoped = scoped + if not scoped: + self._attr_supported_features = MediaPlayerEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._volume_max = ( + self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX + ) + self._attr_state = STATES.get( + self.get("vehicle_state_media_info_media_playback_status") or "Off", + ) + self._attr_volume_step = ( + 1.0 + / self._volume_max + / ( + self.get("vehicle_state_media_info_audio_volume_increment") + or VOLUME_STEP + ) + ) + + if volume := self.get("vehicle_state_media_info_audio_volume"): + self._attr_volume_level = volume / self._volume_max + else: + self._attr_volume_level = None + + if duration := self.get("vehicle_state_media_info_now_playing_duration"): + self._attr_media_duration = duration / 1000 + else: + self._attr_media_duration = None + + if duration and ( + position := self.get("vehicle_state_media_info_now_playing_elapsed") + ): + self._attr_media_position = position / 1000 + else: + self._attr_media_position = None + + self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title") + self._attr_media_artist = self.get( + "vehicle_state_media_info_now_playing_artist" + ) + self._attr_media_album_name = self.get( + "vehicle_state_media_info_now_playing_album" + ) + self._attr_media_playlist = self.get( + "vehicle_state_media_info_now_playing_station" + ) + self._attr_source = self.get("vehicle_state_media_info_now_playing_source") + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.adjust_volume(int(volume * self._volume_max)) + ) + self._attr_volume_level = volume + self.async_write_ha_state() + + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + async def async_media_pause(self) -> None: + """Send pause command.""" + if self.state == MediaPlayerState.PLAYING: + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_next_track()) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_prev_track()) diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 615156e6fdc..d05d713c1eb 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -8,8 +8,11 @@ from dataclasses import dataclass from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import Scope +from homeassistant.helpers.device_registry import DeviceInfo + from .coordinator import ( - TeslemetryEnergyDataCoordinator, + TeslemetryEnergySiteInfoCoordinator, + TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, ) @@ -31,6 +34,7 @@ class TeslemetryVehicleData: coordinator: TeslemetryVehicleDataCoordinator vin: str wakelock = asyncio.Lock() + device: DeviceInfo @dataclass @@ -38,6 +42,7 @@ class TeslemetryEnergyData: """Data for a vehicle in the Teslemetry integration.""" api: EnergySpecific - coordinator: TeslemetryEnergyDataCoordinator + live_coordinator: TeslemetryEnergySiteLiveCoordinator + info_coordinator: TeslemetryEnergySiteInfoCoordinator id: int - info: dict[str, str] + device: DeviceInfo diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py new file mode 100644 index 00000000000..8c14c8e4186 --- /dev/null +++ b/homeassistant/components/teslemetry/number.py @@ -0,0 +1,206 @@ +"""Number platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from itertools import chain +from typing import Any + +from tesla_fleet_api import EnergySpecific, VehicleSpecific +from tesla_fleet_api.const import Scope + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level + +from . import TeslemetryConfigEntry +from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .helpers import handle_command, handle_vehicle_command +from .models import TeslemetryEnergyData, TeslemetryVehicleData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): + """Describes Teslemetry Number entity.""" + + func: Callable[[VehicleSpecific, float], Awaitable[Any]] + native_min_value: float + native_max_value: float + min_key: str | None = None + max_key: str + scopes: list[Scope] + + +VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( + TeslemetryNumberVehicleEntityDescription( + 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, + mode=NumberMode.AUTO, + max_key="charge_state_charge_current_request_max", + func=lambda api, value: api.set_charging_amps(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS], + ), + TeslemetryNumberVehicleEntityDescription( + 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, + mode=NumberMode.AUTO, + min_key="charge_state_charge_limit_soc_min", + max_key="charge_state_charge_limit_soc_max", + func=lambda api, value: api.set_charge_limit(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), +) + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): + """Describes Teslemetry Number entity.""" + + func: Callable[[EnergySpecific, float], Awaitable[Any]] + requires: str | None = None + + +ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = ( + TeslemetryNumberBatteryEntityDescription( + key="backup_reserve_percent", + func=lambda api, value: api.backup(int(value)), + requires="components_battery", + ), + TeslemetryNumberBatteryEntityDescription( + key="off_grid_vehicle_charging_reserve_percent", + func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), + requires="components_off_grid_vehicle_charging_reserve_supported", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry number platform from a config entry.""" + + async_add_entities( + chain( + ( # Add vehicle entities + TeslemetryVehicleNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + for vehicle in entry.runtime_data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( # Add energy site entities + TeslemetryEnergyInfoNumberSensorEntity( + energysite, + description, + entry.runtime_data.scopes, + ) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.requires is None + or energysite.info_coordinator.data.get(description.requires) + ), + ) + ) + + +class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): + """Vehicle number entity base class.""" + + entity_description: TeslemetryNumberVehicleEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + super().__init__( + data, + description.key, + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + + if (min_key := self.entity_description.min_key) is not None: + self._attr_native_min_value = self.get_number( + min_key, + self.entity_description.native_min_value, + ) + else: + self._attr_native_min_value = self.entity_description.native_min_value + + self._attr_native_max_value = self.get_number( + self.entity_description.max_key, + self.entity_description.native_max_value, + ) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslemetryEnergyInfoNumberSensorEntity(TeslemetryEnergyInfoEntity, NumberEntity): + """Energy info number entity base class.""" + + entity_description: TeslemetryNumberBatteryEntityDescription + _attr_native_step = PRECISION_WHOLE + _attr_native_min_value = 0 + _attr_native_max_value = 100 + _attr_device_class = NumberDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__( + self, + data: TeslemetryEnergyData, + description: TeslemetryNumberBatteryEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + self._attr_icon = icon_for_battery_level(self.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope() + await handle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py new file mode 100644 index 00000000000..7cbdd4e31d2 --- /dev/null +++ b/homeassistant/components/teslemetry/select.py @@ -0,0 +1,264 @@ +"""Select platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from itertools import chain + +from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .helpers import handle_command, handle_vehicle_command +from .models import TeslemetryEnergyData, TeslemetryVehicleData + +OFF = "off" +LOW = "low" +MEDIUM = "medium" +HIGH = "high" + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SeatHeaterDescription(SelectEntityDescription): + """Seat Heater entity description.""" + + position: Seat + available_fn: Callable[[TeslemetrySeatHeaterSelectEntity], bool] = lambda _: True + + +SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( + SeatHeaterDescription( + key="climate_state_seat_heater_left", + position=Seat.FRONT_LEFT, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_right", + position=Seat.FRONT_RIGHT, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_rear_left", + position=Seat.REAR_LEFT, + available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_rear_center", + position=Seat.REAR_CENTER, + available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_rear_right", + position=Seat.REAR_RIGHT, + available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_third_row_left", + position=Seat.THIRD_LEFT, + available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_third_row_right", + position=Seat.THIRD_RIGHT, + available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry select platform from a config entry.""" + + async_add_entities( + chain( + ( + TeslemetrySeatHeaterSelectEntity( + vehicle, description, entry.runtime_data.scopes + ) + for description in SEAT_HEATER_DESCRIPTIONS + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + ), + ( + TeslemetryExportRuleSelectEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + and energysite.info_coordinator.data.get("components_solar") + ), + ) + ) + + +class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): + """Select entity for vehicle seat heater.""" + + entity_description: SeatHeaterDescription + + _attr_options = [ + OFF, + LOW, + MEDIUM, + HIGH, + ] + + def __init__( + self, + data: TeslemetryVehicleData, + description: SeatHeaterDescription, + scopes: list[Scope], + ) -> None: + """Initialize the vehicle seat select entity.""" + self.entity_description = description + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_available = self.entity_description.available_fn(self) + value = self._value + if value is None: + self._attr_current_option = None + else: + self._attr_current_option = self._attr_options[value] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + level = self._attr_options.index(option) + # AC must be on to turn on seat heater + if level and not self.get("climate_state_is_climate_on"): + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command( + self.api.remote_seat_heater_request(self.entity_description.position, level) + ) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): + """Select entity for vehicle steering wheel heater.""" + + _attr_options = [ + OFF, + LOW, + HIGH, + ] + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the vehicle steering wheel select entity.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "climate_state_steering_wheel_heat_level", + ) + + def _async_update_attrs(self) -> None: + """Handle updated data from the coordinator.""" + + value = self._value + if value is None: + self._attr_current_option = None + else: + self._attr_current_option = self._attr_options[value] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + level = self._attr_options.index(option) + # AC must be on to turn on steering wheel heater + if level and not self.get("climate_state_is_climate_on"): + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command( + self.api.remote_steering_wheel_heat_level_request(level) + ) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): + """Select entity for operation mode select entities.""" + + _attr_options: list[str] = [ + EnergyOperationMode.AUTONOMOUS, + EnergyOperationMode.BACKUP, + EnergyOperationMode.SELF_CONSUMPTION, + ] + + def __init__( + self, + data: TeslemetryEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the operation mode select entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + super().__init__(data, "default_real_mode") + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_current_option = self._value + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope() + await handle_command(self.api.operation(option)) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslemetryExportRuleSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): + """Select entity for export rules select entities.""" + + _attr_options: list[str] = [ + EnergyExportMode.NEVER, + EnergyExportMode.BATTERY_OK, + EnergyExportMode.PV_ONLY, + ] + + def __init__( + self, + data: TeslemetryEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the export rules select entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + super().__init__(data, "components_customer_preferred_export_rule") + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_current_option = self.get(self.key, EnergyExportMode.NEVER.value) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope() + await handle_command( + self.api.grid_import_export(customer_preferred_export_rule=option) + ) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 6380a4d0c71..90b37cc1dac 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta from itertools import chain from typing import cast @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -34,14 +33,17 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance -from .const import DOMAIN +from . import TeslemetryConfigEntry from .entity import ( - TeslemetryEnergyEntity, + TeslemetryEnergyInfoEntity, + TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, TeslemetryWallConnectorEntity, ) from .models import TeslemetryEnergyData, TeslemetryVehicleData +PARALLEL_UPDATES = 0 + CHARGE_STATES = { "Starting": "starting", "Charging": "charging", @@ -298,7 +300,7 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( ), ) -ENERGY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( +ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, @@ -401,37 +403,53 @@ WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( ), ) +ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="vpp_backup_reserve_percent", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription(key="version"), +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Teslemetry sensor platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] - async_add_entities( chain( ( # Add vehicles TeslemetryVehicleSensorEntity(vehicle, description) - for vehicle in data.vehicles + for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS ), ( # Add vehicles time sensors TeslemetryVehicleTimeSensorEntity(vehicle, description) - for vehicle in data.vehicles + for vehicle in entry.runtime_data.vehicles for description in VEHICLE_TIME_DESCRIPTIONS ), ( # Add energy site live - TeslemetryEnergySensorEntity(energysite, description) - for energysite in data.energysites - for description in ENERGY_DESCRIPTIONS - if description.key in energysite.coordinator.data + TeslemetryEnergyLiveSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_LIVE_DESCRIPTIONS + if description.key in energysite.live_coordinator.data ), ( # Add wall connectors TeslemetryWallConnectorSensorEntity(energysite, din, description) - for energysite in data.energysites - for din in energysite.coordinator.data.get("wall_connectors", {}) + for energysite in entry.runtime_data.energysites + for din in energysite.live_coordinator.data.get("wall_connectors", {}) for description in WALL_CONNECTOR_DESCRIPTIONS ), + ( # Add energy site info + TeslemetryEnergyInfoSensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.key in energysite.info_coordinator.data + ), ) ) @@ -443,21 +461,23 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def __init__( self, - vehicle: TeslemetryVehicleData, + data: TeslemetryVehicleData, description: TeslemetrySensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description - super().__init__(vehicle, description.key) + super().__init__(data, description.key) - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self.entity_description.value_fn(self._value) + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + if self.has: + self._attr_native_value = self.entity_description.value_fn(self._value) + else: + self._attr_native_value = None class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): - """Base class for Teslemetry vehicle metric sensors.""" + """Base class for Teslemetry vehicle time sensors.""" entity_description: TeslemetryTimeEntityDescription @@ -475,35 +495,31 @@ class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): super().__init__(data, description.key) - @property - def native_value(self) -> datetime | None: - """Return the state of the sensor.""" - return self._get_timestamp(self._value) - - @property - def available(self) -> bool: - """Return the availability of the sensor.""" - return isinstance(self._value, int | float) and self._value > 0 + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = isinstance(self._value, int | float) and self._value > 0 + if self._attr_available: + self._attr_native_value = self._get_timestamp(self._value) -class TeslemetryEnergySensorEntity(TeslemetryEnergyEntity, SensorEntity): +class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity): """Base class for Teslemetry energy site metric sensors.""" entity_description: SensorEntityDescription def __init__( self, - energysite: TeslemetryEnergyData, + data: TeslemetryEnergyData, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(energysite, description.key) self.entity_description = description + super().__init__(data, description.key) - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self.get() + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = not self.is_none + self._attr_native_value = self._value class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): @@ -513,19 +529,39 @@ class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorE def __init__( self, - energysite: TeslemetryEnergyData, + data: TeslemetryEnergyData, din: str, description: SensorEntityDescription, ) -> None: """Initialize the sensor.""" + self.entity_description = description super().__init__( - energysite, + data, din, description.key, ) - self.entity_description = description - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self._value + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = not self.is_none + self._attr_native_value = self._value + + +class TeslemetryEnergyInfoSensorEntity(TeslemetryEnergyInfoEntity, SensorEntity): + """Base class for Teslemetry energy site metric sensors.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + data: TeslemetryEnergyData, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = not self.is_none + self._attr_native_value = self._value diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index fa4419fbfcb..fe45b4ee9e3 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Account is already configured" + }, "error": { "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", "subscription_required": "Subscription required, please visit {short_url}", @@ -16,7 +19,110 @@ } }, "entity": { + "binary_sensor": { + "backup_capable": { + "name": "Backup capable" + }, + "charge_state_battery_heater_on": { + "name": "Battery heater" + }, + "charge_state_charger_phases": { + "name": "Charger has multiple phases" + }, + "charge_state_conn_charge_cable": { + "name": "Charge cable" + }, + "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_cabin_overheat_protection_actively_cooling": { + "name": "Cabin overheat protection actively cooling" + }, + "climate_state_is_preconditioning": { + "name": "Preconditioning" + }, + "components_grid_services_enabled": { + "name": "Grid services enabled" + }, + "grid_services_active": { + "name": "Grid services active" + }, + "state": { + "name": "Status" + }, + "vehicle_state_dashcam_state": { + "name": "Dashcam" + }, + "vehicle_state_df": { + "name": "Front driver door" + }, + "vehicle_state_dr": { + "name": "Rear driver door" + }, + "vehicle_state_fd_window": { + "name": "Front driver window" + }, + "vehicle_state_fp_window": { + "name": "Front passenger window" + }, + "vehicle_state_is_user_present": { + "name": "User present" + }, + "vehicle_state_pf": { + "name": "Front passenger door" + }, + "vehicle_state_pr": { + "name": "Rear passenger door" + }, + "vehicle_state_rd_window": { + "name": "Rear driver window" + }, + "vehicle_state_rp_window": { + "name": "Rear passenger window" + }, + "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" + } + }, + "button": { + "boombox": { + "name": "Play fart" + }, + "enable_keyless_driving": { + "name": "Keyless driving" + }, + "flash_lights": { + "name": "Flash lights" + }, + "homelink": { + "name": "Homelink" + }, + "honk": { + "name": "Honk horn" + }, + "wake": { + "name": "Wake" + } + }, "climate": { + "climate_state_cabin_overheat_protection": { + "name": "Cabin overheat protection" + }, "driver_temp": { "name": "[%key:component::climate::title%]", "state_attributes": { @@ -31,6 +137,147 @@ } } }, + "device_tracker": { + "location": { + "name": "Location" + }, + "route": { + "name": "Route" + } + }, + "lock": { + "charge_state_charge_port_latch": { + "name": "Charge cable lock" + }, + "vehicle_state_locked": { + "name": "[%key:component::lock::title%]" + }, + "vehicle_state_speed_limit_mode_active": { + "name": "Speed limit" + } + }, + "select": { + "climate_state_seat_heater_left": { + "name": "Seat heater front left", + "state": { + "high": "High", + "low": "Low", + "medium": "Medium", + "off": "Off" + } + }, + "climate_state_seat_heater_rear_center": { + "name": "Seat heater rear center", + "state": { + "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_rear_left": { + "name": "Seat heater rear left", + "state": { + "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_rear_right": { + "name": "Seat heater rear right", + "state": { + "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_right": { + "name": "Seat heater front right", + "state": { + "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_third_row_left": { + "name": "Seat heater third row left", + "state": { + "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_third_row_right": { + "name": "Seat heater third row right", + "state": { + "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_steering_wheel_heat_level": { + "name": "Steering wheel heater", + "state": { + "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]", + "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "components_customer_preferred_export_rule": { + "name": "Allow export", + "state": { + "battery_ok": "Battery", + "never": "Never", + "pv_only": "Solar only" + } + }, + "default_real_mode": { + "name": "Operation mode", + "state": { + "autonomous": "Autonomous", + "backup": "Backup", + "self_consumption": "Self consumption" + } + } + }, + "media_player": { + "media": { + "name": "[%key:component::media_player::title%]" + } + }, + "number": { + "backup_reserve_percent": { + "name": "Backup reserve" + }, + "charge_state_charge_current_request": { + "name": "Charge current" + }, + "charge_state_charge_limit_soc": { + "name": "Charge limit" + }, + "off_grid_vehicle_charging_reserve_percent": { + "name": "Off grid reserve" + } + }, + "cover": { + "charge_state_charge_port_door_open": { + "name": "Charge port door" + }, + "vehicle_state_ft": { + "name": "Frunk" + }, + "vehicle_state_rt": { + "name": "Trunk" + }, + "windows": { + "name": "Windows" + } + }, "sensor": { "battery_power": { "name": "Battery power" @@ -166,9 +413,15 @@ "vehicle_state_tpms_pressure_rr": { "name": "Tire pressure rear right" }, + "version": { + "name": "version" + }, "vin": { "name": "Vehicle" }, + "vpp_backup_reserve_percent": { + "name": "VPP backup reserve" + }, "wall_connector_fault_state": { "name": "Fault state code" }, @@ -178,6 +431,48 @@ "wall_connector_state": { "name": "State code" } + }, + "switch": { + "charge_state_user_charge_enable_request": { + "name": "Charge" + }, + "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_heat": { + "name": "Auto steering wheel heater" + }, + "climate_state_defrost_mode": { + "name": "Defrost" + }, + "components_disallow_charge_from_grid_with_solar_installed": { + "name": "Allow charging from grid" + }, + "user_settings_storm_mode_enabled": { + "name": "Storm watch" + }, + "vehicle_state_sentry_mode": { + "name": "Sentry mode" + }, + "vehicle_state_valet_mode": { + "name": "Valet mode" + } + }, + "update": { + "vehicle_state_software_update_status": { + "name": "[%key:component::update::title%]" + } + } + }, + "exceptions": { + "no_cable": { + "message": "Charge cable will lock automatically when connected" + }, + "invalid_cop_temp": { + "message": "Cabin overheat protection does not support that temperature" } } } diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py new file mode 100644 index 00000000000..3204d73410f --- /dev/null +++ b/homeassistant/components/teslemetry/switch.py @@ -0,0 +1,262 @@ +"""Switch platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from itertools import chain +from typing import Any + +from tesla_fleet_api.const import Scope, Seat + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .helpers import handle_command, handle_vehicle_command +from .models import TeslemetryEnergyData, TeslemetryVehicleData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeslemetrySwitchEntityDescription(SwitchEntityDescription): + """Describes Teslemetry Switch entity.""" + + on_func: Callable + off_func: Callable + scopes: list[Scope] + + +VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( + TeslemetrySwitchEntityDescription( + key="vehicle_state_sentry_mode", + on_func=lambda api: api.set_sentry_mode(on=True), + off_func=lambda api: api.set_sentry_mode(on=False), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslemetrySwitchEntityDescription( + key="climate_state_auto_seat_climate_left", + on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), + off_func=lambda api: api.remote_auto_seat_climate_request( + Seat.FRONT_LEFT, False + ), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslemetrySwitchEntityDescription( + key="climate_state_auto_seat_climate_right", + on_func=lambda api: api.remote_auto_seat_climate_request( + Seat.FRONT_RIGHT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + Seat.FRONT_RIGHT, False + ), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslemetrySwitchEntityDescription( + key="climate_state_auto_steering_wheel_heat", + on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( + on=True + ), + off_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( + on=False + ), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslemetrySwitchEntityDescription( + key="climate_state_defrost_mode", + on_func=lambda api: api.set_preconditioning_max(on=True, manual_override=False), + off_func=lambda api: api.set_preconditioning_max( + on=False, manual_override=False + ), + scopes=[Scope.VEHICLE_CMDS], + ), +) + +VEHICLE_CHARGE_DESCRIPTION = TeslemetrySwitchEntityDescription( + key="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry Switch platform from a config entry.""" + + async_add_entities( + chain( + ( + TeslemetryVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( + TeslemetryChargeSwitchEntity( + vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslemetryChargeFromGridSwitchEntity( + energysite, + entry.runtime_data.scopes, + ) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + and energysite.info_coordinator.data.get("components_solar") + ), + ( + TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_storm_mode_capable") + ), + ) + ) + + +class TeslemetrySwitchEntity(SwitchEntity): + """Base class for all Teslemetry switch entities.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: TeslemetrySwitchEntityDescription + + +class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEntity): + """Base class for Teslemetry vehicle switch entities.""" + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetrySwitchEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + super().__init__(data, description.key) + self.entity_description = description + self.scoped = any(scope in scopes for scope in description.scopes) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + if self._value is None: + self._attr_is_on = None + else: + self._attr_is_on = bool(self._value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.on_func(self.api)) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.off_func(self.api)) + self._attr_is_on = False + self.async_write_ha_state() + + +class TeslemetryChargeSwitchEntity(TeslemetryVehicleSwitchEntity): + """Entity class for Teslemetry charge switch.""" + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + if self._value is None: + self._attr_is_on = self.get("charge_state_charge_enable_request") + else: + self._attr_is_on = self._value + + +class TeslemetryChargeFromGridSwitchEntity( + TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity +): + """Entity class for Charge From Grid switch.""" + + def __init__( + self, + data: TeslemetryEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + self.scoped = Scope.ENERGY_CMDS in scopes + super().__init__( + data, "components_disallow_charge_from_grid_with_solar_installed" + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + # When disallow_charge_from_grid_with_solar_installed is missing, its Off. + # But this sensor is flipped to match how the Tesla app works. + self._attr_is_on = not self.get(self.key, False) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_scope() + await handle_command( + self.api.grid_import_export( + disallow_charge_from_grid_with_solar_installed=False + ) + ) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_scope() + await handle_command( + self.api.grid_import_export( + disallow_charge_from_grid_with_solar_installed=True + ) + ) + self._attr_is_on = False + self.async_write_ha_state() + + +class TeslemetryStormModeSwitchEntity( + TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity +): + """Entity class for Storm Mode switch.""" + + def __init__( + self, + data: TeslemetryEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + super().__init__(data, "user_settings_storm_mode_enabled") + self.scoped = Scope.ENERGY_CMDS in scopes + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = self._value is not None + self._attr_is_on = bool(self._value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_scope() + await handle_command(self.api.storm_mode(enabled=True)) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_scope() + await handle_command(self.api.storm_mode(enabled=False)) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py new file mode 100644 index 00000000000..de508fa58d4 --- /dev/null +++ b/homeassistant/components/teslemetry/update.py @@ -0,0 +1,110 @@ +"""Update platform for Teslemetry integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from tesla_fleet_api.const import Scope + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslemetryConfigEntry +from .entity import TeslemetryVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslemetryVehicleData + +AVAILABLE = "available" +DOWNLOADING = "downloading" +INSTALLING = "installing" +WIFI_WAIT = "downloading_wifi_wait" +SCHEDULED = "scheduled" + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslemetryConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Teslemetry update platform from a config entry.""" + + async_add_entities( + TeslemetryUpdateEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): + """Teslemetry Updates entity.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the Update.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "vehicle_state_software_update_status", + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + # Supported Features + if self.scoped and self._value in ( + AVAILABLE, + SCHEDULED, + ): + # Only allow install when an update has been fully downloaded + self._attr_supported_features = ( + UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL + ) + else: + self._attr_supported_features = UpdateEntityFeature.PROGRESS + + # Installed Version + self._attr_installed_version = self.get("vehicle_state_car_version") + if self._attr_installed_version is not None: + # Remove build from version + self._attr_installed_version = self._attr_installed_version.split(" ")[0] + + # Latest Version + if self._value in ( + AVAILABLE, + SCHEDULED, + INSTALLING, + DOWNLOADING, + WIFI_WAIT, + ): + self._attr_latest_version = self.coordinator.data[ + "vehicle_state_software_update_version" + ] + else: + self._attr_latest_version = self._attr_installed_version + + # In Progress + if self._value in ( + SCHEDULED, + INSTALLING, + ): + self._attr_in_progress = ( + cast(int, self.get("vehicle_state_software_update_install_perc")) + or True + ) + else: + self._attr_in_progress = False + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.schedule_software_update(offset_sec=60)) + self._attr_in_progress = True + self.async_write_ha_state() diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 6ac96fe8865..9e7bc42fa27 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -12,9 +12,8 @@ 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 +from .models import TessieData PLATFORMS = [ Platform.BINARY_SENSOR, @@ -33,8 +32,10 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) +type TessieConfigEntry = ConfigEntry[TessieData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bool: """Set up Tessie config.""" api_key = entry.data[CONF_ACCESS_TOKEN] @@ -52,28 +53,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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"], - ) + vehicles = [ + 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 + entry.runtime_data = TessieData(vehicles=vehicles) 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, entry: TessieConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 9b7d6861dfb..2d3f1134444 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -10,12 +10,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, TessieState +from . import TessieConfigEntry +from .const import TessieState from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -159,16 +159,17 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie binary sensor platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( - TessieBinarySensorEntity(vehicle.state_coordinator, description) - for vehicle in data + TessieBinarySensorEntity(vehicle, description) + for vehicle in data.vehicles for description in DESCRIPTIONS - if description.key in vehicle.state_coordinator.data ) diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index c357863bc4b..43dadec60e6 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -15,11 +15,10 @@ from tessie_api import ( ) 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 . import TessieConfigEntry from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -47,14 +46,16 @@ DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie Button platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( - TessieButtonEntity(vehicle.state_coordinator, description) - for vehicle in data + TessieButtonEntity(vehicle, description) + for vehicle in data.vehicles for description in DESCRIPTIONS ) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index 4c763726851..2a3b77ab8ce 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -17,25 +17,25 @@ from homeassistant.components.climate import ( 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 . import TessieConfigEntry +from .const import TessieClimateKeeper from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie Climate platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data - async_add_entities( - TessieClimateEntity(vehicle.state_coordinator) for vehicle in data - ) + async_add_entities(TessieClimateEntity(vehicle) for vehicle in data.vehicles) class TessieClimateEntity(TessieEntity, ClimateEntity): diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py index 5ab7280a90c..f3761d4c4ce 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -10,10 +10,11 @@ from aiohttp import ClientConnectionError, ClientResponseError from tessie_api import get_state_of_all_vehicles import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import TessieConfigEntry from .const import DOMAIN TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) @@ -29,7 +30,7 @@ class TessieConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self._reauth_entry: ConfigEntry | None = None + self._reauth_entry: TessieConfigEntry | None = None async def async_step_user( self, user_input: Mapping[str, Any] | None = None @@ -92,13 +93,9 @@ class TessieConfigFlow(ConfigFlow, domain=DOMAIN): except ClientConnectionError: errors["base"] = "cannot_connect" else: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( 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", diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py index 8d275559007..5be08107a29 100644 --- a/homeassistant/components/tessie/cover.py +++ b/homeassistant/components/tessie/cover.py @@ -18,30 +18,32 @@ from homeassistant.components.cover import ( 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 . import TessieConfigEntry +from .const import TessieCoverStates from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( - klass(vehicle.state_coordinator) + klass(vehicle) for klass in ( TessieWindowEntity, TessieChargePortEntity, TessieFrontTrunkEntity, TessieRearTrunkEntity, ) - for vehicle in data + for vehicle in data.vehicles ) diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index da979e5fc31..382c775c200 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -4,29 +4,30 @@ 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 . import TessieConfigEntry from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie device tracker platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( - klass(vehicle.state_coordinator) + klass(vehicle) for klass in ( TessieDeviceTrackerLocationEntity, TessieDeviceTrackerRouteEntity, ) - for vehicle in data + for vehicle in data.vehicles ) diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index e11a99348ed..35d41af32f2 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -46,7 +46,7 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): @property def _value(self) -> Any: """Return value from coordinator data.""" - return self.coordinator.data[self.key] + return self.coordinator.data.get(self.key) def get(self, key: str | None = None, default: Any | None = None) -> Any: """Return a specific value from coordinator data.""" diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 1e5653744fb..9457d476e32 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -15,37 +15,39 @@ from tessie_api import ( from homeassistant.components.automation import automations_with_entity from homeassistant.components.lock import ATTR_CODE, LockEntity from homeassistant.components.script import scripts_with_entity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import TessieConfigEntry from .const import DOMAIN, TessieChargeCableLockStates from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data entities = [ - klass(vehicle.state_coordinator) + klass(vehicle) for klass in (TessieLockEntity, TessieCableLockEntity) - for vehicle in data + for vehicle in data.vehicles ] ent_reg = er.async_get(hass) - for vehicle in data: + for vehicle in data.vehicles: entity_id = ent_reg.async_get_entity_id( Platform.LOCK, DOMAIN, - f"{vehicle.state_coordinator.vin}-vehicle_state_speed_limit_mode_active", + f"{vehicle.vin}-vehicle_state_speed_limit_mode_active", ) if entity_id: entity_entry = ent_reg.async_get(entity_id) @@ -53,7 +55,7 @@ async def async_setup_entry( if entity_entry.disabled: ent_reg.async_remove(entity_id) else: - entities.append(TessieSpeedLimitEntity(vehicle.state_coordinator)) + entities.append(TessieSpeedLimitEntity(vehicle)) entity_automations = automations_with_entity(hass, entity_id) entity_scripts = scripts_with_entity(hass, entity_id) diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index 2b20bf89152..f99c8ad1e1f 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -7,11 +7,10 @@ from homeassistant.components.media_player import ( 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 . import TessieConfigEntry from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -23,12 +22,14 @@ STATES = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie Media platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data - async_add_entities(TessieMediaEntity(vehicle.state_coordinator) for vehicle in data) + async_add_entities(TessieMediaEntity(vehicle) for vehicle in data.vehicles) class TessieMediaEntity(TessieEntity, MediaPlayerEntity): diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py index c17947ed941..3919db3f6d3 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -8,7 +8,7 @@ from .coordinator import TessieStateUpdateCoordinator @dataclass -class TessieVehicle: +class TessieData: """Data for the Tessie integration.""" - state_coordinator: TessieStateUpdateCoordinator + vehicles: list[TessieStateUpdateCoordinator] diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 196ea877f61..222922eba3e 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -13,7 +13,6 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, PRECISION_WHOLE, @@ -23,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import TessieConfigEntry from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -81,16 +80,17 @@ DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( - TessieNumberEntity(vehicle.state_coordinator, description) - for vehicle in data + TessieNumberEntity(vehicle, description) + for vehicle in data.vehicles for description in DESCRIPTIONS - if description.key in vehicle.state_coordinator.data ) diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index a7d8c42472d..801d465ea2a 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -5,11 +5,11 @@ 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 . import TessieConfigEntry +from .const import TessieSeatHeaterOptions from .entity import TessieEntity SEAT_HEATERS = { @@ -24,16 +24,18 @@ SEAT_HEATERS = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie select platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( - TessieSeatHeaterSelectEntity(vehicle.state_coordinator, key) - for vehicle in data + TessieSeatHeaterSelectEntity(vehicle, key) + for vehicle in data.vehicles for key in SEAT_HEATERS - if key in vehicle.state_coordinator.data + if key in vehicle.data # not all vehicles have rear center or third row ) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index dd893adb632..c3023948f4c 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -33,7 +32,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance -from .const import DOMAIN, TessieChargeStates +from . import TessieConfigEntry +from .const import TessieChargeStates from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -259,14 +259,16 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( - TessieSensorEntity(vehicle.state_coordinator, description) - for vehicle in data + TessieSensorEntity(vehicle, description) + for vehicle in data.vehicles for description in DESCRIPTIONS ) diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index 225d65bf852..2f3902b3bd3 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from itertools import chain from typing import Any from tessie_api import ( @@ -24,11 +25,10 @@ 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 DOMAIN +from . import TessieConfigEntry from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -42,11 +42,6 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( - TessieSwitchEntityDescription( - key="charge_state_charge_enable_request", - on_func=lambda: start_charging, - off_func=lambda: stop_charging, - ), TessieSwitchEntityDescription( key="climate_state_defrost_mode", on_func=lambda: start_defrost, @@ -69,20 +64,33 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( ), ) +CHARGE_DESCRIPTION: TessieSwitchEntityDescription = TessieSwitchEntityDescription( + key="charge_state_charge_enable_request", + on_func=lambda: start_charging, + off_func=lambda: stop_charging, +) + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + 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 - ] + chain( + ( + TessieSwitchEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in DESCRIPTIONS + if description.key in vehicle.data + ), + ( + TessieChargeSwitchEntity(vehicle, CHARGE_DESCRIPTION) + for vehicle in entry.runtime_data.vehicles + ), + ) ) @@ -115,3 +123,15 @@ class TessieSwitchEntity(TessieEntity, SwitchEntity): """Turn off the Switch.""" await self.run(self.entity_description.off_func()) self.set((self.entity_description.key, False)) + + +class TessieChargeSwitchEntity(TessieSwitchEntity): + """Entity class for Tessie charge switch.""" + + @property + def is_on(self) -> bool: + """Return the state of the Switch.""" + + if (charge := self.get("charge_state_user_charge_enable_request")) is not None: + return charge + return self._value diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index 77cb2a70de9..5f51a38d77d 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -7,24 +7,24 @@ 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 . import TessieConfigEntry +from .const import TessieUpdateStatus from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TessieConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Tessie Update platform from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data - async_add_entities( - TessieUpdateEntity(vehicle.state_coordinator) for vehicle in data - ) + async_add_entities(TessieUpdateEntity(vehicle) for vehicle in data.vehicles) class TessieUpdateEntity(TessieEntity, UpdateEntity): diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 82cab559d0e..1389d5aa500 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -3,6 +3,9 @@ "device_automation": { "action_type": { "set_value": "Set value for {entity_name}" + }, + "extra_fields": { + "value": "[%key:common::device_automation::extra_fields::value%]" } }, "entity_component": { diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py index 6bf2e00c420..53e86f37f11 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -129,7 +129,9 @@ async def async_setup_entry( class ThermoBeaconBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a ThermoBeacon sensor.""" diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index 21915ca9998..4aca6101685 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -127,7 +127,9 @@ async def async_setup_entry( class ThermoProBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a thermopro ble sensor.""" diff --git a/homeassistant/components/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py index 32850d05e57..253ce7a052e 100644 --- a/homeassistant/components/thethingsnetwork/__init__.py +++ b/homeassistant/components/thethingsnetwork/__init__.py @@ -1,29 +1,28 @@ """Support for The Things network.""" +import logging + import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -CONF_ACCESS_KEY = "access_key" -CONF_APP_ID = "app_id" +from .const import CONF_APP_ID, DOMAIN, PLATFORMS, TTN_API_HOST +from .coordinator import TTNCoordinator -DATA_TTN = "data_thethingsnetwork" -DOMAIN = "thethingsnetwork" - -TTN_ACCESS_KEY = "ttn_access_key" -TTN_APP_ID = "ttn_app_id" -TTN_DATA_STORAGE_URL = ( - "https://{app_id}.data.thethingsnetwork.org/{endpoint}/{device_id}" -) +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { + # Configuration via yaml not longer supported - keeping to warn about migration DOMAIN: vol.Schema( { vol.Required(CONF_APP_ID): cv.string, - vol.Required(CONF_ACCESS_KEY): cv.string, + vol.Required("access_key"): cv.string, } ) }, @@ -33,10 +32,57 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize of The Things Network component.""" - conf = config[DOMAIN] - app_id = conf.get(CONF_APP_ID) - access_key = conf.get(CONF_ACCESS_KEY) - hass.data[DATA_TTN] = {TTN_ACCESS_KEY: access_key, TTN_APP_ID: app_id} + if DOMAIN in config: + ir.async_create_issue( + hass, + DOMAIN, + "manual_migration", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="manual_migration", + translation_placeholders={ + "domain": DOMAIN, + "v2_v3_migration_url": "https://www.thethingsnetwork.org/forum/c/v2-to-v3-upgrade/102", + "v2_deprecation_url": "https://www.thethingsnetwork.org/forum/t/the-things-network-v2-is-permanently-shutting-down-completed/50710", + }, + ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Establish connection with The Things Network.""" + + _LOGGER.debug( + "Set up %s at %s", + entry.data[CONF_API_KEY], + entry.data.get(CONF_HOST, TTN_API_HOST), + ) + + coordinator = TTNCoordinator(hass, entry) + + 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.""" + + _LOGGER.debug( + "Remove %s at %s", + entry.data[CONF_API_KEY], + entry.data.get(CONF_HOST, TTN_API_HOST), + ) + + # Unload entities created for each supported platform + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return True diff --git a/homeassistant/components/thethingsnetwork/config_flow.py b/homeassistant/components/thethingsnetwork/config_flow.py new file mode 100644 index 00000000000..cbb780e7064 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/config_flow.py @@ -0,0 +1,108 @@ +"""The Things Network's integration config flow.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from ttn_client import TTNAuthError, TTNClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_APP_ID, DOMAIN, TTN_API_HOST + +_LOGGER = logging.getLogger(__name__) + + +class TTNFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated config flow.""" + + errors = {} + if user_input is not None: + client = TTNClient( + user_input[CONF_HOST], + user_input[CONF_APP_ID], + user_input[CONF_API_KEY], + 0, + ) + try: + await client.fetch_data() + except TTNAuthError: + _LOGGER.exception("Error authenticating with The Things Network") + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown error occurred") + errors["base"] = "unknown" + + if not errors: + # Create entry + if self._reauth_entry: + return self.async_update_reload_and_abort( + self._reauth_entry, + data=user_input, + reason="reauth_successful", + ) + await self.async_set_unique_id(user_input[CONF_APP_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=str(user_input[CONF_APP_ID]), + data=user_input, + ) + + # Show form for user to provide settings + if not user_input: + if self._reauth_entry: + user_input = self._reauth_entry.data + else: + user_input = {CONF_HOST: TTN_API_HOST} + + schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_APP_ID): str, + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, autocomplete="api_key" + ) + ), + } + ), + user_input, + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a flow initialized by a reauth event.""" + + 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 + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/thethingsnetwork/const.py b/homeassistant/components/thethingsnetwork/const.py new file mode 100644 index 00000000000..1a0b5da7184 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/const.py @@ -0,0 +1,12 @@ +"""The Things Network's integration constants.""" + +from homeassistant.const import Platform + +DOMAIN = "thethingsnetwork" +TTN_API_HOST = "eu1.cloud.thethings.network" + +PLATFORMS = [Platform.SENSOR] + +CONF_APP_ID = "app_id" + +POLLING_PERIOD_S = 60 diff --git a/homeassistant/components/thethingsnetwork/coordinator.py b/homeassistant/components/thethingsnetwork/coordinator.py new file mode 100644 index 00000000000..64608c2f064 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/coordinator.py @@ -0,0 +1,66 @@ +"""The Things Network's integration DataUpdateCoordinator.""" + +from datetime import timedelta +import logging + +from ttn_client import TTNAuthError, TTNClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_APP_ID, POLLING_PERIOD_S + +_LOGGER = logging.getLogger(__name__) + + +class TTNCoordinator(DataUpdateCoordinator[TTNClient.DATA_TYPE]): + """TTN coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=f"TheThingsNetwork_{entry.data[CONF_APP_ID]}", + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta( + seconds=POLLING_PERIOD_S, + ), + ) + + self._client = TTNClient( + entry.data[CONF_HOST], + entry.data[CONF_APP_ID], + entry.data[CONF_API_KEY], + push_callback=self._push_callback, + ) + + async def _async_update_data(self) -> TTNClient.DATA_TYPE: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + measurements = await self._client.fetch_data() + except TTNAuthError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + _LOGGER.error("TTNAuthError") + raise ConfigEntryAuthFailed from err + else: + # Return measurements + _LOGGER.debug("fetched data: %s", measurements) + return measurements + + async def _push_callback(self, data: TTNClient.DATA_TYPE) -> None: + _LOGGER.debug("pushed data: %s", data) + + # Push data to entities + self.async_set_updated_data(data) diff --git a/homeassistant/components/thethingsnetwork/entity.py b/homeassistant/components/thethingsnetwork/entity.py new file mode 100644 index 00000000000..0a86f153cc9 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/entity.py @@ -0,0 +1,71 @@ +"""Support for The Things Network entities.""" + +import logging + +from ttn_client import TTNBaseValue + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TTNCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class TTNEntity(CoordinatorEntity[TTNCoordinator]): + """Representation of a The Things Network Data Storage sensor.""" + + _attr_has_entity_name = True + _ttn_value: TTNBaseValue + + def __init__( + self, + coordinator: TTNCoordinator, + app_id: str, + ttn_value: TTNBaseValue, + ) -> None: + """Initialize a The Things Network Data Storage sensor.""" + + # Pass coordinator to CoordinatorEntity + super().__init__(coordinator) + + self._ttn_value = ttn_value + + self._attr_unique_id = f"{self.device_id}_{self.field_id}" + self._attr_name = self.field_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{app_id}_{self.device_id}")}, + name=self.device_id, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + my_entity_update = self.coordinator.data.get(self.device_id, {}).get( + self.field_id + ) + if ( + my_entity_update + and my_entity_update.received_at > self._ttn_value.received_at + ): + _LOGGER.debug( + "Received update for %s: %s", self.unique_id, my_entity_update + ) + # Check that the type of an entity has not changed since the creation + assert isinstance(my_entity_update, type(self._ttn_value)) + self._ttn_value = my_entity_update + self.async_write_ha_state() + + @property + def device_id(self) -> str: + """Return device_id.""" + return str(self._ttn_value.device_id) + + @property + def field_id(self) -> str: + """Return field_id.""" + return str(self._ttn_value.field_id) diff --git a/homeassistant/components/thethingsnetwork/manifest.json b/homeassistant/components/thethingsnetwork/manifest.json index 4b298a33198..bc132d171f2 100644 --- a/homeassistant/components/thethingsnetwork/manifest.json +++ b/homeassistant/components/thethingsnetwork/manifest.json @@ -1,7 +1,10 @@ { "domain": "thethingsnetwork", "name": "The Things Network", - "codeowners": ["@fabaff"], + "codeowners": ["@angelnu"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/thethingsnetwork", - "iot_class": "local_push" + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["ttn_client==1.0.0"] } diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index ae4fed8600e..82dd169a52d 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -1,165 +1,56 @@ -"""Support for The Things Network's Data storage integration.""" +"""The Things Network's integration sensors.""" -from __future__ import annotations - -import asyncio -from http import HTTPStatus import logging -import aiohttp -from aiohttp.hdrs import ACCEPT, AUTHORIZATION -import voluptuous as vol +from ttn_client import TTNSensorValue -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_DEVICE_ID, - ATTR_TIME, - CONF_DEVICE_ID, - CONTENT_TYPE_JSON, -) +from homeassistant.components.sensor import SensorEntity, StateType +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_TTN, TTN_ACCESS_KEY, TTN_APP_ID, TTN_DATA_STORAGE_URL +from .const import CONF_APP_ID, DOMAIN +from .entity import TTNEntity _LOGGER = logging.getLogger(__name__) -ATTR_RAW = "raw" -DEFAULT_TIMEOUT = 10 -CONF_VALUES = "values" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(CONF_VALUES): {cv.string: cv.string}, - } -) - - -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 The Things Network Data storage sensors.""" - ttn = hass.data[DATA_TTN] - device_id = config[CONF_DEVICE_ID] - values = config[CONF_VALUES] - app_id = ttn.get(TTN_APP_ID) - access_key = ttn.get(TTN_ACCESS_KEY) + """Add entities for TTN.""" - ttn_data_storage = TtnDataStorage(hass, app_id, device_id, access_key, values) - success = await ttn_data_storage.async_update() + coordinator = hass.data[DOMAIN][entry.entry_id] - if not success: - return + sensors: set[tuple[str, str]] = set() - devices = [] - for value, unit_of_measurement in values.items(): - devices.append( - TtnDataSensor(ttn_data_storage, device_id, value, unit_of_measurement) - ) - async_add_entities(devices, True) + def _async_measurement_listener() -> None: + data = coordinator.data + new_sensors = { + (device_id, field_id): TtnDataSensor( + coordinator, + entry.data[CONF_APP_ID], + ttn_value, + ) + for device_id, device_uplinks in data.items() + for field_id, ttn_value in device_uplinks.items() + if (device_id, field_id) not in sensors + and isinstance(ttn_value, TTNSensorValue) + } + if len(new_sensors): + async_add_entities(new_sensors.values()) + sensors.update(new_sensors.keys()) + + entry.async_on_unload(coordinator.async_add_listener(_async_measurement_listener)) + _async_measurement_listener() -class TtnDataSensor(SensorEntity): - """Representation of a The Things Network Data Storage sensor.""" +class TtnDataSensor(TTNEntity, SensorEntity): + """Represents a TTN Home Assistant Sensor.""" - def __init__(self, ttn_data_storage, device_id, value, unit_of_measurement): - """Initialize a The Things Network Data Storage sensor.""" - self._ttn_data_storage = ttn_data_storage - self._state = None - self._device_id = device_id - self._unit_of_measurement = unit_of_measurement - self._value = value - self._name = f"{self._device_id} {self._value}" + _ttn_value: TTNSensorValue @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the entity.""" - if self._ttn_data_storage.data is not None: - try: - return self._state[self._value] - except KeyError: - return None - return None - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - if self._ttn_data_storage.data is not None: - return { - ATTR_DEVICE_ID: self._device_id, - ATTR_RAW: self._state["raw"], - ATTR_TIME: self._state["time"], - } - - async def async_update(self) -> None: - """Get the current state.""" - await self._ttn_data_storage.async_update() - self._state = self._ttn_data_storage.data - - -class TtnDataStorage: - """Get the latest data from The Things Network Data Storage.""" - - def __init__(self, hass, app_id, device_id, access_key, values): - """Initialize the data object.""" - self.data = None - self._hass = hass - self._app_id = app_id - self._device_id = device_id - self._values = values - self._url = TTN_DATA_STORAGE_URL.format( - app_id=app_id, endpoint="api/v2/query", device_id=device_id - ) - self._headers = {ACCEPT: CONTENT_TYPE_JSON, AUTHORIZATION: f"key {access_key}"} - - async def async_update(self): - """Get the current state from The Things Network Data Storage.""" - try: - session = async_get_clientsession(self._hass) - async with asyncio.timeout(DEFAULT_TIMEOUT): - response = await session.get(self._url, headers=self._headers) - - except (TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error while accessing: %s", self._url) - return None - - status = response.status - - if status == HTTPStatus.NO_CONTENT: - _LOGGER.error("The device is not available: %s", self._device_id) - return None - - if status == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("Not authorized for Application ID: %s", self._app_id) - return None - - if status == HTTPStatus.NOT_FOUND: - _LOGGER.error("Application ID is not available: %s", self._app_id) - return None - - data = await response.json() - self.data = data[-1] - - for value in self._values.items(): - if value[0] not in self.data: - _LOGGER.warning("Value not available: %s", value[0]) - - return response + return self._ttn_value.value diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json new file mode 100644 index 00000000000..98572cb318c --- /dev/null +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to The Things Network v3 App", + "description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.", + "data": { + "hostname": "[%key:common::config_flow::data::host%]", + "app_id": "Application ID", + "access_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth_confirm": { + "description": "The Things Network application could not be connected.\n\nPlease check your credentials." + } + }, + "abort": { + "already_configured": "Application ID is already configured", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "manual_migration": { + "description": "Configuring {domain} using YAML was removed as part of migrating to [The Things Network v3]({v2_v3_migration_url}). [The Things Network v2 has shutted down]({v2_deprecation_url}).\n\nPlease remove the {domain} entry from the configuration.yaml and add re-add the integration using the config_flow", + "title": "The {domain} YAML configuration is not supported" + } + } +} diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 2ba5505c6f3..544260a1e34 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -107,10 +107,10 @@ class ThomsonDeviceScanner(DeviceScanner): telnet.write(b"exit\r\n") except EOFError: _LOGGER.exception("Unexpected response from router") - return + return None except ConnectionRefusedError: _LOGGER.exception("Connection refused by router. Telnet enabled?") - return + return None devices = {} for device in devices_result: diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 49a77e9c87b..4f0df6b1533 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -19,12 +19,14 @@ _LOGGER = logging.getLogger(__name__) KNOWN_BRANDS: dict[str | None, str] = { "Amazon": "amazon", "Apple Inc.": "apple", + "Aqara": "aqara_gateway", "eero": "eero", "Google Inc.": "google", "HomeAssistant": "homeassistant", "Home Assistant": "homeassistant", "Nanoleaf": "nanoleaf", "OpenThread": "openthread", + "Samsung": "samsung", } THREAD_TYPE = "_meshcop._udp.local." CLASS_IN = 1 diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 687c4067caf..d436a5ffb72 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -44,9 +44,7 @@ async def ws_add_dataset( try: await dataset_store.async_add_dataset(hass, source, tlv) except TLVError as exc: - connection.send_error( - msg["id"], websocket_api.const.ERR_INVALID_FORMAT, str(exc) - ) + connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(exc)) return connection.send_result(msg["id"]) @@ -94,9 +92,7 @@ async def ws_set_preferred_dataset( try: store.preferred_dataset = dataset_id except KeyError: - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown dataset" - ) + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "unknown dataset") return connection.send_result(msg["id"]) @@ -120,10 +116,10 @@ async def ws_delete_dataset( try: store.async_delete(dataset_id) except KeyError as exc: - connection.send_error(msg["id"], websocket_api.const.ERR_NOT_FOUND, str(exc)) + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(exc)) return except dataset_store.DatasetPreferredError as exc: - connection.send_error(msg["id"], websocket_api.const.ERR_NOT_ALLOWED, str(exc)) + connection.send_error(msg["id"], websocket_api.ERR_NOT_ALLOWED, str(exc)) return connection.send_result(msg["id"]) @@ -145,9 +141,7 @@ async def ws_get_dataset( store = await dataset_store.async_get_store(hass) if not (dataset := store.async_get(dataset_id)): - connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown dataset" - ) + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "unknown dataset") return connection.send_result(msg["id"], {"tlv": dataset.tlv}) diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 2ca1410a890..ea8b469fd32 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -1,12 +1,22 @@ """The threshold component.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device import ( + async_remove_stale_devices_links_keep_entity_device, +) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Min/Max from a config entry.""" + + async_remove_stale_devices_links_keep_entity_device( + hass, + entry.entry_id, + entry.options[CONF_ENTITY_ID], + ) + await hass.config_entries.async_forward_entry_setups( entry, (Platform.BINARY_SENSOR,) ) @@ -18,6 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 9674357eb60..8c3882ff360 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable, Mapping import logging from typing import Any @@ -22,12 +23,15 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, ) +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -80,27 +84,10 @@ async def async_setup_entry( registry, config_entry.options[CONF_ENTITY_ID] ) - source_entity = registry.async_get(entity_id) - dev_reg = dr.async_get(hass) - # Resolve source entity device - if ( - (source_entity is not None) - and (source_entity.device_id is not None) - and ( - ( - device := dev_reg.async_get( - device_id=source_entity.device_id, - ) - ) - is not None - ) - ): - device_info = DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, - ) - else: - device_info = None + device_info = async_device_info_to_link_from_entity( + hass, + entity_id, + ) hysteresis = config_entry.options[CONF_HYSTERESIS] lower = config_entry.options[CONF_LOWER] @@ -111,7 +98,6 @@ async def async_setup_entry( async_add_entities( [ ThresholdSensor( - hass, entity_id, name, lower, @@ -145,7 +131,7 @@ async def async_setup_platform( async_add_entities( [ ThresholdSensor( - hass, entity_id, name, lower, upper, hysteresis, device_class, None + entity_id, name, lower, upper, hysteresis, device_class, None ) ], ) @@ -167,7 +153,6 @@ class ThresholdSensor(BinarySensorEntity): def __init__( self, - hass: HomeAssistant, entity_id: str, name: str, lower: float | None, @@ -178,6 +163,7 @@ class ThresholdSensor(BinarySensorEntity): device_info: DeviceInfo | None = None, ) -> None: """Initialize the Threshold sensor.""" + self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None self._attr_unique_id = unique_id self._attr_device_info = device_info self._entity_id = entity_id @@ -193,9 +179,17 @@ class ThresholdSensor(BinarySensorEntity): self._state: bool | None = None self.sensor_value: float | None = None + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._async_setup_sensor() + + @callback + def _async_setup_sensor(self) -> None: + """Set up the sensor and start tracking state changes.""" + def _update_sensor_state() -> None: """Handle sensor state changes.""" - if (new_state := hass.states.get(self._entity_id)) is None: + if (new_state := self.hass.states.get(self._entity_id)) is None: return try: @@ -210,17 +204,26 @@ class ThresholdSensor(BinarySensorEntity): self._update_state() + if self._preview_callback: + calculated_state = self._async_calculate_state() + self._preview_callback( + calculated_state.state, calculated_state.attributes + ) + @callback def async_threshold_sensor_state_listener( event: Event[EventStateChangedData], ) -> None: """Handle sensor state changes.""" _update_sensor_state() - self.async_write_ha_state() + + # only write state to the state machine if we are not in preview mode + if not self._preview_callback: + self.async_write_ha_state() self.async_on_remove( async_track_state_change_event( - hass, [entity_id], async_threshold_sensor_state_listener + self.hass, [self._entity_id], async_threshold_sensor_state_listener ) ) _update_sensor_state() @@ -305,3 +308,26 @@ class ThresholdSensor(BinarySensorEntity): self._state_position = POSITION_IN_RANGE self._state = True return + + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + # abort early if there is no entity_id + # as without we can't track changes + # or if neither lower nor upper thresholds are set + if not self._entity_id or ( + not hasattr(self, "_threshold_lower") + and not hasattr(self, "_threshold_upper") + ): + self._attr_available = False + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) + return self._call_on_remove_callbacks + + self._preview_callback = preview_callback + + self._async_setup_sensor() + return self._call_on_remove_callbacks diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index a8e330cab38..24f58333782 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -7,8 +7,11 @@ from typing import Any import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_ENTITY_ID, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -17,6 +20,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, ) +from .binary_sensor import ThresholdSensor from .const import CONF_HYSTERESIS, CONF_LOWER, CONF_UPPER, DEFAULT_HYSTERESIS, DOMAIN @@ -48,24 +52,28 @@ OPTIONS_SCHEMA = vol.Schema( mode=selector.NumberSelectorMode.BOX, step="any" ), ), + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain=SENSOR_DOMAIN) + ), } ) CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), - vol.Required(CONF_ENTITY_ID): selector.EntitySelector( - selector.EntitySelectorConfig(domain=SENSOR_DOMAIN) - ), } ).extend(OPTIONS_SCHEMA.schema) CONFIG_FLOW = { - "user": SchemaFlowFormStep(CONFIG_SCHEMA, validate_user_input=_validate_mode) + "user": SchemaFlowFormStep( + CONFIG_SCHEMA, preview="threshold", validate_user_input=_validate_mode + ) } OPTIONS_FLOW = { - "init": SchemaFlowFormStep(OPTIONS_SCHEMA, validate_user_input=_validate_mode) + "init": SchemaFlowFormStep( + OPTIONS_SCHEMA, preview="threshold", validate_user_input=_validate_mode + ) } @@ -79,3 +87,61 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Return config entry title.""" name: str = options[CONF_NAME] return name + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "threshold/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@callback +def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + + if msg["flow_type"] == "config_flow": + entity_id = msg["user_input"][CONF_ENTITY_ID] + name = msg["user_input"][CONF_NAME] + else: + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + if not config_entry: + raise HomeAssistantError("Config entry not found") + entity_id = config_entry.options[CONF_ENTITY_ID] + name = config_entry.options[CONF_NAME] + + @callback + def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: + """Forward config entry state events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], {"attributes": attributes, "state": state} + ) + ) + + preview_entity = ThresholdSensor( + entity_id, + name, + msg["user_input"].get(CONF_LOWER), + msg["user_input"].get(CONF_UPPER), + msg["user_input"].get(CONF_HYSTERESIS), + None, + None, + ) + preview_entity.hass = hass + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( + async_preview_updated + ) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 7305cf835c5..51d6f0560f1 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -21,8 +21,9 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from .const import DATA_HASS_CONFIG, DOMAIN +from .services import async_setup_services -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -33,6 +34,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Tibber component.""" hass.data[DATA_HASS_CONFIG] = config + + async_setup_services(hass) + return True @@ -42,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: tibber_connection = tibber.Tibber( access_token=entry.data[CONF_ACCESS_TOKEN], websession=async_get_clientsession(hass), - time_zone=dt_util.DEFAULT_TIME_ZONE, + time_zone=dt_util.get_default_time_zone(), ) hass.data[DOMAIN] = tibber_connection @@ -68,8 +72,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # set up notify platform, no entry support for notify component yet, - # have to use discovery to load platform. + # Use discovery to load platform legacy notify platform + # The use of the legacy notify service was deprecated with HA Core 2024.6 + # Support will be removed with HA Core 2024.12 hass.async_create_task( discovery.async_load_platform( hass, @@ -79,6 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DATA_HASS_CONFIG], ) ) + return True diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py new file mode 100644 index 00000000000..c3746cb9a58 --- /dev/null +++ b/homeassistant/components/tibber/coordinator.py @@ -0,0 +1,163 @@ +"""Coordinator for Tibber sensors.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import cast + +import tibber + +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN as TIBBER_DOMAIN + +FIVE_YEARS = 5 * 365 * 24 + +_LOGGER = logging.getLogger(__name__) + + +class TibberDataCoordinator(DataUpdateCoordinator[None]): + """Handle Tibber data and insert statistics.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, tibber_connection: tibber.Tibber) -> None: + """Initialize the data handler.""" + super().__init__( + hass, + _LOGGER, + name=f"Tibber {tibber_connection.name}", + update_interval=timedelta(minutes=20), + ) + self._tibber_connection = tibber_connection + + async def _async_update_data(self) -> None: + """Update data via API.""" + try: + await self._tibber_connection.fetch_consumption_data_active_homes() + await self._tibber_connection.fetch_production_data_active_homes() + await self._insert_statistics() + except tibber.RetryableHttpException as err: + raise UpdateFailed(f"Error communicating with API ({err.status})") from err + except tibber.FatalHttpException: + # Fatal error. Reload config entry to show correct error. + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + + async def _insert_statistics(self) -> None: + """Insert Tibber statistics.""" + for home in self._tibber_connection.get_homes(): + sensors: list[tuple[str, bool, str]] = [] + if home.hourly_consumption_data: + sensors.append(("consumption", False, UnitOfEnergy.KILO_WATT_HOUR)) + sensors.append(("totalCost", False, home.currency)) + if home.hourly_production_data: + sensors.append(("production", True, UnitOfEnergy.KILO_WATT_HOUR)) + sensors.append(("profit", True, home.currency)) + + for sensor_type, is_production, unit in sensors: + statistic_id = ( + f"{TIBBER_DOMAIN}:energy_" + f"{sensor_type.lower()}_" + f"{home.home_id.replace('-', '')}" + ) + + last_stats = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, statistic_id, True, set() + ) + + if not last_stats: + # First time we insert 5 years of data (if available) + hourly_data = await home.get_historic_data( + 5 * 365 * 24, production=is_production + ) + + _sum = 0.0 + last_stats_time = None + else: + # hourly_consumption/production_data contains the last 30 days + # of consumption/production data. + # We update the statistics with the last 30 days + # of data to handle corrections in the data. + hourly_data = ( + home.hourly_production_data + if is_production + else home.hourly_consumption_data + ) + + from_time = dt_util.parse_datetime(hourly_data[0]["from"]) + if from_time is None: + continue + start = from_time - timedelta(hours=1) + stat = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + start, + None, + {statistic_id}, + "hour", + None, + {"sum"}, + ) + if statistic_id in stat: + first_stat = stat[statistic_id][0] + _sum = cast(float, first_stat["sum"]) + last_stats_time = first_stat["start"] + else: + hourly_data = await home.get_historic_data( + FIVE_YEARS, production=is_production + ) + _sum = 0.0 + last_stats_time = None + + statistics = [] + + last_stats_time_dt = ( + dt_util.utc_from_timestamp(last_stats_time) + if last_stats_time + else None + ) + + for data in hourly_data: + if data.get(sensor_type) is None: + continue + + from_time = dt_util.parse_datetime(data["from"]) + if from_time is None or ( + last_stats_time_dt is not None + and from_time <= last_stats_time_dt + ): + continue + + _sum += data[sensor_type] + + statistics.append( + StatisticData( + start=from_time, + state=data[sensor_type], + sum=_sum, + ) + ) + + metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{home.name} {sensor_type}", + source=TIBBER_DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=unit, + ) + async_add_external_statistics(self.hass, metadata, statistics) diff --git a/homeassistant/components/tibber/icons.json b/homeassistant/components/tibber/icons.json new file mode 100644 index 00000000000..c6cdd9b0e25 --- /dev/null +++ b/homeassistant/components/tibber/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "get_prices": "mdi:cash" + } +} diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index b0816de39e2..1c9f86ed502 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -3,21 +3,26 @@ from __future__ import annotations from collections.abc import Callable -import logging from typing import Any +from tibber import Tibber + from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, + NotifyEntity, + NotifyEntityFeature, + migrate_notify_issue, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as TIBBER_DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_get_service( hass: HomeAssistant, @@ -25,10 +30,17 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> TibberNotificationService: """Get the Tibber notification service.""" - tibber_connection = hass.data[TIBBER_DOMAIN] + tibber_connection: Tibber = hass.data[TIBBER_DOMAIN] return TibberNotificationService(tibber_connection.send_notification) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tibber notification entity.""" + async_add_entities([TibberNotificationEntity(entry.entry_id)]) + + class TibberNotificationService(BaseNotificationService): """Implement the notification service for Tibber.""" @@ -38,8 +50,41 @@ class TibberNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to Tibber devices.""" + migrate_notify_issue( + self.hass, + TIBBER_DOMAIN, + "Tibber", + "2024.12.0", + service_name=self._service_name, + ) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) try: await self._notify(title=title, message=message) - except TimeoutError: - _LOGGER.error("Timeout sending message with Tibber") + except TimeoutError as exc: + raise HomeAssistantError( + translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" + ) from exc + + +class TibberNotificationEntity(NotifyEntity): + """Implement the notification entity service for Tibber.""" + + _attr_supported_features = NotifyEntityFeature.TITLE + _attr_name = TIBBER_DOMAIN + _attr_icon = "mdi:message-flash" + + def __init__(self, unique_id: str) -> None: + """Initialize Tibber notify entity.""" + self._attr_unique_id = unique_id + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to Tibber devices.""" + tibber_connection: Tibber = self.hass.data[TIBBER_DOMAIN] + try: + await tibber_connection.send_notification( + title or ATTR_TITLE_DEFAULT, message + ) + except TimeoutError as exc: + raise HomeAssistantError( + translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" + ) from exc diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 7da0a2b7947..a9090add49b 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -2,22 +2,16 @@ from __future__ import annotations +from collections.abc import Callable import datetime from datetime import timedelta import logging from random import randrange -from typing import Any, cast +from typing import Any import aiohttp import tibber -from homeassistant.components.recorder import get_instance -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData -from homeassistant.components.recorder.statistics import ( - async_add_external_statistics, - get_last_statistics, - statistics_during_period, -) from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -37,23 +31,18 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.device_registry import ( - DeviceInfo, - async_get as async_get_dev_reg, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, - UpdateFailed, ) from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN as TIBBER_DOMAIN, MANUFACTURER - -FIVE_YEARS = 5 * 365 * 24 +from .coordinator import TibberDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -278,8 +267,8 @@ async def async_setup_entry( tibber_connection = hass.data[TIBBER_DOMAIN] - entity_registry = async_get_entity_reg(hass) - device_registry = async_get_dev_reg(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) coordinator: TibberDataCoordinator | None = None entities: list[TibberSensor] = [] @@ -303,9 +292,12 @@ async def async_setup_entry( ) if home.has_real_time_consumption: + entity_creator = TibberRtEntityCreator( + async_add_entities, home, entity_registry + ) await home.rt_subscribe( TibberRtDataCoordinator( - async_add_entities, home, hass + entity_creator.add_sensors, home, hass ).async_set_updated_data ) @@ -351,8 +343,8 @@ class TibberSensor(SensorEntity): self._home_name = tibber_home.info["viewer"]["home"]["address"].get( "address1", "" ) - self._device_name: None | str = None - self._model: None | str = None + self._device_name: str | None = None + self._model: str | None = None @property def device_info(self) -> DeviceInfo: @@ -444,7 +436,7 @@ class TibberSensorElPrice(TibberSensor): ]["estimatedAnnualConsumption"] -class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]): +class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): """Representation of a Tibber sensor.""" def __init__( @@ -532,38 +524,20 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) self.async_write_ha_state() -class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module - """Handle Tibber realtime data.""" +class TibberRtEntityCreator: + """Create realtime Tibber entities.""" def __init__( self, async_add_entities: AddEntitiesCallback, tibber_home: tibber.TibberHome, - hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Initialize the data handler.""" self._async_add_entities = async_add_entities self._tibber_home = tibber_home - self.hass = hass self._added_sensors: set[str] = set() - super().__init__( - hass, - _LOGGER, - name=tibber_home.info["viewer"]["home"]["address"].get( - "address1", "Tibber" - ), - ) - - self._async_remove_device_updates_handler = self.async_add_listener( - self._add_sensors - ) - self.entity_registry = async_get_entity_reg(hass) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) - - @callback - def _handle_ha_stop(self, _event: Event) -> None: - """Handle Home Assistant stopping.""" - self._async_remove_device_updates_handler() + self._entity_registry = entity_registry @callback def _migrate_unique_id(self, sensor_description: SensorEntityDescription) -> None: @@ -573,19 +547,19 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en description_key = sensor_description.key entity_id: str | None = None if translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE: - entity_id = self.entity_registry.async_get_entity_id( + entity_id = self._entity_registry.async_get_entity_id( "sensor", TIBBER_DOMAIN, f"{home_id}_rt_{translation_key.replace('_', ' ')}", ) elif translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION: - entity_id = self.entity_registry.async_get_entity_id( + entity_id = self._entity_registry.async_get_entity_id( "sensor", TIBBER_DOMAIN, f"{home_id}_rt_{RT_SENSORS_UNIQUE_ID_MIGRATION[translation_key]}", ) elif translation_key != description_key: - entity_id = self.entity_registry.async_get_entity_id( + entity_id = self._entity_registry.async_get_entity_id( "sensor", TIBBER_DOMAIN, f"{home_id}_rt_{translation_key}", @@ -602,18 +576,17 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en new_unique_id, ) try: - self.entity_registry.async_update_entity( + self._entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) except ValueError as err: _LOGGER.error(err) @callback - def _add_sensors(self) -> None: + def add_sensors( + self, coordinator: TibberRtDataCoordinator, live_measurement: Any + ) -> None: """Add sensor.""" - if not (live_measurement := self.get_live_measurement()): - return - new_entities = [] for sensor_description in RT_SENSORS: if sensor_description.key in self._added_sensors: @@ -627,151 +600,52 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en self._tibber_home, sensor_description, state, - self, + coordinator, ) new_entities.append(entity) self._added_sensors.add(sensor_description.key) if new_entities: self._async_add_entities(new_entities) + +class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module + """Handle Tibber realtime data.""" + + def __init__( + self, + add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None], + tibber_home: tibber.TibberHome, + hass: HomeAssistant, + ) -> None: + """Initialize the data handler.""" + self._add_sensor_callback = add_sensor_callback + super().__init__( + hass, + _LOGGER, + name=tibber_home.info["viewer"]["home"]["address"].get( + "address1", "Tibber" + ), + ) + + self._async_remove_device_updates_handler = self.async_add_listener( + self._data_updated + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) + + @callback + def _handle_ha_stop(self, _event: Event) -> None: + """Handle Home Assistant stopping.""" + self._async_remove_device_updates_handler() + + @callback + def _data_updated(self) -> None: + """Triggered when data is updated.""" + if live_measurement := self.get_live_measurement(): + self._add_sensor_callback(self, live_measurement) + def get_live_measurement(self) -> Any: """Get live measurement data.""" if errors := self.data.get("errors"): _LOGGER.error(errors[0]) return None return self.data.get("data", {}).get("liveMeasurement") - - -class TibberDataCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """Handle Tibber data and insert statistics.""" - - config_entry: ConfigEntry - - def __init__(self, hass: HomeAssistant, tibber_connection: tibber.Tibber) -> None: - """Initialize the data handler.""" - super().__init__( - hass, - _LOGGER, - name=f"Tibber {tibber_connection.name}", - update_interval=timedelta(minutes=20), - ) - self._tibber_connection = tibber_connection - - async def _async_update_data(self) -> None: - """Update data via API.""" - try: - await self._tibber_connection.fetch_consumption_data_active_homes() - await self._tibber_connection.fetch_production_data_active_homes() - await self._insert_statistics() - except tibber.RetryableHttpException as err: - raise UpdateFailed(f"Error communicating with API ({err.status})") from err - except tibber.FatalHttpException: - # Fatal error. Reload config entry to show correct error. - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) - - async def _insert_statistics(self) -> None: - """Insert Tibber statistics.""" - for home in self._tibber_connection.get_homes(): - sensors: list[tuple[str, bool, str]] = [] - if home.hourly_consumption_data: - sensors.append(("consumption", False, UnitOfEnergy.KILO_WATT_HOUR)) - sensors.append(("totalCost", False, home.currency)) - if home.hourly_production_data: - sensors.append(("production", True, UnitOfEnergy.KILO_WATT_HOUR)) - sensors.append(("profit", True, home.currency)) - - for sensor_type, is_production, unit in sensors: - statistic_id = ( - f"{TIBBER_DOMAIN}:energy_" - f"{sensor_type.lower()}_" - f"{home.home_id.replace('-', '')}" - ) - - last_stats = await get_instance(self.hass).async_add_executor_job( - get_last_statistics, self.hass, 1, statistic_id, True, set() - ) - - if not last_stats: - # First time we insert 5 years of data (if available) - hourly_data = await home.get_historic_data( - 5 * 365 * 24, production=is_production - ) - - _sum = 0.0 - last_stats_time = None - else: - # hourly_consumption/production_data contains the last 30 days - # of consumption/production data. - # We update the statistics with the last 30 days - # of data to handle corrections in the data. - hourly_data = ( - home.hourly_production_data - if is_production - else home.hourly_consumption_data - ) - - from_time = dt_util.parse_datetime(hourly_data[0]["from"]) - if from_time is None: - continue - start = from_time - timedelta(hours=1) - stat = await get_instance(self.hass).async_add_executor_job( - statistics_during_period, - self.hass, - start, - None, - {statistic_id}, - "hour", - None, - {"sum"}, - ) - if statistic_id in stat: - first_stat = stat[statistic_id][0] - _sum = cast(float, first_stat["sum"]) - last_stats_time = first_stat["start"] - else: - hourly_data = await home.get_historic_data( - FIVE_YEARS, production=is_production - ) - _sum = 0.0 - last_stats_time = None - - statistics = [] - - last_stats_time_dt = ( - dt_util.utc_from_timestamp(last_stats_time) - if last_stats_time - else None - ) - - for data in hourly_data: - if data.get(sensor_type) is None: - continue - - from_time = dt_util.parse_datetime(data["from"]) - if from_time is None or ( - last_stats_time_dt is not None - and from_time <= last_stats_time_dt - ): - continue - - _sum += data[sensor_type] - - statistics.append( - StatisticData( - start=from_time, - state=data[sensor_type], - sum=_sum, - ) - ) - - metadata = StatisticMetaData( - has_mean=False, - has_sum=True, - name=f"{home.name} {sensor_type}", - source=TIBBER_DOMAIN, - statistic_id=statistic_id, - unit_of_measurement=unit, - ) - async_add_external_statistics(self.hass, metadata, statistics) diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py new file mode 100644 index 00000000000..82353bb78d7 --- /dev/null +++ b/homeassistant/components/tibber/services.py @@ -0,0 +1,106 @@ +"""Services for Tibber integration.""" + +from __future__ import annotations + +import datetime as dt +from datetime import date, datetime +from functools import partial +from typing import Any, Final + +import voluptuous as vol + +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + +PRICE_SERVICE_NAME = "get_prices" +ATTR_START: Final = "start" +ATTR_END: Final = "end" + +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) + + +async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResponse: + tibber_connection = hass.data[DOMAIN] + + start = __get_date(call.data.get(ATTR_START), "start") + end = __get_date(call.data.get(ATTR_END), "end") + + if start >= end: + end = start + dt.timedelta(days=1) + + tibber_prices: dict[str, Any] = {} + + for tibber_home in tibber_connection.get_homes(only_active=True): + home_nickname = tibber_home.name + + price_info = tibber_home.info["viewer"]["home"]["currentSubscription"][ + "priceInfo" + ] + price_data = [ + { + "start_time": dt.datetime.fromisoformat(price["startsAt"]), + "price": price["total"], + "level": price["level"], + } + for key in ("today", "tomorrow") + for price in price_info[key] + ] + + selected_data = [ + price + for price in price_data + if price["start_time"].replace(tzinfo=None) >= start + and price["start_time"].replace(tzinfo=None) < end + ] + tibber_prices[home_nickname] = selected_data + + return {"prices": tibber_prices} + + +def __get_date(date_input: str | None, mode: str | None) -> date | datetime: + """Get date.""" + if not date_input: + if mode == "end": + increment = dt.timedelta(days=1) + else: + increment = dt.timedelta() + return datetime.fromisoformat(dt_util.now().date().isoformat()) + increment + + 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, + }, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Tibber integration.""" + + hass.services.async_register( + DOMAIN, + PRICE_SERVICE_NAME, + partial(__get_prices, hass=hass), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/tibber/services.yaml b/homeassistant/components/tibber/services.yaml new file mode 100644 index 00000000000..0a4413aa54e --- /dev/null +++ b/homeassistant/components/tibber/services.yaml @@ -0,0 +1,12 @@ +get_prices: + fields: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 23:00:00" + selector: + datetime: diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index af14c96674d..8d73d435c8c 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -84,6 +84,22 @@ } } }, + "services": { + "get_prices": { + "name": "Get energy prices", + "description": "Get hourly energy prices from Tibber", + "fields": { + "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 the last hour of today if omitted." + } + } + } + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" @@ -101,5 +117,10 @@ "description": "Enter your access token from {url}" } } + }, + "exceptions": { + "send_message_timeout": { + "message": "Timeout sending message with Tibber" + } } } diff --git a/homeassistant/components/tilt_ble/sensor.py b/homeassistant/components/tilt_ble/sensor.py index 380bb90ca15..e8e1f902cd9 100644 --- a/homeassistant/components/tilt_ble/sensor.py +++ b/homeassistant/components/tilt_ble/sensor.py @@ -102,7 +102,9 @@ async def async_setup_entry( class TiltBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[ + PassiveBluetoothDataProcessor[float | int | None, SensorUpdate] + ], SensorEntity, ): """Representation of a Tilt Hydrometer BLE sensor.""" diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index f65978144c6..9ae98992acb 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -35,7 +35,7 @@ USER_SCHEMA = vol.Schema( { vol.Required(CONF_DISPLAY_OPTIONS): SelectSelector( SelectSelectorConfig( - options=[option for option in OPTION_TYPES if option != "beat"], + options=OPTION_TYPES, mode=SelectSelectorMode.DROPDOWN, translation_key="display_options", ) diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py index 5d13ec0203c..53656bae181 100644 --- a/homeassistant/components/time_date/const.py +++ b/homeassistant/components/time_date/const.py @@ -18,6 +18,5 @@ OPTION_TYPES = [ "date_time_utc", "date_time_iso", "time_date", - "beat", "time_utc", ] diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 57bb87e6ea5..ed999e5a0b2 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -20,11 +20,10 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time -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 .const import DOMAIN, OPTION_TYPES +from .const import OPTION_TYPES _LOGGER = logging.getLogger(__name__) @@ -51,23 +50,6 @@ async def async_setup_platform( _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return False - if "beat" in config[CONF_DISPLAY_OPTIONS]: - async_create_issue( - hass, - DOMAIN, - "deprecated_beat", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_beat", - translation_placeholders={ - "config_key": "beat", - "display_options": "display_options", - "integration": DOMAIN, - }, - ) - _LOGGER.warning("'beat': is deprecated and will be removed in version 2024.7") - async_add_entities( [TimeDateSensor(variable) for variable in config[CONF_DISPLAY_OPTIONS]] ) @@ -95,8 +77,7 @@ class TimeDateSensor(SensorEntity): """Initialize the sensor.""" self._attr_translation_key = option_type self.type = option_type - object_id = "internet_time" if option_type == "beat" else option_type - self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self.entity_id = ENTITY_ID_FORMAT.format(option_type) self._attr_unique_id = option_type if entry_id else None self._update_internal_state(dt_util.utcnow()) @@ -169,13 +150,8 @@ class TimeDateSensor(SensorEntity): tomorrow = dt_util.as_local(time_date) + timedelta(days=1) return dt_util.start_of_local_day(tomorrow) - if self.type == "beat": - # Add 1 hour because @0 beats is at 23:00:00 UTC. - timestamp = dt_util.as_timestamp(time_date + timedelta(hours=1)) - interval = 86.4 - else: - timestamp = dt_util.as_timestamp(time_date) - interval = 60 + timestamp = dt_util.as_timestamp(time_date) + interval = 60 delta = interval - (timestamp % interval) next_interval = time_date + timedelta(seconds=delta) @@ -201,21 +177,6 @@ class TimeDateSensor(SensorEntity): self._state = f"{time}, {date}" elif self.type == "time_utc": self._state = time_utc - elif self.type == "beat": - # Calculate Swatch Internet Time. - time_bmt = time_date + timedelta(hours=1) - delta = timedelta( - hours=time_bmt.hour, - minutes=time_bmt.minute, - seconds=time_bmt.second, - microseconds=time_bmt.microsecond, - ) - - # Use integers to better handle rounding. For example, - # int(63763.2/86.4) = 737 but 637632//864 = 738. - beat = int(delta.total_seconds() * 10) // 864 - - self._state = f"@{beat:03d}" elif self.type == "date_time_iso": self._state = dt_util.parse_datetime( f"{date} {time}", raise_on_error=True diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json index e9efe949b9b..adf37253f90 100644 --- a/homeassistant/components/time_date/strings.json +++ b/homeassistant/components/time_date/strings.json @@ -66,18 +66,5 @@ "name": "[%key:component::time_date::selector::display_options::options::time_utc%]" } } - }, - "issues": { - "deprecated_beat": { - "title": "The `{config_key}` Time & Date sensor is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::time_date::issues::deprecated_beat::title%]", - "description": "Please remove the `{config_key}` key from the {integration} config entry options and click submit to fix this issue." - } - } - } - } } } diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 5da68d99dd6..8927439a6cc 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import Any, Self, TypeVar +from typing import Any, Self import voluptuous as vol @@ -29,7 +29,6 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -_T = TypeVar("_T") _LOGGER = logging.getLogger(__name__) DOMAIN = "timer" @@ -82,7 +81,7 @@ def _format_timedelta(delta: timedelta) -> str: return f"{int(hours)}:{int(minutes):02}:{int(seconds):02}" -def _none_to_empty_dict(value: _T | None) -> _T | dict[Any, Any]: +def _none_to_empty_dict[_T](value: _T | None) -> _T | dict[Any, Any]: if value is None: return {} return value diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index c35f92fd27f..5b6c7077a97 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -36,7 +36,7 @@ from .const import ( CONF_BEFORE_TIME, ) -SunEventType = Literal["sunrise", "sunset"] +type SunEventType = Literal["sunrise", "sunset"] _LOGGER = logging.getLogger(__name__) @@ -148,7 +148,7 @@ class TodSensor(BinarySensorEntity): assert self._time_after is not None assert self._time_before is not None assert self._next_update is not None - if time_zone := dt_util.get_time_zone(self.hass.config.time_zone): + if time_zone := dt_util.get_default_time_zone(): return { ATTR_AFTER: self._time_after.astimezone(time_zone).isoformat(), ATTR_BEFORE: self._time_before.astimezone(time_zone).isoformat(), @@ -160,9 +160,7 @@ class TodSensor(BinarySensorEntity): """Convert naive time from config to utc_datetime with current day.""" # get the current local date from utc time current_local_date = ( - dt_util.utcnow() - .astimezone(dt_util.get_time_zone(self.hass.config.time_zone)) - .date() + dt_util.utcnow().astimezone(dt_util.get_default_time_zone()).date() ) # calculate utc datetime corresponding to local time return dt_util.as_utc(datetime.combine(current_local_date, naive_time)) diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index 81d5ca2ae0c..50afe916b27 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -4,7 +4,6 @@ from __future__ import annotations from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity @@ -21,7 +20,9 @@ class ListAddItemIntent(intent.IntentHandler): """Handle ListAddItem intents.""" intent_type = INTENT_LIST_ADD_ITEM - slot_schema = {"item": cv.string, "name": cv.string} + description = "Add item to a todo list" + slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string} + platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" @@ -35,18 +36,19 @@ class ListAddItemIntent(intent.IntentHandler): target_list: TodoListEntity | None = None # Find matching list - for list_state in intent.async_match_states( - hass, name=list_name, domains=[DOMAIN] - ): - target_list = component.get_entity(list_state.entity_id) - if target_list is not None: - break + match_constraints = intent.MatchTargetsConstraints( + name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + target_list = component.get_entity(match_result.states[0].entity_id) if target_list is None: raise intent.IntentHandleError(f"No to-do list: {list_name}") - assert target_list is not None - # Add to list await target_list.async_create_todo_item( TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 9b8d0a7c08f..e3f87043e02 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -66,6 +66,7 @@ _LOGGER = logging.getLogger(__name__) NEW_TASK_SERVICE_SCHEMA = vol.Schema( { vol.Required(CONTENT): cv.string, + vol.Optional(DESCRIPTION): cv.string, vol.Optional(PROJECT_NAME, default="inbox"): vol.All(cv.string, vol.Lower), vol.Optional(LABELS): cv.ensure_list_csv, vol.Optional(ASSIGNEE): cv.string, @@ -225,6 +226,8 @@ def async_register_services( content = call.data[CONTENT] data: dict[str, Any] = {"project_id": project_id} + if description := call.data.get(DESCRIPTION): + data["description"] = description if task_labels := call.data.get(LABELS): data["labels"] = task_labels diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 745f1775e87..2d17cf9e7d4 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -46,7 +46,7 @@ class TodoistConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_api_key" else: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/todoist/services.yaml b/homeassistant/components/todoist/services.yaml index 9593b6bb6a4..1bd6320ebe3 100644 --- a/homeassistant/components/todoist/services.yaml +++ b/homeassistant/components/todoist/services.yaml @@ -5,6 +5,9 @@ new_task: example: Pick up the mail. selector: text: + description: + selector: + text: project: example: Errands default: Inbox diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 0f81702a4d0..0cc74c9c8c6 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -29,6 +29,10 @@ "name": "Content", "description": "The name of the task." }, + "description": { + "name": "Description", + "description": "A description for the task." + }, "project": { "name": "Project", "description": "The name of the project this task should belong to." diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index 5fdcdea6c30..ed53015ccb4 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -91,7 +91,7 @@ class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): # pylin return ToloSaunaData(status, settings) -class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): # pylint: disable=hass-enforce-coordinator-module +class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): """CoordinatorEntity for TOLO Sauna.""" _attr_has_entity_name = True diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 3ff811369fd..5fd99e86cb4 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -2,129 +2,24 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -from math import ceil -from typing import Any - from pytomorrowio import TomorrowioV4 -from pytomorrowio.const import CURRENT, FORECASTS -from pytomorrowio.exceptions import ( - CantConnectException, - InvalidAPIKeyException, - RateLimitedException, - UnknownException, -) +from pytomorrowio.const import CURRENT from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LOCATION, - CONF_LONGITUDE, -) -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTRIBUTION, - CONF_TIMESTEP, - DOMAIN, - INTEGRATION_NAME, - LOGGER, - TMRW_ATTR_CARBON_MONOXIDE, - TMRW_ATTR_CHINA_AQI, - TMRW_ATTR_CHINA_HEALTH_CONCERN, - TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, - TMRW_ATTR_CLOUD_BASE, - TMRW_ATTR_CLOUD_CEILING, - TMRW_ATTR_CLOUD_COVER, - TMRW_ATTR_CONDITION, - TMRW_ATTR_DEW_POINT, - TMRW_ATTR_EPA_AQI, - TMRW_ATTR_EPA_HEALTH_CONCERN, - TMRW_ATTR_EPA_PRIMARY_POLLUTANT, - TMRW_ATTR_FEELS_LIKE, - TMRW_ATTR_FIRE_INDEX, - TMRW_ATTR_HUMIDITY, - TMRW_ATTR_NITROGEN_DIOXIDE, - TMRW_ATTR_OZONE, - TMRW_ATTR_PARTICULATE_MATTER_10, - TMRW_ATTR_PARTICULATE_MATTER_25, - TMRW_ATTR_POLLEN_GRASS, - TMRW_ATTR_POLLEN_TREE, - TMRW_ATTR_POLLEN_WEED, - TMRW_ATTR_PRECIPITATION, - TMRW_ATTR_PRECIPITATION_PROBABILITY, - TMRW_ATTR_PRECIPITATION_TYPE, - TMRW_ATTR_PRESSURE, - TMRW_ATTR_PRESSURE_SURFACE_LEVEL, - TMRW_ATTR_SOLAR_GHI, - TMRW_ATTR_SULPHUR_DIOXIDE, - TMRW_ATTR_TEMPERATURE, - TMRW_ATTR_TEMPERATURE_HIGH, - TMRW_ATTR_TEMPERATURE_LOW, - TMRW_ATTR_UV_HEALTH_CONCERN, - TMRW_ATTR_UV_INDEX, - TMRW_ATTR_VISIBILITY, - TMRW_ATTR_WIND_DIRECTION, - TMRW_ATTR_WIND_GUST, - TMRW_ATTR_WIND_SPEED, -) +from .const import ATTRIBUTION, DOMAIN, INTEGRATION_NAME +from .coordinator import TomorrowioDataUpdateCoordinator PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] -@callback -def async_get_entries_by_api_key( - hass: HomeAssistant, api_key: str, exclude_entry: ConfigEntry | None = None -) -> list[ConfigEntry]: - """Get all entries for a given API key.""" - return [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_API_KEY] == api_key - and (exclude_entry is None or exclude_entry != entry) - ] - - -@callback -def async_set_update_interval( - hass: HomeAssistant, api: TomorrowioV4, exclude_entry: ConfigEntry | None = None -) -> timedelta: - """Calculate update_interval.""" - # We check how many Tomorrow.io configured instances are using the same API key and - # calculate interval to not exceed allowed numbers of requests. Divide 90% of - # max_requests by the number of API calls because we want a buffer in the - # number of API calls left at the end of the day. - entries = async_get_entries_by_api_key(hass, api.api_key, exclude_entry) - minutes = ceil( - (24 * 60 * len(entries) * api.num_api_requests) - / (api.max_requests_per_day * 0.9) - ) - LOGGER.debug( - ( - "Number of config entries: %s\n" - "Number of API Requests per call: %s\n" - "Max requests per day: %s\n" - "Update interval: %s minutes" - ), - len(entries), - api.num_api_requests, - api.max_requests_per_day, - minutes, - ) - return timedelta(minutes=minutes) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tomorrow.io API from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -164,166 +59,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module - """Define an object to hold Tomorrow.io data.""" - - def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: - """Initialize.""" - self._api = api - self.data = {CURRENT: {}, FORECASTS: {}} - self.entry_id_to_location_dict: dict[str, str] = {} - self._coordinator_ready: asyncio.Event | None = None - - super().__init__(hass, LOGGER, name=f"{DOMAIN}_{self._api.api_key_masked}") - - def add_entry_to_location_dict(self, entry: ConfigEntry) -> None: - """Add an entry to the location dict.""" - latitude = entry.data[CONF_LOCATION][CONF_LATITUDE] - longitude = entry.data[CONF_LOCATION][CONF_LONGITUDE] - self.entry_id_to_location_dict[entry.entry_id] = f"{latitude},{longitude}" - - async def async_setup_entry(self, entry: ConfigEntry) -> None: - """Load config entry into coordinator.""" - # If we haven't loaded any data yet, register all entries with this API key and - # get the initial data for all of them. We do this because another config entry - # may start setup before we finish setting the initial data and we don't want - # to do multiple refreshes on startup. - if self._coordinator_ready is None: - LOGGER.debug( - "Setting up coordinator for API key %s, loading data for all entries", - self._api.api_key_masked, - ) - self._coordinator_ready = asyncio.Event() - for entry_ in async_get_entries_by_api_key(self.hass, self._api.api_key): - self.add_entry_to_location_dict(entry_) - LOGGER.debug( - "Loaded %s entries, initiating first refresh", - len(self.entry_id_to_location_dict), - ) - await self.async_config_entry_first_refresh() - self._coordinator_ready.set() - else: - # If we have an event, we need to wait for it to be set before we proceed - await self._coordinator_ready.wait() - # If we're not getting new data because we already know this entry, we - # don't need to schedule a refresh - if entry.entry_id in self.entry_id_to_location_dict: - return - LOGGER.debug( - ( - "Adding new entry to existing coordinator for API key %s, doing a " - "partial refresh" - ), - self._api.api_key_masked, - ) - # We need a refresh, but it's going to be a partial refresh so we can - # minimize repeat API calls - self.add_entry_to_location_dict(entry) - await self.async_refresh() - - self.update_interval = async_set_update_interval(self.hass, self._api) - self._async_unsub_refresh() - if self._listeners: - self._schedule_refresh() - - async def async_unload_entry(self, entry: ConfigEntry) -> bool | None: - """Unload a config entry from coordinator. - - Returns whether coordinator can be removed as well because there are no - config entries tied to it anymore. - """ - self.entry_id_to_location_dict.pop(entry.entry_id) - self.update_interval = async_set_update_interval(self.hass, self._api, entry) - return not self.entry_id_to_location_dict - - async def _async_update_data(self) -> dict[str, Any]: - """Update data via library.""" - data: dict[str, Any] = {} - # If we are refreshing because of a new config entry that's not already in our - # data, we do a partial refresh to avoid wasted API calls. - if self.data and any( - entry_id not in self.data for entry_id in self.entry_id_to_location_dict - ): - data = self.data - - LOGGER.debug( - "Fetching data for %s entries", - len(set(self.entry_id_to_location_dict) - set(data)), - ) - for entry_id, location in self.entry_id_to_location_dict.items(): - if entry_id in data: - continue - entry = self.hass.config_entries.async_get_entry(entry_id) - assert entry - try: - data[entry_id] = await self._api.realtime_and_all_forecasts( - [ - # Weather - TMRW_ATTR_TEMPERATURE, - TMRW_ATTR_HUMIDITY, - TMRW_ATTR_PRESSURE, - TMRW_ATTR_WIND_SPEED, - TMRW_ATTR_WIND_DIRECTION, - TMRW_ATTR_CONDITION, - TMRW_ATTR_VISIBILITY, - TMRW_ATTR_OZONE, - TMRW_ATTR_WIND_GUST, - TMRW_ATTR_CLOUD_COVER, - TMRW_ATTR_PRECIPITATION_TYPE, - # Sensors - TMRW_ATTR_CARBON_MONOXIDE, - TMRW_ATTR_CHINA_AQI, - TMRW_ATTR_CHINA_HEALTH_CONCERN, - TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, - TMRW_ATTR_CLOUD_BASE, - TMRW_ATTR_CLOUD_CEILING, - TMRW_ATTR_CLOUD_COVER, - TMRW_ATTR_DEW_POINT, - TMRW_ATTR_EPA_AQI, - TMRW_ATTR_EPA_HEALTH_CONCERN, - TMRW_ATTR_EPA_PRIMARY_POLLUTANT, - TMRW_ATTR_FEELS_LIKE, - TMRW_ATTR_FIRE_INDEX, - TMRW_ATTR_NITROGEN_DIOXIDE, - TMRW_ATTR_OZONE, - TMRW_ATTR_PARTICULATE_MATTER_10, - TMRW_ATTR_PARTICULATE_MATTER_25, - TMRW_ATTR_POLLEN_GRASS, - TMRW_ATTR_POLLEN_TREE, - TMRW_ATTR_POLLEN_WEED, - TMRW_ATTR_PRECIPITATION_TYPE, - TMRW_ATTR_PRESSURE_SURFACE_LEVEL, - TMRW_ATTR_SOLAR_GHI, - TMRW_ATTR_SULPHUR_DIOXIDE, - TMRW_ATTR_UV_INDEX, - TMRW_ATTR_UV_HEALTH_CONCERN, - TMRW_ATTR_WIND_GUST, - ], - [ - TMRW_ATTR_TEMPERATURE_LOW, - TMRW_ATTR_TEMPERATURE_HIGH, - TMRW_ATTR_DEW_POINT, - TMRW_ATTR_HUMIDITY, - TMRW_ATTR_WIND_SPEED, - TMRW_ATTR_WIND_DIRECTION, - TMRW_ATTR_CONDITION, - TMRW_ATTR_PRECIPITATION, - TMRW_ATTR_PRECIPITATION_PROBABILITY, - ], - nowcast_timestep=entry.options[CONF_TIMESTEP], - location=location, - ) - except ( - CantConnectException, - InvalidAPIKeyException, - RateLimitedException, - UnknownException, - ) as error: - raise UpdateFailed from error - - return data - - class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): """Base Tomorrow.io Entity.""" diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 1a8cd328045..90bb488a7c2 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -160,7 +160,7 @@ class TomorrowioConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_API_KEY] = "invalid_api_key" except RateLimitedException: errors[CONF_API_KEY] = "rate_limited" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/tomorrowio/coordinator.py b/homeassistant/components/tomorrowio/coordinator.py new file mode 100644 index 00000000000..60b997e4c0d --- /dev/null +++ b/homeassistant/components/tomorrowio/coordinator.py @@ -0,0 +1,273 @@ +"""The Tomorrow.io integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from math import ceil +from typing import Any + +from pytomorrowio import TomorrowioV4 +from pytomorrowio.const import CURRENT, FORECASTS +from pytomorrowio.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_TIMESTEP, + DOMAIN, + LOGGER, + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_CONDITION, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_NITROGEN_DIOXIDE, + TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_UV_HEALTH_CONCERN, + TMRW_ATTR_UV_INDEX, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_WIND_SPEED, +) + + +@callback +def async_get_entries_by_api_key( + hass: HomeAssistant, api_key: str, exclude_entry: ConfigEntry | None = None +) -> list[ConfigEntry]: + """Get all entries for a given API key.""" + return [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_API_KEY] == api_key + and (exclude_entry is None or exclude_entry != entry) + ] + + +@callback +def async_set_update_interval( + hass: HomeAssistant, api: TomorrowioV4, exclude_entry: ConfigEntry | None = None +) -> timedelta: + """Calculate update_interval.""" + # We check how many Tomorrow.io configured instances are using the same API key and + # calculate interval to not exceed allowed numbers of requests. Divide 90% of + # max_requests by the number of API calls because we want a buffer in the + # number of API calls left at the end of the day. + entries = async_get_entries_by_api_key(hass, api.api_key, exclude_entry) + minutes = ceil( + (24 * 60 * len(entries) * api.num_api_requests) + / (api.max_requests_per_day * 0.9) + ) + LOGGER.debug( + ( + "Number of config entries: %s\n" + "Number of API Requests per call: %s\n" + "Max requests per day: %s\n" + "Update interval: %s minutes" + ), + len(entries), + api.num_api_requests, + api.max_requests_per_day, + minutes, + ) + return timedelta(minutes=minutes) + + +class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Define an object to hold Tomorrow.io data.""" + + def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: + """Initialize.""" + self._api = api + self.data = {CURRENT: {}, FORECASTS: {}} + self.entry_id_to_location_dict: dict[str, str] = {} + self._coordinator_ready: asyncio.Event | None = None + + super().__init__(hass, LOGGER, name=f"{DOMAIN}_{self._api.api_key_masked}") + + def add_entry_to_location_dict(self, entry: ConfigEntry) -> None: + """Add an entry to the location dict.""" + latitude = entry.data[CONF_LOCATION][CONF_LATITUDE] + longitude = entry.data[CONF_LOCATION][CONF_LONGITUDE] + self.entry_id_to_location_dict[entry.entry_id] = f"{latitude},{longitude}" + + async def async_setup_entry(self, entry: ConfigEntry) -> None: + """Load config entry into coordinator.""" + # If we haven't loaded any data yet, register all entries with this API key and + # get the initial data for all of them. We do this because another config entry + # may start setup before we finish setting the initial data and we don't want + # to do multiple refreshes on startup. + if self._coordinator_ready is None: + LOGGER.debug( + "Setting up coordinator for API key %s, loading data for all entries", + self._api.api_key_masked, + ) + self._coordinator_ready = asyncio.Event() + for entry_ in async_get_entries_by_api_key(self.hass, self._api.api_key): + self.add_entry_to_location_dict(entry_) + LOGGER.debug( + "Loaded %s entries, initiating first refresh", + len(self.entry_id_to_location_dict), + ) + await self.async_config_entry_first_refresh() + self._coordinator_ready.set() + else: + # If we have an event, we need to wait for it to be set before we proceed + await self._coordinator_ready.wait() + # If we're not getting new data because we already know this entry, we + # don't need to schedule a refresh + if entry.entry_id in self.entry_id_to_location_dict: + return + LOGGER.debug( + ( + "Adding new entry to existing coordinator for API key %s, doing a " + "partial refresh" + ), + self._api.api_key_masked, + ) + # We need a refresh, but it's going to be a partial refresh so we can + # minimize repeat API calls + self.add_entry_to_location_dict(entry) + await self.async_refresh() + + self.update_interval = async_set_update_interval(self.hass, self._api) + self._async_unsub_refresh() + if self._listeners: + self._schedule_refresh() + + async def async_unload_entry(self, entry: ConfigEntry) -> bool | None: + """Unload a config entry from coordinator. + + Returns whether coordinator can be removed as well because there are no + config entries tied to it anymore. + """ + self.entry_id_to_location_dict.pop(entry.entry_id) + self.update_interval = async_set_update_interval(self.hass, self._api, entry) + return not self.entry_id_to_location_dict + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + data: dict[str, Any] = {} + # If we are refreshing because of a new config entry that's not already in our + # data, we do a partial refresh to avoid wasted API calls. + if self.data and any( + entry_id not in self.data for entry_id in self.entry_id_to_location_dict + ): + data = self.data + + LOGGER.debug( + "Fetching data for %s entries", + len(set(self.entry_id_to_location_dict) - set(data)), + ) + for entry_id, location in self.entry_id_to_location_dict.items(): + if entry_id in data: + continue + entry = self.hass.config_entries.async_get_entry(entry_id) + assert entry + try: + data[entry_id] = await self._api.realtime_and_all_forecasts( + [ + # Weather + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_PRECIPITATION_TYPE, + # Sensors + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, + TMRW_ATTR_NITROGEN_DIOXIDE, + TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_UV_INDEX, + TMRW_ATTR_UV_HEALTH_CONCERN, + TMRW_ATTR_WIND_GUST, + ], + [ + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + ], + nowcast_timestep=entry.options[CONF_TIMESTEP], + location=location, + ) + except ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, + ) as error: + raise UpdateFailed from error + + return data diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index f3ca5302b2a..cfe2d870ccb 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -38,7 +38,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity +from . import TomorrowioEntity from .const import ( DOMAIN, TMRW_ATTR_CARBON_MONOXIDE, @@ -69,6 +69,7 @@ from .const import ( TMRW_ATTR_UV_INDEX, TMRW_ATTR_WIND_GUST, ) +from .coordinator import TomorrowioDataUpdateCoordinator @dataclass(frozen=True) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 3b60f171bbe..e77a798f1e4 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -37,7 +37,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util -from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity +from . import TomorrowioEntity from .const import ( CLEAR_CONDITIONS, CONDITIONS, @@ -60,6 +60,7 @@ from .const import ( TMRW_ATTR_WIND_DIRECTION, TMRW_ATTR_WIND_SPEED, ) +from .coordinator import TomorrowioDataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index cd4e55fd050..0dd740544df 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -4,19 +4,16 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from toonapi import ToonConnectionError, ToonError from .models import ToonEntity -_ToonEntityT = TypeVar("_ToonEntityT", bound=ToonEntity) -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) -def toon_exception_handler( +def toon_exception_handler[_ToonEntityT: ToonEntity, **_P]( func: Callable[Concatenate[_ToonEntityT, _P], Coroutine[Any, Any, None]], ) -> Callable[Concatenate[_ToonEntityT, _P], Coroutine[Any, Any, None]]: """Decorate Toon calls to handle Toon exceptions. diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 511a0fd6270..17a16674dd5 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -74,6 +74,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False def __init__( self, diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index e3f9b9ba6b3..b590c54e2ba 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -21,7 +21,6 @@ TO_REDACT = [ ] # Private variable access needed for diagnostics -# pylint: disable=protected-access async def async_get_config_entry_diagnostics( @@ -33,17 +32,17 @@ async def async_get_config_entry_diagnostics( data: dict[str, Any] = {} data["client"] = { "auto_bypass_low_battery": client.auto_bypass_low_battery, - "module_flags": client._module_flags, + "module_flags": client._module_flags, # noqa: SLF001 "retry_delay": client.retry_delay, - "invalid_credentials": client._invalid_credentials, + "invalid_credentials": client._invalid_credentials, # noqa: SLF001 } data["user"] = { - "master": client._user._master_user, - "user_admin": client._user._user_admin, - "config_admin": client._user._config_admin, - "security_problem": client._user.security_problem(), - "features": client._user._features, + "master": client._user._master_user, # noqa: SLF001 + "user_admin": client._user._user_admin, # noqa: SLF001 + "config_admin": client._user._config_admin, # noqa: SLF001 + "security_problem": client._user.security_problem(), # noqa: SLF001 + "features": client._user._features, # noqa: SLF001 } data["locations"] = [] @@ -51,7 +50,7 @@ async def async_get_config_entry_diagnostics( new_location = { "location_id": location.location_id, "name": location.location_name, - "module_flags": location._module_flags, + "module_flags": location._module_flags, # noqa: SLF001 "security_device_id": location.security_device_id, "ac_loss": location.ac_loss, "low_battery": location.low_battery, diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 23766e69257..52b226a1c57 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from kasa import ( AuthenticationException, @@ -20,11 +20,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator -_T = TypeVar("_T", bound="CoordinatedTPLinkEntity") -_P = ParamSpec("_P") - -def async_refresh_after( +def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a wrapper to raise HA errors and refresh after.""" diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index fa022fcac77..19b3d58dbd4 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -19,7 +19,12 @@ from .config_flow import CONF_SITE, create_omada_client from .const import DOMAIN from .controller import OmadaSiteController -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SWITCH, Platform.UPDATE] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.SWITCH, + Platform.UPDATE, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -50,10 +55,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway_coordinator = await controller.get_gateway_coordinator() if gateway_coordinator: await gateway_coordinator.async_config_entry_first_refresh() + await controller.get_clients_coordinator().async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index 4666968924d..5ea56a9ad9f 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -218,7 +218,7 @@ class TpLinkOmadaConfigFlow(ConfigFlow, domain=DOMAIN): except OmadaClientException as ex: _LOGGER.error("Unexpected API error: %s", ex) errors["base"] = "unknown" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return None diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index c9842f93a5a..d92a6f37e24 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,58 +1,15 @@ """Controller for sharing Omada API coordinators between platforms.""" from tplink_omada_client import OmadaSiteClient -from tplink_omada_client.devices import ( - OmadaGateway, - OmadaSwitch, - OmadaSwitchPortDetails, -) +from tplink_omada_client.devices import OmadaSwitch from homeassistant.core import HomeAssistant -from .coordinator import OmadaCoordinator - -POLL_SWITCH_PORT = 300 -POLL_GATEWAY = 300 - - -class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): # pylint: disable=hass-enforce-coordinator-module - """Coordinator for getting details about ports on a switch.""" - - def __init__( - self, - hass: HomeAssistant, - omada_client: OmadaSiteClient, - network_switch: OmadaSwitch, - ) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT - ) - self._network_switch = network_switch - - async def poll_update(self) -> dict[str, OmadaSwitchPortDetails]: - """Poll a switch's current state.""" - ports = await self.omada_client.get_switch_ports(self._network_switch) - return {p.port_id: p for p in ports} - - -class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): # pylint: disable=hass-enforce-coordinator-module - """Coordinator for getting details about the site's gateway.""" - - def __init__( - self, - hass: HomeAssistant, - omada_client: OmadaSiteClient, - mac: str, - ) -> None: - """Initialize my coordinator.""" - super().__init__(hass, omada_client, "Gateway", POLL_GATEWAY) - self.mac = mac - - async def poll_update(self) -> dict[str, OmadaGateway]: - """Poll a the gateway's current state.""" - gateway = await self.omada_client.get_gateway(self.mac) - return {self.mac: gateway} +from .coordinator import ( + OmadaClientsCoordinator, + OmadaGatewayCoordinator, + OmadaSwitchPortCoordinator, +) class OmadaSiteController: @@ -60,6 +17,7 @@ class OmadaSiteController: _gateway_coordinator: OmadaGatewayCoordinator | None = None _initialized_gateway_coordinator = False + _clients_coordinator: OmadaClientsCoordinator | None = None def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: """Create the controller.""" @@ -98,3 +56,12 @@ class OmadaSiteController: ) return self._gateway_coordinator + + def get_clients_coordinator(self) -> OmadaClientsCoordinator: + """Get coordinator for site's clients.""" + if not self._clients_coordinator: + self._clients_coordinator = OmadaClientsCoordinator( + self._hass, self._omada_client + ) + + return self._clients_coordinator diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 893d2e2778d..da0a79ef991 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -3,9 +3,10 @@ import asyncio from datetime import timedelta import logging -from typing import Generic, TypeVar -from tplink_omada_client import OmadaSiteClient +from tplink_omada_client import OmadaSiteClient, OmadaSwitchPortDetails +from tplink_omada_client.clients import OmadaWirelessClient +from tplink_omada_client.devices import OmadaGateway, OmadaSwitch from tplink_omada_client.exceptions import OmadaClientException from homeassistant.core import HomeAssistant @@ -13,10 +14,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) -T = TypeVar("T") +POLL_SWITCH_PORT = 300 +POLL_GATEWAY = 300 +POLL_CLIENTS = 300 -class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): +class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]): """Coordinator for synchronizing bulk Omada data.""" def __init__( @@ -35,7 +38,7 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): ) self.omada_client = omada_client - async def _async_update_data(self) -> dict[str, T]: + async def _async_update_data(self) -> dict[str, _T]: """Fetch data from API endpoint.""" try: async with asyncio.timeout(10): @@ -43,6 +46,62 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): except OmadaClientException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - async def poll_update(self) -> dict[str, T]: + async def poll_update(self) -> dict[str, _T]: """Poll the current data from the controller.""" raise NotImplementedError("Update method not implemented") + + +class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): + """Coordinator for getting details about ports on a switch.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + network_switch: OmadaSwitch, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT + ) + self._network_switch = network_switch + + async def poll_update(self) -> dict[str, OmadaSwitchPortDetails]: + """Poll a switch's current state.""" + ports = await self.omada_client.get_switch_ports(self._network_switch) + return {p.port_id: p for p in ports} + + +class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): + """Coordinator for getting details about the site's gateway.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + mac: str, + ) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "Gateway", POLL_GATEWAY) + self.mac = mac + + async def poll_update(self) -> dict[str, OmadaGateway]: + """Poll a the gateway's current state.""" + gateway = await self.omada_client.get_gateway(self.mac) + return {self.mac: gateway} + + +class OmadaClientsCoordinator(OmadaCoordinator[OmadaWirelessClient]): + """Coordinator for getting details about the site's connected clients.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "ClientsList", POLL_CLIENTS) + + async def poll_update(self) -> dict[str, OmadaWirelessClient]: + """Poll the site's current active wi-fi clients.""" + return { + c.mac: c + async for c in self.omada_client.get_connected_clients() + if isinstance(c, OmadaWirelessClient) + } diff --git a/homeassistant/components/tplink_omada/device_tracker.py b/homeassistant/components/tplink_omada/device_tracker.py new file mode 100644 index 00000000000..be734592d11 --- /dev/null +++ b/homeassistant/components/tplink_omada/device_tracker.py @@ -0,0 +1,107 @@ +"""Connected Wi-Fi device scanners for TP-Link Omada access points.""" + +import logging + +from tplink_omada_client.clients import OmadaWirelessClient + +from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .config_flow import CONF_SITE +from .const import DOMAIN +from .controller import OmadaClientsCoordinator, OmadaSiteController + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up device trackers and scanners.""" + + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + + clients_coordinator = controller.get_clients_coordinator() + site_id = config_entry.data[CONF_SITE] + + # Add all known WiFi devices as potentially tracked devices. They will only be + # tracked if the user enables the entity. + async_add_entities( + [ + OmadaClientScannerEntity( + site_id, client.mac, client.name, clients_coordinator + ) + async for client in controller.omada_client.get_known_clients() + if isinstance(client, OmadaWirelessClient) + ] + ) + + +class OmadaClientScannerEntity( + CoordinatorEntity[OmadaClientsCoordinator], ScannerEntity +): + """Entity for a client connected to the Omada network.""" + + _client_details: OmadaWirelessClient | None = None + + def __init__( + self, + site_id: str, + client_id: str, + display_name: str, + coordinator: OmadaClientsCoordinator, + ) -> None: + """Initialize the scanner.""" + super().__init__(coordinator) + self._site_id = site_id + self._client_id = client_id + self._attr_name = display_name + + @property + def source_type(self) -> SourceType: + """Return the source type of the device.""" + return SourceType.ROUTER + + def _do_update(self) -> None: + self._client_details = self.coordinator.data.get(self._client_id) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._do_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._do_update() + self.async_write_ha_state() + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + return self._client_details.ip if self._client_details else None + + @property + def mac_address(self) -> str | None: + """Return the mac address of the device.""" + return self._client_id + + @property + def hostname(self) -> str | None: + """Return hostname of the device.""" + return self._client_details.host_name if self._client_details else None + + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return self._client_details.is_active if self._client_details else False + + @property + def unique_id(self) -> str | None: + """Return the unique id of the device.""" + return f"scanner_{self._site_id}_{self._client_id}" diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index a0bb562c652..13ec7b3c6cb 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -1,6 +1,6 @@ """Base entity definitions.""" -from typing import Any, Generic, TypeVar +from typing import Any from tplink_omada_client.devices import OmadaDevice @@ -11,13 +11,11 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OmadaCoordinator -T = TypeVar("T", bound="OmadaCoordinator[Any]") - -class OmadaDeviceEntity(CoordinatorEntity[T], Generic[T]): +class OmadaDeviceEntity[_T: OmadaCoordinator[Any]](CoordinatorEntity[_T]): """Common base class for all entities associated with Omada SDN Devices.""" - def __init__(self, coordinator: T, device: OmadaDevice) -> None: + def __init__(self, coordinator: _T, device: OmadaDevice) -> None: """Initialize the device.""" super().__init__(coordinator) self.device = device diff --git a/homeassistant/components/traccar_server/binary_sensor.py b/homeassistant/components/traccar_server/binary_sensor.py index 6ee5757dcea..58c46502b53 100644 --- a/homeassistant/components/traccar_server/binary_sensor.py +++ b/homeassistant/components/traccar_server/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, Literal, TypeVar, cast +from typing import Any, Literal from pytraccar import DeviceModel @@ -22,13 +22,9 @@ from .const import DOMAIN from .coordinator import TraccarServerCoordinator from .entity import TraccarServerEntity -_T = TypeVar("_T") - @dataclass(frozen=True, kw_only=True) -class TraccarServerBinarySensorEntityDescription( - Generic[_T], BinarySensorEntityDescription -): +class TraccarServerBinarySensorEntityDescription[_T](BinarySensorEntityDescription): """Describe Traccar Server sensor entity.""" data_key: Literal["position", "device", "geofence", "attributes"] @@ -37,7 +33,9 @@ class TraccarServerBinarySensorEntityDescription( value_fn: Callable[[_T], bool | None] -TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS = ( +TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[ + TraccarServerBinarySensorEntityDescription[Any], ... +] = ( TraccarServerBinarySensorEntityDescription[DeviceModel]( key="attributes.motion", data_key="position", @@ -65,18 +63,18 @@ async def async_setup_entry( TraccarServerBinarySensor( coordinator=coordinator, device=entry["device"], - description=cast(TraccarServerBinarySensorEntityDescription, description), + description=description, ) for entry in coordinator.data.values() for description in TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS ) -class TraccarServerBinarySensor(TraccarServerEntity, BinarySensorEntity): +class TraccarServerBinarySensor[_T](TraccarServerEntity, BinarySensorEntity): """Represent a traccar server binary sensor.""" _attr_has_entity_name = True - entity_description: TraccarServerBinarySensorEntityDescription + entity_description: TraccarServerBinarySensorEntityDescription[_T] def __init__( self, diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py index 678bcc461e7..45a43c08685 100644 --- a/homeassistant/components/traccar_server/config_flow.py +++ b/homeassistant/components/traccar_server/config_flow.py @@ -146,7 +146,7 @@ class TraccarServerConfigFlow(ConfigFlow, domain=DOMAIN): except TraccarException as exception: LOGGER.error("Unable to connect to Traccar Server: %s", exception) errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 3d44b1ecede..95ce42469f1 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -35,7 +35,7 @@ class TraccarServerCoordinatorDataDevice(TypedDict): attributes: dict[str, Any] -TraccarServerCoordinatorData = dict[int, TraccarServerCoordinatorDataDevice] +type TraccarServerCoordinatorData = dict[int, TraccarServerCoordinatorDataDevice] class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorData]): diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py index 7f46399eb3f..bb3c4ed4401 100644 --- a/homeassistant/components/traccar_server/sensor.py +++ b/homeassistant/components/traccar_server/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, Literal, TypeVar, cast +from typing import Any, Literal from pytraccar import DeviceModel, GeofenceModel, PositionModel @@ -24,11 +24,9 @@ from .const import DOMAIN from .coordinator import TraccarServerCoordinator from .entity import TraccarServerEntity -_T = TypeVar("_T") - @dataclass(frozen=True, kw_only=True) -class TraccarServerSensorEntityDescription(Generic[_T], SensorEntityDescription): +class TraccarServerSensorEntityDescription[_T](SensorEntityDescription): """Describe Traccar Server sensor entity.""" data_key: Literal["position", "device", "geofence", "attributes"] @@ -37,7 +35,9 @@ class TraccarServerSensorEntityDescription(Generic[_T], SensorEntityDescription) value_fn: Callable[[_T], StateType] -TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS = ( +TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS: tuple[ + TraccarServerSensorEntityDescription[Any], ... +] = ( TraccarServerSensorEntityDescription[PositionModel]( key="attributes.batteryLevel", data_key="position", @@ -45,7 +45,7 @@ TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, - value_fn=lambda x: x["attributes"].get("batteryLevel", -1), + value_fn=lambda x: x["attributes"].get("batteryLevel"), ), TraccarServerSensorEntityDescription[PositionModel]( key="speed", @@ -91,18 +91,18 @@ async def async_setup_entry( TraccarServerSensor( coordinator=coordinator, device=entry["device"], - description=cast(TraccarServerSensorEntityDescription, description), + description=description, ) for entry in coordinator.data.values() for description in TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS ) -class TraccarServerSensor(TraccarServerEntity, SensorEntity): +class TraccarServerSensor[_T](TraccarServerEntity, SensorEntity): """Represent a tracked device.""" _attr_has_entity_name = True - entity_description: TraccarServerSensorEntityDescription + entity_description: TraccarServerSensorEntityDescription[_T] def __init__( self, diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 03b1845d6a8..79830e0b63f 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -40,7 +40,7 @@ TRACE_CONFIG_SCHEMA = { CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] +type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] @callback @@ -185,7 +185,7 @@ async def async_restore_traces(hass: HomeAssistant) -> None: try: trace = RestoredTrace(json_trace) # Catch any exception to not blow up if the stored trace is invalid - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Failed to restore trace") continue _async_store_restored_trace(hass, trace) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 136e8b3632a..fd5abe24c06 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -33,13 +33,10 @@ from .const import ( ATTR_MINUTES_REST, ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, - CLIENT, CLIENT_ID, - DOMAIN, RECONNECT_INTERVAL, SERVER_UNAVAILABLE, SWITCH_KEY_MAP, - TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, TRACKER_SWITCH_STATUS_UPDATED, @@ -68,12 +65,21 @@ class Trackables: pos_report: dict -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +@dataclass(slots=True) +class TractiveData: + """Class for Tractive data.""" + + client: TractiveClient + trackables: list[Trackables] + + +type TractiveConfigEntry = ConfigEntry[TractiveData] + + +async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> bool: """Set up tractive from a config entry.""" data = entry.data - hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) - client = aiotractive.Tractive( data[CONF_EMAIL], data[CONF_PASSWORD], @@ -101,10 +107,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # When the pet defined in Tractive has no tracker linked we get None as `trackable`. # So we have to remove None values from trackables list. - trackables = [item for item in trackables if item] + filtered_trackables = [item for item in trackables if item] - hass.data[DOMAIN][entry.entry_id][CLIENT] = tractive - hass.data[DOMAIN][entry.entry_id][TRACKABLES] = trackables + entry.runtime_data = TractiveData(tractive, filtered_trackables) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -114,6 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cancel_listen_task) ) + entry.async_on_unload(tractive.unsubscribe) return True @@ -142,17 +148,17 @@ async def _generate_trackables( tracker.details(), tracker.hw_info(), tracker.pos_report() ) + if not tracker_details.get("_id"): + raise ConfigEntryNotReady( + f"Tractive API returns incomplete data for tracker {trackable['device_id']}", + ) + return Trackables(tracker, trackable, tracker_details, hw_info, pos_report) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - tractive = hass.data[DOMAIN][entry.entry_id].pop(CLIENT) - await tractive.unsubscribe() - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class TractiveClient: diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index dd7237a2b38..80219154d81 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -9,13 +9,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables, TractiveClient -from .const import CLIENT, DOMAIN, TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED +from . import Trackables, TractiveClient, TractiveConfigEntry +from .const import TRACKER_HARDWARE_STATUS_UPDATED from .entity import TractiveEntity @@ -57,11 +56,13 @@ SENSOR_TYPE = BinarySensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TractiveConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" - client = hass.data[DOMAIN][entry.entry_id][CLIENT] - trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + client = entry.runtime_data.client + trackables = entry.runtime_data.trackables entities = [ TractiveBinarySensor(client, item, SENSOR_TYPE) diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py index a6b0d43a2b7..5859a0c719e 100644 --- a/homeassistant/components/tractive/config_flow.py +++ b/homeassistant/components/tractive/config_flow.py @@ -58,7 +58,7 @@ class TractiveConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -88,7 +88,7 @@ class TractiveConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index f26c0ee2345..cb5d4066dd9 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -23,9 +23,6 @@ ATTR_TRACKER_STATE = "tracker_state" # Please do not use it anywhere else. CLIENT_ID = "625e5349c3c3b41c28a669f1" -CLIENT = "client" -TRACKABLES = "trackables" - TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" TRACKER_SWITCH_STATUS_UPDATED = f"{DOMAIN}_tracker_switch_updated" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 134515469fc..d5d6f5f541c 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -5,17 +5,13 @@ from __future__ import annotations from typing import Any from homeassistant.components.device_tracker import SourceType, TrackerEntity -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 . import Trackables, TractiveClient +from . import Trackables, TractiveClient, TractiveConfigEntry from .const import ( - CLIENT, - DOMAIN, SERVER_UNAVAILABLE, - TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, ) @@ -23,11 +19,13 @@ from .entity import TractiveEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TractiveConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" - client = hass.data[DOMAIN][entry.entry_id][CLIENT] - trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + client = entry.runtime_data.client + trackables = entry.runtime_data.trackables entities = [TractiveDeviceTracker(client, item) for item in trackables] diff --git a/homeassistant/components/tractive/diagnostics.py b/homeassistant/components/tractive/diagnostics.py index cd1f5632f46..a0fc0628f08 100644 --- a/homeassistant/components/tractive/diagnostics.py +++ b/homeassistant/components/tractive/diagnostics.py @@ -5,20 +5,19 @@ 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_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN, TRACKABLES +from . import TractiveConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_EMAIL, "title", "_id"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: TractiveConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - trackables = hass.data[DOMAIN][config_entry.entry_id][TRACKABLES] + trackables = config_entry.runtime_data.trackables return async_redact_data( { diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 1edee71467b..a92efa660b6 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, PERCENTAGE, @@ -23,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import Trackables, TractiveClient +from . import Trackables, TractiveClient, TractiveConfigEntry from .const import ( ATTR_ACTIVITY_LABEL, ATTR_CALORIES, @@ -34,9 +33,6 @@ from .const import ( ATTR_MINUTES_REST, ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, - CLIENT, - DOMAIN, - TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_WELLNESS_STATUS_UPDATED, ) @@ -183,11 +179,13 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TractiveConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" - client = hass.data[DOMAIN][entry.entry_id][CLIENT] - trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + client = entry.runtime_data.client + trackables = entry.runtime_data.trackables entities = [ TractiveSensor(client, item, description) diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 52aa9f1e901..3bf6887e99c 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -9,19 +9,15 @@ from typing import Any, Literal, cast from aiotractive.exceptions import TractiveError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables, TractiveClient +from . import Trackables, TractiveClient, TractiveConfigEntry from .const import ( ATTR_BUZZER, ATTR_LED, ATTR_LIVE_TRACKING, - CLIENT, - DOMAIN, - TRACKABLES, TRACKER_SWITCH_STATUS_UPDATED, ) from .entity import TractiveEntity @@ -59,11 +55,13 @@ SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TractiveConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Tractive switches.""" - client = hass.data[DOMAIN][entry.entry_id][CLIENT] - trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] + client = entry.runtime_data.client + trackables = entry.runtime_data.trackables entities = [ TractiveSwitch(client, item, description) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 3186e803087..938bfce2318 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -50,7 +50,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: camera_info = await camera_api.async_get_camera(location) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.error( "Could not migrate the config entry. No connection to the api" ) @@ -76,7 +76,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: camera_info = await camera_api.async_get_camera(location) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.error( "Could not migrate the config entry. No connection to the api" ) diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index 3b79cc0f0bd..1f82a535f16 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -85,7 +85,7 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( @@ -121,7 +121,7 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): if ferry_to: name = name + f" to {ferry_to}" if ferry_time != "00:00:00": - name = name + f" at {str(ferry_time)}" + name = name + f" at {ferry_time!s}" try: await self.validate_input(api_key, ferry_from, ferry_to) @@ -129,7 +129,7 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoFerryFound: errors["base"] = "invalid_route" - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 errors["base"] = "cannot_connect" else: if not errors: diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index cb11889345a..6cfed88b79c 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -77,7 +77,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): datetime.combine( departure_day, self._time, - dt_util.get_time_zone(self.hass.config.time_zone), + dt_util.get_default_time_zone(), ) if self._time else dt_util.now() diff --git a/homeassistant/components/trafikverket_ferry/util.py b/homeassistant/components/trafikverket_ferry/util.py index a45e8b31daa..ca7e3af3902 100644 --- a/homeassistant/components/trafikverket_ferry/util.py +++ b/homeassistant/components/trafikverket_ferry/util.py @@ -11,5 +11,5 @@ def create_unique_id( """Create unique id.""" return ( f"{ferry_from.casefold().replace(' ', '')}-{ferry_to.casefold().replace(' ', '')}" - f"-{str(ferry_time)}-{str(weekdays)}" + f"-{ferry_time!s}-{weekdays!s}" ) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 48e603eff02..d03eeca8f65 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -87,7 +87,7 @@ async def validate_input( when = datetime.combine( departure_day, _time, - dt_util.get_time_zone(hass.config.time_zone), + dt_util.get_default_time_zone(), ) try: @@ -114,7 +114,7 @@ async def validate_input( except UnknownError as error: _LOGGER.error("Unknown error occurred during validation %s", str(error)) errors["base"] = "cannot_connect" - except Exception as error: # pylint: disable=broad-exception-caught + except Exception as error: # noqa: BLE001 _LOGGER.error("Unknown exception occurred during validation %s", str(error)) errors["base"] = "cannot_connect" diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index e56f5d3a2e9..c202473da79 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -105,7 +105,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): when = datetime.combine( departure_day, self._time, - dt_util.get_time_zone(self.hass.config.time_zone), + dt_util.get_default_time_zone(), ) try: if self._time: diff --git a/homeassistant/components/trafikverket_train/util.py b/homeassistant/components/trafikverket_train/util.py index b28a51d339d..9648436f1e5 100644 --- a/homeassistant/components/trafikverket_train/util.py +++ b/homeassistant/components/trafikverket_train/util.py @@ -14,7 +14,7 @@ def create_unique_id( timestr = str(depart_time) if depart_time else "" return ( f"{from_station.casefold().replace(' ', '')}-{to_station.casefold().replace(' ', '')}" - f"-{timestr.casefold().replace(' ', '')}-{str(weekdays)}" + f"-{timestr.casefold().replace(' ', '')}-{weekdays!s}" ) diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 05be4fc460e..cf7ca905acb 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -53,7 +53,7 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 errors["base"] = "cannot_connect" else: return self.async_create_entry( @@ -102,7 +102,7 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_station" except MultipleWeatherStationsFound: errors["base"] = "more_stations" - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index d7d6ae4ea0c..37771430199 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from functools import partial import logging import re -from typing import Any +from typing import Any, Literal import transmission_rpc from transmission_rpc.error import ( @@ -15,7 +15,7 @@ from transmission_rpc.error import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -28,12 +28,17 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import ( config_validation as cv, entity_registry as er, selector, ) +from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DELETE_DATA, @@ -102,8 +107,20 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All( ) ) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Transmission component.""" + setup_hass_services(hass) + return True + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: TransmissionConfigEntry +) -> bool: """Set up the Transmission Component.""" @callback @@ -135,13 +152,67 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.async_add_executor_job(coordinator.init_torrent_list) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + config_entry.runtime_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 Transmission Entry from config_entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate an old config entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version == 1: + # Version 1.2 adds ssl and path + if config_entry.minor_version < 2: + new = {**config_entry.data} + + new[CONF_PATH] = DEFAULT_PATH + new[CONF_SSL] = DEFAULT_SSL + + hass.config_entries.async_update_entry( + config_entry, data=new, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + +def _get_coordinator_from_service_data( + hass: HomeAssistant, entry_id: str +) -> TransmissionDataUpdateCoordinator: + """Return coordinator for entry id.""" + entry: TransmissionConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + raise HomeAssistantError(f"Config entry {entry_id} is not found or not loaded") + return entry.runtime_data + + +def setup_hass_services(hass: HomeAssistant) -> None: + """Home Assistant services.""" + async def add_torrent(service: ServiceCall) -> None: """Add new torrent to download.""" - torrent = service.data[ATTR_TORRENT] + entry_id: str = service.data[CONF_ENTRY_ID] + coordinator = _get_coordinator_from_service_data(hass, entry_id) + torrent: str = service.data[ATTR_TORRENT] if torrent.startswith( ("http", "ftp:", "magnet:") ) or hass.config.is_allowed_path(torrent): @@ -152,18 +223,24 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def start_torrent(service: ServiceCall) -> None: """Start torrent.""" + entry_id: str = service.data[CONF_ENTRY_ID] + coordinator = _get_coordinator_from_service_data(hass, entry_id) torrent_id = service.data[CONF_ID] await hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id) await coordinator.async_request_refresh() async def stop_torrent(service: ServiceCall) -> None: """Stop torrent.""" + entry_id: str = service.data[CONF_ENTRY_ID] + coordinator = _get_coordinator_from_service_data(hass, entry_id) torrent_id = service.data[CONF_ID] await hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id) await coordinator.async_request_refresh() async def remove_torrent(service: ServiceCall) -> None: """Remove torrent.""" + entry_id: str = service.data[CONF_ENTRY_ID] + coordinator = _get_coordinator_from_service_data(hass, entry_id) torrent_id = service.data[CONF_ID] delete_data = service.data[ATTR_DELETE_DATA] await hass.async_add_executor_job( @@ -196,58 +273,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b schema=SERVICE_STOP_TORRENT_SCHEMA, ) - return True - - -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload Transmission Entry from config_entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - hass.data[DOMAIN].pop(config_entry.entry_id) - - if not hass.data[DOMAIN]: - hass.services.async_remove(DOMAIN, SERVICE_ADD_TORRENT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE_TORRENT) - hass.services.async_remove(DOMAIN, SERVICE_START_TORRENT) - hass.services.async_remove(DOMAIN, SERVICE_STOP_TORRENT) - - return unload_ok - - -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Migrate an old config entry.""" - _LOGGER.debug( - "Migrating from version %s.%s", - config_entry.version, - config_entry.minor_version, - ) - - if config_entry.version == 1: - # Version 1.2 adds ssl and path - if config_entry.minor_version < 2: - new = {**config_entry.data} - - new[CONF_PATH] = DEFAULT_PATH - new[CONF_SSL] = DEFAULT_SSL - - hass.config_entries.async_update_entry( - config_entry, data=new, version=1, minor_version=2 - ) - - _LOGGER.debug( - "Migration to version %s.%s successful", - config_entry.version, - config_entry.minor_version, - ) - - return True - async def get_api( hass: HomeAssistant, entry: dict[str, Any] ) -> transmission_rpc.Client: """Get Transmission client.""" + protocol: Literal["http", "https"] protocol = "https" if entry[CONF_SSL] else "http" host = entry[CONF_HOST] port = entry[CONF_PORT] diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index 62879d2d0af..2a4fd5aae0b 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Transmission Bittorent Client.""" +"""Config flow for Transmission Bittorrent Client.""" from __future__ import annotations diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 0dd77fa6aa3..120918b24a2 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,4 +1,4 @@ -"""Constants for the Transmission Bittorent Client component.""" +"""Constants for the Transmission Bittorrent Client component.""" from __future__ import annotations diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 1c379685c1c..d6b5b695656 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -93,16 +93,14 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): def check_completed_torrent(self) -> None: """Get completed torrent functionality.""" - old_completed_torrent_names = { - torrent.name for torrent in self._completed_torrents - } + old_completed_torrents = {torrent.id for torrent in self._completed_torrents} current_completed_torrents = [ torrent for torrent in self.torrents if torrent.status == "seeding" ] for torrent in current_completed_torrents: - if torrent.name not in old_completed_torrent_names: + if torrent.id not in old_completed_torrents: self.hass.bus.fire( EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} ) @@ -111,14 +109,14 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): def check_started_torrent(self) -> None: """Get started torrent functionality.""" - old_started_torrent_names = {torrent.name for torrent in self._started_torrents} + old_started_torrents = {torrent.id for torrent in self._started_torrents} current_started_torrents = [ torrent for torrent in self.torrents if torrent.status == "downloading" ] for torrent in current_started_torrents: - if torrent.name not in old_started_torrent_names: + if torrent.id not in old_started_torrents: self.hass.bus.fire( EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} ) @@ -127,10 +125,10 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): def check_removed_torrent(self) -> None: """Get removed torrent functionality.""" - current_torrent_names = {torrent.name for torrent in self.torrents} + current_torrents = {torrent.id for torrent in self.torrents} for torrent in self._all_torrents: - if torrent.name not in current_torrent_names: + if torrent.id not in current_torrents: self.hass.bus.fire( EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} ) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 9ee42045aab..737520adb5f 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -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 @@ -22,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import TransmissionConfigEntry from .const import ( DOMAIN, STATE_ATTR_TORRENT_INFO, @@ -134,14 +134,12 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TransmissionConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Transmission sensors.""" - coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( TransmissionSensor(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 8e79d8246e0..d88f794cb10 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -2,21 +2,18 @@ from collections.abc import 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.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import TransmissionConfigEntry from .const import DOMAIN from .coordinator import TransmissionDataUpdateCoordinator -_LOGGING = logging.getLogger(__name__) - @dataclass(frozen=True, kw_only=True) class TransmissionSwitchEntityDescription(SwitchEntityDescription): @@ -47,14 +44,12 @@ SWITCH_TYPES: tuple[TransmissionSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: TransmissionConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Transmission switch.""" - coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( TransmissionSwitch(coordinator, description) for description in SWITCH_TYPES diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index 7ec2d140c5e..c38730e7591 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -3,8 +3,11 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device import ( + async_remove_stale_devices_links_keep_entity_device, +) PLATFORMS = [Platform.BINARY_SENSOR] @@ -12,6 +15,12 @@ PLATFORMS = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trend from a config entry.""" + async_remove_stale_devices_links_keep_entity_device( + hass, + entry.entry_id, + entry.options[CONF_ENTITY_ID], + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2b70e2394f0..6788d22219b 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -32,7 +32,9 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -133,6 +135,11 @@ async def async_setup_entry( ) -> None: """Set up trend sensor from config entry.""" + device_info = async_device_info_to_link_from_entity( + hass, + entry.options[CONF_ENTITY_ID], + ) + async_add_entities( [ SensorTrend( @@ -147,6 +154,7 @@ async def async_setup_entry( 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, + device_info=device_info, ) ] ) @@ -172,6 +180,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): unique_id: str | None = None, device_class: BinarySensorDeviceClass | None = None, sensor_entity_id: str | None = None, + device_info: dr.DeviceInfo | None = None, ) -> None: """Initialize the sensor.""" self._entity_id = entity_id @@ -185,6 +194,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self._attr_name = name self._attr_device_class = device_class self._attr_unique_id = unique_id + self._attr_device_info = device_info if sensor_entity_id: self.entity_id = sensor_entity_id diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 3055bf46ca7..15cd10552ed 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1114,7 +1114,7 @@ def websocket_get_engine( if not provider: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"tts engine {engine_id} not found", ) return @@ -1149,7 +1149,7 @@ def websocket_list_engine_voices( if not engine_instance: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"tts engine {engine_id} not found", ) return diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index 99015512498..ab22a44cab6 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -18,4 +18,4 @@ DOMAIN = "tts" DATA_TTS_MANAGER = "tts_manager" -TtsAudioType = tuple[str | None, bytes | None] +type TtsAudioType = tuple[str | None, bytes | None] diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 88249ed107b..e36a1227603 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -148,7 +148,7 @@ async def async_setup_legacy( return tts.async_register_legacy_engine(p_type, provider, p_config) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error setting up platform: %s", p_type) return diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index ceb8f056c22..a9e65556e38 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -35,6 +35,8 @@ from .const import ( # Suppress logs from the library, it logs unneeded on error logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL) +type TuyaConfigEntry = ConfigEntry[HomeAssistantTuyaData] + class HomeAssistantTuyaData(NamedTuple): """Tuya data stored in the Home Assistant data object.""" @@ -43,7 +45,7 @@ class HomeAssistantTuyaData(NamedTuple): listener: SharingDeviceListener -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Async setup hass config entry.""" if CONF_APP_TYPE in entry.data: raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") @@ -73,9 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise # Connection is successful, store the manager & listener - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantTuyaData( - manager=manager, listener=listener - ) + entry.runtime_data = HomeAssistantTuyaData(manager=manager, listener=listener) # Cleanup device registry await cleanup_device_registry(hass, manager) @@ -108,18 +108,17 @@ async def cleanup_device_registry(hass: HomeAssistant, device_manager: Manager) break -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool: """Unloading the Tuya platforms.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - tuya: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + tuya = entry.runtime_data if tuya.manager.mq is not None: tuya.manager.mq.stop() tuya.manager.remove_device_listener(tuya.listener) - del hass.data[DOMAIN][entry.entry_id] return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> None: """Remove a config entry. This will revoke the credentials from Tuya. @@ -184,7 +183,7 @@ class TokenListener(SharingTokenListener): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: TuyaConfigEntry, ) -> None: """Init TokenListener.""" self.hass = hass diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 59075cf00cd..29da625a990 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -11,7 +11,6 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityDescription, AlarmControlPanelEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -22,9 +21,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType class Mode(StrEnum): @@ -59,10 +58,10 @@ ALARM: dict[str, tuple[AlarmControlPanelEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya alarm dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: @@ -89,6 +88,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): """Tuya Alarm Entity.""" _attr_name = None + _attr_code_arm_required = False def __init__( self, diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index c9f4734a7df..2d6d9b478c8 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -11,15 +11,14 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode @dataclass(frozen=True) @@ -191,6 +190,10 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { key=DPCode.DOORCONTACT_STATE, device_class=BinarySensorDeviceClass.DOOR, ), + TuyaBinarySensorEntityDescription( + key=DPCode.SWITCH, # Used by non-standard contact sensor implementations + device_class=BinarySensorDeviceClass.DOOR, + ), TAMPER_BINARY_SENSOR, ), # Access Control @@ -338,10 +341,10 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya binary sensor dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index a170ddb09e9..f62bba928b4 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -5,15 +5,14 @@ from __future__ import annotations from tuya_sharing import CustomerDevice, Manager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode # All descriptions can be found here. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq @@ -59,10 +58,10 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya buttons dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 79f8c1b1692..f3913611b07 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -6,14 +6,13 @@ from tuya_sharing import CustomerDevice, Manager from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature -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 . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq @@ -25,10 +24,10 @@ CAMERAS: tuple[str, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya cameras dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 3be80193beb..d47c71532a4 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -18,15 +18,14 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, @@ -82,10 +81,10 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya climate dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index a9c53d807bc..3b0d22e8cf7 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -113,6 +113,7 @@ class DPCode(StrEnum): BASIC_OSD = "basic_osd" BASIC_PRIVATE = "basic_private" BASIC_WDR = "basic_wdr" + BATTERY = "battery" # Used by non-standard contact sensor implementations BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage BATTERY_STATE = "battery_state" # Battery state BATTERY_VALUE = "battery_value" # Battery value @@ -165,6 +166,7 @@ class DPCode(StrEnum): CRY_DETECTION_SWITCH = "cry_detection_switch" CUP_NUMBER = "cup_number" # NUmber of cups CUR_CURRENT = "cur_current" # Actual current + CUR_NEUTRAL = "cur_neutral" # Total reverse energy CUR_POWER = "cur_power" # Actual power CUR_VOLTAGE = "cur_voltage" # Actual voltage DECIBEL_SENSITIVITY = "decibel_sensitivity" @@ -252,6 +254,7 @@ class DPCode(StrEnum): POWDER_SET = "powder_set" # Powder POWER = "power" POWER_GO = "power_go" + POWER_TOTAL = "power_total" PRESENCE_STATE = "presence_state" PRESSURE_STATE = "pressure_state" PRESSURE_VALUE = "pressure_value" @@ -266,6 +269,7 @@ class DPCode(StrEnum): RESET_FILTER = "reset_filter" RESET_MAP = "reset_map" RESET_ROLL_BRUSH = "reset_roll_brush" + REVERSE_ENERGY_TOTAL = "reverse_energy_total" ROLL_BRUSH = "roll_brush" SEEK = "seek" SENSITIVITY = "sensitivity" # Sensitivity @@ -341,6 +345,7 @@ class DPCode(StrEnum): TOTAL_FORWARD_ENERGY = "total_forward_energy" TOTAL_TIME = "total_time" TOTAL_PM = "total_pm" + TOTAL_POWER = "total_power" TVOC = "tvoc" UPPER_TEMP = "upper_temp" UPPER_TEMP_F = "upper_temp_f" @@ -441,7 +446,7 @@ UNITS = ( ), UnitOfMeasurement( unit=UnitOfEnergy.KILO_WATT_HOUR, - aliases={"kwh", "kilowatt-hour", "kW·h"}, + aliases={"kwh", "kilowatt-hour", "kW·h", "kW.h"}, device_classes={SensorDeviceClass.ENERGY}, ), UnitOfMeasurement( diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 7dc54888ac4..e92c6f5c5f2 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -15,14 +15,13 @@ from homeassistant.components.cover import ( CoverEntityDescription, CoverEntityFeature, ) -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 . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @dataclass(frozen=True) @@ -47,7 +46,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { key=DPCode.CONTROL, translation_key="curtain", current_state=DPCode.SITUATION_SET, - current_position=(DPCode.PERCENT_CONTROL, DPCode.PERCENT_STATE), + current_position=(DPCode.PERCENT_STATE, DPCode.PERCENT_CONTROL), set_position=DPCode.PERCENT_CONTROL, device_class=CoverDeviceClass.CURTAIN, ), @@ -143,10 +142,10 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya cover dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index f817261c8fc..9675b215ce2 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -9,25 +9,24 @@ from typing import Any, cast from tuya_sharing import CustomerDevice from homeassistant.components.diagnostics import REDACTED -from homeassistant.config_entries import ConfigEntry 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 . import TuyaConfigEntry from .const import DOMAIN, DPCode async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TuyaConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" return _async_get_diagnostics(hass, entry) async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: TuyaConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device entry.""" return _async_get_diagnostics(hass, entry, device) @@ -36,11 +35,11 @@ async def async_get_device_diagnostics( @callback def _async_get_diagnostics( hass: HomeAssistant, - entry: ConfigEntry, + entry: TuyaConfigEntry, device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data mqtt_connected = None if hass_data.manager.mq.client: diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 3925da1d507..d4c19f6b55a 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -12,7 +12,6 @@ from homeassistant.components.fan import ( FanEntity, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,9 +20,9 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import EnumTypeData, IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType TUYA_SUPPORT_TYPE = { "fs", # Fan @@ -35,10 +34,10 @@ TUYA_SUPPORT_TYPE = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up tuya fan dynamically through tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 927aaf8a74a..3d16b0dfbbb 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -12,14 +12,13 @@ from homeassistant.components.humidifier import ( HumidifierEntityDescription, HumidifierEntityFeature, ) -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 . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @dataclass(frozen=True) @@ -56,10 +55,10 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya (de)humidifier dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d898e837d8e..3533dabf92a 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -17,15 +17,14 @@ from homeassistant.components.light import ( LightEntityDescription, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode from .util import remap_value @@ -409,10 +408,10 @@ class ColorData: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up tuya light dynamically through tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]): diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 2be7deef89f..d7614fb837a 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -9,13 +9,12 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import IntegerTypeData, TuyaEntity from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType @@ -278,14 +277,22 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.TEMPERATURE, ), ), + # Pool HeatPump + "znrb": ( + NumberEntityDescription( + key=DPCode.TEMP_SET, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + ), } async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya number dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index dcc1aae1fba..1465724faac 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -7,20 +7,19 @@ from typing import Any from tuya_sharing import Manager, SharingScene from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .const import DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya scenes.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data scenes = await hass.async_add_executor_job(hass_data.manager.query_scenes) async_add_entities(TuyaSceneEntity(hass_data.manager, scene) for scene in scenes) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 6e128bfdcc4..111b9e40918 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -5,15 +5,14 @@ from __future__ import annotations from tuya_sharing import CustomerDevice, Manager from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. @@ -320,10 +319,10 @@ SELECTS["pc"] = SELECTS["kg"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya select dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index df11840931d..5b6a4ed053e 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -27,7 +26,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import ElectricityTypeData, EnumTypeData, IntegerTypeData, TuyaEntity from .const import ( DEVICE_CLASS_UNITS, @@ -56,6 +55,14 @@ BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + TuyaSensorEntityDescription( + key=DPCode.BATTERY, # Used by non-standard contact sensor implementations + translation_key="battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), TuyaSensorEntityDescription( key=DPCode.BATTERY_STATE, translation_key="battery_state", @@ -214,6 +221,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ), # CO Detector # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v @@ -519,6 +547,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": BATTERY_SENSORS, # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, @@ -665,6 +696,20 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + TuyaSensorEntityDescription( + key=DPCode.REVERSE_ENERGY_TOTAL, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_POWER, + translation_key="total_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, translation_key="phase_a_current", @@ -747,6 +792,12 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + TuyaSensorEntityDescription( + key=DPCode.CUR_NEUTRAL, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, translation_key="phase_a_current", @@ -1063,6 +1114,33 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # VESKA-micro inverter + "znnbq": ( + TuyaSensorEntityDescription( + key=DPCode.REVERSE_ENERGY_TOTAL, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.POWER_TOTAL, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=0, + suggested_unit_of_measurement=UnitOfPower.WATT, + ), + ), + # Pool HeatPump + "znrb": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), } # Socket (duplicate of `kg`) @@ -1075,10 +1153,10 @@ SENSORS["pc"] = SENSORS["kg"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya sensor dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 04473e44e22..683705c6546 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -11,14 +11,13 @@ from homeassistant.components.siren import ( SirenEntityDescription, SirenEntityFeature, ) -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 . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq @@ -48,10 +47,10 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya siren dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index cfce12273a0..46530a1d938 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -275,7 +275,7 @@ "name": "Indicator light mode", "state": { "none": "[%key:common::state::off%]", - "pos": "Indicate switch location", + "pos": "Indicate inverted switch state", "relay": "Indicate switch on/off state" } }, @@ -517,6 +517,9 @@ "total_energy": { "name": "Total energy" }, + "total_production": { + "name": "Total production" + }, "phase_a_current": { "name": "Phase A current" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 36debaeadde..f84e63aba37 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -11,15 +11,14 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode # All descriptions can be found here. Mostly the Boolean data types in the # default instruction set of each category end up being a Switch. @@ -50,6 +49,28 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="water", ), ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e + "cs": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + icon="mdi:atom", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FILTER_RESET, + translation_key="filter_reset", + icon="mdi:filter", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -408,6 +429,18 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( @@ -521,6 +554,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( @@ -652,6 +694,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Pool HeatPump + "znrb": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), } # Socket (duplicate of `pc`) @@ -660,10 +709,10 @@ SWITCHES["cz"] = SWITCHES["pc"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 6774aaac8a1..360d6d4f5c3 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -13,15 +13,14 @@ from homeassistant.components.vacuum import ( StateVacuumEntity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantTuyaData +from . import TuyaConfigEntry from .base import EnumTypeData, IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { @@ -52,10 +51,10 @@ TUYA_STATUS_TO_HA = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Tuya vacuum dynamically through Tuya discovery.""" - hass_data: HomeAssistantTuyaData = hass.data[DOMAIN][entry.entry_id] + hass_data = entry.runtime_data @callback def async_discover_device(device_ids: list[str]) -> None: diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index d9881b0b2c8..f447ef6257d 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -23,6 +23,11 @@ SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] +type TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator[ + dict[WasteType, list[date]] +] +type TwenteMilieuConfigEntry = ConfigEntry[TwenteMilieuDataUpdateCoordinator] + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Twente Milieu from a config entry.""" @@ -34,14 +39,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, ) - coordinator: DataUpdateCoordinator[dict[WasteType, list[date]]] = ( - DataUpdateCoordinator( - hass, - LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - update_method=twentemilieu.update, - ) + coordinator: TwenteMilieuDataUpdateCoordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + update_method=twentemilieu.update, ) await coordinator.async_config_entry_first_refresh() @@ -51,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, unique_id=str(entry.data[CONF_ID]) ) - hass.data.setdefault(DOMAIN, {})[entry.data[CONF_ID]] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -59,7 +62,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Twente Milieu config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.data[CONF_ID]] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 8bd008e3eb3..8e7452823b7 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -2,30 +2,26 @@ from __future__ import annotations -from datetime import date, datetime, timedelta - -from twentemilieu import WasteType +from datetime import datetime, timedelta from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator import homeassistant.util.dt as dt_util -from .const import DOMAIN, WASTE_TYPE_TO_DESCRIPTION +from . import TwenteMilieuConfigEntry +from .const import WASTE_TYPE_TO_DESCRIPTION from .entity import TwenteMilieuEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TwenteMilieuConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Twente Milieu calendar based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.data[CONF_ID]] - async_add_entities([TwenteMilieuCalendar(coordinator, entry)]) + async_add_entities([TwenteMilieuCalendar(entry)]) class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): @@ -35,13 +31,9 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): _attr_name = None _attr_translation_key = "calendar" - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[WasteType, list[date]]], - entry: ConfigEntry, - ) -> None: + def __init__(self, entry: TwenteMilieuConfigEntry) -> None: """Initialize the Twente Milieu entity.""" - super().__init__(coordinator, entry) + super().__init__(entry) self._attr_unique_id = str(entry.data[CONF_ID]) self._event: CalendarEvent | None = None diff --git a/homeassistant/components/twentemilieu/diagnostics.py b/homeassistant/components/twentemilieu/diagnostics.py index ea68473ae3b..9de3f9bfaff 100644 --- a/homeassistant/components/twentemilieu/diagnostics.py +++ b/homeassistant/components/twentemilieu/diagnostics.py @@ -2,29 +2,19 @@ from __future__ import annotations -from datetime import date from typing import Any -from twentemilieu import WasteType - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import DOMAIN async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[dict[WasteType, list[date]]] = hass.data[DOMAIN][ - entry.data[CONF_ID] - ] return { f"WasteType.{waste_type.name}": [ waste_date.isoformat() for waste_date in waste_dates ] - for waste_type, waste_dates in coordinator.data.items() + for waste_type, waste_dates in entry.runtime_data.data.items() } diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py index 1e0fa651998..896a8e32de9 100644 --- a/homeassistant/components/twentemilieu/entity.py +++ b/homeassistant/components/twentemilieu/entity.py @@ -2,36 +2,24 @@ from __future__ import annotations -from datetime import date - -from twentemilieu import WasteType - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import TwenteMilieuDataUpdateCoordinator from .const import DOMAIN -class TwenteMilieuEntity( - CoordinatorEntity[DataUpdateCoordinator[dict[WasteType, list[date]]]], Entity -): +class TwenteMilieuEntity(CoordinatorEntity[TwenteMilieuDataUpdateCoordinator], Entity): """Defines a Twente Milieu entity.""" _attr_has_entity_name = True - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[WasteType, list[date]]], - entry: ConfigEntry, - ) -> None: + def __init__(self, entry: ConfigEntry) -> None: """Initialize the Twente Milieu entity.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator=entry.runtime_data) self._attr_device_info = DeviceInfo( configuration_url="https://www.twentemilieu.nl", entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index f799fa62314..2d2e3de0f0e 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN from .entity import TwenteMilieuEntity @@ -69,9 +68,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Twente Milieu sensor based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.data[CONF_ID]] async_add_entities( - TwenteMilieuSensor(coordinator, description, entry) for description in SENSORS + TwenteMilieuSensor(entry, description) for description in SENSORS ) @@ -82,12 +80,11 @@ class TwenteMilieuSensor(TwenteMilieuEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[dict[WasteType, list[date]]], - description: TwenteMilieuSensorDescription, entry: ConfigEntry, + description: TwenteMilieuSensorDescription, ) -> None: """Initialize the Twente Milieu entity.""" - super().__init__(coordinator, entry) + super().__init__(entry) self.entity_description = description self._attr_unique_id = f"{DOMAIN}_{entry.data[CONF_ID]}_{description.key}" diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 60c9dcabb36..40a744684b9 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] - client = await Twitch( + client = Twitch( app_id=implementation.client_id, authenticate_app=False, ) diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py index 146d2f39088..7f006f194f5 100644 --- a/homeassistant/components/twitch/config_flow.py +++ b/homeassistant/components/twitch/config_flow.py @@ -50,7 +50,7 @@ class OAuth2FlowHandler( self.flow_impl, ) - client = await Twitch( + client = Twitch( app_id=implementation.client_id, authenticate_app=False, ) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 69a6ec423ae..b893b612f2a 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -14,7 +14,9 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN as UNIFI_DOMAIN, PLATFORMS, UNIFI_WIRELESS_CLIENTS from .errors import AuthenticationRequired, CannotConnect from .hub import UnifiHub, get_unifi_api -from .services import async_setup_services, async_unload_services +from .services import async_setup_services + +type UnifiConfigEntry = ConfigEntry[UnifiHub] SAVE_DELAY = 10 STORAGE_KEY = "unifi_data" @@ -25,16 +27,18 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(UNIFI_DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Integration doesn't support configuration through configuration.yaml.""" + async_setup_services(hass) + hass.data[UNIFI_WIRELESS_CLIENTS] = wireless_clients = UnifiWirelessClients(hass) await wireless_clients.async_load() return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: UnifiConfigEntry +) -> bool: """Set up the UniFi Network integration.""" - hass.data.setdefault(UNIFI_DOMAIN, {}) - try: api = await get_unifi_api(hass, config_entry.data) @@ -44,41 +48,33 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationRequired as err: raise ConfigEntryAuthFailed from err - hub = UnifiHub(hass, config_entry, api) + hub = config_entry.runtime_data = UnifiHub(hass, config_entry, api) await hub.initialize() - hass.data[UNIFI_DOMAIN][config_entry.entry_id] = hub await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) hub.async_update_device_registry() hub.entity_loader.load_entities() - if len(hass.data[UNIFI_DOMAIN]) == 1: - async_setup_services(hass) - hub.websocket.start() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) ) - return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: UnifiConfigEntry +) -> bool: """Unload a config entry.""" - hub: UnifiHub = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) - - if not hass.data[UNIFI_DOMAIN]: - async_unload_services(hass) - - return await hub.async_reset() + return await config_entry.runtime_data.async_reset() async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: UnifiConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove config entry from a device.""" - hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data return not any( identifier for _, identifier in device_entry.connections diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 86c38a5bf3d..6684e33e532 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -29,11 +29,11 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import UnifiConfigEntry from .entity import ( HandlerT, UnifiEntity, @@ -43,7 +43,6 @@ from .entity import ( async_wlan_available_fn, async_wlan_device_info_fn, ) -from .hub import UnifiHub async def async_restart_device_control_fn( @@ -123,15 +122,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up button platform for UniFi Network integration.""" - UnifiHub.get_hub(hass, config_entry).entity_loader.register_platform( - async_add_entities, - UnifiButtonEntity, - ENTITY_DESCRIPTIONS, - requires_admin=True, + config_entry.runtime_data.entity_loader.register_platform( + async_add_entities, UnifiButtonEntity, ENTITY_DESCRIPTIONS, requires_admin=True ) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 79b5e035f41..e93b59b0673 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -21,6 +21,7 @@ import voluptuous as vol from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -36,6 +37,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from . import UnifiConfigEntry from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -162,10 +164,11 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): config_entry = self.reauth_config_entry abort_reason = "reauth_successful" - if config_entry: - hub: UnifiHub | None = self.hass.data.get(UNIFI_DOMAIN, {}).get( - config_entry.entry_id - ) + if ( + config_entry is not None + and config_entry.state is not ConfigEntryState.NOT_LOADED + ): + hub = config_entry.runtime_data if hub and hub.available: return self.async_abort(reason="already_configured") @@ -249,7 +252,7 @@ class UnifiOptionsFlowHandler(OptionsFlow): hub: UnifiHub - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: UnifiConfigEntry) -> None: """Initialize UniFi Network options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) @@ -258,9 +261,7 @@ class UnifiOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the UniFi Network options.""" - if self.config_entry.entry_id not in self.hass.data[UNIFI_DOMAIN]: - return self.async_abort(reason="integration_not_setup") - self.hub = self.hass.data[UNIFI_DOMAIN][self.config_entry.entry_id] + self.hub = self.config_entry.runtime_data self.options[CONF_BLOCK_CLIENT] = self.hub.config.option_block_clients if self.show_advanced_options: diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index dc48b9c31fe..a1014bfd184 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -18,13 +18,13 @@ from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey from homeassistant.components.device_tracker import DOMAIN, ScannerEntity, SourceType -from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er import homeassistant.util.dt as dt_util +from . import UnifiConfigEntry from .const import DOMAIN as UNIFI_DOMAIN from .entity import ( HandlerT, @@ -185,12 +185,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( @callback -def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) -> None: """Normalize client unique ID to have a prefix rather than suffix. Introduced with release 2023.12. """ - hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data ent_reg = er.async_get(hass) @callback @@ -210,12 +210,12 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> No async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" async_update_unique_id(hass, config_entry) - UnifiHub.get_hub(hass, config_entry).entity_loader.register_platform( + config_entry.runtime_data.entity_loader.register_platform( async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 7df082ca0a4..21174342594 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -7,13 +7,11 @@ from itertools import chain from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac -from .const import DOMAIN as UNIFI_DOMAIN -from .hub import UnifiHub +from . import UnifiConfigEntry TO_REDACT = {CONF_PASSWORD} REDACT_CONFIG = {CONF_HOST, CONF_PASSWORD, CONF_USERNAME} @@ -73,10 +71,10 @@ def async_replace_list_data( async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: UnifiConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data diag: dict[str, Any] = {} macs_to_redact: dict[str, str] = {} diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 30b5ba6e686..29448a4114a 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -18,7 +18,6 @@ from homeassistant.core import callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import async_entries_for_config_entry from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS from ..entity import UnifiEntity, UnifiEntityDescription @@ -86,7 +85,7 @@ class UnifiEntityLoader: entity_registry = er.async_get(self.hub.hass) macs: list[str] = [ entry.unique_id.split("-", 1)[1] - for entry in async_entries_for_config_entry( + for entry in er.async_entries_for_config_entry( entity_registry, config.entry.entry_id ) if entry.domain == Platform.DEVICE_TRACKER and "-" in entry.unique_id diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index f8c1f2517a2..c7615714764 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -3,10 +3,10 @@ from __future__ import annotations from datetime import datetime +from typing import TYPE_CHECKING import aiounifi -from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import ( @@ -22,12 +22,18 @@ from .entity_helper import UnifiEntityHelper from .entity_loader import UnifiEntityLoader from .websocket import UnifiWebsocket +if TYPE_CHECKING: + from .. import UnifiConfigEntry + class UnifiHub: """Manages a single UniFi Network instance.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: aiounifi.Controller + self, + hass: HomeAssistant, + config_entry: UnifiConfigEntry, + api: aiounifi.Controller, ) -> None: """Initialize the system.""" self.hass = hass @@ -40,13 +46,6 @@ class UnifiHub: self.site = config_entry.data[CONF_SITE_ID] self.is_admin = False - @callback - @staticmethod - def get_hub(hass: HomeAssistant, config_entry: ConfigEntry) -> UnifiHub: - """Get UniFi hub from config entry.""" - hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - return hub - @property def available(self) -> bool: """Websocket connection state.""" @@ -122,15 +121,14 @@ class UnifiHub: @staticmethod async def async_config_entry_updated( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: UnifiConfigEntry ) -> None: """Handle signals of config entry being updated. If config entry is updated due to reauth flow the entry might already have been reset and thus is not available. """ - if not (hub := hass.data[UNIFI_DOMAIN].get(config_entry.entry_id)): - return + hub = config_entry.runtime_data hub.config = UnifiConfig.from_config_entry(config_entry) async_dispatcher_send(hass, hub.signal_options_update) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 285477fe133..bbc20e2b06b 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -14,12 +14,12 @@ from aiounifi.models.api import ApiItemT from aiounifi.models.wlan import Wlan from homeassistant.components.image import ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util +from . import UnifiConfigEntry from .entity import ( HandlerT, UnifiEntity, @@ -65,15 +65,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up image platform for UniFi Network integration.""" - UnifiHub.get_hub(hass, config_entry).entity_loader.register_platform( - async_add_entities, - UnifiImageEntity, - ENTITY_DESCRIPTIONS, - requires_admin=True, + config_entry.runtime_data.entity_loader.register_platform( + async_add_entities, UnifiImageEntity, ENTITY_DESCRIPTIONS, requires_admin=True ) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 504c2f505a7..f4bfaec2d42 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==77"], + "requirements": ["aiounifi==79"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 2685f075cd5..028d70d8880 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -32,7 +32,6 @@ from homeassistant.components.sensor import ( SensorStateClass, UnitOfTemperature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate, UnitOfPower from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -40,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util +from . import UnifiConfigEntry from .const import DEVICE_STATES from .entity import ( HandlerT, @@ -108,6 +108,27 @@ def async_wlan_client_value_fn(hub: UnifiHub, wlan: Wlan) -> int: ) +@callback +def async_device_clients_value_fn(hub: UnifiHub, device: Device) -> int: + """Calculate the amount of clients connected to a device.""" + + return len( + [ + client.mac + for client in hub.api.clients.values() + if ( + ( + client.access_point_mac != "" + and client.access_point_mac == device.mac + ) + or (client.access_point_mac == "" and client.switch_mac == device.mac) + ) + and dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) + < hub.config.option_detection_time + ] + ) + + @callback def async_device_uptime_value_fn(hub: UnifiHub, device: Device) -> datetime | None: """Calculate the approximate time the device started (based on uptime returned from API, in seconds).""" @@ -302,10 +323,25 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( unique_id_fn=lambda hub, obj_id: f"wlan_clients-{obj_id}", value_fn=async_wlan_client_value_fn, ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device clients", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + name_fn=lambda device: "Clients", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=True, + unique_id_fn=lambda hub, obj_id: f"device_clients-{obj_id}", + value_fn=async_device_clients_value_fn, + ), UnifiSensorEntityDescription[Outlets, Outlet]( key="Outlet power metering", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, api_handler_fn=lambda api: api.outlets, available_fn=async_device_available_fn, @@ -321,6 +357,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="SmartPower AC power budget", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=1, api_handler_fn=lambda api: api.devices, @@ -336,6 +373,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="SmartPower AC power consumption", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=1, api_handler_fn=lambda api: api.devices, @@ -420,11 +458,11 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Network integration.""" - UnifiHub.get_hub(hass, config_entry).entity_loader.register_platform( + config_entry.runtime_data.entity_loader.register_platform( async_add_entities, UnifiSensorEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index 096f4f27dae..ce726a0f5d0 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -6,6 +6,7 @@ from typing import Any from aiounifi.models.client import ClientReconnectRequest, ClientRemoveRequest import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr @@ -49,13 +50,6 @@ def async_setup_services(hass: HomeAssistant) -> None: ) -@callback -def async_unload_services(hass: HomeAssistant) -> None: - """Unload UniFi Network services.""" - for service in SUPPORTED_SERVICES: - hass.services.async_remove(UNIFI_DOMAIN, service) - - async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) -> None: """Try to get wireless client to reconnect to Wi-Fi.""" device_registry = dr.async_get(hass) @@ -73,9 +67,10 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - if mac == "": return - for hub in hass.data[UNIFI_DOMAIN].values(): - if ( - not hub.available + for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): + if config_entry.state is not ConfigEntryState.LOADED or ( + (hub := config_entry.runtime_data) + and not hub.available or (client := hub.api.clients.get(mac)) is None or client.is_wired ): @@ -91,8 +86,12 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Total time between first seen and last seen is less than 15 minutes. - Neither IP, hostname nor name is configured. """ - for hub in hass.data[UNIFI_DOMAIN].values(): - if not hub.available: + for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): + if ( + config_entry.state is not ConfigEntryState.LOADED + or (hub := config_entry.runtime_data) + and not hub.available + ): continue clients_to_remove = [] diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 45357dd67d2..be475803f7e 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -38,13 +38,13 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er +from . import UnifiConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN from .entity import ( HandlerT, @@ -270,12 +270,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( @callback -def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) -> None: """Normalize switch unique ID to have a prefix rather than midfix. Introduced with release 2023.12. """ - hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data ent_reg = er.async_get(hass) @callback @@ -299,12 +299,12 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> No async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" async_update_unique_id(hass, config_entry) - UnifiHub.get_hub(hass, config_entry).entity_loader.register_platform( + config_entry.runtime_data.entity_loader.register_platform( async_add_entities, UnifiSwitchEntity, ENTITY_DESCRIPTIONS, diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index a8fe3c83427..b3cfc6f1c66 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -18,17 +18,16 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import UnifiConfigEntry from .entity import ( UnifiEntity, UnifiEntityDescription, async_device_available_fn, async_device_device_info_fn, ) -from .hub import UnifiHub LOGGER = logging.getLogger(__name__) @@ -68,11 +67,11 @@ ENTITY_DESCRIPTIONS: tuple[UnifiUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for UniFi Network integration.""" - UnifiHub.get_hub(hass, config_entry).entity_loader.register_platform( + config_entry.runtime_data.entity_loader.register_platform( async_add_entities, UnifiDeviceUpdateEntity, ENTITY_DESCRIPTIONS, diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index d85f91be860..068c5665e6b 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -6,16 +6,15 @@ from datetime import timedelta import logging from aiohttp.client_exceptions import ServerDisconnectedError -from pyunifiprotect.data import Bootstrap -from pyunifiprotect.data.types import FirmwareReleaseChannel -from pyunifiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.data import Bootstrap +from uiprotect.data.types import FirmwareReleaseChannel +from uiprotect.exceptions import ClientError, NotAuthorized -# Import the test_util.anonymize module from the pyunifiprotect package +# Import the test_util.anonymize module from the uiprotect package # in __init__ to ensure it gets imported in the executor since the # diagnostics module will not be imported in the executor. -from pyunifiprotect.test_util.anonymize import anonymize_data # noqa: F401 +from uiprotect.test_util.anonymize import anonymize_data # noqa: F401 -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -37,10 +36,10 @@ from .const import ( OUTDATED_LOG_MESSAGE, PLATFORMS, ) -from .data import ProtectData, async_ufp_instance_for_config_entry_ids +from .data import ProtectData, UFPConfigEntry from .discovery import async_start_discovery from .migrate import async_migrate_data -from .services import async_cleanup_services, async_setup_services +from .services import async_setup_services from .utils import ( _async_unifi_mac_from_hass, async_create_api_client, @@ -54,15 +53,20 @@ SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +EARLY_ACCESS_URL = ( + "https://www.home-assistant.io/integrations/unifiprotect#software-support" +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" # Only start discovery once regardless of how many entries they have + async_setup_services(hass) async_start_discovery(hass) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") @@ -107,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_service + entry.runtime_data = data_service entry.async_on_unload(entry.add_update_listener(_async_options_updated)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) @@ -122,8 +126,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, "ea_channel_warning", is_fixable=True, - is_persistent=True, - learn_more_url="https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + is_persistent=False, + learn_more_url=EARLY_ACCESS_URL, severity=IssueSeverity.WARNING, translation_key="ea_channel_warning", translation_placeholders={"version": str(nvr_info.version)}, @@ -160,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, data_service: ProtectData, bootstrap: Bootstrap, ) -> None: @@ -171,30 +175,24 @@ async def _async_setup_entry( raise ConfigEntryNotReady await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async_setup_services(hass) hass.http.register_view(ThumbnailProxyView(hass)) hass.http.register_view(VideoProxyView(hass)) -async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Unload UniFi Protect config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: ProtectData = hass.data[DOMAIN][entry.entry_id] - await data.async_stop() - hass.data[DOMAIN].pop(entry.entry_id) - async_cleanup_services(hass) - - return bool(unload_ok) + await entry.runtime_data.async_stop() + return unload_ok async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: UFPConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove ufp config entry from a device.""" unifi_macs = { @@ -202,8 +200,7 @@ async def async_remove_config_entry_device( for connection in device_entry.connections if connection[0] == dr.CONNECTION_NETWORK_MAC } - api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id}) - assert api is not None + api = config_entry.runtime_data.api if api.bootstrap.nvr.mac in unifi_macs: return False for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT): diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index f779fc7a1ad..5596d3b7a62 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -2,11 +2,10 @@ from __future__ import annotations +from collections.abc import Sequence import dataclasses -import logging -from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( NVR, Camera, Light, @@ -15,38 +14,35 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, ProtectModelWithId, Sensor, + SmartDetectObjectType, ) -from pyunifiprotect.data.nvr import UOSDisk +from uiprotect.data.nvr import UOSDisk from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .data import ProtectData, UFPConfigEntry from .entity import ( + BaseProtectEntity, EventEntityMixin, ProtectDeviceEntity, ProtectNVREntity, async_all_device_entities, ) -from .models import PermRequired, ProtectEventMixin, ProtectRequiredKeysMixin -from .utils import async_dispatch_id as _ufpd +from .models import PermRequired, ProtectEntityDescription, ProtectEventMixin -_LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" @dataclasses.dataclass(frozen=True, kw_only=True) class ProtectBinaryEntityDescription( - ProtectRequiredKeysMixin, BinarySensorEntityDescription + ProtectEntityDescription, BinarySensorEntityDescription ): """Describes UniFi Protect Binary Sensor entity.""" @@ -68,13 +64,13 @@ MOUNT_DEVICE_CLASS_MAP = { CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", - name="Is Dark", + name="Is dark", icon="mdi:brightness-6", ufp_value="is_dark", ), ProtectBinaryEntityDescription( key="ssh", - name="SSH Enabled", + name="SSH enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -83,7 +79,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_status", @@ -92,7 +88,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="hdr_mode", - name="HDR Mode", + name="HDR mode", icon="mdi:brightness-7", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_hdr", @@ -110,7 +106,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="system_sounds", - name="System Sounds", + name="System sounds", icon="mdi:speaker", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="has_speaker", @@ -120,7 +116,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_name", - name="Overlay: Show Name", + name="Overlay: show name", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_name_enabled", @@ -128,7 +124,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_date", - name="Overlay: Show Date", + name="Overlay: show date", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_date_enabled", @@ -136,7 +132,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_logo", - name="Overlay: Show Logo", + name="Overlay: show logo", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_logo_enabled", @@ -144,7 +140,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="osd_bitrate", - name="Overlay: Show Bitrate", + name="Overlay: show bitrate", icon="mdi:fullscreen", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="osd_settings.is_debug_enabled", @@ -152,14 +148,14 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="motion_enabled", - name="Detections: Motion", + name="Detections: motion", icon="mdi:run-fast", ufp_value="recording_settings.enable_motion_detection", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( key="smart_person", - name="Detections: Person", + name="Detections: person", icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_person", @@ -168,16 +164,25 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_vehicle", - name="Detections: Vehicle", + name="Detections: vehicle", icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_vehicle", ufp_value="is_vehicle_detection_on", ufp_perm=PermRequired.NO_WRITE, ), + ProtectBinaryEntityDescription( + key="smart_animal", + name="Detections: animal", + icon="mdi:paw", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_animal", + ufp_value="is_animal_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), ProtectBinaryEntityDescription( key="smart_package", - name="Detections: Package", + name="Detections: package", icon="mdi:package-variant-closed", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_package", @@ -186,7 +191,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_licenseplate", - name="Detections: License Plate", + name="Detections: license plate", icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_license_plate", @@ -195,7 +200,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_smoke", - name="Detections: Smoke", + name="Detections: smoke", icon="mdi:fire", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_smoke", @@ -213,7 +218,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_siren", - name="Detections: Siren", + name="Detections: siren", icon="mdi:alarm-bell", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_siren", @@ -222,7 +227,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_baby_cry", - name="Detections: Baby Cry", + name="Detections: baby cry", icon="mdi:cradle", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_baby_cry", @@ -231,7 +236,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_speak", - name="Detections: Speaking", + name="Detections: speaking", icon="mdi:account-voice", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_speaking", @@ -240,7 +245,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_bark", - name="Detections: Barking", + name="Detections: barking", icon="mdi:dog", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_bark", @@ -249,7 +254,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_car_alarm", - name="Detections: Car Alarm", + name="Detections: car alarm", icon="mdi:car", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_car_alarm", @@ -258,7 +263,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_car_horn", - name="Detections: Car Horn", + name="Detections: car horn", icon="mdi:bugle", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_car_horn", @@ -267,7 +272,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_glass_break", - name="Detections: Glass Break", + name="Detections: glass break", icon="mdi:glass-fragile", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_glass_break", @@ -276,7 +281,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="track_person", - name="Tracking: Person", + name="Tracking: person", icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="is_ptz", @@ -288,19 +293,19 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="dark", - name="Is Dark", + name="Is dark", icon="mdi:brightness-6", ufp_value="is_dark", ), ProtectBinaryEntityDescription( key="motion", - name="Motion Detected", + name="Motion detected", device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_pir_motion_detected", ), ProtectBinaryEntityDescription( key="light", - name="Flood Light", + name="Flood light", icon="mdi:spotlight-beam", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="is_light_on", @@ -308,7 +313,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="ssh", - name="SSH Enabled", + name="SSH enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -317,7 +322,7 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_device_settings.is_indicator_enabled", @@ -325,7 +330,9 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ) -SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( +# The mountable sensors can be remounted at run-time which +# means they can change their device class at run-time. +MOUNTABLE_SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key=_KEY_DOOR, name="Contact", @@ -333,6 +340,9 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_value="is_opened", ufp_enabled="is_contact_sensor_enabled", ), +) + +SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="leak", name="Leak", @@ -349,20 +359,20 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="motion", - name="Motion Detected", + name="Motion detected", device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_motion_detected", ufp_enabled="is_motion_sensor_enabled", ), ProtectBinaryEntityDescription( key="tampering", - name="Tampering Detected", + name="Tampering detected", device_class=BinarySensorDeviceClass.TAMPER, ufp_value="is_tampering_detected", ), ProtectBinaryEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="led_settings.is_enabled", @@ -370,7 +380,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="motion_enabled", - name="Motion Detection", + name="Motion detection", icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="motion_settings.is_enabled", @@ -378,7 +388,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="temperature", - name="Temperature Sensor", + name="Temperature sensor", icon="mdi:thermometer", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="temperature_settings.is_enabled", @@ -386,7 +396,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="humidity", - name="Humidity Sensor", + name="Humidity sensor", icon="mdi:water-percent", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="humidity_settings.is_enabled", @@ -394,7 +404,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="light", - name="Light Sensor", + name="Light sensor", icon="mdi:brightness-5", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="light_settings.is_enabled", @@ -402,7 +412,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="alarm", - name="Alarm Sound Detection", + name="Alarm sound detection", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="alarm_settings.is_enabled", ufp_perm=PermRequired.NO_WRITE, @@ -427,127 +437,139 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_enabled="is_motion_detection_on", ufp_event_obj="last_motion_event", ), +) + +SMART_EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_obj_any", - name="Object Detected", + name="Object detected", icon="mdi:eye", - ufp_value="is_smart_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_detect_event", + entity_registry_enabled_default=False, ), ProtectBinaryEventEntityDescription( key="smart_obj_person", - name="Person Detected", + name="Person detected", icon="mdi:walk", - ufp_value="is_person_currently_detected", + ufp_obj_type=SmartDetectObjectType.PERSON, ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", ufp_event_obj="last_person_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_vehicle", - name="Vehicle Detected", + name="Vehicle detected", icon="mdi:car", - ufp_value="is_vehicle_currently_detected", + ufp_obj_type=SmartDetectObjectType.VEHICLE, ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", ufp_event_obj="last_vehicle_detect_event", ), + ProtectBinaryEventEntityDescription( + key="smart_obj_animal", + name="Animal detected", + icon="mdi:paw", + ufp_obj_type=SmartDetectObjectType.ANIMAL, + ufp_required_field="can_detect_animal", + ufp_enabled="is_animal_detection_on", + ufp_event_obj="last_animal_detect_event", + ), ProtectBinaryEventEntityDescription( key="smart_obj_package", - name="Package Detected", + name="Package detected", icon="mdi:package-variant-closed", - ufp_value="is_package_currently_detected", entity_registry_enabled_default=False, + ufp_obj_type=SmartDetectObjectType.PACKAGE, ufp_required_field="can_detect_package", ufp_enabled="is_package_detection_on", ufp_event_obj="last_package_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_any", - name="Audio Object Detected", + name="Audio object detected", icon="mdi:eye", - ufp_value="is_audio_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_audio_detect_event", + entity_registry_enabled_default=False, ), ProtectBinaryEventEntityDescription( key="smart_audio_smoke", - name="Smoke Alarm Detected", + name="Smoke alarm detected", icon="mdi:fire", - ufp_value="is_smoke_currently_detected", + ufp_obj_type=SmartDetectObjectType.SMOKE, ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", ufp_event_obj="last_smoke_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", - name="CO Alarm Detected", + name="CO alarm detected", icon="mdi:molecule-co", - ufp_value="is_cmonx_currently_detected", ufp_required_field="can_detect_co", ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", + ufp_obj_type=SmartDetectObjectType.CMONX, ), ProtectBinaryEventEntityDescription( key="smart_audio_siren", - name="Siren Detected", + name="Siren detected", icon="mdi:alarm-bell", - ufp_value="is_siren_currently_detected", + ufp_obj_type=SmartDetectObjectType.SIREN, ufp_required_field="can_detect_siren", ufp_enabled="is_siren_detection_on", ufp_event_obj="last_siren_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_baby_cry", - name="Baby Cry Detected", + name="Baby cry detected", icon="mdi:cradle", - ufp_value="is_baby_cry_currently_detected", + ufp_obj_type=SmartDetectObjectType.BABY_CRY, ufp_required_field="can_detect_baby_cry", ufp_enabled="is_baby_cry_detection_on", ufp_event_obj="last_baby_cry_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_speak", - name="Speaking Detected", + name="Speaking detected", icon="mdi:account-voice", - ufp_value="is_speaking_currently_detected", + ufp_obj_type=SmartDetectObjectType.SPEAK, ufp_required_field="can_detect_speaking", ufp_enabled="is_speaking_detection_on", ufp_event_obj="last_speaking_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_bark", - name="Barking Detected", + name="Barking detected", icon="mdi:dog", - ufp_value="is_bark_currently_detected", + ufp_obj_type=SmartDetectObjectType.BARK, ufp_required_field="can_detect_bark", ufp_enabled="is_bark_detection_on", ufp_event_obj="last_bark_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_car_alarm", - name="Car Alarm Detected", + name="Car alarm detected", icon="mdi:car", - ufp_value="is_car_alarm_currently_detected", + ufp_obj_type=SmartDetectObjectType.BURGLAR, ufp_required_field="can_detect_car_alarm", ufp_enabled="is_car_alarm_detection_on", ufp_event_obj="last_car_alarm_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_car_horn", - name="Car Horn Detected", + name="Car horn detected", icon="mdi:bugle", - ufp_value="is_car_horn_currently_detected", + ufp_obj_type=SmartDetectObjectType.CAR_HORN, ufp_required_field="can_detect_car_horn", ufp_enabled="is_car_horn_detection_on", ufp_event_obj="last_car_horn_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_glass_break", - name="Glass Break Detected", + name="Glass break detected", icon="mdi:glass-fragile", - ufp_value="last_glass_break_detect", + ufp_obj_type=SmartDetectObjectType.GLASS_BREAK, ufp_required_field="can_detect_glass_break", ufp_enabled="is_glass_break_detection_on", ufp_event_obj="last_glass_break_detect_event", @@ -564,7 +586,7 @@ DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="led_settings.is_enabled", @@ -575,7 +597,7 @@ DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( VIEWER_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="ssh", - name="SSH Enabled", + name="SSH enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -593,94 +615,17 @@ DISK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ) +_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CAMERA: CAMERA_SENSORS, + ModelType.LIGHT: LIGHT_SENSORS, + ModelType.SENSOR: SENSE_SENSORS, + ModelType.DOORLOCK: DOORLOCK_SENSORS, + ModelType.VIEWPORT: VIEWER_SENSORS, +} -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up binary sensors for UniFi Protect integration.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] - - @callback - def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, - ProtectDeviceBinarySensor, - camera_descs=CAMERA_SENSORS, - light_descs=LIGHT_SENSORS, - sense_descs=SENSE_SENSORS, - lock_descs=DOORLOCK_SENSORS, - viewer_descs=VIEWER_SENSORS, - ufp_device=device, - ) - if device.is_adopted and isinstance(device, Camera): - entities += _async_event_entities(data, ufp_device=device) - async_add_entities(entities) - - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) - ) - - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, - ProtectDeviceBinarySensor, - camera_descs=CAMERA_SENSORS, - light_descs=LIGHT_SENSORS, - sense_descs=SENSE_SENSORS, - lock_descs=DOORLOCK_SENSORS, - viewer_descs=VIEWER_SENSORS, - ) - entities += _async_event_entities(data) - entities += _async_nvr_entities(data) - - async_add_entities(entities) - - -@callback -def _async_event_entities( - data: ProtectData, - ufp_device: ProtectAdoptableDeviceModel | None = None, -) -> list[ProtectDeviceEntity]: - entities: list[ProtectDeviceEntity] = [] - devices = ( - data.get_by_types({ModelType.CAMERA}) if ufp_device is None else [ufp_device] - ) - for device in devices: - for description in EVENT_SENSORS: - if not description.has_required(device): - continue - entities.append(ProtectEventBinarySensor(data, device, description)) - _LOGGER.debug( - "Adding binary sensor entity %s for %s", - description.name, - device.display_name, - ) - - return entities - - -@callback -def _async_nvr_entities( - data: ProtectData, -) -> list[ProtectDeviceEntity]: - entities: list[ProtectDeviceEntity] = [] - device = data.api.bootstrap.nvr - if device.system_info.ustorage is None: - return entities - - for disk in device.system_info.ustorage.disks: - for description in DISK_SENSORS: - if not disk.has_disk: - continue - - entities.append(ProtectDiskBinarySensor(data, device, description, disk)) - _LOGGER.debug( - "Adding binary sensor entity %s", - f"{disk.type} {disk.slot}", - ) - - return entities +_MOUNTABLE_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.SENSOR: MOUNTABLE_SENSE_SENSORS, +} class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): @@ -688,30 +633,31 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): device: Camera | Light | Sensor entity_description: ProtectBinaryEntityDescription + _state_attrs: tuple[str, ...] = ("_attr_available", "_attr_is_on") @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - entity_description = self.entity_description - updated_device = self.device - self._attr_is_on = entity_description.get_ufp_value(updated_device) - # UP Sense can be any of the 3 contact sensor device classes - if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - 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 + self._attr_is_on = self.entity_description.get_ufp_value(self.device) + + +class MountableProtectDeviceBinarySensor(ProtectDeviceBinarySensor): + """A UniFi Protect Device Binary Sensor that can change device class at runtime.""" + + device: Sensor + _state_attrs: tuple[str, ...] = ( + "_attr_available", + "_attr_is_on", + "_attr_device_class", + ) @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_is_on, self._attr_device_class) + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) + # UP Sense can be any of the 3 contact sensor device classes + self._attr_device_class = MOUNT_DEVICE_CLASS_MAP.get( + self.device.mount_type, BinarySensorDeviceClass.DOOR + ) class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): @@ -719,6 +665,7 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): _disk: UOSDisk entity_description: ProtectBinaryEntityDescription + _state_attrs = ("_attr_available", "_attr_is_on") def __init__( self, @@ -742,7 +689,6 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - slot = self._disk.slot self._attr_available = False @@ -757,41 +703,129 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): self._attr_is_on = not self._disk.is_healthy - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_is_on) - class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): """A UniFi Protect Device Binary Sensor for events.""" entity_description: ProtectBinaryEventEntityDescription + _state_attrs = ("_attr_available", "_attr_is_on", "_attr_extra_state_attributes") @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - is_on = self.entity_description.get_is_on(self.device, self._event) - self._attr_is_on: bool | None = is_on - if not is_on: - self._event = None + description = self.entity_description + event = self.entity_description.get_event_obj(device) + if is_on := bool(description.get_ufp_value(device)): + if event: + self._set_event_attrs(event) + else: self._attr_extra_state_attributes = {} + self._attr_is_on = is_on + + +class ProtectSmartEventBinarySensor(EventEntityMixin, BinarySensorEntity): + """A UniFi Protect Device Binary Sensor for smart events.""" + + device: Camera + entity_description: ProtectBinaryEventEntityDescription + _state_attrs = ("_attr_available", "_attr_is_on", "_attr_extra_state_attributes") @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. + def _set_event_done(self) -> None: + self._attr_is_on = False + self._attr_extra_state_attributes = {} - Called before and after updating entity and state is only written if there - is a change. - """ + @callback + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + description = self.entity_description - return ( - self._attr_available, - self._attr_is_on, - self._attr_extra_state_attributes, + prev_event = self._event + prev_event_end = self._event_end + super()._async_update_device_from_protect(device) + if event := description.get_event_obj(device): + self._event = event + self._event_end = event.end if event else None + + if not ( + event + and description.has_matching_smart(event) + and not self._event_already_ended(prev_event, prev_event_end) + ): + self._set_event_done() + return + + self._attr_is_on = True + self._set_event_attrs(event) + if event.end: + self._async_event_with_immediate_end() + + +MODEL_DESCRIPTIONS_WITH_CLASS = ( + (_MODEL_DESCRIPTIONS, ProtectDeviceBinarySensor), + (_MOUNTABLE_MODEL_DESCRIPTIONS, MountableProtectDeviceBinarySensor), +) + + +@callback +def _async_event_entities( + data: ProtectData, + ufp_device: ProtectAdoptableDeviceModel | None = None, +) -> list[ProtectDeviceEntity]: + entities: list[ProtectDeviceEntity] = [] + for device in data.get_cameras() if ufp_device is None else [ufp_device]: + entities.extend( + ProtectSmartEventBinarySensor(data, device, description) + for description in SMART_EVENT_SENSORS + if description.has_required(device) ) + entities.extend( + ProtectEventBinarySensor(data, device, description) + for description in EVENT_SENSORS + if description.has_required(device) + ) + return entities + + +@callback +def _async_nvr_entities( + data: ProtectData, +) -> list[BaseProtectEntity]: + device = data.api.bootstrap.nvr + if (ustorage := device.system_info.ustorage) is None: + return [] + return [ + ProtectDiskBinarySensor(data, device, description, disk) + for disk in ustorage.disks + for description in DISK_SENSORS + if disk.has_disk + ] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors for UniFi Protect integration.""" + data = entry.runtime_data + + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities: list[BaseProtectEntity] = [] + for model_descriptions, klass in MODEL_DESCRIPTIONS_WITH_CLASS: + entities += async_all_device_entities( + data, klass, model_descriptions=model_descriptions, ufp_device=device + ) + if device.is_adopted and isinstance(device, Camera): + entities += _async_event_entities(data, ufp_device=device) + async_add_entities(entities) + + data.async_subscribe_adopt(_add_new_device) + entities: list[BaseProtectEntity] = [] + for model_descriptions, klass in MODEL_DESCRIPTIONS_WITH_CLASS: + entities += async_all_device_entities( + data, klass, model_descriptions=model_descriptions + ) + entities += _async_event_entities(data) + entities += _async_nvr_entities(data) + async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index db27306aedf..6c0ef37e1df 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -2,29 +2,28 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass import logging from typing import Final -from pyunifiprotect.data import ProtectAdoptableDeviceModel, ProtectModelWithId +from uiprotect.data import ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEVICES_THAT_ADOPT, DISPATCH_ADD, DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .const import DEVICES_THAT_ADOPT, DOMAIN +from .data import UFPConfigEntry from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import PermRequired, ProtectSetableKeysMixin, T -from .utils import async_dispatch_id as _ufpd +from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T _LOGGER = logging.getLogger(__name__) @@ -47,14 +46,14 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( key="reboot", entity_registry_enabled_default=False, device_class=ButtonDeviceClass.RESTART, - name="Reboot Device", + name="Reboot device", ufp_press="reboot", ufp_perm=PermRequired.WRITE, ), ProtectButtonEntityDescription( key="unadopt", entity_registry_enabled_default=False, - name="Unadopt Device", + name="Unadopt device", icon="mdi:delete", ufp_press="unadopt", ufp_perm=PermRequired.DELETE, @@ -63,7 +62,7 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ADOPT_BUTTON = ProtectButtonEntityDescription[ProtectAdoptableDeviceModel]( key=KEY_ADOPT, - name="Adopt Device", + name="Adopt device", icon="mdi:plus-circle", ufp_press="adopt", ) @@ -71,7 +70,7 @@ ADOPT_BUTTON = ProtectButtonEntityDescription[ProtectAdoptableDeviceModel]( SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="clear_tamper", - name="Clear Tamper", + name="Clear tamper", icon="mdi:notification-clear-all", ufp_press="clear_tamper", ufp_perm=PermRequired.WRITE, @@ -81,20 +80,26 @@ SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="play", - name="Play Chime", + name="Play chime", device_class=DEVICE_CLASS_CHIME_BUTTON, icon="mdi:play", ufp_press="play", ), ProtectButtonEntityDescription( key="play_buzzer", - name="Play Buzzer", + name="Play buzzer", icon="mdi:play", ufp_press="play_buzzer", ), ) +_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CHIME: CHIME_BUTTONS, + ModelType.SENSOR: SENSOR_BUTTONS, +} + + @callback def _async_remove_adopt_button( hass: HomeAssistant, device: ProtectAdoptableDeviceModel @@ -108,11 +113,11 @@ def _async_remove_adopt_button( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Discover devices on a UniFi Protect NVR.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: @@ -121,8 +126,7 @@ async def async_setup_entry( ProtectButton, all_descs=ALL_DEVICE_BUTTONS, unadopted_descs=[ADOPT_BUTTON], - chime_descs=CHIME_BUTTONS, - sense_descs=SENSOR_BUTTONS, + model_descriptions=_MODEL_DESCRIPTIONS, ufp_device=device, ) async_add_entities(entities) @@ -133,34 +137,30 @@ async def async_setup_entry( if not device.can_adopt or not device.can_create(data.api.bootstrap.auth_user): _LOGGER.debug("Device is not adoptable: %s", device.id) return + async_add_entities( + async_all_device_entities( + data, + ProtectButton, + unadopted_descs=[ADOPT_BUTTON], + ufp_device=device, + ) + ) - entities = async_all_device_entities( + data.async_subscribe_adopt(_add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, data.add_signal, _async_add_unadopted_device) + ) + + async_add_entities( + async_all_device_entities( data, ProtectButton, + all_descs=ALL_DEVICE_BUTTONS, unadopted_descs=[ADOPT_BUTTON], - ufp_device=device, - ) - async_add_entities(entities) - - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) - ) - entry.async_on_unload( - async_dispatcher_connect( - hass, _ufpd(entry, DISPATCH_ADD), _async_add_unadopted_device + model_descriptions=_MODEL_DESCRIPTIONS, ) ) - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, - ProtectButton, - all_descs=ALL_DEVICE_BUTTONS, - unadopted_descs=[ADOPT_BUTTON], - chime_descs=CHIME_BUTTONS, - sense_descs=SENSOR_BUTTONS, - ) - async_add_entities(entities) - for device in data.get_by_types(DEVICES_THAT_ADOPT): _async_remove_adopt_button(hass, device) @@ -170,20 +170,9 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): entity_description: ProtectButtonEntityDescription - def __init__( - self, - data: ProtectData, - device: ProtectAdoptableDeviceModel, - description: ProtectButtonEntityDescription, - ) -> None: - """Initialize an UniFi camera.""" - super().__init__(data, device, description) - self._attr_name = f"{self.device.display_name} {self.entity_description.name}" - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - if self.entity_description.key == KEY_ADOPT: device = self.device self._attr_available = device.can_adopt and device.can_create( @@ -192,6 +181,5 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - if self.entity_description.ufp_press is not None: await getattr(self.device, self.entity_description.ufp_press)() diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 8e10c09872b..2a97aa26823 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -2,21 +2,18 @@ from __future__ import annotations -from collections.abc import Generator import logging -from typing import Any, cast -from pyunifiprotect.data import ( +from typing_extensions import Generator +from uiprotect.data import ( Camera as UFPCamera, CameraChannel, - ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId, StateType, ) from homeassistant.components.camera import Camera, CameraEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -29,20 +26,18 @@ from .const import ( ATTR_FPS, ATTR_HEIGHT, ATTR_WIDTH, - DISPATCH_ADOPT, - DISPATCH_CHANNELS, DOMAIN, ) -from .data import ProtectData +from .data import ProtectData, UFPConfigEntry from .entity import ProtectDeviceEntity -from .utils import async_dispatch_id as _ufpd, get_camera_base_name +from .utils import get_camera_base_name _LOGGER = logging.getLogger(__name__) @callback def _create_rtsp_repair( - hass: HomeAssistant, entry: ConfigEntry, data: ProtectData, camera: UFPCamera + hass: HomeAssistant, entry: UFPConfigEntry, data: ProtectData, camera: UFPCamera ) -> None: edit_key = "readonly" if camera.can_write(data.api.bootstrap.auth_user): @@ -68,17 +63,14 @@ def _create_rtsp_repair( @callback def _get_camera_channels( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, data: ProtectData, ufp_device: UFPCamera | None = None, -) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]: +) -> Generator[tuple[UFPCamera, CameraChannel, bool]]: """Get all the camera channels.""" - devices = ( - data.get_by_types({ModelType.CAMERA}) if ufp_device is None else [ufp_device] - ) - for camera in devices: - camera = cast(UFPCamera, camera) + cameras = data.get_cameras() if ufp_device is None else [ufp_device] + for camera in cameras: if not camera.channels: if ufp_device is None: # only warn on startup @@ -108,7 +100,7 @@ def _get_camera_channels( def _async_camera_entities( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, data: ProtectData, ufp_device: UFPCamera | None = None, ) -> list[ProtectDeviceEntity]: @@ -146,35 +138,37 @@ def _async_camera_entities( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Discover cameras on a UniFi Protect NVR.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if not isinstance(device, UFPCamera): return + async_add_entities(_async_camera_entities(hass, entry, data, ufp_device=device)) - entities = _async_camera_entities(hass, entry, data, ufp_device=device) - async_add_entities(entities) - + data.async_subscribe_adopt(_add_new_device) entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) - ) - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_CHANNELS), _add_new_device) + async_dispatcher_connect(hass, data.channels_signal, _add_new_device) ) + async_add_entities(_async_camera_entities(hass, entry, data)) - entities = _async_camera_entities(hass, entry, data) - async_add_entities(entities) + +_EMPTY_CAMERA_FEATURES = CameraEntityFeature(0) class ProtectCamera(ProtectDeviceEntity, Camera): """A Ubiquiti UniFi Protect Camera.""" device: UFPCamera + _state_attrs = ( + "_attr_available", + "_attr_is_recording", + "_attr_motion_detection_enabled", + ) def __init__( self, @@ -196,10 +190,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera): camera_name = get_camera_base_name(channel) if self._secure: self._attr_unique_id = f"{device.mac}_{channel.id}" - self._attr_name = f"{device.display_name} {camera_name}" + self._attr_name = camera_name else: self._attr_unique_id = f"{device.mac}_{channel.id}_insecure" - self._attr_name = f"{device.display_name} {camera_name} (Insecure)" + self._attr_name = f"{camera_name} (insecure)" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure @@ -214,27 +208,12 @@ class ProtectCamera(ProtectDeviceEntity, Camera): rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url # _async_set_stream_source called by __init__ - self._stream_source = ( # pylint: disable=attribute-defined-outside-init - None if disable_stream else rtsp_url - ) + # pylint: disable-next=attribute-defined-outside-init + self._stream_source = None if disable_stream else rtsp_url if self._stream_source: self._attr_supported_features = CameraEntityFeature.STREAM else: - self._attr_supported_features = CameraEntityFeature(0) - - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return ( - self._attr_available, - self._attr_is_recording, - self._attr_motion_detection_enabled, - ) + self._attr_supported_features = _EMPTY_CAMERA_FEATURES @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 19561a6003d..284b7003485 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -8,9 +8,9 @@ from pathlib import Path from typing import Any from aiohttp import CookieJar -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import NVR -from pyunifiprotect.exceptions import ClientError, NotAuthorized +from uiprotect import ProtectApiClient +from uiprotect.data import NVR +from uiprotect.exceptions import ClientError, NotAuthorized from unifi_discovery import async_console_is_alive import voluptuous as vol diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 39be5f0e7cb..9839d823585 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -1,6 +1,6 @@ """Constant definitions for UniFi Protect Integration.""" -from pyunifiprotect.data import ModelType, Version +from uiprotect.data import ModelType, Version from homeassistant.const import Platform @@ -35,7 +35,7 @@ CONFIG_OPTIONS = [ DEFAULT_PORT = 443 DEFAULT_ATTRIBUTION = "Powered by UniFi Protect Server" DEFAULT_BRAND = "Ubiquiti" -DEFAULT_SCAN_INTERVAL = 20 +DEFAULT_SCAN_INTERVAL = 60 DEFAULT_VERIFY_SSL = False DEFAULT_MAX_MEDIA = 1000 diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 6c5a1472015..e3e4cbc7f50 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -2,31 +2,35 @@ from __future__ import annotations -from collections.abc import Callable, Generator, Iterable +from collections import defaultdict +from collections.abc import Callable, Iterable from datetime import datetime, timedelta from functools import partial import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from typing_extensions import Generator +from uiprotect import ProtectApiClient +from uiprotect.data import ( NVR, Bootstrap, Camera, Event, EventType, - Liveview, ModelType, ProtectAdoptableDeviceModel, WSSubscriptionMessage, ) -from pyunifiprotect.exceptions import ClientError, NotAuthorized -from pyunifiprotect.utils import log_event +from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.utils import log_event from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.event import async_track_time_interval from .const import ( @@ -40,20 +44,25 @@ from .const import ( DISPATCH_CHANNELS, DOMAIN, ) -from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type +from .utils import async_get_devices_by_type _LOGGER = logging.getLogger(__name__) -ProtectDeviceType = ProtectAdoptableDeviceModel | NVR +type ProtectDeviceType = ProtectAdoptableDeviceModel | NVR +type UFPConfigEntry = ConfigEntry[ProtectData] @callback -def async_last_update_was_successful(hass: HomeAssistant, entry: ConfigEntry) -> bool: +def async_last_update_was_successful( + hass: HomeAssistant, entry: UFPConfigEntry +) -> bool: """Check if the last update was successful for a config entry.""" - return bool( - DOMAIN in hass.data - and entry.entry_id in hass.data[DOMAIN] - and hass.data[DOMAIN][entry.entry_id].last_update_success - ) + return hasattr(entry, "runtime_data") and entry.runtime_data.last_update_success + + +@callback +def _async_dispatch_id(entry: UFPConfigEntry, dispatch: str) -> str: + """Generate entry specific dispatch ID.""" + return f"{DOMAIN}.{entry.entry_id}.{dispatch}" class ProtectData: @@ -64,23 +73,24 @@ class ProtectData: hass: HomeAssistant, protect: ProtectApiClient, update_interval: timedelta, - entry: ConfigEntry, + entry: UFPConfigEntry, ) -> None: """Initialize an subscriber.""" - super().__init__() - - self._hass = hass self._entry = entry self._hass = hass self._update_interval = update_interval - self._subscriptions: dict[str, list[Callable[[ProtectDeviceType], None]]] = {} + self._subscriptions: defaultdict[ + str, set[Callable[[ProtectDeviceType], None]] + ] = defaultdict(set) self._pending_camera_ids: set[str] = set() self._unsub_interval: CALLBACK_TYPE | None = None self._unsub_websocket: CALLBACK_TYPE | None = None self._auth_failures = 0 - self.last_update_success = False self.api = protect + self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT) + self.add_signal = _async_dispatch_id(entry, DISPATCH_ADD) + self.channels_signal = _async_dispatch_id(entry, DISPATCH_CHANNELS) @property def disable_stream(self) -> bool: @@ -92,9 +102,18 @@ class ProtectData: """Max number of events to load at once.""" return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) + @callback + def async_subscribe_adopt( + self, add_callback: Callable[[ProtectAdoptableDeviceModel], None] + ) -> None: + """Add an callback for on device adopt.""" + self._entry.async_on_unload( + async_dispatcher_connect(self._hass, self.adopt_signal, add_callback) + ) + def get_by_types( self, device_types: Iterable[ModelType], ignore_unadopted: bool = True - ) -> Generator[ProtectAdoptableDeviceModel, None, None]: + ) -> Generator[ProtectAdoptableDeviceModel]: """Get all devices matching types.""" for device_type in device_types: devices = async_get_devices_by_type( @@ -105,6 +124,12 @@ class ProtectData: continue yield device + def get_cameras(self, ignore_unadopted: bool = True) -> Generator[Camera]: + """Get all cameras.""" + return cast( + Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted) + ) + async def async_setup(self) -> None: """Subscribe and do the refresh.""" self._unsub_websocket = self.api.subscribe_websocket( @@ -166,12 +191,10 @@ class ProtectData: def _async_add_device(self, device: ProtectAdoptableDeviceModel) -> None: if device.is_adopted_by_us: _LOGGER.debug("Device adopted: %s", device.id) - async_dispatcher_send( - self._hass, _ufpd(self._entry, DISPATCH_ADOPT), device - ) + async_dispatcher_send(self._hass, self.adopt_signal, device) else: _LOGGER.debug("New device detected: %s", device.id) - async_dispatcher_send(self._hass, _ufpd(self._entry, DISPATCH_ADD), device) + async_dispatcher_send(self._hass, self.add_signal, device) @callback def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None: @@ -196,9 +219,7 @@ class ProtectData: and "channels" in changed_data ): self._pending_camera_ids.remove(device.id) - async_dispatcher_send( - self._hass, _ufpd(self._entry, DISPATCH_CHANNELS), device - ) + async_dispatcher_send(self._hass, self.channels_signal, device) # trigger update for all Cameras with LCD screens when NVR Doorbell settings updates if "doorbell_settings" in changed_data: @@ -206,48 +227,55 @@ class ProtectData: "Doorbell messages updated. Updating devices with LCD screens" ) self.api.bootstrap.nvr.update_all_messages() - for camera in self.get_by_types({ModelType.CAMERA}): - camera = cast(Camera, camera) + for camera in self.get_cameras(): if camera.feature_flags.has_lcd_screen: self._async_signal_device_update(camera) @callback def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: - if message.new_obj is None: + """Process a message from the websocket.""" + if (new_obj := message.new_obj) is None: if isinstance(message.old_obj, ProtectAdoptableDeviceModel): self._async_remove_device(message.old_obj) return - obj = message.new_obj - if isinstance(obj, (ProtectAdoptableDeviceModel, NVR)): - if message.old_obj is None and isinstance(obj, ProtectAdoptableDeviceModel): - self._async_add_device(obj) - elif getattr(obj, "is_adopted_by_us", True): - self._async_update_device(obj, message.changed_data) - - # trigger updates for camera that the event references - elif isinstance(obj, Event): + model_type = new_obj.model + if model_type is ModelType.EVENT: + if TYPE_CHECKING: + assert isinstance(new_obj, Event) if _LOGGER.isEnabledFor(logging.DEBUG): - log_event(obj) - if obj.type is EventType.DEVICE_ADOPTED: - if obj.metadata is not None and obj.metadata.device_id is not None: - device = self.api.bootstrap.get_device_from_id( - obj.metadata.device_id - ) - if device is not None: - self._async_add_device(device) - elif obj.camera is not None: - self._async_signal_device_update(obj.camera) - elif obj.light is not None: - self._async_signal_device_update(obj.light) - elif obj.sensor is not None: - self._async_signal_device_update(obj.sensor) - # alert user viewport needs restart so voice clients can get new options - elif len(self.api.bootstrap.viewers) > 0 and isinstance(obj, Liveview): + log_event(new_obj) + if ( + (new_obj.type is EventType.DEVICE_ADOPTED) + and (metadata := new_obj.metadata) + and (device_id := metadata.device_id) + and (device := self.api.bootstrap.get_device_from_id(device_id)) + ): + self._async_add_device(device) + elif camera := new_obj.camera: + self._async_signal_device_update(camera) + elif light := new_obj.light: + self._async_signal_device_update(light) + elif sensor := new_obj.sensor: + self._async_signal_device_update(sensor) + return + + if model_type is ModelType.LIVEVIEW and len(self.api.bootstrap.viewers) > 0: + # alert user viewport needs restart so voice clients can get new options _LOGGER.warning( "Liveviews updated. Restart Home Assistant to update Viewport select" " options" ) + return + + if message.old_obj is None and isinstance(new_obj, ProtectAdoptableDeviceModel): + self._async_add_device(new_obj) + return + + if getattr(new_obj, "is_adopted_by_us", True) and hasattr(new_obj, "mac"): + if TYPE_CHECKING: + assert isinstance(new_obj, (ProtectAdoptableDeviceModel, NVR)) + self._async_update_device(new_obj, message.changed_data) @callback def _async_process_updates(self, updates: Bootstrap | None) -> None: @@ -277,7 +305,7 @@ class ProtectData: ) @callback - def async_subscribe_device_id( + def async_subscribe( self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> CALLBACK_TYPE: """Add an callback subscriber.""" @@ -285,11 +313,11 @@ class ProtectData: self._unsub_interval = async_track_time_interval( self._hass, self._async_poll, self._update_interval ) - self._subscriptions.setdefault(mac, []).append(update_callback) - return partial(self.async_unsubscribe_device_id, mac, update_callback) + self._subscriptions[mac].add(update_callback) + return partial(self._async_unsubscribe, mac, update_callback) @callback - def async_unsubscribe_device_id( + def _async_unsubscribe( self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> None: """Remove a callback subscriber.""" @@ -303,21 +331,66 @@ class ProtectData: @callback def _async_signal_device_update(self, device: ProtectDeviceType) -> None: """Call the callbacks for a device_id.""" - if not (subscriptions := self._subscriptions.get(device.mac)): + mac = device.mac + if not (subscriptions := self._subscriptions.get(mac)): return - _LOGGER.debug("Updating device: %s (%s)", device.name, device.mac) + _LOGGER.debug("Updating device: %s (%s)", device.name, mac) for update_callback in subscriptions: update_callback(device) @callback def async_ufp_instance_for_config_entry_ids( - hass: HomeAssistant, config_entry_ids: set[str] + hass: HomeAssistant, config_entry_ids: list[str] ) -> ProtectApiClient | None: """Find the UFP instance for the config entry ids.""" - domain_data = hass.data[DOMAIN] - for config_entry_id in config_entry_ids: - if config_entry_id in domain_data: - protect_data: ProtectData = domain_data[config_entry_id] - return protect_data.api + return next( + iter( + entry.runtime_data.api + for entry_id in config_entry_ids + if (entry := hass.config_entries.async_get_entry(entry_id)) + and hasattr(entry, "runtime_data") + ), + None, + ) + + +@callback +def async_get_ufp_entries(hass: HomeAssistant) -> list[UFPConfigEntry]: + """Get all the UFP entries.""" + return cast( + list[UFPConfigEntry], + [ + entry + for entry in hass.config_entries.async_entries( + DOMAIN, include_ignore=True, include_disabled=True + ) + if hasattr(entry, "runtime_data") + ], + ) + + +@callback +def async_get_data_for_nvr_id(hass: HomeAssistant, nvr_id: str) -> ProtectData | None: + """Find the ProtectData instance for the NVR id.""" + return next( + iter( + entry.runtime_data + for entry in async_get_ufp_entries(hass) + if entry.runtime_data.api.bootstrap.nvr.id == nvr_id + ), + None, + ) + + +@callback +def async_get_data_for_entry_id( + hass: HomeAssistant, entry_id: str +) -> ProtectData | None: + """Find the ProtectData instance for a config entry id.""" + if (entry := hass.config_entries.async_get_entry(entry_id)) and hasattr( + entry, "runtime_data" + ): + entry = cast(UFPConfigEntry, entry) + return entry.runtime_data return None diff --git a/homeassistant/components/unifiprotect/diagnostics.py b/homeassistant/components/unifiprotect/diagnostics.py index b85870a08c5..b72f35db0b5 100644 --- a/homeassistant/components/unifiprotect/diagnostics.py +++ b/homeassistant/components/unifiprotect/diagnostics.py @@ -4,20 +4,18 @@ from __future__ import annotations from typing import Any, cast -from pyunifiprotect.test_util.anonymize import anonymize_data +from uiprotect.test_util.anonymize import anonymize_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .data import ProtectData +from .data import UFPConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: UFPConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: ProtectData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data bootstrap = cast(dict[str, Any], anonymize_data(data.api.bootstrap.unifi_dict())) return {"bootstrap": bootstrap, "options": dict(config_entry.options)} diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 49478ce0582..7eceb861955 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -3,29 +3,25 @@ from __future__ import annotations from collections.abc import Callable, Sequence +from datetime import datetime +from functools import partial import logging -from typing import TYPE_CHECKING, Any +from operator import attrgetter +from typing import TYPE_CHECKING -from pyunifiprotect.data import ( +from uiprotect.data import ( NVR, - Camera, - Chime, - Doorlock, Event, - Light, ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId, - Sensor, StateType, - Viewer, ) from homeassistant.core import callback import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.typing import UNDEFINED from .const import ( ATTR_EVENT_ID, @@ -35,7 +31,7 @@ from .const import ( DOMAIN, ) from .data import ProtectData -from .models import PermRequired, ProtectEventMixin, ProtectRequiredKeysMixin +from .models import PermRequired, ProtectEntityDescription, ProtectEventMixin _LOGGER = logging.getLogger(__name__) @@ -43,52 +39,51 @@ _LOGGER = logging.getLogger(__name__) @callback def _async_device_entities( data: ProtectData, - klass: type[ProtectDeviceEntity], + klass: type[BaseProtectEntity], model_type: ModelType, - descs: Sequence[ProtectRequiredKeysMixin], - unadopted_descs: Sequence[ProtectRequiredKeysMixin], + descs: Sequence[ProtectEntityDescription], + unadopted_descs: Sequence[ProtectEntityDescription] | None = None, ufp_device: ProtectAdoptableDeviceModel | None = None, -) -> list[ProtectDeviceEntity]: +) -> list[BaseProtectEntity]: if not descs and not unadopted_descs: return [] - entities: list[ProtectDeviceEntity] = [] + entities: list[BaseProtectEntity] = [] devices = ( [ufp_device] if ufp_device is not None else data.get_by_types({model_type}, ignore_unadopted=False) ) + auth_user = data.api.bootstrap.auth_user for device in devices: if TYPE_CHECKING: - assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime)) + assert isinstance(device, ProtectAdoptableDeviceModel) if not device.is_adopted_by_us: - for description in unadopted_descs: - entities.append( - klass( - data, - device=device, - description=description, + if unadopted_descs: + for description in unadopted_descs: + entities.append( + klass( + data, + device=device, + description=description, + ) + ) + _LOGGER.debug( + "Adding %s entity %s for %s", + klass.__name__, + description.name, + device.display_name, ) - ) - _LOGGER.debug( - "Adding %s entity %s for %s", - klass.__name__, - description.name, - device.display_name, - ) continue - can_write = device.can_write(data.api.bootstrap.auth_user) + can_write = device.can_write(auth_user) for description in descs: - if description.ufp_perm is not None: - if description.ufp_perm is PermRequired.WRITE and not can_write: + if (perms := description.ufp_perm) is not None: + if perms is PermRequired.WRITE and not can_write: continue - if description.ufp_perm is PermRequired.NO_WRITE and can_write: + if perms is PermRequired.NO_WRITE and can_write: continue - if ( - description.ufp_perm is PermRequired.DELETE - and not device.can_delete(data.api.bootstrap.auth_user) - ): + if perms is PermRequired.DELETE and not device.can_delete(auth_user): continue if not description.has_required(device): @@ -111,112 +106,93 @@ def _async_device_entities( return entities +_ALL_MODEL_TYPES = ( + ModelType.CAMERA, + ModelType.LIGHT, + ModelType.SENSOR, + ModelType.VIEWPORT, + ModelType.DOORLOCK, + ModelType.CHIME, +) + + +@callback +def _combine_model_descs( + model_type: ModelType, + model_descriptions: dict[ModelType, Sequence[ProtectEntityDescription]] | None, + all_descs: Sequence[ProtectEntityDescription] | None, +) -> list[ProtectEntityDescription]: + """Combine all the descriptions with descriptions a model type.""" + descs: list[ProtectEntityDescription] = list(all_descs) if all_descs else [] + if model_descriptions and (model_descs := model_descriptions.get(model_type)): + descs.extend(model_descs) + return descs + + @callback def async_all_device_entities( data: ProtectData, - klass: type[ProtectDeviceEntity], - camera_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - light_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - viewer_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - chime_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, - unadopted_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + klass: type[BaseProtectEntity], + model_descriptions: dict[ModelType, Sequence[ProtectEntityDescription]] + | None = None, + all_descs: Sequence[ProtectEntityDescription] | None = None, + unadopted_descs: list[ProtectEntityDescription] | None = None, ufp_device: ProtectAdoptableDeviceModel | None = None, -) -> list[ProtectDeviceEntity]: +) -> list[BaseProtectEntity]: """Generate a list of all the device entities.""" - all_descs = list(all_descs or []) - unadopted_descs = list(unadopted_descs or []) - camera_descs = list(camera_descs or []) + all_descs - light_descs = list(light_descs or []) + all_descs - sense_descs = list(sense_descs or []) + all_descs - viewer_descs = list(viewer_descs or []) + all_descs - lock_descs = list(lock_descs or []) + all_descs - chime_descs = list(chime_descs or []) + all_descs - if ufp_device is None: - return ( - _async_device_entities( - data, klass, ModelType.CAMERA, camera_descs, unadopted_descs + entities: list[BaseProtectEntity] = [] + for model_type in _ALL_MODEL_TYPES: + descs = _combine_model_descs(model_type, model_descriptions, all_descs) + entities.extend( + _async_device_entities(data, klass, model_type, descs, unadopted_descs) ) - + _async_device_entities( - data, klass, ModelType.LIGHT, light_descs, unadopted_descs - ) - + _async_device_entities( - data, klass, ModelType.SENSOR, sense_descs, unadopted_descs - ) - + _async_device_entities( - data, klass, ModelType.VIEWPORT, viewer_descs, unadopted_descs - ) - + _async_device_entities( - data, klass, ModelType.DOORLOCK, lock_descs, unadopted_descs - ) - + _async_device_entities( - data, klass, ModelType.CHIME, chime_descs, unadopted_descs - ) - ) + return entities - descs = [] - if ufp_device.model is ModelType.CAMERA: - descs = camera_descs - elif ufp_device.model is ModelType.LIGHT: - descs = light_descs - elif ufp_device.model is ModelType.SENSOR: - descs = sense_descs - elif ufp_device.model is ModelType.VIEWPORT: - descs = viewer_descs - elif ufp_device.model is ModelType.DOORLOCK: - descs = lock_descs - elif ufp_device.model is ModelType.CHIME: - descs = chime_descs - - if not descs and not unadopted_descs or ufp_device.model is None: - return [] + device_model_type = ufp_device.model + assert device_model_type is not None + descs = _combine_model_descs(device_model_type, model_descriptions, all_descs) return _async_device_entities( - data, klass, ufp_device.model, descs, unadopted_descs, ufp_device + data, klass, device_model_type, descs, unadopted_descs, ufp_device ) -class ProtectDeviceEntity(Entity): +class BaseProtectEntity(Entity): """Base class for UniFi protect entities.""" - device: ProtectAdoptableDeviceModel + device: ProtectAdoptableDeviceModel | NVR _attr_should_poll = False + _attr_attribution = DEFAULT_ATTRIBUTION + _state_attrs: tuple[str, ...] = ("_attr_available",) + _attr_has_entity_name = True + _async_get_ufp_enabled: Callable[[ProtectAdoptableDeviceModel], bool] | None = None def __init__( self, data: ProtectData, - device: ProtectAdoptableDeviceModel, + device: ProtectAdoptableDeviceModel | NVR, description: EntityDescription | None = None, ) -> None: """Initialize the entity.""" super().__init__() - self.data: ProtectData = data + self.data = data self.device = device - self._async_get_ufp_enabled: ( - Callable[[ProtectAdoptableDeviceModel], bool] | None - ) = None if description is None: - self._attr_unique_id = f"{self.device.mac}" - self._attr_name = f"{self.device.display_name}" + self._attr_unique_id = self.device.mac + self._attr_name = None else: self.entity_description = description self._attr_unique_id = f"{self.device.mac}_{description.key}" - name = ( - description.name - if description.name and description.name is not UNDEFINED - else "" - ) - self._attr_name = f"{self.device.display_name} {name.title()}" - if isinstance(description, ProtectRequiredKeysMixin): + if isinstance(description, ProtectEntityDescription): self._async_get_ufp_enabled = description.get_ufp_enabled - self._attr_attribution = DEFAULT_ATTRIBUTION self._async_set_device_info() self._async_update_device_from_protect(device) + self._state_getters = tuple( + partial(attrgetter(attr), self) for attr in self._state_attrs + ) async def async_update(self) -> None: """Update the entity. @@ -256,24 +232,18 @@ class ProtectDeviceEntity(Entity): and (not async_get_ufp_enabled or async_get_ufp_enabled(device)) ) - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available,) - @callback def _async_updated_event(self, device: ProtectAdoptableDeviceModel | NVR) -> None: """When device is updated from Protect.""" - - previous_attrs = self._async_get_state_attrs() + previous_attrs = [getter() for getter in self._state_getters] self._async_update_device_from_protect(device) - current_attrs = self._async_get_state_attrs() - if previous_attrs != current_attrs: + changed = False + for idx, getter in enumerate(self._state_getters): + if previous_attrs[idx] != getter(): + changed = True + break + + if changed: if _LOGGER.isEnabledFor(logging.DEBUG): device_name = device.name or "" if hasattr(self, "entity_description") and self.entity_description.name: @@ -284,7 +254,7 @@ class ProtectDeviceEntity(Entity): device_name, device.mac, previous_attrs, - current_attrs, + tuple((getattr(self, attr)) for attr in self._state_attrs), ) self.async_write_ha_state() @@ -292,26 +262,20 @@ class ProtectDeviceEntity(Entity): """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove( - self.data.async_subscribe_device_id( - self.device.mac, self._async_updated_event - ) + self.data.async_subscribe(self.device.mac, self._async_updated_event) ) -class ProtectNVREntity(ProtectDeviceEntity): +class ProtectDeviceEntity(BaseProtectEntity): + """Base class for UniFi protect entities.""" + + device: ProtectAdoptableDeviceModel + + +class ProtectNVREntity(BaseProtectEntity): """Base class for unifi protect entities.""" - # separate subclass on purpose - device: NVR # type: ignore[assignment] - - def __init__( - self, - entry: ProtectData, - device: NVR, - description: EntityDescription | None = None, - ) -> None: - """Initialize the entity.""" - super().__init__(entry, device, description) # type: ignore[arg-type] + device: NVR @callback def _async_set_device_info(self) -> None: @@ -328,8 +292,7 @@ class ProtectNVREntity(ProtectDeviceEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: data = self.data - last_update_success = data.last_update_success - if last_update_success: + if last_update_success := data.last_update_success: self.device = data.api.bootstrap.nvr self._attr_available = last_update_success @@ -338,28 +301,48 @@ class ProtectNVREntity(ProtectDeviceEntity): class EventEntityMixin(ProtectDeviceEntity): """Adds motion event attributes to sensor.""" - _unrecorded_attributes = frozenset({ATTR_EVENT_ID, ATTR_EVENT_SCORE}) - entity_description: ProtectEventMixin - - def __init__( - self, - *args: Any, - **kwarg: Any, - ) -> None: - """Init an sensor that has event thumbnails.""" - super().__init__(*args, **kwarg) - self._event: Event | None = None + _unrecorded_attributes = frozenset({ATTR_EVENT_ID, ATTR_EVENT_SCORE}) + _event: Event | None = None + _event_end: datetime | None = None @callback - def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: - event = self.entity_description.get_event_obj(device) - if event is not None: - self._attr_extra_state_attributes = { - ATTR_EVENT_ID: event.id, - ATTR_EVENT_SCORE: event.score, - } - else: - self._attr_extra_state_attributes = {} - self._event = event - super()._async_update_device_from_protect(device) + def _set_event_done(self) -> None: + """Clear the event and state.""" + + @callback + def _set_event_attrs(self, event: Event) -> None: + """Set event attrs.""" + self._attr_extra_state_attributes = { + ATTR_EVENT_ID: event.id, + ATTR_EVENT_SCORE: event.score, + } + + @callback + def _async_event_with_immediate_end(self) -> None: + # If the event is so short that the detection is received + # in the same message as the end of the event we need to write + # state and than clear the event and write state again. + self.async_write_ha_state() + self._set_event_done() + self.async_write_ha_state() + + @callback + def _event_already_ended( + self, prev_event: Event | None, prev_event_end: datetime | None + ) -> bool: + """Determine if the event has already ended. + + The event_end time is passed because the prev_event and event object + may be the same object, and the uiprotect code will mutate the + event object so we need to check the datetime object that was + saved from the last time the entity was updated. + """ + event = self._event + return bool( + event + and event.end + and prev_event + and prev_event_end + and prev_event.id == event.id + ) diff --git a/homeassistant/components/unifiprotect/icons.json b/homeassistant/components/unifiprotect/icons.json index b357a892ff4..bb713d4ee79 100644 --- a/homeassistant/components/unifiprotect/icons.json +++ b/homeassistant/components/unifiprotect/icons.json @@ -2,7 +2,6 @@ "services": { "add_doorbell_text": "mdi:message-plus", "remove_doorbell_text": "mdi:message-minus", - "set_default_doorbell_text": "mdi:message-processing", "set_chime_paired_doorbells": "mdi:bell-cog", "remove_privacy_zone": "mdi:eye-minus" } diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 3ce236b3e23..651b9c7d3d4 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( Light, ModelType, ProtectAdoptableDeviceModel, @@ -13,26 +13,22 @@ from pyunifiprotect.data import ( ) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -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 DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .data import UFPConfigEntry from .entity import ProtectDeviceEntity -from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up lights for UniFi Protect integration.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: @@ -41,10 +37,7 @@ async def async_setup_entry( ): async_add_entities([ProtectLight(data, device)]) - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) - ) - + data.async_subscribe_adopt(_add_new_device) async_add_entities( ProtectLight(data, device) for device in data.get_by_types({ModelType.LIGHT}) @@ -70,16 +63,7 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_icon = "mdi:spotlight-beam" _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_is_on, self._attr_brightness) + _state_attrs = ("_attr_available", "_attr_is_on", "_attr_brightness") @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index c54f9b316ff..b649813135b 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any, cast -from pyunifiprotect.data import ( +from uiprotect.data import ( Doorlock, LockStatusType, ModelType, @@ -14,79 +14,50 @@ from pyunifiprotect.data import ( ) from homeassistant.components.lock import LockEntity, LockEntityDescription -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 DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .data import UFPConfigEntry from .entity import ProtectDeviceEntity -from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up locks on a UniFi Protect NVR.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if isinstance(device, Doorlock): async_add_entities([ProtectLock(data, device)]) - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + data.async_subscribe_adopt(_add_new_device) + + async_add_entities( + ProtectLock( + data, cast(Doorlock, device), LockEntityDescription(key="lock", name="Lock") + ) + for device in data.get_by_types({ModelType.DOORLOCK}) ) - entities = [] - for device in data.get_by_types({ModelType.DOORLOCK}): - device = cast(Doorlock, device) - entities.append(ProtectLock(data, device)) - - async_add_entities(entities) - class ProtectLock(ProtectDeviceEntity, LockEntity): """A Ubiquiti UniFi Protect Speaker.""" device: Doorlock entity_description: LockEntityDescription - - def __init__( - self, - data: ProtectData, - doorlock: Doorlock, - ) -> None: - """Initialize an UniFi lock.""" - super().__init__( - data, - doorlock, - LockEntityDescription(key="lock"), - ) - - self._attr_name = f"{self.device.display_name} Lock" - - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return ( - self._attr_available, - self._attr_is_locked, - self._attr_is_locking, - self._attr_is_unlocking, - self._attr_is_jammed, - ) + _state_attrs = ( + "_attr_available", + "_attr_is_locked", + "_attr_is_locking", + "_attr_is_unlocking", + "_attr_is_jammed", + ) @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a26fab2e80b..987329abbba 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -1,7 +1,7 @@ { "domain": "unifiprotect", "name": "UniFi Protect", - "codeowners": ["@AngellusMortis", "@bdraco"], + "codeowners": [], "config_flow": true, "dependencies": ["http", "repairs"], "dhcp": [ @@ -39,9 +39,8 @@ "documentation": "https://www.home-assistant.io/integrations/unifiprotect", "integration_type": "hub", "iot_class": "local_push", - "loggers": ["pyunifiprotect", "unifi_discovery"], - "quality_scale": "platinum", - "requirements": ["pyunifiprotect==5.1.2", "unifi-discovery==1.1.8"], + "loggers": ["uiprotect", "unifi_discovery"], + "requirements": ["uiprotect==1.20.0", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 50fec39e9cb..d9b2dad7220 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -3,16 +3,15 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, - ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId, StateType, ) -from pyunifiprotect.exceptions import StreamError +from uiprotect.exceptions import StreamError from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -25,27 +24,27 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .data import UFPConfigEntry from .entity import ProtectDeviceEntity -from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) +_SPEAKER_DESCRIPTION = MediaPlayerEntityDescription( + key="speaker", name="Speaker", device_class=MediaPlayerDeviceClass.SPEAKER +) + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Discover cameras with speakers on a UniFi Protect NVR.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: @@ -54,18 +53,13 @@ async def async_setup_entry( ): async_add_entities([ProtectMediaPlayer(data, device)]) - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + data.async_subscribe_adopt(_add_new_device) + async_add_entities( + ProtectMediaPlayer(data, device, _SPEAKER_DESCRIPTION) + for device in data.get_cameras() + if device.has_speaker or device.has_removable_speaker ) - entities = [] - for device in data.get_by_types({ModelType.CAMERA}): - device = cast(Camera, device) - if device.has_speaker or device.has_removable_speaker: - entities.append(ProtectMediaPlayer(data, device)) - - async_add_entities(entities) - class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): """A Ubiquiti UniFi Protect Speaker.""" @@ -79,23 +73,8 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.BROWSE_MEDIA ) - - def __init__( - self, - data: ProtectData, - camera: Camera, - ) -> None: - """Initialize an UniFi speaker.""" - super().__init__( - data, - camera, - MediaPlayerEntityDescription( - key="speaker", device_class=MediaPlayerDeviceClass.SPEAKER - ), - ) - - self._attr_name = f"{self.device.display_name} Speaker" - self._attr_media_content_type = MediaType.MUSIC + _attr_media_content_type = MediaType.MUSIC + _state_attrs = ("_attr_available", "_attr_state", "_attr_volume_level") @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -117,16 +96,6 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): ) self._attr_available = is_connected and updated_device.feature_flags.has_speaker - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_state, self._attr_volume_level) - async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" diff --git a/homeassistant/components/unifiprotect/media_source.py b/homeassistant/components/unifiprotect/media_source.py index ba962891454..a646c037d62 100644 --- a/homeassistant/components/unifiprotect/media_source.py +++ b/homeassistant/components/unifiprotect/media_source.py @@ -7,15 +7,9 @@ from datetime import date, datetime, timedelta from enum import Enum from typing import Any, NoReturn, cast -from pyunifiprotect.data import ( - Camera, - Event, - EventType, - ModelType, - SmartDetectObjectType, -) -from pyunifiprotect.exceptions import NvrError -from pyunifiprotect.utils import from_js_time +from uiprotect.data import Camera, Event, EventType, SmartDetectObjectType +from uiprotect.exceptions import NvrError +from uiprotect.utils import from_js_time from yarl import URL from homeassistant.components.camera import CameraImageView @@ -32,7 +26,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .const import DOMAIN -from .data import ProtectData +from .data import ProtectData, async_get_ufp_entries from .views import async_generate_event_video_url, async_generate_thumbnail_url VIDEO_FORMAT = "video/mp4" @@ -87,21 +81,15 @@ EVENT_NAME_MAP = { } -def get_ufp_event(event_type: SimpleEventType) -> set[EventType]: - """Get UniFi Protect event type from SimpleEventType.""" - - return EVENT_MAP[event_type] - - async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up UniFi Protect media source.""" - - data_sources: dict[str, ProtectData] = {} - for data in hass.data.get(DOMAIN, {}).values(): - if isinstance(data, ProtectData): - data_sources[data.api.bootstrap.nvr.id] = data - - return ProtectMediaSource(hass, data_sources) + return ProtectMediaSource( + hass, + { + entry.runtime_data.api.bootstrap.nvr.id: entry.runtime_data + for entry in async_get_ufp_entries(hass) + }, + ) @callback @@ -494,7 +482,7 @@ class ProtectMediaSource(MediaSource): ) -> list[BrowseMediaSource]: """Build media source for a given range of time and event type.""" - event_types = event_types or get_ufp_event(SimpleEventType.ALL) + event_types = event_types or EVENT_MAP[SimpleEventType.ALL] types = list(event_types) sources: list[BrowseMediaSource] = [] events = await data.api.get_events_raw( @@ -549,21 +537,20 @@ class ProtectMediaSource(MediaSource): return source now = dt_util.now() - - args = { - "data": data, - "start": now - timedelta(days=days), - "end": now, - "reserve": True, - "event_types": get_ufp_event(event_type), - } - camera: Camera | None = None + event_camera_id: str | None = None if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) - args["camera_id"] = camera_id + event_camera_id = camera_id - events = await self._build_events(**args) # type: ignore[arg-type] + events = await self._build_events( + data=data, + start=now - timedelta(days=days), + end=now, + camera_id=event_camera_id, + event_types=EVENT_MAP[event_type], + reserve=True, + ) source.children = events source.title = self._breadcrumb( data, @@ -670,7 +657,7 @@ class ProtectMediaSource(MediaSource): hour=0, minute=0, second=0, - tzinfo=dt_util.DEFAULT_TIME_ZONE, + tzinfo=dt_util.get_default_time_zone(), ) if is_all: if start_dt.month < 12: @@ -680,21 +667,21 @@ class ProtectMediaSource(MediaSource): else: end_dt = start_dt + timedelta(hours=24) - args = { - "data": data, - "start": start_dt, - "end": end_dt, - "reserve": False, - "event_types": get_ufp_event(event_type), - } - camera: Camera | None = None + event_camera_id: str | None = None if camera_id != "all": camera = data.api.bootstrap.cameras.get(camera_id) - args["camera_id"] = camera_id + event_camera_id = camera_id title = f"{start.strftime('%B %Y')} > {title}" - events = await self._build_events(**args) # type: ignore[arg-type] + events = await self._build_events( + data=data, + start=start_dt, + end=end_dt, + camera_id=event_camera_id, + reserve=False, + event_types=EVENT_MAP[event_type], + ) source.children = events source.title = self._breadcrumb( data, @@ -855,8 +842,7 @@ class ProtectMediaSource(MediaSource): cameras: list[BrowseMediaSource] = [await self._build_camera(data, "all")] - for camera in data.get_by_types({ModelType.CAMERA}): - camera = cast(Camera, camera) + for camera in data.get_cameras(): if not camera.can_read_media(data.api.bootstrap.auth_user): continue cameras.append(await self._build_camera(data, camera.id)) diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 1fbf8bab8e2..e469b684518 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -4,20 +4,20 @@ from __future__ import annotations from itertools import chain import logging +from typing import TypedDict -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import Bootstrap -from typing_extensions import TypedDict +from uiprotect import ProtectApiClient +from uiprotect.data import Bootstrap from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.issue_registry import IssueSeverity from .const import DOMAIN +from .data import UFPConfigEntry _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ class EntityUsage(TypedDict): @callback def check_if_used( - hass: HomeAssistant, entry: ConfigEntry, entities: dict[str, EntityRef] + hass: HomeAssistant, entry: UFPConfigEntry, entities: dict[str, EntityRef] ) -> dict[str, EntityUsage]: """Check for usages of entities and return them.""" @@ -67,7 +67,7 @@ def check_if_used( @callback def create_repair_if_used( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, breaks_in: str, entities: dict[str, EntityRef], ) -> None: @@ -101,7 +101,7 @@ def create_repair_if_used( async def async_migrate_data( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, protect: ProtectApiClient, bootstrap: Bootstrap, ) -> None: @@ -113,7 +113,7 @@ async def async_migrate_data( @callback -def async_deprecate_hdr_package(hass: HomeAssistant, entry: ConfigEntry) -> None: +def async_deprecate_hdr_package(hass: HomeAssistant, entry: UFPConfigEntry) -> None: """Check for usages of hdr_mode switch and package sensor and raise repair if it is used. UniFi Protect v3.0.22 changed how HDR works so it is no longer a simple on/off toggle. There is diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index a9c79556135..23106a4e5d7 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -5,29 +5,26 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import Enum +from functools import partial import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from operator import attrgetter +from typing import Any, Generic, TypeVar -from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel +from uiprotect import make_enabled_getter, make_required_getter, make_value_getter +from uiprotect.data import ( + NVR, + Event, + ProtectAdoptableDeviceModel, + SmartDetectObjectType, +) from homeassistant.helpers.entity import EntityDescription -from .utils import get_nested_attr - _LOGGER = logging.getLogger(__name__) T = TypeVar("T", bound=ProtectAdoptableDeviceModel | NVR) -def split_tuple(value: tuple[str, ...] | str | None) -> tuple[str, ...] | None: - """Split string to tuple.""" - if value is None: - return None - if TYPE_CHECKING: - assert isinstance(value, str) - return tuple(value.split(".")) - - class PermRequired(int, Enum): """Type of permission level required for entity.""" @@ -37,92 +34,70 @@ class PermRequired(int, Enum): @dataclass(frozen=True, kw_only=True) -class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): - """Mixin for required keys.""" +class ProtectEntityDescription(EntityDescription, Generic[T]): + """Base class for protect entity descriptions.""" - # `ufp_required_field`, `ufp_value`, and `ufp_enabled` are defined as - # a `str` in the dataclass, but `__post_init__` converts it to a - # `tuple[str, ...]` to avoid doing it at run time in `get_nested_attr` - # which is usually called millions of times per day. - ufp_required_field: tuple[str, ...] | str | None = None - ufp_value: tuple[str, ...] | str | None = None + ufp_required_field: str | None = None + ufp_value: str | None = None ufp_value_fn: Callable[[T], Any] | None = None - ufp_enabled: tuple[str, ...] | str | None = None + ufp_enabled: str | None = None ufp_perm: PermRequired | None = None - def __post_init__(self) -> None: - """Pre-convert strings to tuples for faster get_nested_attr.""" - 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)) + # The below are set in __post_init__ + has_required: Callable[[T], bool] = bool + get_ufp_enabled: Callable[[T], bool] | None = None def get_ufp_value(self, obj: T) -> Any: - """Return value from UniFi Protect device.""" - if (ufp_value := self.ufp_value) is not None: - if TYPE_CHECKING: - # `ufp_value` is defined as a `str` in the dataclass, but - # `__post_init__` converts it to a `tuple[str, ...]` to avoid - # doing it at run time in `get_nested_attr` which is usually called - # millions of times per day. This tells mypy that it's a tuple. - assert isinstance(ufp_value, tuple) - return get_nested_attr(obj, ufp_value) - if (ufp_value_fn := self.ufp_value_fn) is not None: - return ufp_value_fn(obj) - - # reminder for future that one is required + """Return value from UniFi Protect device; overridden in __post_init__.""" + # ufp_value or ufp_value_fn are required, the + # RuntimeError is to catch any issues in the code + # with new descriptions. raise RuntimeError( # pragma: no cover - "`ufp_value` or `ufp_value_fn` is required" + f"`ufp_value` or `ufp_value_fn` is required for {self}" ) - def get_ufp_enabled(self, obj: T) -> bool: - """Return value from UniFi Protect device.""" - if (ufp_enabled := self.ufp_enabled) is not None: - if TYPE_CHECKING: - # `ufp_enabled` is defined as a `str` in the dataclass, but - # `__post_init__` converts it to a `tuple[str, ...]` to avoid - # doing it at run time in `get_nested_attr` which is usually called - # millions of times per day. This tells mypy that it's a tuple. - assert isinstance(ufp_enabled, tuple) - return bool(get_nested_attr(obj, ufp_enabled)) - return True + def __post_init__(self) -> None: + """Override get_ufp_value, has_required, and get_ufp_enabled if required.""" + _setter = partial(object.__setattr__, self) - def has_required(self, obj: T) -> bool: - """Return if has required field.""" - if (ufp_required_field := self.ufp_required_field) is None: - return True - if TYPE_CHECKING: - # `ufp_required_field` is defined as a `str` in the dataclass, but - # `__post_init__` converts it to a `tuple[str, ...]` to avoid - # doing it at run time in `get_nested_attr` which is usually called - # millions of times per day. This tells mypy that it's a tuple. - assert isinstance(ufp_required_field, tuple) - return bool(get_nested_attr(obj, ufp_required_field)) + if (ufp_value := self.ufp_value) is not None: + _setter("get_ufp_value", make_value_getter(ufp_value)) + elif (ufp_value_fn := self.ufp_value_fn) is not None: + _setter("get_ufp_value", ufp_value_fn) + + if (ufp_enabled := self.ufp_enabled) is not None: + _setter("get_ufp_enabled", make_enabled_getter(ufp_enabled)) + + if (ufp_required_field := self.ufp_required_field) is not None: + _setter("has_required", make_required_getter(ufp_required_field)) @dataclass(frozen=True, kw_only=True) -class ProtectEventMixin(ProtectRequiredKeysMixin[T]): +class ProtectEventMixin(ProtectEntityDescription[T]): """Mixin for events.""" ufp_event_obj: str | None = None + ufp_obj_type: SmartDetectObjectType | None = None def get_event_obj(self, obj: T) -> Event | None: """Return value from UniFi Protect device.""" - - if self.ufp_event_obj is not None: - event: Event | None = getattr(obj, self.ufp_event_obj, None) - return event return None - def get_is_on(self, obj: T, event: Event | None) -> bool: - """Return value if event is active.""" + def has_matching_smart(self, event: Event) -> bool: + """Determine if the detection type is a match.""" + return ( + not (obj_type := self.ufp_obj_type) or obj_type in event.smart_detect_types + ) - return event is not None and self.get_ufp_value(obj) + def __post_init__(self) -> None: + """Override get_event_obj if ufp_event_obj is set.""" + if (_ufp_event_obj := self.ufp_event_obj) is not None: + object.__setattr__(self, "get_event_obj", attrgetter(_ufp_event_obj)) + super().__post_init__() @dataclass(frozen=True, kw_only=True) -class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): +class ProtectSetableKeysMixin(ProtectEntityDescription[T]): """Mixin for settable values.""" ufp_set_method: str | None = None diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 49c629ac42f..a0d360af80b 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -2,33 +2,27 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from datetime import timedelta -import logging -from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, Doorlock, Light, + ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId, ) from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .data import ProtectData, UFPConfigEntry 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__) +from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T @dataclass(frozen=True, kw_only=True) @@ -65,7 +59,7 @@ def _get_chime_duration(obj: Camera) -> int: CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="wdr_value", - name="Wide Dynamic Range", + name="Wide dynamic range", icon="mdi:state-machine", entity_category=EntityCategory.CONFIG, ufp_min=0, @@ -78,7 +72,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="mic_level", - name="Microphone Level", + name="Microphone level", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -93,7 +87,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="zoom_position", - name="Zoom Level", + name="Zoom level", icon="mdi:magnify-plus-outline", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -107,7 +101,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="chime_duration", - name="Chime Duration", + name="Chime duration", icon="mdi:bell", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -122,7 +116,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription( key="icr_lux", - name="Infrared Custom Lux Trigger", + name="Infrared custom lux trigger", icon="mdi:white-balance-sunny", entity_category=EntityCategory.CONFIG, ufp_min=1, @@ -139,7 +133,7 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="sensitivity", - name="Motion Sensitivity", + name="Motion sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -153,7 +147,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ), ProtectNumberEntityDescription[Light]( key="duration", - name="Auto-shutoff Duration", + name="Auto-shutoff duration", icon="mdi:camera-timer", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -170,7 +164,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription( key="sensitivity", - name="Motion Sensitivity", + name="Motion sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -187,7 +181,7 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ProtectNumberEntityDescription[Doorlock]( key="auto_lock_time", - name="Auto-lock Timeout", + name="Auto-lock timeout", icon="mdi:walk", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTime.SECONDS, @@ -216,52 +210,50 @@ CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_perm=PermRequired.WRITE, ), ) +_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CAMERA: CAMERA_NUMBERS, + ModelType.LIGHT: LIGHT_NUMBERS, + ModelType.SENSOR: SENSE_NUMBERS, + ModelType.DOORLOCK: DOORLOCK_NUMBERS, + ModelType.CHIME: CHIME_NUMBERS, +} async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up number entities for UniFi Protect integration.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - entities = async_all_device_entities( + async_add_entities( + async_all_device_entities( + data, + ProtectNumbers, + model_descriptions=_MODEL_DESCRIPTIONS, + ufp_device=device, + ) + ) + + data.async_subscribe_adopt(_add_new_device) + async_add_entities( + async_all_device_entities( data, ProtectNumbers, - camera_descs=CAMERA_NUMBERS, - light_descs=LIGHT_NUMBERS, - sense_descs=SENSE_NUMBERS, - lock_descs=DOORLOCK_NUMBERS, - chime_descs=CHIME_NUMBERS, - ufp_device=device, + model_descriptions=_MODEL_DESCRIPTIONS, ) - async_add_entities(entities) - - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) ) - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, - ProtectNumbers, - camera_descs=CAMERA_NUMBERS, - light_descs=LIGHT_NUMBERS, - sense_descs=SENSE_NUMBERS, - lock_descs=DOORLOCK_NUMBERS, - chime_descs=CHIME_NUMBERS, - ) - - async_add_entities(entities) - class ProtectNumbers(ProtectDeviceEntity, NumberEntity): """A UniFi Protect Number Entity.""" device: Camera | Light entity_description: ProtectNumberEntityDescription + _state_attrs = ("_attr_available", "_attr_native_value") def __init__( self, @@ -283,13 +275,3 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) - - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_native_value) diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index ddd5dc087a1..020da0a03f6 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -4,18 +4,19 @@ from __future__ import annotations from typing import cast -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import Bootstrap, Camera, ModelType -from pyunifiprotect.data.types import FirmwareReleaseChannel +from uiprotect import ProtectApiClient +from uiprotect.data import Bootstrap, Camera, ModelType +from uiprotect.data.types import FirmwareReleaseChannel import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry +from homeassistant.helpers import issue_registry as ir from .const import CONF_ALLOW_EA +from .data import UFPConfigEntry, async_get_data_for_entry_id from .utils import async_create_api_client @@ -23,9 +24,9 @@ class ProtectRepair(RepairsFlow): """Handler for an issue fixing flow.""" _api: ProtectApiClient - _entry: ConfigEntry + _entry: UFPConfigEntry - def __init__(self, *, api: ProtectApiClient, entry: ConfigEntry) -> None: + def __init__(self, *, api: ProtectApiClient, entry: UFPConfigEntry) -> None: """Create flow.""" self._api = api @@ -34,7 +35,7 @@ class ProtectRepair(RepairsFlow): @callback def _async_get_placeholders(self) -> dict[str, str]: - issue_registry = async_get_issue_registry(self.hass) + issue_registry = ir.async_get(self.hass) description_placeholders = {} if issue := issue_registry.async_get_issue(self.handler, self.issue_id): description_placeholders = issue.translation_placeholders or {} @@ -128,7 +129,7 @@ class RTSPRepair(ProtectRepair): self, *, api: ProtectApiClient, - entry: ConfigEntry, + entry: UFPConfigEntry, camera_id: str, ) -> None: """Create flow.""" @@ -219,29 +220,34 @@ class RTSPRepair(ProtectRepair): ) +@callback +def _async_get_or_create_api_client( + hass: HomeAssistant, entry: ConfigEntry +) -> ProtectApiClient: + """Get or create an API client.""" + if data := async_get_data_for_entry_id(hass, entry.entry_id): + return data.api + return async_create_api_client(hass, entry) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" - if data is not None and issue_id == "ea_channel_warning": - entry_id = cast(str, data["entry_id"]) - if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: - api = async_create_api_client(hass, entry) + if ( + data is not None + and "entry_id" in data + and (entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"]))) + ): + api = _async_get_or_create_api_client(hass, entry) + if issue_id == "ea_channel_warning": return EAConfirmRepair(api=api, entry=entry) - - elif data is not None and issue_id == "cloud_user": - entry_id = cast(str, data["entry_id"]) - if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: - api = async_create_api_client(hass, entry) + if issue_id == "cloud_user": return CloudAccountRepair(api=api, entry=entry) - - elif data is not None and issue_id.startswith("rtsp_disabled_"): - entry_id = cast(str, data["entry_id"]) - camera_id = cast(str, data["camera_id"]) - if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: - api = async_create_api_client(hass, entry) - return RTSPRepair(api=api, entry=entry, camera_id=camera_id) - + if issue_id.startswith("rtsp_disabled_"): + return RTSPRepair( + api=api, entry=entry, camera_id=cast(str, data["camera_id"]) + ) return ConfirmRepairFlow() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 6ba90948fca..9e742caa9ce 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -2,14 +2,14 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import Enum import logging from typing import Any, Final -from pyunifiprotect.api import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect.api import ProtectApiClient +from uiprotect.data import ( Camera, ChimeType, DoorbellMessageType, @@ -18,6 +18,7 @@ from pyunifiprotect.data import ( Light, LightModeEnableType, LightModeType, + ModelType, MountType, ProtectAdoptableDeviceModel, ProtectModelWithId, @@ -27,17 +28,15 @@ from pyunifiprotect.data import ( ) from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE -from .data import ProtectData +from .const import TYPE_EMPTY_VALUE +from .data import ProtectData, UFPConfigEntry from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import PermRequired, ProtectSetableKeysMixin, T -from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current +from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T +from .utils import async_get_light_motion_current _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -189,7 +188,7 @@ async def _set_liveview(obj: Viewer, liveview_id: str) -> None: CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="recording_mode", - name="Recording Mode", + name="Recording mode", icon="mdi:video-outline", entity_category=EntityCategory.CONFIG, ufp_options=DEVICE_RECORDING_MODES, @@ -200,7 +199,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription( key="infrared", - name="Infrared Mode", + name="Infrared mode", icon="mdi:circle-opacity", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_ir", @@ -212,7 +211,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription[Camera]( key="doorbell_text", - name="Doorbell Text", + name="Doorbell text", icon="mdi:card-text", entity_category=EntityCategory.CONFIG, device_class=DEVICE_CLASS_LCD_MESSAGE, @@ -224,7 +223,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription( key="chime_type", - name="Chime Type", + name="Chime type", icon="mdi:bell", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_chime", @@ -236,7 +235,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription( key="hdr_mode", - name="HDR Mode", + name="HDR mode", icon="mdi:brightness-7", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_hdr", @@ -250,7 +249,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Light]( key=_KEY_LIGHT_MOTION, - name="Light Mode", + name="Light mode", icon="mdi:spotlight", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, @@ -260,7 +259,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription[Light]( key="paired_camera", - name="Paired Camera", + name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", @@ -273,7 +272,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription( key="mount_type", - name="Mount Type", + name="Mount type", icon="mdi:screwdriver", entity_category=EntityCategory.CONFIG, ufp_options=MOUNT_TYPES, @@ -284,7 +283,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ProtectSelectEntityDescription[Sensor]( key="paired_camera", - name="Paired Camera", + name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", @@ -297,7 +296,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ProtectSelectEntityDescription[Doorlock]( key="paired_camera", - name="Paired Camera", + name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.CONFIG, ufp_value="camera_id", @@ -320,49 +319,46 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ), ) +_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CAMERA: CAMERA_SELECTS, + ModelType.LIGHT: LIGHT_SELECTS, + ModelType.SENSOR: SENSE_SELECTS, + ModelType.VIEWPORT: VIEWER_SELECTS, + ModelType.DOORLOCK: DOORLOCK_SELECTS, +} + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up number entities for UniFi Protect integration.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - entities = async_all_device_entities( - data, - ProtectSelects, - camera_descs=CAMERA_SELECTS, - light_descs=LIGHT_SELECTS, - sense_descs=SENSE_SELECTS, - viewer_descs=VIEWER_SELECTS, - lock_descs=DOORLOCK_SELECTS, - ufp_device=device, + async_add_entities( + async_all_device_entities( + data, + ProtectSelects, + model_descriptions=_MODEL_DESCRIPTIONS, + ufp_device=device, + ) ) - async_add_entities(entities) - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + data.async_subscribe_adopt(_add_new_device) + async_add_entities( + async_all_device_entities( + data, ProtectSelects, model_descriptions=_MODEL_DESCRIPTIONS + ) ) - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, - ProtectSelects, - camera_descs=CAMERA_SELECTS, - light_descs=LIGHT_SELECTS, - sense_descs=SENSE_SELECTS, - viewer_descs=VIEWER_SELECTS, - lock_descs=DOORLOCK_SELECTS, - ) - - async_add_entities(entities) - class ProtectSelects(ProtectDeviceEntity, SelectEntity): """A UniFi Protect Select Entity.""" device: Camera | Light | Viewer entity_description: ProtectSelectEntityDescription + _state_attrs = ("_attr_available", "_attr_options", "_attr_current_option") def __init__( self, @@ -373,7 +369,6 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): """Initialize the unifi protect select entity.""" self._async_set_options(data, description) super().__init__(data, device, description) - self._attr_name = f"{self.device.display_name} {self.entity_description.name}" @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -423,13 +418,3 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) - - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_options, self._attr_current_option) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index b19b3daadee..84cac342d00 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations +from collections.abc import Callable, Sequence from dataclasses import dataclass from datetime import datetime +from functools import partial import logging -from typing import Any, cast +from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( NVR, Camera, Light, @@ -16,6 +18,7 @@ from pyunifiprotect.data import ( ProtectDeviceModel, ProtectModelWithId, Sensor, + SmartDetectObjectType, ) from homeassistant.components.sensor import ( @@ -24,7 +27,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, @@ -37,19 +39,18 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .data import ProtectData, UFPConfigEntry from .entity import ( + BaseProtectEntity, EventEntityMixin, ProtectDeviceEntity, ProtectNVREntity, async_all_device_entities, ) -from .models import PermRequired, ProtectEventMixin, ProtectRequiredKeysMixin, T -from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current +from .models import PermRequired, ProtectEntityDescription, ProtectEventMixin, T +from .utils import async_get_light_motion_current _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -57,18 +58,25 @@ OBJECT_TYPE_NONE = "none" @dataclass(frozen=True, kw_only=True) class ProtectSensorEntityDescription( - ProtectRequiredKeysMixin[T], SensorEntityDescription + ProtectEntityDescription[T], SensorEntityDescription ): """Describes UniFi Protect Sensor entity.""" precision: int | None = None - def get_ufp_value(self, obj: T) -> Any: - """Return value from UniFi Protect device.""" - value = super().get_ufp_value(obj) - if self.precision and value is not None: - return round(value, self.precision) - return value + def __post_init__(self) -> None: + """Ensure values are rounded if precision is set.""" + super().__post_init__() + if precision := self.precision: + object.__setattr__( + self, + "get_ufp_value", + partial(self._rounded_value, precision, self.get_ufp_value), + ) + + def _rounded_value(self, precision: int, getter: Callable[[T], Any], obj: T) -> Any: + """Round value to precision if set.""" + return None if (v := getter(obj)) is None else round(v, precision) @dataclass(frozen=True, kw_only=True) @@ -124,7 +132,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="ble_signal", - name="Bluetooth Signal Strength", + name="Bluetooth signal strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, @@ -135,7 +143,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="phy_rate", - name="Link Speed", + name="Link speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, @@ -146,7 +154,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="wifi_signal", - name="WiFi Signal Strength", + name="WiFi signal strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_registry_enabled_default=False, @@ -160,7 +168,7 @@ ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="oldest_recording", - name="Oldest Recording", + name="Oldest recording", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -168,22 +176,26 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="storage_used", - name="Storage Used", + name="Storage used", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.storage.used", + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=2, ), ProtectSensorEntityDescription( key="write_rate", - name="Disk Write Rate", + name="Disk write rate", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ufp_value="stats.storage.rate_per_second", precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + suggested_display_precision=2, ), ProtectSensorEntityDescription( key="voltage", @@ -200,7 +212,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="doorbell_last_trip_time", - name="Last Doorbell Ring", + name="Last doorbell ring", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:doorbell-video", ufp_required_field="feature_flags.is_doorbell", @@ -209,7 +221,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="lens_type", - name="Lens Type", + name="Lens type", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:camera-iris", ufp_required_field="has_removable_lens", @@ -217,7 +229,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="mic_level", - name="Microphone Level", + name="Microphone level", icon="mdi:microphone", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -228,7 +240,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="recording_mode", - name="Recording Mode", + name="Recording mode", icon="mdi:video-outline", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="recording_settings.mode", @@ -236,7 +248,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="infrared", - name="Infrared Mode", + name="Infrared mode", icon="mdi:circle-opacity", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_led_ir", @@ -245,7 +257,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="doorbell_text", - name="Doorbell Text", + name="Doorbell text", icon="mdi:card-text", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_lcd_screen", @@ -254,7 +266,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="chime_type", - name="Chime Type", + name="Chime type", icon="mdi:bell", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -266,30 +278,34 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="stats_rx", - name="Received Data", + name="Received data", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ufp_value="stats.rx_bytes", + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=2, ), ProtectSensorEntityDescription( key="stats_tx", - name="Transferred Data", + name="Transferred data", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ufp_value="stats.tx_bytes", + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + suggested_display_precision=2, ), ) SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="battery_level", - name="Battery Level", + name="Battery level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -298,7 +314,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="light_level", - name="Light Level", + name="Light level", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, @@ -307,7 +323,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="humidity_level", - name="Humidity Level", + name="Humidity level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -325,34 +341,34 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[Sensor]( key="alarm_sound", - name="Alarm Sound Detected", + name="Alarm sound detected", ufp_value_fn=_get_alarm_sound, ufp_enabled="is_alarm_sensor_enabled", ), ProtectSensorEntityDescription( key="door_last_trip_time", - name="Last Open", + name="Last open", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="open_status_changed_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="motion_last_trip_time", - name="Last Motion Detected", + name="Last motion detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="motion_detected_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="tampering_last_trip_time", - name="Last Tampering Detected", + name="Last tampering detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="tampering_detected_at", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", - name="Motion Sensitivity", + name="Motion sensitivity", icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -361,7 +377,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="mount_type", - name="Mount Type", + name="Mount type", icon="mdi:screwdriver", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="mount_type", @@ -369,7 +385,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="paired_camera", - name="Paired Camera", + name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", @@ -380,7 +396,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="battery_level", - name="Battery Level", + name="Battery level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -389,7 +405,7 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="paired_camera", - name="Paired Camera", + name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", @@ -408,7 +424,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="storage_utilization", - name="Storage Utilization", + name="Storage utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", entity_category=EntityCategory.DIAGNOSTIC, @@ -418,7 +434,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="record_rotating", - name="Type: Timelapse Video", + name="Type: timelapse video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, @@ -428,7 +444,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="record_timelapse", - name="Type: Continuous Video", + name="Type: continuous video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, @@ -438,7 +454,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="record_detections", - name="Type: Detections Video", + name="Type: detections video", native_unit_of_measurement=PERCENTAGE, icon="mdi:server", entity_category=EntityCategory.DIAGNOSTIC, @@ -448,7 +464,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="resolution_HD", - name="Resolution: HD Video", + name="Resolution: HD video", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, @@ -458,7 +474,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="resolution_4K", - name="Resolution: 4K Video", + name="Resolution: 4K video", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, @@ -468,7 +484,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="resolution_free", - name="Resolution: Free Space", + name="Resolution: free space", native_unit_of_measurement=PERCENTAGE, icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, @@ -478,7 +494,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[NVR]( key="record_capacity", - name="Recording Capacity", + name="Recording capacity", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:record-rec", entity_category=EntityCategory.DIAGNOSTIC, @@ -490,7 +506,7 @@ NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="cpu_utilization", - name="CPU Utilization", + name="CPU utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:speedometer", entity_registry_enabled_default=False, @@ -500,7 +516,7 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="cpu_temperature", - name="CPU Temperature", + name="CPU temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -510,7 +526,7 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[NVR]( key="memory_utilization", - name="Memory Utilization", + name="Memory utilization", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", entity_registry_enabled_default=False, @@ -521,13 +537,13 @@ NVR_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ) -EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( +LICENSE_PLATE_EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( ProtectSensorEventEntityDescription( key="smart_obj_licenseplate", - name="License Plate Detected", + name="License plate detected", icon="mdi:car", translation_key="license_plate", - ufp_value="is_license_plate_currently_detected", + ufp_obj_type=SmartDetectObjectType.LICENSE_PLATE, ufp_required_field="can_detect_license_plate", ufp_event_obj="last_license_plate_detect_event", ), @@ -537,14 +553,14 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="motion_last_trip_time", - name="Last Motion Detected", + name="Last motion detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_motion", entity_registry_enabled_default=False, ), ProtectSensorEntityDescription( key="sensitivity", - name="Motion Sensitivity", + name="Motion sensitivity", icon="mdi:walk", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -553,7 +569,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription[Light]( key="light_motion", - name="Light Mode", + name="Light mode", icon="mdi:spotlight", entity_category=EntityCategory.DIAGNOSTIC, ufp_value_fn=async_get_light_motion_current, @@ -561,7 +577,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ProtectSensorEntityDescription( key="paired_camera", - name="Paired Camera", + name="Paired camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, ufp_value="camera.display_name", @@ -572,7 +588,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="motion_last_trip_time", - name="Last Motion Detected", + name="Last motion detected", device_class=SensorDeviceClass.TIMESTAMP, ufp_value="last_motion", entity_registry_enabled_default=False, @@ -582,7 +598,7 @@ MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ProtectSensorEntityDescription( key="last_ring", - name="Last Ring", + name="Last ring", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:bell", ufp_value="last_ring", @@ -609,14 +625,23 @@ VIEWER_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ), ) +_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CAMERA: CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, + ModelType.SENSOR: SENSE_SENSORS, + ModelType.LIGHT: LIGHT_SENSORS, + ModelType.DOORLOCK: DOORLOCK_SENSORS, + ModelType.CHIME: CHIME_SENSORS, + ModelType.VIEWPORT: VIEWER_SENSORS, +} + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: @@ -624,32 +649,19 @@ async def async_setup_entry( data, ProtectDeviceSensor, all_descs=ALL_DEVICES_SENSORS, - camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, - sense_descs=SENSE_SENSORS, - light_descs=LIGHT_SENSORS, - lock_descs=DOORLOCK_SENSORS, - chime_descs=CHIME_SENSORS, - viewer_descs=VIEWER_SENSORS, + model_descriptions=_MODEL_DESCRIPTIONS, ufp_device=device, ) if device.is_adopted_by_us and isinstance(device, Camera): entities += _async_event_entities(data, ufp_device=device) async_add_entities(entities) - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) - ) - - entities: list[ProtectDeviceEntity] = async_all_device_entities( + data.async_subscribe_adopt(_add_new_device) + entities = async_all_device_entities( data, ProtectDeviceSensor, all_descs=ALL_DEVICES_SENSORS, - camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, - sense_descs=SENSE_SENSORS, - light_descs=LIGHT_SENSORS, - lock_descs=DOORLOCK_SENSORS, - chime_descs=CHIME_SENSORS, - viewer_descs=VIEWER_SENSORS, + model_descriptions=_MODEL_DESCRIPTIONS, ) entities += _async_event_entities(data) entities += _async_nvr_entities(data) @@ -663,31 +675,28 @@ def _async_event_entities( ufp_device: Camera | None = None, ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] - devices = ( - data.get_by_types({ModelType.CAMERA}) if ufp_device is None else [ufp_device] - ) - for device in devices: - device = cast(Camera, device) + cameras = data.get_cameras() if ufp_device is None else [ufp_device] + for camera in cameras: for description in MOTION_TRIP_SENSORS: - entities.append(ProtectDeviceSensor(data, device, description)) + entities.append(ProtectDeviceSensor(data, camera, description)) _LOGGER.debug( "Adding trip sensor entity %s for %s", description.name, - device.display_name, + camera.display_name, ) - if not device.feature_flags.has_smart_detect: + if not camera.feature_flags.has_smart_detect: continue - for event_desc in EVENT_SENSORS: - if not event_desc.has_required(device): + for event_desc in LICENSE_PLATE_EVENT_SENSORS: + if not event_desc.has_required(camera): continue - entities.append(ProtectEventSensor(data, device, event_desc)) + entities.append(ProtectLicensePlateEventSensor(data, camera, event_desc)) _LOGGER.debug( "Adding sensor entity %s for %s", description.name, - device.display_name, + camera.display_name, ) return entities @@ -696,8 +705,8 @@ def _async_event_entities( @callback def _async_nvr_entities( data: ProtectData, -) -> list[ProtectDeviceEntity]: - entities: list[ProtectDeviceEntity] = [] +) -> list[BaseProtectEntity]: + entities: list[BaseProtectEntity] = [] device = data.api.bootstrap.nvr for description in NVR_SENSORS + NVR_DISABLED_SENSORS: entities.append(ProtectNVRSensor(data, device, description)) @@ -706,90 +715,68 @@ def _async_nvr_entities( return entities -class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): - """A Ubiquiti UniFi Protect Sensor.""" +class BaseProtectSensor(BaseProtectEntity, SensorEntity): + """A UniFi Protect Sensor Entity.""" entity_description: ProtectSensorEntityDescription + _state_attrs = ("_attr_available", "_attr_native_value") def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_native_value) - - -class ProtectNVRSensor(ProtectNVREntity, SensorEntity): +class ProtectDeviceSensor(BaseProtectSensor, ProtectDeviceEntity): """A Ubiquiti UniFi Protect Sensor.""" - entity_description: ProtectSensorEntityDescription - def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: - super()._async_update_device_from_protect(device) - self._attr_native_value = self.entity_description.get_ufp_value(self.device) - - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_native_value) +class ProtectNVRSensor(BaseProtectSensor, ProtectNVREntity): + """A Ubiquiti UniFi Protect Sensor.""" class ProtectEventSensor(EventEntityMixin, SensorEntity): """A UniFi Protect Device Sensor with access tokens.""" entity_description: ProtectSensorEventEntityDescription + _state_attrs = ( + "_attr_available", + "_attr_native_value", + "_attr_extra_state_attributes", + ) + + +class ProtectLicensePlateEventSensor(ProtectEventSensor): + """A UniFi Protect license plate sensor.""" + + device: Camera + + @callback + def _set_event_done(self) -> None: + self._attr_native_value = OBJECT_TYPE_NONE + self._attr_extra_state_attributes = {} @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: - # do not call ProtectDeviceSensor method since we want event to get value here - EventEntityMixin._async_update_device_from_protect(self, device) - event = self._event - entity_description = self.entity_description - is_on = entity_description.get_is_on(self.device, self._event) - is_license_plate = ( - entity_description.ufp_event_obj == "last_license_plate_detect_event" - ) - if ( - not is_on - or event is None - or ( - is_license_plate - and (event.metadata is None or event.metadata.license_plate is None) - ) + description = self.entity_description + + prev_event = self._event + prev_event_end = self._event_end + super()._async_update_device_from_protect(device) + if event := description.get_event_obj(device): + self._event = event + self._event_end = event.end + + if not ( + event + and (metadata := event.metadata) + and (license_plate := metadata.license_plate) + and description.has_matching_smart(event) + and not self._event_already_ended(prev_event, prev_event_end) ): - self._attr_native_value = OBJECT_TYPE_NONE - self._event = None - self._attr_extra_state_attributes = {} + self._set_event_done() return - if is_license_plate: - # type verified above - self._attr_native_value = event.metadata.license_plate.name # type: ignore[union-attr] - else: - self._attr_native_value = event.smart_detect_types[0].value - - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return ( - self._attr_available, - self._attr_native_value, - self._attr_extra_state_attributes, - ) + self._attr_native_value = license_plate.name + self._set_event_attrs(event) + if event.end: + self._async_event_with_immediate_end() diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 8c62664f55b..119fe52756c 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -7,13 +7,12 @@ import functools from typing import Any, cast from pydantic import ValidationError -from pyunifiprotect.api import ProtectApiClient -from pyunifiprotect.data import Camera, Chime -from pyunifiprotect.exceptions import ClientError +from uiprotect.api import ProtectApiClient +from uiprotect.data import Camera, Chime +from uiprotect.exceptions import ClientError import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -32,13 +31,11 @@ SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text" SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text" SERVICE_SET_PRIVACY_ZONE = "set_privacy_zone" SERVICE_REMOVE_PRIVACY_ZONE = "remove_privacy_zone" -SERVICE_SET_DEFAULT_DOORBELL_TEXT = "set_default_doorbell_text" SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells" ALL_GLOBAL_SERIVCES = [ SERVICE_ADD_DOORBELL_TEXT, SERVICE_REMOVE_DOORBELL_TEXT, - SERVICE_SET_DEFAULT_DOORBELL_TEXT, SERVICE_SET_CHIME_PAIRED, SERVICE_REMOVE_PRIVACY_ZONE, ] @@ -145,12 +142,6 @@ async def remove_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: await _async_service_call_nvr(hass, call, "remove_custom_doorbell_message", message) -async def set_default_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: - """Set the default doorbell text message.""" - message: str = call.data[ATTR_MESSAGE] - await _async_service_call_nvr(hass, call, "set_default_doorbell_message", message) - - async def remove_privacy_zone(hass: HomeAssistant, call: ServiceCall) -> None: """Remove privacy zone from camera.""" @@ -231,11 +222,6 @@ def async_setup_services(hass: HomeAssistant) -> None: functools.partial(remove_doorbell_text, hass), DOORBELL_TEXT_SCHEMA, ), - ( - SERVICE_SET_DEFAULT_DOORBELL_TEXT, - functools.partial(set_default_doorbell_text, hass), - DOORBELL_TEXT_SCHEMA, - ), ( SERVICE_SET_CHIME_PAIRED, functools.partial(set_chime_paired_doorbells, hass), @@ -251,15 +237,3 @@ def async_setup_services(hass: HomeAssistant) -> None: if hass.services.has_service(DOMAIN, name): continue hass.services.async_register(DOMAIN, name, method, schema=schema) - - -def async_cleanup_services(hass: HomeAssistant) -> None: - """Cleanup global UniFi Protect services (if all config entries unloaded).""" - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: - for name in ALL_GLOBAL_SERIVCES: - hass.services.async_remove(DOMAIN, name) diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index e747b9e7240..192dfd0566f 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -22,18 +22,6 @@ remove_doorbell_text: required: true selector: text: -set_default_doorbell_text: - fields: - device_id: - required: true - selector: - device: - integration: unifiprotect - message: - example: Welcome! - required: true - selector: - text: set_chime_paired_doorbells: fields: device_id: diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index b83d514f836..1435de5011e 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -55,7 +55,7 @@ "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", "override_connection_host": "Override Connection Host", "max_media": "Max number of event to load for Media Browser (increases RAM usage)", - "allow_ea": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" + "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" } } } @@ -67,7 +67,7 @@ "step": { "start": { "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel. [Home Assistant does not support Early Access versions](https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access), so you should immediately switch to the Official Release Channel. Accidentally upgrading to an Early Access version can break your UniFi Protect integration.\n\nBy submitting this form, you have switched back to the Official Release Channel or agree to run an unsupported version of UniFi Protect, which may break your Home Assistant integration at any time." + "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." }, "confirm": { "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", @@ -168,20 +168,6 @@ } } }, - "set_default_doorbell_text": { - "name": "Set default doorbell text", - "description": "Sets the default doorbell message. This will be the message that is automatically selected when a message \"expires\".", - "fields": { - "device_id": { - "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::name%]", - "description": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::description%]" - }, - "message": { - "name": "Default message", - "description": "The default message for your doorbell. Must be less than 30 characters." - } - } - }, "set_chime_paired_doorbells": { "name": "Set chime paired doorbells", "description": "Use to set the paired doorbell(s) with a smart chime.", diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index bd7cfa4d2a2..ca56a602209 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -2,13 +2,15 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass +from functools import partial import logging from typing import Any -from pyunifiprotect.data import ( - NVR, +from uiprotect.data import ( Camera, + ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId, RecordingMode, @@ -16,18 +18,19 @@ from pyunifiprotect.data import ( ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DISPATCH_ADOPT, DOMAIN -from .data import ProtectData -from .entity import ProtectDeviceEntity, ProtectNVREntity, async_all_device_entities -from .models import PermRequired, ProtectSetableKeysMixin, T -from .utils import async_dispatch_id as _ufpd +from .data import ProtectData, UFPConfigEntry +from .entity import ( + BaseProtectEntity, + ProtectDeviceEntity, + ProtectNVREntity, + async_all_device_entities, +) +from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T _LOGGER = logging.getLogger(__name__) ATTR_PREV_MIC = "prev_mic_level" @@ -51,7 +54,7 @@ async def _set_highfps(obj: Camera, value: bool) -> None: CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", - name="SSH Enabled", + name="SSH enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, @@ -61,7 +64,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_led_status", @@ -71,7 +74,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="hdr_mode", - name="HDR Mode", + name="HDR mode", icon="mdi:brightness-7", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -92,7 +95,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="system_sounds", - name="System Sounds", + name="System sounds", icon="mdi:speaker", entity_category=EntityCategory.CONFIG, ufp_required_field="has_speaker", @@ -103,7 +106,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_name", - name="Overlay: Show Name", + name="Overlay: show name", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_name_enabled", @@ -112,7 +115,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_date", - name="Overlay: Show Date", + name="Overlay: show date", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_date_enabled", @@ -121,7 +124,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_logo", - name="Overlay: Show Logo", + name="Overlay: show logo", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_logo_enabled", @@ -130,7 +133,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="osd_bitrate", - name="Overlay: Show Nerd Mode", + name="Overlay: show nerd mode", icon="mdi:fullscreen", entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_debug_enabled", @@ -139,7 +142,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="color_night_vision", - name="Color Night Vision", + name="Color night vision", icon="mdi:light-flood-down", entity_category=EntityCategory.CONFIG, ufp_required_field="has_color_night_vision", @@ -149,7 +152,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="motion", - name="Detections: Motion", + name="Detections: motion", icon="mdi:run-fast", entity_category=EntityCategory.CONFIG, ufp_value="recording_settings.enable_motion_detection", @@ -159,7 +162,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_person", - name="Detections: Person", + name="Detections: person", icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_person", @@ -170,7 +173,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_vehicle", - name="Detections: Vehicle", + name="Detections: vehicle", icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_vehicle", @@ -179,9 +182,20 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_vehicle_detection", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="smart_animal", + name="Detections: animal", + icon="mdi:paw", + entity_category=EntityCategory.CONFIG, + ufp_required_field="can_detect_animal", + ufp_value="is_animal_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_animal_detection", + ufp_perm=PermRequired.WRITE, + ), ProtectSwitchEntityDescription( key="smart_package", - name="Detections: Package", + name="Detections: package", icon="mdi:package-variant-closed", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_package", @@ -192,7 +206,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_licenseplate", - name="Detections: License Plate", + name="Detections: license plate", icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_license_plate", @@ -203,7 +217,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_smoke", - name="Detections: Smoke", + name="Detections: smoke", icon="mdi:fire", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_smoke", @@ -225,7 +239,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_siren", - name="Detections: Siren", + name="Detections: siren", icon="mdi:alarm-bell", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_siren", @@ -236,7 +250,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_baby_cry", - name="Detections: Baby Cry", + name="Detections: baby cry", icon="mdi:cradle", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_baby_cry", @@ -247,7 +261,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_speak", - name="Detections: Speaking", + name="Detections: speaking", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_speaking", @@ -258,7 +272,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_bark", - name="Detections: Barking", + name="Detections: barking", icon="mdi:dog", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_bark", @@ -269,7 +283,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_car_alarm", - name="Detections: Car Alarm", + name="Detections: car alarm", icon="mdi:car", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_alarm", @@ -280,7 +294,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_car_horn", - name="Detections: Car Horn", + name="Detections: car horn", icon="mdi:bugle", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_car_horn", @@ -291,7 +305,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_glass_break", - name="Detections: Glass Break", + name="Detections: glass break", icon="mdi:glass-fragile", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_glass_break", @@ -302,7 +316,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="track_person", - name="Tracking: Person", + name="Tracking: person", icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_required_field="is_ptz", @@ -314,7 +328,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( key="privacy_mode", - name="Privacy Mode", + name="Privacy mode", icon="mdi:eye-settings", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_privacy_mask", @@ -325,7 +339,7 @@ PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", @@ -334,7 +348,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="motion", - name="Motion Detection", + name="Motion detection", icon="mdi:walk", entity_category=EntityCategory.CONFIG, ufp_value="motion_settings.is_enabled", @@ -343,7 +357,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="temperature", - name="Temperature Sensor", + name="Temperature sensor", icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ufp_value="temperature_settings.is_enabled", @@ -352,7 +366,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="humidity", - name="Humidity Sensor", + name="Humidity sensor", icon="mdi:water-percent", entity_category=EntityCategory.CONFIG, ufp_value="humidity_settings.is_enabled", @@ -361,7 +375,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="light", - name="Light Sensor", + name="Light sensor", icon="mdi:brightness-5", entity_category=EntityCategory.CONFIG, ufp_value="light_settings.is_enabled", @@ -370,7 +384,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="alarm", - name="Alarm Sound Detection", + name="Alarm sound detection", entity_category=EntityCategory.CONFIG, ufp_value="alarm_settings.is_enabled", ufp_set_method="set_alarm_status", @@ -382,7 +396,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", - name="SSH Enabled", + name="SSH enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, @@ -392,7 +406,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="light_device_settings.is_indicator_enabled", @@ -404,7 +418,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="status_light", - name="Status Light On", + name="Status light on", icon="mdi:led-on", entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", @@ -416,7 +430,7 @@ DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="ssh", - name="SSH Enabled", + name="SSH enabled", icon="mdi:lock", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, @@ -429,7 +443,7 @@ VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ProtectSwitchEntityDescription( key="analytics_enabled", - name="Analytics Enabled", + name="Analytics enabled", icon="mdi:google-analytics", entity_category=EntityCategory.CONFIG, ufp_value="is_analytics_enabled", @@ -437,7 +451,7 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="insights_enabled", - name="Insights Enabled", + name="Insights enabled", icon="mdi:magnify", entity_category=EntityCategory.CONFIG, ufp_value="is_insights_enabled", @@ -445,82 +459,24 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ) +_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CAMERA: CAMERA_SWITCHES, + ModelType.LIGHT: LIGHT_SWITCHES, + ModelType.SENSOR: SENSE_SWITCHES, + ModelType.DOORLOCK: DOORLOCK_SWITCHES, + ModelType.VIEWPORT: VIEWER_SWITCHES, +} -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up sensors for UniFi Protect integration.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] - - @callback - def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - entities = async_all_device_entities( - data, - ProtectSwitch, - camera_descs=CAMERA_SWITCHES, - light_descs=LIGHT_SWITCHES, - sense_descs=SENSE_SWITCHES, - lock_descs=DOORLOCK_SWITCHES, - viewer_descs=VIEWER_SWITCHES, - ufp_device=device, - ) - entities += async_all_device_entities( - data, - ProtectPrivacyModeSwitch, - camera_descs=[PRIVACY_MODE_SWITCH], - ufp_device=device, - ) - async_add_entities(entities) - - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) - ) - - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, - ProtectSwitch, - camera_descs=CAMERA_SWITCHES, - light_descs=LIGHT_SWITCHES, - sense_descs=SENSE_SWITCHES, - lock_descs=DOORLOCK_SWITCHES, - viewer_descs=VIEWER_SWITCHES, - ) - entities += async_all_device_entities( - data, - ProtectPrivacyModeSwitch, - camera_descs=[PRIVACY_MODE_SWITCH], - ) - - if ( - data.api.bootstrap.nvr.can_write(data.api.bootstrap.auth_user) - and data.api.bootstrap.nvr.is_insights_enabled is not None - ): - for switch in NVR_SWITCHES: - entities.append( - ProtectNVRSwitch( - data, device=data.api.bootstrap.nvr, description=switch - ) - ) - async_add_entities(entities) +_PRIVACY_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CAMERA: [PRIVACY_MODE_SWITCH] +} -class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): - """A UniFi Protect Switch.""" +class ProtectBaseSwitch(BaseProtectEntity, SwitchEntity): + """Base class for UniFi Protect Switch.""" entity_description: ProtectSwitchEntityDescription - - def __init__( - self, - data: ProtectData, - device: ProtectAdoptableDeviceModel, - description: ProtectSwitchEntityDescription, - ) -> None: - """Initialize an UniFi Protect Switch.""" - super().__init__(data, device, description) - self._attr_name = f"{self.device.display_name} {self.entity_description.name}" - self._switch_type = self.entity_description.key + _state_attrs = ("_attr_available", "_attr_is_on") def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) @@ -534,47 +490,14 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): """Turn the device off.""" await self.entity_description.ufp_set(self.device, False) - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_is_on) +class ProtectSwitch(ProtectBaseSwitch, ProtectDeviceEntity): + """A UniFi Protect Switch.""" -class ProtectNVRSwitch(ProtectNVREntity, SwitchEntity): +class ProtectNVRSwitch(ProtectBaseSwitch, ProtectNVREntity): """A UniFi Protect NVR Switch.""" - entity_description: ProtectSwitchEntityDescription - - def __init__( - self, - data: ProtectData, - device: NVR, - description: ProtectSwitchEntityDescription, - ) -> None: - """Initialize an UniFi Protect Switch.""" - super().__init__(data, device, description) - self._attr_name = f"{self.device.display_name} {self.entity_description.name}" - - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self.entity_description.get_ufp_value(self.device) is True - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - - await self.entity_description.ufp_set(self.device, True) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - - await self.entity_description.ufp_set(self.device, False) - class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): """A UniFi Protect Switch.""" @@ -584,21 +507,20 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): def __init__( self, data: ProtectData, - device: ProtectAdoptableDeviceModel, + device: Camera, description: ProtectSwitchEntityDescription, ) -> None: """Initialize an UniFi Protect Switch.""" super().__init__(data, device, description) - - if self.device.is_privacy_on: + if device.is_privacy_on: extra_state = self.extra_state_attributes or {} self._previous_mic_level = extra_state.get(ATTR_PREV_MIC, 100) self._previous_record_mode = extra_state.get( ATTR_PREV_RECORD, RecordingMode.ALWAYS ) else: - self._previous_mic_level = self.device.mic_volume - self._previous_record_mode = self.device.recording_settings.mode + self._previous_mic_level = device.mic_volume + self._previous_record_mode = device.recording_settings.mode @callback def _update_previous_attr(self) -> None: @@ -613,21 +535,18 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - # do not add extra state attribute on initialize if self.entity_id: self._update_previous_attr() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - self._previous_mic_level = self.device.mic_volume self._previous_record_mode = self.device.recording_settings.mode await self.device.set_privacy(True, 0, RecordingMode.NEVER) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - extra_state = self.extra_state_attributes or {} prev_mic = extra_state.get(ATTR_PREV_MIC, self._previous_mic_level) prev_record = extra_state.get(ATTR_PREV_RECORD, self._previous_record_mode) @@ -636,14 +555,44 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): async def async_added_to_hass(self) -> None: """Restore extra state attributes on startp up.""" await super().async_added_to_hass() - if not (last_state := await self.async_get_last_state()): return - - self._previous_mic_level = last_state.attributes.get( + last_attrs = last_state.attributes + self._previous_mic_level = last_attrs.get( ATTR_PREV_MIC, self._previous_mic_level ) - self._previous_record_mode = last_state.attributes.get( + self._previous_record_mode = last_attrs.get( ATTR_PREV_RECORD, self._previous_record_mode ) self._update_previous_attr() + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for UniFi Protect integration.""" + data = entry.runtime_data + + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + _make_entities = partial(async_all_device_entities, data, ufp_device=device) + entities: list[BaseProtectEntity] = [] + entities += _make_entities(ProtectSwitch, _MODEL_DESCRIPTIONS) + entities += _make_entities(ProtectPrivacyModeSwitch, _PRIVACY_DESCRIPTIONS) + async_add_entities(entities) + + _make_entities = partial(async_all_device_entities, data) + data.async_subscribe_adopt(_add_new_device) + entities: list[BaseProtectEntity] = [] + entities += _make_entities(ProtectSwitch, _MODEL_DESCRIPTIONS) + entities += _make_entities(ProtectPrivacyModeSwitch, _PRIVACY_DESCRIPTIONS) + bootstrap = data.api.bootstrap + nvr = bootstrap.nvr + if nvr.can_write(bootstrap.auth_user) and nvr.is_insights_enabled is not None: + entities.extend( + ProtectNVRSwitch(data, device=nvr, description=switch) + for switch in NVR_SWITCHES + ) + async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 584bd511ee5..e01a6b31f11 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -2,28 +2,25 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass -from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, DoorbellMessageType, + ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId, ) from homeassistant.components.text import TextEntity, TextEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DISPATCH_ADOPT, DOMAIN -from .data import ProtectData +from .data import UFPConfigEntry from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import PermRequired, ProtectSetableKeysMixin, T -from .utils import async_dispatch_id as _ufpd +from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T @dataclass(frozen=True, kw_only=True) @@ -53,68 +50,49 @@ CAMERA: tuple[ProtectTextEntityDescription, ...] = ( ), ) +_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { + ModelType.CAMERA: CAMERA, +} + async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - entities = async_all_device_entities( - data, - ProtectDeviceText, - camera_descs=CAMERA, - ufp_device=device, + async_add_entities( + async_all_device_entities( + data, + ProtectDeviceText, + model_descriptions=_MODEL_DESCRIPTIONS, + ufp_device=device, + ) ) - async_add_entities(entities) - entry.async_on_unload( - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + data.async_subscribe_adopt(_add_new_device) + async_add_entities( + async_all_device_entities( + data, ProtectDeviceText, model_descriptions=_MODEL_DESCRIPTIONS + ) ) - entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, - ProtectDeviceText, - camera_descs=CAMERA, - ) - - async_add_entities(entities) - class ProtectDeviceText(ProtectDeviceEntity, TextEntity): """A Ubiquiti UniFi Protect Sensor.""" entity_description: ProtectTextEntityDescription - - def __init__( - self, - data: ProtectData, - device: ProtectAdoptableDeviceModel, - description: ProtectTextEntityDescription, - ) -> None: - """Initialize an UniFi Protect sensor.""" - super().__init__(data, device, description) + _state_attrs = ("_attr_available", "_attr_native_value") @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) - @callback - def _async_get_state_attrs(self) -> tuple[Any, ...]: - """Retrieve data that goes into the current state of the entity. - - Called before and after updating entity and state is only written if there - is a change. - """ - - return (self._attr_available, self._attr_native_value) - async def async_set_value(self, value: str) -> None: """Change the value.""" - await self.entity_description.ufp_set(self.device, value) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 8199d729943..d98ad72e1d1 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -2,16 +2,16 @@ from __future__ import annotations -from collections.abc import Generator, Iterable +from collections.abc import Iterable import contextlib -from enum import Enum from pathlib import Path import socket -from typing import Any +from typing import TYPE_CHECKING from aiohttp import CookieJar -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from typing_extensions import Generator +from uiprotect import ProtectApiClient +from uiprotect.data import ( Bootstrap, CameraChannel, Light, @@ -20,7 +20,6 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -36,24 +35,11 @@ from .const import ( CONF_ALL_UPDATES, CONF_OVERRIDE_CHOST, DEVICES_FOR_SUBSCRIBE, - DOMAIN, ModelType, ) -_SENTINEL = object() - - -def get_nested_attr(obj: Any, attrs: tuple[str, ...]) -> Any: - """Fetch a nested attribute.""" - if len(attrs) == 1: - value = getattr(obj, attrs[0], None) - else: - value = obj - for key in attrs: - if (value := getattr(value, key, _SENTINEL)) is _SENTINEL: - return None - - return value.value if isinstance(value, Enum) else value +if TYPE_CHECKING: + from .data import UFPConfigEntry @callback @@ -89,17 +75,15 @@ def async_get_devices_by_type( bootstrap: Bootstrap, device_type: ModelType ) -> dict[str, ProtectAdoptableDeviceModel]: """Get devices by type.""" - - devices: dict[str, ProtectAdoptableDeviceModel] = getattr( - bootstrap, f"{device_type.value}s" - ) + devices: dict[str, ProtectAdoptableDeviceModel] + devices = getattr(bootstrap, device_type.devices_key) return devices @callback def async_get_devices( bootstrap: Bootstrap, model_type: Iterable[ModelType] -) -> Generator[ProtectAdoptableDeviceModel, None, None]: +) -> Generator[ProtectAdoptableDeviceModel]: """Return all device by type.""" return ( device @@ -120,16 +104,9 @@ def async_get_light_motion_current(obj: Light) -> str: return obj.light_mode_settings.mode.value -@callback -def async_dispatch_id(entry: ConfigEntry, dispatch: str) -> str: - """Generate entry specific dispatch ID.""" - - return f"{DOMAIN}.{entry.entry_id}.{dispatch}" - - @callback def async_create_api_client( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: UFPConfigEntry ) -> ProtectApiClient: """Create ProtectApiClient from config entry.""" @@ -156,6 +133,6 @@ def get_camera_base_name(channel: CameraChannel) -> str: camera_name = channel.name if channel.name != "Package Camera": - camera_name = f"{channel.name} Resolution Channel" + camera_name = f"{channel.name} resolution channel" return camera_name diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index 0f9bff63689..00128492c67 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -9,15 +9,14 @@ from typing import Any from urllib.parse import urlencode from aiohttp import web -from pyunifiprotect.data import Camera, Event -from pyunifiprotect.exceptions import ClientError +from uiprotect.data import Camera, Event +from uiprotect.exceptions import ClientError from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DOMAIN -from .data import ProtectData +from .data import ProtectData, async_get_data_for_entry_id, async_get_data_for_nvr_id _LOGGER = logging.getLogger(__name__) @@ -99,18 +98,13 @@ class ProtectProxyView(HomeAssistantView): def __init__(self, hass: HomeAssistant) -> None: """Initialize a thumbnail proxy view.""" self.hass = hass - self.data = hass.data[DOMAIN] - def _get_data_or_404(self, nvr_id: str) -> ProtectData | web.Response: - all_data: list[ProtectData] = [] - - for entry_id, data in self.data.items(): - if isinstance(data, ProtectData): - if nvr_id == entry_id: - return data - if data.api.bootstrap.nvr.id == nvr_id: - return data - all_data.append(data) + def _get_data_or_404(self, nvr_id_or_entry_id: str) -> ProtectData | web.Response: + if data := ( + async_get_data_for_nvr_id(self.hass, nvr_id_or_entry_id) + or async_get_data_for_entry_id(self.hass, nvr_id_or_entry_id) + ): + return data return _404("Invalid NVR ID") diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 8356e289094..e4acc6b8657 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -248,7 +248,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): def _entity_lkp(self, entity_id, state_attr=None): """Look up an entity state.""" if (state_obj := self.hass.states.get(entity_id)) is None: - return + return None if state_attr: return state_obj.attributes.get(state_attr) diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 18a427a40bd..1db0b0b6fe3 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -93,7 +93,7 @@ class UPBConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidUpbFile: errors["base"] = "invalid_upb_file" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 371dedab49c..4b65406f312 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -27,12 +27,10 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import UpCloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -56,40 +54,6 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update" STATE_MAP = {"error": STATE_PROBLEM, "started": STATE_ON, "stopped": STATE_OFF} -class UpCloudDataUpdateCoordinator( - DataUpdateCoordinator[dict[str, upcloud_api.Server]] -): # pylint: disable=hass-enforce-coordinator-module - """UpCloud data update coordinator.""" - - def __init__( - self, - hass: HomeAssistant, - *, - cloud_manager: upcloud_api.CloudManager, - update_interval: timedelta, - username: str, - ) -> None: - """Initialize coordinator.""" - super().__init__( - hass, _LOGGER, name=f"{username}@UpCloud", update_interval=update_interval - ) - self.cloud_manager = cloud_manager - - async def async_update_config(self, config_entry: ConfigEntry) -> None: - """Handle config update.""" - self.update_interval = timedelta( - seconds=config_entry.options[CONF_SCAN_INTERVAL] - ) - - async def _async_update_data(self) -> dict[str, upcloud_api.Server]: - return { - x.uuid: x - for x in await self.hass.async_add_executor_job( - self.cloud_manager.get_servers - ) - } - - @dataclasses.dataclass class UpCloudHassData: """Home Assistant UpCloud runtime data.""" diff --git a/homeassistant/components/upcloud/coordinator.py b/homeassistant/components/upcloud/coordinator.py new file mode 100644 index 00000000000..e10128a30e4 --- /dev/null +++ b/homeassistant/components/upcloud/coordinator.py @@ -0,0 +1,49 @@ +"""Coordinator for UpCloud.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import upcloud_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class UpCloudDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, upcloud_api.Server]] +): + """UpCloud data update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + *, + cloud_manager: upcloud_api.CloudManager, + update_interval: timedelta, + username: str, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, _LOGGER, name=f"{username}@UpCloud", update_interval=update_interval + ) + self.cloud_manager = cloud_manager + + async def async_update_config(self, config_entry: ConfigEntry) -> None: + """Handle config update.""" + self.update_interval = timedelta( + seconds=config_entry.options[CONF_SCAN_INTERVAL] + ) + + async def _async_update_data(self) -> dict[str, upcloud_api.Server]: + return { + x.uuid: x + for x in await self.hass.async_add_executor_job( + self.cloud_manager.get_servers + ) + } diff --git a/homeassistant/components/upcloud/manifest.json b/homeassistant/components/upcloud/manifest.json index 2bb2ae8c33a..cd829f6dd9d 100644 --- a/homeassistant/components/upcloud/manifest.json +++ b/homeassistant/components/upcloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upcloud", "iot_class": "cloud_polling", - "requirements": ["upcloud-api==2.0.0"] + "requirements": ["upcloud-api==2.5.1"] } diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 57d63c92ede..352237bf201 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -495,14 +495,14 @@ async def websocket_release_notes( if entity is None: connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" ) return if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_SUPPORTED, + websocket_api.ERR_NOT_SUPPORTED, "Entity does not support release notes", ) return diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index f2f3ffd0a1b..ea9930f047f 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -36,13 +36,13 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.entry_id) - hass.data.setdefault(DOMAIN, {}) - udn = entry.data[CONFIG_ENTRY_UDN] st = entry.data[CONFIG_ENTRY_ST] usn = f"{udn}::{st}" @@ -168,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() # Save coordinator. - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator # Setup platforms, creating sensors/binary_sensors. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -179,10 +179,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a UPnP/IGD device from a config entry.""" LOGGER.debug("Unloading config entry: %s", entry.entry_id) - - # Unload platforms. - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 71c13d0c8a9..9784f9c6e0b 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -9,13 +9,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpDataUpdateCoordinator -from .const import DOMAIN, LOGGER, WAN_STATUS +from . import UpnpConfigEntry, UpnpDataUpdateCoordinator +from .const import LOGGER, WAN_STATUS from .entity import UpnpEntity, UpnpEntityDescription @@ -38,11 +37,11 @@ SENSOR_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpnpConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" - coordinator: UpnpDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities = [ UpnpStatusBinarySensor( diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 5d72904bfaf..df7128830b3 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfDataRate, @@ -21,12 +20,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import UpnpConfigEntry from .const import ( BYTES_RECEIVED, BYTES_SENT, DATA_PACKETS, DATA_RATE_PACKETS_PER_SECOND, - DOMAIN, KIBIBYTES_PER_SEC_RECEIVED, KIBIBYTES_PER_SEC_SENT, LOGGER, @@ -38,7 +37,6 @@ from .const import ( ROUTER_UPTIME, WAN_STATUS, ) -from .coordinator import UpnpDataUpdateCoordinator from .entity import UpnpEntity, UpnpEntityDescription @@ -146,11 +144,11 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: UpnpConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" - coordinator: UpnpDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data entities: list[UpnpSensor] = [ UpnpSensor( diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index feb747c6b9e..ffe3c3e4563 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -50,7 +50,7 @@ class UptimeRobotConfigFlow(ConfigFlow, domain=DOMAIN): except UptimeRobotException as exception: LOGGER.error(exception) errors["base"] = "cannot_connect" - except Exception as exception: # pylint: disable=broad-except + except Exception as exception: # noqa: BLE001 LOGGER.exception(exception) errors["base"] = "unknown" else: diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 46950ba5b91..d4201d7f284 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -362,10 +362,33 @@ class USBDiscovery: async def _async_process_ports(self, ports: list[ListPortInfo]) -> None: """Process each discovered port.""" - for port in ports: - if port.vid is None and port.pid is None: - continue - await self._async_process_discovered_usb_device(usb_device_from_port(port)) + usb_devices = [ + usb_device_from_port(port) + for port in ports + if port.vid is not None or port.pid is not None + ] + + # CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and + # `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them. + if sys.platform == "darwin": + silabs_serials = { + dev.serial_number + for dev in usb_devices + if dev.device.startswith("/dev/cu.SLAB_USBtoUART") + } + + usb_devices = [ + dev + for dev in usb_devices + if dev.serial_number not in silabs_serials + or ( + dev.serial_number in silabs_serials + and dev.device.startswith("/dev/cu.SLAB_USBtoUART") + ) + ] + + for usb_device in usb_devices: + await self._async_process_discovered_usb_device(usb_device) async def _async_scan_serial(self) -> None: """Scan serial ports.""" diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 4bacde32367..c6a8635f831 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -11,12 +11,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import ( - device_registry as dr, - discovery, - entity_registry as er, -) +from homeassistant.helpers import discovery, entity_registry as er import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device import ( + async_remove_stale_devices_links_keep_entity_device, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType @@ -191,6 +190,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Utility Meter from a config entry.""" + + async_remove_stale_devices_links_keep_entity_device( + hass, entry.entry_id, entry.options[CONF_SOURCE_SENSOR] + ) + entity_registry = er.async_get(hass) hass.data[DATA_UTILITY][entry.entry_id] = { "source": entry.options[CONF_SOURCE_SENSOR], @@ -230,23 +234,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" - old_source = hass.data[DATA_UTILITY][entry.entry_id]["source"] + await hass.config_entries.async_reload(entry.entry_id) - if old_source == entry.options[CONF_SOURCE_SENSOR]: - return - - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - - old_source_entity = entity_registry.async_get(old_source) - if not old_source_entity or not old_source_entity.device_id: - return - - device_registry.async_update_device( - old_source_entity.device_id, remove_config_entry_id=entry.entry_id - ) - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 49799ba1e67..d1990463cbd 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -43,6 +43,7 @@ ATTR_TARIFF = "tariff" ATTR_TARIFFS = "tariffs" ATTR_VALUE = "value" ATTR_CRON_PATTERN = "cron pattern" +ATTR_NEXT_RESET = "next_reset" SIGNAL_START_PAUSE_METER = "utility_meter_start_pause" SIGNAL_RESET_METER = "utility_meter_reset" diff --git a/homeassistant/components/utility_meter/diagnostics.py b/homeassistant/components/utility_meter/diagnostics.py new file mode 100644 index 00000000000..1ff723f7a89 --- /dev/null +++ b/homeassistant/components/utility_meter/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for Utility Meter.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DATA_TARIFF_SENSORS, DATA_UTILITY + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + tariff_sensors = [] + + for sensor in hass.data[DATA_UTILITY][entry.entry_id][DATA_TARIFF_SENSORS]: + restored_last_extra_data = await sensor.async_get_last_extra_data() + + tariff_sensors.append( + { + "name": sensor.name, + "entity_id": sensor.entity_id, + "extra_attributes": sensor.extra_state_attributes, + "last_sensor_data": restored_last_extra_data, + "period": sensor._period, # noqa: SLF001 + "cron": sensor._cron_pattern, # noqa: SLF001 + "source": sensor._sensor_source_id, # noqa: SLF001 + } + ) + + return { + "config_entry": entry, + "tariff_sensors": tariff_sensors, + } diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 461fee3ba9f..d5b1206d046 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -8,7 +8,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -30,28 +30,10 @@ async def async_setup_entry( unique_id = config_entry.entry_id - registry = er.async_get(hass) - source_entity = registry.async_get(config_entry.options[CONF_SOURCE_SENSOR]) - dev_reg = dr.async_get(hass) - # Resolve source entity device - if ( - (source_entity is not None) - and (source_entity.device_id is not None) - and ( - ( - device := dev_reg.async_get( - device_id=source_entity.device_id, - ) - ) - is not None - ) - ): - device_info = DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, - ) - else: - device_info = None + device_info = async_device_info_to_link_from_entity( + hass, + config_entry.options[CONF_SOURCE_SENSOR], + ) tariff_select = TariffSelect( name, diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 223e54d7d9f..6b8c07c7ef7 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -37,12 +37,8 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.helpers import ( - device_registry as dr, - entity_platform, - entity_registry as er, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import entity_platform, entity_registry as er +from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -57,7 +53,7 @@ import homeassistant.util.dt as dt_util from homeassistant.util.enum import try_parse_enum from .const import ( - ATTR_CRON_PATTERN, + ATTR_NEXT_RESET, ATTR_VALUE, BIMONTHLY, CONF_CRON_PATTERN, @@ -130,27 +126,10 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - source_entity = registry.async_get(source_entity_id) - dev_reg = dr.async_get(hass) - # Resolve source entity device - if ( - (source_entity is not None) - and (source_entity.device_id is not None) - and ( - ( - device := dev_reg.async_get( - device_id=source_entity.device_id, - ) - ) - is not None - ) - ): - device_info = DeviceInfo( - identifiers=device.identifiers, - connections=device.connections, - ) - else: - device_info = None + device_info = async_device_info_to_link_from_entity( + hass, + source_entity_id, + ) cron_pattern = None delta_values = config_entry.options[CONF_METER_DELTA_VALUES] @@ -373,6 +352,7 @@ class UtilityMeterSensor(RestoreSensor): _attr_translation_key = "utility_meter" _attr_should_poll = False + _unrecorded_attributes = frozenset({ATTR_NEXT_RESET}) def __init__( self, @@ -424,6 +404,7 @@ class UtilityMeterSensor(RestoreSensor): self._sensor_periodically_resetting = periodically_resetting self._tariff = tariff self._tariff_entity = tariff_entity + self._next_reset = None def start(self, attributes: Mapping[str, Any]) -> None: """Initialize unit and state upon source initial update.""" @@ -563,14 +544,15 @@ class UtilityMeterSensor(RestoreSensor): async def _program_reset(self): """Program the reset of the utility meter.""" if self._cron_pattern is not None: - tz = dt_util.get_time_zone(self.hass.config.time_zone) + tz = dt_util.get_default_time_zone() + self._next_reset = croniter(self._cron_pattern, dt_util.now(tz)).get_next( + datetime + ) # we need timezone for DST purposes (see issue #102984) self.async_on_remove( async_track_point_in_time( self.hass, self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now(tz)).get_next( - datetime - ), # we need timezone for DST purposes (see issue #102984) + self._next_reset, ) ) @@ -736,15 +718,10 @@ class UtilityMeterSensor(RestoreSensor): def extra_state_attributes(self): """Return the state attributes of the sensor.""" state_attr = { - ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: str(self._last_period), ATTR_LAST_VALID_STATE: str(self._last_valid_state), } - if self._period is not None: - state_attr[ATTR_PERIOD] = self._period - if self._cron_pattern is not None: - state_attr[ATTR_CRON_PATTERN] = self._cron_pattern if self._tariff is not None: state_attr[ATTR_TARIFF] = self._tariff # last_reset in utility meter was used before last_reset was added for long term @@ -754,6 +731,8 @@ class UtilityMeterSensor(RestoreSensor): # in extra state attributes. if last_reset := self._last_reset: state_attr[ATTR_LAST_RESET] = last_reset.isoformat() + if self._next_reset is not None: + state_attr[ATTR_NEXT_RESET] = self._next_reset.isoformat() return state_attr diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 4615bc2990a..3162fc67566 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -247,8 +247,7 @@ class UnifiVideoCamera(Camera): ( uri for i, uri in enumerate(channel["rtspUris"]) - # pylint: disable-next=protected-access - if re.search(self._nvr._host, uri) + if re.search(self._nvr._host, uri) # noqa: SLF001 ) ) diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 75d306b392a..0c07891df72 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -9,7 +9,6 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client -from .const import DOMAIN from .coordinator import V2CUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -20,7 +19,10 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type V2CConfigEntry = ConfigEntry[V2CUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: """Set up V2C from a config entry.""" host = entry.data[CONF_HOST] @@ -29,7 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator + + if coordinator.data.ID and entry.unique_id != coordinator.data.ID: + hass.config_entries.async_update_entry(entry, unique_id=coordinator.data.ID) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -38,7 +43,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 203cc9f3396..28ad3665996 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -12,11 +12,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import V2CConfigEntry from .coordinator import V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -51,11 +50,11 @@ TRYDAN_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: V2CConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up V2C binary sensor platform.""" - coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( V2CBinarySensorBaseEntity(coordinator, description, config_entry.entry_id) diff --git a/homeassistant/components/v2c/config_flow.py b/homeassistant/components/v2c/config_flow.py index 4d798795cbe..0421d882ee6 100644 --- a/homeassistant/components/v2c/config_flow.py +++ b/homeassistant/components/v2c/config_flow.py @@ -41,13 +41,18 @@ class V2CConfigFlow(ConfigFlow, domain=DOMAIN): ) try: - await evse.get_data() + data = await evse.get_data() + except TrydanError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + if data.ID: + await self.async_set_unique_id(data.ID) + self._abort_if_unique_id_configured() + return self.async_create_entry( title=f"EVSE {user_input[CONF_HOST]}", data=user_input ) diff --git a/homeassistant/components/v2c/diagnostics.py b/homeassistant/components/v2c/diagnostics.py new file mode 100644 index 00000000000..289d585b164 --- /dev/null +++ b/homeassistant/components/v2c/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for V2C.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import V2CConfigEntry + +TO_REDACT = {CONF_HOST, "title"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: V2CConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + if TYPE_CHECKING: + assert coordinator.evse + + coordinator_data = coordinator.evse.data + evse_raw_data = coordinator.evse.raw_data + + return { + "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": str(coordinator_data), + "raw_data": evse_raw_data["content"].decode("utf-8"), # type: ignore[attr-defined] + "host_status": evse_raw_data["status_code"], + } diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index 0c0609de347..1b76b669956 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -15,6 +15,12 @@ }, "fv_power": { "default": "mdi:solar-power-variant" + }, + "meter_error": { + "default": "mdi:alert" + }, + "battery_power": { + "default": "mdi:home-battery" } }, "switch": { diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ce0e9d7b847..ffe4b52ee6e 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/v2c", "iot_class": "local_polling", - "requirements": ["pytrydan==0.4.0"] + "requirements": ["pytrydan==0.7.0"] } diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 376509c4780..2ff70226132 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -13,11 +13,10 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import V2CConfigEntry from .coordinator import V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -48,11 +47,11 @@ TRYDAN_NUMBER_SETTINGS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: V2CConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up V2C Trydan number platform.""" - coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( V2CSettingsNumberEntity(coordinator, description, config_entry.entry_id) diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 871dd65aa75..fc0cc0bfaa8 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import logging from pytrydan import TrydanData +from pytrydan.models.trydan import SlaveCommunicationState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -14,12 +15,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import V2CConfigEntry from .coordinator import V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -30,9 +31,16 @@ _LOGGER = logging.getLogger(__name__) class V2CSensorEntityDescription(SensorEntityDescription): """Describes an EVSE Power sensor entity.""" - value_fn: Callable[[TrydanData], float] + value_fn: Callable[[TrydanData], StateType] +def get_meter_value(value: SlaveCommunicationState) -> str: + """Return the value of the enum and replace slave by meter.""" + return value.name.lower().replace("slave", "meter") + + +_METER_ERROR_OPTIONS = [get_meter_value(error) for error in SlaveCommunicationState] + TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_power", @@ -75,16 +83,33 @@ TRYDAN_SENSORS = ( device_class=SensorDeviceClass.POWER, value_fn=lambda evse_data: evse_data.fv_power, ), + V2CSensorEntityDescription( + key="meter_error", + translation_key="meter_error", + value_fn=lambda evse_data: get_meter_value(evse_data.slave_error), + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=_METER_ERROR_OPTIONS, + ), + V2CSensorEntityDescription( + key="battery_power", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.battery_power, + entity_registry_enabled_default=False, + ), ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: V2CConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up V2C sensor platform.""" - coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( V2CSensorBaseEntity(coordinator, description, config_entry.entry_id) @@ -108,6 +133,6 @@ class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): self._attr_unique_id = f"{entry_id}_{description.key}" @property - def native_value(self) -> float | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a60b61831fd..3342652cfb4 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, "step": { "user": { "data": { @@ -47,6 +50,49 @@ }, "fv_power": { "name": "Photovoltaic power" + }, + "battery_power": { + "name": "Battery power" + }, + "meter_error": { + "name": "Meter error", + "state": { + "no_error": "No error", + "communication": "Communication", + "reading": "Reading", + "meter": "Meter", + "waiting_wifi": "Waiting for Wi-Fi", + "waiting_communication": "Waiting communication", + "wrong_ip": "Wrong IP", + "meter_not_found": "Meter not found", + "wrong_meter": "Wrong meter", + "no_response": "No response", + "clamp_not_connected": "Clamp not connected", + "illegal_function": "Illegal function", + "illegal_data_address": "Illegal data address", + "illegal_data_value": "Illegal data value", + "server_device_failure": "Server device failure", + "acknowledge": "Acknowledge", + "server_device_busy": "Server device busy", + "negative_acknowledge": "Negative acknowledge", + "memory_parity_error": "Memory parity error", + "gateway_path_unavailable": "Gateway path unavailable", + "gateway_target_no_resp": "Gateway target no response", + "server_rtu_inactive244_timeout": "Server RTU inactive/timeout", + "invalid_server": "Invalid server", + "crc_error": "CRC error", + "fc_mismatch": "FC mismatch", + "server_id_mismatch": "Server id mismatch", + "packet_length_error": "Packet length error", + "parameter_count_error": "Parameter count error", + "parameter_limit_error": "Parameter limit error", + "request_queue_full": "Request queue full", + "illegal_ip_or_port": "Illegal IP or port", + "ip_connection_failed": "IP connection failed", + "tcp_head_mismatch": "TCP head mismatch", + "empty_message": "Empty message", + "undefined_error": "Undefined error" + } } }, "switch": { diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index 0974a712153..cd89e954275 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -17,11 +17,10 @@ from pytrydan.models.trydan import ( ) 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 DOMAIN +from . import V2CConfigEntry from .coordinator import V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -80,11 +79,11 @@ TRYDAN_SWITCHES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: V2CConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up V2C switch platform.""" - coordinator: V2CUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( V2CSwitchEntity(coordinator, description, config_entry.entry_id) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index b50068de149..f68f9a4f082 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -34,7 +34,6 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import group as group_pre_import # noqa: F401 from .const import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETURNING _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vacuum/group.py b/homeassistant/components/vacuum/group.py deleted file mode 100644 index f8cd790e623..00000000000 --- a/homeassistant/components/vacuum/group.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, callback - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - -from .const import DOMAIN, STATE_CLEANING, STATE_ERROR, STATE_RETURNING - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states( - DOMAIN, - { - STATE_ON, - STATE_CLEANING, - STATE_RETURNING, - STATE_ERROR, - }, - STATE_ON, - STATE_OFF, - ) diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py index 534078ec8af..8952c13875d 100644 --- a/homeassistant/components/vacuum/intent.py +++ b/homeassistant/components/vacuum/intent.py @@ -13,11 +13,21 @@ async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the vacuum intents.""" intent.async_register( hass, - intent.ServiceIntentHandler(INTENT_VACUUM_START, DOMAIN, SERVICE_START), + intent.ServiceIntentHandler( + INTENT_VACUUM_START, + DOMAIN, + SERVICE_START, + description="Starts a vacuum", + platforms={DOMAIN}, + ), ) intent.async_register( hass, intent.ServiceIntentHandler( - INTENT_VACUUM_RETURN_TO_BASE, DOMAIN, SERVICE_RETURN_TO_BASE + INTENT_VACUUM_RETURN_TO_BASE, + DOMAIN, + SERVICE_RETURN_TO_BASE, + description="Returns a vacuum to base", + platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 673c76b7f8d..1efaf87e748 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -12,6 +12,9 @@ "action_type": { "clean": "Let {entity_name} clean", "dock": "Let {entity_name} return to the dock" + }, + "extra_fields": { + "for": "[%key:common::device_automation::extra_fields::for%]" } }, "entity_component": { diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index b8e94e9dfb7..292786e4c0e 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -6,7 +6,7 @@ import ipaddress import logging from typing import NamedTuple -from vallox_websocket_api import MetricData, Profile, Vallox, ValloxApiException +from vallox_websocket_api import Profile, Vallox, ValloxApiException import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -14,11 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_FAN_SPEED_AWAY, @@ -26,8 +22,8 @@ from .const import ( DEFAULT_FAN_SPEED_HOME, DEFAULT_NAME, DOMAIN, - STATE_SCAN_INTERVAL, ) +from .coordinator import ValloxDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -93,10 +89,6 @@ SERVICE_TO_METHOD = { } -class ValloxDataUpdateCoordinator(DataUpdateCoordinator[MetricData]): # pylint: disable=hass-enforce-coordinator-module - """The DataUpdateCoordinator for Vallox.""" - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the client and boot the platforms.""" host = entry.data[CONF_HOST] @@ -104,22 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = Vallox(host) - async def async_update_data() -> MetricData: - """Fetch state update.""" - _LOGGER.debug("Updating Vallox state cache") - - try: - return await client.fetch_metric_data() - except ValloxApiException as err: - raise UpdateFailed("Error during state cache update") from err - - coordinator = ValloxDataUpdateCoordinator( - hass, - _LOGGER, - name=f"{name} DataUpdateCoordinator", - update_interval=STATE_SCAN_INTERVAL, - update_method=async_update_data, - ) + coordinator = ValloxDataUpdateCoordinator(hass, name, client) await coordinator.async_config_entry_first_refresh() @@ -161,7 +138,7 @@ class ValloxServiceHandler: """Services implementation.""" def __init__( - self, client: Vallox, coordinator: DataUpdateCoordinator[MetricData] + self, client: Vallox, coordinator: ValloxDataUpdateCoordinator ) -> None: """Initialize the proxy.""" self._client = client diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index fbcfa403738..20593fa4402 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -13,8 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxDataUpdateCoordinator, ValloxEntity +from . import ValloxEntity from .const import DOMAIN +from .coordinator import ValloxDataUpdateCoordinator class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index 4812097d4e0..3660c641b7c 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -18,7 +18,7 @@ from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, } @@ -47,10 +47,10 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, + data_schema=CONFIG_SCHEMA, ) - errors = {} + errors: dict[str, str] = {} host = user_input[CONF_HOST] @@ -62,7 +62,7 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): errors[CONF_HOST] = "invalid_host" except ValloxApiException: errors[CONF_HOST] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors[CONF_HOST] = "unknown" else: @@ -76,7 +76,55 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, {CONF_HOST: host} + ), + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the Vallox device host address.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + + if not user_input: + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, {CONF_HOST: entry.data.get(CONF_HOST)} + ), + ) + + updated_host = user_input[CONF_HOST] + + if entry.data.get(CONF_HOST) != updated_host: + self._async_abort_entries_match({CONF_HOST: updated_host}) + + errors: dict[str, str] = {} + + try: + await validate_host(self.hass, updated_host) + except InvalidHost: + errors[CONF_HOST] = "invalid_host" + except ValloxApiException: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors[CONF_HOST] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data={**entry.data, CONF_HOST: updated_host}, + reason="reconfigure_successful", + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + CONFIG_SCHEMA, {CONF_HOST: updated_host} + ), errors=errors, ) diff --git a/homeassistant/components/vallox/coordinator.py b/homeassistant/components/vallox/coordinator.py new file mode 100644 index 00000000000..c2485c7b4fd --- /dev/null +++ b/homeassistant/components/vallox/coordinator.py @@ -0,0 +1,42 @@ +"""Coordinator for Vallox ventilation units.""" + +from __future__ import annotations + +import logging + +from vallox_websocket_api import MetricData, Vallox, ValloxApiException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import STATE_SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class ValloxDataUpdateCoordinator(DataUpdateCoordinator[MetricData]): + """The DataUpdateCoordinator for Vallox.""" + + def __init__( + self, + hass: HomeAssistant, + name: str, + client: Vallox, + ) -> None: + """Initialize Vallox data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{name} DataUpdateCoordinator", + update_interval=STATE_SCAN_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> MetricData: + """Fetch state update.""" + _LOGGER.debug("Updating Vallox state cache") + + try: + return await self.client.fetch_metric_data() + except ValloxApiException as err: + raise UpdateFailed("Error during state cache update") from err diff --git a/homeassistant/components/vallox/date.py b/homeassistant/components/vallox/date.py index 0cdb7cdbb3f..0236117fd0f 100644 --- a/homeassistant/components/vallox/date.py +++ b/homeassistant/components/vallox/date.py @@ -12,8 +12,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxDataUpdateCoordinator, ValloxEntity +from . import ValloxEntity from .const import DOMAIN +from .coordinator import ValloxDataUpdateCoordinator class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity): diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 46f6fb022e4..a5bdf0983ae 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import ValloxDataUpdateCoordinator, ValloxEntity +from . import ValloxEntity from .const import ( DOMAIN, METRIC_KEY_MODE, @@ -26,6 +26,7 @@ from .const import ( PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE, VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE, ) +from .coordinator import ValloxDataUpdateCoordinator class ExtraStateAttributeDetails(NamedTuple): diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index 83316a13645..93190da1f16 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -16,8 +16,9 @@ from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxDataUpdateCoordinator, ValloxEntity +from . import ValloxEntity from .const import DOMAIN +from .coordinator import ValloxDataUpdateCoordinator class ValloxNumberEntity(ValloxEntity, NumberEntity): diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 8fca6f3b05d..281bc002f68 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import ValloxDataUpdateCoordinator, ValloxEntity +from . import ValloxEntity from .const import ( DOMAIN, METRIC_KEY_MODE, @@ -32,6 +32,7 @@ from .const import ( VALLOX_CELL_STATE_TO_STR, VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE, ) +from .coordinator import ValloxDataUpdateCoordinator class ValloxSensorEntity(ValloxEntity, SensorEntity): @@ -108,7 +109,7 @@ class ValloxFilterRemainingSensor(ValloxSensorEntity): return datetime.combine( next_filter_change_date, - time(hour=13, minute=0, second=0, tzinfo=dt_util.DEFAULT_TIME_ZONE), + time(hour=13, minute=0, second=0, tzinfo=dt_util.get_default_time_zone()), ) diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index d23d54c75cb..072b59b78e0 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -8,10 +8,19 @@ "data_description": { "host": "Hostname or IP address of your Vallox device." } + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::vallox::config::step::user::data_description::host%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 90e2311bf95..d70de89606d 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -13,8 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxDataUpdateCoordinator, ValloxEntity +from . import ValloxEntity from .const import DOMAIN +from .coordinator import ValloxDataUpdateCoordinator class ValloxSwitchEntity(ValloxEntity, SwitchEntity): diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 202666e6123..65f8a1d8d31 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from velbusaio.channels import Channel as VelbusChannel @@ -44,11 +44,7 @@ class VelbusEntity(Entity): self.async_write_ha_state() -_T = TypeVar("_T", bound="VelbusEntity") -_P = ParamSpec("_P") - - -def api_call( +def api_call[_T: VelbusEntity, **_P]( func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 6f817a23325..f778533cad8 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2024.4.1"], + "requirements": ["velbus-aio==2024.5.1"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index 679af4bd20a..c0d4ec8035b 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -67,7 +67,7 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): except (PyVLXException, ConnectionError): create_repair("cannot_connect") return self.async_abort(reason="cannot_connect") - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 create_repair("unknown") return self.async_abort(reason="unknown") @@ -95,7 +95,7 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN): except (PyVLXException, ConnectionError) as err: errors["base"] = "cannot_connect" LOGGER.debug("Cannot connect: %s", err) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.exception("Unexpected exception: %s", err) errors["base"] = "unknown" else: diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 13368a60350..cbcfd3dff90 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -2,10 +2,6 @@ from __future__ import annotations -import asyncio -from datetime import timedelta - -from requests import RequestException from venstarcolortouch import VenstarColorTouch from homeassistant.config_entries import ConfigEntry @@ -18,11 +14,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import update_coordinator from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP, VENSTAR_TIMEOUT +from .const import DOMAIN, VENSTAR_TIMEOUT +from .coordinator import VenstarDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] @@ -65,67 +61,6 @@ async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: return unload_ok -class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching Venstar data.""" - - def __init__( - self, - hass: HomeAssistant, - *, - venstar_connection: VenstarColorTouch, - ) -> None: - """Initialize global Venstar data updater.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=60), - ) - self.client = venstar_connection - self.runtimes: list[dict[str, int]] = [] - - async def _async_update_data(self) -> None: - """Update the state.""" - try: - await self.hass.async_add_executor_job(self.client.update_info) - except (OSError, RequestException) as ex: - raise update_coordinator.UpdateFailed( - f"Exception during Venstar info update: {ex}" - ) from ex - - # older venstars sometimes cannot handle rapid sequential connections - await asyncio.sleep(VENSTAR_SLEEP) - - try: - await self.hass.async_add_executor_job(self.client.update_sensors) - except (OSError, RequestException) as ex: - raise update_coordinator.UpdateFailed( - f"Exception during Venstar sensor update: {ex}" - ) from ex - - # older venstars sometimes cannot handle rapid sequential connections - await asyncio.sleep(VENSTAR_SLEEP) - - try: - await self.hass.async_add_executor_job(self.client.update_alerts) - except (OSError, RequestException) as ex: - raise update_coordinator.UpdateFailed( - f"Exception during Venstar alert update: {ex}" - ) from ex - - # older venstars sometimes cannot handle rapid sequential connections - await asyncio.sleep(VENSTAR_SLEEP) - - try: - self.runtimes = await self.hass.async_add_executor_job( - self.client.get_runtimes - ) - except (OSError, RequestException) as ex: - raise update_coordinator.UpdateFailed( - f"Exception during Venstar runtime update: {ex}" - ) from ex - - class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): """Representation of a Venstar entity.""" diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index e0aacadffa7..f47cf59be9c 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -36,7 +36,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import VenstarDataUpdateCoordinator, VenstarEntity +from . import VenstarEntity from .const import ( _LOGGER, ATTR_FAN_STATE, @@ -46,6 +46,7 @@ from .const import ( DOMAIN, HOLD_MODE_TEMPERATURE, ) +from .coordinator import VenstarDataUpdateCoordinator PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/venstar/config_flow.py b/homeassistant/components/venstar/config_flow.py index 5a193568c87..289f7936676 100644 --- a/homeassistant/components/venstar/config_flow.py +++ b/homeassistant/components/venstar/config_flow.py @@ -65,7 +65,7 @@ class VenstarConfigFlow(ConfigFlow, domain=DOMAIN): title = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/venstar/coordinator.py b/homeassistant/components/venstar/coordinator.py new file mode 100644 index 00000000000..b825775de7f --- /dev/null +++ b/homeassistant/components/venstar/coordinator.py @@ -0,0 +1,75 @@ +"""Coordinator for the venstar component.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from requests import RequestException +from venstarcolortouch import VenstarColorTouch + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP + + +class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): + """Class to manage fetching Venstar data.""" + + def __init__( + self, + hass: HomeAssistant, + *, + venstar_connection: VenstarColorTouch, + ) -> None: + """Initialize global Venstar data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + self.client = venstar_connection + self.runtimes: list[dict[str, int]] = [] + + async def _async_update_data(self) -> None: + """Update the state.""" + try: + await self.hass.async_add_executor_job(self.client.update_info) + except (OSError, RequestException) as ex: + raise update_coordinator.UpdateFailed( + f"Exception during Venstar info update: {ex}" + ) from ex + + # older venstars sometimes cannot handle rapid sequential connections + await asyncio.sleep(VENSTAR_SLEEP) + + try: + await self.hass.async_add_executor_job(self.client.update_sensors) + except (OSError, RequestException) as ex: + raise update_coordinator.UpdateFailed( + f"Exception during Venstar sensor update: {ex}" + ) from ex + + # older venstars sometimes cannot handle rapid sequential connections + await asyncio.sleep(VENSTAR_SLEEP) + + try: + await self.hass.async_add_executor_job(self.client.update_alerts) + except (OSError, RequestException) as ex: + raise update_coordinator.UpdateFailed( + f"Exception during Venstar alert update: {ex}" + ) from ex + + # older venstars sometimes cannot handle rapid sequential connections + await asyncio.sleep(VENSTAR_SLEEP) + + try: + self.runtimes = await self.hass.async_add_executor_job( + self.client.get_runtimes + ) + except (OSError, RequestException) as ex: + raise update_coordinator.UpdateFailed( + f"Exception during Venstar runtime update: {ex}" + ) from ex diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 24b4b2f8b16..ee4ad43ade6 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -23,8 +23,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VenstarDataUpdateCoordinator, VenstarEntity +from . import VenstarEntity from .const import DOMAIN +from .coordinator import VenstarDataUpdateCoordinator RUNTIME_HEAT1 = "heat1" RUNTIME_HEAT2 = "heat2" @@ -65,13 +66,15 @@ SCHEDULE_PARTS: dict[int, str] = { 255: "inactive", } +STAGES: dict[int, str] = {0: "idle", 1: "first_stage", 2: "second_stage"} + @dataclass(frozen=True, kw_only=True) class VenstarSensorEntityDescription(SensorEntityDescription): """Base description of a Sensor entity.""" value_fn: Callable[[VenstarDataUpdateCoordinator, str], Any] - name_fn: Callable[[str], str] + name_fn: Callable[[str], str] | None uom_fn: Callable[[Any], str | None] @@ -140,7 +143,8 @@ class VenstarSensor(VenstarEntity, SensorEntity): super().__init__(coordinator, config) self.entity_description = entity_description self.sensor_name = sensor_name - self._attr_name = entity_description.name_fn(sensor_name) + if entity_description.name_fn: + self._attr_name = entity_description.name_fn(sensor_name) self._config = config @property @@ -230,6 +234,17 @@ INFO_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( value_fn=lambda coordinator, sensor_name: SCHEDULE_PARTS[ coordinator.client.get_info(sensor_name) ], - name_fn=lambda _: "Schedule Part", + name_fn=None, + ), + VenstarSensorEntityDescription( + key="activestage", + device_class=SensorDeviceClass.ENUM, + options=list(STAGES.values()), + translation_key="active_stage", + uom_fn=lambda _: None, + value_fn=lambda coordinator, sensor_name: STAGES[ + coordinator.client.get_info(sensor_name) + ], + name_fn=None, ), ) diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index 92dfac211fb..952353dcbfe 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -26,6 +26,7 @@ "entity": { "sensor": { "schedule_part": { + "name": "Schedule Part", "state": { "morning": "Morning", "day": "Day", @@ -33,6 +34,14 @@ "night": "Night", "inactive": "Inactive" } + }, + "active_stage": { + "name": "Active stage", + "state": { + "idle": "Idle", + "first_stage": "First stage", + "second_stage": "Second stage" + } } } } diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index acbb89f4367..722a6b86d4b 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -4,9 +4,8 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Awaitable import logging -from typing import Any, Generic, TypeVar +from typing import Any import pyvera as veraApi from requests.exceptions import RequestException @@ -157,16 +156,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload Withings config entry.""" + """Unload vera config entry.""" controller_data: ControllerData = get_controller_data(hass, config_entry) - - tasks: list[Awaitable] = [ - hass.config_entries.async_forward_entry_unload(config_entry, platform) - for platform in get_configured_platforms(controller_data) - ] - tasks.append(hass.async_add_executor_job(controller_data.controller.stop)) - await asyncio.gather(*tasks) - + await asyncio.gather( + *( + hass.config_entries.async_unload_platforms( + config_entry, get_configured_platforms(controller_data) + ), + hass.async_add_executor_job(controller_data.controller.stop), + ) + ) return True @@ -207,10 +206,7 @@ def map_vera_device( ) -_DeviceTypeT = TypeVar("_DeviceTypeT", bound=veraApi.VeraDevice) - - -class VeraDevice(Generic[_DeviceTypeT], Entity): +class VeraDevice[_DeviceTypeT: veraApi.VeraDevice](Entity): """Representation of a Vera device entity.""" def __init__( diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index e758636900b..7dceb1b3f8f 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b device_dict = await async_process_devices(hass, manager) - forward_setup = hass.config_entries.async_forward_entry_setup + forward_setups = hass.config_entries.async_forward_entry_setups hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager @@ -97,7 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return if new_switches and not switches: switches.extend(new_switches) - hass.async_create_task(forward_setup(config_entry, Platform.SWITCH)) + hass.async_create_task(forward_setups(config_entry, [Platform.SWITCH])) fan_set = set(fan_devs) new_fans = list(fan_set.difference(fans)) @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return if new_fans and not fans: fans.extend(new_fans) - hass.async_create_task(forward_setup(config_entry, Platform.FAN)) + hass.async_create_task(forward_setups(config_entry, [Platform.FAN])) light_set = set(light_devs) new_lights = list(light_set.difference(lights)) @@ -117,7 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return if new_lights and not lights: lights.extend(new_lights) - hass.async_create_task(forward_setup(config_entry, Platform.LIGHT)) + hass.async_create_task(forward_setups(config_entry, [Platform.LIGHT])) sensor_set = set(sensor_devs) new_sensors = list(sensor_set.difference(sensors)) @@ -127,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return if new_sensors and not sensors: sensors.extend(new_sensors) - hass.async_create_task(forward_setup(config_entry, Platform.SENSOR)) + hass.async_create_task(forward_setups(config_entry, [Platform.SENSOR])) hass.services.async_register( DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 0212a7afa57..33fc88f32d6 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -67,7 +67,7 @@ class VeSyncBaseEntity(Entity): # sensors. Maintaining base_unique_id allows us to group related # entities under a single device. if isinstance(self.device.sub_device_no, int): - return f"{self.device.cid}{str(self.device.sub_device_no)}" + return f"{self.device.cid}{self.device.sub_device_no!s}" return self.device.cid @property diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 490048190fa..1333327609d 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -19,10 +19,6 @@ import requests import voluptuous as vol from homeassistant.components.climate import ( - PRESET_COMFORT, - PRESET_ECO, - PRESET_HOME, - PRESET_SLEEP, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -78,14 +74,11 @@ VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = { VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, } -VICARE_TO_HA_PRESET_HEATING = { - HeatingProgram.COMFORT: PRESET_COMFORT, - HeatingProgram.ECO: PRESET_ECO, - HeatingProgram.NORMAL: PRESET_HOME, - HeatingProgram.REDUCED: PRESET_SLEEP, -} - -HA_TO_VICARE_PRESET_HEATING = {v: k for k, v in VICARE_TO_HA_PRESET_HEATING.items()} +CHANGABLE_HEATING_PROGRAMS = [ + HeatingProgram.COMFORT, + HeatingProgram.COMFORT_HEATING, + HeatingProgram.ECO, +] def _build_entities( @@ -143,7 +136,6 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_min_temp = VICARE_TEMP_HEATING_MIN _attr_max_temp = VICARE_TEMP_HEATING_MAX _attr_target_temperature_step = PRECISION_WHOLE - _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) _current_action: bool | None = None _current_mode: str | None = None _enable_turn_on_off_backwards_compatibility = False @@ -162,6 +154,13 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._current_program = None self._attr_translation_key = translation_key + self._attributes["vicare_programs"] = self._circuit.getPrograms() + self._attr_preset_modes = [ + preset + for heating_program in self._attributes["vicare_programs"] + if (preset := HeatingProgram.to_ha_preset(heating_program)) is not None + ] + def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" try: @@ -293,11 +292,13 @@ class ViCareClimate(ViCareEntity, ClimateEntity): @property def preset_mode(self): """Return the current preset mode, e.g., home, away, temp.""" - return VICARE_TO_HA_PRESET_HEATING.get(self._current_program) + return HeatingProgram.to_ha_preset(self._current_program) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode and deactivate any existing programs.""" - target_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) + target_program = HeatingProgram.from_ha_preset( + preset_mode, self._attributes["vicare_programs"] + ) if target_program is None: raise ServiceValidationError( translation_domain=DOMAIN, @@ -308,12 +309,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) _LOGGER.debug("Current preset %s", self._current_program) - if self._current_program and self._current_program not in [ - HeatingProgram.NORMAL, - HeatingProgram.REDUCED, - HeatingProgram.STANDBY, - ]: - # We can't deactivate "normal", "reduced" or "standby" + if ( + self._current_program + and self._current_program in CHANGABLE_HEATING_PROGRAMS + ): _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) @@ -327,12 +326,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) from err _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) - if target_program not in [ - HeatingProgram.NORMAL, - HeatingProgram.REDUCED, - HeatingProgram.STANDBY, - ]: - # And we can't explicitly activate "normal", "reduced" or "standby", either + if target_program in CHANGABLE_HEATING_PROGRAMS: _LOGGER.debug("activating %s", target_program) try: self._circuit.activateProgram(target_program) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 41266f8bde7..0e98729e40f 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -694,10 +694,50 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( key="photovoltaic_energy_production_today", translation_key="photovoltaic_energy_production_today", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, value_getter=lambda api: api.getPhotovoltaicProductionCumulatedCurrentDay(), unit_getter=lambda api: api.getPhotovoltaicProductionCumulatedUnit(), ), + ViCareSensorEntityDescription( + key="photovoltaic_energy_production_this_week", + translation_key="photovoltaic_energy_production_this_week", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_getter=lambda api: api.getPhotovoltaicProductionCumulatedCurrentWeek(), + unit_getter=lambda api: api.getPhotovoltaicProductionCumulatedUnit(), + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="photovoltaic_energy_production_this_month", + translation_key="photovoltaic_energy_production_this_month", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_getter=lambda api: api.getPhotovoltaicProductionCumulatedCurrentMonth(), + unit_getter=lambda api: api.getPhotovoltaicProductionCumulatedUnit(), + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="photovoltaic_energy_production_this_year", + translation_key="photovoltaic_energy_production_this_year", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_getter=lambda api: api.getPhotovoltaicProductionCumulatedCurrentYear(), + unit_getter=lambda api: api.getPhotovoltaicProductionCumulatedUnit(), + entity_registry_enabled_default=False, + ), + ViCareSensorEntityDescription( + key="photovoltaic_energy_production_total", + translation_key="photovoltaic_energy_production_total", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_getter=lambda api: api.getPhotovoltaicProductionCumulatedLifeCycle(), + unit_getter=lambda api: api.getPhotovoltaicProductionCumulatedUnit(), + ), ViCareSensorEntityDescription( key="photovoltaic_status", translation_key="photovoltaic_status", diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index f81d01b71cf..de92d0ec271 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -319,6 +319,18 @@ "photovoltaic_energy_production_today": { "name": "Solar energy production today" }, + "photovoltaic_energy_production_this_week": { + "name": "Solar energy production this week" + }, + "photovoltaic_energy_production_this_month": { + "name": "Solar energy production this month" + }, + "photovoltaic_energy_production_this_year": { + "name": "Solar energy production this year" + }, + "photovoltaic_energy_production_total": { + "name": "Solar energy production total" + }, "photovoltaic_status": { "name": "Solar state", "state": { diff --git a/homeassistant/components/vicare/types.py b/homeassistant/components/vicare/types.py index 2bed638bfb9..7e1ec7f8bee 100644 --- a/homeassistant/components/vicare/types.py +++ b/homeassistant/components/vicare/types.py @@ -8,6 +8,13 @@ from typing import Any from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from homeassistant.components.climate import ( + PRESET_COMFORT, + PRESET_ECO, + PRESET_HOME, + PRESET_SLEEP, +) + class HeatingProgram(enum.StrEnum): """ViCare preset heating programs. @@ -24,6 +31,38 @@ class HeatingProgram(enum.StrEnum): REDUCED_HEATING = "reducedHeating" STANDBY = "standby" + @staticmethod + def to_ha_preset(program: str) -> str | None: + """Return the mapped Home Assistant preset for the ViCare heating program.""" + + try: + heating_program = HeatingProgram(program) + except ValueError: + # ignore unsupported / unmapped programs + return None + return VICARE_TO_HA_PRESET_HEATING.get(heating_program) if program else None + + @staticmethod + def from_ha_preset( + ha_preset: str, supported_heating_programs: list[str] + ) -> str | None: + """Return the mapped ViCare heating program for the Home Assistant preset.""" + for program in supported_heating_programs: + if VICARE_TO_HA_PRESET_HEATING.get(HeatingProgram(program)) == ha_preset: + return program + return None + + +VICARE_TO_HA_PRESET_HEATING = { + HeatingProgram.COMFORT: PRESET_COMFORT, + HeatingProgram.COMFORT_HEATING: PRESET_COMFORT, + HeatingProgram.ECO: PRESET_ECO, + HeatingProgram.NORMAL: PRESET_HOME, + HeatingProgram.NORMAL_HEATING: PRESET_HOME, + HeatingProgram.REDUCED: PRESET_SLEEP, + HeatingProgram.REDUCED_HEATING: PRESET_SLEEP, +} + @dataclass(frozen=True) class ViCareDevice: diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py index 47e45aecadd..b21c63bfb97 100644 --- a/homeassistant/components/vilfo/config_flow.py +++ b/homeassistant/components/vilfo/config_flow.py @@ -111,7 +111,7 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.error("Unexpected exception: %s", err) errors["base"] = "unknown" else: diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index b8df8fb4529..09d6f3be090 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -2,12 +2,8 @@ from __future__ import annotations -from datetime import timedelta -import logging from typing import Any -from pyvizio.const import APPS -from pyvizio.util import gen_apps_list_from_url import voluptuous as vol from homeassistant.components.media_player import MediaPlayerDeviceClass @@ -15,14 +11,11 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA - -_LOGGER = logging.getLogger(__name__) +from .coordinator import VizioAppsDataUpdateCoordinator def validate_apps(config: ConfigType) -> ConfigType: @@ -96,53 +89,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data.pop(DOMAIN) return unload_ok - - -class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): # pylint: disable=hass-enforce-coordinator-module - """Define an object to hold Vizio app config data.""" - - def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: - """Initialize.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(days=1), - ) - self.fail_count = 0 - self.fail_threshold = 10 - self.store = store - - async def async_config_entry_first_refresh(self) -> None: - """Refresh data for the first time when a config entry is setup.""" - self.data = await self.store.async_load() or APPS - await super().async_config_entry_first_refresh() - - async def _async_update_data(self) -> list[dict[str, Any]]: - """Update data via library.""" - if data := await gen_apps_list_from_url( - session=async_get_clientsession(self.hass) - ): - # Reset the fail count and threshold when the data is successfully retrieved - self.fail_count = 0 - self.fail_threshold = 10 - # Store the new data if it has changed so we have it for the next restart - if data != self.data: - await self.store.async_save(data) - return data - # For every failure, increase the fail count until we reach the threshold. - # We then log a warning, increase the threshold, and reset the fail count. - # This is here to prevent silent failures but to reduce repeat logs. - if self.fail_count == self.fail_threshold: - _LOGGER.warning( - ( - "Unable to retrieve the apps list from the external server for the " - "last %s days" - ), - self.fail_threshold, - ) - self.fail_count = 0 - self.fail_threshold += 10 - else: - self.fail_count += 1 - return self.data diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py new file mode 100644 index 00000000000..1930828b595 --- /dev/null +++ b/homeassistant/components/vizio/coordinator.py @@ -0,0 +1,69 @@ +"""Coordinator for the vizio component.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from pyvizio.const import APPS +from pyvizio.util import gen_apps_list_from_url + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): + """Define an object to hold Vizio app config data.""" + + def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(days=1), + ) + self.fail_count = 0 + self.fail_threshold = 10 + self.store = store + + async def async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup.""" + self.data = await self.store.async_load() or APPS + await super().async_config_entry_first_refresh() + + async def _async_update_data(self) -> list[dict[str, Any]]: + """Update data via library.""" + if data := await gen_apps_list_from_url( + session=async_get_clientsession(self.hass) + ): + # Reset the fail count and threshold when the data is successfully retrieved + self.fail_count = 0 + self.fail_threshold = 10 + # Store the new data if it has changed so we have it for the next restart + if data != self.data: + await self.store.async_save(data) + return data + # For every failure, increase the fail count until we reach the threshold. + # We then log a warning, increase the threshold, and reset the fail count. + # This is here to prevent silent failures but to reduce repeat logs. + if self.fail_count == self.fail_threshold: + _LOGGER.warning( + ( + "Unable to retrieve the apps list from the external server for the " + "last %s days" + ), + self.fail_threshold, + ) + self.fail_count = 0 + self.fail_threshold += 10 + else: + self.fail_count += 1 + return self.data diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 18af2c0dbb2..ba9c92f94f1 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -34,7 +34,6 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VizioAppsDataUpdateCoordinator from .const import ( CONF_ADDITIONAL_CONFIGS, CONF_APPS, @@ -53,6 +52,7 @@ from .const import ( VIZIO_SOUND_MODE, VIZIO_VOLUME, ) +from .coordinator import VizioAppsDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vlc_telnet/__init__.py b/homeassistant/components/vlc_telnet/__init__.py index 9cab66cab24..a61fcafd2cb 100644 --- a/homeassistant/components/vlc_telnet/__init__.py +++ b/homeassistant/components/vlc_telnet/__init__.py @@ -14,7 +14,7 @@ from .const import LOGGER PLATFORMS = [Platform.MEDIA_PLAYER] -VlcConfigEntry = ConfigEntry["VlcData"] +type VlcConfigEntry = ConfigEntry[VlcData] @dataclass diff --git a/homeassistant/components/vlc_telnet/config_flow.py b/homeassistant/components/vlc_telnet/config_flow.py index 67325686282..6ccb92e5b8b 100644 --- a/homeassistant/components/vlc_telnet/config_flow.py +++ b/homeassistant/components/vlc_telnet/config_flow.py @@ -94,7 +94,7 @@ class VLCTelnetConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -127,7 +127,7 @@ class VLCTelnetConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -180,7 +180,7 @@ class VLCTelnetConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") except InvalidAuth: return self.async_abort(reason="invalid_auth") - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/vlc_telnet/manifest.json b/homeassistant/components/vlc_telnet/manifest.json index cdb5595d69c..7a5e00cff21 100644 --- a/homeassistant/components/vlc_telnet/manifest.json +++ b/homeassistant/components/vlc_telnet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vlc_telnet", "iot_class": "local_polling", "loggers": ["aiovlc"], - "requirements": ["aiovlc==0.1.0"] + "requirements": ["aiovlc==0.3.2"] } diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 7d4b8490c77..bd58b2ad23a 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, Literal from aiovlc.client import Client from aiovlc.exceptions import AuthError, CommandError, ConnectError @@ -30,8 +30,12 @@ from .const import DEFAULT_NAME, DOMAIN, LOGGER MAX_VOLUME = 500 -_VlcDeviceT = TypeVar("_VlcDeviceT", bound="VlcDevice") -_P = ParamSpec("_P") + +def _get_str(data: dict, key: str) -> str | None: + """Get a value from a dictionary and cast it to a string or None.""" + if value := data.get(key): + return str(value) + return None async def async_setup_entry( @@ -46,7 +50,7 @@ async def async_setup_entry( async_add_entities([VlcDevice(entry, vlc, name, available)], True) -def catch_vlc_errors( +def catch_vlc_errors[_VlcDeviceT: VlcDevice, **_P]( func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]], ) -> Callable[Concatenate[_VlcDeviceT, _P], Coroutine[Any, Any, None]]: """Catch VLC errors.""" @@ -59,7 +63,6 @@ def catch_vlc_errors( except CommandError as err: LOGGER.error("Command error: %s", err) except ConnectError as err: - # pylint: disable=protected-access if self._attr_available: LOGGER.error("Connection error: %s", err) self._attr_available = False @@ -156,10 +159,10 @@ class VlcDevice(MediaPlayerEntity): data = info.data LOGGER.debug("Info data: %s", data) - self._attr_media_album_name = data.get("data", {}).get("album") - self._attr_media_artist = data.get("data", {}).get("artist") - self._attr_media_title = data.get("data", {}).get("title") - now_playing = data.get("data", {}).get("now_playing") + self._attr_media_album_name = _get_str(data.get("data", {}), "album") + self._attr_media_artist = _get_str(data.get("data", {}), "artist") + self._attr_media_title = _get_str(data.get("data", {}), "title") + now_playing = _get_str(data.get("data", {}), "now_playing") # Many radio streams put artist/title/album in now_playing and title is the station name. if now_playing: @@ -172,7 +175,7 @@ class VlcDevice(MediaPlayerEntity): # Fall back to filename. if data_info := data.get("data"): - self._attr_media_title = data_info["filename"] + self._attr_media_title = _get_str(data_info, "filename") # Strip out auth signatures if streaming local media if (media_title := self.media_title) and ( @@ -272,7 +275,7 @@ class VlcDevice(MediaPlayerEntity): @catch_vlc_errors async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" - shuffle_command = "on" if shuffle else "off" + shuffle_command: Literal["on", "off"] = "on" if shuffle else "off" await self._vlc.random(shuffle_command) async def async_browse_media( diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index ed7f63b6c39..6b6adb6a18d 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -92,7 +92,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except aiovodafone_exceptions.ModelNotSupported: errors["base"] = "model_not_supported" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -127,7 +127,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except aiovodafone_exceptions.CannotAuthenticate: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index cf096a93d50..d2f408e355b 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -108,7 +108,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): exceptions.AlreadyLogged, exceptions.GenericLoginError, ) as err: - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + raise UpdateFailed(f"Error fetching data: {err!r}") from err except (ConfigEntryAuthFailed, UpdateFailed): await self.api.close() raise diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 7e2e974e709..47137fff26c 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiovodafone"], "quality_scale": "silver", - "requirements": ["aiovodafone==0.5.4"] + "requirements": ["aiovodafone==0.6.0"] } diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 581f4090657..84bbcc19409 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -80,7 +80,7 @@ SUPPORT_LANGUAGES = [ "vi-vn", ] -SUPPORT_CODECS = ["mp3", "wav", "aac", "ogg", "caf"] +SUPPORT_CODECS = ["mp3", "wav", "aac", "ogg", "caf"] # codespell:ignore caf SUPPORT_FORMATS = [ "8khz_8bit_mono", diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index e86fcd4417d..8edda1d20b0 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -79,7 +79,7 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, self._host, self._port) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index 1cb434e49bc..80358a28ced 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -60,7 +60,7 @@ class VolvoOnCallConfigFlow(ConfigFlow, domain=DOMAIN): await self.is_valid(user_input) except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unhandled exception in user step") errors["base"] = "unknown" if not errors: diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py index ae44c507c6a..560d777b517 100644 --- a/homeassistant/components/vulcan/config_flow.py +++ b/homeassistant/components/vulcan/config_flow.py @@ -73,7 +73,7 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): except ClientConnectionError as err: errors = {"base": "cannot_connect"} _LOGGER.error("Connection error: %s", err) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors = {"base": "unknown"} if not errors: @@ -156,7 +156,7 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_select_saved_credentials( errors={"base": "cannot_connect"} ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") return await self.async_step_auth(errors={"base": "unknown"}) if len(students) == 1: @@ -268,7 +268,7 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): except ClientConnectionError as err: errors["base"] = "cannot_connect" _LOGGER.error("Connection error: %s", err) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" if not errors: diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index f05a61e34dc..5ce592aacd8 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -147,7 +147,7 @@ async def websocket_entity_info( if entity is None: connection.send_error( - msg["id"], websocket_api.const.ERR_NOT_FOUND, "Entity not found" + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" ) return @@ -156,7 +156,7 @@ async def websocket_entity_info( wake_words = await entity.get_supported_wake_words() except TimeoutError: connection.send_error( - msg["id"], websocket_api.const.ERR_TIMEOUT, "Timeout fetching wake words" + msg["id"], websocket_api.ERR_TIMEOUT, "Timeout fetching wake words" ) return diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index bf7c6d1f654..e24ccd28440 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Callable from datetime import timedelta from http import HTTPStatus import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate import requests from wallbox import Wallbox @@ -64,11 +64,8 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 210: ChargerStatus.LOCKED_CAR_CONNECTED, } -_WallboxCoordinatorT = TypeVar("_WallboxCoordinatorT", bound="WallboxCoordinator") -_P = ParamSpec("_P") - -def _require_authentication( +def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any], ) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]: """Authenticate with decorator using Wallbox API.""" diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index e7e7a536654..51ba801c92e 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -45,7 +45,7 @@ async def get_by_station_number( measuring_station = await client.get_by_station_number(station_number) except WAQIConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" return measuring_station, errors @@ -76,7 +76,7 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except WAQIConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -118,7 +118,7 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): ) except WAQIConnectionError: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index d742fd72858..cb04bd7d6ac 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==3.0.1"] + "requirements": ["aiowaqi==3.1.0"] } diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index ce967a9b538..4c921c68336 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any from aiowaqi import WAQIAirQuality from aiowaqi.models import Pollutant @@ -17,13 +16,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - ATTR_TIME, - PERCENTAGE, - UnitOfPressure, - UnitOfTemperature, -) +from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -49,7 +42,7 @@ ATTR_SULFUR_DIOXIDE = "sulfur_dioxide" class WAQISensorEntityDescription(SensorEntityDescription): """Describes WAQI sensor entity.""" - available_fn: Callable[[WAQIAirQuality], bool] + available_fn: Callable[[WAQIAirQuality], bool] = lambda _: True value_fn: Callable[[WAQIAirQuality], StateType] @@ -59,7 +52,6 @@ SENSORS: list[WAQISensorEntityDescription] = [ device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda aq: aq.air_quality_index, - available_fn=lambda _: True, ), WAQISensorEntityDescription( key="humidity", @@ -141,7 +133,6 @@ SENSORS: list[WAQISensorEntityDescription] = [ device_class=SensorDeviceClass.ENUM, options=[pollutant.value for pollutant in Pollutant], value_fn=lambda aq: aq.dominant_pollutant, - available_fn=lambda _: True, ), ] @@ -152,11 +143,9 @@ async def async_setup_entry( """Set up the WAQI sensor.""" coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - WaqiSensor(coordinator, sensor) - for sensor in SENSORS - if sensor.available_fn(coordinator.data) - ] + WaqiSensor(coordinator, sensor) + for sensor in SENSORS + if sensor.available_fn(coordinator.data) ) @@ -188,28 +177,3 @@ class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): def native_value(self) -> StateType: """Return the state of the device.""" return self.entity_description.value_fn(self.coordinator.data) - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return old state attributes if the entity is AQI entity.""" - # These are deprecated and will be removed in 2024.5 - if self.entity_description.key != "air_quality": - return None - attrs: dict[str, Any] = {} - attrs[ATTR_TIME] = self.coordinator.data.measured_at - attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant - - iaqi = self.coordinator.data.extended_air_quality - - attribute = { - ATTR_PM2_5: iaqi.pm25, - ATTR_PM10: iaqi.pm10, - ATTR_HUMIDITY: iaqi.humidity, - ATTR_PRESSURE: iaqi.pressure, - ATTR_TEMPERATURE: iaqi.temperature, - ATTR_OZONE: iaqi.ozone, - ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, - ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, - } - res_attributes = {k: v for k, v in attribute.items() if v is not None} - return {**attrs, **res_attributes} diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index d6871947b77..1623b391e53 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -42,7 +42,6 @@ from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter -from . import group as group_pre_import # noqa: F401 from .const import DOMAIN DEFAULT_MIN_TEMP = 110 diff --git a/homeassistant/components/water_heater/group.py b/homeassistant/components/water_heater/group.py deleted file mode 100644 index f74bf8a9ae4..00000000000 --- a/homeassistant/components/water_heater/group.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, callback - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - -from .const import ( - DOMAIN, - STATE_ECO, - STATE_ELECTRIC, - STATE_GAS, - STATE_HEAT_PUMP, - STATE_HIGH_DEMAND, - STATE_PERFORMANCE, -) - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.on_off_states( - DOMAIN, - { - STATE_ON, - STATE_ECO, - STATE_ELECTRIC, - STATE_PERFORMANCE, - STATE_HIGH_DEMAND, - STATE_HEAT_PUMP, - STATE_GAS, - }, - STATE_ON, - STATE_OFF, - ) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 956cfe76b63..741b277d84d 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -18,6 +18,24 @@ "performance": "Performance" }, "state_attributes": { + "current_operation": { + "name": "Current operation" + }, + "current_temperature": { + "name": "Current temperature" + }, + "max_temp": { + "name": "Max target temperature" + }, + "min_temp": { + "name": "Min target temperature" + }, + "target_temp_high": { + "name": "Upper target temperature" + }, + "target_temp_low": { + "name": "Lower target temperature" + }, "away_mode": { "name": "Away mode", "state": { diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py index 8a412f81575..de8c85f5ff0 100644 --- a/homeassistant/components/watson_iot/__init__.py +++ b/homeassistant/components/watson_iot/__init__.py @@ -100,12 +100,12 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: or state.entity_id in exclude_e or state.domain in exclude_d ): - return + return None if (include_e and state.entity_id not in include_e) or ( include_d and state.domain not in include_d ): - return + return None try: _state_as_value = float(state.state) diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index 549f6fc7679..db68738b302 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -97,7 +97,7 @@ class WattTimeConfigFlow(ConfigFlow, domain=DOMAIN): errors={"base": "invalid_auth"}, description_placeholders={CONF_USERNAME: username}, ) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.exception("Unexpected exception while logging in: %s", err) return self.async_show_form( step_id=error_step_id, @@ -156,7 +156,7 @@ class WattTimeConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=STEP_COORDINATES_DATA_SCHEMA, errors={CONF_LATITUDE: "unknown_coordinates"}, ) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 LOGGER.exception("Unexpected exception while getting region: %s", err) return self.async_show_form( step_id="coordinates", diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 9c131f3242c..83b2e2aa7c7 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -1,24 +1,184 @@ """The waze_travel_time component.""" import asyncio +import logging + +from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError +import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_REGION, Platform +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.selector import ( + BooleanSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) -from .const import DOMAIN, SEMAPHORE +from .const import ( + CONF_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS, + CONF_DESTINATION, + CONF_ORIGIN, + CONF_REALTIME, + CONF_UNITS, + CONF_VEHICLE_TYPE, + DEFAULT_VEHICLE_TYPE, + DOMAIN, + METRIC_UNITS, + REGIONS, + SEMAPHORE, + UNITS, + VEHICLE_TYPES, +) PLATFORMS = [Platform.SENSOR] +SERVICE_GET_TRAVEL_TIMES = "get_travel_times" +SERVICE_GET_TRAVEL_TIMES_SCHEMA = vol.Schema( + { + vol.Required(CONF_ORIGIN): TextSelector(), + vol.Required(CONF_DESTINATION): TextSelector(), + vol.Required(CONF_REGION): SelectSelector( + SelectSelectorConfig( + options=REGIONS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_REGION, + sort=True, + ) + ), + vol.Optional(CONF_REALTIME, default=False): BooleanSelector(), + vol.Optional(CONF_VEHICLE_TYPE, default=DEFAULT_VEHICLE_TYPE): SelectSelector( + SelectSelectorConfig( + options=VEHICLE_TYPES, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_VEHICLE_TYPE, + sort=True, + ) + ), + vol.Optional(CONF_UNITS, default=METRIC_UNITS): SelectSelector( + SelectSelectorConfig( + options=UNITS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_UNITS, + sort=True, + ) + ), + vol.Optional(CONF_AVOID_TOLL_ROADS, default=False): BooleanSelector(), + vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS, default=False): BooleanSelector(), + vol.Optional(CONF_AVOID_FERRIES, default=False): BooleanSelector(), + } +) + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Load the saved entities.""" if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}): hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse: + httpx_client = get_async_client(hass) + client = WazeRouteCalculator( + region=service.data[CONF_REGION].upper(), client=httpx_client + ) + response = await async_get_travel_times( + client=client, + origin=service.data[CONF_ORIGIN], + destination=service.data[CONF_DESTINATION], + vehicle_type=service.data[CONF_VEHICLE_TYPE], + avoid_toll_roads=service.data[CONF_AVOID_TOLL_ROADS], + avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS], + avoid_ferries=service.data[CONF_AVOID_FERRIES], + realtime=service.data[CONF_REALTIME], + ) + return {"routes": [vars(route) for route in response]} if response else None + + hass.services.async_register( + DOMAIN, + SERVICE_GET_TRAVEL_TIMES, + async_get_travel_times_service, + SERVICE_GET_TRAVEL_TIMES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) return True +async def async_get_travel_times( + client: WazeRouteCalculator, + origin: str, + destination: str, + vehicle_type: str, + avoid_toll_roads: bool, + avoid_subscription_roads: bool, + avoid_ferries: bool, + realtime: bool, + incl_filter: str | None = None, + excl_filter: str | None = None, +) -> list[CalcRoutesResponse] | None: + """Get all available routes.""" + + _LOGGER.debug( + "Getting update for origin: %s destination: %s", + origin, + destination, + ) + routes = [] + vehicle_type = "" if vehicle_type.upper() == "CAR" else vehicle_type.upper() + try: + routes = await client.calc_routes( + origin, + destination, + vehicle_type=vehicle_type, + avoid_toll_roads=avoid_toll_roads, + avoid_subscription_roads=avoid_subscription_roads, + avoid_ferries=avoid_ferries, + real_time=realtime, + alternatives=3, + ) + + if incl_filter not in {None, ""}: + routes = [ + r + for r in routes + if any( + incl_filter.lower() == street_name.lower() # type: ignore[union-attr] + for street_name in r.street_names + ) + ] + + if excl_filter not in {None, ""}: + routes = [ + r + for r in routes + if not any( + excl_filter.lower() == street_name.lower() # type: ignore[union-attr] + for street_name in r.street_names + ) + ] + + if len(routes) < 1: + _LOGGER.warning("No routes found") + return None + except WRCError as exp: + _LOGGER.warning("Error on retrieving data: %s", exp) + return None + + else: + return routes + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index d0f63b97b78..12dc8336f92 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -51,16 +51,18 @@ OPTIONS_SCHEMA = vol.Schema( vol.Optional(CONF_REALTIME): BooleanSelector(), vol.Required(CONF_VEHICLE_TYPE): SelectSelector( SelectSelectorConfig( - options=sorted(VEHICLE_TYPES), + options=VEHICLE_TYPES, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_VEHICLE_TYPE, + sort=True, ) ), vol.Required(CONF_UNITS): SelectSelector( SelectSelectorConfig( - options=sorted(UNITS), + options=UNITS, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_UNITS, + sort=True, ) ), vol.Optional(CONF_AVOID_TOLL_ROADS): BooleanSelector(), @@ -76,9 +78,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_DESTINATION): TextSelector(), vol.Required(CONF_REGION): SelectSelector( SelectSelectorConfig( - options=sorted(REGIONS), + options=REGIONS, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_REGION, + sort=True, ) ), } diff --git a/homeassistant/components/waze_travel_time/icons.json b/homeassistant/components/waze_travel_time/icons.json index 54d3183363e..fa95e8fdd8a 100644 --- a/homeassistant/components/waze_travel_time/icons.json +++ b/homeassistant/components/waze_travel_time/icons.json @@ -5,5 +5,8 @@ "default": "mdi:car" } } + }, + "services": { + "get_travel_times": "mdi:timelapse" } } diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 518de269bc5..7663b4a102e 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -8,7 +8,7 @@ import logging from typing import Any import httpx -from pywaze.route_calculator import WazeRouteCalculator, WRCError +from pywaze.route_calculator import WazeRouteCalculator from homeassistant.components.sensor import ( SensorDeviceClass, @@ -30,6 +30,7 @@ from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates from homeassistant.util.unit_conversion import DistanceConverter +from . import async_get_travel_times from .const import ( CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, @@ -186,65 +187,38 @@ class WazeTravelTimeData: excl_filter = self.config_entry.options.get(CONF_EXCL_FILTER) realtime = self.config_entry.options[CONF_REALTIME] vehicle_type = self.config_entry.options[CONF_VEHICLE_TYPE] - vehicle_type = "" if vehicle_type.upper() == "CAR" else vehicle_type.upper() avoid_toll_roads = self.config_entry.options[CONF_AVOID_TOLL_ROADS] avoid_subscription_roads = self.config_entry.options[ CONF_AVOID_SUBSCRIPTION_ROADS ] avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] - units = self.config_entry.options[CONF_UNITS] - - routes = {} - try: - routes = await self.client.calc_routes( - self.origin, - self.destination, - vehicle_type=vehicle_type, - avoid_toll_roads=avoid_toll_roads, - avoid_subscription_roads=avoid_subscription_roads, - avoid_ferries=avoid_ferries, - real_time=realtime, - alternatives=3, - ) - - if incl_filter not in {None, ""}: - routes = [ - r - for r in routes - if any( - incl_filter.lower() == street_name.lower() - for street_name in r.street_names - ) - ] - - if excl_filter not in {None, ""}: - routes = [ - r - for r in routes - if not any( - excl_filter.lower() == street_name.lower() - for street_name in r.street_names - ) - ] - - if len(routes) < 1: - _LOGGER.warning("No routes found") - return - + routes = await async_get_travel_times( + self.client, + self.origin, + self.destination, + vehicle_type, + avoid_toll_roads, + avoid_subscription_roads, + avoid_ferries, + realtime, + incl_filter, + excl_filter, + ) + if routes: route = routes[0] - - self.duration = route.duration - distance = route.distance - - if units == IMPERIAL_UNITS: - # Convert to miles. - self.distance = DistanceConverter.convert( - distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES - ) - else: - self.distance = distance - - self.route = route.name - except WRCError as exp: - _LOGGER.warning("Error on retrieving data: %s", exp) + else: + _LOGGER.warning("No routes found") return + + self.duration = route.duration + distance = route.distance + + if self.config_entry.options[CONF_UNITS] == IMPERIAL_UNITS: + # Convert to miles. + self.distance = DistanceConverter.convert( + distance, UnitOfLength.KILOMETERS, UnitOfLength.MILES + ) + else: + self.distance = distance + + self.route = route.name diff --git a/homeassistant/components/waze_travel_time/services.yaml b/homeassistant/components/waze_travel_time/services.yaml new file mode 100644 index 00000000000..7fba565dd47 --- /dev/null +++ b/homeassistant/components/waze_travel_time/services.yaml @@ -0,0 +1,57 @@ +get_travel_times: + fields: + origin: + required: true + example: "38.9" + selector: + text: + destination: + required: true + example: "-77.04833" + selector: + text: + region: + required: true + default: "us" + selector: + select: + translation_key: region + options: + - us + - na + - eu + - il + - au + units: + default: "metric" + selector: + select: + translation_key: units + options: + - metric + - imperial + vehicle_type: + default: "car" + selector: + select: + translation_key: vehicle_type + options: + - car + - taxi + - motorcycle + realtime: + required: false + selector: + boolean: + avoid_toll_roads: + required: false + selector: + boolean: + avoid_ferries: + required: false + selector: + boolean: + avoid_subscription_roads: + required: false + selector: + boolean: diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index e6dd3c3a22e..6b0b4184af7 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -60,5 +60,49 @@ "au": "Australia" } } + }, + "services": { + "get_travel_times": { + "name": "Get Travel Times", + "description": "Get route alternatives and travel times between two locations.", + "fields": { + "origin": { + "name": "[%key:component::waze_travel_time::config::step::user::data::origin%]", + "description": "The origin of the route." + }, + "destination": { + "name": "[%key:component::waze_travel_time::config::step::user::data::destination%]", + "description": "The destination of the route." + }, + "region": { + "name": "[%key:component::waze_travel_time::config::step::user::data::region%]", + "description": "The region. Controls which waze server is used." + }, + "units": { + "name": "[%key:component::waze_travel_time::options::step::init::data::units%]", + "description": "Which unit system to use." + }, + "vehicle_type": { + "name": "[%key:component::waze_travel_time::options::step::init::data::vehicle_type%]", + "description": "Which vehicle to use." + }, + "realtime": { + "name": "[%key:component::waze_travel_time::options::step::init::data::realtime%]", + "description": "Use real-time or statistical data." + }, + "avoid_toll_roads": { + "name": "[%key:component::waze_travel_time::options::step::init::data::avoid_toll_roads%]", + "description": "Whether to avoid toll roads." + }, + "avoid_ferries": { + "name": "[%key:component::waze_travel_time::options::step::init::data::avoid_ferries%]", + "description": "Whether to avoid ferries." + }, + "avoid_subscription_roads": { + "name": "[%key:component::waze_travel_time::options::step::init::data::avoid_subscription_roads%]", + "description": "Whether to avoid subscription roads. " + } + } + } } } diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 048e969b238..b3ce52510d2 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -37,7 +37,6 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import ABCCachedProperties, Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -48,7 +47,6 @@ from homeassistant.util.dt import utcnow from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import group as group_pre_import # noqa: F401 from .const import ( ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, @@ -123,8 +121,6 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 -LEGACY_SERVICE_GET_FORECAST: Final = "get_forecast" -"""Deprecated: please use SERVICE_GET_FORECASTS.""" SERVICE_GET_FORECASTS: Final = "get_forecasts" _ObservationUpdateCoordinatorT = TypeVar( @@ -204,17 +200,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) - component.async_register_legacy_entity_service( - LEGACY_SERVICE_GET_FORECAST, - {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, - async_get_forecast_service, - required_features=[ - WeatherEntityFeature.FORECAST_DAILY, - WeatherEntityFeature.FORECAST_HOURLY, - WeatherEntityFeature.FORECAST_TWICE_DAILY, - ], - supports_response=SupportsResponse.ONLY, - ) component.async_register_entity_service( SERVICE_GET_FORECASTS, {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, @@ -1012,32 +997,6 @@ def raise_unsupported_forecast(entity_id: str, forecast_type: str) -> None: ) -async def async_get_forecast_service( - weather: WeatherEntity, service_call: ServiceCall -) -> ServiceResponse: - """Get weather forecast. - - Deprecated: please use async_get_forecasts_service. - """ - _LOGGER.warning( - "Detected use of service 'weather.get_forecast'. " - "This is deprecated and will stop working in Home Assistant 2024.6. " - "Use 'weather.get_forecasts' instead which supports multiple entities", - ) - ir.async_create_issue( - weather.hass, - DOMAIN, - "deprecated_service_weather_get_forecast", - breaks_in_ha_version="2024.6.0", - is_fixable=True, - is_persistent=False, - issue_domain=weather.platform.platform_name, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_service_weather_get_forecast", - ) - return await async_get_forecasts_service(weather, service_call) - - async def async_get_forecasts_service( weather: WeatherEntity, service_call: ServiceCall ) -> ServiceResponse: @@ -1059,8 +1018,7 @@ async def async_get_forecasts_service( if native_forecast_list is None: converted_forecast_list = [] else: - # pylint: disable-next=protected-access - converted_forecast_list = weather._convert_forecast(native_forecast_list) + converted_forecast_list = weather._convert_forecast(native_forecast_list) # noqa: SLF001 return { "forecast": converted_forecast_list, } diff --git a/homeassistant/components/weather/group.py b/homeassistant/components/weather/group.py deleted file mode 100644 index 2bc4a122fdc..00000000000 --- a/homeassistant/components/weather/group.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Describe group states.""" - -from typing import TYPE_CHECKING - -from homeassistant.core import HomeAssistant, callback - -if TYPE_CHECKING: - from homeassistant.components.group import GroupIntegrationRegistry - -from .const import DOMAIN - - -@callback -def async_describe_on_off_states( - hass: HomeAssistant, registry: "GroupIntegrationRegistry" -) -> None: - """Describe group on off states.""" - registry.exclude_domain(DOMAIN) diff --git a/homeassistant/components/weather/intent.py b/homeassistant/components/weather/intent.py index c216fcda17d..e00a386b619 100644 --- a/homeassistant/components/weather/intent.py +++ b/homeassistant/components/weather/intent.py @@ -6,10 +6,8 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, State from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, WeatherEntity +from . import DOMAIN INTENT_GET_WEATHER = "HassGetWeather" @@ -23,50 +21,30 @@ class GetWeatherIntent(intent.IntentHandler): """Handle GetWeather intents.""" intent_type = INTENT_GET_WEATHER - slot_schema = {vol.Optional("name"): cv.string} + description = "Gets the current weather" + slot_schema = {vol.Optional("name"): intent.non_empty_string} + platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - weather: WeatherEntity | None = None weather_state: State | None = None - component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] - entities = list(component.entities) - + name: str | None = None if "name" in slots: - # Named weather entity - weather_name = slots["name"]["value"] + name = slots["name"]["value"] - # Find matching weather entity - matching_states = intent.async_match_states( - hass, name=weather_name, domains=[DOMAIN] + match_constraints = intent.MatchTargetsConstraints( + name=name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints ) - for maybe_weather_state in matching_states: - weather = component.get_entity(maybe_weather_state.entity_id) - if weather is not None: - weather_state = maybe_weather_state - break - if weather is None: - raise intent.IntentHandleError( - f"No weather entity named {weather_name}" - ) - elif entities: - # First weather entity - weather = entities[0] - weather_name = weather.name - weather_state = hass.states.get(weather.entity_id) - - if weather is None: - raise intent.IntentHandleError("No weather entity") - - if weather_state is None: - raise intent.IntentHandleError(f"No state for weather: {weather.name}") - - assert weather is not None - assert weather_state is not None + weather_state = match_result.states[0] # Create response response = intent_obj.create_response() @@ -75,8 +53,8 @@ class GetWeatherIntent(intent.IntentHandler): success_results=[ intent.IntentResponseTarget( type=intent.IntentResponseTargetType.ENTITY, - name=weather_name, - id=weather.entity_id, + name=weather_state.name, + id=weather_state.entity_id, ) ] ) diff --git a/homeassistant/components/weatherflow_cloud/const.py b/homeassistant/components/weatherflow_cloud/const.py index 43594863e14..24ae2f3a3cb 100644 --- a/homeassistant/components/weatherflow_cloud/const.py +++ b/homeassistant/components/weatherflow_cloud/const.py @@ -7,3 +7,25 @@ LOGGER = logging.getLogger(__package__) ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest REST Api" MANUFACTURER = "WeatherFlow" + +STATE_MAP = { + "clear-day": "sunny", + "clear-night": "clear-night", + "cloudy": "cloudy", + "foggy": "fog", + "partly-cloudy-day": "partlycloudy", + "partly-cloudy-night": "partlycloudy", + "possibly-rainy-day": "rainy", + "possibly-rainy-night": "rainy", + "possibly-sleet-day": "snowy-rainy", + "possibly-sleet-night": "snowy-rainy", + "possibly-snow-day": "snowy", + "possibly-snow-night": "snowy", + "possibly-thunderstorm-day": "lightning-rainy", + "possibly-thunderstorm-night": "lightning-rainy", + "rainy": "rainy", + "sleet": "snowy-rainy", + "snow": "snowy", + "thunderstorm": "lightning", + "windy": "windy", +} diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 361349dcbe8..93df04d833c 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", - "requirements": ["weatherflow4py==0.2.20"] + "requirements": ["weatherflow4py==0.2.21"] } diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py index 23aa6b1a031..47e2b6a28df 100644 --- a/homeassistant/components/weatherflow_cloud/weather.py +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_ATTRIBUTION, DOMAIN, MANUFACTURER +from .const import ATTR_ATTRIBUTION, DOMAIN, MANUFACTURER, STATE_MAP from .coordinator import WeatherFlowCloudDataUpdateCoordinator @@ -86,7 +86,7 @@ class WeatherFlowWeather( @property def condition(self) -> str | None: """Return current condition - required property.""" - return self.local_data.weather.current_conditions.icon.ha_icon + return STATE_MAP[self.local_data.weather.current_conditions.icon.value] @property def native_temperature(self) -> float | None: diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 0076c85e268..34e11f49978 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -87,10 +87,24 @@ def async_generate_id() -> str: @callback @bind_hass -def async_generate_url(hass: HomeAssistant, webhook_id: str) -> str: +def async_generate_url( + hass: HomeAssistant, + webhook_id: str, + allow_internal: bool = True, + allow_external: bool = True, + allow_ip: bool | None = None, + prefer_external: bool | None = True, +) -> str: """Generate the full URL for a webhook_id.""" return ( - f"{get_url(hass, prefer_external=True, allow_cloud=False)}" + f"{get_url( + hass, + allow_internal=allow_internal, + allow_external=allow_external, + allow_cloud=False, + allow_ip=allow_ip, + prefer_external=prefer_external, + )}" f"{async_generate_path(webhook_id)}" ) @@ -178,7 +192,7 @@ async def async_handle_webhook( response: Response | None = await webhook["handler"](hass, webhook_id, request) if response is None: response = Response(status=HTTPStatus.OK) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error processing webhook %s", webhook_id) return Response(status=HTTPStatus.OK) return response diff --git a/homeassistant/components/webmin/__init__.py b/homeassistant/components/webmin/__init__.py index 56f30d3b26f..3c41b44cb69 100644 --- a/homeassistant/components/webmin/__init__.py +++ b/homeassistant/components/webmin/__init__.py @@ -4,27 +4,25 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import WebminUpdateCoordinator PLATFORMS = [Platform.SENSOR] +type WebminConfigEntry = ConfigEntry[WebminUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: WebminConfigEntry) -> bool: """Set up Webmin from a config entry.""" coordinator = WebminUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() await coordinator.async_setup() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WebminConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py index 1d9c86edbac..5fa3aefb048 100644 --- a/homeassistant/components/webmin/config_flow.py +++ b/homeassistant/components/webmin/config_flow.py @@ -34,8 +34,7 @@ async def validate_user_input( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: """Validate user input.""" - # pylint: disable-next=protected-access - handler.parent_handler._async_abort_entries_match( + handler.parent_handler._async_abort_entries_match( # noqa: SLF001 {CONF_HOST: user_input[CONF_HOST]} ) instance, _ = get_instance_from_options(handler.parent_handler.hass, user_input) diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py index 28c8d54b0d2..dab5e495c1a 100644 --- a/homeassistant/components/webmin/coordinator.py +++ b/homeassistant/components/webmin/coordinator.py @@ -51,4 +51,6 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): } async def _async_update_data(self) -> dict[str, Any]: - return await self.instance.update() + data = await self.instance.update() + data["disk_fs"] = {item["dir"]: item for item in data["disk_fs"]} + return data diff --git a/homeassistant/components/webmin/diagnostics.py b/homeassistant/components/webmin/diagnostics.py index 390db73814a..fc8d6cf1798 100644 --- a/homeassistant/components/webmin/diagnostics.py +++ b/homeassistant/components/webmin/diagnostics.py @@ -3,12 +3,10 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import WebminUpdateCoordinator +from . import WebminConfigEntry TO_REDACT = { CONF_HOST, @@ -27,10 +25,9 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WebminConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: WebminUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data( - {"entry": entry.as_dict(), "data": coordinator.data}, TO_REDACT + {"entry": entry.as_dict(), "data": entry.runtime_data.data}, TO_REDACT ) diff --git a/homeassistant/components/webmin/helpers.py b/homeassistant/components/webmin/helpers.py index 6d290183e76..57cf54642ac 100644 --- a/homeassistant/components/webmin/helpers.py +++ b/homeassistant/components/webmin/helpers.py @@ -43,5 +43,7 @@ def get_instance_from_options( def get_sorted_mac_addresses(data: dict[str, Any]) -> list[str]: """Return a sorted list of mac addresses.""" return sorted( - [iface["ether"] for iface in data["active_interfaces"] if "ether" in iface] + iface["ether"] + for iface in data["active_interfaces"] + if "ether" in iface and iface["name"].startswith(("en", "eth", "wl")) ) diff --git a/homeassistant/components/webmin/icons.json b/homeassistant/components/webmin/icons.json index 2421974024a..67a9ef45f0c 100644 --- a/homeassistant/components/webmin/icons.json +++ b/homeassistant/components/webmin/icons.json @@ -21,6 +21,39 @@ }, "swap_free": { "default": "mdi:memory" + }, + "disk_total": { + "default": "mdi:harddisk" + }, + "disk_used": { + "default": "mdi:harddisk" + }, + "disk_free": { + "default": "mdi:harddisk" + }, + "disk_fs_total": { + "default": "mdi:harddisk" + }, + "disk_fs_used": { + "default": "mdi:harddisk" + }, + "disk_fs_free": { + "default": "mdi:harddisk" + }, + "disk_fs_itotal": { + "default": "mdi:harddisk" + }, + "disk_fs_iused": { + "default": "mdi:harddisk" + }, + "disk_fs_ifree": { + "default": "mdi:harddisk" + }, + "disk_fs_used_percent": { + "default": "mdi:harddisk" + }, + "disk_fs_iused_percent": { + "default": "mdi:harddisk" } } } diff --git a/homeassistant/components/webmin/sensor.py b/homeassistant/components/webmin/sensor.py index 90d3fd71532..cf1a9845c02 100644 --- a/homeassistant/components/webmin/sensor.py +++ b/homeassistant/components/webmin/sensor.py @@ -2,21 +2,30 @@ from __future__ import annotations +from dataclasses import dataclass + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfInformation +from homeassistant.const import PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from . import WebminConfigEntry from .coordinator import WebminUpdateCoordinator + +@dataclass(frozen=True, kw_only=True) +class WebminFSSensorDescription(SensorEntityDescription): + """Represents a filesystem sensor description.""" + + mountpoint: str + + SENSOR_TYPES: list[SensorEntityDescription] = [ SensorEntityDescription( key="load_1m", @@ -76,19 +85,140 @@ SENSOR_TYPES: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + SensorEntityDescription( + key="disk_total", + translation_key="disk_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="disk_free", + translation_key="disk_free", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="disk_used", + translation_key="disk_used", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ] +def generate_filesystem_sensor_description( + mountpoint: str, +) -> list[WebminFSSensorDescription]: + """Return all sensor descriptions for a mount point.""" + + return [ + WebminFSSensorDescription( + mountpoint=mountpoint, + key="total", + translation_key="disk_fs_total", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + WebminFSSensorDescription( + mountpoint=mountpoint, + key="used", + translation_key="disk_fs_used", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + WebminFSSensorDescription( + mountpoint=mountpoint, + key="free", + translation_key="disk_fs_free", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + WebminFSSensorDescription( + mountpoint=mountpoint, + key="itotal", + translation_key="disk_fs_itotal", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + WebminFSSensorDescription( + mountpoint=mountpoint, + key="iused", + translation_key="disk_fs_iused", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + WebminFSSensorDescription( + mountpoint=mountpoint, + key="ifree", + translation_key="disk_fs_ifree", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + WebminFSSensorDescription( + mountpoint=mountpoint, + key="used_percent", + translation_key="disk_fs_used_percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + WebminFSSensorDescription( + mountpoint=mountpoint, + key="iused_percent", + translation_key="disk_fs_iused_percent", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ] + + async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: WebminConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Webmin sensors based on a config entry.""" - coordinator: WebminUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + coordinator = entry.runtime_data + + entities: list[WebminSensor | WebminFSSensor] = [ WebminSensor(coordinator, description) for description in SENSOR_TYPES if description.key in coordinator.data - ) + ] + + for fs, values in coordinator.data["disk_fs"].items(): + entities += [ + WebminFSSensor(coordinator, description) + for description in generate_filesystem_sensor_description(fs) + if description.key in values + ] + + async_add_entities(entities) class WebminSensor(CoordinatorEntity[WebminUpdateCoordinator], SensorEntity): @@ -111,3 +241,32 @@ class WebminSensor(CoordinatorEntity[WebminUpdateCoordinator], SensorEntity): def native_value(self) -> int | float: """Return the state of the sensor.""" return self.coordinator.data[self.entity_description.key] + + +class WebminFSSensor(CoordinatorEntity[WebminUpdateCoordinator], SensorEntity): + """Represents a Webmin filesystem sensor.""" + + entity_description: WebminFSSensorDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: WebminUpdateCoordinator, + description: WebminFSSensorDescription, + ) -> None: + """Initialize a Webmin filesystem sensor.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_translation_placeholders = {"mountpoint": description.mountpoint} + self._attr_unique_id = ( + f"{coordinator.mac_address}_{description.mountpoint}_{description.key}" + ) + + @property + def native_value(self) -> int | float: + """Return the state of the sensor.""" + return self.coordinator.data["disk_fs"][self.entity_description.mountpoint][ + self.entity_description.key + ] diff --git a/homeassistant/components/webmin/strings.json b/homeassistant/components/webmin/strings.json index 9963298d230..9a6d6d4fbe4 100644 --- a/homeassistant/components/webmin/strings.json +++ b/homeassistant/components/webmin/strings.json @@ -48,6 +48,39 @@ }, "swap_free": { "name": "Swap free" + }, + "disk_total": { + "name": "Disks total space" + }, + "disk_used": { + "name": "Disks used space" + }, + "disk_free": { + "name": "Disks free space" + }, + "disk_fs_total": { + "name": "Disk total space {mountpoint}" + }, + "disk_fs_used": { + "name": "Disk used space {mountpoint}" + }, + "disk_fs_free": { + "name": "Disk free space {mountpoint}" + }, + "disk_fs_itotal": { + "name": "Disk total inodes {mountpoint}" + }, + "disk_fs_iused": { + "name": "Disk used inodes {mountpoint}" + }, + "disk_fs_ifree": { + "name": "Disk free inodes {mountpoint}" + }, + "disk_fs_used_percent": { + "name": "Disk usage {mountpoint}" + }, + "disk_fs_iused_percent": { + "name": "Disk inode usage {mountpoint}" } } } diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 34ff8aafca2..6aef47515db 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -9,7 +9,7 @@ from datetime import timedelta from functools import wraps from http import HTTPStatus import logging -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, cast from aiowebostv import WebOsClient, WebOsTvPairError @@ -79,11 +79,7 @@ async def async_setup_entry( async_add_entities([LgWebOSMediaPlayerEntity(entry, client)]) -_T = TypeVar("_T", bound="LgWebOSMediaPlayerEntity") -_P = ParamSpec("_P") - - -def cmd( +def cmd[_T: LgWebOSMediaPlayerEntity, **_P]( 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/__init__.py b/homeassistant/components/websocket_api/__init__.py index 291b652ac09..d8427bff10e 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -16,6 +16,7 @@ from .connection import ActiveConnection, current_connection # noqa: F401 from .const import ( # noqa: F401 ERR_HOME_ASSISTANT_ERROR, ERR_INVALID_FORMAT, + ERR_NOT_ALLOWED, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, ERR_SERVICE_VALIDATION_ERROR, @@ -24,6 +25,7 @@ from .const import ( # noqa: F401 ERR_UNAUTHORIZED, ERR_UNKNOWN_COMMAND, ERR_UNKNOWN_ERROR, + TYPE_RESULT, AsyncWebSocketCommandHandler, WebSocketCommandHandler, ) @@ -56,11 +58,10 @@ def async_register_command( schema: vol.Schema | None = None, ) -> None: """Register a websocket command.""" - # pylint: disable=protected-access if handler is None: handler = cast(const.WebSocketCommandHandler, command_or_handler) - command = handler._ws_command # type: ignore[attr-defined] - schema = handler._ws_schema # type: ignore[attr-defined] + command = handler._ws_command # type: ignore[attr-defined] # noqa: SLF001 + schema = handler._ws_schema # type: ignore[attr-defined] # noqa: SLF001 else: command = command_or_handler if (handlers := hass.data.get(DOMAIN)) is None: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 0f52685ca2d..f66930c8d00 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -46,10 +46,10 @@ from homeassistant.helpers.json import ( ExtendedJSONEncoder, find_paths_unserializable_data, json_bytes, + json_fragment, ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import ( - Integration, IntegrationNotFound, async_get_integration, async_get_integration_descriptions, @@ -103,7 +103,7 @@ def pong_message(iden: int) -> dict[str, Any]: @callback def _forward_events_check_permissions( - send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[bytes | str | dict[str, Any]], None], user: User, message_id_as_bytes: bytes, event: Event, @@ -123,7 +123,7 @@ def _forward_events_check_permissions( @callback def _forward_events_unconditional( - send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[bytes | str | dict[str, Any]], None], message_id_as_bytes: bytes, event: Event, ) -> None: @@ -300,7 +300,7 @@ async def handle_call_service( translation_key=err.translation_key, translation_placeholders=err.translation_placeholders, ) - except Exception as err: # pylint: disable=broad-except + except Exception as err: connection.logger.exception("Unexpected exception") connection.send_error(msg["id"], const.ERR_UNKNOWN_ERROR, str(err)) @@ -365,7 +365,7 @@ def _send_handle_get_states_response( @callback def _forward_entity_changes( - send_message: Callable[[str | bytes | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[str | bytes | dict[str, Any]], None], entity_ids: set[str], user: User, message_id_as_bytes: bytes, @@ -505,19 +505,15 @@ async def handle_manifest_list( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" - wanted_integrations = msg.get("integrations") - if wanted_integrations is None: - wanted_integrations = async_get_loaded_integrations(hass) - - ints_or_excs = await async_get_integrations(hass, wanted_integrations) - integrations: list[Integration] = [] + ints_or_excs = await async_get_integrations( + hass, msg.get("integrations") or async_get_loaded_integrations(hass) + ) + manifest_json_fragments: list[json_fragment] = [] for int_or_exc in ints_or_excs.values(): if isinstance(int_or_exc, Exception): raise int_or_exc - integrations.append(int_or_exc) - connection.send_result( - msg["id"], [integration.manifest for integration in integrations] - ) + manifest_json_fragments.append(int_or_exc.manifest_json_fragment) + connection.send_result(msg["id"], manifest_json_fragments) @decorators.websocket_command( @@ -530,9 +526,10 @@ async def handle_manifest_get( """Handle integrations command.""" try: integration = await async_get_integration(hass, msg["integration"]) - connection.send_result(msg["id"], integration.manifest) except IntegrationNotFound: connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Integration not found") + else: + connection.send_result(msg["id"], integration.manifest_json_fragment) @callback @@ -865,7 +862,10 @@ async def handle_validate_config( try: await validator(hass, schema(msg[key])) - except vol.Invalid as err: + except ( + vol.Invalid, + HomeAssistantError, + ) as err: result[key] = {"valid": False, "error": str(err)} else: result[key] = {"valid": True, "error": None} diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 3c0743601dd..ef70df4a123 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -26,8 +26,8 @@ current_connection = ContextVar["ActiveConnection | None"]( "current_connection", default=None ) -MessageHandler = Callable[[HomeAssistant, "ActiveConnection", dict[str, Any]], None] -BinaryHandler = Callable[[HomeAssistant, "ActiveConnection", bytes], None] +type MessageHandler = Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], None] +type BinaryHandler = Callable[[HomeAssistant, ActiveConnection, bytes], None] class ActiveConnection: @@ -171,7 +171,7 @@ class ActiveConnection: try: handler(self.hass, self, payload) - except Exception: # pylint: disable=broad-except + except Exception: self.logger.exception("Error handling binary message") self.binary_handlers[index] = None @@ -227,7 +227,7 @@ class ActiveConnection: handler(self.hass, self, msg) else: handler(self.hass, self, schema(msg)) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 self.async_handle_exception(msg, err) self.last_id = cur_id @@ -238,7 +238,7 @@ class ActiveConnection: for unsub in self.subscriptions.values(): try: unsub() - except Exception: # pylint: disable=broad-except + except Exception: # If one fails, make sure we still try the rest self.logger.exception( "Error unsubscribing from subscription: %s", unsub diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 25d3ff8dcb3..a0d031834ae 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -11,11 +11,11 @@ if TYPE_CHECKING: from .connection import ActiveConnection -WebSocketCommandHandler = Callable[ - [HomeAssistant, "ActiveConnection", dict[str, Any]], None +type WebSocketCommandHandler = Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], None ] -AsyncWebSocketCommandHandler = Callable[ - [HomeAssistant, "ActiveConnection", dict[str, Any]], Awaitable[None] +type AsyncWebSocketCommandHandler = Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None] ] DOMAIN: Final = "websocket_api" @@ -25,8 +25,15 @@ PENDING_MSG_PEAK_TIME: Final = 5 # Maximum number of messages that can be pending at any given time. # This is effectively the upper limit of the number of entities # that can fire state changes within ~1 second. +# Ideally we would use homeassistant.const.MAX_EXPECTED_ENTITY_IDS +# but since chrome will lock up with too many messages we need to +# limit it to a lower number. MAX_PENDING_MSG: Final = 4096 +# Maximum number of messages that are pending before we force +# resolve the ready future. +PENDING_MSG_MAX_FORCE_READY: Final = 256 + ERR_ID_REUSE: Final = "id_reuse" ERR_INVALID_FORMAT: Final = "invalid_format" ERR_NOT_ALLOWED: Final = "not_allowed" diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 0ed8be30139..5131d02b4d3 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -25,7 +25,7 @@ async def _handle_async_response( """Create a response and handle exception.""" try: await func(hass, connection, msg) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 connection.async_handle_exception(msg, err) @@ -100,27 +100,27 @@ def ws_require_user( if only_owner and not connection.user.is_owner: output_error("only_owner", "Only allowed as owner") - return + return None if only_system_user and not connection.user.system_generated: output_error("only_system_user", "Only allowed as system user") - return + return None if not allow_system_user and connection.user.system_generated: output_error("not_system_user", "Not allowed as system user") - return + return None if only_active_user and not connection.user.is_active: output_error("only_active_user", "Only allowed as active user") - return + return None if only_inactive_user and connection.user.is_active: output_error("only_inactive_user", "Not allowed as active user") - return + return None if only_supervisor and connection.user.name != HASSIO_USER_NAME: output_error("only_supervisor", "Only allowed as Supervisor") - return + return None return func(hass, connection, msg) @@ -144,11 +144,10 @@ def websocket_command( def decorate(func: const.WebSocketCommandHandler) -> const.WebSocketCommandHandler: """Decorate ws command function.""" - # pylint: disable=protected-access if is_dict and len(schema) == 1: # type only empty schema - func._ws_schema = False # type: ignore[attr-defined] + func._ws_schema = False # type: ignore[attr-defined] # noqa: SLF001 elif is_dict: - func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] + func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] # noqa: SLF001 else: if TYPE_CHECKING: assert not isinstance(schema, dict) @@ -158,8 +157,8 @@ def websocket_command( ), *schema.validators[1:], ) - func._ws_schema = extended_schema # type: ignore[attr-defined] - func._ws_command = command # type: ignore[attr-defined] + func._ws_schema = extended_schema # type: ignore[attr-defined] # noqa: SLF001 + func._ws_command = command # type: ignore[attr-defined] # noqa: SLF001 return func return decorate diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index fc75b46ddbd..c65c4c65988 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -24,6 +24,7 @@ from .auth import AUTH_REQUIRED_MESSAGE, AuthPhase from .const import ( DATA_CONNECTIONS, MAX_PENDING_MSG, + PENDING_MSG_MAX_FORCE_READY, PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, SIGNAL_WEBSOCKET_CONNECTED, @@ -67,6 +68,7 @@ class WebSocketHandler: __slots__ = ( "_hass", + "_loop", "_request", "_wsock", "_handle_task", @@ -78,11 +80,13 @@ class WebSocketHandler: "_connection", "_message_queue", "_ready_future", + "_release_ready_queue_size", ) def __init__(self, hass: HomeAssistant, request: web.Request) -> None: """Initialize an active connection.""" self._hass = hass + self._loop = hass.loop self._request: web.Request = request self._wsock = web.WebSocketResponse(heartbeat=55) self._handle_task: asyncio.Task | None = None @@ -97,8 +101,9 @@ class WebSocketHandler: # to where messages are queued. This allows the implementation # to use a deque and an asyncio.Future to avoid the overhead of # an asyncio.Queue. - self._message_queue: deque[bytes | None] = deque() - self._ready_future: asyncio.Future[None] | None = None + self._message_queue: deque[bytes] = deque() + self._ready_future: asyncio.Future[int] | None = None + self._release_ready_queue_size: int = 0 def __repr__(self) -> str: """Return the representation.""" @@ -126,45 +131,35 @@ class WebSocketHandler: message_queue = self._message_queue logger = self._logger wsock = self._wsock - loop = self._hass.loop + loop = self._loop + is_debug_log_enabled = partial(logger.isEnabledFor, logging.DEBUG) debug = logger.debug - is_enabled_for = logger.isEnabledFor - logging_debug = logging.DEBUG + can_coalesce = self._connection and self._connection.can_coalesce + ready_message_count = len(message_queue) # Exceptions if Socket disconnected or cancelled by connection handler try: while not wsock.closed: - if (messages_remaining := len(message_queue)) == 0: + if not message_queue: self._ready_future = loop.create_future() - await self._ready_future - messages_remaining = len(message_queue) + ready_message_count = await self._ready_future - # A None message is used to signal the end of the connection - if (message := message_queue.popleft()) is None: + if self._closing: return - debug_enabled = is_enabled_for(logging_debug) - messages_remaining -= 1 + if not can_coalesce: + # coalesce may be enabled later in the connection + can_coalesce = self._connection and self._connection.can_coalesce - if ( - not messages_remaining - or not (connection := self._connection) - or not connection.can_coalesce - ): - if debug_enabled: + if not can_coalesce or ready_message_count == 1: + message = message_queue.popleft() + if is_debug_log_enabled(): debug("%s: Sending %s", self.description, message) await send_bytes_text(message) continue - messages: list[bytes] = [message] - while messages_remaining: - # A None message is used to signal the end of the connection - if (message := message_queue.popleft()) is None: - return - messages.append(message) - messages_remaining -= 1 - - coalesced_messages = b"".join((b"[", b",".join(messages), b"]")) - if debug_enabled: + coalesced_messages = b"".join((b"[", b",".join(message_queue), b"]")) + message_queue.clear() + if is_debug_log_enabled(): debug("%s: Sending %s", self.description, coalesced_messages) await send_bytes_text(coalesced_messages) except asyncio.CancelledError: @@ -197,14 +192,15 @@ class WebSocketHandler: # max pending messages. return - if isinstance(message, dict): - message = message_to_json_bytes(message) - elif isinstance(message, str): - message = message.encode("utf-8") + if type(message) is not bytes: # noqa: E721 + if isinstance(message, dict): + message = message_to_json_bytes(message) + elif isinstance(message, str): + message = message.encode("utf-8") message_queue = self._message_queue - queue_size_before_add = len(message_queue) - if queue_size_before_add >= MAX_PENDING_MSG: + message_queue.append(message) + if (queue_size_after_add := len(message_queue)) >= MAX_PENDING_MSG: self._logger.error( ( "%s: Client unable to keep up with pending messages. Reached %s pending" @@ -218,14 +214,14 @@ class WebSocketHandler: self._cancel() return - message_queue.append(message) - ready_future = self._ready_future - if ready_future and not ready_future.done(): - ready_future.set_result(None) + if self._release_ready_queue_size == 0: + # Try to coalesce more messages to reduce the number of writes + self._release_ready_queue_size = queue_size_after_add + self._loop.call_soon(self._release_ready_future_or_reschedule) peak_checker_active = self._peak_checker_unsub is not None - if queue_size_before_add <= PENDING_MSG_PEAK: + if queue_size_after_add <= PENDING_MSG_PEAK: if peak_checker_active: self._cancel_peak_checker() return @@ -235,6 +231,32 @@ class WebSocketHandler: self._hass, PENDING_MSG_PEAK_TIME, self._check_write_peak ) + @callback + def _release_ready_future_or_reschedule(self) -> None: + """Release the ready future or reschedule. + + We will release the ready future if the queue did not grow since the + last time we tried to release the ready future. + + If we reach PENDING_MSG_MAX_FORCE_READY, we will release the ready future + immediately so avoid the coalesced messages from growing too large. + """ + if not (ready_future := self._ready_future) or not ( + queue_size := len(self._message_queue) + ): + self._release_ready_queue_size = 0 + return + # If we are below the max pending to force ready, and there are new messages + # in the queue since the last time we tried to release the ready future, we + # try again later so we can coalesce more messages. + if queue_size > self._release_ready_queue_size < PENDING_MSG_MAX_FORCE_READY: + self._release_ready_queue_size = queue_size + self._loop.call_soon(self._release_ready_future_or_reschedule) + return + self._release_ready_queue_size = 0 + if not ready_future.done(): + ready_future.set_result(queue_size) + @callback def _check_write_peak(self, _utc_time: dt.datetime) -> None: """Check that we are no longer above the write peak.""" @@ -295,7 +317,7 @@ class WebSocketHandler: EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop ) - writer = wsock._writer # pylint: disable=protected-access + writer = wsock._writer # noqa: SLF001 if TYPE_CHECKING: assert writer is not None @@ -378,7 +400,7 @@ class WebSocketHandler: # added a way to set the limit, but there is no way to actually # reach the code to set the limit, so we have to set it directly. # - writer._limit = 2**20 # pylint: disable=protected-access + writer._limit = 2**20 # noqa: SLF001 async_handle_str = connection.async_handle async_handle_binary = connection.async_handle_binary @@ -426,7 +448,7 @@ class WebSocketHandler: except Disconnect as ex: debug("%s: Connection closed by client: %s", self.description, ex) - except Exception: # pylint: disable=broad-except + except Exception: self._logger.exception( "%s: Unexpected error inside websocket API", self.description ) @@ -440,10 +462,8 @@ class WebSocketHandler: connection.async_handle_close() self._closing = True - - self._message_queue.append(None) if self._ready_future and not self._ready_future.done(): - self._ready_future.set_result(None) + self._ready_future.set_result(len(self._message_queue)) # If the writer gets canceled we still need to close the websocket # so we have another finally block to make sure we close the websocket diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 98db92dfef7..238f8be0c3b 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -15,7 +15,7 @@ from homeassistant.const import ( COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE, ) -from homeassistant.core import Event, EventStateChangedData, State +from homeassistant.core import CompressedState, Event, EventStateChangedData from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import ( JSON_DUMP, @@ -177,7 +177,14 @@ def _partial_cached_state_diff_message(event: Event[EventStateChangedData]) -> b ) -def _state_diff_event(event: Event[EventStateChangedData]) -> dict: +def _state_diff_event( + event: Event[EventStateChangedData], +) -> dict[ + str, + list[str] + | dict[str, CompressedState] + | dict[str, dict[str, dict[str, str | list[str]]]], +]: """Convert a state_changed event to the minimal version. State update example @@ -188,21 +195,10 @@ def _state_diff_event(event: Event[EventStateChangedData]) -> dict: "r": [entity_id,…] } """ - if (event_new_state := event.data["new_state"]) is None: + if (new_state := event.data["new_state"]) is None: return {ENTITY_EVENT_REMOVE: [event.data["entity_id"]]} - if (event_old_state := event.data["old_state"]) is None: - return { - ENTITY_EVENT_ADD: { - event_new_state.entity_id: event_new_state.as_compressed_state - } - } - return _state_diff(event_old_state, event_new_state) - - -def _state_diff( - old_state: State, new_state: State -) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: - """Create a diff dict that can be used to overlay changes.""" + if (old_state := event.data["old_state"]) is None: + return {ENTITY_EVENT_ADD: {new_state.entity_id: new_state.as_compressed_state}} additions: dict[str, Any] = {} diff: dict[str, dict[str, Any]] = {STATE_DIFF_ADDITIONS: additions} new_state_context = new_state.context diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 7d068cbd5bf..3ef7ac92f98 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -20,8 +20,8 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN +from .coordinator import DeviceCoordinator, async_register_device from .models import WemoConfigEntryData, WemoData, async_wemo_data -from .wemo_device import DeviceCoordinator, async_register_device # Max number of devices to initialize at once. This limit is in place to # avoid tying up too many executor threads with WeMo device setup. @@ -44,8 +44,8 @@ WEMO_MODEL_DISPATCH = { _LOGGER = logging.getLogger(__name__) -DispatchCallback = Callable[[DeviceCoordinator], Coroutine[Any, Any, None]] -HostPortTuple = tuple[str, int | None] +type DispatchCallback = Callable[[DeviceCoordinator], Coroutine[Any, Any, None]] +type HostPortTuple = tuple[str, int | None] def coerce_host_port(value: str) -> HostPortTuple: @@ -144,6 +144,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: dispatcher = wemo_data.config_entry_data.dispatcher if unload_ok := await dispatcher.async_unload_platforms(hass): + for coordinator in list( + wemo_data.config_entry_data.device_coordinators.values() + ): + await coordinator.async_shutdown() assert not wemo_data.config_entry_data.device_coordinators wemo_data.config_entry_data = None # type: ignore[assignment] return unload_ok diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index 396a555e4f4..f2bcb04d96f 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -8,8 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import async_wemo_dispatcher_connect +from .coordinator import DeviceCoordinator from .entity import WemoBinaryStateEntity, WemoEntity -from .wemo_device import DeviceCoordinator async def async_setup_entry( diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py index 97a9eb34057..10a9bf5604b 100644 --- a/homeassistant/components/wemo/config_flow.py +++ b/homeassistant/components/wemo/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler from .const import DOMAIN -from .wemo_device import Options, OptionsValidationError +from .coordinator import Options, OptionsValidationError async def _async_has_devices(hass: HomeAssistant) -> bool: diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/coordinator.py similarity index 96% rename from homeassistant/components/wemo/wemo_device.py rename to homeassistant/components/wemo/coordinator.py index 7326e0b42f5..a186b666470 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/coordinator.py @@ -24,11 +24,8 @@ from homeassistant.const import ( CONF_UNIQUE_ID, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import ( - CONNECTION_UPNP, - DeviceInfo, - async_get as async_get_device_registry, -) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_UPNP, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT @@ -37,9 +34,9 @@ from .models import async_wemo_data _LOGGER = logging.getLogger(__name__) # Literal values must match options.error keys from strings.json. -ErrorStringKey = Literal["long_press_requires_subscription"] +type ErrorStringKey = Literal["long_press_requires_subscription"] # Literal values must match options.step.init.data keys from strings.json. -OptionsFieldKey = Literal["enable_subscription", "enable_long_press"] +type OptionsFieldKey = Literal["enable_subscription", "enable_long_press"] class OptionsValidationError(Exception): @@ -88,7 +85,7 @@ class Options: ) -class DeviceCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module +class DeviceCoordinator(DataUpdateCoordinator[None]): """Home Assistant wrapper for a pyWeMo device.""" options: Options | None = None @@ -142,6 +139,8 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-en async def async_shutdown(self) -> None: """Unregister push subscriptions and remove from coordinators dict.""" + if self._shutdown_requested: + return await super().async_shutdown() if TYPE_CHECKING: # mypy doesn't known that the device_id is set in async_setup. @@ -210,7 +209,7 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-en if self.last_update_success: _LOGGER.exception("Subscription callback failed") self.last_update_success = False - except Exception as err: # pylint: disable=broad-except + except Exception as err: self.last_exception = err self.last_update_success = False _LOGGER.exception("Unexpected error fetching %s data", self.name) @@ -289,7 +288,7 @@ async def async_register_device( await device.async_refresh() if not device.last_update_success and device.last_exception: raise device.last_exception - device_registry = async_get_device_registry(hass) + device_registry = dr.async_get(hass) entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, **_create_device_info(wemo) ) diff --git a/homeassistant/components/wemo/device_trigger.py b/homeassistant/components/wemo/device_trigger.py index d9cadcdd576..560c95523cd 100644 --- a/homeassistant/components/wemo/device_trigger.py +++ b/homeassistant/components/wemo/device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from .const import DOMAIN as WEMO_DOMAIN, WEMO_SUBSCRIPTION_EVENT -from .wemo_device import async_get_coordinator +from .coordinator import async_get_coordinator TRIGGER_TYPES = {EVENT_TYPE_LONG_PRESS} diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index a6fe677d357..db64aa3137e 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -2,16 +2,16 @@ from __future__ import annotations -from collections.abc import Generator import contextlib import logging from pywemo.exceptions import ActionException +from typing_extensions import Generator from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .wemo_device import DeviceCoordinator +from .coordinator import DeviceCoordinator _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,7 @@ class WemoEntity(CoordinatorEntity[DeviceCoordinator]): return self._device_info @contextlib.contextmanager - def _wemo_call_wrapper(self, message: str) -> Generator[None, None, None]: + def _wemo_call_wrapper(self, message: str) -> Generator[None]: """Wrap calls to the device that change its state. 1. Takes care of making available=False when communications with the diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 89b20bdde25..3ef8aa67a3d 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -22,8 +22,8 @@ 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 +from .coordinator import DeviceCoordinator from .entity import WemoBinaryStateEntity -from .wemo_device import DeviceCoordinator SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 00c5204eba9..26dec417631 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -23,8 +23,8 @@ import homeassistant.util.color as color_util from . import async_wemo_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN +from .coordinator import DeviceCoordinator from .entity import WemoBinaryStateEntity, WemoEntity -from .wemo_device import DeviceCoordinator # The WEMO_ constants below come from pywemo itself WEMO_OFF = 0 diff --git a/homeassistant/components/wemo/models.py b/homeassistant/components/wemo/models.py index ee12ccbf846..80213c9ba33 100644 --- a/homeassistant/components/wemo/models.py +++ b/homeassistant/components/wemo/models.py @@ -1,5 +1,7 @@ """Common data structures and helpers for accessing them.""" +from __future__ import annotations + from collections.abc import Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -12,16 +14,16 @@ from .const import DOMAIN if TYPE_CHECKING: # Avoid circular dependencies. from . import HostPortTuple, WemoDiscovery, WemoDispatcher - from .wemo_device import DeviceCoordinator + from .coordinator import DeviceCoordinator @dataclass class WemoConfigEntryData: """Config entry state data.""" - device_coordinators: dict[str, "DeviceCoordinator"] - discovery: "WemoDiscovery" - dispatcher: "WemoDispatcher" + device_coordinators: dict[str, DeviceCoordinator] + discovery: WemoDiscovery + dispatcher: WemoDispatcher @dataclass @@ -29,7 +31,7 @@ class WemoData: """Component state data.""" discovery_enabled: bool - static_config: Sequence["HostPortTuple"] + static_config: Sequence[HostPortTuple] registry: pywemo.SubscriptionRegistry # config_entry_data is set when the config entry is loaded and unset when it's # unloaded. It's a programmer error if config_entry_data is accessed when the diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 555e2591832..90e3546eaf7 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -19,8 +19,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import async_wemo_dispatcher_connect +from .coordinator import DeviceCoordinator from .entity import WemoEntity -from .wemo_device import DeviceCoordinator @dataclass(frozen=True) diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 14e3013afc1..3f7bb08b704 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import async_wemo_dispatcher_connect +from .coordinator import DeviceCoordinator from .entity import WemoBinaryStateEntity -from .wemo_device import DeviceCoordinator SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 5e1cb102d77..7c39b1fbb29 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -108,6 +108,7 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors, + description_placeholders={"name": "Whirlpool"}, ) async def async_step_user(self, user_input=None) -> ConfigFlowResult: @@ -127,7 +128,7 @@ class WhirlpoolConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NoAppliances: errors["base"] = "no_appliances" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index b1658947263..4b4673b771e 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -14,7 +14,7 @@ } }, "reauth_confirm": { - "title": "Correct your Whirlpool account credentials", + "title": "[%key:common::config_flow::title::reauth%]", "description": "For 'brand', please choose the brand of the mobile app you use, or the brand of the appliances in your account", "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 78d22bb79d9..710255153c2 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -119,7 +119,7 @@ class WirelessTagPlatform: ), tag, ) - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 _LOGGER.error( "Unable to handle tag update: %s error: %s", str(tag), diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 2b3d782a055..908548084ae 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -59,7 +59,7 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] SUBSCRIBE_DELAY = timedelta(seconds=5) UNSUBSCRIBE_DELAY = timedelta(seconds=1) CONF_CLOUDHOOK_URL = "cloudhook_url" -WithingsConfigEntry = ConfigEntry["WithingsData"] +type WithingsConfigEntry = ConfigEntry[WithingsData] @dataclass(slots=True) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index c90455de7ec..5eb4e08595a 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -68,10 +68,8 @@ class WithingsFlowHandler( ) if self.reauth_entry.unique_id == user_id: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.reauth_entry, data={**self.reauth_entry.data, **data} ) - await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index cb271fee755..361a20acafd 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -4,11 +4,12 @@ from __future__ import annotations from abc import abstractmethod from datetime import date, datetime, timedelta -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from aiowithings import ( Activity, Goals, + MeasurementPosition, MeasurementType, NotificationCategory, SleepSummary, @@ -30,12 +31,10 @@ from .const import LOGGER if TYPE_CHECKING: from . import WithingsConfigEntry -_T = TypeVar("_T") - UPDATE_INTERVAL = timedelta(minutes=10) -class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): +class WithingsDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base coordinator.""" config_entry: WithingsConfigEntry @@ -75,19 +74,21 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): ) await self.async_request_refresh() - async def _async_update_data(self) -> _T: + async def _async_update_data(self) -> _DataT: try: return await self._internal_update_data() except (WithingsUnauthorizedError, WithingsAuthenticationFailedError) as exc: raise ConfigEntryAuthFailed from exc @abstractmethod - async def _internal_update_data(self) -> _T: + async def _internal_update_data(self) -> _DataT: """Update coordinator data.""" class WithingsMeasurementDataUpdateCoordinator( - WithingsDataUpdateCoordinator[dict[MeasurementType, float]] + WithingsDataUpdateCoordinator[ + dict[tuple[MeasurementType, MeasurementPosition | None], float] + ] ): """Withings measurement coordinator.""" @@ -100,9 +101,13 @@ class WithingsMeasurementDataUpdateCoordinator( NotificationCategory.WEIGHT, NotificationCategory.PRESSURE, } - self._previous_data: dict[MeasurementType, float] = {} + self._previous_data: dict[ + tuple[MeasurementType, MeasurementPosition | None], float + ] = {} - async def _internal_update_data(self) -> dict[MeasurementType, float]: + async def _internal_update_data( + self, + ) -> dict[tuple[MeasurementType, MeasurementPosition | None], float]: """Retrieve measurement data.""" if self._last_valid_update is None: now = dt_util.utcnow() diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index 1f74f2be444..d8b59075368 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -26,11 +26,30 @@ async def async_get_config_entry_diagnostics( withings_data = entry.runtime_data + positional_measurements: dict[str, list[str]] = {} + measurements: list[str] = [] + + for measurement in withings_data.measurement_coordinator.data: + measurement_type, measurement_position = measurement + measurement_type_name = measurement_type.name.lower() + if measurement_position is not None: + measurement_position_name = measurement_position.name.lower() + if measurement_type_name not in positional_measurements: + positional_measurements[measurement_type_name] = [] + positional_measurements[measurement_type_name].append( + measurement_position_name + ) + else: + measurements.append(measurement_type_name) + return { "has_valid_external_webhook_url": has_valid_external_webhook_url, "has_cloudhooks": has_cloudhooks, "webhooks_connected": withings_data.measurement_coordinator.webhooks_connected, - "received_measurements": list(withings_data.measurement_coordinator.data), + "received_measurements": { + "positional": positional_measurements, + "non_positional": measurements, + }, "received_sleep_data": withings_data.sleep_coordinator.data is not None, "received_workout_data": withings_data.workout_coordinator.data is not None, "received_activity_data": withings_data.activity_coordinator.data is not None, diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 4c9b27c72fc..a5cb62b72a2 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TypeVar +from typing import Any from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -10,10 +10,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import WithingsDataUpdateCoordinator -_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) - -class WithingsEntity(CoordinatorEntity[_T]): +class WithingsEntity[_T: WithingsDataUpdateCoordinator[Any]](CoordinatorEntity[_T]): """Base class for withings entities.""" _attr_has_entity_name = True diff --git a/homeassistant/components/withings/icons.json b/homeassistant/components/withings/icons.json index f76761ce953..f6fb5e74136 100644 --- a/homeassistant/components/withings/icons.json +++ b/homeassistant/components/withings/icons.json @@ -19,6 +19,24 @@ "hydration": { "default": "mdi:water" }, + "muscle_mass_for_segments_left_arm": { + "default": "mdi:arm-flex" + }, + "muscle_mass_for_segments_right_arm": { + "default": "mdi:arm-flex" + }, + "fat_free_mass_for_segments_left_arm": { + "default": "mdi:arm-flex" + }, + "fat_free_mass_for_segments_right_arm": { + "default": "mdi:arm-flex" + }, + "fat_mass_for_segments_left_arm": { + "default": "mdi:arm-flex" + }, + "fat_mass_for_segments_right_arm": { + "default": "mdi:arm-flex" + }, "deep_sleep": { "default": "mdi:sleep" }, diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 36e34ffc187..4c97f43fd80 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==2.1.0"] + "requirements": ["aiowithings==3.0.1"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index d803481617b..20fd72845ae 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -5,11 +5,12 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import Generic, TypeVar +from typing import Any from aiowithings import ( Activity, Goals, + MeasurementPosition, MeasurementType, SleepSummary, Workout, @@ -63,6 +64,7 @@ class WithingsMeasurementSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" measurement_type: MeasurementType + measurement_position: MeasurementPosition | None = None MEASUREMENT_SENSORS: dict[ @@ -260,6 +262,47 @@ MEASUREMENT_SENSORS: dict[ } +def get_positional_measurement_description( + measurement_type: MeasurementType, measurement_position: MeasurementPosition +) -> WithingsMeasurementSensorEntityDescription | None: + """Get the sensor description for a measurement type.""" + if measurement_position not in ( + MeasurementPosition.TORSO, + MeasurementPosition.LEFT_ARM, + MeasurementPosition.RIGHT_ARM, + MeasurementPosition.LEFT_LEG, + MeasurementPosition.RIGHT_LEG, + ) or measurement_type not in ( + MeasurementType.MUSCLE_MASS_FOR_SEGMENTS, + MeasurementType.FAT_FREE_MASS_FOR_SEGMENTS, + MeasurementType.FAT_MASS_FOR_SEGMENTS, + ): + return None + return WithingsMeasurementSensorEntityDescription( + key=f"{measurement_type.name.lower()}_{measurement_position.name.lower()}", + measurement_type=measurement_type, + measurement_position=measurement_position, + translation_key=f"{measurement_type.name.lower()}_{measurement_position.name.lower()}", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ) + + +def get_measurement_description( + measurement: tuple[MeasurementType, MeasurementPosition | None], +) -> WithingsMeasurementSensorEntityDescription | None: + """Get the sensor description for a measurement type.""" + measurement_type, measurement_position = measurement + if measurement_position is not None: + return get_positional_measurement_description( + measurement_type, measurement_position + ) + return MEASUREMENT_SENSORS.get(measurement_type) + + @dataclass(frozen=True, kw_only=True) class WithingsSleepSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -630,11 +673,9 @@ async def async_setup_entry( entities: list[SensorEntity] = [] entities.extend( - WithingsMeasurementSensor( - measurement_coordinator, MEASUREMENT_SENSORS[measurement_type] - ) + WithingsMeasurementSensor(measurement_coordinator, description) for measurement_type in measurement_coordinator.data - if measurement_type in MEASUREMENT_SENSORS + if (description := get_measurement_description(measurement_type)) is not None ) current_measurement_types = set(measurement_coordinator.data) @@ -646,10 +687,10 @@ async def async_setup_entry( if new_measurement_types: current_measurement_types.update(new_measurement_types) async_add_entities( - WithingsMeasurementSensor( - measurement_coordinator, MEASUREMENT_SENSORS[measurement_type] - ) + WithingsMeasurementSensor(measurement_coordinator, description) for measurement_type in new_measurement_types + if (description := get_measurement_description(measurement_type)) + is not None ) measurement_coordinator.async_add_listener(_async_measurement_listener) @@ -767,11 +808,10 @@ async def async_setup_entry( async_add_entities(entities) -_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) -_ED = TypeVar("_ED", bound=SensorEntityDescription) - - -class WithingsSensor(WithingsEntity[_T], SensorEntity, Generic[_T, _ED]): +class WithingsSensor[ + _T: WithingsDataUpdateCoordinator[Any], + _ED: SensorEntityDescription, +](WithingsEntity[_T], SensorEntity): """Implementation of a Withings sensor.""" entity_description: _ED @@ -797,14 +837,23 @@ class WithingsMeasurementSensor( @property def native_value(self) -> float: """Return the state of the entity.""" - return self.coordinator.data[self.entity_description.measurement_type] + return self.coordinator.data[ + ( + self.entity_description.measurement_type, + self.entity_description.measurement_position, + ) + ] @property def available(self) -> bool: """Return if the sensor is available.""" return ( super().available - and self.entity_description.measurement_type in self.coordinator.data + and ( + self.entity_description.measurement_type, + self.entity_description.measurement_position, + ) + in self.coordinator.data ) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index a142dd23eac..fb86b16c3be 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -104,6 +104,51 @@ "electrodermal_activity_right_foot": { "name": "Electrodermal activity right foot" }, + "muscle_mass_for_segments_torso": { + "name": "Muscle mass in torso" + }, + "muscle_mass_for_segments_left_arm": { + "name": "Muscle mass in left arm" + }, + "muscle_mass_for_segments_right_arm": { + "name": "Muscle mass in right arm" + }, + "muscle_mass_for_segments_left_leg": { + "name": "Muscle mass in left leg" + }, + "muscle_mass_for_segments_right_leg": { + "name": "Muscle mass in right leg" + }, + "fat_free_mass_for_segments_torso": { + "name": "Fat free mass in torso" + }, + "fat_free_mass_for_segments_left_arm": { + "name": "Fat free mass in left arm" + }, + "fat_free_mass_for_segments_right_arm": { + "name": "Fat free mass in right arm" + }, + "fat_free_mass_for_segments_left_leg": { + "name": "Fat free mass in left leg" + }, + "fat_free_mass_for_segments_right_leg": { + "name": "Fat free mass in right leg" + }, + "fat_mass_for_segments_torso": { + "name": "Fat mass in torso" + }, + "fat_mass_for_segments_left_arm": { + "name": "Fat mass in left arm" + }, + "fat_mass_for_segments_right_arm": { + "name": "Fat mass in right arm" + }, + "fat_mass_for_segments_left_leg": { + "name": "Fat mass in left leg" + }, + "fat_mass_for_segments_right_leg": { + "name": "Fat mass in right leg" + }, "breathing_disturbances_intensity": { "name": "Breathing disturbances intensity" }, diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 3220856b89d..71bc0a9aaa8 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -168,7 +168,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except WizLightConnectionError: errors["base"] = "no_wiz_light" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 6f5bb25b162..ba87fb58122 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -6,11 +6,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN, LOGGER +from .const import LOGGER from .coordinator import WLEDDataUpdateCoordinator PLATFORMS = ( - Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, Platform.NUMBER, @@ -20,8 +19,10 @@ PLATFORMS = ( Platform.UPDATE, ) +type WLEDConfigEntry = ConfigEntry[WLEDDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool: """Set up WLED from a config entry.""" coordinator = WLEDDataUpdateCoordinator(hass, entry=entry) await coordinator.async_config_entry_first_refresh() @@ -36,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -47,18 +48,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool: """Unload WLED config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data # Ensure disconnected and cleanup stop sub await coordinator.wled.disconnect() if coordinator.unsub: coordinator.unsub() - del hass.data[DOMAIN][entry.entry_id] - return unload_ok diff --git a/homeassistant/components/wled/binary_sensor.py b/homeassistant/components/wled/binary_sensor.py deleted file mode 100644 index 260c43c8ba0..00000000000 --- a/homeassistant/components/wled/binary_sensor.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Support for WLED binary sensor.""" - -from __future__ import annotations - -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.entity_platform import AddEntitiesCallback - -from .const import DOMAIN -from .coordinator import WLEDDataUpdateCoordinator -from .models import WLEDEntity - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up a WLED binary sensor based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - WLEDUpdateBinarySensor(coordinator), - ] - ) - - -class WLEDUpdateBinarySensor(WLEDEntity, BinarySensorEntity): - """Defines a WLED firmware binary sensor.""" - - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_device_class = BinarySensorDeviceClass.UPDATE - _attr_translation_key = "firmware" - - # Disabled by default, as this entity is deprecated. - _attr_entity_registry_enabled_default = False - - def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize the button entity.""" - super().__init__(coordinator=coordinator) - self._attr_unique_id = f"{coordinator.data.info.mac_address}_update" - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - current = self.coordinator.data.info.version - beta = self.coordinator.data.info.version_latest_beta - stable = self.coordinator.data.info.version_latest_stable - - return current is not None and ( - (stable is not None and stable > current) - or ( - beta is not None - and (current.alpha or current.beta or current.release_candidate) - and beta > current - ) - ) diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index 7d3047c7c35..74799b4dcc4 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -3,25 +3,23 @@ from __future__ import annotations from homeassistant.components.button import ButtonDeviceClass, ButtonEntity -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 . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator +from .entity import WLEDEntity from .helpers import wled_exception_handler -from .models import WLEDEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WLEDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WLED button based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([WLEDRestartButton(coordinator)]) + async_add_entities([WLEDRestartButton(entry.runtime_data)]) class WLEDRestartButton(WLEDEntity, ButtonEntity): diff --git a/homeassistant/components/wled/diagnostics.py b/homeassistant/components/wled/diagnostics.py index f1eed3fc0aa..e81760e0f72 100644 --- a/homeassistant/components/wled/diagnostics.py +++ b/homeassistant/components/wled/diagnostics.py @@ -5,18 +5,16 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import WLEDDataUpdateCoordinator +from . import WLEDConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WLEDConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data data: dict[str, Any] = { "info": async_redact_data(coordinator.data.info.__dict__, "wifi"), diff --git a/homeassistant/components/wled/models.py b/homeassistant/components/wled/entity.py similarity index 97% rename from homeassistant/components/wled/models.py rename to homeassistant/components/wled/entity.py index ac7103303cc..f91e06a5858 100644 --- a/homeassistant/components/wled/models.py +++ b/homeassistant/components/wled/entity.py @@ -1,4 +1,4 @@ -"""Models for WLED.""" +"""Base entity for WLED.""" from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index ad9a02b38ca..0dd29fdc2a3 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -3,19 +3,16 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate from wled import WLEDConnectionError, WLEDError from homeassistant.exceptions import HomeAssistantError -from .models import WLEDEntity - -_WLEDEntityT = TypeVar("_WLEDEntityT", bound=WLEDEntity) -_P = ParamSpec("_P") +from .entity import WLEDEntity -def wled_exception_handler( +def wled_exception_handler[_WLEDEntityT: WLEDEntity, **_P]( 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/light.py b/homeassistant/components/wled/light.py index 1e31f090c70..36ebd024de3 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -15,25 +15,25 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_COLOR_PRIMARY, ATTR_ON, ATTR_SEGMENT_ID, DOMAIN +from . import WLEDConfigEntry +from .const import ATTR_COLOR_PRIMARY, ATTR_ON, ATTR_SEGMENT_ID from .coordinator import WLEDDataUpdateCoordinator +from .entity import WLEDEntity from .helpers import wled_exception_handler -from .models import WLEDEntity PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WLEDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WLED light based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.keep_main_light: async_add_entities([WLEDMainLight(coordinator=coordinator)]) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index b6e14963b9e..a01bbcabdd6 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.17.0"], + "requirements": ["wled==0.18.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index e6142c1cea6..5af466360bb 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -9,26 +9,26 @@ from functools import partial from wled import Segment from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_INTENSITY, ATTR_SPEED, DOMAIN +from . import WLEDConfigEntry +from .const import ATTR_INTENSITY, ATTR_SPEED from .coordinator import WLEDDataUpdateCoordinator +from .entity import WLEDEntity from .helpers import wled_exception_handler -from .models import WLEDEntity PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WLEDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WLED number based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data update_segments = partial( async_update_segments, diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 755cd5746e8..20b14531ac7 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -7,26 +7,25 @@ from functools import partial from wled import Live, Playlist, Preset from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator +from .entity import WLEDEntity from .helpers import wled_exception_handler -from .models import WLEDEntity PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WLEDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WLED select based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index daf5748021f..7d18665a085 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -27,9 +26,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DOMAIN +from . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator -from .models import WLEDEntity +from .entity import WLEDEntity @dataclass(frozen=True, kw_only=True) @@ -128,11 +127,11 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WLEDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WLED sensor based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( WLEDSensorEntity(coordinator, description) for description in SENSORS diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index a5e998ec548..7ec75b956c0 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -6,32 +6,26 @@ from functools import partial from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_DURATION, - ATTR_FADE, - ATTR_TARGET_BRIGHTNESS, - ATTR_UDP_PORT, - DOMAIN, -) +from . import WLEDConfigEntry +from .const import ATTR_DURATION, ATTR_FADE, ATTR_TARGET_BRIGHTNESS, ATTR_UDP_PORT from .coordinator import WLEDDataUpdateCoordinator +from .entity import WLEDEntity from .helpers import wled_exception_handler -from .models import WLEDEntity PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WLEDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WLED switch based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index bde2986a841..05df5fcf54f 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -9,24 +9,22 @@ 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 +from . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator +from .entity import WLEDEntity from .helpers import wled_exception_handler -from .models import WLEDEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WLEDConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WLED update based on a config entry.""" - coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([WLEDUpdateEntity(coordinator)]) + async_add_entities([WLEDUpdateEntity(entry.runtime_data)]) class WLEDUpdateEntity(WLEDEntity, UpdateEntity): diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index e1c23893f75..ad1759ba2cb 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -100,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, name=DOMAIN, update_method=async_update_data, - update_interval=timedelta(seconds=90), + update_interval=timedelta(seconds=60), ) await coordinator.async_refresh() diff --git a/homeassistant/components/wolflink/config_flow.py b/homeassistant/components/wolflink/config_flow.py index bfa66648b4b..6e218bfd1ce 100644 --- a/homeassistant/components/wolflink/config_flow.py +++ b/homeassistant/components/wolflink/config_flow.py @@ -43,7 +43,7 @@ class WolfLinkConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 88dcce39993..e406217a0c8 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], - "requirements": ["wolf-comm==0.0.7"] + "requirements": ["wolf-comm==0.0.8"] } diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index f25cf41b992..60a0489ec5c 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -35,7 +35,7 @@ async def _async_validate_country_and_province( DOMAIN, "bad_country", is_fixable=True, - is_persistent=True, + is_persistent=False, severity=IssueSeverity.ERROR, translation_key="bad_country", translation_placeholders={"title": entry.title}, @@ -59,7 +59,7 @@ async def _async_validate_country_and_province( DOMAIN, "bad_province", is_fixable=True, - is_persistent=True, + is_persistent=False, severity=IssueSeverity.ERROR, translation_key="bad_province", translation_placeholders={ diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 1963359bf0a..5df8e6c3d75 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -68,6 +68,32 @@ def validate_dates(holiday_list: list[str]) -> list[str]: return calc_holidays +def _get_obj_holidays( + country: str | None, province: str | None, year: int, language: str | None +) -> HolidayBase: + """Get the object for the requested country and year.""" + if not country: + return HolidayBase() + + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=language, + ) + if (supported_languages := obj_holidays.supported_languages) and language == "en": + for lang in supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years=year, + language=lang, + ) + LOGGER.debug("Changing language from %s to %s", language, lang) + return obj_holidays + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -83,29 +109,9 @@ async def async_setup_entry( language: str | None = entry.options.get(CONF_LANGUAGE) year: int = (dt_util.now() + timedelta(days=days_offset)).year - - if country: - obj_holidays: HolidayBase = country_holidays( - country, - subdiv=province, - years=year, - language=language, - ) - if ( - supported_languages := obj_holidays.supported_languages - ) and language == "en": - for lang in supported_languages: - if lang.startswith("en"): - obj_holidays = country_holidays( - country, - subdiv=province, - years=year, - language=lang, - ) - LOGGER.debug("Changing language from %s to %s", language, lang) - else: - obj_holidays = HolidayBase() - + obj_holidays: HolidayBase = await hass.async_add_executor_job( + _get_obj_holidays, country, province, year, language + ) calc_add_holidays: list[str] = validate_dates(add_holidays) calc_remove_holidays: list[str] = validate_dates(remove_holidays) @@ -198,7 +204,6 @@ async def async_setup_entry( entry.entry_id, ) ], - True, ) @@ -264,7 +269,7 @@ class IsWorkdaySensor(BinarySensorEntity): def _update_state_and_setup_listener(self) -> None: """Update state and setup listener for next interval.""" - now = dt_util.utcnow() + now = dt_util.now() self.update_data(now) self.unsub = async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.get_next_interval(now) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index e0813cd90cd..1148f46e2d1 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.47"] + "requirements": ["holidays==0.51"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index 1221514da42..5f05cb1ffbd 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -139,7 +139,7 @@ class HolidayFixFlow(RepairsFlow): await self.hass.async_add_executor_job( validate_custom_dates, new_options ) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 errors["remove_holidays"] = "remove_holiday_error" else: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index 0cf0b557f35..b0cf6717e4d 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -102,7 +102,7 @@ class WS66iConfigFlow(ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 3ef71e2901b..00d587e2bb4 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -89,7 +89,7 @@ def _make_satellite( device_id=device.id, ) - return WyomingSatellite(hass, service, satellite_device) + return WyomingSatellite(hass, config_entry, service, satellite_device) async def update_listener(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py index 5ed890bc60e..4591283036f 100644 --- a/homeassistant/components/wyoming/entity.py +++ b/homeassistant/components/wyoming/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.helpers import entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from .const import DOMAIN from .satellite import SatelliteDevice @@ -21,4 +21,5 @@ class WyomingSatelliteEntity(entity.Entity): self._attr_unique_id = f"{device.satellite_id}-{self.entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.satellite_id)}, + entry_type=DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 830ba5a3435..30104a88dce 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,9 +3,10 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline"], + "dependencies": ["assist_pipeline", "intent", "conversation"], "documentation": "https://www.home-assistant.io/integrations/wyoming", + "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.5.3"], + "requirements": ["wyoming==1.5.4"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 9569c420a1e..5af0c54abad 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -1,27 +1,32 @@ """Support for Wyoming satellite services.""" import asyncio -from collections.abc import AsyncGenerator import io import logging +import time from typing import Final +from uuid import uuid4 import wave +from typing_extensions import AsyncGenerator from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop from wyoming.client import AsyncTcpClient from wyoming.error import Error +from wyoming.event import Event from wyoming.info import Describe, Info from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import PauseSatellite, RunSatellite +from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection -from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components import assist_pipeline, intent, stt, tts from homeassistant.components.assist_pipeline import select as pipeline_select -from homeassistant.core import Context, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Context, HomeAssistant, callback from .const import DOMAIN from .data import WyomingService @@ -35,6 +40,7 @@ _RESTART_SECONDS: Final = 3 _PING_TIMEOUT: Final = 5 _PING_SEND_DELAY: Final = 2 _PIPELINE_FINISH_TIMEOUT: Final = 1 +_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -49,10 +55,15 @@ class WyomingSatellite: """Remove voice satellite running the Wyoming protocol.""" def __init__( - self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + service: WyomingService, + device: SatelliteDevice, ) -> None: """Initialize satellite.""" self.hass = hass + self.config_entry = config_entry self.service = service self.device = device self.is_running = True @@ -65,6 +76,9 @@ class WyomingSatellite: self._pipeline_id: str | None = None self._muted_changed_event = asyncio.Event() + self._conversation_id: str | None = None + self._conversation_id_time: float | None = None + 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) @@ -73,6 +87,10 @@ class WyomingSatellite: """Run and maintain a connection to satellite.""" _LOGGER.debug("Running satellite task") + unregister_timer_handler = intent.async_register_timer_handler( + self.hass, self.device.device_id, self._handle_timer + ) + try: while self.is_running: try: @@ -88,7 +106,7 @@ class WyomingSatellite: await self._connect_and_loop() except asyncio.CancelledError: raise # don't restart - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # noqa: BLE001 _LOGGER.debug("%s: %s", err.__class__.__name__, str(err)) # Ensure sensor is off (before restart) @@ -97,6 +115,8 @@ class WyomingSatellite: # Wait to restart await self.on_restart() finally: + unregister_timer_handler() + # Ensure sensor is off (before stop) self.device.set_is_active(False) @@ -142,7 +162,8 @@ class WyomingSatellite: def _send_pause(self) -> None: """Send a pause message to satellite.""" if self._client is not None: - self.hass.async_create_background_task( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event(PauseSatellite().event()), "pause satellite", ) @@ -207,11 +228,11 @@ class WyomingSatellite: send_ping = True # Read events and check for pipeline end in parallel - pipeline_ended_task = self.hass.async_create_background_task( - self._pipeline_ended_event.wait(), "satellite pipeline ended" + pipeline_ended_task = self.config_entry.async_create_background_task( + self.hass, self._pipeline_ended_event.wait(), "satellite pipeline ended" ) - client_event_task = self.hass.async_create_background_task( - self._client.read_event(), "satellite event read" + client_event_task = self.config_entry.async_create_background_task( + self.hass, self._client.read_event(), "satellite event read" ) pending = {pipeline_ended_task, client_event_task} @@ -222,8 +243,8 @@ class WyomingSatellite: if send_ping: # Ensure satellite is still connected send_ping = False - self.hass.async_create_background_task( - self._send_delayed_ping(), "ping satellite" + self.config_entry.async_create_background_task( + self.hass, self._send_delayed_ping(), "ping satellite" ) async with asyncio.timeout(_PING_TIMEOUT): @@ -234,8 +255,12 @@ class WyomingSatellite: # Pipeline run end event was received _LOGGER.debug("Pipeline finished") self._pipeline_ended_event.clear() - pipeline_ended_task = self.hass.async_create_background_task( - self._pipeline_ended_event.wait(), "satellite pipeline ended" + pipeline_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._pipeline_ended_event.wait(), + "satellite pipeline ended", + ) ) pending.add(pipeline_ended_task) @@ -307,8 +332,8 @@ class WyomingSatellite: _LOGGER.debug("Unexpected event from satellite: %s", client_event) # Next event - client_event_task = self.hass.async_create_background_task( - self._client.read_event(), "satellite event read" + client_event_task = self.config_entry.async_create_background_task( + self.hass, self._client.read_event(), "satellite event read" ) pending.add(client_event_task) @@ -346,9 +371,23 @@ class WyomingSatellite: start_stage, end_stage, ) + + # Reset conversation id, if necessary + if (self._conversation_id_time is None) or ( + (time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC + ): + self._conversation_id = None + + if self._conversation_id is None: + self._conversation_id = str(uuid4()) + + # Update timeout + self._conversation_id_time = time.monotonic() + self._is_pipeline_running = True self._pipeline_ended_event.clear() - self.hass.async_create_background_task( + self.config_entry.async_create_background_task( + self.hass, assist_pipeline.async_pipeline_from_audio_stream( self.hass, context=Context(), @@ -373,6 +412,7 @@ class WyomingSatellite: ), device_id=self.device.device_id, wake_word_phrase=wake_word_phrase, + conversation_id=self._conversation_id, ), name="wyoming satellite pipeline", ) @@ -400,8 +440,6 @@ class WyomingSatellite: self.hass.add_job(self._client.write_event(Detect().event())) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: # Wake word detection - self.device.set_is_active(True) - # Inform client of wake word detection if event.data and (wake_word_output := event.data.get("wake_word_output")): detection = Detection( @@ -532,7 +570,7 @@ class WyomingSatellite: await self._client.write_event(AudioStop(timestamp=timestamp).event()) _LOGGER.debug("TTS streaming complete") - async def _stt_stream(self) -> AsyncGenerator[bytes, None]: + async def _stt_stream(self) -> AsyncGenerator[bytes]: """Yield audio chunks from a queue.""" try: is_first_chunk = True @@ -544,3 +582,38 @@ class WyomingSatellite: yield chunk except asyncio.CancelledError: pass # ignore + + @callback + def _handle_timer( + self, event_type: intent.TimerEventType, timer: intent.TimerInfo + ) -> None: + """Forward timer events to satellite.""" + assert self._client is not None + + _LOGGER.debug("Timer event: type=%s, info=%s", event_type, timer) + event: Event | None = None + if event_type == intent.TimerEventType.STARTED: + event = TimerStarted( + id=timer.id, + total_seconds=timer.seconds, + name=timer.name, + start_hours=timer.start_hours, + start_minutes=timer.start_minutes, + start_seconds=timer.start_seconds, + ).event() + elif event_type == intent.TimerEventType.UPDATED: + event = TimerUpdated( + id=timer.id, + is_active=timer.is_active, + total_seconds=timer.seconds, + ).event() + elif event_type == intent.TimerEventType.CANCELLED: + event = TimerCancelled(id=timer.id).event() + elif event_type == intent.TimerEventType.FINISHED: + event = TimerFinished(id=timer.id).event() + + if event is not None: + # Send timer event to satellite + self.config_entry.async_create_background_task( + self.hass, self._client.write_event(event), "wyoming timer event" + ) diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py index 7366a52efab..c012c60bc5a 100644 --- a/homeassistant/components/wyoming/switch.py +++ b/homeassistant/components/wyoming/switch.py @@ -51,6 +51,7 @@ class WyomingSatelliteMuteSwitch( # Default to off self._attr_is_on = (state is not None) and (state.state == STATE_ON) + self._device.is_muted = self._attr_is_on async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index 3c9b5a44f04..6ab46cea069 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -2,23 +2,10 @@ from __future__ import annotations -from contextlib import suppress -from dataclasses import dataclass -from datetime import timedelta import logging from xbox.webapi.api.client import XboxLiveClient -from xbox.webapi.api.provider.catalog.const import SYSTEM_PFN_ID_MAP -from xbox.webapi.api.provider.catalog.models import AlternateIdType, Product -from xbox.webapi.api.provider.people.models import ( - PeopleResponse, - Person, - PresenceDetail, -) -from xbox.webapi.api.provider.smartglass.models import ( - SmartglassConsoleList, - SmartglassConsoleStatus, -) +from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -28,10 +15,10 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, ) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import api from .const import DOMAIN +from .coordinator import XboxUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -89,142 +76,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -@dataclass -class ConsoleData: - """Xbox console status data.""" - - status: SmartglassConsoleStatus - app_details: Product | None - - -@dataclass -class PresenceData: - """Xbox user presence data.""" - - xuid: str - gamertag: str - display_pic: str - online: bool - status: str - in_party: bool - in_game: bool - in_multiplayer: bool - gamer_score: str - gold_tenure: str | None - account_tier: str - - -@dataclass -class XboxData: - """Xbox dataclass for update coordinator.""" - - consoles: dict[str, ConsoleData] - presence: dict[str, PresenceData] - - -class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): # pylint: disable=hass-enforce-coordinator-module - """Store Xbox Console Status.""" - - def __init__( - self, - hass: HomeAssistant, - client: XboxLiveClient, - consoles: SmartglassConsoleList, - ) -> None: - """Initialize.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=10), - ) - self.data = XboxData({}, {}) - self.client: XboxLiveClient = client - self.consoles: SmartglassConsoleList = consoles - - async def _async_update_data(self) -> XboxData: - """Fetch the latest console status.""" - # Update Console Status - new_console_data: dict[str, ConsoleData] = {} - for console in self.consoles.result: - current_state: ConsoleData | None = self.data.consoles.get(console.id) - status: SmartglassConsoleStatus = ( - await self.client.smartglass.get_console_status(console.id) - ) - - _LOGGER.debug( - "%s status: %s", - console.name, - status.dict(), - ) - - # Setup focus app - app_details: Product | None = None - if current_state is not None: - app_details = current_state.app_details - - if status.focus_app_aumid: - if ( - not current_state - or status.focus_app_aumid != current_state.status.focus_app_aumid - ): - app_id = status.focus_app_aumid.split("!")[0] - id_type = AlternateIdType.PACKAGE_FAMILY_NAME - if app_id in SYSTEM_PFN_ID_MAP: - id_type = AlternateIdType.LEGACY_XBOX_PRODUCT_ID - app_id = SYSTEM_PFN_ID_MAP[app_id][id_type] - catalog_result = ( - await self.client.catalog.get_product_from_alternate_id( - app_id, id_type - ) - ) - if catalog_result and catalog_result.products: - app_details = catalog_result.products[0] - else: - app_details = None - - new_console_data[console.id] = ConsoleData( - status=status, app_details=app_details - ) - - # Update user presence - presence_data: dict[str, PresenceData] = {} - batch: PeopleResponse = await self.client.people.get_friends_own_batch( - [self.client.xuid] - ) - own_presence: Person = batch.people[0] - presence_data[own_presence.xuid] = _build_presence_data(own_presence) - - friends: PeopleResponse = await self.client.people.get_friends_own() - for friend in friends.people: - if not friend.is_favorite: - continue - - presence_data[friend.xuid] = _build_presence_data(friend) - - return XboxData(new_console_data, presence_data) - - -def _build_presence_data(person: Person) -> PresenceData: - """Build presence data from a person.""" - active_app: PresenceDetail | None = None - with suppress(StopIteration): - active_app = next( - presence for presence in person.presence_details if presence.is_primary - ) - - return PresenceData( - xuid=person.xuid, - gamertag=person.gamertag, - display_pic=person.display_pic_raw, - online=person.presence_state == "Online", - status=person.presence_text, - in_party=person.multiplayer_summary.in_party > 0, - in_game=active_app is not None and active_app.is_game, - in_multiplayer=person.multiplayer_summary.in_multiplayer_session, - gamer_score=person.gamer_score, - gold_tenure=person.detail.tenure, - account_tier=person.detail.account_tier, - ) diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py index 7769d639f44..f252385d4ca 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/base_sensor.py @@ -7,8 +7,8 @@ from yarl import URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PresenceData, XboxUpdateCoordinator from .const import DOMAIN +from .coordinator import PresenceData, XboxUpdateCoordinator class XboxBaseSensorEntity(CoordinatorEntity[XboxUpdateCoordinator]): diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index ffd99cde30e..0f0b9799d3d 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -10,9 +10,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import XboxUpdateCoordinator from .base_sensor import XboxBaseSensorEntity from .const import DOMAIN +from .coordinator import XboxUpdateCoordinator PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"] diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py new file mode 100644 index 00000000000..4012820c43c --- /dev/null +++ b/homeassistant/components/xbox/coordinator.py @@ -0,0 +1,167 @@ +"""Coordinator for the xbox integration.""" + +from __future__ import annotations + +from contextlib import suppress +from dataclasses import dataclass +from datetime import timedelta +import logging + +from xbox.webapi.api.client import XboxLiveClient +from xbox.webapi.api.provider.catalog.const import SYSTEM_PFN_ID_MAP +from xbox.webapi.api.provider.catalog.models import AlternateIdType, Product +from xbox.webapi.api.provider.people.models import ( + PeopleResponse, + Person, + PresenceDetail, +) +from xbox.webapi.api.provider.smartglass.models import ( + SmartglassConsoleList, + SmartglassConsoleStatus, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ConsoleData: + """Xbox console status data.""" + + status: SmartglassConsoleStatus + app_details: Product | None + + +@dataclass +class PresenceData: + """Xbox user presence data.""" + + xuid: str + gamertag: str + display_pic: str + online: bool + status: str + in_party: bool + in_game: bool + in_multiplayer: bool + gamer_score: str + gold_tenure: str | None + account_tier: str + + +@dataclass +class XboxData: + """Xbox dataclass for update coordinator.""" + + consoles: dict[str, ConsoleData] + presence: dict[str, PresenceData] + + +class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): + """Store Xbox Console Status.""" + + def __init__( + self, + hass: HomeAssistant, + client: XboxLiveClient, + consoles: SmartglassConsoleList, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=10), + ) + self.data = XboxData({}, {}) + self.client: XboxLiveClient = client + self.consoles: SmartglassConsoleList = consoles + + async def _async_update_data(self) -> XboxData: + """Fetch the latest console status.""" + # Update Console Status + new_console_data: dict[str, ConsoleData] = {} + for console in self.consoles.result: + current_state: ConsoleData | None = self.data.consoles.get(console.id) + status: SmartglassConsoleStatus = ( + await self.client.smartglass.get_console_status(console.id) + ) + + _LOGGER.debug( + "%s status: %s", + console.name, + status.dict(), + ) + + # Setup focus app + app_details: Product | None = None + if current_state is not None: + app_details = current_state.app_details + + if status.focus_app_aumid: + if ( + not current_state + or status.focus_app_aumid != current_state.status.focus_app_aumid + ): + app_id = status.focus_app_aumid.split("!")[0] + id_type = AlternateIdType.PACKAGE_FAMILY_NAME + if app_id in SYSTEM_PFN_ID_MAP: + id_type = AlternateIdType.LEGACY_XBOX_PRODUCT_ID + app_id = SYSTEM_PFN_ID_MAP[app_id][id_type] + catalog_result = ( + await self.client.catalog.get_product_from_alternate_id( + app_id, id_type + ) + ) + if catalog_result and catalog_result.products: + app_details = catalog_result.products[0] + else: + app_details = None + + new_console_data[console.id] = ConsoleData( + status=status, app_details=app_details + ) + + # Update user presence + presence_data: dict[str, PresenceData] = {} + batch: PeopleResponse = await self.client.people.get_friends_own_batch( + [self.client.xuid] + ) + own_presence: Person = batch.people[0] + presence_data[own_presence.xuid] = _build_presence_data(own_presence) + + friends: PeopleResponse = await self.client.people.get_friends_own() + for friend in friends.people: + if not friend.is_favorite: + continue + + presence_data[friend.xuid] = _build_presence_data(friend) + + return XboxData(new_console_data, presence_data) + + +def _build_presence_data(person: Person) -> PresenceData: + """Build presence data from a person.""" + active_app: PresenceDetail | None = None + with suppress(StopIteration): + active_app = next( + presence for presence in person.presence_details if presence.is_primary + ) + + return PresenceData( + xuid=person.xuid, + gamertag=person.gamertag, + display_pic=person.display_pic_raw, + online=person.presence_state == "Online", + status=person.presence_text, + in_party=person.multiplayer_summary.in_party > 0, + in_game=active_app is not None and active_app.is_game, + in_multiplayer=person.multiplayer_summary.in_multiplayer_session, + gamer_score=person.gamer_score, + gold_tenure=person.detail.tenure, + account_tier=person.detail.account_tier, + ) diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index f2cbc2e7c87..7298c7e2da3 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -27,9 +27,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ConsoleData, XboxUpdateCoordinator from .browse_media import build_item_response from .const import DOMAIN +from .coordinator import ConsoleData, XboxUpdateCoordinator SUPPORT_XBOX = ( MediaPlayerEntityFeature.TURN_ON diff --git a/homeassistant/components/xbox/media_source.py b/homeassistant/components/xbox/media_source.py index ea444ce1bc9..a63f3b2027b 100644 --- a/homeassistant/components/xbox/media_source.py +++ b/homeassistant/components/xbox/media_source.py @@ -5,7 +5,7 @@ from __future__ import annotations from contextlib import suppress from dataclasses import dataclass -from pydantic.error_wrappers import ValidationError # pylint: disable=no-name-in-module +from pydantic import ValidationError from xbox.webapi.api.client import XboxLiveClient from xbox.webapi.api.provider.catalog.models import FieldsTemplate, Image from xbox.webapi.api.provider.gameclips.models import GameclipsResponse diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index a720025a1e6..1b4ffdf35cc 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -27,8 +27,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ConsoleData, XboxUpdateCoordinator from .const import DOMAIN +from .coordinator import ConsoleData, XboxUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index 4e258399a5d..ff6591d5b3e 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -10,9 +10,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import XboxUpdateCoordinator from .base_sensor import XboxBaseSensorEntity from .const import DOMAIN +from .coordinator import XboxUpdateCoordinator SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"] diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 76227d89e94..869a7a1cf1f 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -69,7 +69,7 @@ class XiaomiDeviceScanner(DeviceScanner): self.mac2name = dict(mac2name_list) else: # Error, handled in the _retrieve_list_with_retry - return + return None return self.mac2name.get(device.upper(), None) def _update_info(self): @@ -117,34 +117,34 @@ def _retrieve_list(host, token, **kwargs): res = requests.get(url, timeout=10, **kwargs) except requests.exceptions.Timeout: _LOGGER.exception("Connection to the router timed out at URL %s", url) - return + return None if res.status_code != HTTPStatus.OK: _LOGGER.exception("Connection failed with http code %s", res.status_code) - return + return None try: result = res.json() except ValueError: # If json decoder could not parse the response _LOGGER.exception("Failed to parse response from mi router") - return + return None try: xiaomi_code = result["code"] except KeyError: _LOGGER.exception("No field code in response from mi router. %s", result) - return + return None if xiaomi_code == 0: try: return result["list"] except KeyError: _LOGGER.exception("No list in response from mi router. %s", result) - return + return None else: _LOGGER.info( "Receive wrong Xiaomi code %s, expected 0 in response %s", xiaomi_code, result, ) - return + return None def _get_token(host, username, password): @@ -155,14 +155,14 @@ def _get_token(host, username, password): res = requests.post(url, data=data, timeout=5) except requests.exceptions.Timeout: _LOGGER.exception("Connection to the router timed out") - return + return None if res.status_code == HTTPStatus.OK: try: result = res.json() except ValueError: # If JSON decoder could not parse the response _LOGGER.exception("Failed to parse response from mi router") - return + return None try: return result["token"] except KeyError: @@ -171,7 +171,7 @@ def _get_token(host, username, password): "url: [%s] \nwith parameter: [%s] \nwas: [%s]" ) _LOGGER.exception(error_message, url, data, result) - return + return None else: _LOGGER.error( "Invalid response: [%s] at url: [%s] with data [%s]", res, url, data diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 89071432c2b..cee2980fe07 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -268,7 +268,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor): "bug (https://github.com/home-assistant/core/pull/" "11631#issuecomment-357507744)" ) - return + return None if NO_MOTION in data: self._no_motion_since = data[NO_MOTION] diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 19c1f3feea1..4a9753bfe85 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -17,11 +17,8 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.device_registry import ( - CONNECTION_BLUETOOTH, - DeviceRegistry, - async_get, -) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -167,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return await data.async_poll(connectable_device) - device_registry = async_get(hass) + device_registry = dr.async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( XiaomiActiveBluetoothProcessorCoordinator( hass, diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index c8d4666e482..8734f45c405 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -107,7 +107,7 @@ BINARY_SENSOR_DESCRIPTIONS = { def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, -) -> PassiveBluetoothDataUpdate: +) -> PassiveBluetoothDataUpdate[bool | None]: """Convert a sensor update to a bluetooth data update.""" return PassiveBluetoothDataUpdate( devices={ @@ -155,7 +155,7 @@ async def async_setup_entry( class XiaomiBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor], + PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor[bool | None]], BinarySensorEntity, ): """Representation of a Xiaomi binary sensor.""" diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index ef5212584d8..1cd49e851ea 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine from logging import Logger from typing import Any -from xiaomi_ble import XiaomiBluetoothDeviceData +from xiaomi_ble import SensorUpdate, XiaomiBluetoothDeviceData from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -23,7 +23,9 @@ from homeassistant.helpers.debounce import Debouncer from .const import CONF_SLEEPY_DEVICE -class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): +class XiaomiActiveBluetoothProcessorCoordinator( + ActiveBluetoothProcessorCoordinator[SensorUpdate] +): """Define a Xiaomi Bluetooth Active Update Processor Coordinator.""" def __init__( @@ -33,13 +35,13 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina *, address: str, mode: BluetoothScanningMode, - update_method: Callable[[BluetoothServiceInfoBleak], Any], + update_method: Callable[[BluetoothServiceInfoBleak], SensorUpdate], needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool], device_data: XiaomiBluetoothDeviceData, discovered_event_classes: set[str], poll_method: Callable[ [BluetoothServiceInfoBleak], - Coroutine[Any, Any, Any], + Coroutine[Any, Any, SensorUpdate], ] | None = None, poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, @@ -68,7 +70,9 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) -class XiaomiPassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): +class XiaomiPassiveBluetoothDataProcessor[_T]( + PassiveBluetoothDataProcessor[_T, SensorUpdate] +): """Define a Xiaomi Bluetooth Passive Update Data Processor.""" coordinator: XiaomiActiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index ef0556b6966..1e0a09015ee 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.28.0"] + "requirements": ["xiaomi-ble==0.30.0"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index c5354a54394..65b33c3c559 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + from xiaomi_ble import DeviceClass, SensorUpdate, Units from xiaomi_ble.parser import ExtendedSensorDeviceClass @@ -18,11 +20,11 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, - CONDUCTIVITY, LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfConductivity, UnitOfElectricPotential, UnitOfMass, UnitOfPressure, @@ -51,7 +53,7 @@ SENSOR_DESCRIPTIONS = { (DeviceClass.CONDUCTIVITY, Units.CONDUCTIVITY): SensorEntityDescription( key=str(Units.CONDUCTIVITY), device_class=None, - native_unit_of_measurement=CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS, state_class=SensorStateClass.MEASUREMENT, ), ( @@ -162,7 +164,7 @@ SENSOR_DESCRIPTIONS = { def sensor_update_to_bluetooth_data_update( sensor_update: SensorUpdate, -) -> PassiveBluetoothDataUpdate: +) -> PassiveBluetoothDataUpdate[float | None]: """Convert a sensor update to a bluetooth data update.""" return PassiveBluetoothDataUpdate( devices={ @@ -177,7 +179,9 @@ def sensor_update_to_bluetooth_data_update( if description.device_class }, entity_data={ - device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + device_key_to_bluetooth_entity_key(device_key): cast( + float | None, sensor_values.native_value + ) for device_key, sensor_values in sensor_update.entity_values.items() }, entity_names={ @@ -210,7 +214,7 @@ async def async_setup_entry( class XiaomiBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor], + PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor[float | None]], SensorEntity, ): """Representation of a xiaomi ble sensor.""" diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 8ee8bac3fea..048c9bd92e2 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -83,7 +83,7 @@ "button_fan": "Button Fan \"{subtype}\"", "button_swing": "Button Swing \"{subtype}\"", "button_decrease_speed": "Button Decrease Speed \"{subtype}\"", - "button_increase_speed": "Button Inrease Speed \"{subtype}\"", + "button_increase_speed": "Button Increase Speed \"{subtype}\"", "button_stop": "Button Stop \"{subtype}\"", "button_light": "Button Light \"{subtype}\"", "button_wind_speed": "Button Wind Speed \"{subtype}\"", diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 72530227e88..58d5ed247ad 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -54,6 +54,7 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): _attr_icon = "mdi:shield-home" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_code_arm_required = False def __init__( self, gateway_device, gateway_name, model, mac_address, gateway_device_id diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index e2a129e147d..c689ede27eb 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -243,7 +243,7 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "cloud_login_error" except MiCloudAccessDenied: errors["base"] = "cloud_login_error" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception in Miio cloud login") return self.async_abort(reason="unknown") @@ -256,7 +256,7 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): devices_raw = await self.hass.async_add_executor_job( miio_cloud.get_devices, cloud_country ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception in Miio cloud get devices") return self.async_abort(reason="unknown") @@ -353,7 +353,7 @@ class XiaomiMiioFlowHandler(ConfigFlow, domain=DOMAIN): except SetupException: if self.model is None: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception in connect Xiaomi device") return self.async_abort(reason="unknown") diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index d643602531d..24b494f3d08 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -67,6 +67,7 @@ MODEL_AIRPURIFIER_MA1 = "zhimi.airpurifier.ma1" MODEL_AIRPURIFIER_MA2 = "zhimi.airpurifier.ma2" MODEL_AIRPURIFIER_PRO = "zhimi.airpurifier.v6" MODEL_AIRPURIFIER_PROH = "zhimi.airpurifier.va1" +MODEL_AIRPURIFIER_PROH_EU = "zhimi.airpurifier.vb2" MODEL_AIRPURIFIER_PRO_V7 = "zhimi.airpurifier.v7" MODEL_AIRPURIFIER_SA1 = "zhimi.airpurifier.sa1" MODEL_AIRPURIFIER_SA2 = "zhimi.airpurifier.sa2" @@ -125,6 +126,7 @@ MODELS_PURIFIER_MIOT = [ MODEL_AIRPURIFIER_3C, MODEL_AIRPURIFIER_3H, MODEL_AIRPURIFIER_PROH, + MODEL_AIRPURIFIER_PROH_EU, MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4, diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index 39cb0ee5f96..e90a86ab7e9 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -4,7 +4,7 @@ import datetime from enum import Enum from functools import partial import logging -from typing import Any, TypeVar +from typing import Any from construct.core import ChecksumError from miio import Device, DeviceException @@ -22,8 +22,6 @@ from .const import DOMAIN, AuthException, SetupException _LOGGER = logging.getLogger(__name__) -_T = TypeVar("_T", bound=DataUpdateCoordinator[Any]) - class ConnectXiaomiDevice: """Class to async connect to a Xiaomi Device.""" @@ -109,7 +107,9 @@ class XiaomiMiioEntity(Entity): return device_info -class XiaomiCoordinatedMiioEntity(CoordinatorEntity[_T]): +class XiaomiCoordinatedMiioEntity[_T: DataUpdateCoordinator[Any]]( + CoordinatorEntity[_T] +): """Representation of a base a coordinated Xiaomi Miio Entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index bef39535176..b785adef15a 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -54,6 +54,7 @@ from .const import ( MODEL_AIRPURIFIER_M2, MODEL_AIRPURIFIER_MA2, MODEL_AIRPURIFIER_PROH, + MODEL_AIRPURIFIER_PROH_EU, MODEL_AIRPURIFIER_ZA1, MODEL_FAN_SA1, MODEL_FAN_V2, @@ -137,6 +138,9 @@ MODEL_TO_ATTR_MAP: dict[str, list] = { MODEL_AIRPURIFIER_PROH: [ AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) ], + MODEL_AIRPURIFIER_PROH_EU: [ + AttributeEnumMapping(ATTR_LED_BRIGHTNESS, AirpurifierMiotLedBrightness) + ], MODEL_FAN_SA1: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], MODEL_FAN_V2: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], MODEL_FAN_V3: [AttributeEnumMapping(ATTR_LED_BRIGHTNESS, FanLedBrightness)], @@ -256,10 +260,10 @@ class XiaomiGenericSelector(XiaomiSelector): if description.options_map: self._options_map = {} - for key, val in enum_class._member_map_.items(): + for key, val in enum_class._member_map_.items(): # noqa: SLF001 self._options_map[description.options_map[key]] = val else: - self._options_map = enum_class._member_map_ + self._options_map = enum_class._member_map_ # noqa: SLF001 self._reverse_map = {val: key for key, val in self._options_map.items()} self._enum_class = enum_class diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 9f70ef6bb17..ab992a8fe96 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -834,7 +834,8 @@ async def async_setup_entry( elif model in MODELS_VACUUM or model.startswith( (ROBOROCK_GENERIC, ROCKROBO_GENERIC) ): - return _setup_vacuum_sensors(hass, config_entry, async_add_entities) + _setup_vacuum_sensors(hass, config_entry, async_add_entities) + return for sensor, description in SENSOR_TYPES.items(): if sensor not in sensors: diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 4f7af2be7ee..4da1bf35d1a 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -147,7 +147,7 @@ async def async_send_message( # noqa: C901 self.force_starttls = use_tls self.use_ipv6 = False - self.add_event_handler("failed_auth", self.disconnect_on_login_fail) + self.add_event_handler("failed_all_auth", self.disconnect_on_login_fail) self.add_event_handler("session_start", self.start) if room: diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index c914e3c316f..1ef68d98a13 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er from .const import LOGGER, PLATFORMS from .coordinator import YaleDataUpdateCoordinator -YaleConfigEntry = ConfigEntry["YaleDataUpdateCoordinator"] +type YaleConfigEntry = ConfigEntry[YaleDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index 3ce63cb3fbb..0e53c814fd4 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -6,9 +6,11 @@ from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import YaleConfigEntry +from .const import DOMAIN, YALE_ALL_ERRORS from .coordinator import YaleDataUpdateCoordinator from .entity import YaleAlarmEntity @@ -54,6 +56,16 @@ class YalePanicButton(YaleAlarmEntity, ButtonEntity): if TYPE_CHECKING: assert self.coordinator.yale, "Connection to API is missing" - await self.hass.async_add_executor_job( - self.coordinator.yale.trigger_panic_button - ) + try: + await self.hass.async_add_executor_job( + self.coordinator.yale.trigger_panic_button + ) + except YALE_ALL_ERRORS as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="could_not_trigger_panic", + translation_placeholders={ + "entity_id": self.entity_id, + "error": str(error), + }, + ) from error diff --git a/homeassistant/components/yale_smart_alarm/const.py b/homeassistant/components/yale_smart_alarm/const.py index 2582854a3bc..e7b732c6cf9 100644 --- a/homeassistant/components/yale_smart_alarm/const.py +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -39,6 +39,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LOCK, + Platform.SENSOR, ] STATE_MAP = { diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 642704b637d..5307e166e17 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -39,6 +39,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): locks = [] door_windows = [] + temp_sensors = [] for device in updates["cycle"]["device_status"]: state = device["status1"] @@ -107,19 +108,24 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): device["_state"] = "unavailable" door_windows.append(device) continue + if device["type"] == "device_type.temperature_sensor": + temp_sensors.append(device) _sensor_map = { contact["address"]: contact["_state"] for contact in door_windows } _lock_map = {lock["address"]: lock["_state"] for lock in locks} + _temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors} return { "alarm": updates["arm_status"], "locks": locks, "door_windows": door_windows, + "temp_sensors": temp_sensors, "status": updates["status"], "online": updates["online"], "sensor_map": _sensor_map, + "temp_map": _temp_map, "lock_map": _lock_map, "panel_info": updates["panel_info"], } diff --git a/homeassistant/components/yale_smart_alarm/sensor.py b/homeassistant/components/yale_smart_alarm/sensor.py new file mode 100644 index 00000000000..50343f2e41f --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/sensor.py @@ -0,0 +1,39 @@ +"""Sensors for Yale Alarm.""" + +from __future__ import annotations + +from typing import cast + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import YaleConfigEntry +from .entity import YaleEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Yale sensor entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + YaleTemperatureSensor(coordinator, data) + for data in coordinator.data["temp_sensors"] + ) + + +class YaleTemperatureSensor(YaleEntity, SensorEntity): + """Representation of a Yale temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + @property + def native_value(self) -> StateType: + "Return native value." + return cast(float, self.coordinator.data["temp_map"][self._attr_unique_id]) diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index a698da20d8d..ce89c9e69ea 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -69,6 +69,9 @@ }, "could_not_change_lock": { "message": "Could not set lock, check system ready for lock" + }, + "could_not_trigger_panic": { + "message": "Could not trigger panic button for entity id {entity_id}: {error}" } } } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 8c9c5176003..c5183623660 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -25,15 +25,17 @@ from .const import ( CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, - DOMAIN, ) from .models import YaleXSBLEData from .util import async_find_existing_service_info, bluetooth_callback_matcher +type YALEXSBLEConfigEntry = ConfigEntry[YaleXSBLEData] + + PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> bool: """Set up Yale Access Bluetooth from a config entry.""" local_name = entry.data[CONF_LOCAL_NAME] address = entry.data[CONF_ADDRESS] @@ -98,9 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"{ex}; Try moving the Bluetooth adapter closer to {local_name}" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = YaleXSBLEData( - entry.title, push_lock, always_connected - ) + entry.runtime_data = YaleXSBLEData(entry.title, push_lock, always_connected) @callback def _async_device_unavailable( @@ -132,18 +132,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: YALEXSBLEConfigEntry +) -> None: """Handle options update.""" - data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data if entry.title != data.title or data.always_connected != entry.options.get( CONF_ALWAYS_CONNECTED ): await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/yalexs_ble/binary_sensor.py b/homeassistant/components/yalexs_ble/binary_sensor.py index a127aa66b93..7cd142bb9ba 100644 --- a/homeassistant/components/yalexs_ble/binary_sensor.py +++ b/homeassistant/components/yalexs_ble/binary_sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from yalexs_ble import ConnectionInfo, DoorStatus, LockInfo, LockState -from homeassistant import config_entries from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -12,18 +11,17 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity -from .models import YaleXSBLEData async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: YALEXSBLEConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up YALE XS binary sensors.""" - data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data lock = data.lock if lock.lock_info and lock.lock_info.door_sense: async_add_entities([YaleXSBLEDoorSensor(data)]) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 3ec7f675d7a..c0df4e26821 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -57,7 +57,7 @@ async def async_validate_lock_or_error( return {CONF_KEY: "invalid_auth"} except BleakError: return {"base": "cannot_connect"} - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error") return {"base": "unknown"} return {} diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 9f508b1a8ee..6eb32e3f78a 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -7,23 +7,20 @@ from typing import Any from yalexs_ble import ConnectionInfo, LockInfo, LockState, LockStatus from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity -from .models import YaleXSBLEData async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: YALEXSBLEConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up locks.""" - data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id] - async_add_entities([YaleXSBLELock(data)]) + async_add_entities([YaleXSBLELock(entry.runtime_data)]) class YaleXSBLELock(YALEXSBLEEntity, LockEntity): diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 1fc0601996e..90f61219e0b 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from yalexs_ble import ConnectionInfo, LockInfo, LockState -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -23,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity from .models import YaleXSBLEData @@ -75,11 +74,11 @@ SENSORS: tuple[YaleXSBLESensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: YALEXSBLEConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up YALE XS Bluetooth sensors.""" - data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities(YaleXSBLESensor(description, data) for description in SENSORS) diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 34d352b790e..a074f34c782 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -51,7 +51,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) except (MusicCastConnectionException, ClientConnectorError): errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/yardian/config_flow.py b/homeassistant/components/yardian/config_flow.py index e23ca536d4e..0a947537db0 100644 --- a/homeassistant/components/yardian/config_flow.py +++ b/homeassistant/components/yardian/config_flow.py @@ -57,7 +57,7 @@ class YardianConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except NetworkException: errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index ede652dd037..1d514c131d2 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import logging import math -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate import voluptuous as vol import yeelight @@ -67,10 +67,6 @@ from .const import ( from .device import YeelightDevice from .entity import YeelightEntity -_YeelightBaseLightT = TypeVar("_YeelightBaseLightT", bound="YeelightBaseLight") -_R = TypeVar("_R") -_P = ParamSpec("_P") - _LOGGER = logging.getLogger(__name__) ATTR_MINUTES = "minutes" @@ -243,7 +239,7 @@ def _parse_custom_effects(effects_config) -> dict[str, dict[str, Any]]: return effects -def _async_cmd( +def _async_cmd[_YeelightBaseLightT: YeelightBaseLight, **_P, _R]( func: Callable[Concatenate[_YeelightBaseLightT, _P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[_YeelightBaseLightT, _P], Coroutine[Any, Any, _R | None]]: """Define a wrapper to catch exceptions from the bulb.""" diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py index fbc3294e25d..f512d31cb6b 100644 --- a/homeassistant/components/yi/camera.py +++ b/homeassistant/components/yi/camera.py @@ -149,7 +149,7 @@ class YiCamera(Camera): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" if not self._is_on: - return + return None stream = CameraMjpeg(self._manager.binary) await stream.open_camera(self._last_url, extra_cmd=self._extra_arguments) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index fec678ce435..004c5a70cc1 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -66,9 +66,12 @@ class YoLinkHomeMessageListener(MessageListener): device_coordinators = entry_data.device_coordinators if not device_coordinators: return - device_coordinator = device_coordinators.get(device.device_id) + device_coordinator: YoLinkCoordinator = device_coordinators.get( + device.device_id + ) if device_coordinator is None: return + device_coordinator.dev_online = True device_coordinator.async_set_updated_data(msg_data) # handling events if ( diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 110b9cb9810..e829fe08d32 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -16,3 +16,4 @@ YOLINK_EVENT = f"{DOMAIN}_event" YOLINK_OFFLINE_TIME = 32400 DEV_MODEL_WATER_METER_YS5007 = "YS5007" +DEV_MODEL_MULTI_OUTLET_YS6801 = "YS6801" diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index b7bd1d4784f..5353d5d5b8c 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.3"] + "requirements": ["yolink-api==0.4.4"] } diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 7a24ec1bd13..c999f04d90d 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -25,7 +25,7 @@ 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 DEV_MODEL_MULTI_OUTLET_YS6801, DOMAIN from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -35,7 +35,7 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription): """YoLink SwitchEntityDescription.""" exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - plug_index: int | None = None + plug_index_fn: Callable[[YoLinkDevice], int | None] = lambda _: None DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( @@ -61,36 +61,47 @@ DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( key="multi_outlet_usb_ports", translation_key="usb_ports", device_class=SwitchDeviceClass.OUTLET, - exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=0, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET + and device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801), + plug_index_fn=lambda _: 0, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_1", translation_key="plug_1", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=1, + plug_index_fn=lambda device: ( + 1 + if device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801) + else 0 + ), ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_2", translation_key="plug_2", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=2, + plug_index_fn=lambda device: ( + 2 + if device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801) + else 1 + ), ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_3", translation_key="plug_3", device_class=SwitchDeviceClass.OUTLET, - exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=3, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET + and device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801), + plug_index_fn=lambda _: 3, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_4", translation_key="plug_4", device_class=SwitchDeviceClass.OUTLET, - exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=4, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET + and device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801), + plug_index_fn=lambda _: 4, ), ) @@ -152,7 +163,8 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): def update_entity_state(self, state: dict[str, str | list[str]]) -> None: """Update HA Entity State.""" self._attr_is_on = self._get_state( - state.get("state"), self.entity_description.plug_index + state.get("state"), + self.entity_description.plug_index_fn(self.coordinator.device), ) self.async_write_ha_state() @@ -164,12 +176,14 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): ATTR_DEVICE_MULTI_OUTLET, ]: client_request = OutletRequestBuilder.set_state_request( - state, self.entity_description.plug_index + state, self.entity_description.plug_index_fn(self.coordinator.device) ) else: client_request = ClientRequest("setState", {"state": state}) await self.call_device(client_request) - self._attr_is_on = self._get_state(state, self.entity_description.plug_index) + self._attr_is_on = self._get_state( + state, self.entity_description.plug_index_fn(self.coordinator.device) + ) self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 7c0ea36a060..9a81de38388 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==1.0.1"] + "requirements": ["youless-api==2.1.0"] } diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 81cd8b384d2..ed0fc703cc4 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -42,6 +42,7 @@ async def async_setup_entry( async_add_entities( [ + WaterSensor(coordinator, device), GasSensor(coordinator, device), EnergyMeterSensor( coordinator, device, "low", SensorStateClass.TOTAL_INCREASING @@ -110,6 +111,27 @@ class YoulessBaseSensor( return super().available and self.get_sensor is not None +class WaterSensor(YoulessBaseSensor): + """The Youless Water sensor.""" + + _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS + _attr_device_class = SensorDeviceClass.WATER + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str + ) -> None: + """Instantiate a Water sensor.""" + super().__init__(coordinator, device, "water", "Water meter", "water") + self._attr_name = "Water usage" + self._attr_icon = "mdi:water" + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor for providing the value.""" + return self.coordinator.data.water_meter + + class GasSensor(YoulessBaseSensor): """The Youless gas sensor.""" diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 025ed8780e6..32b37b93eb2 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -111,7 +111,7 @@ class OAuth2FlowHandler( reason="access_not_configured", description_placeholders={"message": error}, ) - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 LOGGER.error("Unknown error occurred: %s", ex.args) return self.async_abort(reason="unknown") self._title = own_channel.snippet.title diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 58d3c1fd3f2..851af54da32 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -1,5 +1,6 @@ """Support for Zabbix.""" +from collections.abc import Callable from contextlib import suppress import json import logging @@ -24,7 +25,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import event as event_helper, state as state_helper import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( @@ -100,15 +101,17 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = zapi - def event_to_metrics(event, float_keys, string_keys): + def event_to_metrics( + event: Event, float_keys: set[str], string_keys: set[str] + ) -> list[ZabbixMetric] | None: """Add an event to the outgoing Zabbix list.""" state = event.data.get("new_state") if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): - return + return None entity_id = state.entity_id if not entities_filter(entity_id): - return + return None floats = {} strings = {} @@ -158,7 +161,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: if publish_states_host: zabbix_sender = ZabbixSender(zabbix_server=conf[CONF_HOST]) - instance = ZabbixThread(hass, zabbix_sender, event_to_metrics) + instance = ZabbixThread(zabbix_sender, event_to_metrics) instance.setup(hass) return True @@ -169,41 +172,47 @@ class ZabbixThread(threading.Thread): MAX_TRIES = 3 - def __init__(self, hass, zabbix_sender, event_to_metrics): + def __init__( + self, + zabbix_sender: ZabbixSender, + event_to_metrics: Callable[ + [Event, set[str], set[str]], list[ZabbixMetric] | None + ], + ) -> None: """Initialize the listener.""" threading.Thread.__init__(self, name="Zabbix") - self.queue = queue.Queue() + self.queue: queue.Queue = queue.Queue() self.zabbix_sender = zabbix_sender self.event_to_metrics = event_to_metrics self.write_errors = 0 self.shutdown = False - self.float_keys = set() - self.string_keys = set() + self.float_keys: set[str] = set() + self.string_keys: set[str] = set() - def setup(self, hass): + def setup(self, hass: HomeAssistant) -> None: """Set up the thread and start it.""" hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self._shutdown) self.start() _LOGGER.debug("Started publishing state changes to Zabbix") - def _shutdown(self, event): + def _shutdown(self, event: Event) -> None: """Shut down the thread.""" self.queue.put(None) self.join() @callback - def _event_listener(self, event): + def _event_listener(self, event: Event[EventStateChangedData]) -> None: """Listen for new messages on the bus and queue them for Zabbix.""" item = (time.monotonic(), event) self.queue.put(item) - def get_metrics(self): + def get_metrics(self) -> tuple[int, list[ZabbixMetric]]: """Return a batch of events formatted for writing.""" queue_seconds = QUEUE_BACKLOG_SECONDS + self.MAX_TRIES * RETRY_DELAY count = 0 - metrics = [] + metrics: list[ZabbixMetric] = [] dropped = 0 @@ -233,7 +242,7 @@ class ZabbixThread(threading.Thread): return count, metrics - def write_to_zabbix(self, metrics): + def write_to_zabbix(self, metrics: list[ZabbixMetric]) -> None: """Write preprocessed events to zabbix, with retry.""" for retry in range(self.MAX_TRIES + 1): @@ -254,7 +263,7 @@ class ZabbixThread(threading.Thread): _LOGGER.error("Write error: %s", err) self.write_errors += len(metrics) - def run(self): + def run(self) -> None: """Process incoming events.""" while not self.shutdown: count, metrics = self.get_metrics() diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index eaa06367408..4c6af57f780 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -2,8 +2,11 @@ from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any +from pyzabbix import ZabbixAPI import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -11,7 +14,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .. import zabbix @@ -88,25 +91,25 @@ def setup_platform( class ZabbixTriggerCountSensor(SensorEntity): """Get the active trigger count for all Zabbix monitored hosts.""" - def __init__(self, zapi, name="Zabbix"): + def __init__(self, zapi: ZabbixAPI, name: str | None = "Zabbix") -> None: """Initialize Zabbix sensor.""" self._name = name self._zapi = zapi - self._state = None - self._attributes = {} + self._state: int | None = None + self._attributes: dict[str, Any] = {} @property - def name(self): + def name(self) -> str | None: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" return self._state @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the units of measurement.""" return "issues" @@ -122,7 +125,7 @@ class ZabbixTriggerCountSensor(SensorEntity): self._state = len(triggers) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of the device.""" return self._attributes @@ -130,7 +133,9 @@ class ZabbixTriggerCountSensor(SensorEntity): class ZabbixSingleHostTriggerCountSensor(ZabbixTriggerCountSensor): """Get the active trigger count for a single Zabbix monitored host.""" - def __init__(self, zapi, hostid, name=None): + def __init__( + self, zapi: ZabbixAPI, hostid: list[str], name: str | None = None + ) -> None: """Initialize Zabbix sensor.""" super().__init__(zapi, name) self._hostid = hostid @@ -154,7 +159,9 @@ class ZabbixSingleHostTriggerCountSensor(ZabbixTriggerCountSensor): class ZabbixMultipleHostTriggerCountSensor(ZabbixTriggerCountSensor): """Get the active trigger count for specified Zabbix monitored hosts.""" - def __init__(self, zapi, hostids, name=None): + def __init__( + self, zapi: ZabbixAPI, hostids: list[str], name: str | None = None + ) -> None: """Initialize Zabbix sensor.""" super().__init__(zapi, name) self._hostids = hostids diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py index 5de4f3fdce3..6657bfb9edd 100644 --- a/homeassistant/components/zengge/light.py +++ b/homeassistant/components/zengge/light.py @@ -41,10 +41,7 @@ def setup_platform( """Set up the Zengge platform.""" lights = [] for address, device_config in config[CONF_DEVICES].items(): - device = {} - device["name"] = device_config[CONF_NAME] - device["address"] = address - light = ZenggeLight(device) + light = ZenggeLight(device_config[CONF_NAME], address) if light.is_valid: lights.append(light) @@ -56,22 +53,20 @@ class ZenggeLight(LightEntity): _attr_supported_color_modes = {ColorMode.HS, ColorMode.WHITE} - def __init__(self, device): + def __init__(self, name: str, address: str) -> None: """Initialize the light.""" - self._attr_name = device["name"] - self._attr_unique_id = device["address"] + self._attr_name = name + self._attr_unique_id = address self.is_valid = True - self._bulb = zengge(device["address"]) + self._bulb = zengge(address) self._white = 0 self._attr_brightness = 0 self._attr_hs_color = (0, 0) self._attr_is_on = False if self._bulb.connect() is False: self.is_valid = False - _LOGGER.error( - "Failed to connect to bulb %s, %s", device["address"], device["name"] - ) + _LOGGER.error("Failed to connect to bulb %s, %s", address, name) return @property diff --git a/homeassistant/components/zeversolar/config_flow.py b/homeassistant/components/zeversolar/config_flow.py index e4deae47c8f..1f2357c224f 100644 --- a/homeassistant/components/zeversolar/config_flow.py +++ b/homeassistant/components/zeversolar/config_flow.py @@ -48,7 +48,7 @@ class ZeverSolarConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except zeversolar.ZeverSolarTimeout: errors["base"] = "timeout_connect" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/homeassistant/components/zeversolar/diagnostics.py b/homeassistant/components/zeversolar/diagnostics.py new file mode 100644 index 00000000000..b8901a7e793 --- /dev/null +++ b/homeassistant/components/zeversolar/diagnostics.py @@ -0,0 +1,58 @@ +"""Provides diagnostics for Zeversolar.""" + +from typing import Any + +from zeversolar import ZeverSolarData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN +from .coordinator import ZeversolarCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator: ZeversolarCoordinator = hass.data[DOMAIN][config_entry.entry_id] + data: ZeverSolarData = coordinator.data + + payload: dict[str, Any] = { + "wifi_enabled": data.wifi_enabled, + "serial_or_registry_id": data.serial_or_registry_id, + "registry_key": data.registry_key, + "hardware_version": data.hardware_version, + "software_version": data.software_version, + "reported_datetime": data.reported_datetime, + "communication_status": data.communication_status.value, + "num_inverters": data.num_inverters, + "serial_number": data.serial_number, + "pac": data.pac, + "status": data.status.value, + "meter_status": data.meter_status.value, + } + + return payload + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + coordinator: ZeversolarCoordinator = hass.data[DOMAIN][entry.entry_id] + + updateInterval = ( + None + if coordinator.update_interval is None + else coordinator.update_interval.total_seconds() + ) + + return { + "name": coordinator.name, + "always_update": coordinator.always_update, + "last_update_success": coordinator.last_update_success, + "update_interval": updateInterval, + } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index de761138ce1..ed74cde47e1 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,6 +1,5 @@ """Support for Zigbee Home Automation devices.""" -import asyncio import contextlib import copy import logging @@ -238,12 +237,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> websocket_api.async_unload_api(hass) # our components don't have unload methods so no need to look at return values - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(config_entry, platform) - for platform in PLATFORMS - ) - ) + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/zha/backup.py b/homeassistant/components/zha/backup.py index 25d5a83b6a4..e31ae09eeb6 100644 --- a/homeassistant/components/zha/backup.py +++ b/homeassistant/components/zha/backup.py @@ -13,7 +13,13 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.debug("Performing coordinator backup") - zha_gateway = get_zha_gateway(hass) + try: + zha_gateway = get_zha_gateway(hass) + except ValueError: + # If ZHA config is in `configuration.yaml` and ZHA is not set up, do nothing + _LOGGER.warning("No ZHA gateway exists, skipping coordinator backup") + return + await zha_gateway.application_controller.backups.create_backup(load_devices=True) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 6ffb6d6f909..bdd2fd03ca0 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations import functools import logging +from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT from zigpy.quirks.v2 import BinarySensorMetadata import zigpy.types as t from zigpy.zcl.clusters.general import OnOff @@ -27,6 +28,7 @@ from .core.const import ( CLUSTER_HANDLER_HUE_OCCUPANCY, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_THERMOSTAT, CLUSTER_HANDLER_ZONE, ENTITY_METADATA, SIGNAL_ADD_ENTITIES, @@ -337,3 +339,43 @@ class AqaraE1CurtainMotorOpenedByHandBinarySensor(BinarySensor): _attribute_name = "hand_open" _attr_translation_key = "hand_open" _attr_entity_category = EntityCategory.DIAGNOSTIC + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossMountingModeActive(BinarySensor): + """Danfoss TRV proprietary attribute exposing whether in mounting mode.""" + + _unique_id_suffix = "mounting_mode_active" + _attribute_name = "mounting_mode_active" + _attr_translation_key: str = "mounting_mode_active" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossHeatRequired(BinarySensor): + """Danfoss TRV proprietary attribute exposing whether heat is required.""" + + _unique_id_suffix = "heat_required" + _attribute_name = "heat_required" + _attr_translation_key: str = "heat_required" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossPreheatStatus(BinarySensor): + """Danfoss TRV proprietary attribute exposing whether in pre-heating mode.""" + + _unique_id_suffix = "preheat_status" + _attribute_name = "preheat_status" + _attr_translation_key: str = "preheat_status" + _attr_entity_registry_enabled_default = False + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index ae7b0945230..8833d5c116f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -7,7 +7,7 @@ import contextlib from enum import Enum import functools import logging -from typing import TYPE_CHECKING, Any, ParamSpec, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict import zigpy.exceptions import zigpy.util @@ -51,10 +51,8 @@ _LOGGER = logging.getLogger(__name__) RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3) UNPROXIED_CLUSTER_METHODS = {"general_command"} - -_P = ParamSpec("_P") -_FuncType = Callable[_P, Awaitable[Any]] -_ReturnFuncType = Callable[_P, Coroutine[Any, Any, Any]] +type _FuncType[**_P] = Callable[_P, Awaitable[Any]] +type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, Any]] @contextlib.contextmanager @@ -75,7 +73,7 @@ def wrap_zigpy_exceptions() -> Iterator[None]: raise HomeAssistantError(message) from exc -def retry_request(func: _FuncType[_P]) -> _ReturnFuncType[_P]: +def retry_request[**_P](func: _FuncType[_P]) -> _ReturnFuncType[_P]: """Send a request with retries and wrap expected zigpy exceptions.""" @functools.wraps(func) @@ -583,7 +581,7 @@ class ZDOClusterHandler(LogMixin): self._cluster = device.device.endpoints[0] self._zha_device = device self._status = ClusterHandlerStatus.CREATED - self._unique_id = f"{str(device.ieee)}:{device.name}_ZDO" + self._unique_id = f"{device.ieee!s}:{device.name}_ZDO" self._cluster.add_listener(self) @property diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index d455ade4e66..1230549832b 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -90,7 +90,7 @@ class PumpClusterHandler(ClusterHandler): class ThermostatClusterHandler(ClusterHandler): """Thermostat cluster handler.""" - REPORT_CONFIG = ( + REPORT_CONFIG: tuple[AttrReportConfig, ...] = ( AttrReportConfig( attr=Thermostat.AttributeDefs.local_temperature.name, config=REPORT_CONFIG_CLIMATE, diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index dc8af821724..9d5d68d2c7e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -6,8 +6,13 @@ import logging from typing import TYPE_CHECKING, Any from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType -from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, XIAOMI_AQARA_VIBRATION_AQ1 +from zhaquirks.quirk_ids import ( + DANFOSS_ALLY_THERMOSTAT, + TUYA_PLUG_MANUFACTURER, + XIAOMI_AQARA_VIBRATION_AQ1, +) import zigpy.zcl +from zigpy.zcl import clusters from zigpy.zcl.clusters.closures import DoorLock from homeassistant.core import callback @@ -27,6 +32,8 @@ from ..const import ( ) from . import AttrReportConfig, ClientClusterHandler, ClusterHandler from .general import MultistateInputClusterHandler +from .homeautomation import DiagnosticClusterHandler +from .hvac import ThermostatClusterHandler, UserInterfaceClusterHandler if TYPE_CHECKING: from ..endpoint import Endpoint @@ -444,3 +451,65 @@ class SonoffPresenceSenorClusterHandler(ClusterHandler): super().__init__(cluster, endpoint) if self.cluster.endpoint.model == "SNZB-06P": self.ZCL_INIT_ATTRS = {"last_illumination_state": True} + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + clusters.hvac.Thermostat.cluster_id, DANFOSS_ALLY_THERMOSTAT +) +class DanfossThermostatClusterHandler(ThermostatClusterHandler): + """Thermostat cluster handler for the Danfoss TRV and derivatives.""" + + REPORT_CONFIG = ( + *ThermostatClusterHandler.REPORT_CONFIG, + AttrReportConfig(attr="open_window_detection", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="heat_required", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="mounting_mode_active", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="load_estimate", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="adaptation_run_status", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="preheat_status", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="preheat_time", config=REPORT_CONFIG_DEFAULT), + ) + + ZCL_INIT_ATTRS = { + **ThermostatClusterHandler.ZCL_INIT_ATTRS, + "external_open_window_detected": True, + "window_open_feature": True, + "exercise_day_of_week": True, + "exercise_trigger_time": True, + "mounting_mode_control": False, # Can change + "orientation": True, + "external_measured_room_sensor": False, # Can change + "radiator_covered": True, + "heat_available": True, + "load_balancing_enable": True, + "load_room_mean": False, # Can change + "control_algorithm_scale_factor": True, + "regulation_setpoint_offset": True, + "adaptation_run_control": True, + "adaptation_run_settings": True, + } + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + clusters.hvac.UserInterface.cluster_id, DANFOSS_ALLY_THERMOSTAT +) +class DanfossUserInterfaceClusterHandler(UserInterfaceClusterHandler): + """Interface cluster handler for the Danfoss TRV and derivatives.""" + + ZCL_INIT_ATTRS = { + **UserInterfaceClusterHandler.ZCL_INIT_ATTRS, + "viewing_direction": True, + } + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + clusters.homeautomation.Diagnostic.cluster_id, DANFOSS_ALLY_THERMOSTAT +) +class DanfossDiagnosticClusterHandler(DiagnosticClusterHandler): + """Diagnostic cluster handler for the Danfoss TRV and derivatives.""" + + REPORT_CONFIG = ( + *DiagnosticClusterHandler.REPORT_CONFIG, + AttrReportConfig(attr="sw_error_code", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="motor_step_counter", config=REPORT_CONFIG_DEFAULT), + ) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 74110d390ed..2359fe0a1c3 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -245,7 +245,7 @@ ZHA_CONFIG_SCHEMAS = { ZHA_ALARM_OPTIONS: CONF_ZHA_ALARM_SCHEMA, } -_ControllerClsType = type[zigpy.application.ControllerApplication] +type _ControllerClsType = type[zigpy.application.ControllerApplication] class RadioType(enum.Enum): diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index b8e15024811..d20fb7f2a38 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -3,12 +3,10 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, TypeVar - -_TypeT = TypeVar("_TypeT", bound=type[Any]) +from typing import Any -class DictRegistry(dict[int | str, _TypeT]): +class DictRegistry[_TypeT: type[Any]](dict[int | str, _TypeT]): """Dict Registry of items.""" def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: @@ -22,7 +20,9 @@ class DictRegistry(dict[int | str, _TypeT]): return decorator -class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): +class NestedDictRegistry[_TypeT: type[Any]]( + dict[int | str, dict[int | str | None, _TypeT]] +): """Dict Registry of multiple items per key.""" def register( @@ -43,7 +43,9 @@ class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): class SetRegistry(set[int | str]): """Set Registry of items.""" - def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: + def register[_TypeT: type[Any]]( + self, name: int | str + ) -> Callable[[_TypeT], _TypeT]: """Return decorator to register item with a specific name.""" def decorator(cluster_handler: _TypeT) -> _TypeT: diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index e2c725ee529..163674d614c 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -619,7 +619,7 @@ class ZHADevice(LogMixin): for endpoint in self._endpoints.values(): try: await endpoint.async_initialize(from_cache) - except Exception: # pylint: disable=broad-exception-caught + except Exception: # noqa: BLE001 self.debug("Failed to initialize endpoint", exc_info=True) self.debug("power source: %s", self.power_source) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index 1bb1750b6ac..32483a3bc53 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Awaitable, Callable import functools import logging -from typing import TYPE_CHECKING, Any, Final, TypeVar +from typing import TYPE_CHECKING, Any, Final from homeassistant.const import Platform from homeassistant.core import callback @@ -29,7 +29,6 @@ ATTR_IN_CLUSTERS: Final[str] = "input_clusters" ATTR_OUT_CLUSTERS: Final[str] = "output_clusters" _LOGGER = logging.getLogger(__name__) -CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) class Endpoint: @@ -44,7 +43,7 @@ class Endpoint: self._all_cluster_handlers: dict[str, ClusterHandler] = {} self._claimed_cluster_handlers: dict[str, ClusterHandler] = {} self._client_cluster_handlers: dict[str, ClientClusterHandler] = {} - self._unique_id: str = f"{str(device.ieee)}-{zigpy_endpoint.endpoint_id}" + self._unique_id: str = f"{device.ieee!s}-{zigpy_endpoint.endpoint_id}" @property def device(self) -> ZHADevice: @@ -209,7 +208,7 @@ class Endpoint: def async_new_entity( self, platform: Platform, - entity_class: CALLABLE_T, + entity_class: type, unique_id: str, cluster_handlers: list[ClusterHandler], **kwargs: Any, diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 4c41909f660..8b8826e2648 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -96,7 +96,7 @@ if TYPE_CHECKING: from ..entity import ZhaEntity from .cluster_handlers import ClusterHandler - _LogFilterType = Filter | Callable[[LogRecord], bool] + type _LogFilterType = Filter | Callable[[LogRecord], bool] _LOGGER = logging.getLogger(__name__) @@ -269,7 +269,7 @@ class ZHAGateway: delta_msg = "not known" if zha_device.last_seen is not None: delta = round(time.time() - zha_device.last_seen) - delta_msg = f"{str(timedelta(seconds=delta))} ago" + delta_msg = f"{timedelta(seconds=delta)!s} ago" _LOGGER.debug( ( "[%s](%s) restored as '%s', last seen: %s," @@ -296,7 +296,7 @@ class ZHAGateway: @property def radio_concurrency(self) -> int: """Maximum configured radio concurrency.""" - return self.application_controller._concurrent_requests_semaphore.max_value # pylint: disable=protected-access + return self.application_controller._concurrent_requests_semaphore.max_value # noqa: SLF001 async def async_fetch_updated_state_mains(self) -> None: """Fetch updated state for mains powered devices.""" @@ -470,7 +470,7 @@ class ZHAGateway: if zha_device is not None: device_info = zha_device.zha_device_info zha_device.async_cleanup_handles() - async_dispatcher_send(self.hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") + async_dispatcher_send(self.hass, f"{SIGNAL_REMOVE}_{zha_device.ieee!s}") self.hass.async_create_task( self._async_remove_device(zha_device, entity_refs), "ZHAGateway._async_remove_device", diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 3f8090f4080..2508dd34fd4 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -14,7 +14,7 @@ from dataclasses import dataclass import enum import logging import re -from typing import TYPE_CHECKING, Any, TypeVar, overload +from typing import TYPE_CHECKING, Any, overload import voluptuous as vol import zigpy.exceptions @@ -62,7 +62,6 @@ if TYPE_CHECKING: from .device import ZHADevice from .gateway import ZHAGateway -_T = TypeVar("_T") _LOGGER = logging.getLogger(__name__) @@ -98,7 +97,7 @@ async def safe_read( only_cache=only_cache, manufacturer=manufacturer, ) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 return {} return result @@ -228,7 +227,7 @@ def async_is_bindable_target(source_zha_device, target_zha_device): @callback -def async_get_zha_config_value( +def async_get_zha_config_value[_T]( config_entry: ConfigEntry, section: str, config_key: str, default: _T ) -> _T: """Get the value for the specified configuration from the ZHA config entry.""" diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index b9110a8dcde..9d23b77efaa 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -6,7 +6,7 @@ import collections from collections.abc import Callable import dataclasses from operator import attrgetter -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING import attr from zigpy import zcl @@ -23,9 +23,6 @@ if TYPE_CHECKING: from .cluster_handlers import ClientClusterHandler, ClusterHandler -_ZhaEntityT = TypeVar("_ZhaEntityT", bound=type["ZhaEntity"]) -_ZhaGroupEntityT = TypeVar("_ZhaGroupEntityT", bound=type["ZhaGroupEntity"]) - GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] IKEA_AIR_PURIFIER_CLUSTER = 0xFC7D @@ -387,7 +384,7 @@ class ZHAEntityRegistry: """Match a ZHA group to a ZHA Entity class.""" return self._group_registry.get(component) - def strict_match( + def strict_match[_ZhaEntityT: type[ZhaEntity]]( self, component: Platform, cluster_handler_names: set[str] | str | None = None, @@ -418,7 +415,7 @@ class ZHAEntityRegistry: return decorator - def multipass_match( + def multipass_match[_ZhaEntityT: type[ZhaEntity]]( self, component: Platform, cluster_handler_names: set[str] | str | None = None, @@ -453,7 +450,7 @@ class ZHAEntityRegistry: return decorator - def config_diagnostic_match( + def config_diagnostic_match[_ZhaEntityT: type[ZhaEntity]]( self, component: Platform, cluster_handler_names: set[str] | str | None = None, @@ -488,7 +485,7 @@ class ZHAEntityRegistry: return decorator - def group_match( + def group_match[_ZhaGroupEntityT: type[ZhaGroupEntity]]( self, component: Platform ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: """Decorate a group match rule.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 5e729a74f0d..6fd08de889f 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1136,13 +1136,13 @@ class LightGroup(BaseLight, ZhaGroupEntity): # time of any members. if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS: self._DEFAULT_MIN_TRANSITION_TIME = ( - MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME + MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME # noqa: SLF001 ) # Check all group members to see if they support execute_if_off. # If at least one member has a color cluster and doesn't support it, # it's not used. - for endpoint in member.device._endpoints.values(): + for endpoint in member.device._endpoints.values(): # noqa: SLF001 for cluster_handler in endpoint.all_cluster_handlers.values(): if ( cluster_handler.name == CLUSTER_HANDLER_COLOR diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 9a0ca62542e..f517742f16f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,15 +21,15 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.38.4", + "bellows==0.39.1", "pyserial==3.5", - "zha-quirks==0.0.115", + "zha-quirks==0.0.116", "zigpy-deconz==0.23.1", - "zigpy==0.64.0", + "zigpy==0.64.1", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", - "universal-silabs-flasher==0.0.18", + "universal-silabs-flasher==0.0.20", "pyserial-asyncio-fast==0.11" ], "usb": [ @@ -132,6 +132,14 @@ { "type": "_slzb-06._tcp.local.", "name": "slzb-06*" + }, + { + "type": "_xzg._tcp.local.", + "name": "xzg*" + }, + { + "type": "_czc._tcp.local.", + "name": "czc*" } ] } diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 8af2fe178c8..9320b4494a4 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -6,12 +6,19 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self +from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT from zigpy.quirks.v2 import NumberMetadata from zigpy.zcl.clusters.hvac import Thermostat from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature +from homeassistant.const import ( + EntityCategory, + Platform, + UnitOfMass, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -1073,3 +1080,74 @@ class MinHeatSetpointLimit(ZCLHeatSetpointLimitEntity): _attr_entity_category = EntityCategory.CONFIG _max_source = Thermostat.AttributeDefs.max_heat_setpoint_limit.name + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossExerciseTriggerTime(ZHANumberConfigurationEntity): + """Danfoss proprietary attribute to set the time to exercise the valve.""" + + _unique_id_suffix = "exercise_trigger_time" + _attribute_name: str = "exercise_trigger_time" + _attr_translation_key: str = "exercise_trigger_time" + _attr_native_min_value: int = 0 + _attr_native_max_value: int = 1439 + _attr_mode: NumberMode = NumberMode.BOX + _attr_native_unit_of_measurement: str = UnitOfTime.MINUTES + _attr_icon: str = "mdi:clock" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossExternalMeasuredRoomSensor(ZCLTemperatureEntity): + """Danfoss proprietary attribute to communicate the value of the external temperature sensor.""" + + _unique_id_suffix = "external_measured_room_sensor" + _attribute_name: str = "external_measured_room_sensor" + _attr_translation_key: str = "external_temperature_sensor" + _attr_native_min_value: float = -80 + _attr_native_max_value: float = 35 + _attr_icon: str = "mdi:thermometer" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossLoadRoomMean(ZHANumberConfigurationEntity): + """Danfoss proprietary attribute to set a value for the load.""" + + _unique_id_suffix = "load_room_mean" + _attribute_name: str = "load_room_mean" + _attr_translation_key: str = "load_room_mean" + _attr_native_min_value: int = -8000 + _attr_native_max_value: int = 2000 + _attr_mode: NumberMode = NumberMode.BOX + _attr_icon: str = "mdi:scale-balance" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossRegulationSetpointOffset(ZHANumberConfigurationEntity): + """Danfoss proprietary attribute to set the regulation setpoint offset.""" + + _unique_id_suffix = "regulation_setpoint_offset" + _attribute_name: str = "regulation_setpoint_offset" + _attr_translation_key: str = "regulation_setpoint_offset" + _attr_mode: NumberMode = NumberMode.BOX + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = "mdi:thermostat" + _attr_native_min_value: float = -2.5 + _attr_native_max_value: float = 2.5 + _attr_native_step: float = 0.1 + _attr_multiplier = 1 / 10 diff --git a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py index 4ee10c7bb93..3cd22c99ec7 100644 --- a/homeassistant/components/zha/repairs/wrong_silabs_firmware.py +++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py @@ -85,7 +85,7 @@ async def probe_silabs_firmware_type( try: await flasher.probe_app_type() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 _LOGGER.debug("Failed to probe application type", exc_info=True) return flasher.app_type diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 98d5debd999..026a85fbfdc 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -7,7 +7,12 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self -from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF +from zhaquirks.danfoss import thermostat as danfoss_thermostat +from zhaquirks.quirk_ids import ( + DANFOSS_ALLY_THERMOSTAT, + 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 @@ -29,6 +34,7 @@ from .core.const import ( CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_THERMOSTAT, ENTITY_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -688,3 +694,105 @@ class KeypadLockout(ZCLEnumSelectEntity): _attribute_name: str = "keypad_lockout" _enum = KeypadLockoutEnum _attr_translation_key: str = "keypad_lockout" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossExerciseDayOfTheWeek(ZCLEnumSelectEntity): + """Danfoss proprietary attribute for setting the day of the week for exercising.""" + + _unique_id_suffix = "exercise_day_of_week" + _attribute_name = "exercise_day_of_week" + _attr_translation_key: str = "exercise_day_of_week" + _enum = danfoss_thermostat.DanfossExerciseDayOfTheWeekEnum + _attr_icon: str = "mdi:wrench-clock" + + +class DanfossOrientationEnum(types.enum8): + """Vertical or Horizontal.""" + + Horizontal = 0x00 + Vertical = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossOrientation(ZCLEnumSelectEntity): + """Danfoss proprietary attribute for setting the orientation of the valve. + + Needed for biasing the internal temperature sensor. + This is implemented as an enum here, but is a boolean on the device. + """ + + _unique_id_suffix = "orientation" + _attribute_name = "orientation" + _attr_translation_key: str = "valve_orientation" + _enum = DanfossOrientationEnum + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossAdaptationRunControl(ZCLEnumSelectEntity): + """Danfoss proprietary attribute for controlling the current adaptation run.""" + + _unique_id_suffix = "adaptation_run_control" + _attribute_name = "adaptation_run_control" + _attr_translation_key: str = "adaptation_run_command" + _enum = danfoss_thermostat.DanfossAdaptationRunControlEnum + + +class DanfossControlAlgorithmScaleFactorEnum(types.enum8): + """The time scale factor for changing the opening of the valve. + + Not all values are given, therefore there are some extrapolated values with a margin of error of about 5 minutes. + This is implemented as an enum here, but is a number on the device. + """ + + quick_5min = 0x01 + + quick_10min = 0x02 # extrapolated + quick_15min = 0x03 # extrapolated + quick_25min = 0x04 # extrapolated + + moderate_30min = 0x05 + + moderate_40min = 0x06 # extrapolated + moderate_50min = 0x07 # extrapolated + moderate_60min = 0x08 # extrapolated + moderate_70min = 0x09 # extrapolated + + slow_80min = 0x0A + + quick_open_disabled = 0x11 # not sure what it does; also requires lower 4 bits to be in [1, 10] I assume + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossControlAlgorithmScaleFactor(ZCLEnumSelectEntity): + """Danfoss proprietary attribute for setting the scale factor of the setpoint filter time constant.""" + + _unique_id_suffix = "control_algorithm_scale_factor" + _attribute_name = "control_algorithm_scale_factor" + _attr_translation_key: str = "setpoint_response_time" + _enum = DanfossControlAlgorithmScaleFactorEnum + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="thermostat_ui", + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossViewingDirection(ZCLEnumSelectEntity): + """Danfoss proprietary attribute for setting the viewing direction of the screen.""" + + _unique_id_suffix = "viewing_direction" + _attribute_name = "viewing_direction" + _attr_translation_key: str = "viewing_direction" + _enum = danfoss_thermostat.DanfossViewingDirectionEnum diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index e8507a96e2c..99d950dc06a 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -12,6 +12,8 @@ import numbers import random from typing import TYPE_CHECKING, Any, Self +from zhaquirks.danfoss import thermostat as danfoss_thermostat +from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT from zigpy import types from zigpy.quirks.v2 import ZCLEnumMetadata, ZCLSensorMetadata from zigpy.state import Counter, State @@ -376,7 +378,7 @@ class EnumSensor(Sensor): def _init_from_quirks_metadata(self, entity_metadata: ZCLEnumMetadata) -> None: """Init this entity from the quirks metadata.""" - ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access + ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # noqa: SLF001 self._attribute_name = entity_metadata.attribute_name self._enum = entity_metadata.enum @@ -1499,3 +1501,129 @@ class AqaraCurtainHookStateSensor(EnumSensor): _attr_translation_key: str = "hooks_state" _attr_icon: str = "mdi:hook" _attr_entity_category = EntityCategory.DIAGNOSTIC + + +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class BitMapSensor(Sensor): + """A sensor with only state attributes. + + The sensor value will be an aggregate of the state attributes. + """ + + _bitmap: types.bitmap8 | types.bitmap16 + + def formatter(self, _value: int) -> str: + """Summary of all attributes.""" + binary_state_attributes = [ + key for (key, elem) in self.extra_state_attributes.items() if elem + ] + + return "something" if binary_state_attributes else "nothing" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Bitmap.""" + value = self._cluster_handler.cluster.get(self._attribute_name) + + state_attr = {} + + for bit in list(self._bitmap): + if value is None: + state_attr[bit.name] = False + else: + state_attr[bit.name] = bit in self._bitmap(value) + + return state_attr + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossOpenWindowDetection(EnumSensor): + """Danfoss proprietary attribute. + + Sensor that displays whether the TRV detects an open window using the temperature sensor. + """ + + _unique_id_suffix = "open_window_detection" + _attribute_name = "open_window_detection" + _attr_translation_key: str = "open_window_detected" + _attr_icon: str = "mdi:window-open" + _enum = danfoss_thermostat.DanfossOpenWindowDetectionEnum + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossLoadEstimate(Sensor): + """Danfoss proprietary attribute for communicating its estimate of the radiator load.""" + + _unique_id_suffix = "load_estimate" + _attribute_name = "load_estimate" + _attr_translation_key: str = "load_estimate" + _attr_icon: str = "mdi:scale-balance" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossAdaptationRunStatus(BitMapSensor): + """Danfoss proprietary attribute for showing the status of the adaptation run.""" + + _unique_id_suffix = "adaptation_run_status" + _attribute_name = "adaptation_run_status" + _attr_translation_key: str = "adaptation_run_status" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _bitmap = danfoss_thermostat.DanfossAdaptationRunStatusBitmap + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossPreheatTime(Sensor): + """Danfoss proprietary attribute for communicating the time when it starts pre-heating.""" + + _unique_id_suffix = "preheat_time" + _attribute_name = "preheat_time" + _attr_translation_key: str = "preheat_time" + _attr_icon: str = "mdi:radiator" + _attr_entity_registry_enabled_default = False + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="diagnostic", + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossSoftwareErrorCode(BitMapSensor): + """Danfoss proprietary attribute for communicating the error code.""" + + _unique_id_suffix = "sw_error_code" + _attribute_name = "sw_error_code" + _attr_translation_key: str = "software_error" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _bitmap = danfoss_thermostat.DanfossSoftwareErrorCodeBitmap + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="diagnostic", + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class DanfossMotorStepCounter(Sensor): + """Danfoss proprietary attribute for communicating the motor step counter.""" + + _unique_id_suffix = "motor_step_counter" + _attribute_name = "motor_step_counter" + _attr_translation_key: str = "motor_stepcount" + _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 3db54712dee..f25fdf1ebe4 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -249,6 +249,13 @@ "face_4": "With face 4 activated", "face_5": "With face 5 activated", "face_6": "With face 6 activated" + }, + "extra_fields": { + "color": "Color hue", + "duration": "Duration in seconds", + "effect_type": "Effect type", + "led_number": "LED number", + "level": "Brightness (%)" } }, "services": { @@ -569,6 +576,15 @@ }, "hand_open": { "name": "Opened by hand" + }, + "mounting_mode_active": { + "name": "Mounting mode active" + }, + "heat_required": { + "name": "Heat required" + }, + "preheat_status": { + "name": "Pre-heat status" } }, "button": { @@ -739,6 +755,18 @@ }, "min_heat_setpoint_limit": { "name": "Min heat setpoint limit" + }, + "exercise_trigger_time": { + "name": "Exercise start time" + }, + "external_temperature_sensor": { + "name": "External temperature sensor" + }, + "load_room_mean": { + "name": "Load room mean" + }, + "regulation_setpoint_offset": { + "name": "Regulation setpoint offset" } }, "select": { @@ -810,6 +838,21 @@ }, "keypad_lockout": { "name": "Keypad lockout" + }, + "exercise_day_of_week": { + "name": "Exercise day of the week" + }, + "valve_orientation": { + "name": "Valve orientation" + }, + "adaptation_run_command": { + "name": "Adaptation run command" + }, + "viewing_direction": { + "name": "Viewing direction" + }, + "setpoint_response_time": { + "name": "Setpoint response time" } }, "sensor": { @@ -908,6 +951,78 @@ }, "hooks_state": { "name": "Hooks state" + }, + "open_window_detected": { + "name": "Open window detected" + }, + "load_estimate": { + "name": "Load estimate" + }, + "adaptation_run_status": { + "name": "Adaptation run status", + "state": { + "nothing": "Idle", + "something": "State" + }, + "state_attributes": { + "in_progress": { + "name": "In progress" + }, + "run_successful": { + "name": "Run successful" + }, + "valve_characteristic_lost": { + "name": "Valve characteristic lost" + } + } + }, + "preheat_time": { + "name": "Pre-heat time" + }, + "software_error": { + "name": "Software error", + "state": { + "nothing": "Good", + "something": "Error" + }, + "state_attributes": { + "top_pcb_sensor_error": { + "name": "Top PCB sensor error" + }, + "side_pcb_sensor_error": { + "name": "Side PCB sensor error" + }, + "non_volatile_memory_error": { + "name": "Non-volatile memory error" + }, + "unknown_hw_error": { + "name": "Unknown HW error" + }, + "motor_error": { + "name": "Motor error" + }, + "invalid_internal_communication": { + "name": "Invalid internal communication" + }, + "invalid_clock_information": { + "name": "Invalid clock information" + }, + "radio_communication_error": { + "name": "Radio communication error" + }, + "encoder_jammed": { + "name": "Encoder jammed" + }, + "low_battery": { + "name": "Low battery" + }, + "critical_low_battery": { + "name": "Critical low battery" + } + } + }, + "motor_stepcount": { + "name": "Motor stepcount" } }, "switch": { @@ -991,6 +1106,27 @@ }, "buzzer_manual_alarm": { "name": "Buzzer manual alarm" + }, + "external_window_sensor": { + "name": "External window sensor" + }, + "use_internal_window_detection": { + "name": "Use internal window detection" + }, + "mounting_mode": { + "name": "Mounting mode" + }, + "prioritize_external_temperature_sensor": { + "name": "Prioritize external temperature sensor" + }, + "heat_available": { + "name": "Heat available" + }, + "use_load_balancing": { + "name": "Use load balancing" + }, + "adaptation_run_enabled": { + "name": "Adaptation run enabled" } } } diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 14da2344cd4..f07d3d4c8e3 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -6,7 +6,7 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self -from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF +from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT, TUYA_PLUG_ONOFF from zigpy.quirks.v2 import SwitchMetadata from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode from zigpy.zcl.clusters.general import OnOff @@ -25,6 +25,7 @@ from .core.const import ( CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, + CLUSTER_HANDLER_THERMOSTAT, ENTITY_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, @@ -716,3 +717,95 @@ class AqaraE1CurtainMotorHooksLockedSwitch(ZHASwitchConfigurationEntity): _unique_id_suffix = "hooks_lock" _attribute_name = "hooks_lock" _attr_translation_key = "hooks_locked" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossExternalOpenWindowDetected(ZHASwitchConfigurationEntity): + """Danfoss proprietary attribute for communicating an open window.""" + + _unique_id_suffix = "external_open_window_detected" + _attribute_name: str = "external_open_window_detected" + _attr_translation_key: str = "external_window_sensor" + _attr_icon: str = "mdi:window-open" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossWindowOpenFeature(ZHASwitchConfigurationEntity): + """Danfoss proprietary attribute enabling open window detection.""" + + _unique_id_suffix = "window_open_feature" + _attribute_name: str = "window_open_feature" + _attr_translation_key: str = "use_internal_window_detection" + _attr_icon: str = "mdi:window-open" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossMountingModeControl(ZHASwitchConfigurationEntity): + """Danfoss proprietary attribute for switching to mounting mode.""" + + _unique_id_suffix = "mounting_mode_control" + _attribute_name: str = "mounting_mode_control" + _attr_translation_key: str = "mounting_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossRadiatorCovered(ZHASwitchConfigurationEntity): + """Danfoss proprietary attribute for communicating full usage of the external temperature sensor.""" + + _unique_id_suffix = "radiator_covered" + _attribute_name: str = "radiator_covered" + _attr_translation_key: str = "prioritize_external_temperature_sensor" + _attr_icon: str = "mdi:thermometer" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossHeatAvailable(ZHASwitchConfigurationEntity): + """Danfoss proprietary attribute for communicating available heat.""" + + _unique_id_suffix = "heat_available" + _attribute_name: str = "heat_available" + _attr_translation_key: str = "heat_available" + _attr_icon: str = "mdi:water-boiler" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossLoadBalancingEnable(ZHASwitchConfigurationEntity): + """Danfoss proprietary attribute for enabling load balancing.""" + + _unique_id_suffix = "load_balancing_enable" + _attribute_name: str = "load_balancing_enable" + _attr_translation_key: str = "use_load_balancing" + _attr_icon: str = "mdi:scale-balance" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + quirk_ids={DANFOSS_ALLY_THERMOSTAT}, +) +class DanfossAdaptationRunSettings(ZHASwitchConfigurationEntity): + """Danfoss proprietary attribute for enabling daily adaptation run. + + Actually a bitmap, but only the first bit is used. + """ + + _unique_id_suffix = "adaptation_run_settings" + _attribute_name: str = "adaptation_run_settings" + _attr_translation_key: str = "adaptation_run_enabled" diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 758c3715980..1a51a06243e 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast import voluptuous as vol import zigpy.backups @@ -118,11 +118,8 @@ IEEE_SERVICE = "ieee_based_service" IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) -# typing typevar -_T = TypeVar("_T") - -def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: +def _ensure_list_if_present[_T](value: _T | None) -> list[_T] | list[Any] | None: """Wrap value in list if it is provided and not one.""" if value is None: return None @@ -446,7 +443,7 @@ async def websocket_get_device( if not (zha_device := zha_gateway.devices.get(ieee)): connection.send_message( websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" + msg[ID], websocket_api.ERR_NOT_FOUND, "ZHA Device not found" ) ) return @@ -473,7 +470,7 @@ async def websocket_get_group( if not (zha_group := zha_gateway.groups.get(group_id)): connection.send_message( websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + msg[ID], websocket_api.ERR_NOT_FOUND, "ZHA Group not found" ) ) return @@ -551,7 +548,7 @@ async def websocket_add_group_members( if not (zha_group := zha_gateway.groups.get(group_id)): connection.send_message( websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + msg[ID], websocket_api.ERR_NOT_FOUND, "ZHA Group not found" ) ) return @@ -581,7 +578,7 @@ async def websocket_remove_group_members( if not (zha_group := zha_gateway.groups.get(group_id)): connection.send_message( websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + msg[ID], websocket_api.ERR_NOT_FOUND, "ZHA Group not found" ) ) return @@ -1217,7 +1214,7 @@ async def websocket_restore_network_backup( try: await application_controller.backups.restore_backup(backup) except ValueError as err: - connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) + connection.send_error(msg[ID], websocket_api.ERR_INVALID_FORMAT, str(err)) else: connection.send_result(msg[ID]) @@ -1314,7 +1311,7 @@ def async_load_api(hass: HomeAssistant) -> None: manufacturer=manufacturer, ) else: - raise ValueError(f"Device with IEEE {str(ieee)} not found") + raise ValueError(f"Device with IEEE {ieee!s} not found") _LOGGER.debug( ( @@ -1394,7 +1391,7 @@ def async_load_api(hass: HomeAssistant) -> None: manufacturer, ) else: - raise ValueError(f"Device with IEEE {str(ieee)} not found") + raise ValueError(f"Device with IEEE {ieee!s} not found") async_register_admin_service( hass, diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 2473200102d..0fef9961679 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -302,7 +302,7 @@ def _home_conf(hass: HomeAssistant) -> dict: CONF_NAME: hass.config.location_name, CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude, - CONF_RADIUS: DEFAULT_RADIUS, + CONF_RADIUS: hass.config.radius, CONF_ICON: ICON_HOME, CONF_PASSIVE: False, } @@ -363,7 +363,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from storage.""" zone = cls(config) zone.editable = True - zone._generate_attrs() + zone._generate_attrs() # noqa: SLF001 return zone @classmethod @@ -371,7 +371,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from yaml.""" zone = cls(config) zone.editable = False - zone._generate_attrs() + zone._generate_attrs() # noqa: SLF001 return zone @property diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 090a5ecfdf8..dedae10400f 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -4,11 +4,11 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Coroutine from contextlib import suppress import logging from typing import Any +from awesomeversion import AwesomeVersion from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, RemoveNodeReason from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion @@ -79,6 +79,8 @@ from .const import ( ATTR_VALUE, ATTR_VALUE_RAW, CONF_ADDON_DEVICE, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, CONF_ADDON_NETWORK_KEY, CONF_ADDON_S0_LEGACY_KEY, CONF_ADDON_S2_ACCESS_CONTROL_KEY, @@ -86,6 +88,8 @@ from .const import ( CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_DATA_COLLECTION_OPTED_IN, CONF_INTEGRATION_CREATED_ADDON, + CONF_LR_S2_ACCESS_CONTROL_KEY, + CONF_LR_S2_AUTHENTICATED_KEY, CONF_NETWORK_KEY, CONF_S0_LEGACY_KEY, CONF_S2_ACCESS_CONTROL_KEY, @@ -98,6 +102,7 @@ from .const import ( EVENT_DEVICE_ADDED_TO_REGISTRY, LIB_LOGGER, LOGGER, + LR_ADDON_VERSION, USER_AGENT, ZWAVE_JS_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_NOTIFICATION_EVENT, @@ -137,6 +142,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.config_entries.async_update_entry( entry, unique_id=str(entry.unique_id) ) + + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + services = ZWaveServices(hass, ent_reg, dev_reg) + services.async_register() + return True @@ -175,20 +186,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_delete_issue(hass, DOMAIN, "invalid_server_version") LOGGER.info("Connected to Zwave JS Server") - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - services = ZWaveServices(hass, ent_reg, dev_reg) - services.async_register() - # Set up websocket API async_register_api(hass) + entry.runtime_data = {} # Create a task to allow the config entry to be unloaded before the driver is ready. # Unloading the config entry is needed if the client listen task errors. start_client_task = hass.async_create_task(start_client(hass, entry, client)) - hass.data[DOMAIN].setdefault(entry.entry_id, {})[DATA_START_CLIENT_TASK] = ( - start_client_task - ) + entry.runtime_data[DATA_START_CLIENT_TASK] = start_client_task return True @@ -197,9 +202,8 @@ async def start_client( hass: HomeAssistant, entry: ConfigEntry, client: ZwaveClient ) -> None: """Start listening with the client.""" - entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {}) - entry_hass_data[DATA_CLIENT] = client - driver_events = entry_hass_data[DATA_DRIVER_EVENTS] = DriverEvents(hass, entry) + entry.runtime_data[DATA_CLIENT] = client + driver_events = entry.runtime_data[DATA_DRIVER_EVENTS] = DriverEvents(hass, entry) async def handle_ha_shutdown(event: Event) -> None: """Handle HA shutdown.""" @@ -208,7 +212,7 @@ async def start_client( listen_task = asyncio.create_task( client_listen(hass, entry, client, driver_events.ready) ) - entry_hass_data[DATA_CLIENT_LISTEN_TASK] = listen_task + entry.runtime_data[DATA_CLIENT_LISTEN_TASK] = listen_task entry.async_on_unload( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) ) @@ -327,8 +331,8 @@ class DriverEvents: """Set up platform if needed.""" if platform not in self.platform_setup_tasks: self.platform_setup_tasks[platform] = self.hass.async_create_task( - self.hass.config_entries.async_forward_entry_setup( - self.config_entry, platform + self.hass.config_entries.async_forward_entry_setups( + self.config_entry, [platform] ) ) await self.platform_setup_tasks[platform] @@ -921,7 +925,7 @@ async def client_listen( should_reload = False except BaseZwaveJSServerError as err: LOGGER.error("Failed to listen: %s", err) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) @@ -935,11 +939,10 @@ async def client_listen( async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: """Disconnect client.""" - data = hass.data[DOMAIN][entry.entry_id] - client: ZwaveClient = data[DATA_CLIENT] - listen_task: asyncio.Task = data[DATA_CLIENT_LISTEN_TASK] - start_client_task: asyncio.Task = data[DATA_START_CLIENT_TASK] - driver_events: DriverEvents = data[DATA_DRIVER_EVENTS] + client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + listen_task: asyncio.Task = entry.runtime_data[DATA_CLIENT_LISTEN_TASK] + start_client_task: asyncio.Task = entry.runtime_data[DATA_START_CLIENT_TASK] + driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] listen_task.cancel() start_client_task.cancel() platform_setup_tasks = driver_events.platform_setup_tasks.values() @@ -959,25 +962,20 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - info = hass.data[DOMAIN][entry.entry_id] - client: ZwaveClient = info[DATA_CLIENT] - driver_events: DriverEvents = info[DATA_DRIVER_EVENTS] - - tasks: list[Coroutine] = [ - hass.config_entries.async_forward_entry_unload(entry, platform) + client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] + platforms = [ + platform for platform, task in driver_events.platform_setup_tasks.items() if not task.cancel() ] - - unload_ok = all(await asyncio.gather(*tasks)) if tasks else True + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if client.connected and client.driver: await async_disable_server_logging_if_needed(hass, entry, client.driver) - if DATA_CLIENT_LISTEN_TASK in info: + if DATA_CLIENT_LISTEN_TASK in entry.runtime_data: await disconnect_client(hass, entry) - hass.data[DOMAIN].pop(entry.entry_id) - if entry.data.get(CONF_USE_ADDON) and entry.disabled_by: addon_manager: AddonManager = get_addon_manager(hass) LOGGER.debug("Stopping Z-Wave JS add-on") @@ -1016,8 +1014,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" - entry_hass_data = hass.data[DOMAIN][config_entry.entry_id] - client: ZwaveClient = entry_hass_data[DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] # Driver may not be ready yet so we can't allow users to remove a device since # we need to check if the device is still known to the controller @@ -1037,7 +1034,7 @@ async def async_remove_config_entry_device( ): return False - controller_events: ControllerEvents = entry_hass_data[ + controller_events: ControllerEvents = config_entry.runtime_data[ DATA_DRIVER_EVENTS ].controller_events controller_events.registered_unique_ids.pop(device_entry.id, None) @@ -1061,8 +1058,9 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> s2_access_control_key: str = entry.data.get(CONF_S2_ACCESS_CONTROL_KEY, "") s2_authenticated_key: str = entry.data.get(CONF_S2_AUTHENTICATED_KEY, "") s2_unauthenticated_key: str = entry.data.get(CONF_S2_UNAUTHENTICATED_KEY, "") + lr_s2_access_control_key: str = entry.data.get(CONF_LR_S2_ACCESS_CONTROL_KEY, "") + lr_s2_authenticated_key: str = entry.data.get(CONF_LR_S2_AUTHENTICATED_KEY, "") addon_state = addon_info.state - addon_config = { CONF_ADDON_DEVICE: usb_path, CONF_ADDON_S0_LEGACY_KEY: s0_legacy_key, @@ -1070,6 +1068,9 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> CONF_ADDON_S2_AUTHENTICATED_KEY: s2_authenticated_key, CONF_ADDON_S2_UNAUTHENTICATED_KEY: s2_unauthenticated_key, } + if addon_info.version and AwesomeVersion(addon_info.version) >= LR_ADDON_VERSION: + addon_config[CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY] = lr_s2_access_control_key + addon_config[CONF_ADDON_LR_S2_AUTHENTICATED_KEY] = lr_s2_authenticated_key if addon_state == AddonState.NOT_INSTALLED: addon_manager.async_schedule_install_setup_addon( @@ -1109,6 +1110,21 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> updates[CONF_S2_AUTHENTICATED_KEY] = addon_s2_authenticated_key if s2_unauthenticated_key != addon_s2_unauthenticated_key: updates[CONF_S2_UNAUTHENTICATED_KEY] = addon_s2_unauthenticated_key + + if addon_info.version and AwesomeVersion(addon_info.version) >= AwesomeVersion( + LR_ADDON_VERSION + ): + addon_lr_s2_access_control_key = addon_options.get( + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, "" + ) + addon_lr_s2_authenticated_key = addon_options.get( + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, "" + ) + if lr_s2_access_control_key != addon_lr_s2_access_control_key: + updates[CONF_LR_S2_ACCESS_CONTROL_KEY] = addon_lr_s2_access_control_key + if lr_s2_authenticated_key != addon_lr_s2_authenticated_key: + updates[CONF_LR_S2_AUTHENTICATED_KEY] = addon_lr_s2_authenticated_key + if updates: hass.config_entries.async_update_entry(entry, data={**entry.data, **updates}) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index dfb7442d678..fee828c9fd8 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import dataclasses from functools import partial, wraps -from typing import Any, Concatenate, Literal, ParamSpec, cast +from typing import Any, Concatenate, Literal, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -75,7 +75,6 @@ from .config_validation import BITMASK_SCHEMA from .const import ( CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, - DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, USER_AGENT, ) @@ -85,8 +84,6 @@ from .helpers import ( get_device_id, ) -_P = ParamSpec("_P") - DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -119,8 +116,8 @@ ENABLED = "enabled" OPTED_IN = "opted_in" # constants for granting security classes -SECURITY_CLASSES = "security_classes" -CLIENT_SIDE_AUTH = "client_side_auth" +SECURITY_CLASSES = "securityClasses" +CLIENT_SIDE_AUTH = "clientSideAuth" # constants for inclusion INCLUSION_STRATEGY = "inclusion_strategy" @@ -148,19 +145,19 @@ QR_CODE_STRING = "qr_code_string" DSK = "dsk" VERSION = "version" -GENERIC_DEVICE_CLASS = "generic_device_class" -SPECIFIC_DEVICE_CLASS = "specific_device_class" -INSTALLER_ICON_TYPE = "installer_icon_type" -MANUFACTURER_ID = "manufacturer_id" -PRODUCT_TYPE = "product_type" -PRODUCT_ID = "product_id" -APPLICATION_VERSION = "application_version" -MAX_INCLUSION_REQUEST_INTERVAL = "max_inclusion_request_interval" +GENERIC_DEVICE_CLASS = "genericDeviceClass" +SPECIFIC_DEVICE_CLASS = "specificDeviceClass" +INSTALLER_ICON_TYPE = "installerIconType" +MANUFACTURER_ID = "manufacturerId" +PRODUCT_TYPE = "productType" +PRODUCT_ID = "productId" +APPLICATION_VERSION = "applicationVersion" +MAX_INCLUSION_REQUEST_INTERVAL = "maxInclusionRequestInterval" UUID = "uuid" -SUPPORTED_PROTOCOLS = "supported_protocols" +SUPPORTED_PROTOCOLS = "supportedProtocols" ADDITIONAL_PROPERTIES = "additional_properties" STATUS = "status" -REQUESTED_SECURITY_CLASSES = "requested_security_classes" +REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses" FEATURE = "feature" STRATEGY = "strategy" @@ -186,6 +183,7 @@ def convert_planned_provisioning_entry(info: dict) -> ProvisioningEntry: def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation: """Convert QR provisioning information dict to QRProvisioningInformation.""" + ## Remove this when we have fix for QRProvisioningInformation.from_dict() return QRProvisioningInformation( version=info[VERSION], security_classes=info[SECURITY_CLASSES], @@ -202,7 +200,28 @@ def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation supported_protocols=info.get(SUPPORTED_PROTOCOLS), status=info[STATUS], requested_security_classes=info.get(REQUESTED_SECURITY_CLASSES), - additional_properties=info.get(ADDITIONAL_PROPERTIES, {}), + additional_properties={ + k: v + for k, v in info.items() + if k + not in ( + VERSION, + SECURITY_CLASSES, + DSK, + GENERIC_DEVICE_CLASS, + SPECIFIC_DEVICE_CLASS, + INSTALLER_ICON_TYPE, + MANUFACTURER_ID, + PRODUCT_TYPE, + PRODUCT_ID, + APPLICATION_VERSION, + MAX_INCLUSION_REQUEST_INTERVAL, + UUID, + SUPPORTED_PROTOCOLS, + STATUS, + REQUESTED_SECURITY_CLASSES, + ) + }, ) @@ -256,8 +275,8 @@ QR_PROVISIONING_INFORMATION_SCHEMA = vol.All( vol.Optional(REQUESTED_SECURITY_CLASSES): vol.All( cv.ensure_list, [vol.Coerce(SecurityClass)] ), - vol.Optional(ADDITIONAL_PROPERTIES): dict, - } + }, + extra=vol.ALLOW_EXTRA, ), convert_qr_provisioning_information, ) @@ -285,7 +304,7 @@ async def _async_get_entry( ) return None, None, None - client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + client: Client = entry.runtime_data[DATA_CLIENT] if client.driver is None: connection.send_error( @@ -363,7 +382,7 @@ def async_get_node( return async_get_node_func -def async_handle_failed_command( +def async_handle_failed_command[**_P]( orig_func: Callable[ Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], Coroutine[Any, Any, None], @@ -753,6 +772,18 @@ async def websocket_add_node( ) ) + @callback + def node_found(event: dict) -> None: + node = event["node"] + node_details = { + "node_id": node["nodeId"], + } + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "node found", "node": node_details} + ) + ) + @callback def node_added(event: dict) -> None: node = event["node"] @@ -796,6 +827,7 @@ async def websocket_add_node( controller.on("inclusion stopped", forward_event), controller.on("validate dsk and enter pin", forward_dsk), controller.on("grant security classes", forward_requested_grant), + controller.on("node found", node_found), controller.on("node added", node_added), async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered @@ -993,9 +1025,7 @@ async def websocket_get_provisioning_entries( ) -> None: """Get provisioning entries (entries that have been pre-provisioned).""" provisioning_entries = await driver.controller.async_get_provisioning_entries() - connection.send_result( - msg[ID], [dataclasses.asdict(entry) for entry in provisioning_entries] - ) + connection.send_result(msg[ID], [entry.to_dict() for entry in provisioning_entries]) @websocket_api.require_admin @@ -1021,7 +1051,7 @@ async def websocket_parse_qr_code_string( qr_provisioning_information = await async_parse_qr_code_string( client, msg[QR_CODE_STRING] ) - connection.send_result(msg[ID], dataclasses.asdict(qr_provisioning_information)) + connection.send_result(msg[ID], qr_provisioning_information.to_dict()) @websocket_api.require_admin @@ -1279,6 +1309,18 @@ async def websocket_replace_failed_node( ) ) + @callback + def node_found(event: dict) -> None: + node = event["node"] + node_details = { + "node_id": node["nodeId"], + } + connection.send_message( + websocket_api.event_message( + msg[ID], {"event": "node found", "node": node_details} + ) + ) + @callback def node_added(event: dict) -> None: node = event["node"] @@ -1335,6 +1377,7 @@ async def websocket_replace_failed_node( controller.on("validate dsk and enter pin", forward_dsk), controller.on("grant security classes", forward_requested_grant), controller.on("node removed", node_removed), + controller.on("node found", node_found), controller.on("node added", node_added), async_dispatcher_connect( hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered @@ -2210,7 +2253,7 @@ class FirmwareUploadView(HomeAssistantView): assert node.client.driver # Increase max payload - request._client_max_size = 1024 * 1024 * 10 # pylint: disable=protected-access + request._client_max_size = 1024 * 1024 * 10 # noqa: SLF001 data = await request.post() diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 79181e818a2..bd5ce2d810b 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -254,7 +254,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave binary sensor from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 5526faf9c59..7fd42700a05 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -27,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave button from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_button(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 04e3d8c3950..14a3fe579c4 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -102,7 +102,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave climate from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_climate(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 3470f64f79f..dff582558b1 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -46,12 +46,16 @@ from .const import ( CONF_ADDON_DEVICE, CONF_ADDON_EMULATE_HARDWARE, CONF_ADDON_LOG_LEVEL, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, CONF_ADDON_NETWORK_KEY, CONF_ADDON_S0_LEGACY_KEY, CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_INTEGRATION_CREATED_ADDON, + CONF_LR_S2_ACCESS_CONTROL_KEY, + CONF_LR_S2_AUTHENTICATED_KEY, CONF_S0_LEGACY_KEY, CONF_S2_ACCESS_CONTROL_KEY, CONF_S2_AUTHENTICATED_KEY, @@ -86,6 +90,8 @@ ADDON_USER_INPUT_MAP = { CONF_ADDON_S2_ACCESS_CONTROL_KEY: CONF_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY: CONF_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY: CONF_S2_UNAUTHENTICATED_KEY, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: CONF_LR_S2_ACCESS_CONTROL_KEY, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: CONF_LR_S2_AUTHENTICATED_KEY, CONF_ADDON_LOG_LEVEL: CONF_LOG_LEVEL, CONF_ADDON_EMULATE_HARDWARE: CONF_EMULATE_HARDWARE, } @@ -172,6 +178,8 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC): self.s2_access_control_key: str | None = None self.s2_authenticated_key: str | None = None self.s2_unauthenticated_key: str | None = None + self.lr_s2_access_control_key: str | None = None + self.lr_s2_authenticated_key: str | None = None self.usb_path: str | None = None self.ws_address: str | None = None self.restart_addon: bool = False @@ -477,7 +485,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): version_info = await validate_input(self.hass, user_input) except InvalidInput as err: errors["base"] = err.error - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -565,6 +573,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self.s2_unauthenticated_key = addon_config.get( CONF_ADDON_S2_UNAUTHENTICATED_KEY, "" ) + self.lr_s2_access_control_key = addon_config.get( + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, "" + ) + self.lr_s2_authenticated_key = addon_config.get( + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, "" + ) return await self.async_step_finish_addon_setup() if addon_info.state == AddonState.NOT_RUNNING: @@ -584,6 +598,8 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] + self.lr_s2_access_control_key = user_input[CONF_LR_S2_ACCESS_CONTROL_KEY] + self.lr_s2_authenticated_key = user_input[CONF_LR_S2_AUTHENTICATED_KEY] if not self._usb_discovery: self.usb_path = user_input[CONF_USB_PATH] @@ -594,6 +610,8 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, } if new_addon_config != addon_config: @@ -614,6 +632,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): s2_unauthenticated_key = addon_config.get( CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" ) + lr_s2_access_control_key = addon_config.get( + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, self.lr_s2_access_control_key or "" + ) + lr_s2_authenticated_key = addon_config.get( + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" + ) schema = { vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, @@ -624,6 +648,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): vol.Optional( CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key ): str, + vol.Optional( + CONF_LR_S2_ACCESS_CONTROL_KEY, default=lr_s2_access_control_key + ): str, + vol.Optional( + CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key + ): str, } if not self._usb_discovery: @@ -670,6 +700,8 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, } ) return self._async_create_entry_from_vars() @@ -690,6 +722,8 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, CONF_USE_ADDON: self.use_addon, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, }, @@ -743,7 +777,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): version_info = await validate_input(self.hass, user_input) except InvalidInput as err: errors["base"] = err.error - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -801,6 +835,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): self.s2_access_control_key = user_input[CONF_S2_ACCESS_CONTROL_KEY] self.s2_authenticated_key = user_input[CONF_S2_AUTHENTICATED_KEY] self.s2_unauthenticated_key = user_input[CONF_S2_UNAUTHENTICATED_KEY] + self.lr_s2_access_control_key = user_input[CONF_LR_S2_ACCESS_CONTROL_KEY] + self.lr_s2_authenticated_key = user_input[CONF_LR_S2_AUTHENTICATED_KEY] self.usb_path = user_input[CONF_USB_PATH] new_addon_config = { @@ -810,6 +846,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, CONF_ADDON_LOG_LEVEL: user_input[CONF_LOG_LEVEL], CONF_ADDON_EMULATE_HARDWARE: user_input.get( CONF_EMULATE_HARDWARE, False @@ -850,6 +888,12 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): s2_unauthenticated_key = addon_config.get( CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" ) + lr_s2_access_control_key = addon_config.get( + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, self.lr_s2_access_control_key or "" + ) + lr_s2_authenticated_key = addon_config.get( + CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" + ) log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) @@ -868,6 +912,12 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): vol.Optional( CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key ): str, + vol.Optional( + CONF_LR_S2_ACCESS_CONTROL_KEY, default=lr_s2_access_control_key + ): str, + vol.Optional( + CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key + ): str, vol.Optional(CONF_LOG_LEVEL, default=log_level): vol.In( ADDON_LOG_LEVELS ), @@ -921,6 +971,8 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, CONF_USE_ADDON: True, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, } diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index f022cd42d20..a04f9247548 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -4,12 +4,15 @@ from __future__ import annotations import logging +from awesomeversion import AwesomeVersion from zwave_js_server.const.command_class.window_covering import ( WindowCoveringPropertyKey, ) from homeassistant.const import APPLICATION_NAME, __version__ as HA_VERSION +LR_ADDON_VERSION = AwesomeVersion("0.5.0") + USER_AGENT = {APPLICATION_NAME: HA_VERSION} CONF_ADDON_DEVICE = "device" @@ -20,12 +23,16 @@ CONF_ADDON_S0_LEGACY_KEY = "s0_legacy_key" CONF_ADDON_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" CONF_ADDON_S2_AUTHENTICATED_KEY = "s2_authenticated_key" CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" +CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" +CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" CONF_NETWORK_KEY = "network_key" CONF_S0_LEGACY_KEY = "s0_legacy_key" CONF_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" CONF_S2_AUTHENTICATED_KEY = "s2_authenticated_key" CONF_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key" +CONF_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" +CONF_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" CONF_USB_PATH = "usb_path" CONF_USE_ADDON = "use_addon" CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index f0ef1913bbb..363b32cedda 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -57,7 +57,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave Cover from Config Entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_cover(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 5c94b2bb02d..4eed2a5b50c 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -55,5 +55,5 @@ def async_bypass_dynamic_config_validation(hass: HomeAssistant, device_id: str) return True # The driver may not be ready when the config entry is loaded. - client: ZwaveClient = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + client: ZwaveClient = entry.runtime_data[DATA_CLIENT] return client.driver is None diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 3d61699472d..dde455bd9b6 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DATA_CLIENT, DOMAIN, USER_AGENT +from .const import DATA_CLIENT, USER_AGENT from .helpers import ( ZwaveValueMatcher, get_home_and_node_id_from_device_entry, @@ -148,7 +148,7 @@ async def async_get_device_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - client: Client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: Client = config_entry.runtime_data[DATA_CLIENT] identifiers = get_home_and_node_id_from_device_entry(device) node_id = identifiers[1] if identifiers else None driver = client.driver diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index b5d0a4976e9..0b66567c036 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Generator from dataclasses import asdict, dataclass, field from enum import StrEnum from typing import TYPE_CHECKING, Any, cast from awesomeversion import AwesomeVersion +from typing_extensions import Generator from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -27,7 +27,10 @@ from zwave_js_server.const.command_class.lock import ( DOOR_STATUS_PROPERTY, LOCKED_PROPERTY, ) -from zwave_js_server.const.command_class.meter import VALUE_PROPERTY +from zwave_js_server.const.command_class.meter import ( + RESET_PROPERTY as RESET_METER_PROPERTY, + VALUE_PROPERTY, +) from zwave_js_server.const.command_class.protection import LOCAL_PROPERTY, RF_PROPERTY from zwave_js_server.const.command_class.sound_switch import ( DEFAULT_TONE_ID_PROPERTY, @@ -41,7 +44,6 @@ from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_SETPOINT_PROPERTY, ) from zwave_js_server.exceptions import UnknownValueData -from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue, @@ -1104,7 +1106,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.LIGHT, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), - # light for Basic CC + # light for Basic CC with target ZWaveDiscoverySchema( platform=Platform.LIGHT, primary_value=ZWaveValueDiscoverySchema( @@ -1114,9 +1116,24 @@ DISCOVERY_SCHEMAS = [ ), required_values=[ ZWaveValueDiscoverySchema( - command_class={ - CommandClass.BASIC, - }, + command_class={CommandClass.BASIC}, + type={ValueType.NUMBER}, + property={TARGET_VALUE_PROPERTY}, + ) + ], + ), + # sensor for Basic CC without target + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BASIC}, + type={ValueType.NUMBER}, + property={CURRENT_VALUE_PROPERTY}, + ), + absent_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.BASIC}, type={ValueType.NUMBER}, property={TARGET_VALUE_PROPERTY}, ) @@ -1181,13 +1198,25 @@ DISCOVERY_SCHEMAS = [ stateful=False, ), ), + # button + # Meter CC idle + ZWaveDiscoverySchema( + platform=Platform.BUTTON, + hint="meter reset", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.METER}, + property={RESET_METER_PROPERTY}, + type={ValueType.BOOLEAN}, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), ] @callback def async_discover_node_values( node: ZwaveNode, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] -) -> Generator[ZwaveDiscoveryInfo, None, None]: +) -> Generator[ZwaveDiscoveryInfo]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): # We don't need to rediscover an already processed value_id @@ -1198,7 +1227,7 @@ def async_discover_node_values( @callback def async_discover_single_value( value: ZwaveValue, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] -) -> Generator[ZwaveDiscoveryInfo, None, None]: +) -> Generator[ZwaveDiscoveryInfo]: """Run discovery on a single ZWave value and return matching schema info.""" discovered_value_ids[device.id].add(value.value_id) for schema in DISCOVERY_SCHEMAS: @@ -1235,14 +1264,22 @@ def async_discover_single_value( continue # check device_class_generic - if value.node.device_class and not check_device_class( - value.node.device_class.generic, schema.device_class_generic + if schema.device_class_generic and ( + not value.node.device_class + or not any( + value.node.device_class.generic.label == val + for val in schema.device_class_generic + ) ): continue # check device_class_specific - if value.node.device_class and not check_device_class( - value.node.device_class.specific, schema.device_class_specific + if schema.device_class_specific and ( + not value.node.device_class + or not any( + value.node.device_class.specific.label == val + for val in schema.device_class_specific + ) ): continue @@ -1311,7 +1348,7 @@ def async_discover_single_value( @callback def async_discover_single_configuration_value( value: ConfigurationValue, -) -> Generator[ZwaveDiscoveryInfo, None, None]: +) -> Generator[ZwaveDiscoveryInfo]: """Run discovery on single Z-Wave configuration value and return schema matches.""" if value.metadata.writeable and value.metadata.readable: if value.configuration_value_type == ConfigurationValueType.ENUMERATED: @@ -1434,15 +1471,3 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: if schema.stateful is not None and value.metadata.stateful != schema.stateful: return False return True - - -@callback -def check_device_class( - device_class: DeviceClassItem, required_value: set[str] | None -) -> bool: - """Check if device class id or label matches.""" - if required_value is None: - return True - if any(device_class.label == val for val in required_value): - return True - return False diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 7eb85e0ea4d..e619c6afc7c 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -4,8 +4,9 @@ from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass, field +from enum import Enum import logging -from typing import Any, TypeVar, cast +from typing import Any, cast from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.energy_production import ( @@ -357,22 +358,12 @@ class NumericSensorDataTemplateData: unit_of_measurement: str | None = None -T = TypeVar( - "T", - MultilevelSensorType, - MultilevelSensorScaleType, - MeterScaleType, - EnergyProductionParameter, - EnergyProductionScaleType, -) - - class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): """Data template class for Z-Wave Sensor entities.""" @staticmethod - def find_key_from_matching_set( - enum_value: T, set_map: Mapping[str, list[T]] + def find_key_from_matching_set[_T: Enum]( + enum_value: _T, set_map: Mapping[str, list[_T]] ) -> str | None: """Find a key in a set map that matches a given enum value.""" for key, value_set in set_map.items(): diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py index 2b170bdf5bd..8dae66c26ac 100644 --- a/homeassistant/components/zwave_js/event.py +++ b/homeassistant/components/zwave_js/event.py @@ -25,7 +25,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave Event entity from Config Entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_event(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 4cf9a5d40cf..925a48512d8 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -49,7 +49,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave Fan from Config Entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_fan(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 4a4c1030812..598cf2f78f6 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -155,7 +155,7 @@ async def async_enable_server_logging_if_needed( if (curr_server_log_level := driver.log_config.level) and ( LOG_LEVEL_MAP[curr_server_log_level] ) > (lib_log_level := LIB_LOGGER.getEffectiveLevel()): - entry_data = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data LOGGER.warning( ( "Server logging is set to %s and is currently less verbose " @@ -174,7 +174,6 @@ async def async_disable_server_logging_if_needed( hass: HomeAssistant, entry: ConfigEntry, driver: Driver ) -> None: """Disable logging of zwave-js-server in the lib if still connected to server.""" - entry_data = hass.data[DOMAIN][entry.entry_id] if ( not driver or not driver.client.connected @@ -183,8 +182,8 @@ async def async_disable_server_logging_if_needed( return LOGGER.info("Disabling zwave_js server logging") if ( - DATA_OLD_SERVER_LOG_LEVEL in entry_data - and (old_server_log_level := entry_data.pop(DATA_OLD_SERVER_LOG_LEVEL)) + DATA_OLD_SERVER_LOG_LEVEL in entry.runtime_data + and (old_server_log_level := entry.runtime_data.pop(DATA_OLD_SERVER_LOG_LEVEL)) != driver.log_config.level ): LOGGER.info( @@ -275,12 +274,12 @@ def async_get_node_from_device_id( ) if entry and entry.state != ConfigEntryState.LOADED: raise ValueError(f"Device {device_id} config entry is not loaded") - if entry is None or entry.entry_id not in hass.data[DOMAIN]: + if entry is None: raise ValueError( f"Device {device_id} is not from an existing zwave_js config entry" ) - client: ZwaveClient = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + client: ZwaveClient = entry.runtime_data[DATA_CLIENT] driver = client.driver if driver is None: @@ -443,7 +442,9 @@ def async_get_node_status_sensor_entity_id( if not (entry_id := _zwave_js_config_entry(hass, device)): return None - client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + entry = hass.config_entries.async_get_entry(entry_id) + assert entry + client = entry.runtime_data[DATA_CLIENT] node = async_get_node_from_device_id(hass, device_id, dev_reg) return ent_reg.async_get_entity_id( SENSOR_DOMAIN, diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 4030115ab1f..e883858036b 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -73,7 +73,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave humidifier from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_humidifier(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index eba2d4a0cce..020f1b66b3d 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -68,7 +68,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave Light from Config Entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_light(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index d102e5b5f22..5eb89e17402 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -66,7 +66,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave lock from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_lock(info: ZwaveDiscoveryInfo) -> None: @@ -214,5 +214,5 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): return msg = f"Result status is {result.status}" if result.remaining_duration is not None: - msg += f" and remaining duration is {str(result.remaining_duration)}" + msg += f" and remaining duration is {result.remaining_duration!s}" LOGGER.info("%s after setting lock configuration for %s", msg, self.entity_id) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 83a139331bb..f394537803a 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.55.4"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.57.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 15262710095..54162488d89 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave Number entity from Config Entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_number(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index c970c17f5f0..49ad1868005 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -30,7 +30,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave Select entity from Config Entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_select(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f799a70110d..e43c620ff54 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -530,7 +530,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. @@ -689,6 +689,23 @@ class ZwaveSensor(ZWaveBaseEntity, SensorEntity): class ZWaveNumericSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor.""" + def __init__( + self, + config_entry: ConfigEntry, + driver: Driver, + info: ZwaveDiscoveryInfo, + entity_description: SensorEntityDescription, + unit_of_measurement: str | None = None, + ) -> None: + """Initialize a ZWaveBasicSensor entity.""" + super().__init__( + config_entry, driver, info, entity_description, unit_of_measurement + ) + if self.info.primary_value.command_class == CommandClass.BASIC: + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name="Basic" + ) + @callback def on_value_update(self) -> None: """Handle scale changes for this value on value updated event.""" diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index bdd5090bcf8..66d09714723 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio -from collections.abc import Generator, Sequence +from collections.abc import Collection, Sequence import logging import math -from typing import Any, TypeVar +from typing import Any +from typing_extensions import Generator import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import SET_VALUE_SUCCESS, CommandClass, CommandStatus @@ -46,7 +47,7 @@ from .helpers import ( _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", ZwaveNode, Endpoint) +type _NodeOrEndpointType = ZwaveNode | Endpoint def parameter_name_does_not_need_bitmask( @@ -81,9 +82,9 @@ def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: ) -def get_valid_responses_from_results( - zwave_objects: Sequence[T], results: Sequence[Any] -) -> Generator[tuple[T, Any], None, None]: +def get_valid_responses_from_results[_T: ZwaveNode | Endpoint]( + zwave_objects: Sequence[_T], results: Sequence[Any] +) -> Generator[tuple[_T, Any]]: """Return valid responses from a list of results.""" for zwave_object, result in zip(zwave_objects, results, strict=False): if not isinstance(result, Exception): @@ -91,10 +92,10 @@ def get_valid_responses_from_results( def raise_exceptions_from_results( - zwave_objects: Sequence[T], results: Sequence[Any] + zwave_objects: Sequence[_NodeOrEndpointType], results: Sequence[Any] ) -> None: """Raise list of exceptions from a list of results.""" - errors: Sequence[tuple[T, Any]] + errors: Sequence[tuple[_NodeOrEndpointType, Any]] if errors := [ tup for tup in zip(zwave_objects, results, strict=True) @@ -112,7 +113,7 @@ def raise_exceptions_from_results( async def _async_invoke_cc_api( - nodes_or_endpoints: set[T], + nodes_or_endpoints: Collection[_NodeOrEndpointType], command_class: CommandClass, method_name: str, *args: Any, @@ -561,7 +562,7 @@ class ZWaveServices: ) def process_results( - nodes_or_endpoints_list: list[T], _results: list[Any] + nodes_or_endpoints_list: Sequence[_NodeOrEndpointType], _results: list[Any] ) -> None: """Process results for given nodes or endpoints.""" for node_or_endpoint, result in get_valid_responses_from_results( @@ -727,8 +728,8 @@ class ZWaveServices: first_node = next(node for node in nodes) client = first_node.client except StopIteration: - entry_id = self._hass.config_entries.async_entries(const.DOMAIN)[0].entry_id - client = self._hass.data[const.DOMAIN][entry_id][const.DATA_CLIENT] + data = self._hass.config_entries.async_entries(const.DOMAIN)[0].runtime_data + client = data[const.DATA_CLIENT] assert client.driver first_node = next( node diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 413186da9bf..3a09049def3 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave Siren entity from Config Entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_siren(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 9e2317ba728..7c65f1804b1 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -90,6 +90,27 @@ "state.node_status": "Node status changed", "zwave_js.value_updated.config_parameter": "Value change on config parameter {subtype}", "zwave_js.value_updated.value": "Value change on a Z-Wave JS Value" + }, + "extra_fields": { + "code_slot": "Code slot", + "command_class": "Command class", + "data_type": "Data type", + "endpoint": "Endpoint", + "event": "Event", + "event_label": "Event label", + "event_type": "Event type", + "for": "[%key:common::device_automation::extra_fields::for%]", + "from": "From", + "label": "Label", + "property": "Property", + "property_key": "Property key", + "refresh_all_values": "Refresh all values", + "status": "Status", + "to": "[%key:common::device_automation::extra_fields::to%]", + "type.": "Type", + "usercode": "Usercode", + "value": "Value", + "wait_for_result": "Wait for result" } }, "entity": { diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 30ee5fb72bc..ef769209b31 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] @callback def async_add_switch(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 6cf4a31c0eb..921cae19b3a 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -219,7 +219,9 @@ async def async_attach_trigger( drivers: set[Driver] = set() if not (nodes := async_get_nodes_from_targets(hass, config, dev_reg=dev_reg)): entry_id = config[ATTR_CONFIG_ENTRY_ID] - client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + entry = hass.config_entries.async_get_entry(entry_id) + assert entry + client: Client = entry.runtime_data[DATA_CLIENT] driver = client.driver assert driver drivers.add(driver) diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py index 1dbe1f48f0a..1ef9ebaae28 100644 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ b/homeassistant/components/zwave_js/triggers/trigger_helpers.py @@ -37,7 +37,7 @@ def async_bypass_dynamic_config_validation( return True # The driver may not be ready when the config entry is loaded. - client: ZwaveClient = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + client: ZwaveClient = entry.runtime_data[DATA_CLIENT] if client.driver is None: return True diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 3fdbab8aacf..02c59d220e1 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -80,7 +80,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Z-Wave update entity from config entry.""" - client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] cnt: Counter = Counter() @callback diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py index 4794e807049..c2eec09496d 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -67,7 +67,7 @@ class ZWaveMeCover(ZWaveMeEntity, CoverEntity): """Update the current value.""" value = kwargs[ATTR_POSITION] self.controller.zwave_api.send_command( - self.device.id, f"exact?level={str(min(value, 99))}" + self.device.id, f"exact?level={min(value, 99)!s}" ) def stop_cover(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py index 28fd8abe460..272e833d678 100644 --- a/homeassistant/components/zwave_me/number.py +++ b/homeassistant/components/zwave_me/number.py @@ -50,5 +50,5 @@ class ZWaveMeNumber(ZWaveMeEntity, NumberEntity): def set_native_value(self, value: float) -> None: """Update the current value.""" self.controller.zwave_api.send_command( - self.device.id, f"exact?level={str(round(value))}" + self.device.id, f"exact?level={round(value)!s}" ) diff --git a/homeassistant/config.py b/homeassistant/config.py index abb29f6a1a1..8e22f2051f0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -52,6 +52,7 @@ from .const import ( CONF_NAME, CONF_PACKAGES, CONF_PLATFORM, + CONF_RADIUS, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, CONF_TYPE, @@ -69,6 +70,7 @@ from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.async_ import create_eager_task +from .util.hass_dict import HassKey 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, YamlTypeError, load_yaml_dict @@ -81,7 +83,7 @@ RE_ASCII = re.compile(r"\033\[[^m]*m") YAML_CONFIG_FILE = "configuration.yaml" VERSION_FILE = ".HA_VERSION" CONFIG_DIR_NAME = ".homeassistant" -DATA_CUSTOMIZE = "hass_customize" +DATA_CUSTOMIZE: HassKey[EntityValues] = HassKey("hass_customize") AUTOMATION_CONFIG_PATH = "automations.yaml" SCRIPT_CONFIG_PATH = "scripts.yaml" @@ -290,41 +292,6 @@ 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, - HA_DOMAIN, - "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, HA_DOMAIN, "legacy_templates_true") - - if legacy_templates is False: - ir.async_create_issue( - hass, - HA_DOMAIN, - "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, HA_DOMAIN, "legacy_templates_false") - - def _validate_currency(data: Any) -> Any: try: return cv.currency(data) @@ -341,6 +308,7 @@ CORE_CONFIG_SCHEMA = vol.All( CONF_LATITUDE: cv.latitude, CONF_LONGITUDE: cv.longitude, CONF_ELEVATION: vol.Coerce(int), + CONF_RADIUS: cv.positive_int, vol.Remove(CONF_TEMPERATURE_UNIT): cv.temperature_unit, CONF_UNIT_SYSTEM: validate_unit_system, CONF_TIME_ZONE: cv.time_zone, @@ -388,7 +356,7 @@ CORE_CONFIG_SCHEMA = vol.All( _no_duplicate_auth_mfa_module, ), vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), - vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, + vol.Remove(CONF_LEGACY_TEMPLATES): cv.boolean, vol.Optional(CONF_CURRENCY): _validate_currency, vol.Optional(CONF_COUNTRY): cv.country, vol.Optional(CONF_LANGUAGE): cv.language, @@ -881,6 +849,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non CONF_CURRENCY, CONF_COUNTRY, CONF_LANGUAGE, + CONF_RADIUS, ) ): hac.config_source = ConfigSource.YAML @@ -893,10 +862,10 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non (CONF_INTERNAL_URL, "internal_url"), (CONF_EXTERNAL_URL, "external_url"), (CONF_MEDIA_DIRS, "media_dirs"), - (CONF_LEGACY_TEMPLATES, "legacy_templates"), (CONF_CURRENCY, "currency"), (CONF_COUNTRY, "country"), (CONF_LANGUAGE, "language"), + (CONF_RADIUS, "radius"), ): if key in config: setattr(hac, attr, config[key]) @@ -904,12 +873,11 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non if config.get(CONF_DEBUG): hac.debug = True - _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) if CONF_TIME_ZONE in config: - hac.set_time_zone(config[CONF_TIME_ZONE]) + await hac.async_set_time_zone(config[CONF_TIME_ZONE]) if CONF_MEDIA_DIRS not in config: if is_docker_env(): @@ -995,7 +963,7 @@ def _identify_config_schema(module: ComponentProtocol) -> str | None: key = next(k for k in schema if k == module.DOMAIN) except (TypeError, AttributeError, StopIteration): return None - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected error identifying config schema") return None @@ -1078,7 +1046,7 @@ async def merge_packages_config( pack_name, None, config, - f"Invalid package definition '{pack_name}': {str(exc)}. Package " + f"Invalid package definition '{pack_name}': {exc!s}. Package " f"will not be initialized", ) invalid_packages.append(pack_name) @@ -1106,7 +1074,7 @@ async def merge_packages_config( pack_name, comp_name, config, - f"Integration {comp_name} caused error: {str(exc)}", + f"Integration {comp_name} caused error: {exc!s}", ) continue except INTEGRATION_LOAD_EXCEPTIONS as exc: @@ -1465,7 +1433,7 @@ async def _async_load_and_validate_platform_integration( p_integration.integration.documentation, ) config_exceptions.append(exc_info) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, @@ -1549,7 +1517,7 @@ async def async_process_component_config( ) config_exceptions.append(exc_info) return IntegrationConfigInfo(None, config_exceptions) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.CONFIG_VALIDATOR_UNKNOWN_ERR, @@ -1574,7 +1542,7 @@ async def async_process_component_config( ) config_exceptions.append(exc_info) return IntegrationConfigInfo(None, config_exceptions) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.CONFIG_SCHEMA_UNKNOWN_ERR, @@ -1609,7 +1577,7 @@ async def async_process_component_config( ) config_exceptions.append(exc_info) continue - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR, @@ -1672,7 +1640,9 @@ async def async_process_component_config( validated_config for validated_config in await asyncio.gather( *( - create_eager_task(async_load_and_validate(p_integration)) + create_eager_task( + async_load_and_validate(p_integration), loop=hass.loop + ) for p_integration in platform_integrations_to_load ) ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ba642cc0216..c8d671e1fe1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -4,15 +4,7 @@ from __future__ import annotations import asyncio from collections import UserDict -from collections.abc import ( - Callable, - Coroutine, - Generator, - Hashable, - Iterable, - Mapping, - ValuesView, -) +from collections.abc import Callable, Coroutine, Hashable, Iterable, Mapping, ValuesView from contextvars import ContextVar from copy import deepcopy from enum import Enum, StrEnum @@ -24,7 +16,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Generic, Self, cast from async_interrupt import interrupt -from typing_extensions import TypeVar +from typing_extensions import Generator, TypeVar from . import data_entry_flow, loader from .components import persistent_notification @@ -48,7 +40,7 @@ from .exceptions import ( ) from .helpers import device_registry, entity_registry, issue_registry as ir, storage from .helpers.debounce import Debouncer -from .helpers.dispatcher import SignalType, async_dispatcher_send +from .helpers.dispatcher import SignalType, async_dispatcher_send_internal from .helpers.event import ( RANDOM_MICROSECOND_MAX, RANDOM_MICROSECOND_MIN, @@ -66,9 +58,10 @@ from .setup import ( async_setup_component, async_start_setup, ) -from .util import uuid as uuid_util +from .util import ulid as ulid_util from .util.async_ import create_eager_task from .util.decorator import Registry +from .util.enum import try_parse_enum if TYPE_CHECKING: from .components.bluetooth import BluetoothServiceInfoBleak @@ -117,16 +110,13 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry() STORAGE_KEY = "core.config_entries" STORAGE_VERSION = 1 - -# Deprecated since 0.73 -PATH_CONFIG = ".config_entries.json" +STORAGE_VERSION_MINOR = 2 SAVE_DELAY = 1 DISCOVERY_COOLDOWN = 1 _DataT = TypeVar("_DataT", default=Any) -_R = TypeVar("_R") class ConfigEntryState(Enum): @@ -153,7 +143,7 @@ class ConfigEntryState(Enum): """Create new ConfigEntryState.""" obj = object.__new__(cls) obj._value_ = value - obj._recoverable = recoverable + obj._recoverable = recoverable # noqa: SLF001 return obj @property @@ -239,7 +229,9 @@ class OperationNotAllowed(ConfigError): """Raised when a config entry operation is not allowed.""" -UpdateListenerType = Callable[[HomeAssistant, "ConfigEntry"], Coroutine[Any, Any, None]] +type UpdateListenerType = Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, None] +] FROZEN_CONFIG_ENTRY_ATTRS = { "entry_id", @@ -265,6 +257,7 @@ class ConfigFlowResult(FlowResult, total=False): """Typed result dict for config flow.""" minor_version: int + options: Mapping[str, Any] version: int @@ -306,24 +299,24 @@ class ConfigEntry(Generic[_DataT]): def __init__( self, *, - version: int, - minor_version: int, - domain: str, - title: str, data: Mapping[str, Any], - source: str, + disabled_by: ConfigEntryDisabler | None = None, + domain: str, + entry_id: str | None = None, + minor_version: int, + options: Mapping[str, Any] | None, pref_disable_new_entities: bool | None = None, pref_disable_polling: bool | None = None, - options: Mapping[str, Any] | None = None, - unique_id: str | None = None, - entry_id: str | None = None, + source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, - disabled_by: ConfigEntryDisabler | None = None, + title: str, + unique_id: str | None, + version: int, ) -> None: """Initialize a config entry.""" _setter = object.__setattr__ # Unique id of the config entry - _setter(self, "entry_id", entry_id or uuid_util.random_uuid_hex()) + _setter(self, "entry_id", entry_id or ulid_util.ulid_now()) # Version of the configuration. _setter(self, "version", version) @@ -462,7 +455,7 @@ class ConfigEntry(Generic[_DataT]): @property def supports_reconfigure(self) -> bool: - """Return if entry supports config options.""" + """Return if entry supports reconfigure step.""" if self._supports_reconfigure is None and ( handler := HANDLERS.get(self.domain) ): @@ -490,7 +483,7 @@ class ConfigEntry(Generic[_DataT]): "supports_options": self.supports_options, "supports_remove_device": self.supports_remove_device or False, "supports_unload": self.supports_unload or False, - "supports_reconfigure": self.supports_reconfigure or False, + "supports_reconfigure": self.supports_reconfigure, "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "disabled_by": self.disabled_by, @@ -517,6 +510,21 @@ class ConfigEntry(Generic[_DataT]): # Only store setup result as state if it was not forwarded. if domain_is_integration := self.domain == integration.domain: + if self.state in ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_IN_PROGRESS, + ): + raise OperationNotAllowed( + f"The config entry {self.title} ({self.domain}) with entry_id" + f" {self.entry_id} cannot be set up because it is already loaded " + f"in the {self.state} state" + ) + if not self.setup_lock.locked(): + raise OperationNotAllowed( + f"The config entry {self.title} ({self.domain}) with entry_id" + f" {self.entry_id} cannot be set up because it does not hold " + "the setup lock" + ) self._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) if self.supports_unload is None: @@ -712,6 +720,17 @@ class ConfigEntry(Generic[_DataT]): ) -> None: """Set up while holding the setup lock.""" async with self.setup_lock: + if self.state is ConfigEntryState.LOADED: + # If something loaded the config entry while + # we were waiting for the lock, we should not + # set it up again. + _LOGGER.debug( + "Not setting up %s (%s %s) again, already loaded", + self.title, + self.domain, + self.entry_id, + ) + return await self.async_setup(hass, integration=integration) @callback @@ -753,7 +772,14 @@ class ConfigEntry(Generic[_DataT]): component = await integration.async_get_component() - if integration.domain == self.domain: + if domain_is_integration := self.domain == integration.domain: + if not self.setup_lock.locked(): + raise OperationNotAllowed( + f"The config entry {self.title} ({self.domain}) with entry_id" + f" {self.entry_id} cannot be unloaded because it does not hold " + "the setup lock" + ) + if not self.state.recoverable: return False @@ -765,7 +791,7 @@ class ConfigEntry(Generic[_DataT]): supports_unload = hasattr(component, "async_unload_entry") if not supports_unload: - if integration.domain == self.domain: + if domain_is_integration: self._async_set_state( hass, ConfigEntryState.FAILED_UNLOAD, "Unload not supported" ) @@ -777,15 +803,18 @@ class ConfigEntry(Generic[_DataT]): assert isinstance(result, bool) # Only adjust state if we unloaded the component - if result and integration.domain == self.domain: + if domain_is_integration and result: + await self._async_process_on_unload(hass) + if hasattr(self, "runtime_data"): + object.__delattr__(self, "runtime_data") + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) - await self._async_process_on_unload(hass) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: _LOGGER.exception( "Error unloading entry %s for %s", self.title, integration.domain ) - if integration.domain == self.domain: + if domain_is_integration: self._async_set_state( hass, ConfigEntryState.FAILED_UNLOAD, str(exc) or "Unknown error" ) @@ -797,6 +826,13 @@ class ConfigEntry(Generic[_DataT]): if self.source == SOURCE_IGNORE: return + if not self.setup_lock.locked(): + raise OperationNotAllowed( + f"The config entry {self.title} ({self.domain}) with entry_id" + f" {self.entry_id} cannot be removed because it does not hold " + "the setup lock" + ) + if not (integration := self._integration_for_domain): try: integration = await loader.async_get_integration(hass, self.domain) @@ -812,7 +848,7 @@ class ConfigEntry(Generic[_DataT]): return try: await component.async_remove_entry(hass, self) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error calling entry remove callback %s for %s", self.title, @@ -841,7 +877,7 @@ class ConfigEntry(Generic[_DataT]): error_reason_translation_placeholders, ) self.clear_cache() - async_dispatcher_send( + async_dispatcher_send_internal( hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self ) @@ -887,9 +923,8 @@ class ConfigEntry(Generic[_DataT]): ) return False if result: - # pylint: disable-next=protected-access - hass.config_entries._async_schedule_save() - except Exception: # pylint: disable=broad-except + hass.config_entries._async_schedule_save() # noqa: SLF001 + except Exception: _LOGGER.exception( "Error migrating entry %s for %s", self.title, self.domain ) @@ -907,18 +942,18 @@ class ConfigEntry(Generic[_DataT]): def as_dict(self) -> dict[str, Any]: """Return dictionary version of this entry.""" return { - "entry_id": self.entry_id, - "version": self.version, - "minor_version": self.minor_version, - "domain": self.domain, - "title": self.title, "data": dict(self.data), + "disabled_by": self.disabled_by, + "domain": self.domain, + "entry_id": self.entry_id, + "minor_version": self.minor_version, "options": dict(self.options), "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "source": self.source, + "title": self.title, "unique_id": self.unique_id, - "disabled_by": self.disabled_by, + "version": self.version, } @callback @@ -1062,7 +1097,7 @@ class ConfigEntry(Generic[_DataT]): @callback def async_get_active_flows( self, hass: HomeAssistant, sources: set[str] - ) -> Generator[ConfigFlowResult, None, None]: + ) -> Generator[ConfigFlowResult]: """Get any active flows of certain sources for this entry.""" return ( flow @@ -1075,7 +1110,7 @@ class ConfigEntry(Generic[_DataT]): ) @callback - def async_create_task( + def async_create_task[_R]( self, hass: HomeAssistant, target: Coroutine[Any, Any, _R], @@ -1099,7 +1134,7 @@ class ConfigEntry(Generic[_DataT]): return task @callback - def async_create_background_task( + def async_create_background_task[_R]( self, hass: HomeAssistant, target: Coroutine[Any, Any, _R], @@ -1135,6 +1170,19 @@ class FlowCancelledError(Exception): """Error to indicate that a flow has been cancelled.""" +def _report_non_awaited_platform_forwards(entry: ConfigEntry, what: str) -> None: + """Report non awaited platform forwards.""" + report( + f"calls {what} for integration {entry.domain} with " + f"title: {entry.title} and entry_id: {entry.entry_id}, " + f"during setup without awaiting {what}, which can cause " + "the setup lock to be released before the setup is done. " + "This will stop working in Home Assistant 2025.1", + error_if_integration=False, + error_if_core=False, + ) + + class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): """Manage all the config entry flows that are in progress.""" @@ -1183,14 +1231,15 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): if not context or "source" not in context: raise KeyError("Context not set or doesn't have a source set") - flow_id = uuid_util.random_uuid_hex() + flow_id = ulid_util.ulid_now() # Avoid starting a config flow on an integration that only supports # a single config entry, but which already has an entry if ( - context.get("source") not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_UNIGNORE} + context.get("source") + not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_UNIGNORE, SOURCE_RECONFIGURE} + and self.config_entries.async_has_entries(handler, include_ignore=False) and await _support_single_config_entry_only(self.hass, handler) - and self.config_entries.async_entries(handler, include_ignore=False) ): return ConfigFlowResult( type=data_entry_flow.FlowResultType.ABORT, @@ -1294,9 +1343,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): # Avoid adding a config entry for a integration # that only supports a single config entry, but already has an entry if ( - await _support_single_config_entry_only(self.hass, flow.handler) + self.config_entries.async_has_entries(flow.handler, include_ignore=False) + and await _support_single_config_entry_only(self.hass, flow.handler) and flow.context["source"] != SOURCE_IGNORE - and self.config_entries.async_entries(flow.handler, include_ignore=False) ): return ConfigFlowResult( type=data_entry_flow.FlowResultType.ABORT, @@ -1335,10 +1384,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): await flow.async_set_unique_id(None) # Find existing entry. - for check_entry in self.config_entries.async_entries(result["handler"]): - if check_entry.unique_id == flow.unique_id: - existing_entry = check_entry - break + existing_entry = self.config_entries.async_entry_for_domain_unique_id( + result["handler"], flow.unique_id + ) # Unload the entry before setting up the new one. # We will remove it only after the other one is set up, @@ -1347,14 +1395,14 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): await self.config_entries.async_unload(existing_entry.entry_id) entry = ConfigEntry( - version=result["version"], - minor_version=result["minor_version"], - domain=result["handler"], - title=result["title"], data=result["data"], + domain=result["handler"], + minor_version=result["minor_version"], options=result["options"], source=flow.context["source"], + title=result["title"], unique_id=flow.unique_id, + version=result["version"], ) await self.config_entries.async_add(entry) @@ -1523,6 +1571,51 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): return self._domain_unique_id_index.get(domain, {}).get(unique_id) +class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): + """Class to help storing config entry data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize storage class.""" + super().__init__( + hass, + STORAGE_VERSION, + STORAGE_KEY, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1 and old_minor_version < 2: + # Version 1.2 implements migration and freezes the available keys + for entry in data["entries"]: + # Populate keys which were introduced before version 1.2 + + pref_disable_new_entities = entry.get("pref_disable_new_entities") + if pref_disable_new_entities is None and "system_options" in entry: + pref_disable_new_entities = entry.get("system_options", {}).get( + "disable_new_entities" + ) + + entry.setdefault("disabled_by", entry.get("disabled_by")) + entry.setdefault("minor_version", entry.get("minor_version", 1)) + entry.setdefault("options", entry.get("options", {})) + entry.setdefault("pref_disable_new_entities", pref_disable_new_entities) + entry.setdefault( + "pref_disable_polling", entry.get("pref_disable_polling") + ) + entry.setdefault("unique_id", entry.get("unique_id")) + + if old_major_version > 1: + raise NotImplementedError + return data + + class ConfigEntries: """Manage the configuration entries. @@ -1536,9 +1629,7 @@ class ConfigEntries: self.options = OptionsFlowManager(hass) self._hass_config = hass_config self._entries = ConfigEntryItems(hass) - self._store = storage.Store[dict[str, list[dict[str, Any]]]]( - hass, STORAGE_VERSION, STORAGE_KEY - ) + self._store = ConfigEntryStore(hass) EntityRegistryDisabledHandler(hass).async_setup() @callback @@ -1565,6 +1656,21 @@ class ConfigEntries: """Return entry ids.""" return list(self._entries.data) + @callback + def async_has_entries( + self, domain: str, include_ignore: bool = True, include_disabled: bool = True + ) -> bool: + """Return if there are entries for a domain.""" + entries = self._entries.get_entries_for_domain(domain) + if include_ignore and include_disabled: + return bool(entries) + return any( + entry + for entry in entries + if (include_ignore or entry.source != SOURCE_IGNORE) + and (include_disabled or not entry.disabled_by) + ) + @callback def async_entries( self, @@ -1612,15 +1718,16 @@ class ConfigEntries: if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry - if not entry.state.recoverable: - unload_success = entry.state is not ConfigEntryState.FAILED_UNLOAD - else: - unload_success = await self.async_unload(entry_id) + async with entry.setup_lock: + if not entry.state.recoverable: + unload_success = entry.state is not ConfigEntryState.FAILED_UNLOAD + else: + unload_success = await self.async_unload(entry_id, _lock=False) - await entry.async_remove(self.hass) + await entry.async_remove(self.hass) - del self._entries[entry.entry_id] - self._async_schedule_save() + del self._entries[entry.entry_id] + self._async_schedule_save() dev_reg = device_registry.async_get(self.hass) ent_reg = entity_registry.async_get(self.hass) @@ -1665,13 +1772,7 @@ class ConfigEntries: async def async_initialize(self) -> None: """Initialize config entry config.""" - # Migrating for config entries stored before 0.73 - config = await storage.async_migrator( - self.hass, - self.hass.config.path(PATH_CONFIG), - self._store, - old_conf_migrate_func=_old_conf_migrator, - ) + config = await self._store.async_load() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) @@ -1681,43 +1782,27 @@ class ConfigEntries: entries: ConfigEntryItems = ConfigEntryItems(self.hass) for entry in config["entries"]: - pref_disable_new_entities = entry.get("pref_disable_new_entities") - - # Between 0.98 and 2021.6 we stored 'disable_new_entities' in a - # system options dictionary. - if pref_disable_new_entities is None and "system_options" in entry: - pref_disable_new_entities = entry.get("system_options", {}).get( - "disable_new_entities" - ) - - domain = entry["domain"] entry_id = entry["entry_id"] config_entry = ConfigEntry( - version=entry["version"], - minor_version=entry.get("minor_version", 1), - domain=domain, - entry_id=entry_id, data=entry["data"], + disabled_by=try_parse_enum(ConfigEntryDisabler, entry["disabled_by"]), + domain=entry["domain"], + entry_id=entry_id, + minor_version=entry["minor_version"], + options=entry["options"], + pref_disable_new_entities=entry["pref_disable_new_entities"], + pref_disable_polling=entry["pref_disable_polling"], source=entry["source"], title=entry["title"], - # New in 0.89 - options=entry.get("options"), - # New in 0.104 - unique_id=entry.get("unique_id"), - # New in 2021.3 - disabled_by=ConfigEntryDisabler(entry["disabled_by"]) - if entry.get("disabled_by") - else None, - # New in 2021.6 - pref_disable_new_entities=pref_disable_new_entities, - pref_disable_polling=entry.get("pref_disable_polling"), + unique_id=entry["unique_id"], + version=entry["version"], ) entries[entry_id] = config_entry self._entries = entries - async def async_setup(self, entry_id: str) -> bool: + async def async_setup(self, entry_id: str, _lock: bool = True) -> bool: """Set up a config entry. Return True if entry has been successfully loaded. @@ -1727,14 +1812,18 @@ class ConfigEntries: if entry.state is not ConfigEntryState.NOT_LOADED: raise OperationNotAllowed( - f"The config entry {entry.title} ({entry.domain}) with entry_id" - f" {entry.entry_id} cannot be setup because is already loaded in the" - f" {entry.state} state" + f"The config entry '{entry.title}' ({entry.domain}) with entry_id" + f" '{entry.entry_id}' cannot be set up because it is in state " + f"{entry.state}, but needs to be in the {ConfigEntryState.NOT_LOADED} state" ) # Setup Component if not set up yet if entry.domain in self.hass.config.components: - await entry.async_setup(self.hass) + if _lock: + async with entry.setup_lock: + await entry.async_setup(self.hass) + else: + await entry.async_setup(self.hass) else: # Setting up the component will set up all its config entries result = await async_setup_component( @@ -1748,18 +1837,22 @@ class ConfigEntries: entry.state is ConfigEntryState.LOADED # type: ignore[comparison-overlap] ) - async def async_unload(self, entry_id: str) -> bool: + async def async_unload(self, entry_id: str, _lock: bool = True) -> bool: """Unload a config entry.""" if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry if not entry.state.recoverable: raise OperationNotAllowed( - f"The config entry {entry.title} ({entry.domain}) with entry_id" - f" {entry.entry_id} cannot be unloaded because it is not in a" - f" recoverable state ({entry.state})" + f"The config entry '{entry.title}' ({entry.domain}) with entry_id" + f" '{entry.entry_id}' cannot be unloaded because it is in the non" + f" recoverable state {entry.state}" ) + if _lock: + async with entry.setup_lock: + return await entry.async_unload(self.hass) + return await entry.async_unload(self.hass) @callback @@ -1801,12 +1894,12 @@ class ConfigEntries: return entry.state is ConfigEntryState.LOADED async with entry.setup_lock: - unload_result = await self.async_unload(entry_id) + unload_result = await self.async_unload(entry_id, _lock=False) if not unload_result or entry.disabled_by: return unload_result - return await self.async_setup(entry_id) + return await self.async_setup(entry_id, _lock=False) async def async_set_disabled_by( self, entry_id: str, disabled_by: ConfigEntryDisabler | None @@ -1880,6 +1973,7 @@ class ConfigEntries: if entry.entry_id not in self._entries: raise UnknownEntry(entry.entry_id) + self.hass.verify_event_loop_thread("hass.config_entries.async_update_entry") changed = False _setter = object.__setattr__ @@ -1928,23 +2022,60 @@ class ConfigEntries: self, change_type: ConfigEntryChange, entry: ConfigEntry ) -> None: """Dispatch a config entry change.""" - async_dispatcher_send( + async_dispatcher_send_internal( self.hass, SIGNAL_CONFIG_ENTRY_CHANGED, change_type, entry ) async def async_forward_entry_setups( self, entry: ConfigEntry, platforms: Iterable[Platform | str] ) -> None: - """Forward the setup of an entry to platforms.""" + """Forward the setup of an entry to platforms. + + This method should be awaited before async_setup_entry is finished + in each integration. This is to ensure that all platforms are loaded + before the entry is set up. This ensures that the config entry cannot + be unloaded before all platforms are loaded. + + This method is more efficient than async_forward_entry_setup as + it can load multiple platforms at once and does not require a separate + import executor job for each platform. + """ integration = await loader.async_get_integration(self.hass, entry.domain) if not integration.platforms_are_loaded(platforms): with async_pause_setup(self.hass, SetupPhases.WAIT_IMPORT_PLATFORMS): await integration.async_get_platforms(platforms) + + if not entry.setup_lock.locked(): + async with entry.setup_lock: + if entry.state is not ConfigEntryState.LOADED: + raise OperationNotAllowed( + f"The config entry '{entry.title}' ({entry.domain}) with " + f"entry_id '{entry.entry_id}' cannot forward setup for " + f"{platforms} because it is in state {entry.state}, but needs " + f"to be in the {ConfigEntryState.LOADED} state" + ) + await self._async_forward_entry_setups_locked(entry, platforms) + else: + await self._async_forward_entry_setups_locked(entry, platforms) + # If the lock was held when we stated, and it was released during + # the platform setup, it means they did not await the setup call. + if not entry.setup_lock.locked(): + _report_non_awaited_platform_forwards( + entry, "async_forward_entry_setups" + ) + + async def _async_forward_entry_setups_locked( + self, entry: ConfigEntry, platforms: Iterable[Platform | str] + ) -> None: await asyncio.gather( *( create_eager_task( self._async_forward_entry_setup(entry, platform, False), - name=f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", + name=( + f"config entry forward setup {entry.title} " + f"{entry.domain} {entry.entry_id} {platform}" + ), + loop=self.hass.loop, ) for platform in platforms ) @@ -1958,11 +2089,44 @@ class ConfigEntries: By default an entry is setup with the component it belongs to. If that component also has related platforms, the component will have to forward the entry to be setup by that component. + + This method is deprecated and will stop working in Home Assistant 2025.6. + + Instead, await async_forward_entry_setups as it can load + multiple platforms at once and is more efficient since it + does not require a separate import executor job for each platform. """ - return await self._async_forward_entry_setup(entry, domain, True) + report( + "calls async_forward_entry_setup for " + f"integration, {entry.domain} with title: {entry.title} " + f"and entry_id: {entry.entry_id}, which is deprecated and " + "will stop working in Home Assistant 2025.6, " + "await async_forward_entry_setups instead", + error_if_core=False, + error_if_integration=False, + ) + if not entry.setup_lock.locked(): + async with entry.setup_lock: + if entry.state is not ConfigEntryState.LOADED: + raise OperationNotAllowed( + f"The config entry '{entry.title}' ({entry.domain}) with " + f"entry_id '{entry.entry_id}' cannot forward setup for " + f"{domain} because it is in state {entry.state}, but needs " + f"to be in the {ConfigEntryState.LOADED} state" + ) + return await self._async_forward_entry_setup(entry, domain, True) + result = await self._async_forward_entry_setup(entry, domain, True) + # If the lock was held when we stated, and it was released during + # the platform setup, it means they did not await the setup call. + if not entry.setup_lock.locked(): + _report_non_awaited_platform_forwards(entry, "async_forward_entry_setup") + return result async def _async_forward_entry_setup( - self, entry: ConfigEntry, domain: Platform | str, preload_platform: bool + self, + entry: ConfigEntry, + domain: Platform | str, + preload_platform: bool, ) -> bool: """Forward the setup of an entry to a different component.""" # Setup Component if not set up yet @@ -1984,7 +2148,7 @@ class ConfigEntries: with async_pause_setup(self.hass, SetupPhases.WAIT_IMPORT_PLATFORMS): await integration.async_get_platform(domain) - integration = await loader.async_get_integration(self.hass, domain) + integration = loader.async_get_loaded_integration(self.hass, domain) await entry.async_setup(self.hass, integration=integration) return True @@ -1997,7 +2161,11 @@ class ConfigEntries: *( create_eager_task( self.async_forward_entry_unload(entry, platform), - name=f"config entry forward unload {entry.title} {entry.domain} {entry.entry_id} {platform}", + name=( + f"config entry forward unload {entry.title} " + f"{entry.domain} {entry.entry_id} {platform}" + ), + loop=self.hass.loop, ) for platform in platforms ) @@ -2007,12 +2175,16 @@ class ConfigEntries: async def async_forward_entry_unload( self, entry: ConfigEntry, domain: Platform | str ) -> bool: - """Forward the unloading of an entry to a different component.""" + """Forward the unloading of an entry to a different component. + + Its is preferred to call async_unload_platforms instead + of directly calling this method. + """ # It was never loaded. if domain not in self.hass.config.components: return True - integration = await loader.async_get_integration(self.hass, domain) + integration = loader.async_get_loaded_integration(self.hass, domain) return await entry.async_unload(self.hass, integration=integration) @@ -2035,20 +2207,13 @@ class ConfigEntries: Config entries which are created after Home Assistant is started can't be waited for, the function will just return if the config entry is loaded or not. """ - setup_done: dict[str, asyncio.Future[bool]] = self.hass.data.get( - DATA_SETUP_DONE, {} - ) + setup_done = self.hass.data.get(DATA_SETUP_DONE, {}) if setup_future := setup_done.get(entry.domain): await setup_future # The component was not loaded. if entry.domain not in self.hass.config.components: return False - return entry.state == ConfigEntryState.LOADED - - -async def _old_conf_migrator(old_config: dict[str, Any]) -> dict[str, Any]: - """Migrate the pre-0.73 config format to the latest version.""" - return {"entries": old_config} + return entry.state is ConfigEntryState.LOADED @callback @@ -2393,8 +2558,8 @@ class ConfigFlow(ConfigEntryBaseFlow): description_placeholders=description_placeholders, ) - result["options"] = options or {} result["minor_version"] = self.MINOR_VERSION + result["options"] = options or {} result["version"] = self.VERSION return result diff --git a/homeassistant/const.py b/homeassistant/const.py index 45ff6ecf976..577e8df6f39 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -14,6 +14,7 @@ from .helpers.deprecation import ( dir_with_deprecated_constants, ) from .util.event_type import EventType +from .util.hass_dict import HassKey from .util.signal_type import SignalType if TYPE_CHECKING: @@ -22,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 6 +MINOR_VERSION: Final = 7 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -82,6 +83,9 @@ class Platform(StrEnum): WEATHER = "weather" +BASE_PLATFORMS: Final = {platform.value for platform in Platform} + + # Can be used to specify a catch all when registering state or event listeners. MATCH_ALL: Final = "*" @@ -112,6 +116,7 @@ CONF_ACCESS_TOKEN: Final = "access_token" CONF_ADDRESS: Final = "address" CONF_AFTER: Final = "after" CONF_ALIAS: Final = "alias" +CONF_LLM_HASS_API = "llm_hass_api" CONF_ALLOWLIST_EXTERNAL_URLS: Final = "allowlist_external_urls" CONF_API_KEY: Final = "api_key" CONF_API_TOKEN: Final = "api_token" @@ -1124,8 +1129,21 @@ _DEPRECATED_MASS_POUNDS: Final = DeprecatedConstantEnum( ) """Deprecated: please use UnitOfMass.POUNDS""" + # Conductivity units -CONDUCTIVITY: Final = "µS/cm" +class UnitOfConductivity(StrEnum): + """Conductivity units.""" + + SIEMENS = "S/cm" + MICROSIEMENS = "µS/cm" + MILLISIEMENS = "mS/cm" + + +_DEPRECATED_CONDUCTIVITY: Final = DeprecatedConstantEnum( + UnitOfConductivity.MICROSIEMENS, + "2025.6", +) +"""Deprecated: please use UnitOfConductivity.MICROSIEMENS""" # Light units LIGHT_LUX: Final = "lx" @@ -1625,7 +1643,7 @@ SIGNAL_BOOTSTRAP_INTEGRATIONS: SignalType[dict[str, float]] = SignalType( # hass.data key for logging information. -KEY_DATA_LOGGING = "logging" +KEY_DATA_LOGGING: HassKey[str] = HassKey("logging") # Date/Time formats @@ -1633,6 +1651,12 @@ FORMAT_DATE: Final = "%Y-%m-%d" FORMAT_TIME: Final = "%H:%M:%S" FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" + +# Maximum entities expected in the state machine +# This is not a hard limit, but caches and other +# data structures will be pre-allocated to this size +MAX_EXPECTED_ENTITY_IDS: Final = 16384 + # These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) __dir__ = partial( diff --git a/homeassistant/core.py b/homeassistant/core.py index 40d6a544713..ac287fb2d5f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -35,12 +35,11 @@ from time import monotonic from typing import ( TYPE_CHECKING, Any, + Final, Generic, NotRequired, - ParamSpec, Self, TypedDict, - TypeVarTuple, cast, overload, ) @@ -56,6 +55,7 @@ from .const import ( ATTR_FRIENDLY_NAME, ATTR_SERVICE, ATTR_SERVICE_DATA, + BASE_PLATFORMS, COMPRESSED_STATE_ATTRIBUTES, COMPRESSED_STATE_CONTEXT, COMPRESSED_STATE_LAST_CHANGED, @@ -74,6 +74,7 @@ from .const import ( EVENT_STATE_CHANGED, EVENT_STATE_REPORTED, MATCH_ALL, + MAX_EXPECTED_ENTITY_IDS, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_STATE, UnitOfLength, @@ -95,6 +96,7 @@ from .helpers.deprecation import ( dir_with_deprecated_constants, ) from .helpers.json import json_bytes, json_fragment +from .helpers.typing import UNDEFINED, UndefinedType from .util import dt as dt_util, location from .util.async_ import ( cancelling, @@ -104,6 +106,7 @@ from .util.async_ import ( ) from .util.event_type import EventType from .util.executor import InterruptibleThreadPoolExecutor +from .util.hass_dict import HassDict from .util.json import JsonObjectType from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager @@ -129,29 +132,21 @@ FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 -_T = TypeVar("_T") -_R = TypeVar("_R") -_R_co = TypeVar("_R_co", covariant=True) -_P = ParamSpec("_P") -_Ts = TypeVarTuple("_Ts") -# Internal; not helpers.typing.UNDEFINED due to circular dependency -_UNDEF: dict[Any, Any] = {} _SENTINEL = object() -_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) _DataT = TypeVar("_DataT", bound=Mapping[str, Any], default=Mapping[str, Any]) -CALLBACK_TYPE = Callable[[], None] +type CALLBACK_TYPE = Callable[[], None] CORE_STORAGE_KEY = "core.config" CORE_STORAGE_VERSION = 1 -CORE_STORAGE_MINOR_VERSION = 3 +CORE_STORAGE_MINOR_VERSION = 4 DOMAIN = "homeassistant" # How long to wait to log tasks that are blocking BLOCK_LOG_TIMEOUT = 60 -ServiceResponse = JsonObjectType | None -EntityServiceResponse = dict[str, ServiceResponse] +type ServiceResponse = JsonObjectType | None +type EntityServiceResponse = dict[str, ServiceResponse] class ConfigSource(enum.StrEnum): @@ -182,7 +177,6 @@ _DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1") # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 -MAX_EXPECTED_ENTITY_IDS = 16384 EVENTS_EXCLUDED_FROM_MATCH_ALL = { EVENT_HOMEASSISTANT_CLOSE, @@ -232,7 +226,7 @@ def validate_state(state: str) -> str: return state -def callback(func: _CallableT) -> _CallableT: +def callback[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" setattr(func, "_hass_callback", True) return func @@ -273,8 +267,16 @@ def async_get_hass() -> HomeAssistant: This should be used where it's very cumbersome or downright impossible to pass hass to the code which needs it. """ - if not _hass.hass: + if not (hass := async_get_hass_or_none()): raise HomeAssistantError("async_get_hass called from the wrong thread") + return hass + + +def async_get_hass_or_none() -> HomeAssistant | None: + """Return the HomeAssistant instance or None. + + Returns None when called from the wrong thread. + """ return _hass.hass @@ -307,7 +309,7 @@ class HassJobType(enum.Enum): Executor = 3 -class HassJob(Generic[_P, _R_co]): +class HassJob[**_P, _R_co]: """Represent a job to be run later. We check the callable type in advance @@ -324,15 +326,18 @@ class HassJob(Generic[_P, _R_co]): job_type: HassJobType | None = None, ) -> None: """Create a job object.""" - self.target = target + self.target: Final = target self.name = name self._cancel_on_shutdown = cancel_on_shutdown - self._job_type = job_type + if job_type: + # Pre-set the cached_property so we + # avoid the function call + self.__dict__["job_type"] = job_type @cached_property def job_type(self) -> HassJobType: """Return the job type.""" - return self._job_type or get_hassjob_callable_job_type(self.target) + return get_hassjob_callable_job_type(self.target) @property def cancel_on_shutdown(self) -> bool | None: @@ -406,7 +411,7 @@ class HomeAssistant: from . import loader # This is a dictionary that any component can store any data on. - self.data: dict[str, Any] = {} + self.data = HassDict() self.loop = asyncio.get_running_loop() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() @@ -428,20 +433,17 @@ class HomeAssistant: self.import_executor = InterruptibleThreadPoolExecutor( max_workers=1, thread_name_prefix="ImportExecutor" ) + self.loop_thread_id = getattr( + self.loop, "_thread_ident", getattr(self.loop, "_thread_id") + ) def verify_event_loop_thread(self, what: str) -> None: """Report and raise if we are not running in the event loop thread.""" - if ( - loop_thread_ident := self.loop.__dict__.get("_thread_ident") - ) and loop_thread_ident != threading.get_ident(): + if self.loop_thread_id != threading.get_ident(): + # frame is a circular import, so we import it here from .helpers import frame # pylint: disable=import-outside-toplevel - # frame is a circular import, so we import it here - frame.report( - f"calls {what} from a thread", - error_if_core=True, - error_if_integration=True, - ) + frame.report_non_thread_safe_operation(what) @property def _active_tasks(self) -> set[asyncio.Future[Any]]: @@ -556,7 +558,7 @@ class HomeAssistant: self.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE) self.bus.async_fire_internal(EVENT_HOMEASSISTANT_STARTED) - def add_job( + def add_job[*_Ts]( self, target: Callable[[*_Ts], Any] | Coroutine[Any, Any, Any], *args: *_Ts ) -> None: """Add a job to be executed by the event loop or by an executor. @@ -574,15 +576,13 @@ class HomeAssistant: functools.partial(self.async_create_task, target, eager_start=True) ) return - if TYPE_CHECKING: - target = cast(Callable[[*_Ts], Any], target) self.loop.call_soon_threadsafe( functools.partial(self._async_add_hass_job, HassJob(target), *args) ) @overload @callback - def async_add_job( + def async_add_job[_R, *_Ts]( self, target: Callable[[*_Ts], Coroutine[Any, Any, _R]], *args: *_Ts, @@ -591,7 +591,7 @@ class HomeAssistant: @overload @callback - def async_add_job( + def async_add_job[_R, *_Ts]( self, target: Callable[[*_Ts], Coroutine[Any, Any, _R] | _R], *args: *_Ts, @@ -600,7 +600,7 @@ class HomeAssistant: @overload @callback - def async_add_job( + def async_add_job[_R]( self, target: Coroutine[Any, Any, _R], *args: Any, @@ -608,7 +608,7 @@ class HomeAssistant: ) -> asyncio.Future[_R] | None: ... @callback - def async_add_job( + def async_add_job[_R, *_Ts]( self, target: Callable[[*_Ts], Coroutine[Any, Any, _R] | _R] | Coroutine[Any, Any, _R], @@ -642,17 +642,11 @@ class HomeAssistant: if asyncio.iscoroutine(target): return self.async_create_task(target, eager_start=eager_start) - # This code path is performance sensitive and uses - # if TYPE_CHECKING to avoid the overhead of constructing - # the type used for the cast. For history see: - # https://github.com/home-assistant/core/pull/71960 - if TYPE_CHECKING: - target = cast(Callable[[*_Ts], Coroutine[Any, Any, _R] | _R], target) return self._async_add_hass_job(HassJob(target), *args) @overload @callback - def async_add_hass_job( + def async_add_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any, @@ -662,7 +656,7 @@ class HomeAssistant: @overload @callback - def async_add_hass_job( + def async_add_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, @@ -671,7 +665,7 @@ class HomeAssistant: ) -> asyncio.Future[_R] | None: ... @callback - def async_add_hass_job( + def async_add_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, @@ -702,7 +696,7 @@ class HomeAssistant: @overload @callback - def _async_add_hass_job( + def _async_add_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any, @@ -711,7 +705,7 @@ class HomeAssistant: @overload @callback - def _async_add_hass_job( + def _async_add_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, @@ -719,7 +713,7 @@ class HomeAssistant: ) -> asyncio.Future[_R] | None: ... @callback - def _async_add_hass_job( + def _async_add_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, @@ -741,9 +735,7 @@ class HomeAssistant: # https://github.com/home-assistant/core/pull/71960 if hassjob.job_type is HassJobType.Coroutinefunction: if TYPE_CHECKING: - hassjob.target = cast( - Callable[..., Coroutine[Any, Any, _R]], hassjob.target - ) + hassjob = cast(HassJob[..., Coroutine[Any, Any, _R]], hassjob) task = create_eager_task( hassjob.target(*args), name=hassjob.name, loop=self.loop ) @@ -751,12 +743,12 @@ class HomeAssistant: return task elif hassjob.job_type is HassJobType.Callback: if TYPE_CHECKING: - hassjob.target = cast(Callable[..., _R], hassjob.target) + hassjob = cast(HassJob[..., _R], hassjob) self.loop.call_soon(hassjob.target, *args) return None else: if TYPE_CHECKING: - hassjob.target = cast(Callable[..., _R], hassjob.target) + hassjob = cast(HassJob[..., _R], hassjob) task = self.loop.run_in_executor(None, hassjob.target, *args) task_bucket = self._background_tasks if background else self._tasks @@ -779,7 +771,7 @@ class HomeAssistant: ) @callback - def async_create_task( + def async_create_task[_R]( self, target: Coroutine[Any, Any, _R], name: str | None = None, @@ -792,20 +784,14 @@ class HomeAssistant: target: target to call. """ - # We turned on asyncio debug in April 2024 in the dev containers - # in the hope of catching some of the issues that have been - # reported. It will take a while to get all the issues fixed in - # custom components. - # - # In 2025.5 we should guard the `verify_event_loop_thread` - # check with a check for the `hass.config.debug` flag being set as - # long term we don't want to be checking this in production - # environments since it is a performance hit. - self.verify_event_loop_thread("async_create_task") + if self.loop_thread_id != threading.get_ident(): + from .helpers import frame # pylint: disable=import-outside-toplevel + + frame.report_non_thread_safe_operation("hass.async_create_task") return self.async_create_task_internal(target, name, eager_start) @callback - def async_create_task_internal( + def async_create_task_internal[_R]( self, target: Coroutine[Any, Any, _R], name: str | None = None, @@ -836,7 +822,7 @@ class HomeAssistant: return task @callback - def async_create_background_task( + def async_create_background_task[_R]( self, target: Coroutine[Any, Any, _R], name: str, eager_start: bool = True ) -> asyncio.Task[_R]: """Create a task from within the event loop. @@ -868,7 +854,7 @@ class HomeAssistant: return task @callback - def async_add_executor_job( + def async_add_executor_job[*_Ts, _T]( self, target: Callable[[*_Ts], _T], *args: *_Ts ) -> asyncio.Future[_T]: """Add an executor job from within the event loop.""" @@ -882,7 +868,7 @@ class HomeAssistant: return task @callback - def async_add_import_executor_job( + def async_add_import_executor_job[*_Ts, _T]( self, target: Callable[[*_Ts], _T], *args: *_Ts ) -> asyncio.Future[_T]: """Add an import executor job from within the event loop. @@ -893,7 +879,7 @@ class HomeAssistant: @overload @callback - def async_run_hass_job( + def async_run_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any, @@ -902,7 +888,7 @@ class HomeAssistant: @overload @callback - def async_run_hass_job( + def async_run_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, @@ -910,7 +896,7 @@ class HomeAssistant: ) -> asyncio.Future[_R] | None: ... @callback - def async_run_hass_job( + def async_run_hass_job[_R]( self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, @@ -931,7 +917,7 @@ class HomeAssistant: # https://github.com/home-assistant/core/pull/71960 if hassjob.job_type is HassJobType.Callback: if TYPE_CHECKING: - hassjob.target = cast(Callable[..., _R], hassjob.target) + hassjob = cast(HassJob[..., _R], hassjob) hassjob.target(*args) return None @@ -939,24 +925,24 @@ class HomeAssistant: @overload @callback - def async_run_job( + def async_run_job[_R, *_Ts]( self, target: Callable[[*_Ts], Coroutine[Any, Any, _R]], *args: *_Ts ) -> asyncio.Future[_R] | None: ... @overload @callback - def async_run_job( + def async_run_job[_R, *_Ts]( self, target: Callable[[*_Ts], Coroutine[Any, Any, _R] | _R], *args: *_Ts ) -> asyncio.Future[_R] | None: ... @overload @callback - def async_run_job( + def async_run_job[_R]( self, target: Coroutine[Any, Any, _R], *args: Any ) -> asyncio.Future[_R] | None: ... @callback - def async_run_job( + def async_run_job[_R, *_Ts]( self, target: Callable[[*_Ts], Coroutine[Any, Any, _R] | _R] | Coroutine[Any, Any, _R], @@ -983,12 +969,6 @@ class HomeAssistant: if asyncio.iscoroutine(target): return self.async_create_task(target, eager_start=True) - # This code path is performance sensitive and uses - # if TYPE_CHECKING to avoid the overhead of constructing - # the type used for the cast. For history see: - # https://github.com/home-assistant/core/pull/71960 - if TYPE_CHECKING: - target = cast(Callable[[*_Ts], Coroutine[Any, Any, _R] | _R], target) return self.async_run_hass_job(HassJob(target), *args) def block_till_done(self, wait_background_tasks: bool = False) -> None: @@ -1202,7 +1182,7 @@ class HomeAssistant: _LOGGER.exception( "Task %s could not be canceled during final shutdown stage", task ) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Task %s error during final shutdown stage", task) # Prevent run_callback_threadsafe from scheduling any additional @@ -1230,12 +1210,11 @@ class HomeAssistant: def _cancel_cancellable_timers(self) -> None: """Cancel timer handles marked as cancellable.""" - # pylint: disable-next=protected-access - handles: Iterable[asyncio.TimerHandle] = self.loop._scheduled # type: ignore[attr-defined] + handles: Iterable[asyncio.TimerHandle] = self.loop._scheduled # type: ignore[attr-defined] # noqa: SLF001 for handle in handles: if ( not handle.cancelled() - and (args := handle._args) # pylint: disable=protected-access + and (args := handle._args) # noqa: SLF001 and type(job := args[0]) is HassJob and job.cancel_on_shutdown ): @@ -1266,6 +1245,14 @@ class Context: """Compare contexts.""" return isinstance(other, Context) and self.id == other.id + def __copy__(self) -> Context: + """Create a shallow copy of this context.""" + return Context(user_id=self.user_id, parent_id=self.parent_id, id=self.id) + + def __deepcopy__(self, memo: dict[int, Any]) -> Context: + """Create a deep copy of this context.""" + return Context(user_id=self.user_id, parent_id=self.parent_id, id=self.id) + @cached_property def _as_dict(self) -> dict[str, str | None]: """Return a dictionary representation of the context. @@ -1347,7 +1334,7 @@ class Event(Generic[_DataT]): # _as_dict is marked as protected # to avoid callers outside of this module # from misusing it by mistake. - "context": self.context._as_dict, # pylint: disable=protected-access + "context": self.context._as_dict, # noqa: SLF001 } def as_dict(self) -> ReadOnlyDict[str, Any]: @@ -1441,7 +1428,9 @@ class EventBus: def __init__(self, hass: HomeAssistant) -> None: """Initialize a new event bus.""" - self._listeners: dict[EventType[Any] | str, list[_FilterableJobType[Any]]] = {} + self._listeners: defaultdict[ + EventType[Any] | str, list[_FilterableJobType[Any]] + ] = defaultdict(list) self._match_all_listeners: list[_FilterableJobType[Any]] = [] self._listeners[MATCH_ALL] = self._match_all_listeners self._hass = hass @@ -1493,7 +1482,10 @@ class EventBus: This method must be run in the event loop. """ _verify_event_type_length_or_raise(event_type) - self._hass.verify_event_loop_thread("async_fire") + if self._hass.loop_thread_id != threading.get_ident(): + from .helpers import frame # pylint: disable=import-outside-toplevel + + frame.report_non_thread_safe_operation("hass.bus.async_fire") return self.async_fire_internal( event_type, event_data, origin, context, time_fired ) @@ -1516,7 +1508,6 @@ class EventBus: This method must be run in the event loop. """ - if self._debug: _LOGGER.debug( "Bus:Handling %s", _event_repr(event_type, origin, event_data) @@ -1527,22 +1518,14 @@ class EventBus: match_all_listeners = self._match_all_listeners else: match_all_listeners = EMPTY_LIST - if event_type == EVENT_STATE_CHANGED: - aliased_listeners = self._listeners.get(EVENT_STATE_REPORTED, EMPTY_LIST) - else: - aliased_listeners = EMPTY_LIST - listeners = listeners + match_all_listeners + aliased_listeners - if not listeners: - return event: Event[_DataT] | None = None - - for job, event_filter in listeners: + for job, event_filter in listeners + match_all_listeners: if event_filter is not None: try: if event_data is None or not event_filter(event_data): continue - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error in event filter") continue @@ -1557,7 +1540,7 @@ class EventBus: try: self._hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error running job: %s", job) def listen( @@ -1615,18 +1598,32 @@ class EventBus: if event_filter is not None and not is_callback_check_partial(event_filter): raise HomeAssistantError(f"Event filter {event_filter} is not a callback") + filterable_job = (HassJob(listener, f"listen {event_type}"), event_filter) if event_type == EVENT_STATE_REPORTED: if not event_filter: raise HomeAssistantError( f"Event filter is required for event {event_type}" ) - return self._async_listen_filterable_job( - event_type, - ( - HassJob(listener, f"listen {event_type}"), - event_filter, - ), - ) + # Special case for EVENT_STATE_REPORTED, we also want to listen to + # EVENT_STATE_CHANGED + self._listeners[EVENT_STATE_REPORTED].append(filterable_job) + self._listeners[EVENT_STATE_CHANGED].append(filterable_job) + return functools.partial( + self._async_remove_multiple_listeners, + (EVENT_STATE_REPORTED, EVENT_STATE_CHANGED), + filterable_job, + ) + return self._async_listen_filterable_job(event_type, filterable_job) + + @callback + def _async_remove_multiple_listeners( + self, + keys: Iterable[EventType[_DataT] | str], + filterable_job: _FilterableJobType[Any], + ) -> None: + """Remove multiple listeners for specific event_types.""" + for key in keys: + self._async_remove_listener(key, filterable_job) @callback def _async_listen_filterable_job( @@ -1634,7 +1631,8 @@ class EventBus: event_type: EventType[_DataT] | str, filterable_job: _FilterableJobType[_DataT], ) -> CALLBACK_TYPE: - self._listeners.setdefault(event_type, []).append(filterable_job) + """Listen for all events or events of a specific type.""" + self._listeners[event_type].append(filterable_job) return functools.partial( self._async_remove_listener, event_type, filterable_job ) @@ -1763,6 +1761,7 @@ class State: context: Context | None = None, validate_entity_id: bool | None = True, state_info: StateInfo | None = None, + last_updated_timestamp: float | None = None, ) -> None: """Initialize a new state.""" state = str(state) @@ -1793,9 +1792,17 @@ class State: # The recorder or the websocket_api will always call the timestamps, # so we will set the timestamp values here to avoid the overhead of # the function call in the property we know will always be called. - self.last_updated_timestamp = self.last_updated.timestamp() - if self.last_changed == self.last_updated: - self.__dict__["last_changed_timestamp"] = self.last_updated_timestamp + last_updated = self.last_updated + if not last_updated_timestamp: + last_updated_timestamp = last_updated.timestamp() + self.last_updated_timestamp = last_updated_timestamp + if self.last_changed == last_updated: + self.__dict__["last_changed_timestamp"] = last_updated_timestamp + # If last_reported is the same as last_updated async_set will pass + # the same datetime object for both values so we can use an identity + # check here. + if self.last_reported is last_updated: + self.__dict__["last_reported_timestamp"] = last_updated_timestamp @cached_property def name(self) -> str: @@ -1812,8 +1819,6 @@ class State: @cached_property def last_reported_timestamp(self) -> float: """Timestamp of last report.""" - if self.last_reported == self.last_updated: - return self.last_updated_timestamp return self.last_reported.timestamp() @cached_property @@ -1842,7 +1847,7 @@ class State: # _as_dict is marked as protected # to avoid callers outside of this module # from misusing it by mistake. - "context": self.context._as_dict, # pylint: disable=protected-access + "context": self.context._as_dict, # noqa: SLF001 } def as_dict( @@ -1897,7 +1902,7 @@ class State: # _as_dict is marked as protected # to avoid callers outside of this module # from misusing it by mistake. - context = state_context._as_dict # pylint: disable=protected-access + context = state_context._as_dict # noqa: SLF001 compressed_state: CompressedState = { COMPRESSED_STATE_STATE: self.state, COMPRESSED_STATE_ATTRIBUTES: self.attributes, @@ -2272,6 +2277,7 @@ class StateMachine: # mypy does not understand this is only possible if old_state is not None old_last_reported = old_state.last_reported # type: ignore[union-attr] old_state.last_reported = now # type: ignore[union-attr] + old_state.last_reported_timestamp = timestamp # type: ignore[union-attr] self._bus.async_fire_internal( EVENT_STATE_REPORTED, { @@ -2304,6 +2310,7 @@ class StateMachine: context, old_state is None, state_info, + timestamp, ) if old_state is not None: old_state.expire() @@ -2506,7 +2513,7 @@ class ServiceRegistry: This method must be run in the event loop. """ - self._hass.verify_event_loop_thread("async_register") + self._hass.verify_event_loop_thread("hass.services.async_register") self._async_register( domain, service, service_func, schema, supports_response, job_type ) @@ -2565,7 +2572,7 @@ class ServiceRegistry: This method must be run in the event loop. """ - self._hass.verify_event_loop_thread("async_remove") + self._hass.verify_event_loop_thread("hass.services.async_remove") self._async_remove(domain, service) @callback @@ -2751,7 +2758,7 @@ class ServiceRegistry: ) except asyncio.CancelledError: _LOGGER.debug("Service was cancelled: %s", service_call) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error executing service: %s", service_call) async def _execute_service( @@ -2762,14 +2769,16 @@ class ServiceRegistry: target = job.target if job.job_type is HassJobType.Coroutinefunction: if TYPE_CHECKING: - target = cast(Callable[..., Coroutine[Any, Any, _R]], target) + target = cast( + Callable[..., Coroutine[Any, Any, ServiceResponse]], target + ) return await target(service_call) if job.job_type is HassJobType.Callback: if TYPE_CHECKING: - target = cast(Callable[..., _R], target) + target = cast(Callable[..., ServiceResponse], target) return target(service_call) if TYPE_CHECKING: - target = cast(Callable[..., _R], target) + target = cast(Callable[..., ServiceResponse], target) return await self._hass.async_add_executor_job(target, service_call) @@ -2784,16 +2793,27 @@ class _ComponentSet(set[str]): The top level components set only contains the top level components. + The all components set contains all components, including platform + based components. + """ - def __init__(self, top_level_components: set[str]) -> None: + def __init__( + self, top_level_components: set[str], all_components: set[str] + ) -> None: """Initialize the component set.""" self._top_level_components = top_level_components + self._all_components = all_components def add(self, component: str) -> None: """Add a component to the store.""" if "." not in component: self._top_level_components.add(component) + self._all_components.add(component) + else: + platform, _, domain = component.partition(".") + if domain in BASE_PLATFORMS: + self._all_components.add(platform) return super().add(component) def remove(self, component: str) -> None: @@ -2815,6 +2835,9 @@ class Config: def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" + # pylint: disable-next=import-outside-toplevel + from .components.zone import DEFAULT_RADIUS + self.hass = hass self.latitude: float = 0 @@ -2823,6 +2846,9 @@ class Config: self.elevation: int = 0 """Elevation (always in meters regardless of the unit system).""" + self.radius: int = DEFAULT_RADIUS + """Radius of the Home Zone (always in meters regardless of the unit system).""" + self.debug: bool = False self.location_name: str = "Home" self.time_zone: str = "UTC" @@ -2846,8 +2872,14 @@ class Config: # and should not be modified directly self.top_level_components: set[str] = set() + # Set of all loaded components including platform + # based components + self.all_components: set[str] = set() + # Set of loaded components - self.components: _ComponentSet = _ComponentSet(self.top_level_components) + self.components: _ComponentSet = _ComponentSet( + self.top_level_components, self.all_components + ) # API (HTTP) server configuration self.api: ApiConfig | None = None @@ -2899,7 +2931,7 @@ class Config: def is_allowed_external_url(self, url: str) -> bool: """Check if an external URL is allowed.""" - parsed_url = f"{str(yarl.URL(url))}/" + parsed_url = f"{yarl.URL(url)!s}/" return any( allowed @@ -2965,18 +2997,41 @@ class Config: "language": self.language, "safe_mode": self.safe_mode, "debug": self.debug, + "radius": self.radius, } - def set_time_zone(self, time_zone_str: str) -> None: + async def async_set_time_zone(self, time_zone_str: str) -> None: """Help to set the time zone.""" + if time_zone := await dt_util.async_get_time_zone(time_zone_str): + self.time_zone = time_zone_str + dt_util.set_default_time_zone(time_zone) + else: + raise ValueError(f"Received invalid time zone {time_zone_str}") + + def set_time_zone(self, time_zone_str: str) -> None: + """Set the time zone. + + This is a legacy method that should not be used in new code. + Use async_set_time_zone instead. + + It will be removed in Home Assistant 2025.6. + """ + # report is imported here to avoid a circular import + from .helpers.frame import report # pylint: disable=import-outside-toplevel + + report( + "set the time zone using set_time_zone instead of async_set_time_zone" + " which will stop working in Home Assistant 2025.6", + error_if_core=True, + error_if_integration=True, + ) if time_zone := dt_util.get_time_zone(time_zone_str): self.time_zone = time_zone_str dt_util.set_default_time_zone(time_zone) else: raise ValueError(f"Received invalid time zone {time_zone_str}") - @callback - def _update( + async def _async_update( self, *, source: ConfigSource, @@ -2986,12 +3041,12 @@ class Config: unit_system: str | None = None, location_name: str | None = None, time_zone: str | None = None, - # pylint: disable=dangerous-default-value # _UNDEFs not modified - external_url: str | dict[Any, Any] | None = _UNDEF, - internal_url: str | dict[Any, Any] | None = _UNDEF, + external_url: str | UndefinedType | None = UNDEFINED, + internal_url: str | UndefinedType | None = UNDEFINED, currency: str | None = None, - country: str | dict[Any, Any] | None = _UNDEF, + country: str | UndefinedType | None = UNDEFINED, language: str | None = None, + radius: int | None = None, ) -> None: """Update the configuration from a dictionary.""" self.config_source = source @@ -3009,17 +3064,19 @@ class Config: if location_name is not None: self.location_name = location_name if time_zone is not None: - self.set_time_zone(time_zone) - if external_url is not _UNDEF: - self.external_url = cast(str | None, external_url) - if internal_url is not _UNDEF: - self.internal_url = cast(str | None, internal_url) + await self.async_set_time_zone(time_zone) + if external_url is not UNDEFINED: + self.external_url = external_url + if internal_url is not UNDEFINED: + self.internal_url = internal_url if currency is not None: self.currency = currency - if country is not _UNDEF: - self.country = cast(str | None, country) + if country is not UNDEFINED: + self.country = country if language is not None: self.language = language + if radius is not None: + self.radius = radius async def async_update(self, **kwargs: Any) -> None: """Update the configuration from a dictionary.""" @@ -3029,7 +3086,7 @@ class Config: _raise_issue_if_no_country, ) - self._update(source=ConfigSource.STORAGE, **kwargs) + await self._async_update(source=ConfigSource.STORAGE, **kwargs) await self._async_store() self.hass.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE, kwargs) @@ -3055,7 +3112,7 @@ class Config: ): _LOGGER.warning("Invalid internal_url set. It's not allowed to have a path") - self._update( + await self._async_update( source=ConfigSource.STORAGE, latitude=data.get("latitude"), longitude=data.get("longitude"), @@ -3063,11 +3120,12 @@ class Config: unit_system=data.get("unit_system_v2"), location_name=data.get("location_name"), time_zone=data.get("time_zone"), - external_url=data.get("external_url", _UNDEF), - internal_url=data.get("internal_url", _UNDEF), + external_url=data.get("external_url", UNDEFINED), + internal_url=data.get("internal_url", UNDEFINED), currency=data.get("currency"), country=data.get("country"), language=data.get("language"), + radius=data["radius"], ) async def _async_store(self) -> None: @@ -3078,7 +3136,7 @@ class Config: "elevation": self.elevation, # We don't want any integrations to use the name of the unit system # so we are using the private attribute here - "unit_system_v2": self.units._name, # pylint: disable=protected-access + "unit_system_v2": self.units._name, # noqa: SLF001 "location_name": self.location_name, "time_zone": self.time_zone, "external_url": self.external_url, @@ -3086,6 +3144,7 @@ class Config: "currency": self.currency, "country": self.country, "language": self.language, + "radius": self.radius, } await self._store.async_save(data) @@ -3115,6 +3174,10 @@ class Config: old_data: dict[str, Any], ) -> dict[str, Any]: """Migrate to the new version.""" + + # pylint: disable-next=import-outside-toplevel + from .components.zone import DEFAULT_RADIUS + data = old_data if old_major_version == 1 and old_minor_version < 2: # In 1.2, we remove support for "imperial", replaced by "us_customary" @@ -3151,6 +3214,9 @@ class Config: # pylint: disable-next=broad-except except Exception: _LOGGER.exception("Unexpected error during core config migration") + if old_major_version == 1 and old_minor_version < 4: + # In 1.4, we add the key "radius", initialize it with the default. + data.setdefault("radius", DEFAULT_RADIUS) if old_major_version > 1: raise NotImplementedError diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 0bd494992b6..de45702ad95 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations import abc import asyncio +from collections import defaultdict from collections.abc import Callable, Container, Iterable, Mapping from contextlib import suppress import copy @@ -154,7 +155,6 @@ class FlowResult(TypedDict, Generic[_HandlerT], total=False): handler: Required[_HandlerT] last_step: bool | None menu_options: Container[str] - options: Mapping[str, Any] preview: str | None progress_action: str progress_task: asyncio.Task[Any] | None @@ -204,12 +204,12 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): self.hass = hass self._preview: set[_HandlerT] = set() self._progress: dict[str, FlowHandler[_FlowResultT, _HandlerT]] = {} - self._handler_progress_index: dict[ + self._handler_progress_index: defaultdict[ _HandlerT, set[FlowHandler[_FlowResultT, _HandlerT]] - ] = {} - self._init_data_process_index: dict[ + ] = defaultdict(set) + self._init_data_process_index: defaultdict[ type, set[FlowHandler[_FlowResultT, _HandlerT]] - ] = {} + ] = defaultdict(set) @abc.abstractmethod async def async_create_flow( @@ -296,7 +296,7 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): return self._async_flow_handler_to_flow_result( ( progress - for progress in self._init_data_process_index.get(init_data_type, set()) + for progress in self._init_data_process_index.get(init_data_type, ()) if matcher(progress.init_data) ), include_uninitialized, @@ -472,10 +472,9 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): ) -> None: """Add a flow to in progress.""" if flow.init_data is not None: - init_data_type = type(flow.init_data) - self._init_data_process_index.setdefault(init_data_type, set()).add(flow) + self._init_data_process_index[type(flow.init_data)].add(flow) self._progress[flow.flow_id] = flow - self._handler_progress_index.setdefault(flow.handler, set()).add(flow) + self._handler_progress_index[flow.handler].add(flow) @callback def _async_remove_flow_from_index( @@ -501,7 +500,7 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): flow.async_cancel_progress_task() try: flow.async_remove() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error removing %s flow", flow.handler) async def _async_handle_step( @@ -609,19 +608,22 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): include_uninitialized: bool, ) -> list[_FlowResultT]: """Convert a list of FlowHandler to a partial FlowResult that can be serialized.""" - results = [] - for flow in flows: - if not include_uninitialized and flow.cur_step is None: - continue - result = self._flow_result( + return [ + self._flow_result( + flow_id=flow.flow_id, + handler=flow.handler, + context=flow.context, + step_id=flow.cur_step["step_id"], + ) + if flow.cur_step + else self._flow_result( flow_id=flow.flow_id, handler=flow.handler, context=flow.context, ) - if flow.cur_step: - result["step_id"] = flow.cur_step["step_id"] - results.append(result) - return results + for flow in flows + if include_uninitialized or flow.cur_step is not None + ] class FlowHandler(Generic[_FlowResultT, _HandlerT]): diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 044a41aab7a..01e22d16e79 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -2,10 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Generator, Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from typing_extensions import Generator + from .util.event_type import EventType if TYPE_CHECKING: @@ -138,7 +140,7 @@ class ConditionError(HomeAssistantError): """Return indentation.""" return " " * indent + message - def output(self, indent: int) -> Generator[str, None, None]: + def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" raise NotImplementedError @@ -154,7 +156,7 @@ class ConditionErrorMessage(ConditionError): # A message describing this error message: str - def output(self, indent: int) -> Generator[str, None, None]: + def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" yield self._indent(indent, f"In '{self.type}' condition: {self.message}") @@ -170,7 +172,7 @@ class ConditionErrorIndex(ConditionError): # The error that this error wraps error: ConditionError - def output(self, indent: int) -> Generator[str, None, None]: + def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" if self.total > 1: yield self._indent( @@ -189,7 +191,7 @@ class ConditionErrorContainer(ConditionError): # List of ConditionErrors that this error wraps errors: Sequence[ConditionError] - def output(self, indent: int) -> Generator[str, None, None]: + def output(self, indent: int) -> Generator[str]: """Yield an indented representation.""" for item in self.errors: yield from item.output(indent) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 15ae2e369de..bc6b29e4c23 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest """ APPLICATION_CREDENTIALS = [ + "aladdin_connect", "electric_kiwi", "fitbit", "geocaching", @@ -17,6 +18,7 @@ APPLICATION_CREDENTIALS = [ "lametric", "lyric", "microbees", + "monzo", "myuplink", "neato", "nest", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 3c18c27057a..17461225851 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -5,7 +5,9 @@ To update, run python3 -m script.hassfest from __future__ import annotations -BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ +from typing import Final + +BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ { "domain": "airthings_ble", "manufacturer_id": 820, @@ -346,6 +348,10 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "led_ble", "local_name": "AP-*", }, + { + "domain": "led_ble", + "local_name": "MELK-*", + }, { "domain": "medcom_ble", "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 301715ad111..cf6e2bb4fa7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest FLOWS = { "helper": [ "derivative", + "generic_thermostat", "group", "integration", "min_max", @@ -27,6 +28,7 @@ FLOWS = { "aemet", "aftership", "agent_dvr", + "airgradient", "airly", "airnow", "airq", @@ -41,7 +43,6 @@ FLOWS = { "aladdin_connect", "alarmdecoder", "amberelectric", - "ambiclimate", "ambient_network", "ambient_station", "analytics_insights", @@ -54,6 +55,8 @@ FLOWS = { "apcupsd", "apple_tv", "aprilaire", + "apsystems", + "aquacell", "aranet", "arcam_fmj", "arve", @@ -66,6 +69,7 @@ FLOWS = { "aussie_broadband", "awair", "axis", + "azure_data_explorer", "azure_devops", "azure_event_hub", "baf", @@ -163,7 +167,9 @@ FLOWS = { "ezviz", "faa_delays", "fastdotcom", + "feedreader", "fibaro", + "file", "filesize", "fireservicerota", "fitbit", @@ -253,6 +259,7 @@ FLOWS = { "imap", "imgw_pib", "improv_ble", + "incomfort", "inkbird", "insteon", "intellifire", @@ -263,9 +270,11 @@ FLOWS = { "iqvia", "islamic_prayer_times", "iss", + "ista_ecotrend", "isy994", "izone", "jellyfin", + "jewish_calendar", "juicenet", "justnimbus", "jvc_projector", @@ -274,6 +283,7 @@ FLOWS = { "kegtron", "keymitt_ble", "kmtronic", + "knocki", "knx", "kodi", "konnected", @@ -312,8 +322,10 @@ FLOWS = { "lyric", "mailgun", "matter", + "mealie", "meater", "medcom_ble", + "media_extractor", "melcloud", "melnor", "met", @@ -332,6 +344,7 @@ FLOWS = { "modern_forms", "moehlenhoff_alpha2", "monoprice", + "monzo", "moon", "mopeka", "motion_blinds", @@ -388,6 +401,7 @@ FLOWS = { "oralb", "osoenergy", "otbr", + "otp", "ourgroceries", "overkiz", "ovo_energy", @@ -546,6 +560,7 @@ FLOWS = { "tessie", "thermobeacon", "thermopro", + "thethingsnetwork", "thread", "tibber", "tile", diff --git a/homeassistant/generated/countries.py b/homeassistant/generated/countries.py index 452e65afb02..c3c912c4882 100644 --- a/homeassistant/generated/countries.py +++ b/homeassistant/generated/countries.py @@ -7,7 +7,11 @@ to the political situation in the world, please contact the ISO 3166 working gro """ -COUNTRIES = { +from __future__ import annotations + +from typing import Final + +COUNTRIES: Final[set[str]] = { "AD", "AE", "AF", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 9c5d25a7f22..3b5fe9843f2 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -5,7 +5,9 @@ To update, run python3 -m script.hassfest from __future__ import annotations -DHCP: list[dict[str, str | bool]] = [ +from typing import Final + +DHCP: Final[list[dict[str, str | bool]]] = [ { "domain": "airzone", "macaddress": "E84F25*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e1365820bf4..bbf96e4461b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -93,6 +93,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "airgradient": { + "name": "AirGradient", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "airly": { "name": "Airly", "integration_type": "service", @@ -182,7 +188,7 @@ }, "alarmdecoder": { "name": "AlarmDecoder", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -238,23 +244,22 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "ambiclimate": { - "name": "Ambiclimate", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, - "ambient_network": { - "name": "Ambient Weather Network", - "integration_type": "service", - "config_flow": true, - "iot_class": "cloud_polling" - }, - "ambient_station": { - "name": "Ambient Weather Station", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_push" + "ambient_weather": { + "name": "Ambient Weather", + "integrations": { + "ambient_network": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Ambient Weather Network" + }, + "ambient_station": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "name": "Ambient Weather Station" + } + } }, "amcrest": { "name": "Amcrest", @@ -308,7 +313,7 @@ "name": "Anova", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_push" }, "anthemav": { "name": "Anthem A/V Receivers", @@ -408,6 +413,18 @@ "config_flow": false, "iot_class": "cloud_push" }, + "apsystems": { + "name": "APsystems", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, + "aquacell": { + "name": "Aquacell", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "aqualogic": { "name": "AquaLogic", "integration_type": "hub", @@ -560,7 +577,7 @@ }, "aurora_abb_powerone": { "name": "Aurora ABB PowerOne Solar PV", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -588,6 +605,12 @@ "config_flow": true, "iot_class": "local_push" }, + "azure_data_explorer": { + "name": "Azure Data Explorer", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "baf": { "name": "Big Ass Fans", "integration_type": "hub", @@ -867,12 +890,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "circuit": { - "name": "Unify Circuit", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "cisco": { "name": "Cisco", "integrations": { @@ -1228,7 +1245,7 @@ "iot_class": "cloud_push" }, "discovergy": { - "name": "Discovergy", + "name": "inexogy", "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" @@ -1775,7 +1792,7 @@ "feedreader": { "name": "Feedreader", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "ffmpeg": { @@ -1815,7 +1832,7 @@ "file": { "name": "File", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "filesize": { @@ -2106,7 +2123,7 @@ "iot_class": "cloud_polling" }, "generic": { - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_push" }, @@ -2116,12 +2133,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "generic_thermostat": { - "name": "Generic Thermostat", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "geniushub": { "name": "Genius Hub", "integration_type": "hub", @@ -2265,7 +2276,7 @@ "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling", - "name": "Google Generative AI Conversation" + "name": "Google Generative AI" }, "google_mail": { "integration_type": "service", @@ -2642,7 +2653,7 @@ "iot_class": "local_polling" }, "huisbaasje": { - "name": "Huisbaasje", + "name": "EnergyFlip", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" @@ -2797,7 +2808,7 @@ "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "indianamichiganpower": { @@ -2910,6 +2921,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ista_ecotrend": { + "name": "ista EcoTrend", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "isy994": { "name": "Universal Devices ISY/IoX", "integration_type": "hub", @@ -2938,8 +2955,9 @@ "jewish_calendar": { "name": "Jewish Calendar", "integration_type": "hub", - "config_flow": false, - "iot_class": "calculated" + "config_flow": true, + "iot_class": "calculated", + "single_config_entry": true }, "joaoapps_join": { "name": "Joaoapps Join", @@ -3054,6 +3072,12 @@ "config_flow": true, "iot_class": "local_push" }, + "knocki": { + "name": "Knocki", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_push" + }, "knx": { "name": "KNX", "integration_type": "hub", @@ -3356,7 +3380,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", - "name": "Squeezebox (Logitech Media Server)" + "name": "Squeezebox (Lyrion Music Server)" } } }, @@ -3481,6 +3505,12 @@ "config_flow": true, "iot_class": "local_push" }, + "mealie": { + "name": "Mealie", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "meater": { "name": "Meater", "integration_type": "hub", @@ -3496,8 +3526,9 @@ "media_extractor": { "name": "Media Extractor", "integration_type": "hub", - "config_flow": false, - "iot_class": "calculated" + "config_flow": true, + "iot_class": "calculated", + "single_config_entry": true }, "mediaroom": { "name": "Mediaroom", @@ -3745,6 +3776,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "monzo": { + "name": "Monzo", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "moon": { "integration_type": "service", "config_flow": true, @@ -4383,7 +4420,7 @@ "otp": { "name": "One-Time Password (OTP)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "ourgroceries": { @@ -4743,7 +4780,7 @@ }, "pyload": { "name": "pyLoad", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "local_polling" }, @@ -4852,7 +4889,7 @@ "config_flow": true, "iot_class": "local_polling" }, - "rainforest": { + "rainforest_automation": { "name": "Rainforest Automation", "integrations": { "rainforest_eagle": { @@ -5134,17 +5171,22 @@ } } }, - "ruuvi_gateway": { - "name": "Ruuvi Gateway", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, - "ruuvitag_ble": { - "name": "RuuviTag BLE", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "ruuvi": { + "name": "Ruuvi", + "integrations": { + "ruuvi_gateway": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Ruuvi Gateway" + }, + "ruuvitag_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "RuuviTag BLE" + } + } }, "rympro": { "name": "Read Your Meter Pro", @@ -5876,7 +5918,8 @@ "name": "Switcher", "integration_type": "hub", "config_flow": true, - "iot_class": "local_push" + "iot_class": "local_push", + "single_config_entry": true }, "switchmate": { "name": "Switchmate SimplySmart Home", @@ -6126,8 +6169,8 @@ "thethingsnetwork": { "name": "The Things Network", "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" + "config_flow": true, + "iot_class": "cloud_polling" }, "thingspeak": { "name": "ThingSpeak", @@ -6723,15 +6766,20 @@ }, "weatherflow": { "name": "WeatherFlow", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" - }, - "weatherflow_cloud": { - "name": "WeatherflowCloud", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" + "integrations": { + "weatherflow": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "WeatherFlow" + }, + "weatherflow_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "WeatherflowCloud" + } + } }, "webhook": { "name": "Webhook", @@ -6846,7 +6894,7 @@ }, "wyoming": { "name": "Wyoming Protocol", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "local_push" }, @@ -7112,6 +7160,11 @@ "config_flow": true, "iot_class": "calculated" }, + "generic_thermostat": { + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_polling" + }, "group": { "integration_type": "helper", "config_flow": true, @@ -7212,6 +7265,7 @@ "filesize", "garages_amsterdam", "generic", + "generic_thermostat", "google_travel_time", "group", "growatt_server", diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 0c456774e4d..f73388b203c 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -10,6 +10,9 @@ MQTT = { "dsmr_reader": [ "dsmr/#", ], + "esphome": [ + "esphome/discover/#", + ], "fully_kiosk": [ "fully/deviceInfo/+", ], diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 7b1bbff9de0..8efe49b7892 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -277,6 +277,11 @@ ZEROCONF = { "domain": "romy", }, ], + "_airgradient._tcp.local.": [ + { + "domain": "airgradient", + }, + ], "_airplay._tcp.local.": [ { "domain": "apple_tv", @@ -399,6 +404,12 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_czc._tcp.local.": [ + { + "domain": "zha", + "name": "czc*", + }, + ], "_daap._tcp.local.": [ { "domain": "forked_daapd", @@ -806,6 +817,12 @@ ZEROCONF = { "domain": "kodi", }, ], + "_xzg._tcp.local.": [ + { + "domain": "zha", + "name": "xzg*", + }, + ], "_zigate-zigbee-gateway._tcp.local.": [ { "domain": "zha", diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 9f72445822e..abb9bc79dc8 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -1,60 +1 @@ """Helper methods for components within Home Assistant.""" - -from __future__ import annotations - -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .typing import ConfigType - - -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. - """ - # 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( - config_per_platform, - "config.config_per_platform", - "function", - "called", - "2024.6", - ) - return ha_config.config_per_platform(config, domain) - - -config_per_platform.__name__ = "helpers.config_per_platform" - - -def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: - """Extract keys from config for given domain name. - - Async friendly. - """ - # 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/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 2437d42da59..5c4ead4e611 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -20,6 +20,7 @@ from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __v from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util import ssl as ssl_util +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads from .backports.aiohttp_resolver import AsyncResolver @@ -30,8 +31,12 @@ if TYPE_CHECKING: from aiohttp.typedefs import JSONDecoder -DATA_CONNECTOR = "aiohttp_connector" -DATA_CLIENTSESSION = "aiohttp_clientsession" +DATA_CONNECTOR: HassKey[dict[tuple[bool, int], aiohttp.BaseConnector]] = HassKey( + "aiohttp_connector" +) +DATA_CLIENTSESSION: HassKey[dict[tuple[bool, int], aiohttp.ClientSession]] = HassKey( + "aiohttp_clientsession" +) SERVER_SOFTWARE = ( f"{APPLICATION_NAME}/{__version__} " @@ -84,11 +89,7 @@ def async_get_clientsession( This method must be run in the event loop. """ session_key = _make_key(verify_ssl, family) - if DATA_CLIENTSESSION not in hass.data: - sessions: dict[tuple[bool, int], aiohttp.ClientSession] = {} - hass.data[DATA_CLIENTSESSION] = sessions - else: - sessions = hass.data[DATA_CLIENTSESSION] + sessions = hass.data.setdefault(DATA_CLIENTSESSION, {}) if session_key not in sessions: session = _async_create_clientsession( @@ -155,8 +156,7 @@ def _async_create_clientsession( # It's important that we identify as Home Assistant # If a package requires a different user agent, override it by passing a headers # dictionary to the request method. - # pylint: disable-next=protected-access - clientsession._default_headers = MappingProxyType( # type: ignore[assignment] + clientsession._default_headers = MappingProxyType( # type: ignore[assignment] # noqa: SLF001 {USER_AGENT: SERVER_SOFTWARE}, ) @@ -289,11 +289,7 @@ def _async_get_connector( This method must be run in the event loop. """ connector_key = _make_key(verify_ssl, family) - if DATA_CONNECTOR not in hass.data: - connectors: dict[tuple[bool, int], aiohttp.BaseConnector] = {} - hass.data[DATA_CONNECTOR] = connectors - else: - connectors = hass.data[DATA_CONNECTOR] + connectors = hass.data.setdefault(DATA_CONNECTOR, {}) if connector_key in connectors: return connectors[connector_key] diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 4dba510396f..975750ebbdd 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -2,25 +2,30 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Iterable import dataclasses -from typing import Any, Literal, TypedDict, cast +from functools import cached_property +from typing import Any, Literal, TypedDict from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from . import device_registry as dr, entity_registry as er +from .json import json_bytes, json_fragment from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, NormalizedNameBaseRegistryItems, normalize_name, ) -from .registry import BaseRegistry +from .registry import BaseRegistry, RegistryIndexType +from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType -DATA_REGISTRY = "area_registry" +DATA_REGISTRY: HassKey[AreaRegistry] = HassKey("area_registry") EVENT_AREA_REGISTRY_UPDATED: EventType[EventAreaRegistryUpdatedData] = EventType( "area_registry_updated" ) @@ -54,7 +59,7 @@ class EventAreaRegistryUpdatedData(TypedDict): area_id: str -@dataclasses.dataclass(frozen=True, kw_only=True, slots=True) +@dataclasses.dataclass(frozen=True, kw_only=True) class AreaEntry(NormalizedNameBaseRegistryEntry): """Area Registry Entry.""" @@ -65,6 +70,23 @@ class AreaEntry(NormalizedNameBaseRegistryEntry): labels: set[str] = dataclasses.field(default_factory=set) picture: str | None + @cached_property + def json_fragment(self) -> json_fragment: + """Return a JSON representation of this AreaEntry.""" + return json_fragment( + json_bytes( + { + "aliases": list(self.aliases), + "area_id": self.id, + "floor_id": self.floor_id, + "icon": self.icon, + "labels": list(self.labels), + "name": self.name, + "picture": self.picture, + } + ) + ) + class AreaRegistryStore(Store[AreasRegistryStoreData]): """Store area registry data.""" @@ -114,15 +136,15 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): def __init__(self) -> None: """Initialize the area registry items.""" super().__init__() - self._labels_index: dict[str, dict[str, Literal[True]]] = {} - self._floors_index: dict[str, dict[str, Literal[True]]] = {} + self._labels_index: RegistryIndexType = defaultdict(dict) + self._floors_index: RegistryIndexType = defaultdict(dict) def _index_entry(self, key: str, entry: AreaEntry) -> None: """Index an entry.""" if entry.floor_id is not None: - self._floors_index.setdefault(entry.floor_id, {})[key] = True + self._floors_index[entry.floor_id][key] = True for label in entry.labels: - self._labels_index.setdefault(label, {})[key] = True + self._labels_index[label][key] = True super()._index_entry(key, entry) def _unindex_entry( @@ -202,7 +224,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): picture: str | None = None, ) -> AreaEntry: """Create a new area.""" - self.hass.verify_event_loop_thread("async_create") + self.hass.verify_event_loop_thread("area_registry.async_create") normalized_name = normalize_name(name) if self.async_get_area_by_name(name): @@ -231,7 +253,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): @callback def async_delete(self, area_id: str) -> None: """Delete area.""" - self.hass.verify_event_loop_thread("async_delete") + self.hass.verify_event_loop_thread("area_registry.async_delete") device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) device_registry.async_clear_area_id(area_id) @@ -312,7 +334,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): if not new_values: return old - self.hass.verify_event_loop_thread("_async_update") + self.hass.verify_event_loop_thread("area_registry.async_update") new = self.areas[area_id] = dataclasses.replace(old, **new_values) # type: ignore[arg-type] self.async_schedule_save() @@ -416,16 +438,16 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): @callback +@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> AreaRegistry: """Get area registry.""" - return cast(AreaRegistry, hass.data[DATA_REGISTRY]) + return AreaRegistry(hass) async def async_load(hass: HomeAssistant) -> None: """Load area registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = AreaRegistry(hass) - await hass.data[DATA_REGISTRY].async_load() + await async_get(hass).async_load() @callback diff --git a/homeassistant/helpers/category_registry.py b/homeassistant/helpers/category_registry.py index 4ae920055a2..6498859e2ab 100644 --- a/homeassistant/helpers/category_registry.py +++ b/homeassistant/helpers/category_registry.py @@ -5,17 +5,19 @@ from __future__ import annotations from collections.abc import Iterable import dataclasses from dataclasses import dataclass, field -from typing import Literal, TypedDict, cast +from typing import Literal, TypedDict from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from homeassistant.util.ulid import ulid_now from .registry import BaseRegistry +from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType -DATA_REGISTRY = "category_registry" +DATA_REGISTRY: HassKey[CategoryRegistry] = HassKey("category_registry") EVENT_CATEGORY_REGISTRY_UPDATED: EventType[EventCategoryRegistryUpdatedData] = ( EventType("category_registry_updated") ) @@ -45,7 +47,7 @@ class EventCategoryRegistryUpdatedData(TypedDict): category_id: str -EventCategoryRegistryUpdated = Event[EventCategoryRegistryUpdatedData] +type EventCategoryRegistryUpdated = Event[EventCategoryRegistryUpdatedData] @dataclass(slots=True, kw_only=True, frozen=True) @@ -96,6 +98,7 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): icon: str | None = None, ) -> CategoryEntry: """Create a new category.""" + self.hass.verify_event_loop_thread("category_registry.async_create") self._async_ensure_name_is_available(scope, name) category = CategoryEntry( icon=icon, @@ -108,7 +111,7 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): self.categories[scope][category.category_id] = category self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_CATEGORY_REGISTRY_UPDATED, EventCategoryRegistryUpdatedData( action="create", scope=scope, category_id=category.category_id @@ -119,8 +122,9 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): @callback def async_delete(self, *, scope: str, category_id: str) -> None: """Delete category.""" + self.hass.verify_event_loop_thread("category_registry.async_delete") del self.categories[scope][category_id] - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_CATEGORY_REGISTRY_UPDATED, EventCategoryRegistryUpdatedData( action="remove", @@ -153,10 +157,11 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): if not changes: return old + self.hass.verify_event_loop_thread("category_registry.async_update") new = self.categories[scope][category_id] = dataclasses.replace(old, **changes) # type: ignore[arg-type] self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_CATEGORY_REGISTRY_UPDATED, EventCategoryRegistryUpdatedData( action="update", scope=scope, category_id=category_id @@ -216,13 +221,13 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]): @callback +@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> CategoryRegistry: """Get category registry.""" - return cast(CategoryRegistry, hass.data[DATA_REGISTRY]) + return CategoryRegistry(hass) async def async_load(hass: HomeAssistant) -> None: """Load category registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = CategoryRegistry(hass) - await hass.data[DATA_REGISTRY].async_load() + await async_get(hass).async_load() diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 78dddb12381..0626e0033c4 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -220,7 +220,7 @@ async def async_check_ha_config_file( # noqa: C901 except (vol.Invalid, HomeAssistantError) as ex: _comp_error(ex, domain, config, config[domain]) continue - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 logging.getLogger(__name__).exception( "Unexpected error validating config" ) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 6e833e338db..1dd94d85f9a 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -18,7 +18,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import websocket_api from homeassistant.const import CONF_ID -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify @@ -35,15 +35,12 @@ CHANGE_ADDED = "added" CHANGE_UPDATED = "updated" CHANGE_REMOVED = "removed" -_ItemT = TypeVar("_ItemT") -_StoreT = TypeVar("_StoreT", bound="SerializedStorageCollection") -_StorageCollectionT = TypeVar("_StorageCollectionT", bound="StorageCollection") _EntityT = TypeVar("_EntityT", bound=Entity, default=Entity) @dataclass(slots=True) -class CollectionChangeSet: - """Class to represent a change set. +class CollectionChange: + """Class to represent an item in a change set. change_type: One of CHANGE_* item_id: The id of the item @@ -55,7 +52,7 @@ class CollectionChangeSet: item: Any -ChangeListener = Callable[ +type ChangeListener = Callable[ [ # Change type str, @@ -67,7 +64,7 @@ ChangeListener = Callable[ Awaitable[None], ] -ChangeSetListener = Callable[[Iterable[CollectionChangeSet]], Awaitable[None]] +type ChangeSetListener = Callable[[Iterable[CollectionChange]], Awaitable[None]] class CollectionError(HomeAssistantError): @@ -129,7 +126,7 @@ class CollectionEntity(Entity): """Handle updated configuration.""" -class ObservableCollection(ABC, Generic[_ItemT]): +class ObservableCollection[_ItemT](ABC): """Base collection type that can be observed.""" def __init__(self, id_manager: IDManager | None) -> None: @@ -166,16 +163,16 @@ class ObservableCollection(ABC, Generic[_ItemT]): self.change_set_listeners.append(listener) return partial(self.change_set_listeners.remove, listener) - async def notify_changes(self, change_sets: Iterable[CollectionChangeSet]) -> None: + async def notify_changes(self, change_set: Iterable[CollectionChange]) -> None: """Notify listeners of a change.""" await asyncio.gather( *( - listener(change_set.change_type, change_set.item_id, change_set.item) + listener(change.change_type, change.item_id, change.item) for listener in self.listeners - for change_set in change_sets + for change in change_set ), *( - change_set_listener(change_sets) + change_set_listener(change_set) for change_set_listener in self.change_set_listeners ), ) @@ -204,7 +201,7 @@ class YamlCollection(ObservableCollection[dict]): """Load the YAML collection. Overrides existing data.""" old_ids = set(self.data) - change_sets = [] + change_set = [] for item in data: item_id = item[CONF_ID] @@ -219,15 +216,15 @@ class YamlCollection(ObservableCollection[dict]): event = CHANGE_ADDED self.data[item_id] = item - change_sets.append(CollectionChangeSet(event, item_id, item)) + change_set.append(CollectionChange(event, item_id, item)) - change_sets.extend( - CollectionChangeSet(CHANGE_REMOVED, item_id, self.data.pop(item_id)) + change_set.extend( + CollectionChange(CHANGE_REMOVED, item_id, self.data.pop(item_id)) for item_id in old_ids ) - if change_sets: - await self.notify_changes(change_sets) + if change_set: + await self.notify_changes(change_set) class SerializedStorageCollection(TypedDict): @@ -236,7 +233,9 @@ class SerializedStorageCollection(TypedDict): items: list[dict[str, Any]] -class StorageCollection(ObservableCollection[_ItemT], Generic[_ItemT, _StoreT]): +class StorageCollection[_ItemT, _StoreT: SerializedStorageCollection]( + ObservableCollection[_ItemT] +): """Offer a CRUD interface on top of JSON storage.""" def __init__( @@ -274,7 +273,7 @@ class StorageCollection(ObservableCollection[_ItemT], Generic[_ItemT, _StoreT]): await self.notify_changes( [ - CollectionChangeSet(CHANGE_ADDED, item[CONF_ID], item) + CollectionChange(CHANGE_ADDED, item[CONF_ID], item) for item in raw_storage["items"] ] ) @@ -314,7 +313,7 @@ class StorageCollection(ObservableCollection[_ItemT], Generic[_ItemT, _StoreT]): item = self._create_item(item_id, validated_data) self.data[item_id] = item self._async_schedule_save() - await self.notify_changes([CollectionChangeSet(CHANGE_ADDED, item_id, item)]) + await self.notify_changes([CollectionChange(CHANGE_ADDED, item_id, item)]) return item async def async_update_item(self, item_id: str, updates: dict) -> _ItemT: @@ -332,9 +331,7 @@ class StorageCollection(ObservableCollection[_ItemT], Generic[_ItemT, _StoreT]): self.data[item_id] = updated self._async_schedule_save() - await self.notify_changes( - [CollectionChangeSet(CHANGE_UPDATED, item_id, updated)] - ) + await self.notify_changes([CollectionChange(CHANGE_UPDATED, item_id, updated)]) return self.data[item_id] @@ -346,7 +343,7 @@ class StorageCollection(ObservableCollection[_ItemT], Generic[_ItemT, _StoreT]): item = self.data.pop(item_id) self._async_schedule_save() - await self.notify_changes([CollectionChangeSet(CHANGE_REMOVED, item_id, item)]) + await self.notify_changes([CollectionChange(CHANGE_REMOVED, item_id, item)]) @callback def _async_schedule_save(self) -> None: @@ -399,7 +396,7 @@ class IDLessCollection(YamlCollection): """Load the collection. Overrides existing data.""" await self.notify_changes( [ - CollectionChangeSet(CHANGE_REMOVED, item_id, item) + CollectionChange(CHANGE_REMOVED, item_id, item) for item_id, item in list(self.data.items()) ] ) @@ -414,7 +411,7 @@ class IDLessCollection(YamlCollection): await self.notify_changes( [ - CollectionChangeSet(CHANGE_ADDED, item_id, item) + CollectionChange(CHANGE_ADDED, item_id, item) for item_id, item in self.data.items() ] ) @@ -445,14 +442,14 @@ class _CollectionLifeCycle(Generic[_EntityT]): self.entities.pop(item_id, None) @callback - def _add_entity(self, change_set: CollectionChangeSet) -> CollectionEntity: + def _add_entity(self, change_set: CollectionChange) -> CollectionEntity: item_id = change_set.item_id entity = self.collection.create_entity(self.entity_class, change_set.item) self.entities[item_id] = entity entity.async_on_remove(partial(self._entity_removed, item_id)) return entity - async def _remove_entity(self, change_set: CollectionChangeSet) -> None: + async def _remove_entity(self, change_set: CollectionChange) -> None: item_id = change_set.item_id ent_reg = self.ent_reg entities = self.entities @@ -465,29 +462,27 @@ class _CollectionLifeCycle(Generic[_EntityT]): # the entity registry event handled by Entity._async_registry_updated entities.pop(item_id, None) - async def _update_entity(self, change_set: CollectionChangeSet) -> None: + async def _update_entity(self, change_set: CollectionChange) -> None: if entity := self.entities.get(change_set.item_id): await entity.async_update_config(change_set.item) - async def _collection_changed( - self, change_sets: Iterable[CollectionChangeSet] - ) -> None: + async def _collection_changed(self, change_set: Iterable[CollectionChange]) -> None: """Handle a collection change.""" # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. new_entities: list[CollectionEntity] = [] coros: list[Coroutine[Any, Any, CollectionEntity | None]] = [] - grouped: Iterable[CollectionChangeSet] - for _, grouped in groupby(change_sets, _GROUP_BY_KEY): - for change_set in grouped: - change_type = change_set.change_type + grouped: Iterable[CollectionChange] + for _, grouped in groupby(change_set, _GROUP_BY_KEY): + for change in grouped: + change_type = change.change_type if change_type == CHANGE_ADDED: - new_entities.append(self._add_entity(change_set)) + new_entities.append(self._add_entity(change)) elif change_type == CHANGE_REMOVED: - coros.append(self._remove_entity(change_set)) + coros.append(self._remove_entity(change)) elif change_type == CHANGE_UPDATED: - coros.append(self._update_entity(change_set)) + coros.append(self._update_entity(change)) if coros: await asyncio.gather(*coros) @@ -512,7 +507,7 @@ def sync_entity_lifecycle( ).async_setup() -class StorageCollectionWebsocket(Generic[_StorageCollectionT]): +class StorageCollectionWebsocket[_StorageCollectionT: StorageCollection]: """Class to expose storage collection management over websocket.""" def __init__( @@ -530,6 +525,9 @@ class StorageCollectionWebsocket(Generic[_StorageCollectionT]): self.create_schema = create_schema self.update_schema = update_schema + self._remove_subscription: CALLBACK_TYPE | None = None + self._subscribers: set[tuple[websocket_api.ActiveConnection, int]] = set() + assert self.api_prefix[-1] != "/", "API prefix should not end in /" @property @@ -542,19 +540,17 @@ class StorageCollectionWebsocket(Generic[_StorageCollectionT]): self, hass: HomeAssistant, *, - create_list: bool = True, create_create: bool = True, ) -> None: """Set up the websocket commands.""" - if create_list: - websocket_api.async_register_command( - hass, - f"{self.api_prefix}/list", - self.ws_list_item, - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): f"{self.api_prefix}/list"} - ), - ) + websocket_api.async_register_command( + hass, + f"{self.api_prefix}/list", + self.ws_list_item, + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): f"{self.api_prefix}/list"} + ), + ) if create_create: websocket_api.async_register_command( @@ -571,6 +567,15 @@ class StorageCollectionWebsocket(Generic[_StorageCollectionT]): ), ) + websocket_api.async_register_command( + hass, + f"{self.api_prefix}/subscribe", + self._ws_subscribe, + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( + {vol.Required("type"): f"{self.api_prefix}/subscribe"} + ), + ) + websocket_api.async_register_command( hass, f"{self.api_prefix}/update", @@ -610,7 +615,7 @@ class StorageCollectionWebsocket(Generic[_StorageCollectionT]): async def ws_create_item( self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """Create a item.""" + """Create an item.""" try: data = dict(msg) data.pop("id") @@ -620,18 +625,66 @@ class StorageCollectionWebsocket(Generic[_StorageCollectionT]): except vol.Invalid as err: connection.send_error( msg["id"], - websocket_api.const.ERR_INVALID_FORMAT, + websocket_api.ERR_INVALID_FORMAT, humanize_error(data, err), ) except ValueError as err: - connection.send_error( - msg["id"], websocket_api.const.ERR_INVALID_FORMAT, str(err) + connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) + + @callback + def _ws_subscribe( + self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + """Subscribe to collection updates.""" + + async def async_change_listener( + change_set: Iterable[CollectionChange], + ) -> None: + json_msg = [ + { + "change_type": change.change_type, + self.item_id_key: change.item_id, + "item": change.item, + } + for change in change_set + ] + for connection, msg_id in self._subscribers: + connection.send_message(websocket_api.event_message(msg_id, json_msg)) + + if not self._subscribers: + self._remove_subscription = ( + self.storage_collection.async_add_change_set_listener( + async_change_listener + ) ) + self._subscribers.add((connection, msg["id"])) + + @callback + def cancel_subscription() -> None: + self._subscribers.remove((connection, msg["id"])) + if not self._subscribers and self._remove_subscription: + self._remove_subscription() + self._remove_subscription = None + + connection.subscriptions[msg["id"]] = cancel_subscription + + connection.send_message(websocket_api.result_message(msg["id"])) + + json_msg = [ + { + "change_type": CHANGE_ADDED, + self.item_id_key: item_id, + "item": item, + } + for item_id, item in self.storage_collection.data.items() + ] + connection.send_message(websocket_api.event_message(msg["id"], json_msg)) + async def ws_update_item( self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """Update a item.""" + """Update an item.""" data = dict(msg) msg_id = data.pop("id") item_id = data.pop(self.item_id_key) @@ -643,30 +696,28 @@ class StorageCollectionWebsocket(Generic[_StorageCollectionT]): except ItemNotFound: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"Unable to find {self.item_id_key} {item_id}", ) except vol.Invalid as err: connection.send_error( msg["id"], - websocket_api.const.ERR_INVALID_FORMAT, + websocket_api.ERR_INVALID_FORMAT, humanize_error(data, err), ) except ValueError as err: - connection.send_error( - msg_id, websocket_api.const.ERR_INVALID_FORMAT, str(err) - ) + connection.send_error(msg_id, websocket_api.ERR_INVALID_FORMAT, str(err)) async def ws_delete_item( self, hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """Delete a item.""" + """Delete an item.""" try: await self.storage_collection.async_delete_item(msg[self.item_id_key]) except ItemNotFound: connection.send_error( msg["id"], - websocket_api.const.ERR_NOT_FOUND, + websocket_api.ERR_NOT_FOUND, f"Unable to find {self.item_id_key} {msg[self.item_id_key]}", ) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index b8c85902f7f..e15b40a78df 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import deque -from collections.abc import Callable, Container, Generator +from collections.abc import Callable, Container from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft @@ -12,6 +12,7 @@ import re import sys from typing import Any, Protocol, cast +from typing_extensions import Generator import voluptuous as vol from homeassistant.components import zone as zone_cmp @@ -115,7 +116,7 @@ class ConditionProtocol(Protocol): """Evaluate state based on configuration.""" -ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] +type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] def condition_trace_append(variables: TemplateVarsType, path: str) -> TraceElement: @@ -150,7 +151,7 @@ def condition_trace_update_result(**kwargs: Any) -> None: @contextmanager -def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement, None, None]: +def trace_condition(variables: TemplateVarsType) -> Generator[TraceElement]: """Trace condition evaluation.""" should_pop = True trace_element = trace_stack_top(trace_stack_cv) @@ -227,16 +228,25 @@ async def async_from_config( factory = platform.async_condition_from_config # Check if condition is not enabled - if not config.get(CONF_ENABLED, True): + if CONF_ENABLED in config: + enabled = config[CONF_ENABLED] + if isinstance(enabled, Template): + try: + enabled = enabled.async_render(limited=True) + except TemplateError as err: + raise HomeAssistantError( + f"Error rendering condition enabled template: {err}" + ) from err + if not enabled: - @trace_condition_function - def disabled_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool | None: - """Condition not enabled, will act as if it didn't exist.""" - return None + @trace_condition_function + def disabled_condition( + hass: HomeAssistant, variables: TemplateVarsType = None + ) -> bool | None: + """Condition not enabled, will act as if it didn't exist.""" + return None - return disabled_condition + return disabled_condition # Check for partials to properly determine if coroutine function check_factory = factory @@ -343,7 +353,7 @@ async def async_not_from_config( def numeric_state( hass: HomeAssistant, - entity: None | str | State, + entity: str | State | None, below: float | str | None = None, above: float | str | None = None, value_template: Template | None = None, @@ -364,7 +374,7 @@ def numeric_state( def async_numeric_state( hass: HomeAssistant, - entity: None | str | State, + entity: str | State | None, below: float | str | None = None, above: float | str | None = None, value_template: Template | None = None, @@ -536,7 +546,7 @@ def async_numeric_state_from_config(config: ConfigType) -> ConditionCheckerType: def state( hass: HomeAssistant, - entity: None | str | State, + entity: str | State | None, req_state: Any, for_period: timedelta | None = None, attribute: str | None = None, @@ -794,7 +804,7 @@ def time( hass: HomeAssistant, before: dt_time | str | None = None, after: dt_time | str | None = None, - weekday: None | str | Container[str] = None, + weekday: str | Container[str] | None = None, ) -> bool: """Test if local time condition matches. @@ -893,8 +903,8 @@ def time_from_config(config: ConfigType) -> ConditionCheckerType: def zone( hass: HomeAssistant, - zone_ent: None | str | State, - entity: None | str | State, + zone_ent: str | State | None, + entity: str | State | None, ) -> bool: """Test if zone-condition matches. diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index f2247e533a8..b047e1aef81 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, cast from homeassistant import config_entries from homeassistant.components import onboarding @@ -22,13 +22,12 @@ if TYPE_CHECKING: from .service_info.mqtt import MqttServiceInfo -_R = TypeVar("_R", bound="Awaitable[bool] | bool") -DiscoveryFunctionType = Callable[[HomeAssistant], _R] +type DiscoveryFunctionType[_R] = Callable[[HomeAssistant], _R] _LOGGER = logging.getLogger(__name__) -class DiscoveryFlowHandler(config_entries.ConfigFlow, Generic[_R]): +class DiscoveryFlowHandler[_R: Awaitable[bool] | bool](config_entries.ConfigFlow): """Handle a discovery config flow.""" VERSION = 1 diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index caf47432623..c2a61335769 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -10,6 +10,7 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio +from asyncio import Lock from collections.abc import Awaitable, Callable from http import HTTPStatus from json import JSONDecodeError @@ -27,6 +28,7 @@ from homeassistant import config_entries from homeassistant.components import http from homeassistant.core import HomeAssistant, callback from homeassistant.loader import async_get_application_credentials +from homeassistant.util.hass_dict import HassKey from .aiohttp_client import async_get_clientsession from .network import NoURLAvailableError @@ -34,8 +36,15 @@ from .network import NoURLAvailableError _LOGGER = logging.getLogger(__name__) DATA_JWT_SECRET = "oauth2_jwt_secret" -DATA_IMPLEMENTATIONS = "oauth2_impl" -DATA_PROVIDERS = "oauth2_providers" +DATA_IMPLEMENTATIONS: HassKey[dict[str, dict[str, AbstractOAuth2Implementation]]] = ( + HassKey("oauth2_impl") +) +DATA_PROVIDERS: HassKey[ + dict[ + str, + Callable[[HomeAssistant, str], Awaitable[list[AbstractOAuth2Implementation]]], + ] +] = HassKey("oauth2_providers") AUTH_CALLBACK_PATH = "/auth/external/callback" HEADER_FRONTEND_BASE = "HA-Frontend-Base" MY_AUTH_CALLBACK_PATH = "https://my.home-assistant.io/redirect/oauth" @@ -398,10 +407,7 @@ async def async_get_implementations( hass: HomeAssistant, domain: str ) -> dict[str, AbstractOAuth2Implementation]: """Return OAuth2 implementations for specified domain.""" - registered = cast( - dict[str, AbstractOAuth2Implementation], - hass.data.setdefault(DATA_IMPLEMENTATIONS, {}).get(domain, {}), - ) + registered = hass.data.setdefault(DATA_IMPLEMENTATIONS, {}).get(domain, {}) if DATA_PROVIDERS not in hass.data: return registered @@ -501,6 +507,7 @@ class OAuth2Session: self.hass = hass self.config_entry = config_entry self.implementation = implementation + self._token_lock = Lock() @property def token(self) -> dict: @@ -517,14 +524,15 @@ class OAuth2Session: async def async_ensure_token_valid(self) -> None: """Ensure that the current token is valid.""" - if self.valid_token: - return + async with self._token_lock: + if self.valid_token: + return - new_token = await self.implementation.async_refresh_token(self.token) + new_token = await self.implementation.async_refresh_token(self.token) - self.hass.config_entries.async_update_entry( - self.config_entry, data={**self.config_entry.data, "token": new_token} - ) + self.hass.config_entries.async_update_entry( + self.config_entry, data={**self.config_entry.data, "token": new_token} + ) async def async_request( self, method: str, url: str, **kwargs: Any diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index bf20a2d7f5f..295cd13fed4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1,6 +1,8 @@ """Helpers for config validation using voluptuous.""" -from __future__ import annotations +# PEP 563 seems to break typing.get_type_hints when used +# with PEP 695 syntax. Fixed in Python 3.13. +# from __future__ import annotations from collections.abc import Callable, Hashable import contextlib @@ -18,7 +20,7 @@ import re from socket import ( # type: ignore[attr-defined] # private, not in typeshed _GLOBAL_DEFAULT_TIMEOUT, ) -from typing import Any, TypeVar, cast, overload +from typing import Any, cast, overload from urllib.parse import urlparse from uuid import UUID @@ -91,8 +93,8 @@ from homeassistant.const import ( ) from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, async_get_hass, + async_get_hass_or_none, split_entity_id, valid_entity_id, ) @@ -140,9 +142,6 @@ gps = vol.ExactSequence([latitude, longitude]) sun_event = vol.All(vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)) port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) -# typing typevar -_T = TypeVar("_T") - def path(value: Any) -> str: """Validate it's a safe path.""" @@ -288,14 +287,14 @@ def ensure_list(value: None) -> list[Any]: ... @overload -def ensure_list(value: list[_T]) -> list[_T]: ... +def ensure_list[_T](value: list[_T]) -> list[_T]: ... @overload -def ensure_list(value: list[_T] | _T) -> list[_T]: ... +def ensure_list[_T](value: list[_T] | _T) -> list[_T]: ... -def ensure_list(value: _T | None) -> list[_T] | list[Any]: +def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]: """Wrap value in list if it is not one.""" if value is None: return [] @@ -540,7 +539,7 @@ def time_period_seconds(value: float | str) -> timedelta: time_period = vol.Any(time_period_str, time_period_seconds, timedelta, time_period_dict) -def match_all(value: _T) -> _T: +def match_all[_T](value: _T) -> _T: """Validate that matches all values.""" return value @@ -556,7 +555,7 @@ positive_time_period_dict = vol.All(time_period_dict, positive_timedelta) positive_time_period = vol.All(time_period, positive_timedelta) -def remove_falsy(value: list[_T]) -> list[_T]: +def remove_falsy[_T](value: list[_T]) -> list[_T]: """Remove falsy values from a list.""" return [v for v in value if v] @@ -583,7 +582,7 @@ def slug(value: Any) -> str: def schema_with_slug_keys( - value_schema: _T | Callable, *, slug_validator: Callable[[Any], str] = slug + value_schema: dict | Callable, *, slug_validator: Callable[[Any], str] = slug ) -> Callable: """Ensure dicts have slugs as keys. @@ -663,11 +662,7 @@ def template(value: Any | None) -> template_helper.Template: if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") - hass: HomeAssistant | None = None - with contextlib.suppress(HomeAssistantError): - hass = async_get_hass() - - template_value = template_helper.Template(str(value), hass) + template_value = template_helper.Template(str(value), async_get_hass_or_none()) try: template_value.ensure_valid() @@ -685,11 +680,7 @@ def dynamic_template(value: Any | None) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") - hass: HomeAssistant | None = None - with contextlib.suppress(HomeAssistantError): - hass = async_get_hass() - - template_value = template_helper.Template(str(value), hass) + template_value = template_helper.Template(str(value), async_get_hass_or_none()) try: template_value.ensure_valid() @@ -1311,7 +1302,7 @@ SCRIPT_SCHEMA = vol.All(ensure_list, [script_action]) SCRIPT_ACTION_BASE_SCHEMA = { vol.Optional(CONF_ALIAS): string, vol.Optional(CONF_CONTINUE_ON_ERROR): boolean, - vol.Optional(CONF_ENABLED): boolean, + vol.Optional(CONF_ENABLED): vol.Any(boolean, template), } EVENT_SCHEMA = vol.Schema( @@ -1356,7 +1347,7 @@ NUMERIC_STATE_THRESHOLD_SCHEMA = vol.Any( CONDITION_BASE_SCHEMA = { vol.Optional(CONF_ALIAS): string, - vol.Optional(CONF_ENABLED): boolean, + vol.Optional(CONF_ENABLED): vol.Any(boolean, template), } NUMERIC_STATE_CONDITION_SCHEMA = vol.All( @@ -1648,7 +1639,7 @@ TRIGGER_BASE_SCHEMA = vol.Schema( vol.Required(CONF_PLATFORM): str, vol.Optional(CONF_ID): str, vol.Optional(CONF_VARIABLES): SCRIPT_VARIABLES_SCHEMA, - vol.Optional(CONF_ENABLED): boolean, + vol.Optional(CONF_ENABLED): vol.Any(boolean, template), } ) @@ -1784,7 +1775,7 @@ _SCRIPT_STOP_SCHEMA = vol.Schema( } ) -_SCRIPT_PARALLEL_SEQUENCE = vol.Schema( +_SCRIPT_SEQUENCE_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA, @@ -1803,7 +1794,7 @@ _SCRIPT_PARALLEL_SCHEMA = vol.Schema( { **SCRIPT_ACTION_BASE_SCHEMA, vol.Required(CONF_PARALLEL): vol.All( - ensure_list, [vol.Any(_SCRIPT_PARALLEL_SEQUENCE, _parallel_sequence_action)] + ensure_list, [vol.Any(_SCRIPT_SEQUENCE_SCHEMA, _parallel_sequence_action)] ), } ) @@ -1819,6 +1810,7 @@ SCRIPT_ACTION_FIRE_EVENT = "event" SCRIPT_ACTION_IF = "if" SCRIPT_ACTION_PARALLEL = "parallel" SCRIPT_ACTION_REPEAT = "repeat" +SCRIPT_ACTION_SEQUENCE = "sequence" SCRIPT_ACTION_SET_CONVERSATION_RESPONSE = "set_conversation_response" SCRIPT_ACTION_STOP = "stop" SCRIPT_ACTION_VARIABLES = "variables" @@ -1845,6 +1837,7 @@ ACTIONS_MAP = { CONF_SERVICE_TEMPLATE: SCRIPT_ACTION_CALL_SERVICE, CONF_STOP: SCRIPT_ACTION_STOP, CONF_PARALLEL: SCRIPT_ACTION_PARALLEL, + CONF_SEQUENCE: SCRIPT_ACTION_SEQUENCE, CONF_SET_CONVERSATION_RESPONSE: SCRIPT_ACTION_SET_CONVERSATION_RESPONSE, } @@ -1875,6 +1868,7 @@ ACTION_TYPE_SCHEMAS: dict[str, Callable[[Any], dict]] = { SCRIPT_ACTION_IF: _SCRIPT_IF_SCHEMA, SCRIPT_ACTION_PARALLEL: _SCRIPT_PARALLEL_SCHEMA, SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA, + SCRIPT_ACTION_SEQUENCE: _SCRIPT_SEQUENCE_SCHEMA, SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: _SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA, SCRIPT_ACTION_STOP: _SCRIPT_STOP_SCHEMA, SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA, diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index de8f5eb4d53..83555b56dcb 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -5,14 +5,11 @@ from __future__ import annotations import asyncio from collections.abc import Callable from logging import Logger -from typing import Generic, TypeVar from homeassistant.core import HassJob, HomeAssistant, callback -_R_co = TypeVar("_R_co", covariant=True) - -class Debouncer(Generic[_R_co]): +class Debouncer[_R_co]: """Class to rate limit calls to a specific command.""" def __init__( @@ -138,7 +135,7 @@ class Debouncer(Generic[_R_co]): self._job, background=self._background ): await task - except Exception: # pylint: disable=broad-except + except Exception: self.logger.exception("Unexpected exception from %s", self.function) finally: # Schedule a new timer to prevent new runs during cooldown diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 93520866142..65e8f4ef97e 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -3,19 +3,14 @@ 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, NamedTuple, ParamSpec, TypeVar - -_ObjectT = TypeVar("_ObjectT", bound=object) -_R = TypeVar("_R") -_P = ParamSpec("_P") +from typing import Any, NamedTuple -def deprecated_substitute( +def deprecated_substitute[_ObjectT: object]( substitute_name: str, ) -> Callable[[Callable[[_ObjectT], Any]], Callable[[_ObjectT], Any]]: """Help migrate properties to new names. @@ -92,7 +87,7 @@ def get_deprecated( return config.get(new_name, default) -def deprecated_class( +def deprecated_class[**_P, _R]( 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. @@ -117,7 +112,7 @@ def deprecated_class( return deprecated_decorator -def deprecated_function( +def deprecated_function[**_P, _R]( 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. @@ -171,8 +166,7 @@ def _print_deprecation_warning_internal( log_when_no_integration_is_found: bool, ) -> None: # pylint: disable=import-outside-toplevel - from homeassistant.core import HomeAssistant, async_get_hass - from homeassistant.exceptions import HomeAssistantError + from homeassistant.core import async_get_hass_or_none from homeassistant.loader import async_suggest_report_issue from .frame import MissingIntegrationFrame, get_integration_frame @@ -195,11 +189,8 @@ def _print_deprecation_warning_internal( ) else: if integration_frame.custom_integration: - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() report_issue = async_suggest_report_issue( - hass, + async_get_hass_or_none(), integration_domain=integration_frame.integration, module=integration_frame.module, ) @@ -251,6 +242,26 @@ class DeprecatedAlias(NamedTuple): breaks_in_ha_version: str | None +class DeferredDeprecatedAlias: + """Deprecated alias with deferred evaluation of the value.""" + + def __init__( + self, + value_fn: Callable[[], Any], + replacement: str, + breaks_in_ha_version: str | None, + ) -> None: + """Initialize.""" + self.breaks_in_ha_version = breaks_in_ha_version + self.replacement = replacement + self._value_fn = value_fn + + @functools.cached_property + def value(self) -> Any: + """Return the value.""" + return self._value_fn() + + _PREFIX_DEPRECATED = "_DEPRECATED_" @@ -275,7 +286,7 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" ) breaks_in_ha_version = deprecated_const.breaks_in_ha_version - elif isinstance(deprecated_const, DeprecatedAlias): + elif isinstance(deprecated_const, (DeprecatedAlias, DeferredDeprecatedAlias)): description = "alias" value = deprecated_const.value replacement = deprecated_const.replacement @@ -283,8 +294,10 @@ def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> A 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" + f"Value of {_PREFIX_DEPRECATED}{name} is an instance of " + f"{type(deprecated_const)} but an instance of DeprecatedAlias, " + "DeferredDeprecatedAlias, DeprecatedConstant or DeprecatedConstantEnum " + "is required" ) logging.getLogger(module_name).debug(msg) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py new file mode 100644 index 00000000000..e1b9ded5723 --- /dev/null +++ b/homeassistant/helpers/device.py @@ -0,0 +1,86 @@ +"""Provides useful helpers for handling devices.""" + +from homeassistant.core import HomeAssistant, callback + +from . import device_registry as dr, entity_registry as er + + +@callback +def async_entity_id_to_device_id( + hass: HomeAssistant, + entity_id_or_uuid: str, +) -> str | None: + """Resolve the device id to the entity id or entity uuid.""" + + ent_reg = er.async_get(hass) + + entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid) + if (entity := ent_reg.async_get(entity_id)) is None: + return None + + return entity.device_id + + +@callback +def async_device_info_to_link_from_entity( + hass: HomeAssistant, + entity_id_or_uuid: str, +) -> dr.DeviceInfo | None: + """DeviceInfo with information to link a device to a configuration entry in the link category from a entity id or entity uuid.""" + + return async_device_info_to_link_from_device_id( + hass, + async_entity_id_to_device_id(hass, entity_id_or_uuid), + ) + + +@callback +def async_device_info_to_link_from_device_id( + hass: HomeAssistant, + device_id: str | None, +) -> dr.DeviceInfo | None: + """DeviceInfo with information to link a device to a configuration entry in the link category from a device id.""" + + dev_reg = dr.async_get(hass) + + if device_id is None or (device := dev_reg.async_get(device_id=device_id)) is None: + return None + + return dr.DeviceInfo( + identifiers=device.identifiers, + connections=device.connections, + ) + + +@callback +def async_remove_stale_devices_links_keep_entity_device( + hass: HomeAssistant, + entry_id: str, + source_entity_id_or_uuid: str, +) -> None: + """Remove the link between stales devices and a configuration entry, keeping only the device that the informed entity is linked to.""" + + async_remove_stale_devices_links_keep_current_device( + hass=hass, + entry_id=entry_id, + current_device_id=async_entity_id_to_device_id(hass, source_entity_id_or_uuid), + ) + + +@callback +def async_remove_stale_devices_links_keep_current_device( + hass: HomeAssistant, + entry_id: str, + current_device_id: str | None, +) -> None: + """Remove the link between stales devices and a configuration entry, keeping only the device informed. + + Device passed in the current_device_id parameter will be kept linked to the configuration entry. + """ + + dev_reg = dr.async_get(hass) + # Removes all devices from the config entry that are not the same as the current device + for device in dev_reg.devices.get_devices_for_config_entry_id(entry_id): + if device.id == current_device_id: + continue + dev_reg.async_update_device(device.id, remove_config_entry_id=entry_id) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 6b653784824..2a90d885d70 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Mapping from enum import StrEnum from functools import cached_property, lru_cache, partial import logging import time -from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict import attr from yarl import URL @@ -23,6 +24,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data import homeassistant.util.uuid as uuid_util @@ -36,7 +38,8 @@ from .deprecation import ( ) from .frame import report from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment -from .registry import BaseRegistry, BaseRegistryItems +from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType +from .singleton import singleton from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: @@ -46,7 +49,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -DATA_REGISTRY = "device_registry" +DATA_REGISTRY: HassKey[DeviceRegistry] = HassKey("device_registry") EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = EventType( "device_registry_updated" ) @@ -158,7 +161,7 @@ class _EventDeviceRegistryUpdatedData_Update(TypedDict): changes: dict[str, Any] -EventDeviceRegistryUpdatedData = ( +type EventDeviceRegistryUpdatedData = ( _EventDeviceRegistryUpdatedData_CreateRemove | _EventDeviceRegistryUpdatedData_Update ) @@ -241,7 +244,7 @@ class DeviceEntry: """Device Registry Entry.""" area_id: str | None = attr.ib(default=None) - config_entries: set[str] = attr.ib(converter=set, factory=set) + config_entries: list[str] = attr.ib(factory=list) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set) disabled_by: DeviceEntryDisabler | None = attr.ib(default=None) @@ -275,7 +278,7 @@ class DeviceEntry: return { "area_id": self.area_id, "configuration_url": self.configuration_url, - "config_entries": list(self.config_entries), + "config_entries": self.config_entries, "connections": list(self.connections), "disabled_by": self.disabled_by, "entry_type": self.entry_type, @@ -315,7 +318,7 @@ class DeviceEntry: json_bytes( { "area_id": self.area_id, - "config_entries": list(self.config_entries), + "config_entries": self.config_entries, "configuration_url": self.configuration_url, "connections": list(self.connections), "disabled_by": self.disabled_by, @@ -340,7 +343,7 @@ class DeviceEntry: class DeletedDeviceEntry: """Deleted Device Registry Entry.""" - config_entries: set[str] = attr.ib() + config_entries: list[str] = attr.ib() connections: set[tuple[str, str]] = attr.ib() identifiers: set[tuple[str, str]] = attr.ib() id: str = attr.ib() @@ -355,7 +358,7 @@ class DeletedDeviceEntry: """Create DeviceEntry from DeletedDeviceEntry.""" return DeviceEntry( # type ignores: likely https://github.com/python/mypy/issues/8625 - config_entries={config_entry_id}, # type: ignore[arg-type] + config_entries=[config_entry_id], connections=self.connections & connections, # type: ignore[arg-type] identifiers=self.identifiers & identifiers, # type: ignore[arg-type] id=self.id, @@ -368,7 +371,7 @@ class DeletedDeviceEntry: return json_fragment( json_bytes( { - "config_entries": list(self.config_entries), + "config_entries": self.config_entries, "connections": list(self.connections), "identifiers": list(self.identifiers), "id": self.id, @@ -447,10 +450,9 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): return old_data -_EntryTypeT = TypeVar("_EntryTypeT", DeviceEntry, DeletedDeviceEntry) - - -class DeviceRegistryItems(BaseRegistryItems[_EntryTypeT]): +class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)]( + BaseRegistryItems[_EntryTypeT] +): """Container for device registry items, maps device id -> entry. Maintains two additional indexes: @@ -512,19 +514,19 @@ class ActiveDeviceRegistryItems(DeviceRegistryItems[DeviceEntry]): - label -> dict[key, True] """ super().__init__() - self._area_id_index: dict[str, dict[str, Literal[True]]] = {} - self._config_entry_id_index: dict[str, dict[str, Literal[True]]] = {} - self._labels_index: dict[str, dict[str, Literal[True]]] = {} + self._area_id_index: RegistryIndexType = defaultdict(dict) + self._config_entry_id_index: RegistryIndexType = defaultdict(dict) + self._labels_index: RegistryIndexType = defaultdict(dict) def _index_entry(self, key: str, entry: DeviceEntry) -> None: """Index an entry.""" super()._index_entry(key, entry) if (area_id := entry.area_id) is not None: - self._area_id_index.setdefault(area_id, {})[key] = True + self._area_id_index[area_id][key] = True for label in entry.labels: - self._labels_index.setdefault(label, {})[key] = True + self._labels_index[label][key] = True for config_entry_id in entry.config_entries: - self._config_entry_id_index.setdefault(config_entry_id, {})[key] = True + self._config_entry_id_index[config_entry_id][key] = True def _unindex_entry( self, key: str, replacement_entry: DeviceEntry | None = None @@ -681,27 +683,27 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # Reconstruct a DeviceInfo dict from the arguments. # When we upgrade to Python 3.12, we can change this method to instead # accept kwargs typed as a DeviceInfo dict (PEP 692) - device_info: DeviceInfo = {} - for key, val in ( - ("configuration_url", configuration_url), - ("connections", connections), - ("default_manufacturer", default_manufacturer), - ("default_model", default_model), - ("default_name", default_name), - ("entry_type", entry_type), - ("hw_version", hw_version), - ("identifiers", identifiers), - ("manufacturer", manufacturer), - ("model", model), - ("name", name), - ("serial_number", serial_number), - ("suggested_area", suggested_area), - ("sw_version", sw_version), - ("via_device", via_device), - ): - if val is UNDEFINED: - continue - device_info[key] = val # type: ignore[literal-required] + device_info: DeviceInfo = { # type: ignore[assignment] + key: val + for key, val in ( + ("configuration_url", configuration_url), + ("connections", connections), + ("default_manufacturer", default_manufacturer), + ("default_model", default_model), + ("default_name", default_name), + ("entry_type", entry_type), + ("hw_version", hw_version), + ("identifiers", identifiers), + ("manufacturer", manufacturer), + ("model", model), + ("name", name), + ("serial_number", serial_number), + ("suggested_area", suggested_area), + ("sw_version", sw_version), + ("via_device", via_device), + ) + if val is not UNDEFINED + } device_info_type = _validate_device_info(config_entry, device_info) @@ -759,6 +761,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device.id, add_config_entry_id=config_entry_id, configuration_url=configuration_url, + device_info_type=device_info_type, disabled_by=disabled_by, entry_type=entry_type, hw_version=hw_version, @@ -786,6 +789,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): add_config_entry_id: str | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, + device_info_type: str | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -796,6 +800,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, @@ -811,8 +816,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): config_entries = old.config_entries + if merge_connections is not UNDEFINED and new_connections is not UNDEFINED: + raise HomeAssistantError( + "Cannot define both merge_connections and new_connections" + ) + if merge_identifiers is not UNDEFINED and new_identifiers is not UNDEFINED: - raise HomeAssistantError + raise HomeAssistantError( + "Cannot define both merge_identifiers and new_identifiers" + ) if isinstance(disabled_by, str) and not isinstance( disabled_by, DeviceEntryDisabler @@ -841,21 +853,32 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id - if ( - add_config_entry_id is not UNDEFINED - and add_config_entry_id not in old.config_entries - ): - config_entries = old.config_entries | {add_config_entry_id} + if add_config_entry_id is not UNDEFINED: + # primary ones have to be at the start. + if device_info_type == "primary": + # Move entry to first spot + if not config_entries or config_entries[0] != add_config_entry_id: + config_entries = [add_config_entry_id] + [ + entry + for entry in config_entries + if entry != add_config_entry_id + ] + + # Not primary, append + elif add_config_entry_id not in config_entries: + config_entries = [*config_entries, add_config_entry_id] if ( remove_config_entry_id is not UNDEFINED and remove_config_entry_id in config_entries ): - if config_entries == {remove_config_entry_id}: + if config_entries == [remove_config_entry_id]: self.async_remove_device(device_id) return None - config_entries = config_entries - {remove_config_entry_id} + config_entries = [ + entry for entry in config_entries if entry != remove_config_entry_id + ] if config_entries != old.config_entries: new_values["config_entries"] = config_entries @@ -871,6 +894,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values[attr_name] = old_value | setvalue old_values[attr_name] = old_value + if new_connections is not UNDEFINED: + new_values["connections"] = _normalize_connections(new_connections) + old_values["connections"] = old.connections + if new_identifiers is not UNDEFINED: new_values["identifiers"] = new_identifiers old_values["identifiers"] = old.identifiers @@ -904,7 +931,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if not new_values: return old - self.hass.verify_event_loop_thread("async_update_device") + self.hass.verify_event_loop_thread("device_registry.async_update_device") new = attr.evolve(old, **new_values) self.devices[device_id] = new @@ -931,7 +958,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" - self.hass.verify_event_loop_thread("async_remove_device") + self.hass.verify_event_loop_thread("device_registry.async_remove_device") device = self.devices.pop(device_id) self.deleted_devices[device_id] = DeletedDeviceEntry( config_entries=device.config_entries, @@ -964,7 +991,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["devices"]: devices[device["id"]] = DeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries"]), + config_entries=device["config_entries"], configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={ @@ -999,7 +1026,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): # Introduced in 0.111 for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( - config_entries=set(device["config_entries"]), + config_entries=device["config_entries"], connections={tuple(conn) for conn in device["connections"]}, identifiers={tuple(iden) for iden in device["identifiers"]}, id=device["id"], @@ -1030,13 +1057,15 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): config_entries = deleted_device.config_entries if config_entry_id not in config_entries: continue - if config_entries == {config_entry_id}: + if config_entries == [config_entry_id]: # Add a time stamp when the deleted device became orphaned self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, orphaned_timestamp=now_time, config_entries=set() + deleted_device, orphaned_timestamp=now_time, config_entries=[] ) else: - config_entries = config_entries - {config_entry_id} + config_entries = [ + entry for entry in config_entries if entry != config_entry_id + ] # No need to reindex here since we currently # do not have a lookup by config entry self.deleted_devices[deleted_device.id] = attr.evolve( @@ -1076,16 +1105,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): @callback +@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> DeviceRegistry: """Get device registry.""" - return cast(DeviceRegistry, hass.data[DATA_REGISTRY]) + return DeviceRegistry(hass) async def async_load(hass: HomeAssistant) -> None: """Load device registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = DeviceRegistry(hass) - await hass.data[DATA_REGISTRY].async_load() + await async_get(hass).async_load() @callback @@ -1142,8 +1171,8 @@ def async_config_entry_disabled_by_changed( if device.disabled: # Device already disabled, do not overwrite continue - if len(device.config_entries) > 1 and device.config_entries.intersection( - enabled_config_entries + if len(device.config_entries) > 1 and any( + entry_id in enabled_config_entries for entry_id in device.config_entries ): continue registry.async_update_device( diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 2e14759b814..9f656dad56c 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -16,7 +16,7 @@ from homeassistant.const import Platform from homeassistant.loader import bind_hass from ..util.signal_type import SignalTypeFormat -from .dispatcher import async_dispatcher_connect, async_dispatcher_send +from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .typing import ConfigType, DiscoveryInfoType SIGNAL_PLATFORM_DISCOVERED: SignalTypeFormat[DiscoveryDict] = SignalTypeFormat( @@ -95,7 +95,9 @@ async def async_discover( "discovered": discovered, } - async_dispatcher_send(hass, SIGNAL_PLATFORM_DISCOVERED.format(service), data) + async_dispatcher_send_internal( + hass, SIGNAL_PLATFORM_DISCOVERED.format(service), data + ) @bind_hass @@ -177,4 +179,6 @@ async def async_load_platform( "discovered": discovered, } - async_dispatcher_send(hass, SIGNAL_PLATFORM_DISCOVERED.format(service), data) + async_dispatcher_send_internal( + hass, SIGNAL_PLATFORM_DISCOVERED.format(service), data + ) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index e479a47ecfd..9ec0b01dc56 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -10,9 +10,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util.async_ import gather_with_limited_concurrency +from homeassistant.util.hass_dict import HassKey FLOW_INIT_LIMIT = 20 -DISCOVERY_FLOW_DISPATCHER = "discovery_flow_dispatcher" +DISCOVERY_FLOW_DISPATCHER: HassKey[FlowDispatcher] = HassKey( + "discovery_flow_dispatcher" +) @bind_hass @@ -35,7 +38,7 @@ def async_create_flow( ) return - return dispatcher.async_create(domain, context, data) + dispatcher.async_create(domain, context, data) @callback diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index aa8176a1b83..173e441781c 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -2,31 +2,31 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Callable, Coroutine from functools import partial import logging -from typing import Any, TypeVarTuple, overload +from typing import Any, overload from homeassistant.core import ( HassJob, + HassJobType, HomeAssistant, callback, get_hassjob_callable_job_type, ) from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.logging import catch_log_exception +from homeassistant.util.logging import catch_log_exception, log_exception # Explicit reexport of 'SignalType' for backwards compatibility from homeassistant.util.signal_type import SignalType as SignalType # noqa: PLC0414 -_Ts = TypeVarTuple("_Ts") - _LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = "dispatcher" -_DispatcherDataType = dict[ +type _DispatcherDataType[*_Ts] = dict[ SignalType[*_Ts] | str, dict[ Callable[[*_Ts], Any] | Callable[..., Any], @@ -37,7 +37,7 @@ _DispatcherDataType = dict[ @overload @bind_hass -def dispatcher_connect( +def dispatcher_connect[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], None] ) -> Callable[[], None]: ... @@ -50,7 +50,7 @@ def dispatcher_connect( @bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def -def dispatcher_connect( +def dispatcher_connect[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], None], @@ -68,7 +68,7 @@ def dispatcher_connect( @callback -def _async_remove_dispatcher( +def _async_remove_dispatcher[*_Ts]( dispatchers: _DispatcherDataType[*_Ts], signal: SignalType[*_Ts] | str, target: Callable[[*_Ts], Any] | Callable[..., Any], @@ -90,7 +90,7 @@ def _async_remove_dispatcher( @overload @callback @bind_hass -def async_dispatcher_connect( +def async_dispatcher_connect[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], target: Callable[[*_Ts], Any] ) -> Callable[[], None]: ... @@ -105,7 +105,7 @@ def async_dispatcher_connect( @callback @bind_hass -def async_dispatcher_connect( +def async_dispatcher_connect[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts] | str, target: Callable[[*_Ts], Any] | Callable[..., Any], @@ -115,13 +115,8 @@ def async_dispatcher_connect( This method must be run in the event loop. """ if DATA_DISPATCHER not in hass.data: - hass.data[DATA_DISPATCHER] = {} - + hass.data[DATA_DISPATCHER] = defaultdict(dict) dispatchers: _DispatcherDataType[*_Ts] = hass.data[DATA_DISPATCHER] - - if signal not in dispatchers: - dispatchers[signal] = {} - dispatchers[signal][target] = None # Use a partial for the remove since it uses # less memory than a full closure since a partial copies @@ -132,7 +127,7 @@ def async_dispatcher_connect( @overload @bind_hass -def dispatcher_send( +def dispatcher_send[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts ) -> None: ... @@ -143,12 +138,14 @@ def dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: ... @bind_hass # type: ignore[misc] # workaround; exclude typing of 2 overload in func def -def dispatcher_send(hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts) -> None: +def dispatcher_send[*_Ts]( + hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts +) -> None: """Send signal and data.""" - hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) + hass.loop.call_soon_threadsafe(async_dispatcher_send_internal, hass, signal, *args) -def _format_err( +def _format_err[*_Ts]( signal: SignalType[*_Ts] | str, target: Callable[[*_Ts], Any] | Callable[..., Any], *args: Any, @@ -162,16 +159,22 @@ def _format_err( ) -def _generate_job( +def _generate_job[*_Ts]( signal: SignalType[*_Ts] | str, target: Callable[[*_Ts], Any] | Callable[..., Any] -) -> HassJob[..., None | Coroutine[Any, Any, None]]: +) -> HassJob[..., Coroutine[Any, Any, None] | None]: """Generate a HassJob for a signal and target.""" job_type = get_hassjob_callable_job_type(target) + name = f"dispatcher {signal}" + if job_type is HassJobType.Callback: + # We will catch exceptions in the callback to avoid + # wrapping the callback since calling wraps() is more + # expensive than the whole dispatcher_send process + return HassJob(target, name, job_type=job_type) return HassJob( catch_log_exception( target, partial(_format_err, signal, target), job_type=job_type ), - f"dispatcher {signal}", + name, job_type=job_type, ) @@ -179,7 +182,7 @@ def _generate_job( @overload @callback @bind_hass -def async_dispatcher_send( +def async_dispatcher_send[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts ) -> None: ... @@ -192,16 +195,40 @@ def async_dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: @callback @bind_hass -def async_dispatcher_send( +def async_dispatcher_send[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts] | str, *args: *_Ts ) -> None: """Send signal and data. This method must be run in the event loop. """ - if hass.config.debug: - hass.verify_event_loop_thread("async_dispatcher_send") + # We turned on asyncio debug in April 2024 in the dev containers + # in the hope of catching some of the issues that have been + # reported. It will take a while to get all the issues fixed in + # custom components. + # + # In 2025.5 we should guard the `verify_event_loop_thread` + # check with a check for the `hass.config.debug` flag being set as + # long term we don't want to be checking this in production + # environments since it is a performance hit. + hass.verify_event_loop_thread("async_dispatcher_send") + async_dispatcher_send_internal(hass, signal, *args) + +@callback +@bind_hass +def async_dispatcher_send_internal[*_Ts]( + hass: HomeAssistant, signal: SignalType[*_Ts] | str, *args: *_Ts +) -> None: + """Send signal and data. + + This method is intended to only be used by core internally + and should not be considered a stable API. We will make + breaking changes to this function in the future and it + should not be used in integrations. + + This method must be run in the event loop. + """ if (maybe_dispatchers := hass.data.get(DATA_DISPATCHER)) is None: return dispatchers: _DispatcherDataType[*_Ts] = maybe_dispatchers @@ -212,4 +239,13 @@ def async_dispatcher_send( if job is None: job = _generate_job(signal, target) target_list[target] = job - hass.async_run_hass_job(job, *args) + # We do not wrap Callback jobs in catch_log_exception since + # single use dispatchers spend more time wrapping the callback + # than the actual callback takes to run in many cases. + if job.job_type is HassJobType.Callback: + try: + job.target(*args) + except Exception: # noqa: BLE001 + log_exception(partial(_format_err, signal, target), *args) # type: ignore[arg-type] + else: + hass.async_run_hass_job(job, *args) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 3d6623a37f8..cf910a5cba8 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -14,18 +14,10 @@ import logging import math from operator import attrgetter import sys +import threading import time from types import FunctionType -from typing import ( - TYPE_CHECKING, - Any, - Final, - Literal, - NotRequired, - TypedDict, - TypeVar, - final, -) +from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict, final import voluptuous as vol @@ -72,6 +64,7 @@ from .event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) +from .frame import report_non_thread_safe_operation from .typing import UNDEFINED, StateType, UndefinedType timer = time.time @@ -79,8 +72,6 @@ timer = time.time if TYPE_CHECKING: from .entity_platform import EntityPlatform -_T = TypeVar("_T") - _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 DATA_ENTITY_SOURCE = "entity_info" @@ -376,7 +367,7 @@ class CachedProperties(type): attr = getattr(cls, attr_name) if isinstance(attr, (FunctionType, property)): raise TypeError(f"Can't override {attr_name} in subclass") - setattr(cls, private_attr_name, getattr(cls, attr_name)) + setattr(cls, private_attr_name, attr) annotations = cls.__annotations__ if attr_name in annotations: annotations[private_attr_name] = annotations.pop(attr_name) @@ -482,6 +473,10 @@ class Entity( # Protect for multiple updates _update_staged = False + # _verified_state_writable is set to True if the entity has been verified + # to be writable. This is used to avoid repeated checks. + _verified_state_writable = False + # Process updates in parallel parallel_updates: asyncio.Semaphore | None = None @@ -523,7 +518,6 @@ class Entity( # While not purely typed, it makes typehinting more useful for us # and removes the need for constant None checks or asserts. _state_info: StateInfo = None # type: ignore[assignment] - _is_custom_component: bool = False __capabilities_updated_at: deque[float] __capabilities_updated_at_reported: bool = False @@ -587,7 +581,7 @@ class Entity( """Return a unique ID.""" return self._attr_unique_id - @property + @cached_property def use_device_name(self) -> bool: """Return if this entity does not have its own name. @@ -595,14 +589,12 @@ class Entity( """ if hasattr(self, "_attr_name"): return not self._attr_name - - if name_translation_key := self._name_translation_key: - if name_translation_key in self.platform.platform_translations: - return False - + if ( + name_translation_key := self._name_translation_key + ) and name_translation_key in self.platform.platform_translations: + return False if hasattr(self, "entity_description"): return not self.entity_description.name - return not self.name @cached_property @@ -950,7 +942,7 @@ class Entity( if force_refresh: try: await self.async_device_update() - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Update for %s fails", self.entity_id) return elif not self._async_update_ha_state_reported: @@ -996,18 +988,22 @@ class Entity( f"No entity id specified for entity {self.name}" ) + self._verified_state_writable = True + @callback def _async_write_ha_state_from_call_soon_threadsafe(self) -> None: """Write the state to the state machine from the event loop thread.""" - self._async_verify_state_writable() + if not self.hass or not self._verified_state_writable: + self._async_verify_state_writable() self._async_write_ha_state() @callback def async_write_ha_state(self) -> None: """Write the state to the state machine.""" - self._async_verify_state_writable() - if self._is_custom_component or self.hass.config.debug: - self.hass.verify_event_loop_thread("async_write_ha_state") + if not self.hass or not self._verified_state_writable: + self._async_verify_state_writable() + if self.hass.loop_thread_id != threading.get_ident(): + report_non_thread_safe_operation("async_write_ha_state") self._async_write_ha_state() def _stringify_state(self, available: bool) -> str: @@ -1451,8 +1447,6 @@ class Entity( "domain": self.platform.platform_name, "custom_component": is_custom_component, } - self._is_custom_component = is_custom_component - if self.platform.config_entry: entity_info["config_entry"] = self.platform.config_entry.entry_id @@ -1486,7 +1480,7 @@ class Entity( # The check for self.platform guards against integrations not using an # EntityComponent and can be removed in HA Core 2024.1 if self.platform: - entity_sources(self.hass).pop(self.entity_id) + del entity_sources(self.hass)[self.entity_id] @callback def _async_registry_updated( @@ -1603,7 +1597,7 @@ class Entity( return f"" return f"" - async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: + async def async_request_call[_T](self, coro: Coroutine[Any, Any, _T]) -> _T: """Process request batched.""" if self.parallel_updates: await self.parallel_updates.acquire() diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index eb54d83e1dd..aae0e2058e4 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -182,7 +182,10 @@ class EntityComponent(Generic[_EntityT]): key = config_entry.entry_id if key in self._platforms: - raise ValueError("Config entry has already been setup!") + raise ValueError( + f"Config entry {config_entry.title} ({key}) for " + f"{platform_type}.{self.domain} has already been setup!" + ) self._platforms[key] = self._async_init_entity_platform( platform_type, diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index f95c0a0b66a..4dbe3ac68d8 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from contextvars import ContextVar -from datetime import datetime, timedelta +from datetime import timedelta from functools import partial from logging import Logger, getLogger from typing import TYPE_CHECKING, Any, Protocol @@ -30,10 +30,17 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, + PlatformNotReady, +) from homeassistant.generated import languages from homeassistant.setup import SetupPhases, async_start_setup from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey from . import ( config_validation as cv, @@ -43,7 +50,7 @@ from . import ( translation, ) from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider -from .event import async_call_later, async_track_time_interval +from .event import async_call_later from .issue_registry import IssueSeverity, async_create_issue from .typing import UNDEFINED, ConfigType, DiscoveryInfoType @@ -57,9 +64,13 @@ SLOW_ADD_ENTITY_MAX_WAIT = 15 # Per Entity SLOW_ADD_MIN_TIMEOUT = 500 PLATFORM_NOT_READY_RETRIES = 10 -DATA_ENTITY_PLATFORM = "entity_platform" -DATA_DOMAIN_ENTITIES = "domain_entities" -DATA_DOMAIN_PLATFORM_ENTITIES = "domain_platform_entities" +DATA_ENTITY_PLATFORM: HassKey[dict[str, list[EntityPlatform]]] = HassKey( + "entity_platform" +) +DATA_DOMAIN_ENTITIES: HassKey[dict[str, dict[str, Entity]]] = HassKey("domain_entities") +DATA_DOMAIN_PLATFORM_ENTITIES: HassKey[dict[tuple[str, str], dict[str, Entity]]] = ( + HassKey("domain_platform_entities") +) PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds _LOGGER = getLogger(__name__) @@ -125,6 +136,7 @@ class EntityPlatform: self.platform_name = platform_name self.platform = platform self.scan_interval = scan_interval + self.scan_interval_seconds = scan_interval.total_seconds() self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None # Storage for entities for this specific platform only @@ -138,7 +150,7 @@ class EntityPlatform: # Stop tracking tasks after setup is completed self._setup_complete = False # Method to cancel the state change listener - self._async_unsub_polling: CALLBACK_TYPE | None = None + self._async_polling_timer: asyncio.TimerHandle | None = None # Method to cancel the retry of setup self._async_cancel_retry_setup: CALLBACK_TYPE | None = None self._process_updates: asyncio.Lock | None = None @@ -154,20 +166,18 @@ class EntityPlatform: # with the child dict indexed by entity_id # # This is usually media_player, light, switch, etc. - domain_entities: dict[str, dict[str, Entity]] = hass.data.setdefault( + self.domain_entities = hass.data.setdefault( DATA_DOMAIN_ENTITIES, {} - ) - self.domain_entities = domain_entities.setdefault(domain, {}) + ).setdefault(domain, {}) # Storage for entities indexed by domain and platform # with the child dict indexed by entity_id # # This is usually media_player.yamaha, light.hue, switch.tplink, etc. - domain_platform_entities: dict[tuple[str, str], dict[str, Entity]] = ( - hass.data.setdefault(DATA_DOMAIN_PLATFORM_ENTITIES, {}) - ) key = (domain, platform_name) - self.domain_platform_entities = domain_platform_entities.setdefault(key, {}) + self.domain_platform_entities = hass.data.setdefault( + DATA_DOMAIN_PLATFORM_ENTITIES, {} + ).setdefault(key, {}) def __repr__(self) -> str: """Represent an EntityPlatform.""" @@ -192,8 +202,8 @@ class EntityPlatform: to that number. The default value for parallel requests is decided based on the first - entity that is added to Home Assistant. It's 0 if the entity defines - the async_update method, else it's 1. + entity of the platform which is added to Home Assistant. It's 1 if the + entity implements the update method, else it's 0. """ if self.parallel_updates_created: return self.parallel_updates @@ -350,7 +360,7 @@ class EntityPlatform: try: awaitable = async_create_setup_awaitable() if asyncio.iscoroutine(awaitable): - awaitable = create_eager_task(awaitable) + awaitable = create_eager_task(awaitable, loop=hass.loop) async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, self.domain): await asyncio.shield(awaitable) @@ -406,7 +416,17 @@ class EntityPlatform: SLOW_SETUP_MAX_WAIT, ) return False - except Exception: # pylint: disable=broad-except + except (ConfigEntryNotReady, ConfigEntryAuthFailed, ConfigEntryError) as exc: + _LOGGER.error( + "%s raises exception %s in forwarded platform " + "%s; Instead raise %s before calling async_forward_entry_setups", + self.platform_name, + type(exc).__name__, + self.domain, + type(exc).__name__, + ) + return False + except Exception: logger.exception( "Error while setting up %s platform for %s", self.platform_name, @@ -428,7 +448,7 @@ class EntityPlatform: return await translation.async_get_translations( self.hass, language, category, {integration} ) - except Exception as err: # pylint: disable=broad-exception-caught + except Exception as err: # noqa: BLE001 _LOGGER.debug( "Could not load translations for %s", integration, @@ -532,7 +552,7 @@ class EntityPlatform: event loop and will finish faster if we run them concurrently. """ results: list[BaseException | None] | None = None - tasks = [create_eager_task(coro) for coro in coros] + tasks = [create_eager_task(coro, loop=self.hass.loop) for coro in coros] try: async with self.hass.timeout.async_timeout(timeout, self.domain): results = await asyncio.gather(*tasks, return_exceptions=True) @@ -578,7 +598,7 @@ class EntityPlatform: for idx, coro in enumerate(coros): try: await coro - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: entity = entities[idx] self.logger.exception( "Error adding entity %s for domain %s with platform %s", @@ -630,7 +650,7 @@ class EntityPlatform: if ( (self.config_entry and self.config_entry.pref_disable_polling) - or self._async_unsub_polling is not None + or self._async_polling_timer is not None or not any( # Entity may have failed to add or called `add_to_platform_abort` # so we check if the entity is in self.entities before @@ -644,26 +664,28 @@ class EntityPlatform: ): return - self._async_unsub_polling = async_track_time_interval( - self.hass, + self._async_polling_timer = self.hass.loop.call_later( + self.scan_interval_seconds, self._async_handle_interval_callback, - self.scan_interval, - name=f"EntityPlatform poll {self.domain}.{self.platform_name}", ) @callback - def _async_handle_interval_callback(self, now: datetime) -> None: + def _async_handle_interval_callback(self) -> None: """Update all the entity states in a single platform.""" + self._async_polling_timer = self.hass.loop.call_later( + self.scan_interval_seconds, + self._async_handle_interval_callback, + ) if self.config_entry: self.config_entry.async_create_background_task( self.hass, - self._update_entity_states(now), + self._async_update_entity_states(), name=f"EntityPlatform poll {self.domain}.{self.platform_name}", eager_start=True, ) else: self.hass.async_create_background_task( - self._update_entity_states(now), + self._async_update_entity_states(), name=f"EntityPlatform poll {self.domain}.{self.platform_name}", eager_start=True, ) @@ -705,7 +727,7 @@ class EntityPlatform: if update_before_add: try: await entity.async_device_update(warning=False) - except Exception: # pylint: disable=broad-except + except Exception: self.logger.exception("%s: Error on device update!", self.platform_name) entity.add_to_platform_abort() return @@ -883,9 +905,9 @@ class EntityPlatform: def remove_entity_cb() -> None: """Remove entity from entities dict.""" - self.entities.pop(entity_id) - self.domain_entities.pop(entity_id) - self.domain_platform_entities.pop(entity_id) + del self.entities[entity_id] + del self.domain_entities[entity_id] + del self.domain_platform_entities[entity_id] entity.async_on_remove(remove_entity_cb) @@ -908,7 +930,7 @@ class EntityPlatform: for entity in list(self.entities.values()): try: await entity.async_remove() - except Exception: # pylint: disable=broad-except + except Exception: self.logger.exception( "Error while removing entity %s", entity.entity_id ) @@ -919,9 +941,9 @@ class EntityPlatform: @callback def async_unsub_polling(self) -> None: """Stop polling.""" - if self._async_unsub_polling is not None: - self._async_unsub_polling() - self._async_unsub_polling = None + if self._async_polling_timer is not None: + self._async_polling_timer.cancel() + self._async_polling_timer = None @callback def async_prepare(self) -> None: @@ -943,11 +965,10 @@ class EntityPlatform: await self.entities[entity_id].async_remove() # Clean up polling job if no longer needed - if self._async_unsub_polling is not None and not any( + if self._async_polling_timer is not None and not any( entity.should_poll for entity in self.entities.values() ): - self._async_unsub_polling() - self._async_unsub_polling = None + self.async_unsub_polling() async def async_extract_from_service( self, service_call: ServiceCall, expand_group: bool = True @@ -998,7 +1019,7 @@ class EntityPlatform: supports_response, ) - async def _update_entity_states(self, now: datetime) -> None: + async def _async_update_entity_states(self) -> None: """Update the states of all the polling entities. To protect from flooding the executor, we will update async entities @@ -1030,7 +1051,9 @@ class EntityPlatform: return if tasks := [ - create_eager_task(entity.async_update_ha_state(True)) + create_eager_task( + entity.async_update_ha_state(True), loop=self.hass.loop + ) for entity in self.entities.values() if entity.should_poll ]: @@ -1061,6 +1084,4 @@ def async_get_platforms( ): return [] - platforms: list[EntityPlatform] = hass.data[DATA_ENTITY_PLATFORM][integration_name] - - return platforms + return hass.data[DATA_ENTITY_PLATFORM][integration_name] diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index c3bd3031750..dabe2e61917 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,13 +10,14 @@ timer. from __future__ import annotations +from collections import defaultdict from collections.abc import Callable, Container, Hashable, KeysView, Mapping from datetime import datetime, timedelta from enum import StrEnum from functools import cached_property import logging import time -from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict import attr import voluptuous as vol @@ -48,6 +49,7 @@ from homeassistant.exceptions import MaxLengthExceeded from homeassistant.loader import async_suggest_report_issue from homeassistant.util import slugify, uuid as uuid_util from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import format_unserializable_data from homeassistant.util.read_only_dict import ReadOnlyDict @@ -57,15 +59,14 @@ from .device_registry import ( EventDeviceRegistryUpdatedData, ) from .json import JSON_DUMP, find_paths_unserializable_data, json_bytes, json_fragment -from .registry import BaseRegistry, BaseRegistryItems +from .registry import BaseRegistry, BaseRegistryItems, RegistryIndexType +from .singleton import singleton from .typing import UNDEFINED, UndefinedType if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry -T = TypeVar("T") - -DATA_REGISTRY = "entity_registry" +DATA_REGISTRY: HassKey[EntityRegistry] = HassKey("entity_registry") EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = EventType( "entity_registry_updated" ) @@ -132,14 +133,14 @@ class _EventEntityRegistryUpdatedData_Update(TypedDict): old_entity_id: NotRequired[str] -EventEntityRegistryUpdatedData = ( +type EventEntityRegistryUpdatedData = ( _EventEntityRegistryUpdatedData_CreateRemove | _EventEntityRegistryUpdatedData_Update ) -EntityOptionsType = Mapping[str, Mapping[str, Any]] -ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] +type EntityOptionsType = Mapping[str, Mapping[str, Any]] +type ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] DISPLAY_DICT_OPTIONAL = ( # key, attr_name, convert_to_list @@ -533,10 +534,10 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): super().__init__() self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} - self._config_entry_id_index: dict[str, dict[str, Literal[True]]] = {} - self._device_id_index: dict[str, dict[str, Literal[True]]] = {} - self._area_id_index: dict[str, dict[str, Literal[True]]] = {} - self._labels_index: dict[str, dict[str, Literal[True]]] = {} + self._config_entry_id_index: RegistryIndexType = defaultdict(dict) + self._device_id_index: RegistryIndexType = defaultdict(dict) + self._area_id_index: RegistryIndexType = defaultdict(dict) + self._labels_index: RegistryIndexType = defaultdict(dict) def _index_entry(self, key: str, entry: RegistryEntry) -> None: """Index an entry.""" @@ -545,13 +546,13 @@ class EntityRegistryItems(BaseRegistryItems[RegistryEntry]): # python has no ordered set, so we use a dict with True values # https://discuss.python.org/t/add-orderedset-to-stdlib/12730 if (config_entry_id := entry.config_entry_id) is not None: - self._config_entry_id_index.setdefault(config_entry_id, {})[key] = True + self._config_entry_id_index[config_entry_id][key] = True if (device_id := entry.device_id) is not None: - self._device_id_index.setdefault(device_id, {})[key] = True + self._device_id_index[device_id][key] = True if (area_id := entry.area_id) is not None: - self._area_id_index.setdefault(area_id, {})[key] = True + self._area_id_index[area_id][key] = True for label in entry.labels: - self._labels_index.setdefault(label, {})[key] = True + self._labels_index[label][key] = True def _unindex_entry( self, key: str, replacement_entry: RegistryEntry | None = None @@ -617,17 +618,22 @@ def _validate_item( hass: HomeAssistant, domain: str, platform: str, - unique_id: str | Hashable | UndefinedType | Any, *, disabled_by: RegistryEntryDisabler | None | UndefinedType = None, entity_category: EntityCategory | None | UndefinedType = None, hidden_by: RegistryEntryHider | None | UndefinedType = None, + report_non_string_unique_id: bool = True, + unique_id: str | Hashable | UndefinedType | Any, ) -> None: """Validate entity registry item.""" if unique_id is not UNDEFINED and not isinstance(unique_id, Hashable): raise TypeError(f"unique_id must be a string, got {unique_id}") - if unique_id is not UNDEFINED and not isinstance(unique_id, str): - # In HA Core 2025.4, we should fail if unique_id is not a string + if ( + report_non_string_unique_id + and unique_id is not UNDEFINED + and not isinstance(unique_id, str) + ): + # In HA Core 2025.10, we should fail if unique_id is not a string report_issue = async_suggest_report_issue(hass, integration_domain=platform) _LOGGER.error( ("'%s' from integration %s has a non string unique_id" " '%s', please %s"), @@ -819,7 +825,7 @@ class EntityRegistry(BaseRegistry): unit_of_measurement=unit_of_measurement, ) - self.hass.verify_event_loop_thread("async_get_or_create") + self.hass.verify_event_loop_thread("entity_registry.async_get_or_create") _validate_item( self.hass, domain, @@ -850,7 +856,7 @@ class EntityRegistry(BaseRegistry): ): disabled_by = RegistryEntryDisabler.INTEGRATION - def none_if_undefined(value: T | UndefinedType) -> T | None: + def none_if_undefined[_T](value: _T | UndefinedType) -> _T | None: """Return None if value is UNDEFINED, otherwise return value.""" return None if value is UNDEFINED else value @@ -892,7 +898,7 @@ class EntityRegistry(BaseRegistry): @callback def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" - self.hass.verify_event_loop_thread("async_remove") + self.hass.verify_event_loop_thread("entity_registry.async_remove") entity = self.entities.pop(entity_id) config_entry_id = entity.config_entry_id key = (entity.domain, entity.platform, entity.unique_id) @@ -1087,7 +1093,7 @@ class EntityRegistry(BaseRegistry): if not new_values: return old - self.hass.verify_event_loop_thread("_async_update_entity") + self.hass.verify_event_loop_thread("entity_registry.async_update_entity") new = self.entities[entity_id] = attr.evolve(old, **new_values) @@ -1226,7 +1232,11 @@ class EntityRegistry(BaseRegistry): try: domain = split_entity_id(entity["entity_id"])[0] _validate_item( - self.hass, domain, entity["platform"], entity["unique_id"] + self.hass, + domain, + entity["platform"], + report_non_string_unique_id=False, + unique_id=entity["unique_id"], ) except (TypeError, ValueError) as err: report_issue = async_suggest_report_issue( @@ -1282,7 +1292,11 @@ class EntityRegistry(BaseRegistry): try: domain = split_entity_id(entity["entity_id"])[0] _validate_item( - self.hass, domain, entity["platform"], entity["unique_id"] + self.hass, + domain, + entity["platform"], + report_non_string_unique_id=False, + unique_id=entity["unique_id"], ) except (TypeError, ValueError): continue @@ -1373,16 +1387,16 @@ class EntityRegistry(BaseRegistry): @callback +@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> EntityRegistry: """Get entity registry.""" - return cast(EntityRegistry, hass.data[DATA_REGISTRY]) + return EntityRegistry(hass) async def async_load(hass: HomeAssistant) -> None: """Load entity registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = EntityRegistry(hass) - await hass.data[DATA_REGISTRY].async_load() + await async_get(hass).async_load() @callback diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index 7e7bdc7be41..7d9e0aa29e1 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -2,22 +2,20 @@ from __future__ import annotations -from collections import OrderedDict import fnmatch from functools import lru_cache import re from typing import Any +from homeassistant.const import MAX_EXPECTED_ENTITY_IDS from homeassistant.core import split_entity_id -_MAX_EXPECTED_ENTITIES = 16384 - class EntityValues: """Class to store entity id based values. This class is expected to only be used infrequently - as it caches all entity ids up to _MAX_EXPECTED_ENTITIES. + as it caches all entity ids up to MAX_EXPECTED_ENTITY_IDS. The cache includes `self` so it is important to only use this in places where usage of `EntityValues` is immortal. @@ -36,13 +34,13 @@ class EntityValues: if glob is None: compiled: dict[re.Pattern[str], Any] | None = None else: - compiled = OrderedDict() - for key, value in glob.items(): - compiled[re.compile(fnmatch.translate(key))] = value + compiled = { + re.compile(fnmatch.translate(key)): value for key, value in glob.items() + } self._glob = compiled - @lru_cache(maxsize=_MAX_EXPECTED_ENTITIES) + @lru_cache(maxsize=MAX_EXPECTED_ENTITY_IDS) def get(self, entity_id: str) -> dict[str, str]: """Get config for an entity id.""" domain, _ = split_entity_id(entity_id) diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 837c5e2bc1d..24b65cba82a 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -4,11 +4,18 @@ from __future__ import annotations from collections.abc import Callable import fnmatch +from functools import lru_cache import re import voluptuous as vol -from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE +from homeassistant.const import ( + CONF_DOMAINS, + CONF_ENTITIES, + CONF_EXCLUDE, + CONF_INCLUDE, + MAX_EXPECTED_ENTITY_IDS, +) from homeassistant.core import split_entity_id from . import config_validation as cv @@ -197,6 +204,7 @@ def _generate_filter_from_sets_and_pattern_lists( # - Otherwise: exclude if have_include and not have_exclude: + @lru_cache(maxsize=MAX_EXPECTED_ENTITY_IDS) def entity_included(entity_id: str) -> bool: """Return true if entity matches inclusion filters.""" return ( @@ -215,6 +223,7 @@ def _generate_filter_from_sets_and_pattern_lists( # - Otherwise: include if not have_include and have_exclude: + @lru_cache(maxsize=MAX_EXPECTED_ENTITY_IDS) def entity_not_excluded(entity_id: str) -> bool: """Return true if entity matches exclusion filters.""" return not ( @@ -234,6 +243,7 @@ def _generate_filter_from_sets_and_pattern_lists( # - Otherwise: exclude if include_d or include_eg: + @lru_cache(maxsize=MAX_EXPECTED_ENTITY_IDS) def entity_filter_4a(entity_id: str) -> bool: """Return filter function for case 4a.""" return entity_id in include_e or ( @@ -257,6 +267,7 @@ def _generate_filter_from_sets_and_pattern_lists( # - Otherwise: include if exclude_d or exclude_eg: + @lru_cache(maxsize=MAX_EXPECTED_ENTITY_IDS) def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 5c026064c28..4150d871b6b 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -12,7 +12,7 @@ from functools import partial, wraps import logging from random import randint import time -from typing import TYPE_CHECKING, Any, Concatenate, Generic, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, @@ -38,6 +38,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from . import frame from .device_registry import ( @@ -53,20 +54,21 @@ from .sun import get_astral_event_next from .template import RenderInfo, Template, result_as_boolean from .typing import TemplateVarsType -TRACK_STATE_CHANGE_CALLBACKS = "track_state_change_callbacks" -TRACK_STATE_CHANGE_LISTENER = "track_state_change_listener" - -TRACK_STATE_ADDED_DOMAIN_CALLBACKS = "track_state_added_domain_callbacks" -TRACK_STATE_ADDED_DOMAIN_LISTENER = "track_state_added_domain_listener" - -TRACK_STATE_REMOVED_DOMAIN_CALLBACKS = "track_state_removed_domain_callbacks" -TRACK_STATE_REMOVED_DOMAIN_LISTENER = "track_state_removed_domain_listener" - -TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks" -TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener" - -TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS = "track_device_registry_updated_callbacks" -TRACK_DEVICE_REGISTRY_UPDATED_LISTENER = "track_device_registry_updated_listener" +_TRACK_STATE_CHANGE_DATA: HassKey[_KeyedEventData[EventStateChangedData]] = HassKey( + "track_state_change_data" +) +_TRACK_STATE_ADDED_DOMAIN_DATA: HassKey[_KeyedEventData[EventStateChangedData]] = ( + HassKey("track_state_added_domain_data") +) +_TRACK_STATE_REMOVED_DOMAIN_DATA: HassKey[_KeyedEventData[EventStateChangedData]] = ( + HassKey("track_state_removed_domain_data") +) +_TRACK_ENTITY_REGISTRY_UPDATED_DATA: HassKey[ + _KeyedEventData[EventEntityRegistryUpdatedData] +] = HassKey("track_entity_registry_updated_data") +_TRACK_DEVICE_REGISTRY_UPDATED_DATA: HassKey[ + _KeyedEventData[EventDeviceRegistryUpdatedData] +] = HassKey("track_device_registry_updated_data") _ALL_LISTENER = "all" _DOMAINS_LISTENER = "domains" @@ -82,15 +84,13 @@ RANDOM_MICROSECOND_MIN = 50000 RANDOM_MICROSECOND_MAX = 500000 _TypedDictT = TypeVar("_TypedDictT", bound=Mapping[str, Any]) -_P = ParamSpec("_P") @dataclass(slots=True, frozen=True) class _KeyedEventTracker(Generic[_TypedDictT]): """Class to track events by key.""" - listeners_key: str - callbacks_key: str + key: HassKey[_KeyedEventData[_TypedDictT]] event_type: EventType[_TypedDictT] | str dispatcher_callable: Callable[ [ @@ -110,6 +110,14 @@ class _KeyedEventTracker(Generic[_TypedDictT]): ] +@dataclass(slots=True, frozen=True) +class _KeyedEventData(Generic[_TypedDictT]): + """Class to track data for events by key.""" + + listener: CALLBACK_TYPE + callbacks: defaultdict[str, list[HassJob[[Event[_TypedDictT]], Any]]] + + @dataclass(slots=True) class TrackStates: """Class for keeping track of states being tracked. @@ -157,7 +165,7 @@ class TrackTemplateResult: result: Any -def threaded_listener_factory( +def threaded_listener_factory[**_P]( async_factory: Callable[Concatenate[HomeAssistant, _P], Any], ) -> Callable[Concatenate[HomeAssistant, _P], CALLBACK_TYPE]: """Convert an async event helper to a threaded one.""" @@ -191,8 +199,8 @@ def async_track_state_change( action: Callable[ [str, State | None, State | None], Coroutine[Any, Any, None] | None ], - from_state: None | str | Iterable[str] = None, - to_state: None | str | Iterable[str] = None, + from_state: str | Iterable[str] | None = None, + to_state: str | Iterable[str] | None = None, ) -> CALLBACK_TYPE: """Track specific state changes. @@ -325,7 +333,7 @@ def _async_dispatch_entity_id_event( for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error while dispatching event for %s to %s", event.data["entity_id"], @@ -344,8 +352,7 @@ def _async_state_change_filter( _KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker( - listeners_key=TRACK_STATE_CHANGE_LISTENER, - callbacks_key=TRACK_STATE_CHANGE_CALLBACKS, + key=_TRACK_STATE_CHANGE_DATA, event_type=EVENT_STATE_CHANGED, dispatcher_callable=_async_dispatch_entity_id_event, filter_callable=_async_state_change_filter, @@ -370,10 +377,10 @@ def _remove_empty_listener() -> None: """Remove a listener that does nothing.""" -@callback # type: ignore[arg-type] # mypy bug? +@callback def _remove_listener( hass: HomeAssistant, - listeners_key: str, + tracker: _KeyedEventTracker[_TypedDictT], keys: Iterable[str], job: HassJob[[Event[_TypedDictT]], Any], callbacks: dict[str, list[HassJob[[Event[_TypedDictT]], Any]]], @@ -381,12 +388,11 @@ def _remove_listener( """Remove listener.""" for key in keys: callbacks[key].remove(job) - if len(callbacks[key]) == 0: + if not callbacks[key]: del callbacks[key] if not callbacks: - hass.data[listeners_key]() - del hass.data[listeners_key] + hass.data.pop(tracker.key).listener() # tracker, not hass is intentionally the first argument here since its @@ -401,26 +407,24 @@ def _async_track_event( """Track an event by a specific key. This function is intended for internal use only. - - The dispatcher_callable, filter_callable, event_type, and run_immediately - must always be the same for the listener_key as the first call to this - function will set the listener_key in hass.data. """ if not keys: return _remove_empty_listener hass_data = hass.data - callbacks: defaultdict[str, list[HassJob[[Event[_TypedDictT]], Any]]] | None - if not (callbacks := hass_data.get(tracker.callbacks_key)): - callbacks = hass_data[tracker.callbacks_key] = defaultdict(list) - - listeners_key = tracker.listeners_key - if tracker.listeners_key not in hass_data: - hass_data[tracker.listeners_key] = hass.bus.async_listen( + tracker_key = tracker.key + if tracker_key in hass_data: + event_data = hass_data[tracker_key] + callbacks = event_data.callbacks + else: + callbacks = defaultdict(list) + listener = hass.bus.async_listen( tracker.event_type, partial(tracker.dispatcher_callable, hass, callbacks), event_filter=partial(tracker.filter_callable, hass, callbacks), ) + event_data = _KeyedEventData(listener, callbacks) + hass_data[tracker_key] = event_data job = HassJob(action, f"track {tracker.event_type} event {keys}", job_type=job_type) @@ -431,12 +435,12 @@ def _async_track_event( # during startup, and we want to avoid the overhead of # creating empty lists and throwing them away. callbacks[keys].append(job) - keys = [keys] + keys = (keys,) else: for key in keys: callbacks[key].append(job) - return partial(_remove_listener, hass, listeners_key, keys, job, callbacks) + return partial(_remove_listener, hass, tracker, keys, job, callbacks) @callback @@ -455,7 +459,7 @@ def _async_dispatch_old_entity_id_or_entity_id_event( for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error while dispatching event for %s to %s", event.data.get("old_entity_id", event.data["entity_id"]), @@ -474,8 +478,7 @@ def _async_entity_registry_updated_filter( _KEYED_TRACK_ENTITY_REGISTRY_UPDATED = _KeyedEventTracker( - listeners_key=TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, - callbacks_key=TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, + key=_TRACK_ENTITY_REGISTRY_UPDATED_DATA, event_type=EVENT_ENTITY_REGISTRY_UPDATED, dispatcher_callable=_async_dispatch_old_entity_id_or_entity_id_event, filter_callable=_async_entity_registry_updated_filter, @@ -523,7 +526,7 @@ def _async_dispatch_device_id_event( for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error while dispatching event for %s to %s", event.data["device_id"], @@ -532,8 +535,7 @@ def _async_dispatch_device_id_event( _KEYED_TRACK_DEVICE_REGISTRY_UPDATED = _KeyedEventTracker( - listeners_key=TRACK_DEVICE_REGISTRY_UPDATED_LISTENER, - callbacks_key=TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS, + key=_TRACK_DEVICE_REGISTRY_UPDATED_DATA, event_type=EVENT_DEVICE_REGISTRY_UPDATED, dispatcher_callable=_async_dispatch_device_id_event, filter_callable=_async_device_registry_updated_filter, @@ -567,7 +569,7 @@ def _async_dispatch_domain_event( for job in callbacks.get(domain, []) + callbacks.get(MATCH_ALL, []): try: hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception( "Error while processing event %s for domain %s", event, domain ) @@ -582,7 +584,10 @@ def _async_domain_added_filter( """Filter state changes by entity_id.""" return event_data["old_state"] is None and ( MATCH_ALL in callbacks - or split_entity_id(event_data["entity_id"])[0] in callbacks + or + # If old_state is None, new_state must be set but + # mypy doesn't know that + event_data["new_state"].domain in callbacks # type: ignore[union-attr] ) @@ -600,8 +605,7 @@ def async_track_state_added_domain( _KEYED_TRACK_STATE_ADDED_DOMAIN = _KeyedEventTracker( - listeners_key=TRACK_STATE_ADDED_DOMAIN_LISTENER, - callbacks_key=TRACK_STATE_ADDED_DOMAIN_CALLBACKS, + key=_TRACK_STATE_ADDED_DOMAIN_DATA, event_type=EVENT_STATE_CHANGED, dispatcher_callable=_async_dispatch_domain_event, filter_callable=_async_domain_added_filter, @@ -630,13 +634,15 @@ def _async_domain_removed_filter( """Filter state changes by entity_id.""" return event_data["new_state"] is None and ( MATCH_ALL in callbacks - or split_entity_id(event_data["entity_id"])[0] in callbacks + or + # If new_state is None, old_state must be set but + # mypy doesn't know that + event_data["old_state"].domain in callbacks # type: ignore[union-attr] ) _KEYED_TRACK_STATE_REMOVED_DOMAIN = _KeyedEventTracker( - listeners_key=TRACK_STATE_REMOVED_DOMAIN_LISTENER, - callbacks_key=TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, + key=_TRACK_STATE_REMOVED_DOMAIN_DATA, event_type=EVENT_STATE_CHANGED, dispatcher_callable=_async_dispatch_domain_event, filter_callable=_async_domain_removed_filter, @@ -1251,7 +1257,7 @@ class TrackTemplateResultInfo: self.hass.async_run_hass_job(self._job, event, updates) -TrackTemplateResultListener = Callable[ +type TrackTemplateResultListener = Callable[ [ Event[EventStateChangedData] | None, list[TrackTemplateResult], @@ -1561,11 +1567,10 @@ class _TrackTimeInterval: cancel_on_shutdown: bool | None _track_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None _run_job: HassJob[[datetime], Coroutine[Any, Any, None] | None] | None = None - _cancel_callback: CALLBACK_TYPE | None = None + _timer_handle: asyncio.TimerHandle | None = None def async_attach(self) -> None: """Initialize track job.""" - hass = self.hass self._track_job = HassJob( self._interval_listener, self.job_name, @@ -1577,32 +1582,32 @@ class _TrackTimeInterval: f"track time interval {self.seconds}", cancel_on_shutdown=self.cancel_on_shutdown, ) - self._cancel_callback = async_call_at( - hass, - self._track_job, - hass.loop.time() + self.seconds, + self._schedule_timer() + + def _schedule_timer(self) -> None: + """Schedule the timer.""" + if TYPE_CHECKING: + assert self._track_job is not None + hass = self.hass + loop = hass.loop + self._timer_handle = loop.call_at( + loop.time() + self.seconds, self._interval_listener, self._track_job ) @callback - def _interval_listener(self, now: datetime) -> None: + def _interval_listener(self, _: Any) -> None: """Handle elapsed intervals.""" if TYPE_CHECKING: assert self._run_job is not None - assert self._track_job is not None - hass = self.hass - self._cancel_callback = async_call_at( - hass, - self._track_job, - hass.loop.time() + self.seconds, - ) - hass.async_run_hass_job(self._run_job, now, background=True) + self._schedule_timer() + self.hass.async_run_hass_job(self._run_job, dt_util.utcnow(), background=True) @callback def async_cancel(self) -> None: """Cancel the call_at.""" if TYPE_CHECKING: - assert self._cancel_callback is not None - self._cancel_callback() + assert self._timer_handle is not None + self._timer_handle.cancel() @callback @@ -1766,7 +1771,6 @@ class _TrackUTCTimeChange: # time when the timer was scheduled utc_now = time_tracker_utcnow() localized_now = dt_util.as_local(utc_now) if self.local else utc_now - hass.async_run_hass_job(self.job, localized_now, background=True) if TYPE_CHECKING: assert self._pattern_time_change_listener_job is not None self._cancel_callback = async_track_point_in_utc_time( @@ -1774,6 +1778,7 @@ class _TrackUTCTimeChange: self._pattern_time_change_listener_job, self._calculate_next(utc_now + timedelta(seconds=1)), ) + hass.async_run_hass_job(self.job, localized_now, background=True) @callback def async_cancel(self) -> None: @@ -1851,7 +1856,7 @@ track_time_change = threaded_listener_factory(async_track_time_change) def process_state_match( - parameter: None | str | Iterable[str], invert: bool = False + parameter: str | Iterable[str] | None, invert: bool = False ) -> Callable[[str | None], bool]: """Convert parameter to function that matches input against parameter.""" if parameter is None or parameter == MATCH_ALL: diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py index 4a11d85176a..9bf8a2a5d26 100644 --- a/homeassistant/helpers/floor_registry.py +++ b/homeassistant/helpers/floor_registry.py @@ -5,11 +5,12 @@ from __future__ import annotations from collections.abc import Iterable import dataclasses from dataclasses import dataclass -from typing import Literal, TypedDict, cast +from typing import Literal, TypedDict from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util import slugify from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, @@ -17,10 +18,11 @@ from .normalized_name_base_registry import ( normalize_name, ) from .registry import BaseRegistry +from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType -DATA_REGISTRY = "floor_registry" +DATA_REGISTRY: HassKey[FloorRegistry] = HassKey("floor_registry") EVENT_FLOOR_REGISTRY_UPDATED: EventType[EventFloorRegistryUpdatedData] = EventType( "floor_registry_updated" ) @@ -51,7 +53,7 @@ class EventFloorRegistryUpdatedData(TypedDict): floor_id: str -EventFloorRegistryUpdated = Event[EventFloorRegistryUpdatedData] +type EventFloorRegistryUpdated = Event[EventFloorRegistryUpdatedData] @dataclass(slots=True, kw_only=True, frozen=True) @@ -119,6 +121,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): level: int | None = None, ) -> FloorEntry: """Create a new floor.""" + self.hass.verify_event_loop_thread("floor_registry.async_create") if floor := self.async_get_floor_by_name(name): raise ValueError( f"The name {name} ({floor.normalized_name}) is already in use" @@ -137,7 +140,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): floor_id = floor.floor_id self.floors[floor_id] = floor self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_FLOOR_REGISTRY_UPDATED, EventFloorRegistryUpdatedData( action="create", @@ -149,8 +152,9 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): @callback def async_delete(self, floor_id: str) -> None: """Delete floor.""" + self.hass.verify_event_loop_thread("floor_registry.async_delete") del self.floors[floor_id] - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_FLOOR_REGISTRY_UPDATED, EventFloorRegistryUpdatedData( action="remove", @@ -187,10 +191,11 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): if not changes: return old + self.hass.verify_event_loop_thread("floor_registry.async_update") new = self.floors[floor_id] = dataclasses.replace(old, **changes) # type: ignore[arg-type] self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_FLOOR_REGISTRY_UPDATED, EventFloorRegistryUpdatedData( action="update", @@ -238,13 +243,13 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]): @callback +@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> FloorRegistry: """Get floor registry.""" - return cast(FloorRegistry, hass.data[DATA_REGISTRY]) + return FloorRegistry(hass) async def async_load(hass: HomeAssistant) -> None: """Load floor registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = FloorRegistry(hass) - await hass.data[DATA_REGISTRY].async_load() + await async_get(hass).async_load() diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 068a12c0598..8a30c26886e 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from contextlib import suppress from dataclasses import dataclass import functools from functools import cached_property @@ -12,9 +11,9 @@ import linecache import logging import sys from types import FrameType -from typing import Any, TypeVar, cast +from typing import Any, cast -from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.core import async_get_hass_or_none from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue @@ -23,8 +22,6 @@ _LOGGER = logging.getLogger(__name__) # Keep track of integrations already reported to prevent flooding _REPORTED_INTEGRATIONS: set[str] = set() -_CallableT = TypeVar("_CallableT", bound=Callable) - @dataclass(kw_only=True) class IntegrationFrame: @@ -34,17 +31,17 @@ class IntegrationFrame: integration: str module: str | None relative_filename: str - _frame: FrameType + frame: FrameType @cached_property def line_number(self) -> int: """Return the line number of the frame.""" - return self._frame.f_lineno + return self.frame.f_lineno @cached_property def filename(self) -> str: """Return the filename of the frame.""" - return self._frame.f_code.co_filename + return self.frame.f_code.co_filename @cached_property def line(self) -> str: @@ -75,7 +72,7 @@ def get_integration_logger(fallback_name: str) -> logging.Logger: def get_current_frame(depth: int = 0) -> FrameType: """Return the current frame.""" # Add one to depth since get_current_frame is included - return sys._getframe(depth + 1) # pylint: disable=protected-access + return sys._getframe(depth + 1) # noqa: SLF001 def get_integration_frame(exclude_integrations: set | None = None) -> IntegrationFrame: @@ -122,7 +119,7 @@ def get_integration_frame(exclude_integrations: set | None = None) -> Integratio integration=integration, module=found_module, relative_filename=found_frame.f_code.co_filename[index:], - _frame=found_frame, + frame=found_frame, ) @@ -178,11 +175,8 @@ def _report_integration( return _REPORTED_INTEGRATIONS.add(key) - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() report_issue = async_suggest_report_issue( - hass, + async_get_hass_or_none(), integration_domain=integration_frame.integration, module=integration_frame.module, ) @@ -209,7 +203,7 @@ def _report_integration( ) -def warn_use(func: _CallableT, what: str) -> _CallableT: +def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" if asyncio.iscoroutinefunction(func): @@ -224,3 +218,16 @@ def warn_use(func: _CallableT, what: str) -> _CallableT: report(what) return cast(_CallableT, report_use) + + +def report_non_thread_safe_operation(what: str) -> None: + """Report a non-thread safe operation.""" + report( + f"calls {what} from a thread other than the event loop, " + "which may cause Home Assistant to crash or data to corrupt. " + "For more information, see " + "https://developers.home-assistant.io/docs/asyncio_thread_safety/" + f"#{what.replace('.', '')}", + error_if_core=True, + error_if_integration=True, + ) diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index a464056fc07..22f8e2acbeb 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -30,10 +30,10 @@ from .json import find_paths_unserializable_data, json_bytes, json_dumps _LOGGER = logging.getLogger(__name__) -AllowCorsType = Callable[[AbstractRoute | AbstractResource], None] +type AllowCorsType = Callable[[AbstractRoute | AbstractResource], None] KEY_AUTHENTICATED: Final = "ha_authenticated" KEY_ALLOW_ALL_CORS = AppKey[AllowCorsType]("allow_all_cors") -KEY_ALLOW_CONFIGRED_CORS = AppKey[AllowCorsType]("allow_configured_cors") +KEY_ALLOW_CONFIGURED_CORS = AppKey[AllowCorsType]("allow_configured_cors") KEY_HASS: AppKey[HomeAssistant] = AppKey("hass") current_request: ContextVar[Request | None] = ContextVar( @@ -181,7 +181,7 @@ class HomeAssistantView: if self.cors_allowed: allow_cors = app.get(KEY_ALLOW_ALL_CORS) else: - allow_cors = app.get(KEY_ALLOW_CONFIGRED_CORS) + allow_cors = app.get(KEY_ALLOW_CONFIGURED_CORS) if allow_cors: for route in routes: diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index a0112ae0843..c3a65943cb5 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import sys from typing import Any, Self @@ -11,6 +11,7 @@ import httpx from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from homeassistant.util.ssl import ( SSLCipherList, client_context, @@ -23,8 +24,10 @@ from .frame import warn_use # and we want to keep the connection open for a while so we # don't have to reconnect every time so we use 15s to match aiohttp. KEEP_ALIVE_TIMEOUT = 15 -DATA_ASYNC_CLIENT = "httpx_async_client" -DATA_ASYNC_CLIENT_NOVERIFY = "httpx_async_client_noverify" +DATA_ASYNC_CLIENT: HassKey[httpx.AsyncClient] = HassKey("httpx_async_client") +DATA_ASYNC_CLIENT_NOVERIFY: HassKey[httpx.AsyncClient] = HassKey( + "httpx_async_client_noverify" +) DEFAULT_LIMITS = limits = httpx.Limits(keepalive_expiry=KEEP_ALIVE_TIMEOUT) SERVER_SOFTWARE = ( f"{APPLICATION_NAME}/{__version__} " @@ -42,9 +45,7 @@ def get_async_client(hass: HomeAssistant, verify_ssl: bool = True) -> httpx.Asyn """ key = DATA_ASYNC_CLIENT if verify_ssl else DATA_ASYNC_CLIENT_NOVERIFY - client: httpx.AsyncClient | None = hass.data.get(key) - - if client is None: + if (client := hass.data.get(key)) is None: client = hass.data[key] = create_async_httpx_client(hass, verify_ssl) return client @@ -104,7 +105,7 @@ def create_async_httpx_client( def _async_register_async_client_shutdown( hass: HomeAssistant, client: httpx.AsyncClient, - original_aclose: Callable[..., Any], + original_aclose: Callable[[], Coroutine[Any, Any, None]], ) -> None: """Register httpx AsyncClient aclose on Home Assistant shutdown. diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index db90d38744a..e759719f667 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -11,24 +11,16 @@ from typing import Any from homeassistant.core import HomeAssistant, callback from homeassistant.loader import Integration, async_get_integrations +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import load_json_object from .translation import build_resources -ICON_CACHE = "icon_cache" +ICON_CACHE: HassKey[_IconsCache] = HassKey("icon_cache") _LOGGER = logging.getLogger(__name__) -@callback -def _component_icons_path(integration: Integration) -> pathlib.Path: - """Return the icons json file location for a component. - - Ex: components/hue/icons.json - """ - return integration.file_path / "icons.json" - - def _load_icons_files( icons_files: dict[str, pathlib.Path], ) -> dict[str, dict[str, Any]]: @@ -49,7 +41,7 @@ async def _async_get_component_icons( # Determine files to load files_to_load = { - comp: _component_icons_path(integrations[comp]) for comp in components + comp: integrations[comp].file_path / "icons.json" for comp in components } # Load files @@ -142,7 +134,7 @@ async def async_get_icons( components = hass.config.top_level_components if ICON_CACHE in hass.data: - cache: _IconsCache = hass.data[ICON_CACHE] + cache = hass.data[ICON_CACHE] else: cache = hass.data[ICON_CACHE] = _IconsCache(hass) diff --git a/homeassistant/helpers/importlib.py b/homeassistant/helpers/importlib.py index 98c75939084..a4886f8aac5 100644 --- a/homeassistant/helpers/importlib.py +++ b/homeassistant/helpers/importlib.py @@ -10,12 +10,15 @@ import sys from types import ModuleType from homeassistant.core import HomeAssistant +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) -DATA_IMPORT_CACHE = "import_cache" -DATA_IMPORT_FUTURES = "import_futures" -DATA_IMPORT_FAILURES = "import_failures" +DATA_IMPORT_CACHE: HassKey[dict[str, ModuleType]] = HassKey("import_cache") +DATA_IMPORT_FUTURES: HassKey[dict[str, asyncio.Future[ModuleType]]] = HassKey( + "import_futures" +) +DATA_IMPORT_FAILURES: HassKey[dict[str, bool]] = HassKey("import_failures") def _get_module(cache: dict[str, ModuleType], name: str) -> ModuleType: @@ -26,17 +29,15 @@ def _get_module(cache: dict[str, ModuleType], name: str) -> ModuleType: async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType: """Import a module or return it from the cache.""" - cache: dict[str, ModuleType] = hass.data.setdefault(DATA_IMPORT_CACHE, {}) + cache = hass.data.setdefault(DATA_IMPORT_CACHE, {}) if module := cache.get(name): return module - failure_cache: dict[str, bool] = hass.data.setdefault(DATA_IMPORT_FAILURES, {}) + failure_cache = hass.data.setdefault(DATA_IMPORT_FAILURES, {}) if name in failure_cache: raise ModuleNotFoundError(f"{name} not found", name=name) - import_futures: dict[str, asyncio.Future[ModuleType]] import_futures = hass.data.setdefault(DATA_IMPORT_FUTURES, {}) - if future := import_futures.get(name): return await future diff --git a/homeassistant/helpers/instance_id.py b/homeassistant/helpers/instance_id.py index 8bad8f90b9c..3c9790ad13d 100644 --- a/homeassistant/helpers/instance_id.py +++ b/homeassistant/helpers/instance_id.py @@ -29,7 +29,7 @@ async def async_get(hass: HomeAssistant) -> str: hass.config.path(LEGACY_UUID_FILE), store, ) - except Exception: # pylint: disable=broad-exception-caught + except Exception: _LOGGER.exception( ( "Could not read hass instance ID from '%s' or '%s', a new instance ID " diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index fbd26019b64..a3eb19657e8 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -20,10 +20,13 @@ from homeassistant.loader import ( bind_hass, ) from homeassistant.setup import ATTR_COMPONENT, EventComponentLoaded +from homeassistant.util.hass_dict import HassKey from homeassistant.util.logging import catch_log_exception _LOGGER = logging.getLogger(__name__) -DATA_INTEGRATION_PLATFORMS = "integration_platforms" +DATA_INTEGRATION_PLATFORMS: HassKey[list[IntegrationPlatform]] = HassKey( + "integration_platforms" +) @dataclass(slots=True, frozen=True) @@ -160,8 +163,7 @@ async def async_process_integration_platforms( ) -> None: """Process a specific platform for all current and future loaded integrations.""" if DATA_INTEGRATION_PLATFORMS not in hass.data: - integration_platforms: list[IntegrationPlatform] = [] - hass.data[DATA_INTEGRATION_PLATFORMS] = integration_platforms + integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] = [] hass.bus.async_listen( EVENT_COMPONENT_LOADED, partial( diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 119142ec14a..b1ddf5eacc7 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -6,9 +6,10 @@ from abc import abstractmethod import asyncio from collections.abc import Collection, Coroutine, Iterable import dataclasses -from dataclasses import dataclass -from enum import Enum +from dataclasses import dataclass, field +from enum import Enum, auto from functools import cached_property +from itertools import groupby import logging from typing import Any @@ -23,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from . import ( area_registry, @@ -33,7 +35,7 @@ from . import ( ) _LOGGER = logging.getLogger(__name__) -_SlotsType = dict[str, Any] +type _SlotsType = dict[str, Any] INTENT_TURN_OFF = "HassTurnOff" INTENT_TURN_ON = "HassTurnOn" @@ -41,10 +43,17 @@ INTENT_TOGGLE = "HassToggle" INTENT_GET_STATE = "HassGetState" INTENT_NEVERMIND = "HassNevermind" INTENT_SET_POSITION = "HassSetPosition" +INTENT_START_TIMER = "HassStartTimer" +INTENT_CANCEL_TIMER = "HassCancelTimer" +INTENT_INCREASE_TIMER = "HassIncreaseTimer" +INTENT_DECREASE_TIMER = "HassDecreaseTimer" +INTENT_PAUSE_TIMER = "HassPauseTimer" +INTENT_UNPAUSE_TIMER = "HassUnpauseTimer" +INTENT_TIMER_STATUS = "HassTimerStatus" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) -DATA_KEY = "intent" +DATA_KEY: HassKey[dict[str, IntentHandler]] = HassKey("intent") SPEECH_TYPE_PLAIN = "plain" SPEECH_TYPE_SSML = "ssml" @@ -55,9 +64,10 @@ SPEECH_TYPE_SSML = "ssml" def async_register(hass: HomeAssistant, handler: IntentHandler) -> None: """Register an intent with Home Assistant.""" if (intents := hass.data.get(DATA_KEY)) is None: - intents = hass.data[DATA_KEY] = {} + intents = {} + hass.data[DATA_KEY] = intents - assert handler.intent_type is not None, "intent_type cannot be None" + assert getattr(handler, "intent_type", None), "intent_type should be set" if handler.intent_type in intents: _LOGGER.warning( @@ -77,6 +87,12 @@ def async_remove(hass: HomeAssistant, intent_type: str) -> None: intents.pop(intent_type, None) +@callback +def async_get(hass: HomeAssistant) -> Iterable[IntentHandler]: + """Return registered intents.""" + return hass.data.get(DATA_KEY, {}).values() + + @bind_hass async def async_handle( hass: HomeAssistant, @@ -87,9 +103,11 @@ async def async_handle( context: Context | None = None, language: str | None = None, assistant: str | None = None, + device_id: str | None = None, + conversation_agent_id: str | None = None, ) -> IntentResponse: """Handle an intent.""" - handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type) + handler = hass.data.get(DATA_KEY, {}).get(intent_type) if handler is None: raise UnknownIntent(f"Unknown intent {intent_type}") @@ -109,6 +127,8 @@ async def async_handle( context=context, language=language, assistant=assistant, + device_id=device_id, + conversation_agent_id=conversation_agent_id, ) try: @@ -120,6 +140,7 @@ async def async_handle( except IntentError: raise # bubble up intent related errors except Exception as err: + _LOGGER.exception("Error handling %s", intent_type) raise IntentUnexpectedError(f"Error handling {intent_type}") from err return result @@ -139,16 +160,167 @@ class InvalidSlotInfo(IntentError): class IntentHandleError(IntentError): """Error while handling intent.""" + def __init__(self, message: str = "", response_key: str | None = None) -> None: + """Initialize error.""" + super().__init__(message) + self.response_key = response_key + class IntentUnexpectedError(IntentError): """Unexpected error while handling intent.""" -class NoStatesMatchedError(IntentError): +class MatchFailedReason(Enum): + """Possible reasons for match failure in async_match_targets.""" + + NAME = auto() + """No entities matched name constraint.""" + + AREA = auto() + """No entities matched area constraint.""" + + FLOOR = auto() + """No entities matched floor constraint.""" + + DOMAIN = auto() + """No entities matched domain constraint.""" + + DEVICE_CLASS = auto() + """No entities matched device class constraint.""" + + FEATURE = auto() + """No entities matched supported features constraint.""" + + STATE = auto() + """No entities matched required states constraint.""" + + ASSISTANT = auto() + """No entities matched exposed to assistant constraint.""" + + INVALID_AREA = auto() + """Area name from constraint does not exist.""" + + INVALID_FLOOR = auto() + """Floor name from constraint does not exist.""" + + DUPLICATE_NAME = auto() + """Two or more entities matched the same name constraint and could not be disambiguated.""" + + def is_no_entities_reason(self) -> bool: + """Return True if the match failed because no entities matched.""" + return self not in ( + MatchFailedReason.INVALID_AREA, + MatchFailedReason.INVALID_FLOOR, + MatchFailedReason.DUPLICATE_NAME, + ) + + +@dataclass +class MatchTargetsConstraints: + """Constraints for async_match_targets.""" + + name: str | None = None + """Entity name or alias.""" + + area_name: str | None = None + """Area name, id, or alias.""" + + floor_name: str | None = None + """Floor name, id, or alias.""" + + domains: Collection[str] | None = None + """Domain names.""" + + device_classes: Collection[str] | None = None + """Device class names.""" + + features: int | None = None + """Required supported features.""" + + states: Collection[str] | None = None + """Required states for entities.""" + + assistant: str | None = None + """Name of assistant that entities should be exposed to.""" + + allow_duplicate_names: bool = False + """True if entities with duplicate names are allowed in result.""" + + @property + def has_constraints(self) -> bool: + """Returns True if at least one constraint is set (ignores assistant).""" + return bool( + self.name + or self.area_name + or self.floor_name + or self.domains + or self.device_classes + or self.features + or self.states + ) + + +@dataclass +class MatchTargetsPreferences: + """Preferences used to disambiguate duplicate name matches in async_match_targets.""" + + area_id: str | None = None + """Id of area to use when deduplicating names.""" + + floor_id: str | None = None + """Id of floor to use when deduplicating names.""" + + +@dataclass +class MatchTargetsResult: + """Result from async_match_targets.""" + + is_match: bool + """True if one or more entities matched.""" + + no_match_reason: MatchFailedReason | None = None + """Reason for failed match when is_match = False.""" + + states: list[State] = field(default_factory=list) + """List of matched entity states when is_match = True.""" + + no_match_name: str | None = None + """Name of invalid area/floor or duplicate name when match fails for those reasons.""" + + areas: list[area_registry.AreaEntry] = field(default_factory=list) + """Areas that were targeted.""" + + floors: list[floor_registry.FloorEntry] = field(default_factory=list) + """Floors that were targeted.""" + + +class MatchFailedError(IntentError): + """Error when target matching fails.""" + + def __init__( + self, + result: MatchTargetsResult, + constraints: MatchTargetsConstraints, + preferences: MatchTargetsPreferences | None = None, + ) -> None: + """Initialize error.""" + super().__init__() + + self.result = result + self.constraints = constraints + self.preferences = preferences + + def __str__(self) -> str: + """Return string representation.""" + return f"" + + +class NoStatesMatchedError(MatchFailedError): """Error when no states match the intent's constraints.""" def __init__( self, + reason: MatchFailedReason, name: str | None = None, area: str | None = None, floor: str | None = None, @@ -156,123 +328,379 @@ class NoStatesMatchedError(IntentError): device_classes: set[str] | None = None, ) -> None: """Initialize error.""" - super().__init__() - - self.name = name - self.area = area - self.floor = floor - self.domains = domains - self.device_classes = device_classes + super().__init__( + result=MatchTargetsResult(False, reason), + constraints=MatchTargetsConstraints( + name=name, + area_name=area, + floor_name=floor, + domains=domains, + device_classes=device_classes, + ), + ) -class DuplicateNamesMatchedError(IntentError): - """Error when two or more entities with the same name matched.""" +@dataclass +class MatchTargetsCandidate: + """Candidate for async_match_targets.""" - def __init__(self, name: str, area: str | None) -> None: - """Initialize error.""" - super().__init__() - - self.name = name - self.area = area + state: State + entity: entity_registry.RegistryEntry | None = None + area: area_registry.AreaEntry | None = None + floor: floor_registry.FloorEntry | None = None + device: device_registry.DeviceEntry | None = None + matched_name: str | None = None -def _is_device_class( - state: State, - entity: entity_registry.RegistryEntry | None, - device_classes: Collection[str], -) -> bool: - """Return true if entity device class matches.""" - # Try entity first - if (entity is not None) and (entity.device_class is not None): - # Entity device class can be None or blank as "unset" - if entity.device_class in device_classes: - return True - - # Fall back to state attribute - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - return (device_class is not None) and (device_class in device_classes) - - -def _has_name( - state: State, entity: entity_registry.RegistryEntry | None, name: str -) -> bool: - """Return true if entity name or alias matches.""" - if name in (state.entity_id, state.name.casefold()): - return True - - # Check name/aliases - if (entity is None) or (not entity.aliases): - return False - - return any(name == alias.casefold() for alias in entity.aliases) - - -def _find_area( - id_or_name: str, areas: area_registry.AreaRegistry -) -> area_registry.AreaEntry | None: - """Find an area by id or name, checking aliases too.""" - area = areas.async_get_area(id_or_name) or areas.async_get_area_by_name(id_or_name) - if area is not None: - return area - - # Check area aliases - for maybe_area in areas.areas.values(): - if not maybe_area.aliases: +def _find_areas( + name: str, areas: area_registry.AreaRegistry +) -> Iterable[area_registry.AreaEntry]: + """Find all areas matching a name (including aliases).""" + name_norm = _normalize_name(name) + for area in areas.async_list_areas(): + # Accept name or area id + if (area.id == name) or (_normalize_name(area.name) == name_norm): + yield area continue - for area_alias in maybe_area.aliases: - if id_or_name == area_alias.casefold(): - return maybe_area - - return None - - -def _find_floor( - id_or_name: str, floors: floor_registry.FloorRegistry -) -> floor_registry.FloorEntry | None: - """Find an floor by id or name, checking aliases too.""" - floor = floors.async_get_floor(id_or_name) or floors.async_get_floor_by_name( - id_or_name - ) - if floor is not None: - return floor - - # Check floor aliases - for maybe_floor in floors.floors.values(): - if not maybe_floor.aliases: + if not area.aliases: continue - for floor_alias in maybe_floor.aliases: - if id_or_name == floor_alias.casefold(): - return maybe_floor - - return None + for alias in area.aliases: + if _normalize_name(alias) == name_norm: + yield area + break -def _filter_by_areas( - states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]], - areas: Iterable[area_registry.AreaEntry], +def _find_floors( + name: str, floors: floor_registry.FloorRegistry +) -> Iterable[floor_registry.FloorEntry]: + """Find all floors matching a name (including aliases).""" + name_norm = _normalize_name(name) + for floor in floors.async_list_floors(): + # Accept name or floor id + if (floor.floor_id == name) or (_normalize_name(floor.name) == name_norm): + yield floor + continue + + if not floor.aliases: + continue + + for alias in floor.aliases: + if _normalize_name(alias) == name_norm: + yield floor + break + + +def _normalize_name(name: str) -> str: + """Normalize name for comparison.""" + return name.strip().casefold() + + +def _filter_by_name( + name: str, + candidates: Iterable[MatchTargetsCandidate], +) -> Iterable[MatchTargetsCandidate]: + """Filter candidates by name.""" + name_norm = _normalize_name(name) + + for candidate in candidates: + # Accept name or entity id + if (candidate.state.entity_id == name) or _normalize_name( + candidate.state.name + ) == name_norm: + candidate.matched_name = name + yield candidate + continue + + if candidate.entity is None: + continue + + if candidate.entity.name and ( + _normalize_name(candidate.entity.name) == name_norm + ): + candidate.matched_name = name + yield candidate + continue + + # Check aliases + if candidate.entity.aliases: + for alias in candidate.entity.aliases: + if _normalize_name(alias) == name_norm: + candidate.matched_name = name + yield candidate + break + + +def _filter_by_features( + features: int, + candidates: Iterable[MatchTargetsCandidate], +) -> Iterable[MatchTargetsCandidate]: + """Filter candidates by supported features.""" + for candidate in candidates: + if (candidate.entity is not None) and ( + (candidate.entity.supported_features & features) == features + ): + yield candidate + continue + + supported_features = candidate.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if (supported_features & features) == features: + yield candidate + + +def _filter_by_device_classes( + device_classes: Iterable[str], + candidates: Iterable[MatchTargetsCandidate], +) -> Iterable[MatchTargetsCandidate]: + """Filter candidates by device classes.""" + for candidate in candidates: + if ( + (candidate.entity is not None) + and candidate.entity.device_class + and (candidate.entity.device_class in device_classes) + ): + yield candidate + continue + + device_class = candidate.state.attributes.get(ATTR_DEVICE_CLASS) + if device_class and (device_class in device_classes): + yield candidate + + +def _add_areas( + areas: area_registry.AreaRegistry, devices: device_registry.DeviceRegistry, -) -> Iterable[tuple[State, entity_registry.RegistryEntry | None]]: - """Filter state/entity pairs by an area.""" - filter_area_ids: set[str | None] = {a.id for a in areas} - entity_area_ids: dict[str, str | None] = {} - for _state, entity in states_and_entities: - if entity is None: + candidates: Iterable[MatchTargetsCandidate], +) -> None: + """Add area and device entries to match candidates.""" + for candidate in candidates: + if candidate.entity is None: continue - if entity.area_id: - # Use entity's area id first - entity_area_ids[entity.id] = entity.area_id - elif entity.device_id: - # Fall back to device area if not set on entity - device = devices.async_get(entity.device_id) - if device is not None: - entity_area_ids[entity.id] = device.area_id + if candidate.entity.device_id: + candidate.device = devices.async_get(candidate.entity.device_id) - for state, entity in states_and_entities: - if (entity is not None) and (entity_area_ids.get(entity.id) in filter_area_ids): - yield (state, entity) + if candidate.entity.area_id: + # Use entity area first + candidate.area = areas.async_get_area(candidate.entity.area_id) + assert candidate.area is not None + elif (candidate.device is not None) and candidate.device.area_id: + # Fall back to device area + candidate.area = areas.async_get_area(candidate.device.area_id) + + +@callback +def async_match_targets( # noqa: C901 + hass: HomeAssistant, + constraints: MatchTargetsConstraints, + preferences: MatchTargetsPreferences | None = None, + states: list[State] | None = None, +) -> MatchTargetsResult: + """Match entities based on constraints in order to handle an intent.""" + preferences = preferences or MatchTargetsPreferences() + filtered_by_domain = False + + if not states: + # Get all states and filter by domain + states = hass.states.async_all(constraints.domains) + filtered_by_domain = True + if not states: + return MatchTargetsResult(False, MatchFailedReason.DOMAIN) + + if constraints.assistant: + # Filter by exposure + states = [ + s + for s in states + if async_should_expose(hass, constraints.assistant, s.entity_id) + ] + if not states: + return MatchTargetsResult(False, MatchFailedReason.ASSISTANT) + + if constraints.domains and (not filtered_by_domain): + # Filter by domain (if we didn't already do it) + states = [s for s in states if s.domain in constraints.domains] + if not states: + return MatchTargetsResult(False, MatchFailedReason.DOMAIN) + + if constraints.states: + # Filter by state + states = [s for s in states if s.state in constraints.states] + if not states: + return MatchTargetsResult(False, MatchFailedReason.STATE) + + # Exit early so we can to avoid registry lookups + if not ( + constraints.name + or constraints.features + or constraints.device_classes + or constraints.area_name + or constraints.floor_name + ): + return MatchTargetsResult(True, states=states) + + # We need entity registry entries now + er = entity_registry.async_get(hass) + candidates = [MatchTargetsCandidate(s, er.async_get(s.entity_id)) for s in states] + + if constraints.name: + # Filter by entity name or alias + candidates = list(_filter_by_name(constraints.name, candidates)) + if not candidates: + return MatchTargetsResult(False, MatchFailedReason.NAME) + + if constraints.features: + # Filter by supported features + candidates = list(_filter_by_features(constraints.features, candidates)) + if not candidates: + return MatchTargetsResult(False, MatchFailedReason.FEATURE) + + if constraints.device_classes: + # Filter by device class + candidates = list( + _filter_by_device_classes(constraints.device_classes, candidates) + ) + if not candidates: + return MatchTargetsResult(False, MatchFailedReason.DEVICE_CLASS) + + # Check floor/area constraints + targeted_floors: list[floor_registry.FloorEntry] | None = None + targeted_areas: list[area_registry.AreaEntry] | None = None + + # True when area information has been added to candidates + areas_added = False + + if constraints.floor_name or constraints.area_name: + ar = area_registry.async_get(hass) + dr = device_registry.async_get(hass) + _add_areas(ar, dr, candidates) + areas_added = True + + if constraints.floor_name: + # Filter by areas associated with floor + fr = floor_registry.async_get(hass) + targeted_floors = list(_find_floors(constraints.floor_name, fr)) + if not targeted_floors: + return MatchTargetsResult( + False, + MatchFailedReason.INVALID_FLOOR, + no_match_name=constraints.floor_name, + ) + + possible_floor_ids = {floor.floor_id for floor in targeted_floors} + possible_area_ids = { + area.id + for area in ar.async_list_areas() + if area.floor_id in possible_floor_ids + } + + candidates = [ + c + for c in candidates + if (c.area is not None) and (c.area.id in possible_area_ids) + ] + if not candidates: + return MatchTargetsResult( + False, MatchFailedReason.FLOOR, floors=targeted_floors + ) + else: + # All areas are possible + possible_area_ids = {area.id for area in ar.async_list_areas()} + + if constraints.area_name: + targeted_areas = list(_find_areas(constraints.area_name, ar)) + if not targeted_areas: + return MatchTargetsResult( + False, + MatchFailedReason.INVALID_AREA, + no_match_name=constraints.area_name, + ) + + matching_area_ids = {area.id for area in targeted_areas} + + # May be constrained by floors above + possible_area_ids.intersection_update(matching_area_ids) + candidates = [ + c + for c in candidates + if (c.area is not None) and (c.area.id in possible_area_ids) + ] + if not candidates: + return MatchTargetsResult( + False, MatchFailedReason.AREA, areas=targeted_areas + ) + + if constraints.name and (not constraints.allow_duplicate_names): + # Check for duplicates + if not areas_added: + ar = area_registry.async_get(hass) + dr = device_registry.async_get(hass) + _add_areas(ar, dr, candidates) + areas_added = True + + sorted_candidates = sorted( + [c for c in candidates if c.matched_name], + key=lambda c: c.matched_name or "", + ) + final_candidates: list[MatchTargetsCandidate] = [] + for name, group in groupby(sorted_candidates, key=lambda c: c.matched_name): + group_candidates = list(group) + if len(group_candidates) < 2: + # No duplicates for name + final_candidates.extend(group_candidates) + continue + + # Try to disambiguate by preferences + if preferences.floor_id: + group_candidates = [ + c + for c in group_candidates + if (c.area is not None) + and (c.area.floor_id == preferences.floor_id) + ] + if len(group_candidates) < 2: + # Disambiguated by floor + final_candidates.extend(group_candidates) + continue + + if preferences.area_id: + group_candidates = [ + c + for c in group_candidates + if (c.area is not None) and (c.area.id == preferences.area_id) + ] + if len(group_candidates) < 2: + # Disambiguated by area + final_candidates.extend(group_candidates) + continue + + # Couldn't disambiguate duplicate names + return MatchTargetsResult( + False, + MatchFailedReason.DUPLICATE_NAME, + no_match_name=name, + areas=targeted_areas or [], + floors=targeted_floors or [], + ) + + if not final_candidates: + return MatchTargetsResult( + False, + MatchFailedReason.NAME, + areas=targeted_areas or [], + floors=targeted_floors or [], + ) + + candidates = final_candidates + + return MatchTargetsResult( + True, + None, + states=[c.state for c in candidates], + areas=targeted_areas or [], + floors=targeted_floors or [], + ) @callback @@ -281,111 +709,26 @@ def async_match_states( hass: HomeAssistant, name: str | None = None, area_name: str | None = None, - area: area_registry.AreaEntry | None = None, floor_name: str | None = None, - floor: floor_registry.FloorEntry | None = None, domains: Collection[str] | None = None, device_classes: Collection[str] | None = None, - states: Iterable[State] | None = None, - entities: entity_registry.EntityRegistry | None = None, - areas: area_registry.AreaRegistry | None = None, - floors: floor_registry.FloorRegistry | None = None, - devices: device_registry.DeviceRegistry | None = None, + states: list[State] | None = None, assistant: str | None = None, ) -> Iterable[State]: - """Find states that match the constraints.""" - if states is None: - # All states - states = hass.states.async_all() - - if entities is None: - entities = entity_registry.async_get(hass) - - if devices is None: - devices = device_registry.async_get(hass) - - if areas is None: - areas = area_registry.async_get(hass) - - if floors is None: - floors = floor_registry.async_get(hass) - - # Gather entities - states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]] = [] - for state in states: - entity = entities.async_get(state.entity_id) - if (entity is not None) and entity.entity_category: - # Skip diagnostic entities - continue - - states_and_entities.append((state, entity)) - - # Filter by domain and device class - if domains: - states_and_entities = [ - (state, entity) - for state, entity in states_and_entities - if state.domain in domains - ] - - if device_classes: - # Check device class in state attribute and in entity entry (if available) - states_and_entities = [ - (state, entity) - for state, entity in states_and_entities - if _is_device_class(state, entity, device_classes) - ] - - filter_areas: list[area_registry.AreaEntry] = [] - - if (floor is None) and (floor_name is not None): - # Look up floor by name - floor = _find_floor(floor_name, floors) - if floor is None: - _LOGGER.warning("Floor not found: %s", floor_name) - return - - if floor is not None: - filter_areas = [ - a for a in areas.async_list_areas() if a.floor_id == floor.floor_id - ] - - if (area is None) and (area_name is not None): - # Look up area by name - area = _find_area(area_name, areas) - if area is None: - _LOGGER.warning("Area not found: %s", area_name) - return - - if area is not None: - filter_areas = [area] - - if filter_areas: - # Filter by states/entities by area - states_and_entities = list( - _filter_by_areas(states_and_entities, filter_areas, devices) - ) - - if assistant is not None: - # Filter by exposure - states_and_entities = [ - (state, entity) - for state, entity in states_and_entities - if async_should_expose(hass, assistant, state.entity_id) - ] - - if name is not None: - # Filter by name - name = name.casefold() - - # Check states - for state, entity in states_and_entities: - if _has_name(state, entity, name): - yield state - else: - # Not filtered by name - for state, _entity in states_and_entities: - yield state + """Simplified interface to async_match_targets that returns states matching the constraints.""" + result = async_match_targets( + hass, + constraints=MatchTargetsConstraints( + name=name, + area_name=area_name, + floor_name=floor_name, + domains=domains, + device_classes=device_classes, + assistant=assistant, + ), + states=states, + ) + return result.states @callback @@ -398,9 +741,14 @@ def async_test_feature(state: State, feature: int, feature_name: str) -> None: class IntentHandler: """Intent handler registration.""" - intent_type: str | None = None - slot_schema: vol.Schema | None = None - platforms: Iterable[str] | None = [] + intent_type: str + platforms: set[str] | None = None + description: str | None = None + + @property + def slot_schema(self) -> dict | None: + """Return a slot schema.""" + return None @callback def async_can_handle(self, intent_obj: Intent) -> bool: @@ -436,18 +784,21 @@ class IntentHandler: return f"<{self.__class__.__name__} - {self.intent_type}>" +def non_empty_string(value: Any) -> str: + """Coerce value to string and fail if string is empty or whitespace.""" + value_str = cv.string(value) + if not value_str.strip(): + raise vol.Invalid("string value is empty") + + return value_str + + class DynamicServiceIntentHandler(IntentHandler): """Service Intent handler registration (dynamic). Service specific intent handler that calls a service by name/entity_id. """ - slot_schema = { - vol.Any("name", "area", "floor"): cv.string, - vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), - vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), - } - # We use a small timeout in service calls to (hopefully) pass validation # checks, but not try to wait for the call to fully complete. service_timeout: float = 0.2 @@ -456,37 +807,69 @@ class DynamicServiceIntentHandler(IntentHandler): self, intent_type: str, speech: str | None = None, - extra_slots: dict[str, vol.Schema] | None = None, + required_slots: dict[str | tuple[str, str], vol.Schema] | None = None, + optional_slots: dict[str | tuple[str, str], vol.Schema] | None = None, + required_domains: set[str] | None = None, + required_features: int | None = None, + required_states: set[str] | None = None, + description: str | None = None, + platforms: set[str] | None = None, ) -> None: """Create Service Intent Handler.""" self.intent_type = intent_type self.speech = speech - self.extra_slots = extra_slots + self.required_domains = required_domains + self.required_features = required_features + self.required_states = required_states + self.description = description + self.platforms = platforms + + self.required_slots: dict[tuple[str, str], vol.Schema] = {} + if required_slots: + for key, value_schema in required_slots.items(): + if isinstance(key, str): + # Slot name/service data key + key = (key, key) + + self.required_slots[key] = value_schema + + self.optional_slots: dict[tuple[str, str], vol.Schema] = {} + if optional_slots: + for key, value_schema in optional_slots.items(): + if isinstance(key, str): + # Slot name/service data key + key = (key, key) + + self.optional_slots[key] = value_schema @cached_property - def _slot_schema(self) -> vol.Schema: - """Create validation schema for slots (with extra required slots).""" - if self.slot_schema is None: - raise ValueError("Slot schema is not defined") + def slot_schema(self) -> dict: + """Return a slot schema.""" + slot_schema = { + vol.Any("name", "area", "floor"): non_empty_string, + vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } - if self.extra_slots: - slot_schema = { - **self.slot_schema, - **{ - vol.Required(key): schema - for key, schema in self.extra_slots.items() - }, - } - else: - slot_schema = self.slot_schema + if self.required_slots: + slot_schema.update( + { + vol.Required(key[0]): validator + for key, validator in self.required_slots.items() + } + ) - return vol.Schema( - { - key: SLOT_SCHEMA.extend({"value": validator}) - for key, validator in slot_schema.items() - }, - extra=vol.ALLOW_EXTRA, - ) + if self.optional_slots: + slot_schema.update( + { + vol.Optional(key[0]): validator + for key, validator in self.optional_slots.items() + } + ) + + return slot_schema @abstractmethod def get_domain_and_service( @@ -507,97 +890,111 @@ class DynamicServiceIntentHandler(IntentHandler): # Don't match on name if targeting all entities entity_name = None - # Look up area to fail early + # Get area/floor info area_slot = slots.get("area", {}) area_id = area_slot.get("value") - area_name = area_slot.get("text") - area: area_registry.AreaEntry | None = None - if area_id is not None: - areas = area_registry.async_get(hass) - area = areas.async_get_area(area_id) - if area is None: - raise IntentHandleError(f"No area named {area_name}") - # Look up floor to fail early floor_slot = slots.get("floor", {}) floor_id = floor_slot.get("value") - floor_name = floor_slot.get("text") - floor: floor_registry.FloorEntry | None = None - if floor_id is not None: - floors = floor_registry.async_get(hass) - floor = floors.async_get_floor(floor_id) - if floor is None: - raise IntentHandleError(f"No floor named {floor_name}") # Optional domain/device class filters. # Convert to sets for speed. - domains: set[str] | None = None + domains: set[str] | None = self.required_domains device_classes: set[str] | None = None if "domain" in slots: domains = set(slots["domain"]["value"]) + if self.required_domains: + # Must be a subset of intent's required domain(s) + domains.intersection_update(self.required_domains) if "device_class" in slots: device_classes = set(slots["device_class"]["value"]) - states = list( - async_match_states( - hass, - name=entity_name, - area=area, - floor=floor, - domains=domains, - device_classes=device_classes, - assistant=intent_obj.assistant, - ) + match_constraints = MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains=domains, + device_classes=device_classes, + assistant=intent_obj.assistant, + features=self.required_features, + states=self.required_states, + ) + if not match_constraints.has_constraints: + # Fail if attempting to target all devices in the house + raise IntentHandleError("Service handler cannot target all devices") + + match_preferences = MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), ) - if not states: - # No states matched constraints - raise NoStatesMatchedError( - name=entity_text or entity_name, - area=area_name or area_id, - floor=floor_name or floor_id, - domains=domains, - device_classes=device_classes, + match_result = async_match_targets(hass, match_constraints, match_preferences) + if not match_result.is_match: + raise MatchFailedError( + result=match_result, + constraints=match_constraints, + preferences=match_preferences, ) - if entity_name and (len(states) > 1): - # Multiple entities matched for the same name - raise DuplicateNamesMatchedError( - name=entity_text or entity_name, - area=area_name or area_id, - ) + # Ensure name is text + if ("name" in slots) and entity_text: + slots["name"]["value"] = entity_text + + # Replace area/floor values with the resolved ids for use in templates + if ("area" in slots) and match_result.areas: + slots["area"]["value"] = match_result.areas[0].id + + if ("floor" in slots) and match_result.floors: + slots["floor"]["value"] = match_result.floors[0].floor_id # Update intent slots to include any transformations done by the schemas intent_obj.slots = slots - response = await self.async_handle_states(intent_obj, states, area) + response = await self.async_handle_states( + intent_obj, match_result, match_constraints, match_preferences + ) # Make the matched states available in the response - response.async_set_states(matched_states=states, unmatched_states=[]) + response.async_set_states( + matched_states=match_result.states, unmatched_states=[] + ) return response async def async_handle_states( self, intent_obj: Intent, - states: list[State], - area: area_registry.AreaEntry | None = None, + match_result: MatchTargetsResult, + match_constraints: MatchTargetsConstraints, + match_preferences: MatchTargetsPreferences | None = None, ) -> IntentResponse: """Complete action on matched entity states.""" - assert states, "No states" - hass = intent_obj.hass - success_results: list[IntentResponseTarget] = [] + states = match_result.states response = intent_obj.create_response() - if area is not None: - success_results.append( + hass = intent_obj.hass + success_results: list[IntentResponseTarget] = [] + + if match_result.floors: + success_results.extend( + IntentResponseTarget( + type=IntentResponseTargetType.FLOOR, + name=floor.name, + id=floor.floor_id, + ) + for floor in match_result.floors + ) + speech_name = match_result.floors[0].name + elif match_result.areas: + success_results.extend( IntentResponseTarget( type=IntentResponseTargetType.AREA, name=area.name, id=area.id ) + for area in match_result.areas ) - speech_name = area.name + speech_name = match_result.areas[0].name else: speech_name = states[0].name @@ -622,7 +1019,7 @@ class DynamicServiceIntentHandler(IntentHandler): try: await service_coro success_results.append(target) - except Exception: # pylint: disable=broad-except + except Exception: failed_results.append(target) _LOGGER.exception("Service call failed for %s", state.entity_id) @@ -653,11 +1050,20 @@ class DynamicServiceIntentHandler(IntentHandler): hass = intent_obj.hass service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id} - if self.extra_slots: + if self.required_slots: service_data.update( - {key: intent_obj.slots[key]["value"] for key in self.extra_slots} + { + key[1]: intent_obj.slots[key[0]]["value"] + for key in self.required_slots + } ) + if self.optional_slots: + for key in self.optional_slots: + value = intent_obj.slots.get(key[0]) + if value: + service_data[key[1]] = value["value"] + await self._run_then_background( hass.async_create_task_internal( hass.services.async_call( @@ -701,10 +1107,26 @@ class ServiceIntentHandler(DynamicServiceIntentHandler): domain: str, service: str, speech: str | None = None, - extra_slots: dict[str, vol.Schema] | None = None, + required_slots: dict[str | tuple[str, str], vol.Schema] | None = None, + optional_slots: dict[str | tuple[str, str], vol.Schema] | None = None, + required_domains: set[str] | None = None, + required_features: int | None = None, + required_states: set[str] | None = None, + description: str | None = None, + platforms: set[str] | None = None, ) -> None: """Create service handler.""" - super().__init__(intent_type, speech=speech, extra_slots=extra_slots) + super().__init__( + intent_type, + speech=speech, + required_slots=required_slots, + optional_slots=optional_slots, + required_domains=required_domains, + required_features=required_features, + required_states=required_states, + description=description, + platforms=platforms, + ) self.domain = domain self.service = service @@ -738,6 +1160,8 @@ class Intent: "language", "category", "assistant", + "device_id", + "conversation_agent_id", ] def __init__( @@ -751,6 +1175,8 @@ class Intent: language: str, category: IntentCategory | None = None, assistant: str | None = None, + device_id: str | None = None, + conversation_agent_id: str | None = None, ) -> None: """Initialize an intent.""" self.hass = hass @@ -762,6 +1188,8 @@ class Intent: self.language = language self.category = category self.assistant = assistant + self.device_id = device_id + self.conversation_agent_id = conversation_agent_id @callback def create_response(self) -> IntentResponse: @@ -805,6 +1233,7 @@ class IntentResponseTargetType(str, Enum): """Type of target for an intent response.""" AREA = "area" + FLOOR = "floor" DEVICE = "device" ENTITY = "entity" DOMAIN = "domain" @@ -841,6 +1270,7 @@ class IntentResponse: self.failed_results: list[IntentResponseTarget] = [] self.matched_states: list[State] = [] self.unmatched_states: list[State] = [] + self.speech_slots: dict[str, Any] = {} if (self.intent is not None) and (self.intent.category == IntentCategory.QUERY): # speech will be the answer to the query @@ -916,6 +1346,11 @@ class IntentResponse: self.matched_states = matched_states self.unmatched_states = unmatched_states or [] + @callback + def async_set_speech_slots(self, speech_slots: dict[str, Any]) -> None: + """Set slots that will be used in the response template of the default agent.""" + self.speech_slots = speech_slots + @callback def as_dict(self) -> dict[str, Any]: """Return a dictionary representation of an intent response.""" @@ -928,6 +1363,8 @@ class IntentResponse: if self.reprompt: response_dict["reprompt"] = self.reprompt + if self.speech_slots: + response_dict["speech_slots"] = self.speech_slots response_data: dict[str, Any] = {} diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 11bde0edf6b..109d363d262 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -6,7 +6,7 @@ import dataclasses from datetime import datetime from enum import StrEnum import functools as ft -from typing import Any, cast +from typing import Any, Literal, TypedDict, cast from awesomeversion import AwesomeVersion, AwesomeVersionStrategy @@ -14,17 +14,30 @@ from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant, callback from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util +from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from .registry import BaseRegistry +from .singleton import singleton from .storage import Store -DATA_REGISTRY = "issue_registry" -EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED = "repairs_issue_registry_updated" +DATA_REGISTRY: HassKey[IssueRegistry] = HassKey("issue_registry") +EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED: EventType[EventIssueRegistryUpdatedData] = ( + EventType("repairs_issue_registry_updated") +) STORAGE_KEY = "repairs.issue_registry" STORAGE_VERSION_MAJOR = 1 STORAGE_VERSION_MINOR = 2 +class EventIssueRegistryUpdatedData(TypedDict): + """Event data for when the issue registry is updated.""" + + action: Literal["create", "remove", "update"] + domain: str + issue_id: str + + class IssueSeverity(StrEnum): """Issue severity.""" @@ -96,18 +109,16 @@ class IssueRegistryStore(Store[dict[str, list[dict[str, Any]]]]): class IssueRegistry(BaseRegistry): """Class to hold a registry of issues.""" - def __init__(self, hass: HomeAssistant, *, read_only: bool = False) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the issue registry.""" self.hass = hass self.issues: dict[tuple[str, str], IssueEntry] = {} - self._read_only = read_only self._store = IssueRegistryStore( hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, atomic_writes=True, minor_version=STORAGE_VERSION_MINOR, - read_only=read_only, ) @callback @@ -132,7 +143,7 @@ class IssueRegistry(BaseRegistry): translation_placeholders: dict[str, str] | None = None, ) -> IssueEntry: """Get issue. Create if it doesn't exist.""" - + self.hass.verify_event_loop_thread("issue_registry.async_get_or_create") if (issue := self.async_get_issue(domain, issue_id)) is None: issue = IssueEntry( active=True, @@ -152,9 +163,13 @@ class IssueRegistry(BaseRegistry): ) self.issues[(domain, issue_id)] = issue self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, - {"action": "create", "domain": domain, "issue_id": issue_id}, + EventIssueRegistryUpdatedData( + action="create", + domain=domain, + issue_id=issue_id, + ), ) else: replacement = dataclasses.replace( @@ -174,9 +189,13 @@ class IssueRegistry(BaseRegistry): if replacement != issue: issue = self.issues[(domain, issue_id)] = replacement self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, - {"action": "update", "domain": domain, "issue_id": issue_id}, + EventIssueRegistryUpdatedData( + action="update", + domain=domain, + issue_id=issue_id, + ), ) return issue @@ -184,18 +203,24 @@ class IssueRegistry(BaseRegistry): @callback def async_delete(self, domain: str, issue_id: str) -> None: """Delete issue.""" + self.hass.verify_event_loop_thread("issue_registry.async_delete") if self.issues.pop((domain, issue_id), None) is None: return self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, - {"action": "remove", "domain": domain, "issue_id": issue_id}, + EventIssueRegistryUpdatedData( + action="remove", + domain=domain, + issue_id=issue_id, + ), ) @callback def async_ignore(self, domain: str, issue_id: str, ignore: bool) -> IssueEntry: """Ignore issue.""" + self.hass.verify_event_loop_thread("issue_registry.async_ignore") old = self.issues[(domain, issue_id)] dismissed_version = ha_version if ignore else None if old.dismissed_version == dismissed_version: @@ -207,13 +232,25 @@ class IssueRegistry(BaseRegistry): ) self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, - {"action": "update", "domain": domain, "issue_id": issue_id}, + EventIssueRegistryUpdatedData( + action="update", + domain=domain, + issue_id=issue_id, + ), ) return issue + @callback + def make_read_only(self) -> None: + """Make the registry read-only. + + This method is irreversible. + """ + self._store.make_read_only() + async def async_load(self) -> None: """Load the issue registry.""" data = await self._store.async_load() @@ -271,16 +308,18 @@ class IssueRegistry(BaseRegistry): @callback +@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> IssueRegistry: """Get issue registry.""" - return cast(IssueRegistry, hass.data[DATA_REGISTRY]) + return IssueRegistry(hass) async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None: """Load issue registry.""" - assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = IssueRegistry(hass, read_only=read_only) - await hass.data[DATA_REGISTRY].async_load() + ir = async_get(hass) + if read_only: # only used in for check config script + ir.make_read_only() + return await ir.async_load() @callback diff --git a/homeassistant/helpers/label_registry.py b/homeassistant/helpers/label_registry.py index 81901c71745..64e884e1428 100644 --- a/homeassistant/helpers/label_registry.py +++ b/homeassistant/helpers/label_registry.py @@ -5,11 +5,12 @@ from __future__ import annotations from collections.abc import Iterable import dataclasses from dataclasses import dataclass -from typing import Literal, TypedDict, cast +from typing import Literal, TypedDict from homeassistant.core import Event, HomeAssistant, callback from homeassistant.util import slugify from homeassistant.util.event_type import EventType +from homeassistant.util.hass_dict import HassKey from .normalized_name_base_registry import ( NormalizedNameBaseRegistryEntry, @@ -17,10 +18,11 @@ from .normalized_name_base_registry import ( normalize_name, ) from .registry import BaseRegistry +from .singleton import singleton from .storage import Store from .typing import UNDEFINED, UndefinedType -DATA_REGISTRY = "label_registry" +DATA_REGISTRY: HassKey[LabelRegistry] = HassKey("label_registry") EVENT_LABEL_REGISTRY_UPDATED: EventType[EventLabelRegistryUpdatedData] = EventType( "label_registry_updated" ) @@ -51,7 +53,7 @@ class EventLabelRegistryUpdatedData(TypedDict): label_id: str -EventLabelRegistryUpdated = Event[EventLabelRegistryUpdatedData] +type EventLabelRegistryUpdated = Event[EventLabelRegistryUpdatedData] @dataclass(slots=True, frozen=True, kw_only=True) @@ -119,6 +121,7 @@ class LabelRegistry(BaseRegistry[LabelRegistryStoreData]): description: str | None = None, ) -> LabelEntry: """Create a new label.""" + self.hass.verify_event_loop_thread("label_registry.async_create") if label := self.async_get_label_by_name(name): raise ValueError( f"The name {name} ({label.normalized_name}) is already in use" @@ -137,7 +140,7 @@ class LabelRegistry(BaseRegistry[LabelRegistryStoreData]): label_id = label.label_id self.labels[label_id] = label self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_LABEL_REGISTRY_UPDATED, EventLabelRegistryUpdatedData( action="create", @@ -149,8 +152,9 @@ class LabelRegistry(BaseRegistry[LabelRegistryStoreData]): @callback def async_delete(self, label_id: str) -> None: """Delete label.""" + self.hass.verify_event_loop_thread("label_registry.async_delete") del self.labels[label_id] - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_LABEL_REGISTRY_UPDATED, EventLabelRegistryUpdatedData( action="remove", @@ -188,10 +192,11 @@ class LabelRegistry(BaseRegistry[LabelRegistryStoreData]): if not changes: return old + self.hass.verify_event_loop_thread("label_registry.async_update") new = self.labels[label_id] = dataclasses.replace(old, **changes) # type: ignore[arg-type] self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_LABEL_REGISTRY_UPDATED, EventLabelRegistryUpdatedData( action="update", @@ -239,13 +244,13 @@ class LabelRegistry(BaseRegistry[LabelRegistryStoreData]): @callback +@singleton(DATA_REGISTRY) def async_get(hass: HomeAssistant) -> LabelRegistry: """Get label registry.""" - return cast(LabelRegistry, hass.data[DATA_REGISTRY]) + return LabelRegistry(hass) async def async_load(hass: HomeAssistant) -> None: """Load label registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = LabelRegistry(hass) - await hass.data[DATA_REGISTRY].async_load() + await async_get(hass).async_load() diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py new file mode 100644 index 00000000000..53ec092fda2 --- /dev/null +++ b/homeassistant/helpers/llm.py @@ -0,0 +1,472 @@ +"""Module to coordinate llm tools.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from functools import cache, partial +from typing import Any + +import slugify as unicode_slug +import voluptuous as vol + +from homeassistant.components.climate.intent import INTENT_GET_TEMPERATURE +from homeassistant.components.conversation.trace import ( + ConversationTraceEventType, + async_conversation_trace_append, +) +from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER +from homeassistant.components.homeassistant.exposed_entities import async_should_expose +from homeassistant.components.intent import async_device_supports_timers +from homeassistant.components.weather.intent import INTENT_GET_WEATHER +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import yaml +from homeassistant.util.json import JsonObjectType + +from . import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + intent, + service, +) +from .singleton import singleton + +LLM_API_ASSIST = "assist" + +BASE_PROMPT = ( + 'Current time is {{ now().strftime("%H:%M:%S") }}. ' + 'Today\'s date is {{ now().strftime("%Y-%m-%d") }}.\n' +) + +DEFAULT_INSTRUCTIONS_PROMPT = """You are a voice assistant for Home Assistant. +Answer questions about the world truthfully. +Answer in plain text. Keep it simple and to the point. +""" + + +@callback +def async_render_no_api_prompt(hass: HomeAssistant) -> str: + """Return the prompt to be used when no API is configured.""" + return ( + "Only if the user wants to control a device, tell them to edit the AI configuration " + "and allow access to Home Assistant." + ) + + +@singleton("llm") +@callback +def _async_get_apis(hass: HomeAssistant) -> dict[str, API]: + """Get all the LLM APIs.""" + return { + LLM_API_ASSIST: AssistAPI(hass=hass), + } + + +@callback +def async_register_api(hass: HomeAssistant, api: API) -> None: + """Register an API to be exposed to LLMs.""" + apis = _async_get_apis(hass) + + if api.id in apis: + raise HomeAssistantError(f"API {api.id} is already registered") + + apis[api.id] = api + + +async def async_get_api( + hass: HomeAssistant, api_id: str, llm_context: LLMContext +) -> APIInstance: + """Get an API.""" + apis = _async_get_apis(hass) + + if api_id not in apis: + raise HomeAssistantError(f"API {api_id} not found") + + return await apis[api_id].async_get_api_instance(llm_context) + + +@callback +def async_get_apis(hass: HomeAssistant) -> list[API]: + """Get all the LLM APIs.""" + return list(_async_get_apis(hass).values()) + + +@dataclass(slots=True) +class LLMContext: + """Tool input to be processed.""" + + platform: str + context: Context | None + user_prompt: str | None + language: str | None + assistant: str | None + device_id: str | None + + +@dataclass(slots=True) +class ToolInput: + """Tool input to be processed.""" + + tool_name: str + tool_args: dict[str, Any] + + +class Tool: + """LLM Tool base class.""" + + name: str + description: str | None = None + parameters: vol.Schema = vol.Schema({}) + + @abstractmethod + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Call the tool.""" + raise NotImplementedError + + def __repr__(self) -> str: + """Represent a string of a Tool.""" + return f"<{self.__class__.__name__} - {self.name}>" + + +@dataclass +class APIInstance: + """Instance of an API to be used by an LLM.""" + + api: API + api_prompt: str + llm_context: LLMContext + tools: list[Tool] + + async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: + """Call a LLM tool, validate args and return the response.""" + async_conversation_trace_append( + ConversationTraceEventType.LLM_TOOL_CALL, + {"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args}, + ) + + for tool in self.tools: + if tool.name == tool_input.tool_name: + break + else: + raise HomeAssistantError(f'Tool "{tool_input.tool_name}" not found') + + return await tool.async_call(self.api.hass, tool_input, self.llm_context) + + +@dataclass(slots=True, kw_only=True) +class API(ABC): + """An API to expose to LLMs.""" + + hass: HomeAssistant + id: str + name: str + + @abstractmethod + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + raise NotImplementedError + + +class IntentTool(Tool): + """LLM Tool representing an Intent.""" + + def __init__( + self, + name: str, + intent_handler: intent.IntentHandler, + ) -> None: + """Init the class.""" + self.name = name + self.description = ( + intent_handler.description or f"Execute Home Assistant {self.name} intent" + ) + self.extra_slots = None + if not (slot_schema := intent_handler.slot_schema): + return + + slot_schema = {**slot_schema} + extra_slots = set() + + for field in ("preferred_area_id", "preferred_floor_id"): + if field in slot_schema: + extra_slots.add(field) + del slot_schema[field] + + self.parameters = vol.Schema(slot_schema) + if extra_slots: + self.extra_slots = extra_slots + + async def async_call( + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext + ) -> JsonObjectType: + """Handle the intent.""" + slots = {key: {"value": val} for key, val in tool_input.tool_args.items()} + + if self.extra_slots and llm_context.device_id: + device_reg = dr.async_get(hass) + device = device_reg.async_get(llm_context.device_id) + + area: ar.AreaEntry | None = None + floor: fr.FloorEntry | None = None + if device: + area_reg = ar.async_get(hass) + if device.area_id and (area := area_reg.async_get_area(device.area_id)): + if area.floor_id: + floor_reg = fr.async_get(hass) + floor = floor_reg.async_get_floor(area.floor_id) + + for slot_name, slot_value in ( + ("preferred_area_id", area.id if area else None), + ("preferred_floor_id", floor.floor_id if floor else None), + ): + if slot_value and slot_name in self.extra_slots: + slots[slot_name] = {"value": slot_value} + + intent_response = await intent.async_handle( + hass=hass, + platform=llm_context.platform, + intent_type=self.name, + slots=slots, + text_input=llm_context.user_prompt, + context=llm_context.context, + language=llm_context.language, + assistant=llm_context.assistant, + device_id=llm_context.device_id, + ) + response = intent_response.as_dict() + del response["language"] + del response["card"] + return response + + +class AssistAPI(API): + """API exposing Assist API to LLMs.""" + + IGNORE_INTENTS = { + INTENT_GET_TEMPERATURE, + INTENT_GET_WEATHER, + INTENT_OPEN_COVER, # deprecated + INTENT_CLOSE_COVER, # deprecated + intent.INTENT_GET_STATE, + intent.INTENT_NEVERMIND, + intent.INTENT_TOGGLE, + } + + def __init__(self, hass: HomeAssistant) -> None: + """Init the class.""" + super().__init__( + hass=hass, + id=LLM_API_ASSIST, + name="Assist", + ) + self.cached_slugify = cache( + partial(unicode_slug.slugify, separator="_", lowercase=False) + ) + + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: + """Return the instance of the API.""" + if llm_context.assistant: + exposed_entities: dict | None = _get_exposed_entities( + self.hass, llm_context.assistant + ) + else: + exposed_entities = None + + return APIInstance( + api=self, + api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), + llm_context=llm_context, + tools=self._async_get_tools(llm_context, exposed_entities), + ) + + @callback + def _async_get_api_prompt( + self, llm_context: LLMContext, exposed_entities: dict | None + ) -> str: + """Return the prompt for the API.""" + if not exposed_entities: + return ( + "Only if the user wants to control a device, tell them to expose entities " + "to their voice assistant in Home Assistant." + ) + + prompt = [ + ( + "When controlling Home Assistant always call the intent tools. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "When controlling a device, prefer passing just its name and its domain " + "(what comes before the dot in its entity id). " + "When controlling an area, prefer passing just area name and domain." + ) + ] + area: ar.AreaEntry | None = None + floor: fr.FloorEntry | None = None + if llm_context.device_id: + device_reg = dr.async_get(self.hass) + device = device_reg.async_get(llm_context.device_id) + + if device: + area_reg = ar.async_get(self.hass) + if device.area_id and (area := area_reg.async_get_area(device.area_id)): + floor_reg = fr.async_get(self.hass) + if area.floor_id: + floor = floor_reg.async_get_floor(area.floor_id) + + extra = "and all generic commands like 'turn on the lights' should target this area." + + if floor and area: + prompt.append(f"You are in area {area.name} (floor {floor.name}) {extra}") + elif area: + prompt.append(f"You are in area {area.name} {extra}") + else: + prompt.append( + "When a user asks to turn on all devices of a specific type, " + "ask user to specify an area, unless there is only one device of that type." + ) + + if not llm_context.device_id or not async_device_supports_timers( + self.hass, llm_context.device_id + ): + prompt.append("This device does not support timers.") + + if exposed_entities: + prompt.append( + "An overview of the areas and the devices in this smart home:" + ) + prompt.append(yaml.dump(exposed_entities)) + + return "\n".join(prompt) + + @callback + def _async_get_tools( + self, llm_context: LLMContext, exposed_entities: dict | None + ) -> list[Tool]: + """Return a list of LLM tools.""" + ignore_intents = self.IGNORE_INTENTS + if not llm_context.device_id or not async_device_supports_timers( + self.hass, llm_context.device_id + ): + ignore_intents = ignore_intents | { + intent.INTENT_START_TIMER, + intent.INTENT_CANCEL_TIMER, + intent.INTENT_INCREASE_TIMER, + intent.INTENT_DECREASE_TIMER, + intent.INTENT_PAUSE_TIMER, + intent.INTENT_UNPAUSE_TIMER, + intent.INTENT_TIMER_STATUS, + } + + intent_handlers = [ + intent_handler + for intent_handler in intent.async_get(self.hass) + if intent_handler.intent_type not in ignore_intents + ] + + exposed_domains: set[str] | None = None + if exposed_entities is not None: + exposed_domains = { + entity_id.split(".")[0] for entity_id in exposed_entities + } + intent_handlers = [ + intent_handler + for intent_handler in intent_handlers + if intent_handler.platforms is None + or intent_handler.platforms & exposed_domains + ] + + return [ + IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) + for intent_handler in intent_handlers + ] + + +def _get_exposed_entities( + hass: HomeAssistant, assistant: str +) -> dict[str, dict[str, Any]]: + """Get exposed entities.""" + area_registry = ar.async_get(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + interesting_attributes = { + "temperature", + "current_temperature", + "temperature_unit", + "brightness", + "humidity", + "unit_of_measurement", + "device_class", + "current_position", + "percentage", + "volume_level", + "media_title", + "media_artist", + "media_album_name", + } + + entities = {} + + for state in hass.states.async_all(): + if not async_should_expose(hass, assistant, state.entity_id): + continue + + entity_entry = entity_registry.async_get(state.entity_id) + names = [state.name] + area_names = [] + description: str | None = None + + if entity_entry is not None: + names.extend(entity_entry.aliases) + if entity_entry.area_id and ( + area := area_registry.async_get_area(entity_entry.area_id) + ): + # Entity is in area + area_names.append(area.name) + area_names.extend(area.aliases) + elif entity_entry.device_id and ( + device := device_registry.async_get(entity_entry.device_id) + ): + # Check device area + if device.area_id and ( + area := area_registry.async_get_area(device.area_id) + ): + area_names.append(area.name) + area_names.extend(area.aliases) + + if ( + state.domain == "script" + and entity_entry.unique_id + and ( + service_desc := service.async_get_cached_service_description( + hass, "script", entity_entry.unique_id + ) + ) + ): + description = service_desc.get("description") + + info: dict[str, Any] = { + "names": ", ".join(names), + "state": state.state, + } + + if description: + info["description"] = description + + if area_names: + info["areas"] = ", ".join(area_names) + + if attributes := { + attr_name: str(attr_value) if isinstance(attr_value, Enum) else attr_value + for attr_name, attr_value in state.attributes.items() + if attr_name in interesting_attributes + }: + info["attributes"] = attributes + + entities[state.entity_id] = info + + return entities diff --git a/homeassistant/helpers/normalized_name_base_registry.py b/homeassistant/helpers/normalized_name_base_registry.py index f14d99b7831..1cffac9ffc5 100644 --- a/homeassistant/helpers/normalized_name_base_registry.py +++ b/homeassistant/helpers/normalized_name_base_registry.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from functools import lru_cache -from typing import TypeVar from .registry import BaseRegistryItems @@ -15,16 +14,15 @@ class NormalizedNameBaseRegistryEntry: normalized_name: str -_VT = TypeVar("_VT", bound=NormalizedNameBaseRegistryEntry) - - @lru_cache(maxsize=1024) def normalize_name(name: str) -> str: """Normalize a name by removing whitespace and case folding.""" return name.casefold().replace(" ", "") -class NormalizedNameBaseRegistryItems(BaseRegistryItems[_VT]): +class NormalizedNameBaseRegistryItems[_VT: NormalizedNameBaseRegistryEntry]( + BaseRegistryItems[_VT] +): """Base container for normalized name registry items, maps key -> entry. Maintains an additional index: diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index 020c7c3a0d3..c9b1f21cba7 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -6,12 +6,9 @@ import asyncio from collections.abc import Callable, Hashable import logging import time -from typing import TypeVarTuple from homeassistant.core import HomeAssistant, callback -_Ts = TypeVarTuple("_Ts") - _LOGGER = logging.getLogger(__name__) @@ -52,7 +49,7 @@ class KeyedRateLimit: self._rate_limit_timers.clear() @callback - def async_schedule_action( + def async_schedule_action[*_Ts]( self, key: Hashable, rate_limit: float | None, diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 74ebbe5c67a..6155fc9b320 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -1,12 +1,15 @@ """Helpers to check recorder.""" +from __future__ import annotations + import asyncio from dataclasses import dataclass, field from typing import Any from homeassistant.core import HomeAssistant, callback +from homeassistant.util.hass_dict import HassKey -DOMAIN = "recorder" +DOMAIN: HassKey[RecorderData] = HassKey("recorder") @dataclass(slots=True) @@ -14,7 +17,7 @@ class RecorderData: """Recorder data stored in hass.data.""" recorder_platforms: dict[str, Any] = field(default_factory=dict) - db_connected: asyncio.Future = field(default_factory=asyncio.Future) + db_connected: asyncio.Future[bool] = field(default_factory=asyncio.Future) def async_migration_in_progress(hass: HomeAssistant) -> bool: @@ -40,5 +43,4 @@ async def async_wait_recorder(hass: HomeAssistant) -> bool: """ if DOMAIN not in hass.data: return False - db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected - return await db_connected + return await hass.data[DOMAIN].db_connected diff --git a/homeassistant/helpers/redact.py b/homeassistant/helpers/redact.py index ad06f58a50a..cc4f53ae70e 100644 --- a/homeassistant/helpers/redact.py +++ b/homeassistant/helpers/redact.py @@ -3,15 +3,12 @@ from __future__ import annotations from collections.abc import Callable, Iterable, Mapping -from typing import Any, TypeVar, cast, overload +from typing import Any, cast, overload from homeassistant.core import callback REDACTED = "**REDACTED**" -_T = TypeVar("_T") -_ValueT = TypeVar("_ValueT") - def partial_redact( x: str | Any, unmasked_prefix: int = 4, unmasked_suffix: int = 4 @@ -32,19 +29,19 @@ def partial_redact( @overload -def async_redact_data( # type: ignore[overload-overlap] +def async_redact_data[_ValueT]( data: Mapping, to_redact: Iterable[Any] | Mapping[Any, Callable[[_ValueT], _ValueT]] ) -> dict: ... @overload -def async_redact_data( +def async_redact_data[_T, _ValueT]( data: _T, to_redact: Iterable[Any] | Mapping[Any, Callable[[_ValueT], _ValueT]] ) -> _T: ... @callback -def async_redact_data( +def async_redact_data[_T, _ValueT]( data: _T, to_redact: Iterable[Any] | Mapping[Any, Callable[[_ValueT], _ValueT]] ) -> _T: """Redact sensitive data in a dict.""" diff --git a/homeassistant/helpers/registry.py b/homeassistant/helpers/registry.py index 832f50661ae..21f2178554e 100644 --- a/homeassistant/helpers/registry.py +++ b/homeassistant/helpers/registry.py @@ -3,9 +3,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections import UserDict +from collections import UserDict, defaultdict from collections.abc import Mapping, Sequence, ValuesView -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Any, Literal from homeassistant.core import CoreState, HomeAssistant, callback @@ -15,12 +15,10 @@ if TYPE_CHECKING: SAVE_DELAY = 10 SAVE_DELAY_LONG = 180 - -_DataT = TypeVar("_DataT") -_StoreDataT = TypeVar("_StoreDataT", bound=Mapping[str, Any] | Sequence[Any]) +type RegistryIndexType = defaultdict[str, dict[str, Literal[True]]] -class BaseRegistryItems(UserDict[str, _DataT], ABC): +class BaseRegistryItems[_DataT](UserDict[str, _DataT], ABC): """Base class for registry items.""" data: dict[str, _DataT] @@ -46,7 +44,7 @@ class BaseRegistryItems(UserDict[str, _DataT], ABC): self._index_entry(key, entry) def _unindex_entry_value( - self, key: str, value: str, index: dict[str, dict[str, Literal[True]]] + self, key: str, value: str, index: RegistryIndexType ) -> None: """Unindex an entry value. @@ -65,7 +63,7 @@ class BaseRegistryItems(UserDict[str, _DataT], ABC): super().__delitem__(key) -class BaseRegistry(ABC, Generic[_StoreDataT]): +class BaseRegistry[_StoreDataT: Mapping[str, Any] | Sequence[Any]](ABC): """Class to implement a registry.""" hass: HomeAssistant diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 2b3afc2f57b..a2b4b3a9b9a 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -11,6 +11,7 @@ from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import json_loads from . import start @@ -18,9 +19,10 @@ from .entity import Entity from .event import async_track_time_interval from .frame import report from .json import JSONEncoder +from .singleton import singleton from .storage import Store -DATA_RESTORE_STATE = "restore_state" +DATA_RESTORE_STATE: HassKey[RestoreStateData] = HassKey("restore_state") _LOGGER = logging.getLogger(__name__) @@ -96,15 +98,14 @@ class StoredState: async def async_load(hass: HomeAssistant) -> None: """Load the restore state task.""" - restore_state = RestoreStateData(hass) - await restore_state.async_setup() - hass.data[DATA_RESTORE_STATE] = restore_state + await async_get(hass).async_setup() @callback +@singleton(DATA_RESTORE_STATE) def async_get(hass: HomeAssistant) -> RestoreStateData: """Get the restore state data helper.""" - return cast(RestoreStateData, hass.data[DATA_RESTORE_STATE]) + return RestoreStateData(hass) class RestoreStateData: @@ -280,7 +281,7 @@ class RestoreStateData: state, extra_data, dt_util.utcnow() ) - self.entities.pop(entity_id) + del self.entities[entity_id] class RestoreEntity(Entity): diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 67624bfb368..05e4a852ad9 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -356,7 +356,6 @@ class SchemaConfigFlowHandler(ConfigFlow, ABC): self: SchemaConfigFlowHandler, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a config flow step.""" - # pylint: disable-next=protected-access return await self._common_handler.async_step(step_id, user_input) return _async_step @@ -450,7 +449,6 @@ class SchemaOptionsFlowHandler(OptionsFlowWithConfigEntry): self: SchemaConfigFlowHandler, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle an options flow step.""" - # pylint: disable-next=protected-access return await self._common_handler.async_step(step_id, user_input) return _async_step diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1bbe7749ff7..84dabb114cd 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable, Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from contextlib import asynccontextmanager from contextvars import ContextVar from copy import copy @@ -13,9 +13,10 @@ from functools import cached_property, partial import itertools import logging from types import MappingProxyType -from typing import Any, Literal, TypedDict, TypeVar, cast +from typing import Any, Literal, TypedDict, cast import async_interrupt +from typing_extensions import AsyncGenerator import voluptuous as vol from homeassistant import exceptions @@ -81,13 +82,15 @@ from homeassistant.core import ( from homeassistant.util import slugify from homeassistant.util.async_ import create_eager_task from homeassistant.util.dt import utcnow +from homeassistant.util.hass_dict import HassKey from homeassistant.util.signal_type import SignalType, SignalTypeFormat from . import condition, config_validation as cv, service, template from .condition import ConditionCheckerType, trace_condition_function -from .dispatcher import async_dispatcher_connect, async_dispatcher_send +from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .event import async_call_later, async_track_template from .script_variables import ScriptVariables +from .template import Template from .trace import ( TraceElement, async_trace_path, @@ -109,8 +112,6 @@ from .typing import UNDEFINED, ConfigType, UndefinedType # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs -_T = TypeVar("_T") - SCRIPT_MODE_PARALLEL = "parallel" SCRIPT_MODE_QUEUED = "queued" SCRIPT_MODE_RESTART = "restart" @@ -133,9 +134,11 @@ DEFAULT_MAX_EXCEEDED = "WARNING" ATTR_CUR = "current" ATTR_MAX = "max" -DATA_SCRIPTS = "helpers.script" -DATA_SCRIPT_BREAKPOINTS = "helpers.script_breakpoints" -DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED = "helpers.script_not_allowed" +DATA_SCRIPTS: HassKey[list[ScriptData]] = HassKey("helpers.script") +DATA_SCRIPT_BREAKPOINTS: HassKey[dict[str, dict[str, set[str]]]] = HassKey( + "helpers.script_breakpoints" +) +DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED: HassKey[None] = HassKey("helpers.script_not_allowed") RUN_ID_ANY = "*" NODE_ANY = "*" @@ -155,7 +158,14 @@ SCRIPT_DEBUG_CONTINUE_STOP: SignalTypeFormat[Literal["continue", "stop"]] = ( ) SCRIPT_DEBUG_CONTINUE_ALL = "script_debug_continue_all" -script_stack_cv: ContextVar[list[int] | None] = ContextVar("script_stack", default=None) +script_stack_cv: ContextVar[list[str] | None] = ContextVar("script_stack", default=None) + + +class ScriptData(TypedDict): + """Store data related to script instance.""" + + instance: Script + started_before_shutdown: bool class ScriptStoppedError(Exception): @@ -181,7 +191,7 @@ async def trace_action( script_run: _ScriptRun, stop: asyncio.Future[None], variables: dict[str, Any], -) -> AsyncGenerator[TraceElement, None]: +) -> AsyncGenerator[TraceElement]: """Trace action execution.""" path = trace_path_get() trace_element = action_trace_append(variables, path) @@ -208,7 +218,9 @@ async def trace_action( ) ) ): - async_dispatcher_send(hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path) + async_dispatcher_send_internal( + hass, SCRIPT_BREAKPOINT_HIT, key, run_id, path + ) done = hass.loop.create_future() @@ -359,6 +371,11 @@ async def async_validate_action_config( hass, parallel_conf[CONF_SEQUENCE] ) + elif action_type == cv.SCRIPT_ACTION_SEQUENCE: + config[CONF_SEQUENCE] = await async_validate_actions_config( + hass, config[CONF_SEQUENCE] + ) + else: raise ValueError(f"No validation for {action_type}") @@ -412,18 +429,15 @@ class _ScriptRun: def _changed(self) -> None: if not self._stop.done(): - self._script._changed() # pylint: disable=protected-access + self._script._changed() # noqa: SLF001 async def _async_get_condition(self, config): - # pylint: disable-next=protected-access - return await self._script._async_get_condition(config) + return await self._script._async_get_condition(config) # noqa: SLF001 def _log( self, msg: str, *args: Any, level: int = logging.INFO, **kwargs: Any ) -> None: - self._script._log( # pylint: disable=protected-access - msg, *args, level=level, **kwargs - ) + self._script._log(msg, *args, level=level, **kwargs) # noqa: SLF001 def _step_log(self, default_message, timeout=None): self._script.last_action = self._action.get(CONF_ALIAS, default_message) @@ -439,7 +453,7 @@ class _ScriptRun: if (script_stack := script_stack_cv.get()) is None: script_stack = [] script_stack_cv.set(script_stack) - script_stack.append(id(self._script)) + script_stack.append(self._script.unique_id) response = None try: @@ -489,17 +503,29 @@ class _ScriptRun: action = cv.determine_script_action(self._action) - if not self._action.get(CONF_ENABLED, True): - self._log( - "Skipped disabled step %s", self._action.get(CONF_ALIAS, action) - ) - trace_set_result(enabled=False) - return + if CONF_ENABLED in self._action: + enabled = self._action[CONF_ENABLED] + if isinstance(enabled, Template): + try: + enabled = enabled.async_render(limited=True) + except exceptions.TemplateError as ex: + self._handle_exception( + ex, + continue_on_error, + self._log_exceptions or log_exceptions, + ) + if not enabled: + self._log( + "Skipped disabled step %s", + self._action.get(CONF_ALIAS, action), + ) + trace_set_result(enabled=False) + return handler = f"_async_{action}_step" try: await getattr(self, handler)() - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 self._handle_exception( ex, continue_on_error, self._log_exceptions or log_exceptions ) @@ -507,7 +533,7 @@ class _ScriptRun: trace_element.update_variables(self._variables) def _finish(self) -> None: - self._script._runs.remove(self) # pylint: disable=protected-access + self._script._runs.remove(self) # noqa: SLF001 if not self._script.is_running: self._script.last_action = None self._changed() @@ -689,7 +715,9 @@ class _ScriptRun: else: wait_var["remaining"] = None - async def _async_run_long_action(self, long_task: asyncio.Task[_T]) -> _T | None: + async def _async_run_long_action[_T]( + self, long_task: asyncio.Task[_T] + ) -> _T | None: """Run a long task while monitoring for stop request.""" try: async with async_interrupt.interrupt(self._stop, ScriptStoppedError, None): @@ -846,8 +874,7 @@ class _ScriptRun: repeat_vars["item"] = item self._variables["repeat"] = repeat_vars - # pylint: disable-next=protected-access - script = self._script._get_repeat_script(self._step) + script = self._script._get_repeat_script(self._step) # noqa: SLF001 warned_too_many_loops = False async def async_run_sequence(iteration, extra_msg=""): @@ -899,7 +926,7 @@ class _ScriptRun: count = len(items) for iteration, item in enumerate(items, 1): set_repeat_var(iteration, count, item) - extra_msg = f" of {count} with item: {repr(item)}" + extra_msg = f" of {count} with item: {item!r}" if self._stop.done(): break await async_run_sequence(iteration, extra_msg) @@ -1003,8 +1030,7 @@ class _ScriptRun: async def _async_choose_step(self) -> None: """Choose a sequence.""" - # pylint: disable-next=protected-access - choose_data = await self._script._async_get_choose_data(self._step) + choose_data = await self._script._async_get_choose_data(self._step) # noqa: SLF001 with trace_path("choose"): for idx, (conditions, script) in enumerate(choose_data["choices"]): @@ -1025,8 +1051,7 @@ class _ScriptRun: async def _async_if_step(self) -> None: """If sequence.""" - # pylint: disable-next=protected-access - if_data = await self._script._async_get_if_data(self._step) + if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001 test_conditions = False try: @@ -1185,11 +1210,16 @@ class _ScriptRun: response = None raise _StopScript(stop, response) + @async_trace_path("sequence") + async def _async_sequence_step(self) -> None: + """Run a sequence.""" + sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001 + await self._async_run_script(sequence) + @async_trace_path("parallel") async def _async_parallel_step(self) -> None: """Run a sequence in parallel.""" - # pylint: disable-next=protected-access - scripts = await self._script._async_get_parallel_scripts(self._step) + scripts = await self._script._async_get_parallel_scripts(self._step) # noqa: SLF001 async def async_run_with_trace(idx: int, script: Script) -> None: """Run a script with a trace path.""" @@ -1227,7 +1257,7 @@ class _QueuedScriptRun(_ScriptRun): # shared lock. At the same time monitor if we've been told to stop. try: async with async_interrupt.interrupt(self._stop, ScriptStoppedError, None): - await self._script._queue_lck.acquire() # pylint: disable=protected-access + await self._script._queue_lck.acquire() # noqa: SLF001 except ScriptStoppedError as ex: # If we've been told to stop, then just finish up. self._finish() @@ -1239,7 +1269,7 @@ class _QueuedScriptRun(_ScriptRun): def _finish(self) -> None: if self.lock_acquired: - self._script._queue_lck.release() # pylint: disable=protected-access + self._script._queue_lck.release() # noqa: SLF001 self.lock_acquired = False super()._finish() @@ -1291,7 +1321,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> ) -_VarsType = dict[str, Any] | MappingProxyType +type _VarsType = dict[str, Any] | MappingProxyType def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: @@ -1343,7 +1373,7 @@ class Script: domain: str, *, # Used in "Running " log message - change_listener: Callable[..., Any] | None = None, + change_listener: Callable[[], Any] | None = None, copy_variables: bool = False, log_exceptions: bool = True, logger: logging.Logger | None = None, @@ -1372,6 +1402,7 @@ class Script: self.sequence = sequence template.attach(hass, self.sequence) self.name = name + self.unique_id = f"{domain}.{name}-{id(self)}" self.domain = domain self.running_description = running_description or f"{domain} script" self._change_listener = change_listener @@ -1396,6 +1427,7 @@ class Script: self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} self._parallel_scripts: dict[int, list[Script]] = {} + self._sequence_scripts: dict[int, Script] = {} self.variables = variables self._variables_dynamic = template.is_complex(variables) if self._variables_dynamic: @@ -1408,7 +1440,7 @@ class Script: return self._change_listener @change_listener.setter - def change_listener(self, change_listener: Callable[..., Any]) -> None: + def change_listener(self, change_listener: Callable[[], Any]) -> None: """Update the change_listener.""" self._change_listener = change_listener if ( @@ -1693,10 +1725,21 @@ class Script: if ( self.script_mode in (SCRIPT_MODE_RESTART, SCRIPT_MODE_QUEUED) and script_stack is not None - and id(self) in script_stack + and self.unique_id in script_stack ): script_execution_set("disallowed_recursion_detected") - self._log("Disallowed recursion detected", level=logging.WARNING) + formatted_stack = [ + f"- {name_id.partition('-')[0]}" for name_id in script_stack + ] + self._log( + "Disallowed recursion detected, " + f"{script_stack[-1].partition('-')[0]} tried to start " + f"{self.domain}.{self.name} which is already running " + "in the current execution path; " + "Traceback (most recent call last):\n" + f"{"\n".join(formatted_stack)}", + level=logging.WARNING, + ) return None if self.script_mode != SCRIPT_MODE_QUEUED: @@ -1716,10 +1759,6 @@ class Script: # runs before sleeping as otherwise if two runs are started at the exact # same time they will cancel each other out. self._log("Restarting") - # Important: yield to the event loop to allow the script to start in case - # the script is restarting itself so it ends up in the script stack and - # the recursion check above will prevent the script from running. - await asyncio.sleep(0) await self.async_stop(update_state=False, spare=run) if started_action: @@ -1922,6 +1961,35 @@ class Script: self._parallel_scripts[step] = parallel_scripts return parallel_scripts + async def _async_prep_sequence_script(self, step: int) -> Script: + """Prepare a sequence script.""" + action = self.sequence[step] + step_name = action.get(CONF_ALIAS, f"Sequence action at step {step+1}") + + sequence_script = Script( + self._hass, + action[CONF_SEQUENCE], + f"{self.name}: {step_name}", + self.domain, + running_description=self.running_description, + script_mode=SCRIPT_MODE_PARALLEL, + max_runs=self.max_runs, + logger=self._logger, + top_level=False, + ) + sequence_script.change_listener = partial( + self._chain_change_listener, sequence_script + ) + + return sequence_script + + async def _async_get_sequence_script(self, step: int) -> Script: + """Get a (cached) sequence script.""" + if not (sequence_script := self._sequence_scripts.get(step)): + sequence_script = await self._async_prep_sequence_script(step) + self._sequence_scripts[step] = sequence_script + return sequence_script + def _log( self, msg: str, *args: Any, level: int = logging.INFO, **kwargs: Any ) -> None: @@ -1986,7 +2054,7 @@ def debug_continue(hass: HomeAssistant, key: str, run_id: str) -> None: breakpoint_clear(hass, key, run_id, NODE_ANY) signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) - async_dispatcher_send(hass, signal, "continue") + async_dispatcher_send_internal(hass, signal, "continue") @callback @@ -1996,11 +2064,11 @@ def debug_step(hass: HomeAssistant, key: str, run_id: str) -> None: breakpoint_set(hass, key, run_id, NODE_ANY) signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) - async_dispatcher_send(hass, signal, "continue") + async_dispatcher_send_internal(hass, signal, "continue") @callback def debug_stop(hass: HomeAssistant, key: str, run_id: str) -> None: """Stop execution of a running or halted script.""" signal = SCRIPT_DEBUG_CONTINUE_STOP.format(key, run_id) - async_dispatcher_send(hass, signal, "stop") + async_dispatcher_send_internal(hass, signal, "stop") diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index a45ba2d1129..1db4dd9f80b 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -3,9 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence -from enum import IntFlag, StrEnum +from enum import StrEnum from functools import cache -from typing import Any, Generic, Literal, Required, TypedDict, TypeVar, cast +import importlib +from typing import Any, Literal, Required, TypedDict, cast from uuid import UUID import voluptuous as vol @@ -20,8 +21,6 @@ from . import config_validation as cv SELECTORS: decorator.Registry[str, type[Selector]] = decorator.Registry() -_T = TypeVar("_T", bound=Mapping[str, Any]) - def _get_selector_class(config: Any) -> type[Selector]: """Get selector class type.""" @@ -61,7 +60,7 @@ def validate_selector(config: Any) -> dict: } -class Selector(Generic[_T]): +class Selector[_T: Mapping[str, Any]]: """Base class for selectors.""" CONFIG_SCHEMA: Callable @@ -82,63 +81,23 @@ class Selector(Generic[_T]): @cache -def _entity_features() -> dict[str, type[IntFlag]]: - """Return a cached lookup of entity feature enums.""" - # pylint: disable=import-outside-toplevel - from homeassistant.components.alarm_control_panel import ( - AlarmControlPanelEntityFeature, - ) - from homeassistant.components.calendar import CalendarEntityFeature - from homeassistant.components.camera import CameraEntityFeature - from homeassistant.components.climate import ClimateEntityFeature - from homeassistant.components.cover import CoverEntityFeature - from homeassistant.components.fan import FanEntityFeature - from homeassistant.components.humidifier import HumidifierEntityFeature - from homeassistant.components.lawn_mower import LawnMowerEntityFeature - from homeassistant.components.light import LightEntityFeature - from homeassistant.components.lock import LockEntityFeature - from homeassistant.components.media_player import MediaPlayerEntityFeature - from homeassistant.components.notify import NotifyEntityFeature - from homeassistant.components.remote import RemoteEntityFeature - from homeassistant.components.siren import SirenEntityFeature - 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 +def _entity_feature_flag(domain: str, enum_name: str, feature_name: str) -> int: + """Return a cached lookup of an entity feature enum. - return { - "AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature, - "CalendarEntityFeature": CalendarEntityFeature, - "CameraEntityFeature": CameraEntityFeature, - "ClimateEntityFeature": ClimateEntityFeature, - "CoverEntityFeature": CoverEntityFeature, - "FanEntityFeature": FanEntityFeature, - "HumidifierEntityFeature": HumidifierEntityFeature, - "LawnMowerEntityFeature": LawnMowerEntityFeature, - "LightEntityFeature": LightEntityFeature, - "LockEntityFeature": LockEntityFeature, - "MediaPlayerEntityFeature": MediaPlayerEntityFeature, - "NotifyEntityFeature": NotifyEntityFeature, - "RemoteEntityFeature": RemoteEntityFeature, - "SirenEntityFeature": SirenEntityFeature, - "TodoListEntityFeature": TodoListEntityFeature, - "UpdateEntityFeature": UpdateEntityFeature, - "VacuumEntityFeature": VacuumEntityFeature, - "ValveEntityFeature": ValveEntityFeature, - "WaterHeaterEntityFeature": WaterHeaterEntityFeature, - "WeatherEntityFeature": WeatherEntityFeature, - } + This will import a module from disk and is run from an executor when + loading the services schema files. + """ + module = importlib.import_module(f"homeassistant.components.{domain}") + enum = getattr(module, enum_name) + feature = getattr(enum, feature_name) + return cast(int, feature.value) def _validate_supported_feature(supported_feature: str) -> int: """Validate a supported feature and resolve an enum string to its value.""" - known_entity_features = _entity_features() - try: - _, enum, feature = supported_feature.split(".", 2) + domain, enum, feature = supported_feature.split(".", 2) except ValueError as exc: raise vol.Invalid( f"Invalid supported feature '{supported_feature}', expected " @@ -146,8 +105,8 @@ def _validate_supported_feature(supported_feature: str) -> int: ) from exc try: - return cast(int, getattr(known_entity_features[enum], feature).value) - except (AttributeError, KeyError) as exc: + return _entity_feature_flag(domain, enum, feature) + except (ModuleNotFoundError, AttributeError) as exc: raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc @@ -759,6 +718,7 @@ class DurationSelectorConfig(TypedDict, total=False): """Class to represent a duration selector config.""" enable_day: bool + allow_negative: bool @SELECTORS.register("duration") @@ -772,6 +732,8 @@ class DurationSelector(Selector[DurationSelectorConfig]): # Enable day field in frontend. A selection with `days` set is allowed # even if `enable_day` is not set vol.Optional("enable_day"): cv.boolean, + # Allow negative durations. Will default to False in HA Core 2025.6.0. + vol.Optional("allow_negative"): cv.boolean, } ) @@ -781,7 +743,10 @@ class DurationSelector(Selector[DurationSelectorConfig]): def __call__(self, data: Any) -> dict[str, float]: """Validate the passed selection.""" - cv.time_period_dict(data) + if self.config.get("allow_negative", True): + cv.time_period_dict(data) + else: + cv.positive_time_period_dict(data) return cast(dict[str, float], data) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 66c9f7db3e6..a9959902084 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,13 +3,13 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Iterable +from collections.abc import Awaitable, Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, cast import voluptuous as vol @@ -47,6 +47,7 @@ from homeassistant.exceptions import ( ) from homeassistant.loader import Integration, async_get_integrations, bind_hass from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE @@ -67,17 +68,16 @@ from .typing import ConfigType, TemplateVarsType if TYPE_CHECKING: from .entity import Entity - _EntityT = TypeVar("_EntityT", bound=Entity) - - CONF_SERVICE_ENTITY_ID = "entity_id" _LOGGER = logging.getLogger(__name__) -SERVICE_DESCRIPTION_CACHE = "service_description_cache" -ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" - -_T = TypeVar("_T") +SERVICE_DESCRIPTION_CACHE: HassKey[dict[tuple[str, str], dict[str, Any] | None]] = ( + HassKey("service_description_cache") +) +ALL_SERVICE_DESCRIPTIONS_CACHE: HassKey[ + tuple[set[tuple[str, str]], dict[str, dict[str, Any]]] +] = HassKey("all_service_descriptions_cache") @cache @@ -187,7 +187,20 @@ _SERVICE_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_SERVICES_SCHEMA = vol.Schema({cv.slug: vol.Any(None, _SERVICE_SCHEMA)}) + +def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise vol.Invalid("Key does not start with .") + return key + + +_SERVICES_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, starts_with_dot)): object, + cv.slug: vol.Any(None, _SERVICE_SCHEMA), + } +) class ServiceParams(TypedDict): @@ -429,7 +442,7 @@ def extract_entity_ids( @bind_hass -async def async_extract_entities( +async def async_extract_entities[_EntityT: Entity]( hass: HomeAssistant, entities: Iterable[_EntityT], service_call: ServiceCall, @@ -655,14 +668,20 @@ def _load_services_files( return [_load_services_file(hass, integration) for integration in integrations] +@callback +def async_get_cached_service_description( + hass: HomeAssistant, domain: str, service: str +) -> dict[str, Any] | None: + """Return the cached description for a service.""" + return hass.data.get(SERVICE_DESCRIPTION_CACHE, {}).get((domain, service)) + + @bind_hass async def async_get_all_descriptions( hass: HomeAssistant, ) -> dict[str, dict[str, Any]]: """Return descriptions (i.e. user documentation) for all service calls.""" - descriptions_cache: dict[tuple[str, str], dict[str, Any] | None] = ( - hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) - ) + descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) # We don't mutate services here so we avoid calling # async_services which makes a copy of every services @@ -671,22 +690,18 @@ async def async_get_all_descriptions( # See if there are new services not seen before. # Any service that we saw before already has an entry in description_cache. - domains_with_missing_services: set[str] = set() - all_services: set[tuple[str, str]] = set() - for domain, services_by_domain in services.items(): - for service_name in services_by_domain: - cache_key = (domain, service_name) - all_services.add(cache_key) - if cache_key not in descriptions_cache: - domains_with_missing_services.add(domain) - + all_services = { + (domain, service_name) + for domain, services_by_domain in services.items() + for service_name in services_by_domain + } # If we have a complete cache, check if it is still valid all_cache: tuple[set[tuple[str, str]], dict[str, dict[str, Any]]] | None if all_cache := hass.data.get(ALL_SERVICE_DESCRIPTIONS_CACHE): previous_all_services, previous_descriptions_cache = all_cache # If the services are the same, we can return the cache if previous_all_services == all_services: - return previous_descriptions_cache # type: ignore[no-any-return] + return previous_descriptions_cache # Files we loaded for missing descriptions loaded: dict[str, JSON_TYPE] = {} @@ -696,7 +711,9 @@ async def async_get_all_descriptions( # add the new ones to the cache without their descriptions services = {domain: service.copy() for domain, service in services.items()} - if domains_with_missing_services: + if domains_with_missing_services := { + domain for domain, _ in all_services.difference(descriptions_cache) + }: ints_or_excs = await async_get_integrations(hass, domains_with_missing_services) integrations: list[Integration] = [] for domain, int_or_exc in ints_or_excs.items(): @@ -812,9 +829,7 @@ def async_set_service_schema( domain = domain.lower() service = service.lower() - descriptions_cache: dict[tuple[str, str], dict[str, Any] | None] = ( - hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) - ) + descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) description = { "name": schema.get("name", ""), @@ -839,7 +854,7 @@ def async_set_service_schema( def _get_permissible_entity_candidates( call: ServiceCall, entities: dict[str, Entity], - entity_perms: None | (Callable[[str, str], bool]), + entity_perms: Callable[[str, str], bool] | None, target_all_entities: bool, all_referenced: set[str] | None, ) -> list[Entity]: @@ -895,7 +910,7 @@ async def entity_service_call( Calls all platforms simultaneously. """ - entity_perms: None | (Callable[[str, str], bool]) = None + entity_perms: Callable[[str, str], bool] | None = None return_response = call.return_response if call.context.user_id: @@ -1047,7 +1062,7 @@ async def _handle_entity_call( result = await task if asyncio.iscoroutine(result): - _LOGGER.error( + _LOGGER.error( # type: ignore[unreachable] ( "Service %s for %s incorrectly returns a coroutine object. Await result" " instead in service handler. Report bug to integration author" @@ -1155,7 +1170,7 @@ def verify_domain_control( return decorator -class ReloadServiceHelper: +class ReloadServiceHelper[_T]: """Helper for reload services. The helper has the following purposes: @@ -1165,7 +1180,7 @@ class ReloadServiceHelper: def __init__( self, - service_func: Callable[[ServiceCall], Awaitable], + service_func: Callable[[ServiceCall], Coroutine[Any, Any, Any]], reload_targets_func: Callable[[ServiceCall], set[_T]], ) -> None: """Initialize ReloadServiceHelper.""" diff --git a/homeassistant/helpers/service_info/mqtt.py b/homeassistant/helpers/service_info/mqtt.py index b683745e1c0..6ffc981ced1 100644 --- a/homeassistant/helpers/service_info/mqtt.py +++ b/homeassistant/helpers/service_info/mqtt.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from homeassistant.data_entry_flow import BaseServiceInfo -ReceivePayloadType = str | bytes +type ReceivePayloadType = str | bytes @dataclass(slots=True) diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index baaa36e83ce..4a4b9bead47 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -7,9 +7,12 @@ import signal from homeassistant.const import RESTART_EXIT_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) +KEY_HA_STOP: HassKey[asyncio.Task[None]] = HassKey("homeassistant_stop") + @callback @bind_hass @@ -25,9 +28,7 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: """ hass.loop.remove_signal_handler(signal.SIGTERM) hass.loop.remove_signal_handler(signal.SIGINT) - hass.data["homeassistant_stop"] = asyncio.create_task( - hass.async_stop(exit_code) - ) + hass.data[KEY_HA_STOP] = asyncio.create_task(hass.async_stop(exit_code)) try: hass.loop.add_signal_handler(signal.SIGTERM, async_signal_handle, 0) diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 1b1f1b5c617..893ca7a3586 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -29,18 +29,19 @@ The following cases will never be passed to your function: from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from types import MappingProxyType -from typing import Any +from typing import Any, Protocol from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State, callback +from homeassistant.util.hass_dict import HassKey from .integration_platform import async_process_integration_platforms PLATFORM = "significant_change" -DATA_FUNCTIONS = "significant_change" -CheckTypeFunc = Callable[ +DATA_FUNCTIONS: HassKey[dict[str, CheckTypeFunc]] = HassKey("significant_change") +type CheckTypeFunc = Callable[ [ HomeAssistant, str, @@ -51,7 +52,7 @@ CheckTypeFunc = Callable[ bool | None, ] -ExtraCheckTypeFunc = Callable[ +type ExtraCheckTypeFunc = Callable[ [ HomeAssistant, str, @@ -65,6 +66,20 @@ ExtraCheckTypeFunc = Callable[ ] +class SignificantChangeProtocol(Protocol): + """Define the format of significant_change platforms.""" + + def async_check_significant_change( + self, + hass: HomeAssistant, + old_state: str, + old_attrs: Mapping[str, Any], + new_state: str, + new_attrs: Mapping[str, Any], + ) -> bool | None: + """Test if state significantly changed.""" + + async def create_checker( hass: HomeAssistant, _domain: str, @@ -85,7 +100,9 @@ async def _initialize(hass: HomeAssistant) -> None: @callback def process_platform( - hass: HomeAssistant, component_name: str, platform: Any + hass: HomeAssistant, + component_name: str, + platform: SignificantChangeProtocol, ) -> None: """Process a significant change platform.""" functions[component_name] = platform.async_check_significant_change @@ -206,7 +223,7 @@ class SignificantlyChangedChecker: self.last_approved_entities[new_state.entity_id] = (new_state, extra_arg) return True - functions: dict[str, CheckTypeFunc] | None = self.hass.data.get(DATA_FUNCTIONS) + functions = self.hass.data.get(DATA_FUNCTIONS) if functions is None: raise RuntimeError("Significant Change not initialized") diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index bf9b6019164..20e4ee82162 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -5,17 +5,26 @@ from __future__ import annotations import asyncio from collections.abc import Callable import functools -from typing import Any, TypeVar, cast +from typing import Any, cast, overload from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey -_T = TypeVar("_T") - -_FuncType = Callable[[HomeAssistant], _T] +type _FuncType[_T] = Callable[[HomeAssistant], _T] -def singleton(data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: +@overload +def singleton[_T]( + data_key: HassKey[_T], +) -> Callable[[_FuncType[_T]], _FuncType[_T]]: ... + + +@overload +def singleton[_T](data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: ... + + +def singleton[_T](data_key: Any) -> Callable[[_FuncType[_T]], _FuncType[_T]]: """Decorate a function that should be called once per instance. Result will be cached and simultaneous calls will be handled. diff --git a/homeassistant/helpers/start.py b/homeassistant/helpers/start.py index 70664430582..099060e49ca 100644 --- a/homeassistant/helpers/start.py +++ b/homeassistant/helpers/start.py @@ -36,7 +36,7 @@ def _async_at_core_state( hass.async_run_hass_job(at_start_job, hass) return lambda: None - unsub: None | CALLBACK_TYPE = None + unsub: CALLBACK_TYPE | None = None @callback def _matched_event(event: Event) -> None: diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 8c907dfa54a..7e3c12cfc01 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -12,7 +12,7 @@ from json import JSONDecodeError, JSONEncoder import logging import os from pathlib import Path -from typing import Any, Generic, TypeVar +from typing import Any from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, @@ -32,6 +32,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import json as json_util import homeassistant.util.dt as dt_util from homeassistant.util.file import WriteError +from homeassistant.util.hass_dict import HassKey from . import json as json_helper @@ -42,16 +43,14 @@ MAX_LOAD_CONCURRENTLY = 6 STORAGE_DIR = ".storage" _LOGGER = logging.getLogger(__name__) -STORAGE_SEMAPHORE = "storage_semaphore" -STORAGE_MANAGER = "storage_manager" +STORAGE_SEMAPHORE: HassKey[asyncio.Semaphore] = HassKey("storage_semaphore") +STORAGE_MANAGER: HassKey[_StoreManager] = HassKey("storage_manager") MANAGER_CLEANUP_DELAY = 60 -_T = TypeVar("_T", bound=Mapping[str, Any] | Sequence[Any]) - @bind_hass -async def async_migrator( +async def async_migrator[_T: Mapping[str, Any] | Sequence[Any]]( hass: HomeAssistant, old_path: str, store: Store[_T], @@ -218,7 +217,7 @@ class _StoreManager: try: if storage_file.is_file(): data_preload[key] = json_util.load_json(storage_file) - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 _LOGGER.debug("Error loading %s: %s", key, ex) def _initialize_files(self) -> None: @@ -228,7 +227,7 @@ class _StoreManager: @bind_hass -class Store(Generic[_T]): +class Store[_T: Mapping[str, Any] | Sequence[Any]]: """Class to help storing data.""" def __init__( @@ -253,7 +252,7 @@ class Store(Generic[_T]): self._delay_handle: asyncio.TimerHandle | None = None self._unsub_final_write_listener: CALLBACK_TYPE | None = None self._write_lock = asyncio.Lock() - self._load_task: asyncio.Future[_T | None] | None = None + self._load_future: asyncio.Future[_T | None] | None = None self._encoder = encoder self._atomic_writes = atomic_writes self._read_only = read_only @@ -265,6 +264,13 @@ class Store(Generic[_T]): """Return the config path.""" return self.hass.config.path(STORAGE_DIR, self.key) + def make_read_only(self) -> None: + """Make the store read-only. + + This method is irreversible. + """ + self._read_only = True + async def async_load(self) -> _T | None: """Load data. @@ -275,27 +281,32 @@ class Store(Generic[_T]): Will ensure that when a call comes in while another one is in progress, the second call will wait and return the result of the first call. """ - if self._load_task: - return await self._load_task + if self._load_future: + return await self._load_future - load_task = self.hass.async_create_background_task( - self._async_load(), f"Storage load {self.key}", eager_start=True - ) - if not load_task.done(): - # Only set the load task if it didn't complete immediately - self._load_task = load_task - return await load_task + self._load_future = self.hass.loop.create_future() + try: + result = await self._async_load() + except BaseException as ex: + self._load_future.set_exception(ex) + # Ensure the future is marked as retrieved + # since if there is no concurrent call it + # will otherwise never be retrieved. + self._load_future.exception() + raise + else: + self._load_future.set_result(result) + finally: + self._load_future = None + + return result async def _async_load(self) -> _T | None: """Load the data and ensure the task is removed.""" if STORAGE_SEMAPHORE not in self.hass.data: self.hass.data[STORAGE_SEMAPHORE] = asyncio.Semaphore(MAX_LOAD_CONCURRENTLY) - - try: - async with self.hass.data[STORAGE_SEMAPHORE]: - return await self._async_load_data() - finally: - self._load_task = None + async with self.hass.data[STORAGE_SEMAPHORE]: + return await self._async_load_data() async def _async_load_data(self): """Load the data.""" diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index a490a7a8213..8f5e2418b14 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -10,16 +10,19 @@ from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: import astral import astral.location -DATA_LOCATION_CACHE = "astral_location_cache" +DATA_LOCATION_CACHE: HassKey[ + dict[tuple[str, str, str, float, float], astral.location.Location] +] = HassKey("astral_location_cache") ELEVATION_AGNOSTIC_EVENTS = ("noon", "midnight") -_AstralSunEventCallable = Callable[..., datetime.datetime] +type _AstralSunEventCallable = Callable[..., datetime.datetime] @callback diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index ec8badaddc3..69e03904caa 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -15,9 +15,12 @@ from homeassistant.loader import bind_hass from homeassistant.util.package import is_docker_env, is_virtual_env from .importlib import async_import_module +from .singleton import singleton _LOGGER = logging.getLogger(__name__) +_DATA_MAC_VER = "system_info_mac_ver" + @cache def is_official_image() -> bool: @@ -25,6 +28,12 @@ def is_official_image() -> bool: return os.path.isfile("/OFFICIAL_IMAGE") +@singleton(_DATA_MAC_VER) +async def async_get_mac_ver(hass: HomeAssistant) -> str: + """Return the macOS version.""" + return (await hass.async_add_executor_job(platform.mac_ver))[0] + + # Cache the result of getuser() because it can call getpwuid() which # can do blocking I/O to look up the username in /etc/passwd. cached_get_user = cache(getuser) @@ -65,7 +74,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: info_object["user"] = None if platform.system() == "Darwin": - info_object["os_version"] = platform.mac_ver()[0] + info_object["os_version"] = await async_get_mac_ver(hass) elif platform.system() == "Linux": info_object["docker"] = is_docker_env() diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index c12494ba71b..714a57336bd 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,8 +6,8 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Generator, Iterable -from contextlib import AbstractContextManager, suppress +from collections.abc import Callable, Iterable +from contextlib import AbstractContextManager from contextvars import ContextVar from datetime import date, datetime, time, timedelta from functools import cache, lru_cache, partial, wraps @@ -22,17 +22,7 @@ import statistics from struct import error as StructError, pack, unpack_from import sys from types import CodeType, TracebackType -from typing import ( - Any, - Concatenate, - Literal, - NoReturn, - ParamSpec, - Self, - TypeVar, - cast, - overload, -) +from typing import Any, Concatenate, Literal, NoReturn, Self, cast, overload from urllib.parse import urlencode as urllib_urlencode import weakref @@ -44,6 +34,7 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace from lru import LRU import orjson +from typing_extensions import Generator import voluptuous as vol from homeassistant.const import ( @@ -76,6 +67,7 @@ from homeassistant.util import ( slugify as slugify_util, ) from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException @@ -99,12 +91,15 @@ _LOGGER = logging.getLogger(__name__) _SENTINEL = object() DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" -_ENVIRONMENT = "template.environment" -_ENVIRONMENT_LIMITED = "template.environment_limited" -_ENVIRONMENT_STRICT = "template.environment_strict" +_ENVIRONMENT: HassKey[TemplateEnvironment] = HassKey("template.environment") +_ENVIRONMENT_LIMITED: HassKey[TemplateEnvironment] = HassKey( + "template.environment_limited" +) +_ENVIRONMENT_STRICT: HassKey[TemplateEnvironment] = HassKey( + "template.environment_strict" +) _HASS_LOADER = "template.hass_loader" -_RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 _IS_NUMERIC = re.compile(r"^[+-]?(?!0\d)\d*(?:\.\d*)?$") @@ -115,9 +110,6 @@ _RESERVED_NAMES = { "jinja_pass_arg", } -_GROUP_DOMAIN_PREFIX = "group." -_ZONE_DOMAIN_PREFIX = "zone." - _COLLECTABLE_STATE_ATTRIBUTES = { "state", "attributes", @@ -129,10 +121,6 @@ _COLLECTABLE_STATE_ATTRIBUTES = { "name", } -_T = TypeVar("_T") -_R = TypeVar("_R") -_P = ParamSpec("_P") - ALL_STATES_RATE_LIMIT = 60 # seconds DOMAIN_STATES_RATE_LIMIT = 1 # seconds @@ -270,7 +258,9 @@ def is_complex(value: Any) -> bool: def is_template_string(maybe_template: str) -> bool: """Check if the input is a Jinja2 template.""" - return _RE_JINJA_DELIMITERS.search(maybe_template) is not None + return "{" in maybe_template and ( + "{%" in maybe_template or "{{" in maybe_template or "{#" in maybe_template + ) class ResultWrapper: @@ -338,7 +328,33 @@ def _false(arg: str) -> bool: return False -_cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval) +@lru_cache(maxsize=EVAL_CACHE_SIZE) +def _cached_parse_result(render_result: str) -> Any: + """Parse a result and cache the result.""" + result = literal_eval(render_result) + if type(result) in RESULT_WRAPPERS: + result = RESULT_WRAPPERS[type(result)](result, render_result=render_result) + + # If the literal_eval result is a string, use the original + # render, by not returning right here. The evaluation of strings + # resulting in strings impacts quotes, to avoid unexpected + # output; use the original render instead of the evaluated one. + # Complex and scientific values are also unexpected. Filter them out. + if ( + # Filter out string and complex numbers + not isinstance(result, (str, complex)) + and ( + # Pass if not numeric and not a boolean + not isinstance(result, (int, float)) + # Or it's a boolean (inherit from int) + or isinstance(result, bool) + # Or if it's a digit + or _IS_NUMERIC.match(render_result) is not None + ) + ): + return result + + return render_result class RenderInfo: @@ -511,8 +527,7 @@ class Template: wanted_env = _ENVIRONMENT_STRICT else: wanted_env = _ENVIRONMENT - ret: TemplateEnvironment | None = self.hass.data.get(wanted_env) - if ret is None: + if (ret := self.hass.data.get(wanted_env)) is None: ret = self.hass.data[wanted_env] = TemplateEnvironment( self.hass, self._limited, self._strict, self._log_fn ) @@ -520,11 +535,15 @@ class Template: def ensure_valid(self) -> None: """Return if template is valid.""" + if self.is_static or self._compiled_code is not None: + return + + if compiled := self._env.template_cache.get(self.template): + self._compiled_code = compiled + return + with _template_context_manager as cm: cm.set_template(self.template, "compiling") - if self.is_static or self._compiled_code is not None: - return - try: self._compiled_code = self._env.compile(self.template) except jinja2.TemplateError as err: @@ -596,31 +615,7 @@ class Template: def _parse_result(self, render_result: str) -> Any: """Parse the result.""" try: - result = _cached_literal_eval(render_result) - - if type(result) in RESULT_WRAPPERS: - result = RESULT_WRAPPERS[type(result)]( - result, render_result=render_result - ) - - # If the literal_eval result is a string, use the original - # render, by not returning right here. The evaluation of strings - # resulting in strings impacts quotes, to avoid unexpected - # output; use the original render instead of the evaluated one. - # Complex and scientific values are also unexpected. Filter them out. - if ( - # Filter out string and complex numbers - not isinstance(result, (str, complex)) - and ( - # Pass if not numeric and not a boolean - not isinstance(result, (int, float)) - # Or it's a boolean (inherit from int) - or isinstance(result, bool) - # Or if it's a digit - or _IS_NUMERIC.match(render_result) is not None - ) - ): - return result + return _cached_parse_result(render_result) except (ValueError, TypeError, SyntaxError, MemoryError): pass @@ -666,7 +661,7 @@ class Template: _render_with_context(self.template, compiled, **kwargs) except TimeoutError: pass - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 self._exc_info = sys.exc_info() finally: self.hass.loop.call_soon_threadsafe(finish_event.set) @@ -698,19 +693,27 @@ class Template: if self.hass and self.hass.config.debug: self.hass.verify_event_loop_thread("async_render_to_info") self._renders += 1 - assert self.hass and _render_info.get() is None render_info = RenderInfo(self) - # pylint: disable=protected-access + if not self.hass: + raise RuntimeError(f"hass not set while rendering {self}") + + if _render_info.get() is not None: + raise RuntimeError( + f"RenderInfo already set while rendering {self}, " + "this usually indicates the template is being rendered " + "in the wrong thread" + ) + if self.is_static: - render_info._result = self.template.strip() - render_info._freeze_static() + render_info._result = self.template.strip() # noqa: SLF001 + render_info._freeze_static() # noqa: SLF001 return render_info token = _render_info.set(render_info) try: - render_info._result = self.async_render( + render_info._result = self.async_render( # noqa: SLF001 variables, strict=strict, log_fn=log_fn, **kwargs ) except TemplateError as ex: @@ -718,7 +721,7 @@ class Template: finally: _render_info.reset(token) - render_info._freeze() + render_info._freeze() # noqa: SLF001 return render_info def render_with_possible_json_value(self, value, error_value=_SENTINEL): @@ -760,8 +763,10 @@ class Template: variables = dict(variables or {}) variables["value"] = value - with suppress(*JSON_DECODE_EXCEPTIONS): + try: # noqa: SIM105 - suppress is much slower variables["value_json"] = json_loads(value) + except JSON_DECODE_EXCEPTIONS: + pass try: render_result = _render_with_context( @@ -878,7 +883,7 @@ class AllStates: if (render_info := _render_info.get()) is not None: render_info.all_states_lifecycle = True - def __iter__(self) -> Generator[TemplateState, None, None]: + def __iter__(self) -> Generator[TemplateState]: """Return all states.""" self._collect_all() return _state_generator(self._hass, None) @@ -968,7 +973,7 @@ class DomainStates: if (entity_collect := _render_info.get()) is not None: entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined] - def __iter__(self) -> Generator[TemplateState, None, None]: + def __iter__(self) -> Generator[TemplateState]: """Return the iteration over all the states.""" self._collect_domain() return _state_generator(self._hass, self._domain) @@ -1156,7 +1161,7 @@ def _collect_state(hass: HomeAssistant, entity_id: str) -> None: def _state_generator( hass: HomeAssistant, domain: str | None -) -> Generator[TemplateState, None, None]: +) -> Generator[TemplateState]: """State generator for a domain or all states.""" states = hass.states # If domain is None, we want to iterate over all states, but making @@ -1169,7 +1174,7 @@ def _state_generator( # container: Iterable[State] if domain is None: - container = states._states.values() # pylint: disable=protected-access + container = states._states.values() # noqa: SLF001 else: container = states.async_all(domain) for state in container: @@ -1214,10 +1219,10 @@ def forgiving_boolean(value: Any) -> bool | object: ... @overload -def forgiving_boolean(value: Any, default: _T) -> bool | _T: ... +def forgiving_boolean[_T](value: Any, default: _T) -> bool | _T: ... -def forgiving_boolean( +def forgiving_boolean[_T]( value: Any, default: _T | object = _SENTINEL ) -> bool | _T | object: """Try to convert value to a boolean.""" @@ -1885,6 +1890,17 @@ def multiply(value, amount, default=_SENTINEL): return default +def add(value, amount, default=_SENTINEL): + """Filter to convert value to float and add it.""" + try: + return float(value) + amount + except (ValueError, TypeError): + # If value can't be converted to float + if default is _SENTINEL: + raise_no_default("add", value) + return default + + def logarithm(value, base=math.e, default=_SENTINEL): """Filter and function to get logarithm of the value with a specific base.""" try: @@ -2381,20 +2397,25 @@ def struct_unpack(value: bytes, format_string: str, offset: int = 0) -> Any | No return None -def base64_encode(value): +def base64_encode(value: str) -> str: """Perform base64 encode.""" return base64.b64encode(value.encode("utf-8")).decode("utf-8") -def base64_decode(value): - """Perform base64 denode.""" - return base64.b64decode(value).decode("utf-8") +def base64_decode(value: str, encoding: str | None = "utf-8") -> str | bytes: + """Perform base64 decode.""" + decoded = base64.b64decode(value) + if encoding: + return decoded.decode(encoding) + + return decoded def ordinal(value): """Perform ordinal conversion.""" + suffixes = ["th", "st", "nd", "rd"] + ["th"] * 6 # codespell:ignore nd return str(value) + ( - list(["th", "st", "nd", "rd"] + ["th"] * 6)[(int(str(value)[-1])) % 10] + suffixes[(int(str(value)[-1])) % 10] if int(str(value)[-2:]) % 100 not in range(11, 14) else "th" ) @@ -2720,11 +2741,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): super().__init__(undefined=make_logging_undefined(strict, log_fn)) self.hass = hass self.template_cache: weakref.WeakValueDictionary[ - str | jinja2.nodes.Template, CodeType | str | None + str | jinja2.nodes.Template, CodeType | None ] = weakref.WeakValueDictionary() self.add_extension("jinja2.ext.loopcontrols") self.filters["round"] = forgiving_round self.filters["multiply"] = multiply + self.filters["add"] = add self.filters["log"] = logarithm self.filters["sin"] = sine self.filters["cos"] = cosine @@ -2825,7 +2847,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): # evaluated fresh with every execution, rather than executed # at compile time and the value stored. The context itself # can be discarded, we only need to get at the hass object. - def hassfunction( + def hassfunction[**_P, _R]( func: Callable[Concatenate[HomeAssistant, _P], _R], jinja_context: Callable[ [Callable[Concatenate[Any, _P], _R]], @@ -3023,7 +3045,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return super().is_safe_attribute(obj, attr, value) @overload - def compile( # type: ignore[overload-overlap] + def compile( self, source: str | jinja2.nodes.Template, name: str | None = None, @@ -3068,10 +3090,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): defer_init, ) - if (cached := self.template_cache.get(source)) is None: - cached = self.template_cache[source] = super().compile(source) - - return cached + compiled = super().compile(source) + self.template_cache[source] = compiled + return compiled _NO_HASS_ENV = TemplateEnvironment(None) diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 1f5aa47f4e2..6f29ff23bec 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -3,20 +3,19 @@ from __future__ import annotations from collections import deque -from collections.abc import Callable, Coroutine, Generator +from collections.abc import Callable, Coroutine from contextlib import contextmanager from contextvars import ContextVar from functools import wraps -from typing import Any, TypeVar, TypeVarTuple +from typing import Any + +from typing_extensions import Generator from homeassistant.core import ServiceResponse import homeassistant.util.dt as dt_util from .typing import TemplateVarsType -_T = TypeVar("_T") -_Ts = TypeVarTuple("_Ts") - class TraceElement: """Container for trace data.""" @@ -135,7 +134,9 @@ def trace_id_get() -> tuple[str, str] | None: return trace_id_cv.get() -def trace_stack_push(trace_stack_var: ContextVar[list[_T] | None], node: _T) -> None: +def trace_stack_push[_T]( + trace_stack_var: ContextVar[list[_T] | None], node: _T +) -> None: """Push an element to the top of a trace stack.""" trace_stack: list[_T] | None if (trace_stack := trace_stack_var.get()) is None: @@ -151,7 +152,7 @@ def trace_stack_pop(trace_stack_var: ContextVar[list[Any] | None]) -> None: trace_stack.pop() -def trace_stack_top(trace_stack_var: ContextVar[list[_T] | None]) -> _T | None: +def trace_stack_top[_T](trace_stack_var: ContextVar[list[_T] | None]) -> _T | None: """Return the element at the top of a trace stack.""" trace_stack = trace_stack_var.get() return trace_stack[-1] if trace_stack else None @@ -249,7 +250,7 @@ def script_execution_get() -> str | None: @contextmanager -def trace_path(suffix: str | list[str]) -> Generator[None, None, None]: +def trace_path(suffix: str | list[str]) -> Generator[None]: """Go deeper in the config tree. Can not be used as a decorator on couroutine functions. @@ -261,7 +262,7 @@ def trace_path(suffix: str | list[str]) -> Generator[None, None, None]: trace_path_pop(count) -def async_trace_path( +def async_trace_path[*_Ts]( suffix: str | list[str], ) -> Callable[ [Callable[[*_Ts], Coroutine[Any, Any, None]]], diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 182747ec415..01c47aa8d0d 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable, Mapping from contextlib import suppress +from dataclasses import dataclass import logging import pathlib import string @@ -45,17 +46,6 @@ def recursive_flatten( return output -@callback -def component_translation_path(language: str, integration: Integration) -> pathlib.Path: - """Return the translation json file location for a component. - - For component: - - components/hue/translations/nl.json - - """ - return integration.file_path / "translations" / f"{language}.json" - - def _load_translations_files_by_language( translation_files: dict[str, dict[str, pathlib.Path]], ) -> dict[str, dict[str, Any]]: @@ -109,8 +99,9 @@ async def _async_get_component_strings( loaded_translations_by_language: dict[str, dict[str, Any]] = {} has_files_to_load = False for language in languages: + file_name = f"{language}.json" files_to_load: dict[str, pathlib.Path] = { - domain: component_translation_path(language, integration) + domain: integration.file_path / "translations" / file_name for domain in components if ( (integration := integrations.get(domain)) @@ -140,22 +131,34 @@ async def _async_get_component_strings( return translations_by_language +@dataclass(slots=True) +class _TranslationsCacheData: + """Data for the translation cache. + + This class contains data that is designed to be shared + between multiple instances of the translation cache so + we only have to load the data once. + """ + + loaded: dict[str, set[str]] + cache: dict[str, dict[str, dict[str, dict[str, str]]]] + + class _TranslationCache: """Cache for flattened translations.""" - __slots__ = ("hass", "loaded", "cache", "lock") + __slots__ = ("hass", "cache_data", "lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self.hass = hass - self.loaded: dict[str, set[str]] = {} - self.cache: dict[str, dict[str, dict[str, dict[str, str]]]] = {} + self.cache_data = _TranslationsCacheData({}, {}) self.lock = asyncio.Lock() @callback def async_is_loaded(self, language: str, components: set[str]) -> bool: """Return if the given components are loaded for the language.""" - return components.issubset(self.loaded.get(language, set())) + return components.issubset(self.cache_data.loaded.get(language, set())) async def async_load( self, @@ -163,7 +166,7 @@ class _TranslationCache: components: set[str], ) -> None: """Load resources into the cache.""" - loaded = self.loaded.setdefault(language, set()) + loaded = self.cache_data.loaded.setdefault(language, set()) if components_to_load := components - loaded: # Translations are never unloaded so if there are no components to load # we can skip the lock which reduces contention when multiple different @@ -193,7 +196,7 @@ class _TranslationCache: components: set[str], ) -> dict[str, str]: """Read resources from the cache.""" - category_cache = self.cache.get(language, {}).get(category, {}) + category_cache = self.cache_data.cache.get(language, {}).get(category, {}) # If only one component was requested, return it directly # to avoid merging the dictionaries and keeping additional # copies of the same data in memory. @@ -207,6 +210,7 @@ class _TranslationCache: async def _async_load(self, language: str, components: set[str]) -> None: """Populate the cache for a given set of components.""" + loaded = self.cache_data.loaded _LOGGER.debug( "Cache miss for %s: %s", language, @@ -240,7 +244,7 @@ class _TranslationCache: language, components, translation_by_language_strings[language] ) - loaded_english_components = self.loaded.setdefault(LOCALE_EN, set()) + loaded_english_components = loaded.setdefault(LOCALE_EN, set()) # Since we just loaded english anyway we can avoid loading # again if they switch back to english. if loaded_english_components.isdisjoint(components): @@ -249,7 +253,7 @@ class _TranslationCache: ) loaded_english_components.update(components) - self.loaded[language].update(components) + loaded[language].update(components) def _validate_placeholders( self, @@ -304,7 +308,7 @@ class _TranslationCache: ) -> None: """Extract resources into the cache.""" resource: dict[str, Any] | str - cached = self.cache.setdefault(language, {}) + cached = self.cache_data.cache.setdefault(language, {}) categories = { category for component in translation_strings.values() diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index cb14102cb04..a0abbaa390c 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -27,10 +27,12 @@ from homeassistant.core import ( callback, is_callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey +from .template import Template from .typing import ConfigType, TemplateVarsType _PLATFORM_ALIASES = { @@ -42,7 +44,9 @@ _PLATFORM_ALIASES = { "time": "homeassistant", } -DATA_PLUGGABLE_ACTIONS = "pluggable_actions" +DATA_PLUGGABLE_ACTIONS: HassKey[defaultdict[tuple, PluggableActionsEntry]] = HassKey( + "pluggable_actions" +) class TriggerProtocol(Protocol): @@ -138,9 +142,8 @@ class PluggableAction: def async_get_registry(hass: HomeAssistant) -> dict[tuple, PluggableActionsEntry]: """Return the pluggable actions registry.""" if data := hass.data.get(DATA_PLUGGABLE_ACTIONS): - return data # type: ignore[no-any-return] - data = defaultdict(PluggableActionsEntry) - hass.data[DATA_PLUGGABLE_ACTIONS] = data + return data + data = hass.data[DATA_PLUGGABLE_ACTIONS] = defaultdict(PluggableActionsEntry) return data @staticmethod @@ -310,8 +313,16 @@ async def async_initialize_triggers( triggers: list[asyncio.Task[CALLBACK_TYPE]] = [] for idx, conf in enumerate(trigger_config): # Skip triggers that are not enabled - if not conf.get(CONF_ENABLED, True): - continue + if CONF_ENABLED in conf: + enabled = conf[CONF_ENABLED] + if isinstance(enabled, Template): + try: + enabled = enabled.async_render(variables, limited=True) + except TemplateError as err: + log_cb(logging.ERROR, f"Error rendering enabled template: {err}") + continue + if not enabled: + continue platform = await _async_get_trigger_platform(hass, conf) trigger_id = conf.get(CONF_ID, f"{idx}") diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index cf97e92d6be..3cdd9ec9250 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -5,25 +5,23 @@ from enum import Enum from functools import partial from typing import Any, Never -import homeassistant.core - from .deprecation import ( - DeprecatedAlias, + DeferredDeprecatedAlias, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, ) -GPSType = tuple[float, float] -ConfigType = dict[str, Any] -DiscoveryInfoType = dict[str, Any] -ServiceDataType = dict[str, Any] -StateType = str | int | float | None -TemplateVarsType = Mapping[str, Any] | None -NoEventData = Mapping[str, Never] +type GPSType = tuple[float, float] +type ConfigType = dict[str, Any] +type DiscoveryInfoType = dict[str, Any] +type ServiceDataType = dict[str, Any] +type StateType = str | int | float | None +type TemplateVarsType = Mapping[str, Any] | None +type NoEventData = Mapping[str, Never] # Custom type for recorder Queries -QueryType = Any +type QueryType = Any class UndefinedType(Enum): @@ -32,7 +30,19 @@ class UndefinedType(Enum): _singleton = 0 -UNDEFINED = UndefinedType._singleton # pylint: disable=protected-access +UNDEFINED = UndefinedType._singleton # noqa: SLF001 + + +def _deprecated_typing_helper(attr: str) -> DeferredDeprecatedAlias: + """Help to make a DeferredDeprecatedAlias.""" + + def value_fn() -> Any: + # pylint: disable-next=import-outside-toplevel + import homeassistant.core + + return getattr(homeassistant.core, attr) + + return DeferredDeprecatedAlias(value_fn, f"homeassistant.core.{attr}", "2025.5") # The following types should not used and @@ -40,18 +50,10 @@ UNDEFINED = UndefinedType._singleton # pylint: disable=protected-access # They are kept in order not to break custom integrations # that may rely on them. # Deprecated as of 2024.5 use types from homeassistant.core instead. -_DEPRECATED_ContextType = DeprecatedAlias( - homeassistant.core.Context, "homeassistant.core.Context", "2025.5" -) -_DEPRECATED_EventType = DeprecatedAlias( - homeassistant.core.Event, "homeassistant.core.Event", "2025.5" -) -_DEPRECATED_HomeAssistantType = DeprecatedAlias( - homeassistant.core.HomeAssistant, "homeassistant.core.HomeAssistant", "2025.5" -) -_DEPRECATED_ServiceCallType = DeprecatedAlias( - homeassistant.core.ServiceCall, "homeassistant.core.ServiceCall", "2025.5" -) +_DEPRECATED_ContextType = _deprecated_typing_helper("Context") +_DEPRECATED_EventType = _deprecated_typing_helper("Event") +_DEPRECATED_HomeAssistantType = _deprecated_typing_helper("HomeAssistant") +_DEPRECATED_ServiceCallType = _deprecated_typing_helper("ServiceCall") # These can be removed if no deprecated constant are in this module anymore __getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 17a690dfc37..8451c69d2b3 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import abstractmethod import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Generator +from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime, timedelta import logging from random import randint @@ -14,7 +14,7 @@ import urllib.error import aiohttp import requests -from typing_extensions import TypeVar +from typing_extensions import Generator, TypeVar from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -33,9 +33,6 @@ REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True _DataT = TypeVar("_DataT", default=dict[str, Any]) -_BaseDataUpdateCoordinatorT = TypeVar( - "_BaseDataUpdateCoordinatorT", bound="BaseDataUpdateCoordinatorProtocol" -) _DataUpdateCoordinatorT = TypeVar( "_DataUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]", @@ -180,7 +177,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self._async_unsub_refresh() self._debounced_refresh.async_cancel() - def async_contexts(self) -> Generator[Any, None, None]: + def async_contexts(self) -> Generator[Any]: """Return all registered contexts.""" yield from ( context for _, context in self._listeners.values() if context is not None @@ -380,7 +377,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.last_exception = err raise - except Exception as err: # pylint: disable=broad-except + except Exception as err: self.last_exception = err self.last_update_success = False self.logger.exception("Unexpected error fetching %s data", self.name) @@ -462,7 +459,9 @@ class TimestampDataUpdateCoordinator(DataUpdateCoordinator[_DataT]): self.last_update_success_time = utcnow() -class BaseCoordinatorEntity(entity.Entity, Generic[_BaseDataUpdateCoordinatorT]): +class BaseCoordinatorEntity[ + _BaseDataUpdateCoordinatorT: BaseDataUpdateCoordinatorProtocol +](entity.Entity): """Base class for all Coordinator entities.""" def __init__( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 89c3442be6a..9afad610420 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -19,7 +19,7 @@ import pathlib import sys import time from types import ModuleType -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast from awesomeversion import ( AwesomeVersion, @@ -39,6 +39,9 @@ from .generated.mqtt import MQTT from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF +from .helpers.json import json_bytes, json_fragment +from .helpers.typing import UNDEFINED +from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads if TYPE_CHECKING: @@ -48,8 +51,6 @@ if TYPE_CHECKING: from .helpers import device_registry as dr from .helpers.typing import ConfigType -_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) - _LOGGER = logging.getLogger(__name__) # @@ -96,13 +97,24 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = { "dreame_vacuum": BlockedIntegration( AwesomeVersion("1.0.4"), "crashes Home Assistant" ), + # Added in 2024.5.5 because of + # https://github.com/sh00t2kill/dolphin-robot/issues/185 + "mydolphin_plus": BlockedIntegration( + AwesomeVersion("1.0.13"), "crashes Home Assistant" + ), } -DATA_COMPONENTS = "components" -DATA_INTEGRATIONS = "integrations" -DATA_MISSING_PLATFORMS = "missing_platforms" -DATA_CUSTOM_COMPONENTS = "custom_components" -DATA_PRELOAD_PLATFORMS = "preload_platforms" +DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( + "components" +) +DATA_INTEGRATIONS: HassKey[dict[str, Integration | asyncio.Future[None]]] = HassKey( + "integrations" +) +DATA_MISSING_PLATFORMS: HassKey[dict[str, bool]] = HassKey("missing_platforms") +DATA_CUSTOM_COMPONENTS: HassKey[ + dict[str, Integration] | asyncio.Future[dict[str, Integration]] +] = HassKey("custom_components") +DATA_PRELOAD_PLATFORMS: HassKey[list[str]] = HassKey("preload_platforms") PACKAGE_CUSTOM_COMPONENTS = "custom_components" PACKAGE_BUILTIN = "homeassistant.components" CUSTOM_WARNING = ( @@ -118,9 +130,6 @@ IMPORT_EVENT_LOOP_WARNING = ( "experience issues with Home Assistant" ) -_UNDEF = object() # Internal; not helpers.typing.UNDEFINED due to circular dependency - - MOVED_ZEROCONF_PROPS = ("macaddress", "model", "manufacturer") @@ -298,9 +307,7 @@ async def async_get_custom_components( hass: HomeAssistant, ) -> dict[str, Integration]: """Return cached list of custom integrations.""" - comps_or_future: ( - dict[str, Integration] | asyncio.Future[dict[str, Integration]] | None - ) = hass.data.get(DATA_CUSTOM_COMPONENTS) + comps_or_future = hass.data.get(DATA_CUSTOM_COMPONENTS) if comps_or_future is None: future = hass.data[DATA_CUSTOM_COMPONENTS] = hass.loop.create_future() @@ -622,7 +629,7 @@ async def async_get_mqtt(hass: HomeAssistant) -> dict[str, list[str]]: @callback def async_register_preload_platform(hass: HomeAssistant, platform_name: str) -> None: """Register a platform to be preloaded.""" - preload_platforms: list[str] = hass.data[DATA_PRELOAD_PLATFORMS] + preload_platforms = hass.data[DATA_PRELOAD_PLATFORMS] if platform_name not in preload_platforms: preload_platforms.append(platform_name) @@ -746,17 +753,19 @@ class Integration: self._all_dependencies_resolved = True self._all_dependencies = set() - platforms_to_preload: list[str] = hass.data[DATA_PRELOAD_PLATFORMS] - self._platforms_to_preload = platforms_to_preload + self._platforms_to_preload = hass.data[DATA_PRELOAD_PLATFORMS] self._component_future: asyncio.Future[ComponentProtocol] | None = None self._import_futures: dict[str, asyncio.Future[ModuleType]] = {} - cache: dict[str, ModuleType | ComponentProtocol] = hass.data[DATA_COMPONENTS] - self._cache = cache - missing_platforms_cache: dict[str, bool] = hass.data[DATA_MISSING_PLATFORMS] - self._missing_platforms_cache = missing_platforms_cache + self._cache = hass.data[DATA_COMPONENTS] + self._missing_platforms_cache = hass.data[DATA_MISSING_PLATFORMS] self._top_level_files = top_level_files or set() _LOGGER.info("Loaded %s from %s", self.domain, pkg_path) + @cached_property + def manifest_json_fragment(self) -> json_fragment: + """Return manifest as a JSON fragment.""" + return json_fragment(json_bytes(self.manifest)) + @cached_property def name(self) -> str: """Return name.""" @@ -1233,7 +1242,7 @@ class Integration: appropriate locks. """ full_name = f"{self.domain}.{platform_name}" - cache: dict[str, ModuleType] = self.hass.data[DATA_COMPONENTS] + cache = self.hass.data[DATA_COMPONENTS] try: cache[full_name] = self._import_platform(platform_name) except ModuleNotFoundError: @@ -1259,7 +1268,7 @@ class Integration: f"Exception importing {self.pkg_path}.{platform_name}" ) from err - return cache[full_name] + return cast(ModuleType, cache[full_name]) def _import_platform(self, platform_name: str) -> ModuleType: """Import the platform. @@ -1296,7 +1305,7 @@ def _resolve_integrations_from_root( for domain in domains: try: integration = Integration.resolve_from_root(hass, root_module, domain) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error loading integration: %s", domain) else: if integration: @@ -1311,9 +1320,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio Raises IntegrationNotLoaded if the integration is not loaded. """ cache = hass.data[DATA_INTEGRATIONS] - if TYPE_CHECKING: - cache = cast(dict[str, Integration | asyncio.Future[None]], cache) - int_or_fut = cache.get(domain, _UNDEF) + int_or_fut = cache.get(domain, UNDEFINED) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: return int_or_fut @@ -1322,6 +1329,9 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" + cache = hass.data[DATA_INTEGRATIONS] + if type(int_or_fut := cache.get(domain, UNDEFINED)) is Integration: + return int_or_fut integrations_or_excs = await async_get_integrations(hass, [domain]) int_or_exc = integrations_or_excs[domain] if isinstance(int_or_exc, Integration): @@ -1337,14 +1347,12 @@ async def async_get_integrations( results: dict[str, Integration | Exception] = {} needed: dict[str, asyncio.Future[None]] = {} in_progress: dict[str, asyncio.Future[None]] = {} - if TYPE_CHECKING: - cache = cast(dict[str, Integration | asyncio.Future[None]], cache) for domain in domains: - int_or_fut = cache.get(domain, _UNDEF) + int_or_fut = cache.get(domain, UNDEFINED) # Integration is never subclassed, so we can check for type if type(int_or_fut) is Integration: results[domain] = int_or_fut - elif int_or_fut is not _UNDEF: + elif int_or_fut is not UNDEFINED: in_progress[domain] = cast(asyncio.Future[None], int_or_fut) elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") @@ -1352,12 +1360,12 @@ async def async_get_integrations( needed[domain] = cache[domain] = hass.loop.create_future() if in_progress: - await asyncio.gather(*in_progress.values()) + await asyncio.wait(in_progress.values()) for domain in in_progress: - # When we have waited and it's _UNDEF, it doesn't exist + # When we have waited and it's UNDEFINED, it doesn't exist # We don't cache that it doesn't exist, or else people can't fix it # and then restart, because their config will never be valid. - if (int_or_fut := cache.get(domain, _UNDEF)) is _UNDEF: + if (int_or_fut := cache.get(domain, UNDEFINED)) is UNDEFINED: results[domain] = IntegrationNotFound(domain) else: results[domain] = cast(Integration, int_or_fut) @@ -1443,10 +1451,9 @@ def _load_file( Only returns it if also found to be valid. Async friendly. """ - cache: dict[str, ComponentProtocol] = hass.data[DATA_COMPONENTS] - module: ComponentProtocol | None + cache = hass.data[DATA_COMPONENTS] if module := cache.get(comp_or_platform): - return module + return cast(ComponentProtocol, module) for path in (f"{base}.{comp_or_platform}" for base in base_paths): try: @@ -1574,7 +1581,7 @@ class Helpers: return wrapped -def bind_hass(func: _CallableT) -> _CallableT: +def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. The use of this decorator is discouraged, and it should not be used @@ -1669,6 +1676,14 @@ def async_get_issue_tracker( # If we know nothing about the entity, suggest opening an issue on HA core return issue_tracker + if ( + not integration + and (hass and integration_domain) + and (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS)) + and not isinstance(comps_or_future, asyncio.Future) + ): + integration = comps_or_future.get(integration_domain) + if not integration and (hass and integration_domain): with suppress(IntegrationNotLoaded): integration = async_get_loaded_integration(hass, integration_domain) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7da3daf269c..edb0f29919d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,10 +4,10 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.1.0 aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-isal==0.3.1 +aiohttp-fast-zlib==0.1.0 aiohttp==3.9.5 aiohttp_cors==0.7.0 -aiohttp_session==2.12.0 +aiozoneinfo==0.2.0 astral==2.2 async-interrupt==1.1.1 async-upnp-client==0.38.3 @@ -16,27 +16,27 @@ attrs==23.2.0 awesomeversion==24.2.0 bcrypt==4.1.2 bleak-retry-connector==3.5.0 -bleak==0.21.1 +bleak==0.22.1 bluetooth-adapters==0.19.2 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.1 -cryptography==42.0.5 -dbus-fast==2.21.1 +cryptography==42.0.8 +dbus-fast==2.21.3 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==3.0.1 -hass-nabucasa==0.78.0 -hassil==1.6.1 -home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240501.0 -home-assistant-intents==2024.4.24 +habluetooth==3.1.1 +hass-nabucasa==0.81.1 +hassil==1.7.1 +home-assistant-bluetooth==1.12.1 +home-assistant-frontend==20240610.1 +home-assistant-intents==2024.6.21 httpx==0.27.0 ifaddr==0.2.0 -Jinja2==3.1.3 +Jinja2==3.1.4 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.9.15 @@ -53,9 +53,9 @@ python-slugify==8.0.4 PyTurboJPEG==1.7.1 pyudev==0.24.1 PyYAML==6.0.1 -requests==2.31.0 -SQLAlchemy==2.0.29 -typing-extensions>=4.11.0,<5.0 +requests==2.32.3 +SQLAlchemy==2.0.31 +typing-extensions>=4.12.2,<5.0 ulid-transform==0.9.0 urllib3>=1.26.5,<2 voluptuous-serialize==2.6.0 @@ -107,7 +107,7 @@ 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.3.0 +anyio==4.4.0 h11==0.14.0 httpcore==1.0.5 @@ -133,7 +133,7 @@ backoff>=2.0 # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.15 +pydantic==1.10.17 # Breaks asyncio # https://github.com/pubnub/python/issues/130 @@ -201,3 +201,6 @@ tuf>=4.0.0 # pyserial-asyncio does blocking I/O in asyncio loop, use pyserial-asyncio-fast # instead as pyserial-asyncio is not maintained pyserial-asyncio==1000000000.0.0 + +# https://github.com/jd/tenacity/issues/471 +tenacity<8.4.0 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index e29e0c34ece..c0e92610b6e 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -243,7 +243,7 @@ class RequirementsManager: or ex.domain not in integration.after_dependencies ): exceptions.append(ex) - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: # noqa: BLE001 exceptions.insert(0, ex) if exceptions: diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 4e2326d4ea7..a1510336302 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -88,7 +88,7 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): Back ported from cpython 3.12 """ - with events._lock: # type: ignore[attr-defined] # pylint: disable=protected-access + with events._lock: # type: ignore[attr-defined] # noqa: SLF001 if self._watcher is None: # pragma: no branch if can_use_pidfd(): self._watcher = asyncio.PidfdChildWatcher() @@ -96,7 +96,7 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): self._watcher = asyncio.ThreadedChildWatcher() if threading.current_thread() is threading.main_thread(): self._watcher.attach_loop( - self._local._loop # type: ignore[attr-defined] # pylint: disable=protected-access + self._local._loop # type: ignore[attr-defined] # noqa: SLF001 ) @property @@ -137,16 +137,18 @@ def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: if source_traceback := context.get("source_traceback"): stack_summary = "".join(traceback.format_list(source_traceback)) logger.error( - "Error doing job: %s: %s", + "Error doing job: %s (%s): %s", context["message"], + context.get("task"), stack_summary, **kwargs, # type: ignore[arg-type] ) return logger.error( - "Error doing job: %s", + "Error doing job: %s (%s)", context["message"], + context.get("task"), **kwargs, # type: ignore[arg-type] ) @@ -159,15 +161,14 @@ async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: return 1 # threading._shutdown can deadlock forever - # pylint: disable-next=protected-access - threading._shutdown = deadlock_safe_shutdown # type: ignore[attr-defined] + threading._shutdown = deadlock_safe_shutdown # type: ignore[attr-defined] # noqa: SLF001 return await hass.async_run() def _enable_posix_spawn() -> None: """Enable posix_spawn on Alpine Linux.""" - if subprocess._USE_POSIX_SPAWN: # pylint: disable=protected-access + if subprocess._USE_POSIX_SPAWN: # noqa: SLF001 return # The subprocess module does not know about Alpine Linux/musl @@ -175,8 +176,7 @@ def _enable_posix_spawn() -> None: # less efficient. This is a workaround to force posix_spawn() # when using musl since cpython is not aware its supported. tag = next(packaging.tags.sys_tags()) - # pylint: disable-next=protected-access - subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform + subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform # noqa: SLF001 def run(runtime_config: RuntimeConfig) -> int: diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 07f3d06f4cc..34bc536502f 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -10,7 +10,6 @@ from contextlib import suppress import json import logging from timeit import default_timer as timer -from typing import TypeVar from homeassistant import core from homeassistant.const import EVENT_STATE_CHANGED @@ -24,8 +23,6 @@ from homeassistant.helpers.json import JSON_DUMP, JSONEncoder # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs # mypy: no-warn-return-any -_CallableT = TypeVar("_CallableT", bound=Callable) - BENCHMARKS: dict[str, Callable] = {} @@ -56,7 +53,7 @@ async def run_benchmark(bench): await hass.async_stop() -def benchmark(func: _CallableT) -> _CallableT: +def benchmark[_CallableT: Callable](func: _CallableT) -> _CallableT: """Decorate to mark a benchmark.""" BENCHMARKS[func.__name__] = func return func diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index d38e24a24da..568e8c84a30 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -215,7 +215,7 @@ def check(config_dir, secrets=False): def secrets_proxy(*args): secrets = Secrets(*args) - res["secret_cache"] = secrets._cache # pylint: disable=protected-access + res["secret_cache"] = secrets._cache # noqa: SLF001 return secrets try: @@ -236,7 +236,7 @@ def check(config_dir, secrets=False): if err.config: res["warn"].setdefault(domain, []).append(err.config) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 print(color("red", "Fatal error while loading config:"), str(err)) res["except"].setdefault(ERROR_STR, []).append(str(err)) finally: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 8d7161d04e1..9775a3fee45 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Awaitable, Callable, Generator, Mapping +from collections.abc import Awaitable, Callable, Mapping import contextlib import contextvars from enum import StrEnum @@ -14,12 +14,14 @@ import time from types import ModuleType from typing import Any, Final, TypedDict +from typing_extensions import Generator + from . import config as conf_util, core, loader, requirements from .const import ( + BASE_PLATFORMS, # noqa: F401 EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, PLATFORM_FORMAT, - Platform, ) from .core import ( CALLBACK_TYPE, @@ -33,6 +35,7 @@ from .helpers import singleton, translation from .helpers.issue_registry import IssueSeverity, async_create_issue from .helpers.typing import ConfigType from .util.async_ import create_eager_task +from .util.hass_dict import HassKey current_setup_group: contextvars.ContextVar[tuple[str, str | None] | None] = ( contextvars.ContextVar("current_setup_group", default=None) @@ -43,35 +46,39 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT: Final = "component" -BASE_PLATFORMS = {platform.value for platform in Platform} -# DATA_SETUP is a dict[str, asyncio.Future[bool]], indicating domains which are currently +# DATA_SETUP is a dict, indicating domains which are currently # being setup or which failed to setup: # - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain # being setup and the Task is the `_async_setup_component` helper. # - Tasks are removed from DATA_SETUP if setup was successful, that is, # the task returned True. -DATA_SETUP = "setup_tasks" +DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") -# DATA_SETUP_DONE is a dict [str, asyncio.Future[bool]], indicating components which -# will be setup: +# DATA_SETUP_DONE is a dict, indicating components which will be setup: # - Events are added to DATA_SETUP_DONE during bootstrap by # async_set_domains_to_be_loaded, the key is the domain which will be loaded. # - Events are set and removed from DATA_SETUP_DONE when async_setup_component # is finished, regardless of if the setup was successful or not. -DATA_SETUP_DONE = "setup_done" +DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") -# DATA_SETUP_STARTED is a dict [tuple[str, str | None], float], indicating when an attempt +# DATA_SETUP_STARTED is a dict, indicating when an attempt # to setup a component started. -DATA_SETUP_STARTED = "setup_started" +DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( + "setup_started" +) -# DATA_SETUP_TIME is a defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]] -# indicating how time was spent setting up a component and each group (config entry). -DATA_SETUP_TIME = "setup_time" +# DATA_SETUP_TIME is a defaultdict, indicating how time was spent +# setting up a component. +DATA_SETUP_TIME: HassKey[ + defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]] +] = HassKey("setup_time") -DATA_DEPS_REQS = "deps_reqs_processed" +DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") -DATA_PERSISTENT_ERRORS = "bootstrap_persistent_errors" +DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( + "bootstrap_persistent_errors" +) NOTIFY_FOR_TRANSLATION_KEYS = [ "config_validation_err", @@ -126,9 +133,7 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Properly handle after_dependencies. - Keep track of domains which will load but have not yet finished loading """ - setup_done_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault( - DATA_SETUP_DONE, {} - ) + setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) setup_done_futures.update({domain: hass.loop.create_future() for domain in domains}) @@ -149,12 +154,8 @@ async def async_setup_component( if domain in hass.config.components: return True - setup_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault( - DATA_SETUP, {} - ) - setup_done_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault( - DATA_SETUP_DONE, {} - ) + setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) if existing_setup_future := setup_futures.get(domain): return await existing_setup_future @@ -195,22 +196,21 @@ async def _async_process_dependencies( Returns a list of dependencies which failed to set up. """ - setup_futures: dict[str, asyncio.Future[bool]] = hass.data.setdefault( - DATA_SETUP, {} - ) + setup_futures = hass.data.setdefault(DATA_SETUP, {}) dependencies_tasks = { dep: setup_futures.get(dep) or create_eager_task( async_setup_component(hass, dep, config), name=f"setup {dep} as dependency of {integration.domain}", + loop=hass.loop, ) for dep in integration.dependencies if dep not in hass.config.components } after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {} - to_be_loaded: dict[str, asyncio.Future[bool]] = hass.data.get(DATA_SETUP_DONE, {}) + to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) for dep in integration.after_dependencies: if ( dep not in dependencies_tasks @@ -302,7 +302,7 @@ async def _async_setup_component( # If for some reason the background task in bootstrap was too slow # or the integration was added after bootstrap, we will load them here. load_translations_task = create_eager_task( - translation.async_load_integrations(hass, integration_set) + translation.async_load_integrations(hass, integration_set), loop=hass.loop ) # Validate all dependencies exist and there are no circular dependencies if not await integration.resolve_dependencies(): @@ -450,7 +450,11 @@ async def _async_setup_component( *( create_eager_task( entry.async_setup_locked(hass, integration=integration), - name=f"config entry setup {entry.title} {entry.domain} {entry.entry_id}", + name=( + f"config entry setup {entry.title} {entry.domain} " + f"{entry.entry_id}" + ), + loop=hass.loop, ) for entry in entries ) @@ -596,7 +600,7 @@ def _async_when_setup( """Call the callback.""" try: await when_setup_cb(hass, component) - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Error handling when_setup callback for %s", component) if component in hass.config.components: @@ -634,15 +638,7 @@ def _async_when_setup( @core.callback def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: """Return the complete list of loaded integrations.""" - integrations = set() - for component in hass.config.components: - if "." not in component: - integrations.add(component) - continue - platform, _, domain = component.partition(".") - if domain in BASE_PLATFORMS: - integrations.add(platform) - return integrations + return hass.config.all_components class SetupPhases(StrEnum): @@ -680,9 +676,7 @@ def _setup_started( @contextlib.contextmanager -def async_pause_setup( - hass: core.HomeAssistant, phase: SetupPhases -) -> Generator[None, None, None]: +def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator[None]: """Keep track of time we are blocked waiting for other operations. We want to count the time we wait for importing and @@ -730,7 +724,7 @@ def async_start_setup( integration: str, phase: SetupPhases, group: str | None = None, -) -> Generator[None, None, None]: +) -> Generator[None]: """Keep track of when setup starts and finishes. :param hass: Home Assistant instance @@ -808,3 +802,11 @@ def async_get_setup_timings(hass: core.HomeAssistant) -> dict[str, float]: domain_timings[domain] = total_top_level + group_max return domain_timings + + +@callback +def async_get_domain_setup_times( + hass: core.HomeAssistant, domain: str +) -> Mapping[str | None, dict[SetupPhases, float]]: + """Return timing data for each integration.""" + return _setup_times(hass).get(domain, {}) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 97bba2fb3b7..fca55353aa0 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -9,6 +9,14 @@ "is_on": "{entity_name} is on", "is_off": "{entity_name} is off" }, + "extra_fields": { + "above": "Above", + "below": "Below", + "for": "Duration", + "to": "To", + "value": "Value", + "zone": "Zone" + }, "trigger_type": { "changed_states": "{entity_name} turned on or off", "turned_on": "{entity_name} turned on", @@ -88,6 +96,7 @@ "access_token": "Access token", "api_key": "API key", "api_token": "API token", + "llm_hass_api": "Control Home Assistant", "ssl": "Uses an SSL certificate", "verify_ssl": "Verify SSL certificate", "elevation": "Elevation", diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 1ee33bdd173..c9aa2817640 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -10,15 +10,12 @@ import random import re import string import threading -from typing import Any, TypeVar +from typing import Any import slugify as unicode_slug from .dt import as_local, utcnow -_T = TypeVar("_T") -_U = TypeVar("_U") - RE_SANITIZE_FILENAME = re.compile(r"(~|\.\.|/|\\)") RE_SANITIZE_PATH = re.compile(r"(~|\.(\.)+)") @@ -61,7 +58,7 @@ def repr_helper(inp: Any) -> str: return str(inp) -def convert( +def convert[_T, _U]( value: _T | None, to_type: Callable[[_T], _U], default: _U | None = None ) -> _U | None: """Convert value to to_type, returns default if fails.""" @@ -171,14 +168,12 @@ class Throttle: else: host = args[0] if args else wrapper - # pylint: disable=protected-access if not hasattr(host, "_throttle"): - host._throttle = {} + host._throttle = {} # noqa: SLF001 - if id(self) not in host._throttle: - host._throttle[id(self)] = [threading.Lock(), None] - throttle = host._throttle[id(self)] - # pylint: enable=protected-access + if id(self) not in host._throttle: # noqa: SLF001 + host._throttle[id(self)] = [threading.Lock(), None] # noqa: SLF001 + throttle = host._throttle[id(self)] # noqa: SLF001 if not throttle[0].acquire(False): return throttled_value() diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 94906e29f00..2a4616ee634 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -90,8 +90,7 @@ def serialize_response(response: web.Response) -> dict[str, Any]: if (body := response.body) is None: body_decoded = None elif isinstance(body, payload.StringPayload): - # pylint: disable-next=protected-access - body_decoded = body._value.decode(body.encoding) + body_decoded = body._value.decode(body.encoding) # noqa: SLF001 elif isinstance(body, bytes): body_decoded = body.decode(response.charset or "utf-8") else: diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 19c20207e1d..f2dc1291324 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -7,17 +7,14 @@ from collections.abc import Awaitable, Callable, Coroutine import concurrent.futures import logging import threading -from typing import Any, TypeVar, TypeVarTuple +from typing import Any _LOGGER = logging.getLogger(__name__) _SHUTDOWN_RUN_CALLBACK_THREADSAFE = "_shutdown_run_callback_threadsafe" -_T = TypeVar("_T") -_Ts = TypeVarTuple("_Ts") - -def create_eager_task( +def create_eager_task[_T]( coro: Coroutine[Any, Any, _T], *, name: str | None = None, @@ -45,7 +42,7 @@ def cancelling(task: Future[Any]) -> bool: return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) -def run_callback_threadsafe( +def run_callback_threadsafe[_T, *_Ts]( loop: AbstractEventLoop, callback: Callable[[*_Ts], _T], *args: *_Ts ) -> concurrent.futures.Future[_T]: """Submit a callback object to a given event loop. @@ -61,7 +58,7 @@ def run_callback_threadsafe( """Run callback and store result.""" try: future.set_result(callback(*args)) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # noqa: BLE001 if future.set_running_or_notify_cancel(): future.set_exception(exc) else: diff --git a/homeassistant/util/collection.py b/homeassistant/util/collection.py new file mode 100644 index 00000000000..c2ba94569d6 --- /dev/null +++ b/homeassistant/util/collection.py @@ -0,0 +1,36 @@ +"""Helpers for working with collections.""" + +from collections.abc import Collection, Iterable +from functools import partial +from itertools import islice +from typing import Any + + +def take(take_num: int, iterable: Iterable) -> list[Any]: + """Return first n items of the iterable as a list. + + From itertools recipes + """ + return list(islice(iterable, take_num)) + + +def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: + """Break *iterable* into lists of length *n*. + + From more-itertools + """ + return iter(partial(take, chunked_num, iter(iterable)), []) + + +def chunked_or_all(iterable: Collection[Any], chunked_num: int) -> Iterable[Any]: + """Break *collection* into iterables of length *n*. + + Returns the collection if its length is less than *n*. + + Unlike chunked, this function requires a collection so it can + determine the length of the collection and return the collection + if it is less than *n*. + """ + if len(iterable) <= chunked_num: + return (iterable,) + return chunked(iterable, chunked_num) diff --git a/homeassistant/util/decorator.py b/homeassistant/util/decorator.py index 5bd817de103..04c1ec5e47b 100644 --- a/homeassistant/util/decorator.py +++ b/homeassistant/util/decorator.py @@ -3,13 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Hashable -from typing import Any, TypeVar - -_KT = TypeVar("_KT", bound=Hashable) -_VT = TypeVar("_VT", bound=Callable[..., Any]) +from typing import Any -class Registry(dict[_KT, _VT]): +class Registry[_KT: Hashable, _VT: Callable[..., Any]](dict[_KT, _VT]): """Registry of items.""" def register(self, name: _KT) -> Callable[[_VT], _VT]: diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 923838a48a5..30cf7222f3a 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,11 +5,12 @@ from __future__ import annotations import bisect from contextlib import suppress import datetime as dt -from functools import partial +from functools import lru_cache, partial import re from typing import Any, Literal, overload import zoneinfo +from aiozoneinfo import async_get_time_zone as _async_get_time_zone import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" @@ -74,6 +75,12 @@ POSTGRES_INTERVAL_RE = re.compile( ) +@lru_cache(maxsize=1) +def get_default_time_zone() -> dt.tzinfo: + """Get the default time zone.""" + return DEFAULT_TIME_ZONE + + def set_default_time_zone(time_zone: dt.tzinfo) -> None: """Set a default time zone to be used when none is specified. @@ -85,12 +92,14 @@ def set_default_time_zone(time_zone: dt.tzinfo) -> None: assert isinstance(time_zone, dt.tzinfo) DEFAULT_TIME_ZONE = time_zone + get_default_time_zone.cache_clear() def get_time_zone(time_zone_str: str) -> dt.tzinfo | None: """Get time zone from string. Return None if unable to determine. - Async friendly. + Must be run in the executor if the ZoneInfo is not already + in the cache. If you are not sure, use async_get_time_zone. """ try: return zoneinfo.ZoneInfo(time_zone_str) @@ -98,6 +107,17 @@ def get_time_zone(time_zone_str: str) -> dt.tzinfo | None: return None +async def async_get_time_zone(time_zone_str: str) -> dt.tzinfo | None: + """Get time zone from string. Return None if unable to determine. + + Async friendly. + """ + try: + return await _async_get_time_zone(time_zone_str) + except zoneinfo.ZoneInfoNotFoundError: + return None + + # We use a partial here since it is implemented in native code # and avoids the global lookup of UTC utcnow = partial(dt.datetime.now, UTC) diff --git a/homeassistant/util/enum.py b/homeassistant/util/enum.py index d0ef010f8bb..f29812c7984 100644 --- a/homeassistant/util/enum.py +++ b/homeassistant/util/enum.py @@ -3,23 +3,20 @@ from collections.abc import Callable import contextlib from enum import Enum -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any # https://github.com/python/mypy/issues/5107 if TYPE_CHECKING: - _LruCacheT = TypeVar("_LruCacheT", bound=Callable) - def lru_cache(func: _LruCacheT) -> _LruCacheT: + def lru_cache[_T: Callable[..., Any]](func: _T) -> _T: """Stub for lru_cache.""" else: from functools import lru_cache -_EnumT = TypeVar("_EnumT", bound=Enum) - @lru_cache -def try_parse_enum(cls: type[_EnumT], value: Any) -> _EnumT | None: +def try_parse_enum[_EnumT: Enum](cls: type[_EnumT], value: Any) -> _EnumT | None: """Try to parse the value into an Enum. Return None if parsing fails. diff --git a/homeassistant/util/executor.py b/homeassistant/util/executor.py index cfd81e26e34..47b6d08a197 100644 --- a/homeassistant/util/executor.py +++ b/homeassistant/util/executor.py @@ -24,7 +24,7 @@ EXECUTOR_SHUTDOWN_TIMEOUT = 10 def _log_thread_running_at_shutdown(name: str, ident: int) -> None: """Log the stack of a thread that was still running at shutdown.""" - frames = sys._current_frames() # pylint: disable=protected-access + frames = sys._current_frames() # noqa: SLF001 stack = frames.get(ident) formatted_stack = traceback.format_stack(stack) _LOGGER.warning( diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index fa86ce8ff87..6184e4564eb 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -16,7 +16,6 @@ def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: Extracted from dataclasses._process_class. """ - # pylint: disable=protected-access cls_annotations = cls.__dict__.get("__annotations__", {}) cls_fields: list[dataclasses.Field[Any]] = [] @@ -24,20 +23,20 @@ def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, 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] + if dataclasses._is_kw_only(type, _dataclasses) or ( # type: ignore[attr-defined] # noqa: SLF001 isinstance(_type, str) - and dataclasses._is_type( # type: ignore[attr-defined] + and dataclasses._is_type( # type: ignore[attr-defined] # noqa: SLF001 _type, cls, _dataclasses, dataclasses.KW_ONLY, - dataclasses._is_kw_only, # type: ignore[attr-defined] + dataclasses._is_kw_only, # type: ignore[attr-defined] # noqa: SLF001 ) ): 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] + cls_fields.append(dataclasses._get_field(cls, name, _type, kw_only)) # type: ignore[attr-defined] # noqa: SLF001 return [(field.name, field.type, field) for field in cls_fields] diff --git a/homeassistant/util/hass_dict.py b/homeassistant/util/hass_dict.py new file mode 100644 index 00000000000..692a21dfc58 --- /dev/null +++ b/homeassistant/util/hass_dict.py @@ -0,0 +1,27 @@ +"""Implementation for HassDict and custom HassKey types. + +Custom for type checking. See stub file. +""" + +from __future__ import annotations + + +class HassKey[_T](str): + """Generic Hass key type. + + At runtime this is a generic subclass of str. + """ + + __slots__ = () + + +class HassEntryKey[_T](str): + """Key type for integrations with config entries. + + At runtime this is a generic subclass of str. + """ + + __slots__ = () + + +HassDict = dict diff --git a/homeassistant/util/hass_dict.pyi b/homeassistant/util/hass_dict.pyi new file mode 100644 index 00000000000..5e48c1c0144 --- /dev/null +++ b/homeassistant/util/hass_dict.pyi @@ -0,0 +1,181 @@ +"""Stub file for hass_dict. Provide overload for type checking.""" +# ruff: noqa: PYI021 # Allow docstrings + +from typing import Any, Generic, TypeVar, assert_type, overload + +__all__ = [ + "HassDict", + "HassEntryKey", + "HassKey", +] + +_T = TypeVar("_T") # needs to be invariant + +class _Key(Generic[_T]): + """Base class for Hass key types. At runtime delegated to str.""" + + def __init__(self, value: str, /) -> None: ... + def __len__(self) -> int: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __getitem__(self, index: int) -> str: ... + +class HassEntryKey(_Key[_T]): + """Key type for integrations with config entries.""" + +class HassKey(_Key[_T]): + """Generic Hass key type.""" + +class HassDict(dict[_Key[Any] | str, Any]): + """Custom dict type to provide better value type hints for Hass key types.""" + + @overload # type: ignore[override] + def __getitem__[_S](self, key: HassEntryKey[_S], /) -> dict[str, _S]: ... + @overload + def __getitem__[_S](self, key: HassKey[_S], /) -> _S: ... + @overload + def __getitem__(self, key: str, /) -> Any: ... + + # ------ + @overload # type: ignore[override] + def __setitem__[_S]( + self, key: HassEntryKey[_S], value: dict[str, _S], / + ) -> None: ... + @overload + def __setitem__[_S](self, key: HassKey[_S], value: _S, /) -> None: ... + @overload + def __setitem__(self, key: str, value: Any, /) -> None: ... + + # ------ + @overload # type: ignore[override] + def setdefault[_S]( + self, key: HassEntryKey[_S], default: dict[str, _S], / + ) -> dict[str, _S]: ... + @overload + def setdefault[_S](self, key: HassKey[_S], default: _S, /) -> _S: ... + @overload + def setdefault(self, key: str, default: None = None, /) -> Any | None: ... + @overload + def setdefault(self, key: str, default: Any, /) -> Any: ... + + # ------ + @overload # type: ignore[override] + def get[_S](self, key: HassEntryKey[_S], /) -> dict[str, _S] | None: ... + @overload + def get[_S, _U]( + self, key: HassEntryKey[_S], default: _U, / + ) -> dict[str, _S] | _U: ... + @overload + def get[_S](self, key: HassKey[_S], /) -> _S | None: ... + @overload + def get[_S, _U](self, key: HassKey[_S], default: _U, /) -> _S | _U: ... + @overload + def get(self, key: str, /) -> Any | None: ... + @overload + def get(self, key: str, default: Any, /) -> Any: ... + + # ------ + @overload # type: ignore[override] + def pop[_S](self, key: HassEntryKey[_S], /) -> dict[str, _S]: ... + @overload + def pop[_S]( + self, key: HassEntryKey[_S], default: dict[str, _S], / + ) -> dict[str, _S]: ... + @overload + def pop[_S, _U]( + self, key: HassEntryKey[_S], default: _U, / + ) -> dict[str, _S] | _U: ... + @overload + def pop[_S](self, key: HassKey[_S], /) -> _S: ... + @overload + def pop[_S](self, key: HassKey[_S], default: _S, /) -> _S: ... + @overload + def pop[_S, _U](self, key: HassKey[_S], default: _U, /) -> _S | _U: ... + @overload + def pop(self, key: str, /) -> Any: ... + @overload + def pop[_U](self, key: str, default: _U, /) -> Any | _U: ... + +def _test_hass_dict_typing() -> None: # noqa: PYI048 + """Test HassDict overloads work as intended. + + This is tested during the mypy run. Do not move it to 'tests'! + """ + d = HassDict() + entry_key = HassEntryKey[int]("entry_key") + key = HassKey[int]("key") + key2 = HassKey[dict[int, bool]]("key2") + key3 = HassKey[set[str]]("key3") + other_key = "domain" + + # __getitem__ + assert_type(d[entry_key], dict[str, int]) + assert_type(d[entry_key]["entry_id"], int) + assert_type(d[key], int) + assert_type(d[key2], dict[int, bool]) + + # __setitem__ + d[entry_key] = {} + d[entry_key] = 2 # type: ignore[call-overload] + d[entry_key]["entry_id"] = 2 + d[entry_key]["entry_id"] = "Hello World" # type: ignore[assignment] + d[key] = 2 + d[key] = "Hello World" # type: ignore[misc] + d[key] = {} # type: ignore[misc] + d[key2] = {} + d[key2] = 2 # type: ignore[misc] + d[key3] = set() + d[key3] = 2 # type: ignore[misc] + d[other_key] = 2 + d[other_key] = "Hello World" + + # get + assert_type(d.get(entry_key), dict[str, int] | None) + assert_type(d.get(entry_key, True), dict[str, int] | bool) + assert_type(d.get(key), int | None) + assert_type(d.get(key, True), int | bool) + assert_type(d.get(key2), dict[int, bool] | None) + assert_type(d.get(key2, {}), dict[int, bool]) + assert_type(d.get(key3), set[str] | None) + assert_type(d.get(key3, set()), set[str]) + assert_type(d.get(other_key), Any | None) + assert_type(d.get(other_key, True), Any) + assert_type(d.get(other_key, {})["id"], Any) + + # setdefault + assert_type(d.setdefault(entry_key, {}), dict[str, int]) + assert_type(d.setdefault(entry_key, {})["entry_id"], int) + assert_type(d.setdefault(key, 2), int) + assert_type(d.setdefault(key2, {}), dict[int, bool]) + assert_type(d.setdefault(key2, {})[2], bool) + assert_type(d.setdefault(key3, set()), set[str]) + assert_type(d.setdefault(other_key, 2), Any) + assert_type(d.setdefault(other_key), Any | None) + d.setdefault(entry_key, {})["entry_id"] = 2 + d.setdefault(entry_key, {})["entry_id"] = "Hello World" # type: ignore[assignment] + d.setdefault(key, 2) + d.setdefault(key, "Error") # type: ignore[misc] + d.setdefault(key2, {})[2] = True + d.setdefault(key2, {})[2] = "Error" # type: ignore[assignment] + d.setdefault(key3, set()).add("Hello World") + d.setdefault(key3, set()).add(2) # type: ignore[arg-type] + d.setdefault(other_key, {})["id"] = 2 + d.setdefault(other_key, {})["id"] = "Hello World" + d.setdefault(entry_key) # type: ignore[call-overload] + d.setdefault(key) # type: ignore[call-overload] + d.setdefault(key2) # type: ignore[call-overload] + + # pop + assert_type(d.pop(entry_key), dict[str, int]) + assert_type(d.pop(entry_key, {}), dict[str, int]) + assert_type(d.pop(entry_key, 2), dict[str, int] | int) + assert_type(d.pop(key), int) + assert_type(d.pop(key, 2), int) + assert_type(d.pop(key, "Hello World"), int | str) + assert_type(d.pop(key2), dict[int, bool]) + assert_type(d.pop(key2, {}), dict[int, bool]) + assert_type(d.pop(key2, 2), dict[int, bool] | int) + assert_type(d.pop(key3), set[str]) + assert_type(d.pop(key3, set()), set[str]) + assert_type(d.pop(other_key), Any) + assert_type(d.pop(other_key, True), Any | bool) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 9a30ae8f104..1479550b615 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -17,13 +17,13 @@ from .file import WriteError # noqa: F401 _SENTINEL = object() _LOGGER = logging.getLogger(__name__) -JsonValueType = ( - dict[str, "JsonValueType"] | list["JsonValueType"] | str | int | float | bool | None +type JsonValueType = ( + dict[str, JsonValueType] | list[JsonValueType] | str | int | float | bool | None ) """Any data that can be returned by the standard JSON deserializing process.""" -JsonArrayType = list[JsonValueType] +type JsonArrayType = list[JsonValueType] """List that can be returned by the standard JSON deserializing process.""" -JsonObjectType = dict[str, JsonValueType] +type JsonObjectType = dict[str, JsonValueType] """Dictionary that can be returned by the standard JSON deserializing process.""" JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError) diff --git a/homeassistant/util/limited_size_dict.py b/homeassistant/util/limited_size_dict.py index 6166a6c8239..8f0d9315855 100644 --- a/homeassistant/util/limited_size_dict.py +++ b/homeassistant/util/limited_size_dict.py @@ -3,13 +3,10 @@ from __future__ import annotations from collections import OrderedDict -from typing import Any, TypeVar - -_KT = TypeVar("_KT") -_VT = TypeVar("_VT") +from typing import Any -class LimitedSizeDict(OrderedDict[_KT, _VT]): +class LimitedSizeDict[_KT, _VT](OrderedDict[_KT, _VT]): """OrderedDict limited in size.""" def __init__(self, *args: Any, **kwds: Any) -> None: diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index ab163578846..d2554ef543c 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -9,7 +9,7 @@ import logging import logging.handlers import queue import traceback -from typing import Any, TypeVar, TypeVarTuple, cast, overload +from typing import Any, cast, overload from homeassistant.core import ( HassJobType, @@ -18,9 +18,6 @@ from homeassistant.core import ( get_hassjob_callable_job_type, ) -_T = TypeVar("_T") -_Ts = TypeVarTuple("_Ts") - class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" @@ -80,7 +77,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: listener.start() -def log_exception(format_err: Callable[[*_Ts], Any], *args: *_Ts) -> None: +def log_exception[*_Ts](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: @@ -98,7 +95,7 @@ def log_exception(format_err: Callable[[*_Ts], Any], *args: *_Ts) -> None: logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) -async def _async_wrapper( +async def _async_wrapper[*_Ts]( async_func: Callable[[*_Ts], Coroutine[Any, Any, None]], format_err: Callable[[*_Ts], Any], *args: *_Ts, @@ -106,33 +103,33 @@ async def _async_wrapper( """Catch and log exception.""" try: await async_func(*args) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 log_exception(format_err, *args) -def _sync_wrapper( +def _sync_wrapper[*_Ts]( 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 + except Exception: # noqa: BLE001 log_exception(format_err, *args) @callback -def _callback_wrapper( +def _callback_wrapper[*_Ts]( 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 + except Exception: # noqa: BLE001 log_exception(format_err, *args) @overload -def catch_log_exception( +def catch_log_exception[*_Ts]( func: Callable[[*_Ts], Coroutine[Any, Any, Any]], format_err: Callable[[*_Ts], Any], job_type: HassJobType | None = None, @@ -140,14 +137,14 @@ def catch_log_exception( @overload -def catch_log_exception( +def catch_log_exception[*_Ts]( func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], job_type: HassJobType | None = None, ) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: ... -def catch_log_exception( +def catch_log_exception[*_Ts]( func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], job_type: HassJobType | None = None, @@ -170,7 +167,7 @@ def catch_log_exception( return wraps(func)(partial(_sync_wrapper, func, format_err)) # type: ignore[return-value] -def catch_log_coro_exception( +def catch_log_coro_exception[_T, *_Ts]( 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.""" @@ -179,14 +176,14 @@ def catch_log_coro_exception( """Catch and log exception.""" try: return await target - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 log_exception(format_err, *args) return None return coro_wrapper(*args) -def async_create_catching_coro( +def async_create_catching_coro[_T]( target: Coroutine[Any, Any, _T], ) -> Coroutine[Any, Any, _T | None]: """Wrap a coroutine to catch and log exceptions. diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index f8fe5c701f3..8a469569601 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -2,16 +2,15 @@ from __future__ import annotations -from asyncio import get_running_loop from collections.abc import Callable -from contextlib import suppress import functools import linecache import logging -from typing import Any, ParamSpec, TypeVar +import threading +import traceback +from typing import Any -from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import async_get_hass_or_none from homeassistant.helpers.frame import ( MissingIntegrationFrame, get_current_frame, @@ -22,37 +21,19 @@ from homeassistant.loader import async_suggest_report_issue _LOGGER = logging.getLogger(__name__) -_R = TypeVar("_R") -_P = ParamSpec("_P") - - def _get_line_from_cache(filename: str, lineno: int) -> str: """Get line from cache or read from file.""" return (linecache.getline(filename, lineno) or "?").strip() -def check_loop( +def raise_for_blocking_call( func: Callable[..., Any], check_allowed: Callable[[dict[str, Any]], bool] | None = None, strict: bool = True, strict_core: bool = True, - advise_msg: str | None = None, **mapped_args: Any, ) -> None: - """Warn if called inside the event loop. Raise if `strict` is True. - - The default advisory message is 'Use `await hass.async_add_executor_job()' - Set `advise_msg` to an alternate message if the solution differs. - """ - try: - get_running_loop() - in_loop = True - except RuntimeError: - in_loop = False - - if not in_loop: - return - + """Warn if called inside the event loop. Raise if `strict` is True.""" if check_allowed is not None and check_allowed(mapped_args): return @@ -69,39 +50,47 @@ def check_loop( if not strict_core: _LOGGER.warning( "Detected blocking call to %s with args %s in %s, " - "line %s: %s inside the event loop", + "line %s: %s inside the event loop; " + "This is causing stability issues. " + "Please create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%%3Aopen+is%%3Aissue\n" + "%s\n" + "Traceback (most recent call last):\n%s", func.__name__, mapped_args.get("args"), offender_filename, offender_lineno, offender_line, + _dev_help_message(func.__name__), + "".join(traceback.format_stack(f=offender_frame)), ) return if found_frame is None: raise RuntimeError( # noqa: TRY200 - f"Detected blocking call to {func.__name__} inside the event loop " - f"in {offender_filename}, line {offender_lineno}: {offender_line}. " - f"{advise_msg or 'Use `await hass.async_add_executor_job()`'}; " - "This is causing stability issues. Please create a bug report at " - f"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + f"Caught blocking call to {func.__name__} with args {mapped_args.get("args")} " + f"in {offender_filename}, line {offender_lineno}: {offender_line} " + "inside the event loop; " + "This is causing stability issues. " + "Please create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue\n" + f"{_dev_help_message(func.__name__)}" ) - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() report_issue = async_suggest_report_issue( - hass, + async_get_hass_or_none(), integration_domain=integration_frame.integration, module=integration_frame.module, ) _LOGGER.warning( - ( - "Detected blocking call to %s inside the event loop by %sintegration '%s' " - "at %s, line %s: %s (offender: %s, line %s: %s), please %s" - ), + "Detected blocking call to %s with args %s " + "inside the event loop by %sintegration '%s' " + "at %s, line %s: %s (offender: %s, line %s: %s), please %s\n" + "%s\n" + "Traceback (most recent call last):\n%s", func.__name__, + mapped_args.get("args"), "custom " if integration_frame.custom_integration else "", integration_frame.integration, integration_frame.relative_filename, @@ -111,20 +100,35 @@ def check_loop( offender_lineno, offender_line, report_issue, + _dev_help_message(func.__name__), + "".join(traceback.format_stack(f=integration_frame.frame)), ) if strict: raise RuntimeError( - "Blocking calls must be done in the executor or a separate thread;" - f" {advise_msg or 'Use `await hass.async_add_executor_job()`'}; at" - f" {integration_frame.relative_filename}, line {integration_frame.line_number}:" - f" {integration_frame.line} " - f"(offender: {offender_filename}, line {offender_lineno}: {offender_line})" + "Caught blocking call to {func.__name__} with args " + f"{mapped_args.get('args')} inside the event loop by" + f"{'custom ' if integration_frame.custom_integration else ''}" + "integration '{integration_frame.integration}' at " + f"{integration_frame.relative_filename}, line {integration_frame.line_number}:" + f" {integration_frame.line}. (offender: {offender_filename}, line " + f"{offender_lineno}: {offender_line}), please {report_issue}\n" + f"{_dev_help_message(func.__name__)}" ) -def protect_loop( +def _dev_help_message(what: str) -> str: + """Generate help message to guide developers.""" + return ( + "For developers, please see " + "https://developers.home-assistant.io/docs/asyncio_blocking_operations/" + f"#{what.replace('.', '')}" + ) + + +def protect_loop[**_P, _R]( func: Callable[_P, _R], + loop_thread_id: int, strict: bool = True, strict_core: bool = True, check_allowed: Callable[[dict[str, Any]], bool] | None = None, @@ -133,14 +137,15 @@ def protect_loop( @functools.wraps(func) def protected_loop_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: - check_loop( - func, - strict=strict, - strict_core=strict_core, - check_allowed=check_allowed, - args=args, - kwargs=kwargs, - ) + if threading.get_ident() == loop_thread_id: + raise_for_blocking_call( + func, + strict=strict, + strict_core=strict_core, + check_allowed=check_allowed, + args=args, + kwargs=kwargs, + ) return func(*args, **kwargs) return protected_loop_func diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index e01af5400f4..c1372e45b73 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import TypeVar - from .scaling import ( # noqa: F401 int_states_in_range, scale_ranged_value_to_int_range, @@ -11,10 +9,8 @@ from .scaling import ( # noqa: F401 states_in_range, ) -_T = TypeVar("_T") - -def ordered_list_item_to_percentage(ordered_list: list[_T], item: _T) -> int: +def ordered_list_item_to_percentage[_T](ordered_list: list[_T], item: _T) -> int: """Determine the percentage of an item in an ordered list. When using this utility for fan speeds, do not include "off" @@ -37,7 +33,7 @@ def ordered_list_item_to_percentage(ordered_list: list[_T], item: _T) -> int: return (list_position * 100) // list_len -def percentage_to_ordered_list_item(ordered_list: list[_T], percentage: int) -> _T: +def percentage_to_ordered_list_item[_T](ordered_list: list[_T], percentage: int) -> _T: """Find the item that most closely matches the percentage in an ordered list. When using this utility for fan speeds, do not include "off" diff --git a/homeassistant/util/read_only_dict.py b/homeassistant/util/read_only_dict.py index 90245ce7ca9..02befa78f60 100644 --- a/homeassistant/util/read_only_dict.py +++ b/homeassistant/util/read_only_dict.py @@ -1,6 +1,7 @@ """Read only dictionary.""" -from typing import Any, TypeVar +from copy import deepcopy +from typing import Any def _readonly(*args: Any, **kwargs: Any) -> Any: @@ -8,11 +9,7 @@ def _readonly(*args: Any, **kwargs: Any) -> Any: raise RuntimeError("Cannot modify ReadOnlyDict") -_KT = TypeVar("_KT") -_VT = TypeVar("_VT") - - -class ReadOnlyDict(dict[_KT, _VT]): +class ReadOnlyDict[_KT, _VT](dict[_KT, _VT]): """Read only version of dict that is compatible with dict types.""" __setitem__ = _readonly @@ -22,3 +19,13 @@ class ReadOnlyDict(dict[_KT, _VT]): clear = _readonly update = _readonly setdefault = _readonly + + def __copy__(self) -> dict[_KT, _VT]: + """Create a shallow copy.""" + return ReadOnlyDict(self) + + def __deepcopy__(self, memo: Any) -> dict[_KT, _VT]: + """Create a deep copy.""" + return ReadOnlyDict( + {deepcopy(key, memo): deepcopy(value, memo) for key, value in self.items()} + ) diff --git a/homeassistant/util/signal_type.py b/homeassistant/util/signal_type.py index e2730c969c4..2552b3515fc 100644 --- a/homeassistant/util/signal_type.py +++ b/homeassistant/util/signal_type.py @@ -2,42 +2,20 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Generic, TypeVarTuple -_Ts = TypeVarTuple("_Ts") - - -@dataclass(frozen=True) -class _SignalTypeBase(Generic[*_Ts]): +class _SignalTypeBase[*_Ts](str): """Generic base class for SignalType.""" - name: str - - def __hash__(self) -> int: - """Return hash of name.""" - - return hash(self.name) - - def __eq__(self, other: object) -> bool: - """Check equality for dict keys to be compatible with str.""" - - if isinstance(other, str): - return self.name == other - if isinstance(other, SignalType): - return self.name == other.name - return False + __slots__ = () -@dataclass(frozen=True, eq=False) -class SignalType(_SignalTypeBase[*_Ts]): +class SignalType[*_Ts](_SignalTypeBase[*_Ts]): """Generic string class for signal to improve typing.""" + __slots__ = () -@dataclass(frozen=True, eq=False) -class SignalTypeFormat(_SignalTypeBase[*_Ts]): + +class SignalTypeFormat[*_Ts](_SignalTypeBase[*_Ts]): """Generic string class for signal. Requires call to 'format' before use.""" - def format(self, *args: Any, **kwargs: Any) -> SignalType[*_Ts]: - """Format name and return new SignalType instance.""" - return SignalType(self.name.format(*args, **kwargs)) + __slots__ = () diff --git a/homeassistant/util/signal_type.pyi b/homeassistant/util/signal_type.pyi new file mode 100644 index 00000000000..9987c3a0931 --- /dev/null +++ b/homeassistant/util/signal_type.pyi @@ -0,0 +1,69 @@ +"""Stub file for signal_type. Provide overload for type checking.""" +# ruff: noqa: PYI021 # Allow docstring + +from typing import Any, assert_type + +__all__ = [ + "SignalType", + "SignalTypeFormat", +] + +class _SignalTypeBase[*_Ts]: + """Custom base class for SignalType. At runtime delegate to str. + + For type checkers pretend to be its own separate class. + """ + + def __init__(self, value: str, /) -> None: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object, /) -> bool: ... + +class SignalType[*_Ts](_SignalTypeBase[*_Ts]): + """Generic string class for signal to improve typing.""" + +class SignalTypeFormat[*_Ts](_SignalTypeBase[*_Ts]): + """Generic string class for signal. Requires call to 'format' before use.""" + + def format(self, *args: Any, **kwargs: Any) -> SignalType[*_Ts]: ... + +def _test_signal_type_typing() -> None: # noqa: PYI048 + """Test SignalType and dispatcher overloads work as intended. + + This is tested during the mypy run. Do not move it to 'tests'! + """ + # pylint: disable=import-outside-toplevel + from homeassistant.core import HomeAssistant + from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + ) + + hass: HomeAssistant + def test_func(a: int) -> None: ... + def test_func_other(a: int, b: str) -> None: ... + + # No type validation for str signals + signal_str = "signal" + async_dispatcher_connect(hass, signal_str, test_func) + async_dispatcher_connect(hass, signal_str, test_func_other) + async_dispatcher_send(hass, signal_str, 2) + async_dispatcher_send(hass, signal_str, 2, "Hello World") + + # Using SignalType will perform type validation on target and args + signal_1: SignalType[int] = SignalType("signal") + assert_type(signal_1, SignalType[int]) + async_dispatcher_connect(hass, signal_1, test_func) + async_dispatcher_connect(hass, signal_1, test_func_other) # type: ignore[arg-type] + async_dispatcher_send(hass, signal_1, 2) + async_dispatcher_send(hass, signal_1, "Hello World") # type: ignore[misc] + + # SignalTypeFormat cannot be used for dispatcher_connect / dispatcher_send + # Call format() on it first to convert it to a SignalType + signal_format: SignalTypeFormat[int] = SignalTypeFormat("signal_") + signal_2 = signal_format.format("2") + assert_type(signal_format, SignalTypeFormat[int]) + assert_type(signal_2, SignalType[int]) + async_dispatcher_connect(hass, signal_format, test_func) # type: ignore[call-overload] + async_dispatcher_connect(hass, signal_2, test_func) + async_dispatcher_send(hass, signal_format, 2) # type: ignore[call-overload] + async_dispatcher_send(hass, signal_2, 2) diff --git a/homeassistant/util/thread.py b/homeassistant/util/thread.py index 7673d962d74..a016f192142 100644 --- a/homeassistant/util/thread.py +++ b/homeassistant/util/thread.py @@ -31,7 +31,7 @@ def deadlock_safe_shutdown() -> None: for thread in remaining_threads: try: thread.join(timeout_per_thread) - except Exception as err: # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 _LOGGER.warning("Failed to join thread: %s", err) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 04ce0715192..2b9f73afab7 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -10,6 +10,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -169,6 +170,19 @@ class DistanceConverter(BaseUnitConverter): } +class ConductivityConverter(BaseUnitConverter): + """Utility to convert electric current values.""" + + UNIT_CLASS = "conductivity" + NORMALIZED_UNIT = UnitOfConductivity.MICROSIEMENS + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfConductivity.MICROSIEMENS: 1, + UnitOfConductivity.MILLISIEMENS: 1e-3, + UnitOfConductivity.SIEMENS: 1e-6, + } + VALID_UNITS = set(UnitOfConductivity) + + class ElectricCurrentConverter(BaseUnitConverter): """Utility to convert electric current values.""" diff --git a/homeassistant/util/variance.py b/homeassistant/util/variance.py index b109e5c476c..b1dfeacb77a 100644 --- a/homeassistant/util/variance.py +++ b/homeassistant/util/variance.py @@ -5,31 +5,30 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import functools -from typing import Any, ParamSpec, TypeVar, overload - -_R = TypeVar("_R", int, float, datetime) -_P = ParamSpec("_P") +from typing import Any, overload @overload -def ignore_variance( +def ignore_variance[**_P]( func: Callable[_P, int], ignored_variance: int ) -> Callable[_P, int]: ... @overload -def ignore_variance( +def ignore_variance[**_P]( func: Callable[_P, float], ignored_variance: float ) -> Callable[_P, float]: ... @overload -def ignore_variance( +def ignore_variance[**_P]( func: Callable[_P, datetime], ignored_variance: timedelta ) -> Callable[_P, datetime]: ... -def ignore_variance(func: Callable[_P, _R], ignored_variance: Any) -> Callable[_P, _R]: +def ignore_variance[**_P, _R: (int, float, datetime)]( + func: Callable[_P, _R], ignored_variance: Any +) -> Callable[_P, _R]: """Wrap a function that returns old result if new result does not vary enough.""" last_value: _R | None = None diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 0809e86460b..ff9b7cb3601 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -215,7 +215,7 @@ class SafeLineLoader(PythonSafeLoader): ) -LoaderType = FastSafeLoader | PythonSafeLoader +type LoaderType = FastSafeLoader | PythonSafeLoader def load_yaml( @@ -313,6 +313,33 @@ def _add_reference( obj = NodeStrClass(obj) elif isinstance(obj, dict): obj = NodeDictClass(obj) + return _add_reference_to_node_class(obj, loader, node) + + +@overload +def _add_reference_to_node_class( + obj: NodeListClass, loader: LoaderType, node: yaml.nodes.Node +) -> NodeListClass: ... + + +@overload +def _add_reference_to_node_class( + obj: NodeStrClass, loader: LoaderType, node: yaml.nodes.Node +) -> NodeStrClass: ... + + +@overload +def _add_reference_to_node_class( + obj: NodeDictClass, loader: LoaderType, node: yaml.nodes.Node +) -> NodeDictClass: ... + + +def _add_reference_to_node_class( + obj: NodeDictClass | NodeListClass | NodeStrClass, + loader: LoaderType, + node: yaml.nodes.Node, +) -> NodeDictClass | NodeListClass | NodeStrClass: + """Add file reference information to a node class object.""" try: # suppress is much slower obj.__config_file__ = loader.get_name obj.__line__ = node.start_mark.line + 1 @@ -369,7 +396,7 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi # as an empty dictionary loaded_yaml = NodeDictClass() mapping[filename] = loaded_yaml - return _add_reference(mapping, loader, node) + return _add_reference_to_node_class(mapping, loader, node) def _include_dir_merge_named_yaml( @@ -384,7 +411,7 @@ def _include_dir_merge_named_yaml( loaded_yaml = load_yaml(fname, loader.secrets) if isinstance(loaded_yaml, dict): mapping.update(loaded_yaml) - return _add_reference(mapping, loader, node) + return _add_reference_to_node_class(mapping, loader, node) def _include_dir_list_yaml( @@ -453,7 +480,7 @@ def _handle_mapping_tag( ) seen[key] = line - return _add_reference(NodeDictClass(nodes), loader, node) + return _add_reference_to_node_class(NodeDictClass(nodes), loader, node) def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: @@ -469,7 +496,7 @@ def _handle_scalar_tag( obj = node.value if not isinstance(obj, str): return obj - return _add_reference(obj, loader, node) + return _add_reference_to_node_class(NodeStrClass(obj), loader, node) def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: diff --git a/mypy.ini b/mypy.ini index 08e4bcc0e4f..740eb4f2b5b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,6 +8,7 @@ platform = linux plugins = pydantic.mypy show_error_codes = true follow_imports = normal +enable_incomplete_feature = NewGenericSyntax local_partial_types = true strict_equality = true no_implicit_optional = true @@ -241,6 +242,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airgradient.*] +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.airly.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -411,16 +422,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.ambiclimate.*] -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.ambient_network.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -601,6 +602,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.apsystems.*] +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 @@ -2362,6 +2373,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.knocki.*] +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.knx.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2772,6 +2793,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.monzo.*] +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.moon.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3152,16 +3183,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.poolsense.*] -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.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -4033,6 +4054,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.thethingsnetwork.*] +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.threshold.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_coordinator_module.py b/pylint/plugins/hass_enforce_coordinator_module.py index 924b69f1b86..7160a25085d 100644 --- a/pylint/plugins/hass_enforce_coordinator_module.py +++ b/pylint/plugins/hass_enforce_coordinator_module.py @@ -19,24 +19,9 @@ class HassEnforceCoordinatorModule(BaseChecker): "Used when derived data update coordinator should be placed in its own module.", ), } - options = ( - ( - "ignore-wrong-coordinator-module", - { - "default": False, - "type": "yn", - "metavar": "", - "help": "Set to ``no`` if you wish to check if derived data update coordinator " - "is placed in its own module.", - }, - ), - ) def visit_classdef(self, node: nodes.ClassDef) -> None: """Check if derived data update coordinator is placed in its own module.""" - if self.linter.config.ignore_wrong_coordinator_module: - return - root_name = node.root().name # we only want to check component update coordinators diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 2f107fb1bf2..6dd19d96d01 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -69,7 +69,7 @@ class ClassTypeHintMatch: matches: list[TypeHintMatch] -_INNER_MATCH = r"((?:[\w\| ]+)|(?:\.{3})|(?:\w+\[.+\]))" +_INNER_MATCH = r"((?:[\w\| ]+)|(?:\.{3})|(?:\w+\[.+\])|(?:\[\]))" _TYPE_HINT_MATCHERS: dict[str, re.Pattern[str]] = { # a_or_b matches items such as "DiscoveryInfoType | None" # or "dict | list | None" @@ -98,6 +98,7 @@ _METHOD_MATCH: list[TypeHintMatch] = [ _TEST_FIXTURES: dict[str, list[str] | str] = { "aioclient_mock": "AiohttpClientMocker", "aiohttp_client": "ClientSessionGenerator", + "aiohttp_server": "Callable[[], TestServer]", "area_registry": "AreaRegistry", "async_setup_recorder_instance": "RecorderInstanceGenerator", "caplog": "pytest.LogCaptureFixture", @@ -110,7 +111,9 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_schema_validation": "bool", "entity_registry": "EntityRegistry", "entity_registry_enabled_by_default": "None", + "event_loop": "AbstractEventLoop", "freezer": "FrozenDateTimeFactory", + "hass": "HomeAssistant", "hass_access_token": "str", "hass_admin_credential": "Credentials", "hass_admin_user": "MockUser", @@ -127,33 +130,39 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "hass_supervisor_access_token": "str", "hass_supervisor_user": "MockUser", "hass_ws_client": "WebSocketGenerator", + "init_tts_cache_dir_side_effect": "Any", "issue_registry": "IssueRegistry", - "legacy_auth": "LegacyApiPasswordAuthProvider", "local_auth": "HassAuthProvider", - "mock_async_zeroconf": "None", + "mock_async_zeroconf": "MagicMock", "mock_bleak_scanner_start": "MagicMock", "mock_bluetooth": "None", "mock_bluetooth_adapters": "None", + "mock_conversation_agent": "MockAgent", "mock_device_tracker_conf": "list[Device]", - "mock_get_source_ip": "None", + "mock_get_source_ip": "_patch", "mock_hass_config": "None", "mock_hass_config_yaml": "None", - "mock_zeroconf": "None", + "mock_tts_cache_dir": "Path", + "mock_tts_get_cache_files": "MagicMock", + "mock_tts_init_cache_dir": "MagicMock", + "mock_zeroconf": "MagicMock", "mqtt_client_mock": "MqttMockPahoClient", "mqtt_mock": "MqttMockHAClient", "mqtt_mock_entry": "MqttMockHAClientGenerator", "recorder_db_url": "str", "recorder_mock": "Recorder", - "requests_mock": "requests_mock.Mocker", + "request": "pytest.FixtureRequest", + "requests_mock": "Mocker", + "service_calls": "list[ServiceCall]", "snapshot": "SnapshotAssertion", + "socket_enabled": "None", "stub_blueprint_populate": "None", "tmp_path": "Path", "tmpdir": "py.path.local", + "tts_mutagen_mock": "MagicMock", + "unused_tcp_port_factory": "Callable[[], int]", + "unused_udp_port_factory": "Callable[[], int]", } -_TEST_FUNCTION_MATCH = TypeHintMatch( - function_name="test_*", - return_type=None, -) _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { @@ -2909,6 +2918,10 @@ def _is_valid_type( if expected_type == "...": return isinstance(node, nodes.Const) and node.value == Ellipsis + # Special case for an empty list, such as Callable[[], TestServer] + if expected_type == "[]": + return isinstance(node, nodes.List) and not node.elts + # Special case for `xxx | yyy` if match := _TYPE_HINT_MATCHERS["a_or_b"].match(expected_type): return ( @@ -3087,11 +3100,6 @@ def _get_module_platform(module_name: str) -> str | None: return platform.lstrip(".") if platform else "__init__" -def _is_test_function(module_name: str, node: nodes.FunctionDef) -> bool: - """Return True if function is a pytest function.""" - return module_name.startswith("tests.") and node.name.startswith("test_") - - class HassTypeHintChecker(BaseChecker): """Checker for setup type hints.""" @@ -3108,6 +3116,12 @@ class HassTypeHintChecker(BaseChecker): "hass-return-type", "Used when method return type is incorrect", ), + "W7433": ( + "Argument %s is of type %s and could be moved to " + "`@pytest.mark.usefixtures` decorator in %s", + "hass-consider-usefixtures-decorator", + "Used when an argument type is None and could be a fixture", + ), } options = ( ( @@ -3124,15 +3138,20 @@ class HassTypeHintChecker(BaseChecker): _class_matchers: list[ClassTypeHintMatch] _function_matchers: list[TypeHintMatch] - _module_name: str + _module_node: nodes.Module + _in_test_module: bool def visit_module(self, node: nodes.Module) -> None: """Populate matchers for a Module node.""" self._class_matchers = [] self._function_matchers = [] - self._module_name = node.name + self._module_node = node + self._in_test_module = node.name.startswith("tests.") - if (module_platform := _get_module_platform(node.name)) is None: + if ( + self._in_test_module + or (module_platform := _get_module_platform(node.name)) is None + ): return if module_platform in _PLATFORMS: @@ -3207,6 +3226,24 @@ class HassTypeHintChecker(BaseChecker): if self._ignore_function(node, annotations): return + # Check method or function matchers. + if node.is_method(): + matchers = _METHOD_MATCH + else: + if self._in_test_module and node.parent is self._module_node: + if node.name.startswith("test_"): + self._check_test_function(node, False) + return + if (decoratornames := node.decoratornames()) and ( + # `@pytest.fixture` + "_pytest.fixtures.fixture" in decoratornames + # `@pytest.fixture(...)` + or "_pytest.fixtures.FixtureFunctionMarker" in decoratornames + ): + self._check_test_function(node, True) + return + matchers = self._function_matchers + # Check that common arguments are correctly typed. for arg_name, expected_type in _COMMON_ARGUMENTS.items(): arg_node, annotation = _get_named_annotation(node, arg_name) @@ -3217,13 +3254,6 @@ class HassTypeHintChecker(BaseChecker): args=(arg_name, expected_type, node.name), ) - # Check method or function matchers. - if node.is_method(): - matchers = _METHOD_MATCH - else: - matchers = self._function_matchers - if _is_test_function(self._module_name, node): - self._check_test_function(node, annotations) for match in matchers: if not match.need_to_check_function(node): continue @@ -3240,11 +3270,7 @@ class HassTypeHintChecker(BaseChecker): # Check that all positional arguments are correctly annotated. if match.arg_types: for key, expected_type in match.arg_types.items(): - if ( - node.args.args[key].name in _COMMON_ARGUMENTS - or _is_test_function(self._module_name, node) - and node.args.args[key].name in _TEST_FIXTURES - ): + if node.args.args[key].name in _COMMON_ARGUMENTS: # It has already been checked, avoid double-message continue if not _is_valid_type(expected_type, annotations[key]): @@ -3257,11 +3283,7 @@ class HassTypeHintChecker(BaseChecker): # Check that all keyword arguments are correctly annotated. if match.named_arg_types is not None: for arg_name, expected_type in match.named_arg_types.items(): - if ( - arg_name in _COMMON_ARGUMENTS - or _is_test_function(self._module_name, node) - and arg_name in _TEST_FIXTURES - ): + if arg_name in _COMMON_ARGUMENTS: # It has already been checked, avoid double-message continue arg_node, annotation = _get_named_annotation(node, arg_name) @@ -3290,19 +3312,23 @@ class HassTypeHintChecker(BaseChecker): args=(match.return_type or "None", node.name), ) - def _check_test_function( - self, node: nodes.FunctionDef, annotations: list[nodes.NodeNG | None] - ) -> None: - # Check the return type. - if not _is_valid_return_type(_TEST_FUNCTION_MATCH, node.returns): + def _check_test_function(self, node: nodes.FunctionDef, is_fixture: bool) -> None: + # Check the return type, should always be `None` for test_*** functions. + if not is_fixture and not _is_valid_type(None, node.returns, True): self.add_message( "hass-return-type", node=node, - args=(_TEST_FUNCTION_MATCH.return_type or "None", node.name), + args=("None", node.name), ) # Check that all positional arguments are correctly annotated. for arg_name, expected_type in _TEST_FIXTURES.items(): arg_node, annotation = _get_named_annotation(node, arg_name) + if arg_node and expected_type == "None" and not is_fixture: + self.add_message( + "hass-consider-usefixtures-decorator", + node=arg_node, + args=(arg_name, expected_type, node.name), + ) if arg_node and not _is_valid_type(expected_type, annotation): self.add_message( "hass-argument-type", diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index d8f85df011f..b4d30be483d 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -395,6 +395,38 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { } +# Blacklist of imports that should be using the namespace +@dataclass +class NamespaceAlias: + """Class for namespace imports.""" + + alias: str + names: set[str] # function names + + +_FORCE_NAMESPACE_IMPORT: dict[str, NamespaceAlias] = { + "homeassistant.helpers.area_registry": NamespaceAlias("ar", {"async_get"}), + "homeassistant.helpers.category_registry": NamespaceAlias("cr", {"async_get"}), + "homeassistant.helpers.device_registry": NamespaceAlias( + "dr", + { + "async_get", + "async_entries_for_config_entry", + }, + ), + "homeassistant.helpers.entity_registry": NamespaceAlias( + "er", + { + "async_get", + "async_entries_for_config_entry", + }, + ), + "homeassistant.helpers.floor_registry": NamespaceAlias("fr", {"async_get"}), + "homeassistant.helpers.issue_registry": NamespaceAlias("ir", {"async_get"}), + "homeassistant.helpers.label_registry": NamespaceAlias("lr", {"async_get"}), +} + + class HassImportsFormatChecker(BaseChecker): """Checker for imports.""" @@ -422,6 +454,12 @@ class HassImportsFormatChecker(BaseChecker): "Used when an import from another component should be " "from the component root", ), + "W7425": ( + "`%s` should not be imported directly. Please import `%s` as `%s` " + "and use `%s.%s`", + "hass-helper-namespace-import", + "Used when a helper should be used via the namespace", + ), } options = () @@ -524,6 +562,20 @@ class HassImportsFormatChecker(BaseChecker): node=node, args=(import_match.string, obsolete_import.reason), ) + if namespace_alias := _FORCE_NAMESPACE_IMPORT.get(node.modname): + for name in node.names: + if name[0] in namespace_alias.names: + self.add_message( + "hass-helper-namespace-import", + node=node, + args=( + name[0], + node.modname, + namespace_alias.alias, + namespace_alias.alias, + name[0], + ), + ) def register(linter: PyLinter) -> None: diff --git a/pyproject.toml b/pyproject.toml index c036daeb35e..9f83edd7f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0.dev0" +version = "2024.7.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -26,9 +26,9 @@ dependencies = [ "aiodns==3.2.0", "aiohttp==3.9.5", "aiohttp_cors==0.7.0", - "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-isal==0.3.1", + "aiohttp-fast-zlib==0.1.0", + "aiozoneinfo==0.2.0", "astral==2.2", "async-interrupt==1.1.1", "attrs==23.2.0", @@ -40,17 +40,17 @@ dependencies = [ "fnv-hash-fast==0.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.78.0", + "hass-nabucasa==0.81.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.0", - "home-assistant-bluetooth==1.12.0", + "home-assistant-bluetooth==1.12.1", "ifaddr==0.2.0", - "Jinja2==3.1.3", + "Jinja2==3.1.4", "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==42.0.5", + "cryptography==42.0.8", "Pillow==10.3.0", "pyOpenSSL==24.1.0", "orjson==3.9.15", @@ -59,9 +59,9 @@ dependencies = [ "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.1", - "requests==2.31.0", - "SQLAlchemy==2.0.29", - "typing-extensions>=4.11.0,<5.0", + "requests==2.32.3", + "SQLAlchemy==2.0.31", + "typing-extensions>=4.12.2,<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 @@ -93,9 +93,6 @@ include = ["homeassistant*"] [tool.pylint.MAIN] py-version = "3.12" -ignore = [ - "tests", -] # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs = 2 @@ -152,6 +149,7 @@ class-const-naming-style = "any" # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # consider-using-f-string - str.format sometimes more readable +# possibly-used-before-assignment - too many errors / not necessarily issues # --- # Pylint CodeStyle plugin # consider-using-namedtuple-or-dataclass - too opinionated @@ -176,6 +174,7 @@ disable = [ "consider-using-f-string", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", + "possibly-used-before-assignment", # Handled by ruff # Ref: @@ -310,6 +309,8 @@ disable = [ "no-else-continue", # RET507 "no-else-raise", # RET506 "no-else-return", # RET505 + "broad-except", # BLE001 + "protected-access", # SLF001 # "no-self-use", # PLR6301 # Optional plugin, not enabled # Handled by mypy @@ -398,10 +399,10 @@ enable = [ "use-symbolic-message-instead", ] per-file-ignores = [ - # hass-component-root-import: Tests test non-public APIs - # protected-access: Tests do often test internals a lot # redefined-outer-name: Tests reference fixtures in the test function - "/tests/:hass-component-root-import,protected-access,redefined-outer-name", + # use-implicit-booleaness-not-comparison: Tests need to validate that a list + # or a dict is returned + "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison", ] [tool.pylint.REPORTS] @@ -454,14 +455,17 @@ filterwarnings = [ # -- Tests # Ignore custom pytest marks "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", + # https://github.com/rokam/sunweg/blob/3.0.1/sunweg/plant.py#L96 - v3.0.1 - 2024-05-29 + "ignore:The '(kwh_per_kwp|performance_rate)' property is deprecated and will return 0:DeprecationWarning:tests.components.sunweg.test_init", # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.6/elkm1_lib/util.py#L8-L19 + # https://github.com/gwww/elkm1/blob/2.2.7/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.1/env_canada/ec_cache.py + # https://github.com/michaeldavie/env_canada/blob/v0.6.2/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 - # https://github.com/allenporter/ical/blob/7.0.3/ical/util.py#L20-L22 + # https://github.com/allenporter/ical/blob/8.0.0/ical/util.py#L20-L22 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", @@ -469,12 +473,14 @@ filterwarnings = [ # -- Setuptools DeprecationWarnings # https://github.com/googleapis/google-cloud-python/issues/11184 # https://github.com/zopefoundation/meta/issues/194 - "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", + # https://github.com/Azure/azure-sdk-for-python + "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", # -- tracked upstream / open PRs - # https://github.com/certbot/certbot/issues/9828 - v2.8.0 + # https://github.com/certbot/certbot/issues/9828 - v2.10.0 "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 + # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.42.0 + # https://github.com/influxdata/influxdb-client-python/pull/652 "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 "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", @@ -483,8 +489,6 @@ filterwarnings = [ "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", # -- fixed, waiting for release / update - # https://github.com/mkmer/AIOAladdinConnect/commit/8851fff4473d80d70ac518db2533f0fbef63b69c - >=0.2.0 - "ignore:module 'sre_constants' is deprecated:DeprecationWarning:AIOAladdinConnect", # 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/DataDog/datadogpy/pull/290 - >=0.23.0 @@ -493,6 +497,8 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat", # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api", + # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", # 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/majuss/lupupy/pull/15 - >0.3.2 @@ -502,6 +508,8 @@ filterwarnings = [ # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0 # https://github.com/eclipse/paho.mqtt.python/pull/665 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", + # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 + "ignore::DeprecationWarning:holidays", # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", @@ -511,14 +519,12 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:google.pubsub_v1.services.publisher.client", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", - # 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/timmo001/system-bridge-connector/pull/27 - >= 4.1.0 + # 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", + # https://github.com/timmo001/system-bridge-connector/pull/27 - >=4.1.0 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:systembridgeconnector.version", # https://github.com/jschlyter/ttls/commit/d64f1251397b8238cf6a35bea64784de25e3386c - >=1.8.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:ttls", - # 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", # -- fixed for Python 3.13 # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 @@ -534,11 +540,15 @@ filterwarnings = [ # 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", - # https://pypi.org/project/emulated-roku/ - v0.2.1 + # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19 # https://github.com/martonperei/emulated_roku "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", - # https://github.com/thecynic/pylutron - v0.2.10 + # https://github.com/thecynic/pylutron - v0.2.13 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", + # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", + # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 + "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser", @@ -547,37 +557,48 @@ filterwarnings = [ # - SyntaxWarnings # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 # https://github.com/koolsb/pyblackbird/pull/9 -> closed "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", - # https://github.com/pkkid/python-plexapi/pull/1244 - v4.15.11 -> new issue same file - # https://github.com/pkkid/python-plexapi/pull/1370 -> Not fixed here - "ignore:invalid escape sequence:SyntaxWarning:.*plexapi.base", # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", + # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 + # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 + "ignore:invalid escape sequence:SyntaxWarning:.*sanix", # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", + "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty + # https://pypi.org/project/vobject/ - v0.9.7 - 2024-03-25 + # https://github.com/py-vobject/vobject + "ignore:invalid escape sequence:SyntaxWarning:.*vobject.base", # - pkg_resources # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", + # https://pypi.org/project/habitipy/ - v0.3.1 - 2019-01-14 / 2024-04-28 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # https://pypi.org/project/velbus-aio/ - v2024.4.0 - # https://github.com/Cereal2nd/velbus-aio/blob/2024.4.0/velbusaio/handler.py#L13 + # https://pypi.org/project/velbus-aio/ - v2024.4.1 - 2024-04-07 + # https://github.com/Cereal2nd/velbus-aio/blob/2024.4.1/velbusaio/handler.py#L12 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler", # -- Python 3.13 # HomeAssistant "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api", + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor", + # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23 + # https://github.com/nextcord/nextcord/issues/1174 + # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5 + "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player", # https://pypi.org/project/pylutron/ - v0.2.12 - 2024-02-12 # https://github.com/thecynic/pylutron/issues/89 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pylutron", - # https://pypi.org/project/SpeechRecognition/ - v3.10.3 - 2024-03-30 - # https://github.com/Uberi/speech_recognition/blob/3.10.3/speech_recognition/__init__.py#L7 + # https://pypi.org/project/SpeechRecognition/ - v3.10.4 - 2024-05-05 + # https://github.com/Uberi/speech_recognition/blob/3.10.4/speech_recognition/__init__.py#L7 "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", # https://pypi.org/project/voip-utils/ - v0.1.0 - 2023-06-28 # https://github.com/home-assistant-libs/voip-utils/blob/v0.1.0/voip_utils/rtp_audio.py#L2 @@ -604,11 +625,9 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", # https://pypi.org/project/foobot_async/ - v1.0.0 - 2020-11-24 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", - # https://pypi.org/project/habitipy/ - v0.3.0 - 2019-01-14 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig", - # https://pypi.org/project/influxdb/ - v5.3.1 - 2020-11-11 (archived) + # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived) "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 @@ -650,16 +669,12 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", - # https://pypi.org/project/vilfo-api-client/ - v0.4.1 - 2021-11-06 - "ignore:Function 'semver.compare' is deprecated. Deprecated since version 3.0.0:PendingDeprecationWarning:.*vilfo.client", - # https://pypi.org/project/vobject/ - v0.9.6.1 - 2018-07-18 - "ignore:invalid escape sequence:SyntaxWarning:.*vobject.base", # https://pypi.org/project/webrtcvad/ - v2.0.10 - 2017-01-08 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:webrtcvad", ] [tool.ruff] -required-version = ">=0.4.3" +required-version = ">=0.4.8" [tool.ruff.lint] select = [ @@ -676,6 +691,7 @@ select = [ "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? "B904", # Use raise from to specify exception cause "B905", # zip() without an explicit strict= parameter + "BLE", "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings @@ -703,6 +719,7 @@ select = [ "RSE", # flake8-raise "RUF005", # Consider iterable unpacking instead of concatenation "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF010", # Use explicit conversion flag "RUF013", # PEP 484 prohibits implicit Optional "RUF018", # Avoid assignment expressions in assert statements "RUF019", # Unnecessary key check before dictionary access @@ -726,6 +743,7 @@ select = [ "S608", # hardcoded-sql-expression "S609", # unix-command-wildcard-injection "SIM", # flake8-simplify + "SLF", # flake8-self "SLOT", # flake8-slots "T100", # Trace found: {name} used "T20", # flake8-print @@ -752,21 +770,19 @@ ignore = [ "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "PT004", # Fixture {fixture} does not return anything, add leading underscore "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception - "PT012", # `pytest.raises()` block should contain a single simple statement "PT018", # Assertion should be broken down into multiple parts "RUF001", # String contains ambiguous unicode character. "RUF002", # Docstring contains ambiguous unicode character. "RUF003", # Comment contains ambiguous unicode character. "RUF015", # Prefer next(...) over single element slice "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly "SIM108", # Use ternary operator {contents} instead of if-else-block "SIM115", # Use context handler for opening files "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` - # Ignored due to incompatible with mypy: https://github.com/python/mypy/issues/15238 - "UP040", # Checks for use of TypeAlias annotation for declaring type aliases. # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", @@ -787,7 +803,6 @@ ignore = [ "PT019", "PYI024", # Use typing.NamedTuple instead of collections.namedtuple "RET503", - "RET502", "RET501", "TRY002", "TRY301" diff --git a/requirements.txt b/requirements.txt index df001251a04..4c5e349d8b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,9 +6,9 @@ aiodns==3.2.0 aiohttp==3.9.5 aiohttp_cors==0.7.0 -aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-isal==0.3.1 +aiohttp-fast-zlib==0.1.0 +aiozoneinfo==0.2.0 astral==2.2 async-interrupt==1.1.1 attrs==23.2.0 @@ -18,14 +18,14 @@ bcrypt==4.1.2 certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==0.5.0 -hass-nabucasa==0.78.0 +hass-nabucasa==0.81.1 httpx==0.27.0 -home-assistant-bluetooth==1.12.0 +home-assistant-bluetooth==1.12.1 ifaddr==0.2.0 -Jinja2==3.1.3 +Jinja2==3.1.4 lru-dict==1.3.0 PyJWT==2.8.0 -cryptography==42.0.5 +cryptography==42.0.8 Pillow==10.3.0 pyOpenSSL==24.1.0 orjson==3.9.15 @@ -34,9 +34,9 @@ pip>=21.3.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.1 -requests==2.31.0 -SQLAlchemy==2.0.29 -typing-extensions>=4.11.0,<5.0 +requests==2.32.3 +SQLAlchemy==2.0.31 +typing-extensions>=4.12.2,<5.0 ulid-transform==0.9.0 urllib3>=1.26.5,<2 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index ea80e424896..9c940ff410a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,10 +4,7 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.5.1 - -# homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.58 +AEMET-OpenData==0.5.2 # homeassistant.components.honeywell AIOSomecomfort==0.0.25 @@ -15,9 +12,6 @@ AIOSomecomfort==0.0.25 # homeassistant.components.adax Adax-local==0.1.5 -# homeassistant.components.ambiclimate -Ambiclimate==0.2.1 - # homeassistant.components.blinksticklight BlinkStick==1.2.0 @@ -45,7 +39,7 @@ Mastodon.py==1.8.1 Pillow==10.3.0 # homeassistant.components.plex -PlexAPI==4.15.12 +PlexAPI==4.15.13 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -65,6 +59,9 @@ PyFlume==0.6.5 # homeassistant.components.fronius PyFronius==0.7.3 +# homeassistant.components.pyload +PyLoadAPI==1.1.0 + # homeassistant.components.mvglive PyMVGLive==1.1.4 @@ -93,7 +90,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.45.0 +PySwitchbot==0.48.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -125,10 +122,10 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.29 +SQLAlchemy==2.0.31 # homeassistant.components.tami4 -Tami4EdgeAPI==2.1 +Tami4EdgeAPI==3.0 # homeassistant.components.travisci TravisPy==0.3.5 @@ -149,7 +146,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.2 +adext==0.4.3 # homeassistant.components.adguard adguardhome==0.6.3 @@ -185,10 +182,10 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.5.1 +aioairzone-cloud==0.5.3 # homeassistant.components.airzone -aioairzone==0.7.6 +aioairzone==0.7.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -197,6 +194,9 @@ aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 +# homeassistant.components.aquacell +aioaquacell==0.1.7 + # homeassistant.components.aseko_pool_live aioaseko==0.1.1 @@ -204,16 +204,16 @@ aioaseko==0.1.1 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.4.4 +aioautomower==2024.6.1 # homeassistant.components.azure_devops -aioazuredevops==2.0.0 +aioazuredevops==2.1.1 # homeassistant.components.baf aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.12.1 +aiobotocore==2.13.0 # homeassistant.components.comelit aiocomelit==0.9.0 @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==24.3.0 +aioesphomeapi==24.6.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -267,7 +267,7 @@ aiohomekit==3.1.5 aiohue==4.7.1 # homeassistant.components.imap -aioimaplib==1.0.1 +aioimaplib==1.1.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -293,6 +293,9 @@ aiolookin==1.0.0 # homeassistant.components.lyric aiolyric==1.1.0 +# homeassistant.components.mealie +aiomealie==0.4.0 + # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -359,7 +362,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==9.0.0 +aioshelly==10.0.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -374,7 +377,7 @@ aiosolaredge==0.2.0 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.4.1 +aioswitcher==3.4.3 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -386,16 +389,16 @@ aiotankerkoenig==0.4.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==77 +aiounifi==79 # homeassistant.components.vlc_telnet -aiovlc==0.1.0 +aiovlc==0.3.2 # homeassistant.components.vodafone_station -aiovodafone==0.5.4 +aiovodafone==0.6.0 # homeassistant.components.waqi -aiowaqi==3.0.1 +aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -404,16 +407,19 @@ aiowatttime==0.1.1 aiowebostv==0.4.0 # homeassistant.components.withings -aiowithings==2.1.0 +aiowithings==3.0.1 # homeassistant.components.yandex_transport aioymaps==1.2.2 +# homeassistant.components.airgradient +airgradient==0.6.0 + # homeassistant.components.airly airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.8.0 +airthings-ble==0.9.0 # homeassistant.components.airthings airthings-cloud==0.2.0 @@ -437,13 +443,13 @@ amcrest==1.9.8 androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.15 +androidtvremote2==0.1.1 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anova -anova-wifi==0.10.0 +anova-wifi==0.12.0 # homeassistant.components.anthemav anthemav==1.4.1 @@ -452,19 +458,22 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.7.4 +apprise==1.8.0 # homeassistant.components.aprs aprslib==0.7.2 +# homeassistant.components.apsystems +apsystems-ez1==1.3.1 + # homeassistant.components.aqualogic aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.3.3 +aranet4==2.3.4 # homeassistant.components.arcam_fmj -arcam-fmj==1.4.0 +arcam-fmj==1.5.2 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 @@ -516,11 +525,17 @@ axis==61 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.azure_data_explorer +azure-kusto-data[aio]==3.1.0 + +# homeassistant.components.azure_data_explorer +azure-kusto-ingest==3.1.0 + # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 # homeassistant.components.holiday -babel==2.13.1 +babel==2.15.0 # homeassistant.components.baidu baidu-aip==1.6.6 @@ -541,10 +556,10 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.38.4 +bellows==0.39.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.15.2 +bimmer-connected[china]==0.15.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -557,13 +572,13 @@ bleak-esphome==1.0.0 bleak-retry-connector==3.5.0 # homeassistant.components.bluetooth -bleak==0.21.1 +bleak==0.22.1 # homeassistant.components.blebox -blebox-uniapi==2.2.2 +blebox-uniapi==2.4.2 # homeassistant.components.blink -blinkpy==0.22.6 +blinkpy==0.23.0 # homeassistant.components.bitcoin blockchain==1.4.4 @@ -601,13 +616,13 @@ boschshcpy==0.2.91 boto3==1.34.51 # homeassistant.components.bring -bring-api==0.5.7 +bring-api==0.7.1 # homeassistant.components.broadlink broadlink==0.19.0 # homeassistant.components.brother -brother==4.1.0 +brother==4.2.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -619,7 +634,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.8.1 +bthome-ble==3.9.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -628,7 +643,7 @@ bthomehub5-devicelist==0.1.1 btsmarthub-devicelist==0.2.3 # homeassistant.components.buienradar -buienradar==1.0.5 +buienradar==1.0.6 # homeassistant.components.dhcp cached_ipaddress==0.3.0 @@ -636,9 +651,6 @@ cached_ipaddress==0.3.0 # homeassistant.components.caldav caldav==1.3.9 -# homeassistant.components.circuit -circuit-webhook==1.0.1 - # homeassistant.components.cisco_mobility_express ciscomobilityexpress==0.3.9 @@ -670,10 +682,10 @@ construct==2.10.68 croniter==2.0.2 # homeassistant.components.crownstone -crownstone-cloud==1.4.9 +crownstone-cloud==1.4.11 # homeassistant.components.crownstone -crownstone-sse==2.0.4 +crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 @@ -685,7 +697,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.21.1 +dbus-fast==2.21.3 # homeassistant.components.debugpy debugpy==1.8.1 @@ -697,7 +709,7 @@ debugpy==1.8.1 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==7.1.0 +deebot-client==8.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -780,7 +792,7 @@ eliqonline==1.2.2 elkm1-lib==2.2.7 # homeassistant.components.elmax -elmax-api==0.0.4 +elmax-api==0.0.5 # homeassistant.components.elvia elvia==0.1.0 @@ -804,7 +816,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.6.2 +env-canada==0.6.3 # homeassistant.components.season ephem==4.1.5 @@ -819,7 +831,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.1.6 +eq3btsmart==1.1.8 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 @@ -906,7 +918,7 @@ fyta_cli==0.4.1 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.1 +gardena-bluetooth==1.4.2 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 @@ -914,6 +926,9 @@ gassist-text==0.0.11 # homeassistant.components.google gcal-sync==6.0.4 +# homeassistant.components.aladdin_connect +genie-partner-sdk==1.0.2 + # homeassistant.components.geniushub geniushub-client==0.7.1 @@ -946,13 +961,13 @@ gios==4.0.0 gitterpy==0.1.7 # homeassistant.components.glances -glances-api==0.6.0 +glances-api==0.8.0 # homeassistant.components.goalzero goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.2 +goodwe==0.3.6 # homeassistant.components.google_mail # homeassistant.components.google_tasks @@ -965,10 +980,10 @@ google-cloud-pubsub==2.13.11 google-cloud-texttospeech==2.12.3 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.3.1 +google-generativeai==0.6.0 # homeassistant.components.nest -google-nest-sdm==3.0.4 +google-nest-sdm==4.0.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -977,13 +992,13 @@ googlemaps==2.5.1 goslide-api==0.5.1 # homeassistant.components.tailwind -gotailwind==0.2.2 +gotailwind==0.2.3 # homeassistant.components.govee_ble govee-ble==0.31.2 # homeassistant.components.govee_light_local -govee-local-api==1.4.5 +govee-local-api==1.5.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 @@ -1029,25 +1044,25 @@ ha-ffmpeg==3.2.0 ha-iotawattpy==0.1.2 # homeassistant.components.philips_js -ha-philipsjs==3.1.1 +ha-philipsjs==3.2.2 # homeassistant.components.habitica -habitipy==0.2.0 +habitipy==0.3.1 # homeassistant.components.bluetooth -habluetooth==3.0.1 +habluetooth==3.1.1 # homeassistant.components.cloud -hass-nabucasa==0.78.0 +hass-nabucasa==0.81.1 # homeassistant.components.splunk hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.6.1 +hassil==1.7.1 # homeassistant.components.jewish_calendar -hdate==0.10.4 +hdate==0.10.9 # homeassistant.components.heatmiser heatmiserV3==1.1.18 @@ -1075,19 +1090,19 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.47 +holidays==0.51 # homeassistant.components.frontend -home-assistant-frontend==20240501.0 +home-assistant-frontend==20240610.1 # homeassistant.components.conversation -home-assistant-intents==2024.4.24 +home-assistant-intents==2024.6.21 # homeassistant.components.home_connect homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.1.0 +homematicip==1.1.1 # homeassistant.components.horizon horimote==0.4.1 @@ -1119,13 +1134,13 @@ ibmiotf==0.3.4 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.0.0 +ical==8.0.1 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.5.1 +idasen-ha==2.5.3 # homeassistant.components.network ifaddr==0.2.0 @@ -1137,10 +1152,10 @@ iglo==1.2.7 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.1 +imgw_pib==1.0.5 # homeassistant.components.incomfort -incomfort-client==0.5.0 +incomfort-client==0.6.2 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -1160,6 +1175,9 @@ intellifire4py==2.2.2 # homeassistant.components.iperf3 iperf3==0.1.11 +# homeassistant.components.isal +isal==1.6.1 + # homeassistant.components.gogogate2 ismartgate==5.0.1 @@ -1190,6 +1208,9 @@ kegtron-ble==0.4.0 # homeassistant.components.kiwi kiwiki-client==0.1.1 +# homeassistant.components.knocki +knocki==0.1.5 + # homeassistant.components.knx knx-frontend==2024.1.20.105944 @@ -1245,7 +1266,7 @@ linear-garage-door==0.2.9 linode-api==4.1.9b1 # homeassistant.components.lamarzocco -lmcloud==0.4.35 +lmcloud==1.1.13 # homeassistant.components.google_maps locationsharinglib==5.0.1 @@ -1325,6 +1346,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.3.0 +# homeassistant.components.monzo +monzopy==1.3.0 + # homeassistant.components.mopeka mopeka-iot-ble==0.7.0 @@ -1332,7 +1356,7 @@ mopeka-iot-ble==0.7.0 motionblinds==0.6.23 # homeassistant.components.motionblinds_ble -motionblindsble==0.0.9 +motionblindsble==0.1.0 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -1371,7 +1395,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==3.0.1 +nettigo-air-monitor==3.2.0 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1447,7 +1471,7 @@ ollama-hass==0.1.7 omnilogic==0.4.5 # homeassistant.components.ondilo_ico -ondilo==0.4.0 +ondilo==0.5.0 # homeassistant.components.onkyo onkyo-eiscp==1.2.7 @@ -1486,7 +1510,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.4.4 +opower==0.4.7 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1519,7 +1543,7 @@ panasonic-viera==0.3.6 pdunehd==1.3.2 # homeassistant.components.peco -peco==0.0.29 +peco==0.0.30 # homeassistant.components.pencom pencompy==0.0.3 @@ -1551,7 +1575,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.37.3 +plugwise==0.37.4.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1604,7 +1628,7 @@ pvo==2.1.1 py-aosmith==1.0.8 # homeassistant.components.canary -py-canary==0.5.3 +py-canary==0.5.4 # homeassistant.components.ccm15 py-ccm15==0.0.9 @@ -1631,10 +1655,10 @@ py-nightscout==1.2.2 py-schluter==0.1.7 # homeassistant.components.ecovacs -py-sucks==0.9.9 +py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.4.2 +py-synologydsm-api==2.4.4 # homeassistant.components.zabbix py-zabbix==1.1.7 @@ -1652,10 +1676,10 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.3.2 +pyDuotecno==2024.5.1 # homeassistant.components.electrasmart -pyElectra==1.2.0 +pyElectra==1.2.3 # homeassistant.components.emby pyEmby==1.9 @@ -1679,7 +1703,7 @@ pyW215==0.7.0 pyW800rf32==0.4 # homeassistant.components.ads -pyads==3.2.2 +pyads==3.4.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 @@ -1764,7 +1788,7 @@ pydaikin==2.11.1 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==115 +pydeconz==116 # homeassistant.components.delijn pydelijn==1.1.0 @@ -1773,13 +1797,13 @@ pydelijn==1.1.0 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==3.0.0 +pydiscovergy==3.0.1 # homeassistant.components.doods pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.4.1 +pydrawise==2024.6.4 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1793,20 +1817,26 @@ pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 +# homeassistant.components.ista_ecotrend +pyecotrend-ista==3.3.1 + # homeassistant.components.edimax pyedimax==0.2.1 # homeassistant.components.efergy -pyefergy==22.1.1 +pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 +# homeassistant.components.emoncms +pyemoncms==0.0.7 + # homeassistant.components.enphase_envoy -pyenphase==1.20.1 +pyenphase==1.20.3 # homeassistant.components.envisalink -pyenvisalink==4.6 +pyenvisalink==4.7 # homeassistant.components.ephember pyephember==0.3.1 @@ -1878,7 +1908,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.3 +pyinsteon==1.6.1 # homeassistant.components.intesishome pyintesishome==1.8.0 @@ -1887,7 +1917,7 @@ pyintesishome==1.8.0 pyipma==3.0.7 # homeassistant.components.ipp -pyipp==0.15.0 +pyipp==0.16.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1938,7 +1968,7 @@ pylacrosse==0.4 pylast==5.1.0 # homeassistant.components.launch_library -pylaunches==1.4.0 +pylaunches==2.0.0 # homeassistant.components.lg_netcast pylgnetcast==0.3.9 @@ -1956,7 +1986,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.20.0 # homeassistant.components.lutron -pylutron==0.2.12 +pylutron==0.2.13 # homeassistant.components.mailgun pymailgunner==1.4 @@ -2004,7 +2034,7 @@ pynobo==1.8.1 pynuki==1.6.3 # homeassistant.components.nws -pynws[retry]==1.7.0 +pynws[retry]==1.8.2 # homeassistant.components.nx584 pynx584==0.5 @@ -2024,6 +2054,9 @@ pyombi==0.1.10 # homeassistant.components.openuv pyopenuv==2023.02.0 +# homeassistant.components.openweathermap +pyopenweathermap==0.0.9 + # homeassistant.components.opnsense pyopnsense==0.4.0 @@ -2031,7 +2064,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.3 +pyosoenergyapi==1.1.4 # homeassistant.components.opentherm_gw pyotgw==2.2.0 @@ -2042,10 +2075,7 @@ pyotgw==2.2.0 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.10 - -# homeassistant.components.openweathermap -pyowm==3.2.0 +pyoverkiz==1.13.11 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -2087,7 +2117,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==4.0.2 +pyrainbird==6.0.1 # homeassistant.components.recswitch pyrecswitch==1.0.2 @@ -2096,7 +2126,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.1 +pyrisco==0.6.4 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -2105,7 +2135,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.7 +pyrympro==0.0.8 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 @@ -2114,7 +2144,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2024.2.0 +pyschlage==2024.6.0 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -2137,7 +2167,7 @@ pysesame2==1.0.1 pysiaalarm==3.1.1 # homeassistant.components.signal_messenger -pysignalclirestapi==0.3.23 +pysignalclirestapi==0.3.24 # homeassistant.components.sky_hub pyskyqhub==0.1.4 @@ -2194,7 +2224,7 @@ pytfiac==0.4 pythinkingcleaner==0.0.3 # homeassistant.components.motionmount -python-MotionMount==1.0.0 +python-MotionMount==2.0.0 # homeassistant.components.awair python-awair==0.2.4 @@ -2221,7 +2251,7 @@ python-etherscan-api==0.0.3 python-family-hub-local==0.0.2 # homeassistant.components.fully_kiosk -python-fullykiosk==0.0.12 +python-fullykiosk==0.0.13 # homeassistant.components.sms # python-gammu==3.2.4 @@ -2236,7 +2266,7 @@ python-gitlab==1.6.0 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==v5.0.0 +python-homewizard-energy==v6.0.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -2257,7 +2287,7 @@ python-kasa[speedups]==0.6.2.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.10.0 +python-matter-server==6.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2281,9 +2311,6 @@ python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 -# homeassistant.components.qbittorrent -python-qbittorrent==0.4.3 - # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2291,7 +2318,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.0.0 +python-roborock==2.3.0 # homeassistant.components.smarttub python-smarttub==0.0.36 @@ -2300,7 +2327,7 @@ python-smarttub==0.0.36 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.4 +python-tado==0.17.6 # homeassistant.components.technove python-technove==1.2.2 @@ -2337,14 +2364,11 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.10 # homeassistant.components.v2c -pytrydan==0.4.0 +pytrydan==0.7.0 # homeassistant.components.usb pyudev==0.24.1 -# homeassistant.components.unifiprotect -pyunifiprotect==5.1.2 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2402,6 +2426,9 @@ pyzbar==0.1.7 # homeassistant.components.zerproc pyzerproc==0.4.8 +# homeassistant.components.qbittorrent +qbittorrent-api==2024.2.59 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -2427,19 +2454,19 @@ rapt-ble==0.1.2 raspyrfm-client==1.2.8 # homeassistant.components.refoss -refoss-ha==1.2.0 +refoss-ha==1.2.1 # homeassistant.components.rainmachine regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.2 +renault-api==0.2.3 # homeassistant.components.renson renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.9 +reolink-aio==0.9.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2460,7 +2487,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.2 +rokuecp==0.19.3 # homeassistant.components.romy romy==0.0.10 @@ -2572,13 +2599,16 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.3 +soco==0.30.4 # homeassistant.components.solaredge_local solaredge-local==0.2.3 +# homeassistant.components.solarlog +solarlog_cli==0.1.5 + # homeassistant.components.solax -solax==3.1.0 +solax==3.1.1 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -2635,13 +2665,10 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.9 - -# homeassistant.components.solarlog -sunwatcher==0.2.1 +subarulink==0.7.11 # homeassistant.components.sunweg -sunweg==2.1.1 +sunweg==3.0.1 # homeassistant.components.surepetcare surepy==0.9.0 @@ -2689,10 +2716,10 @@ temperusb==1.6.1 # tensorflow==2.5.0 # homeassistant.components.teslemetry -tesla-fleet-api==0.4.9 +tesla-fleet-api==0.6.1 # homeassistant.components.powerwall -tesla-powerwall==0.5.1 +tesla-powerwall==0.5.2 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 @@ -2748,6 +2775,9 @@ transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 +# homeassistant.components.thethingsnetwork +ttn_client==1.0.0 + # homeassistant.components.tuya tuya-device-sharing-sdk==0.1.9 @@ -2763,6 +2793,9 @@ twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.unifiprotect +uiprotect==1.20.0 + # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2776,13 +2809,13 @@ unifi_ap==0.0.1 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.18 +universal-silabs-flasher==0.0.20 # homeassistant.components.upb upb-lib==0.5.6 # homeassistant.components.upcloud -upcloud-api==2.0.0 +upcloud-api==2.5.1 # homeassistant.components.huawei_lte # homeassistant.components.syncthru @@ -2802,7 +2835,7 @@ vallox-websocket-api==5.1.1 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2024.4.1 +velbus-aio==2024.5.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2816,6 +2849,10 @@ voip-utils==0.1.0 # homeassistant.components.volkszaehler volkszaehler==0.4.0 +# homeassistant.components.google_generative_ai_conversation +# homeassistant.components.openai_conversation +voluptuous-openapi==0.0.4 + # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2845,7 +2882,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.20 +weatherflow4py==0.2.21 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 @@ -2866,19 +2903,19 @@ wiffi==1.1.2 wirelesstagpy==0.8.1 # homeassistant.components.wled -wled==0.17.0 +wled==0.18.0 # homeassistant.components.wolflink -wolf-comm==0.0.7 +wolf-comm==0.0.8 # homeassistant.components.wyoming -wyoming==1.5.3 +wyoming==1.5.4 # homeassistant.components.xbox xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.28.0 +xiaomi-ble==0.30.0 # homeassistant.components.knx xknx==2.12.2 @@ -2905,7 +2942,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.4.2 # homeassistant.components.august -yalexs==3.1.0 +yalexs==6.4.0 # homeassistant.components.yeelight yeelight==0.7.14 @@ -2914,16 +2951,16 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.3 +yolink-api==0.4.4 # homeassistant.components.youless -youless-api==1.0.1 +youless-api==2.1.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.04.09 +yt-dlp==2024.05.27 # homeassistant.components.zamg zamg==0.3.6 @@ -2938,7 +2975,7 @@ zeroconf==0.132.2 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.115 +zha-quirks==0.0.116 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.12 @@ -2959,13 +2996,13 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.64.0 +zigpy==0.64.1 # homeassistant.components.zoneminder zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.4 +zwave-js-server-python==0.57.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index e932e9ff6ab..fce669c4929 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.1.0 -coverage==7.5.0 +astroid==3.2.2 +coverage==7.5.3 freezegun==1.5.0 mock-open==1.4.0 -mypy==1.10.0 -pre-commit==3.7.0 -pydantic==1.10.15 -pylint==3.1.0 +mypy-dev==1.11.0a8 +pre-commit==3.7.1 +pydantic==1.10.17 +pylint==3.2.2 pylint-per-file-ignores==1.3.2 pipdeptree==2.19.0 pytest-asyncio==0.23.6 @@ -32,22 +32,22 @@ pytest==8.2.0 requests-mock==1.12.1 respx==0.21.1 syrupy==4.6.1 -tqdm==4.66.2 +tqdm==4.66.4 types-aiofiles==23.2.0.20240403 types-atomicwrites==1.4.5.1 types-croniter==2.0.0.20240423 -types-beautifulsoup4==4.12.0.20240229 +types-beautifulsoup4==4.12.0.20240511 types-caldav==1.3.0.20240331 types-chardet==0.1.5 types-decorator==5.1.8.20240310 types-paho-mqtt==1.6.0.20240321 -types-pillow==10.2.0.20240423 +types-pillow==10.2.0.20240511 types-protobuf==4.24.0.20240106 -types-psutil==5.9.5.20240423 +types-psutil==5.9.5.20240511 types-python-dateutil==2.9.0.20240316 types-python-slugify==8.0.2.20240310 types-pytz==2024.1.0.20240417 types-PyYAML==6.0.12.20240311 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 -uv==0.1.39 +uv==0.2.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 245387d3723..0d3112c7aba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,10 +4,7 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.5.1 - -# homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.58 +AEMET-OpenData==0.5.2 # homeassistant.components.honeywell AIOSomecomfort==0.0.25 @@ -15,9 +12,6 @@ AIOSomecomfort==0.0.25 # homeassistant.components.adax Adax-local==0.1.5 -# homeassistant.components.ambiclimate -Ambiclimate==0.2.1 - # homeassistant.components.doorbird DoorBirdPy==2.1.0 @@ -39,7 +33,7 @@ HATasmota==0.8.0 Pillow==10.3.0 # homeassistant.components.plex -PlexAPI==4.15.12 +PlexAPI==4.15.13 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -56,6 +50,9 @@ PyFlume==0.6.5 # homeassistant.components.fronius PyFronius==0.7.3 +# homeassistant.components.pyload +PyLoadAPI==1.1.0 + # homeassistant.components.met_eireann PyMetEireann==2021.8.0 @@ -81,7 +78,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.45.0 +PySwitchbot==0.48.0 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -110,10 +107,10 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.29 +SQLAlchemy==2.0.31 # homeassistant.components.tami4 -Tami4EdgeAPI==2.1 +Tami4EdgeAPI==3.0 # homeassistant.components.onvif WSDiscovery==2.0.0 @@ -128,7 +125,7 @@ adax==0.4.0 adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder -adext==0.4.2 +adext==0.4.3 # homeassistant.components.adguard adguardhome==0.6.3 @@ -164,10 +161,10 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.5.1 +aioairzone-cloud==0.5.3 # homeassistant.components.airzone -aioairzone==0.7.6 +aioairzone==0.7.7 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -176,6 +173,9 @@ aioambient==2024.01.0 # homeassistant.components.apcupsd aioapcaccess==0.4.2 +# homeassistant.components.aquacell +aioaquacell==0.1.7 + # homeassistant.components.aseko_pool_live aioaseko==0.1.1 @@ -183,16 +183,16 @@ aioaseko==0.1.1 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.4.4 +aioautomower==2024.6.1 # homeassistant.components.azure_devops -aioazuredevops==2.0.0 +aioazuredevops==2.1.1 # homeassistant.components.baf aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.12.1 +aiobotocore==2.13.0 # homeassistant.components.comelit aiocomelit==0.9.0 @@ -222,7 +222,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==24.3.0 +aioesphomeapi==24.6.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -243,7 +243,7 @@ aiohomekit==3.1.5 aiohue==4.7.1 # homeassistant.components.imap -aioimaplib==1.0.1 +aioimaplib==1.1.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 @@ -266,6 +266,9 @@ aiolookin==1.0.0 # homeassistant.components.lyric aiolyric==1.1.0 +# homeassistant.components.mealie +aiomealie==0.4.0 + # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -332,7 +335,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==9.0.0 +aioshelly==10.0.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -347,7 +350,7 @@ aiosolaredge==0.2.0 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.4.1 +aioswitcher==3.4.3 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -359,16 +362,16 @@ aiotankerkoenig==0.4.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==77 +aiounifi==79 # homeassistant.components.vlc_telnet -aiovlc==0.1.0 +aiovlc==0.3.2 # homeassistant.components.vodafone_station -aiovodafone==0.5.4 +aiovodafone==0.6.0 # homeassistant.components.waqi -aiowaqi==3.0.1 +aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 @@ -377,16 +380,19 @@ aiowatttime==0.1.1 aiowebostv==0.4.0 # homeassistant.components.withings -aiowithings==2.1.0 +aiowithings==3.0.1 # homeassistant.components.yandex_transport aioymaps==1.2.2 +# homeassistant.components.airgradient +airgradient==0.6.0 + # homeassistant.components.airly airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.8.0 +airthings-ble==0.9.0 # homeassistant.components.airthings airthings-cloud==0.2.0 @@ -404,10 +410,10 @@ amberelectric==1.1.0 androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.15 +androidtvremote2==0.1.1 # homeassistant.components.anova -anova-wifi==0.10.0 +anova-wifi==0.12.0 # homeassistant.components.anthemav anthemav==1.4.1 @@ -416,16 +422,19 @@ anthemav==1.4.1 apple_weatherkit==1.1.2 # homeassistant.components.apprise -apprise==1.7.4 +apprise==1.8.0 # homeassistant.components.aprs aprslib==0.7.2 +# homeassistant.components.apsystems +apsystems-ez1==1.3.1 + # homeassistant.components.aranet -aranet4==2.3.3 +aranet4==2.3.4 # homeassistant.components.arcam_fmj -arcam-fmj==1.4.0 +arcam-fmj==1.5.2 # homeassistant.components.asterisk_mbox asterisk_mbox==0.5.0 @@ -456,8 +465,14 @@ axis==61 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.azure_data_explorer +azure-kusto-data[aio]==3.1.0 + +# homeassistant.components.azure_data_explorer +azure-kusto-ingest==3.1.0 + # homeassistant.components.holiday -babel==2.13.1 +babel==2.15.0 # homeassistant.components.homekit base36==0.1.1 @@ -466,10 +481,10 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.zha -bellows==0.38.4 +bellows==0.39.1 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.15.2 +bimmer-connected[china]==0.15.3 # homeassistant.components.eq3btsmart # homeassistant.components.esphome @@ -479,13 +494,13 @@ bleak-esphome==1.0.0 bleak-retry-connector==3.5.0 # homeassistant.components.bluetooth -bleak==0.21.1 +bleak==0.22.1 # homeassistant.components.blebox -blebox-uniapi==2.2.2 +blebox-uniapi==2.4.2 # homeassistant.components.blink -blinkpy==0.22.6 +blinkpy==0.23.0 # homeassistant.components.blue_current bluecurrent-api==1.2.3 @@ -512,13 +527,13 @@ bond-async==0.2.1 boschshcpy==0.2.91 # homeassistant.components.bring -bring-api==0.5.7 +bring-api==0.7.1 # homeassistant.components.broadlink broadlink==0.19.0 # homeassistant.components.brother -brother==4.1.0 +brother==4.2.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -527,10 +542,10 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.8.1 +bthome-ble==3.9.1 # homeassistant.components.buienradar -buienradar==1.0.5 +buienradar==1.0.6 # homeassistant.components.dhcp cached_ipaddress==0.3.0 @@ -554,10 +569,10 @@ construct==2.10.68 croniter==2.0.2 # homeassistant.components.crownstone -crownstone-cloud==1.4.9 +crownstone-cloud==1.4.11 # homeassistant.components.crownstone -crownstone-sse==2.0.4 +crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 @@ -569,13 +584,13 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.21.1 +dbus-fast==2.21.3 # homeassistant.components.debugpy debugpy==1.8.1 # homeassistant.components.ecovacs -deebot-client==7.1.0 +deebot-client==8.0.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -640,7 +655,7 @@ elgato==5.1.2 elkm1-lib==2.2.7 # homeassistant.components.elmax -elmax-api==0.0.4 +elmax-api==0.0.5 # homeassistant.components.elvia elvia==0.1.0 @@ -658,7 +673,7 @@ energyzero==2.1.0 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.6.2 +env-canada==0.6.3 # homeassistant.components.season ephem==4.1.5 @@ -673,7 +688,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.1.6 +eq3btsmart==1.1.8 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 @@ -741,7 +756,7 @@ fyta_cli==0.4.1 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.1 +gardena-bluetooth==1.4.2 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 @@ -749,6 +764,9 @@ gassist-text==0.0.11 # homeassistant.components.google gcal-sync==6.0.4 +# homeassistant.components.aladdin_connect +genie-partner-sdk==1.0.2 + # homeassistant.components.geocaching geocachingapi==0.2.1 @@ -775,13 +793,13 @@ getmac==0.9.4 gios==4.0.0 # homeassistant.components.glances -glances-api==0.6.0 +glances-api==0.8.0 # homeassistant.components.goalzero goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.2 +goodwe==0.3.6 # homeassistant.components.google_mail # homeassistant.components.google_tasks @@ -791,22 +809,22 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.13.11 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.3.1 +google-generativeai==0.6.0 # homeassistant.components.nest -google-nest-sdm==3.0.4 +google-nest-sdm==4.0.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 # homeassistant.components.tailwind -gotailwind==0.2.2 +gotailwind==0.2.3 # homeassistant.components.govee_ble govee-ble==0.31.2 # homeassistant.components.govee_light_local -govee-local-api==1.4.5 +govee-local-api==1.5.0 # homeassistant.components.gpsd gps3==0.33.3 @@ -843,22 +861,22 @@ ha-ffmpeg==3.2.0 ha-iotawattpy==0.1.2 # homeassistant.components.philips_js -ha-philipsjs==3.1.1 +ha-philipsjs==3.2.2 # homeassistant.components.habitica -habitipy==0.2.0 +habitipy==0.3.1 # homeassistant.components.bluetooth -habluetooth==3.0.1 +habluetooth==3.1.1 # homeassistant.components.cloud -hass-nabucasa==0.78.0 +hass-nabucasa==0.81.1 # homeassistant.components.conversation -hassil==1.6.1 +hassil==1.7.1 # homeassistant.components.jewish_calendar -hdate==0.10.4 +hdate==0.10.9 # homeassistant.components.here_travel_time here-routing==0.2.0 @@ -877,19 +895,19 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.47 +holidays==0.51 # homeassistant.components.frontend -home-assistant-frontend==20240501.0 +home-assistant-frontend==20240610.1 # homeassistant.components.conversation -home-assistant-intents==2024.4.24 +home-assistant-intents==2024.6.21 # homeassistant.components.home_connect homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.1.0 +homematicip==1.1.1 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -912,19 +930,22 @@ ibeacon-ble==1.2.0 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==8.0.0 +ical==8.0.1 # homeassistant.components.ping icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==2.5.1 +idasen-ha==2.5.3 # homeassistant.components.network ifaddr==0.2.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.1 +imgw_pib==1.0.5 + +# homeassistant.components.incomfort +incomfort-client==0.6.2 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -941,6 +962,9 @@ insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==2.2.2 +# homeassistant.components.isal +isal==1.6.1 + # homeassistant.components.gogogate2 ismartgate==5.0.1 @@ -962,6 +986,9 @@ justnimbus==0.7.3 # homeassistant.components.kegtron kegtron-ble==0.4.0 +# homeassistant.components.knocki +knocki==0.1.5 + # homeassistant.components.knx knx-frontend==2024.1.20.105944 @@ -999,7 +1026,7 @@ libsoundtouch==0.8 linear-garage-door==0.2.9 # homeassistant.components.lamarzocco -lmcloud==0.4.35 +lmcloud==1.1.13 # homeassistant.components.logi_circle logi-circle==0.2.3 @@ -1067,6 +1094,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.3.0 +# homeassistant.components.monzo +monzopy==1.3.0 + # homeassistant.components.mopeka mopeka-iot-ble==0.7.0 @@ -1074,7 +1104,7 @@ mopeka-iot-ble==0.7.0 motionblinds==0.6.23 # homeassistant.components.motionblinds_ble -motionblindsble==0.0.9 +motionblindsble==0.1.0 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -1107,7 +1137,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==3.0.1 +nettigo-air-monitor==3.2.0 # homeassistant.components.nexia nexia==2.0.8 @@ -1162,7 +1192,7 @@ ollama-hass==0.1.7 omnilogic==0.4.5 # homeassistant.components.ondilo_ico -ondilo==0.4.0 +ondilo==0.5.0 # homeassistant.components.onvif onvif-zeep-async==3.1.12 @@ -1186,7 +1216,7 @@ openhomedevice==2.2.0 openwebifpy==4.2.4 # homeassistant.components.opower -opower==0.4.4 +opower==0.4.7 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1210,7 +1240,7 @@ panasonic-viera==0.3.6 pdunehd==1.3.2 # homeassistant.components.peco -peco==0.0.29 +peco==0.0.30 # homeassistant.components.escea pescea==1.0.12 @@ -1228,7 +1258,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.37.3 +plugwise==0.37.4.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1269,7 +1299,7 @@ pvo==2.1.1 py-aosmith==1.0.8 # homeassistant.components.canary -py-canary==0.5.3 +py-canary==0.5.4 # homeassistant.components.ccm15 py-ccm15==0.0.9 @@ -1293,10 +1323,10 @@ py-nextbusnext==1.0.2 py-nightscout==1.2.2 # homeassistant.components.ecovacs -py-sucks==0.9.9 +py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.4.2 +py-synologydsm-api==2.4.4 # homeassistant.components.seventeentrack py17track==2021.12.2 @@ -1308,10 +1338,10 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2024.3.2 +pyDuotecno==2024.5.1 # homeassistant.components.electrasmart -pyElectra==1.2.0 +pyElectra==1.2.3 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 @@ -1381,16 +1411,16 @@ pycsspeechtts==1.0.8 pydaikin==2.11.1 # homeassistant.components.deconz -pydeconz==115 +pydeconz==116 # homeassistant.components.dexcom pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==3.0.0 +pydiscovergy==3.0.1 # homeassistant.components.hydrawise -pydrawise==2024.4.1 +pydrawise==2024.6.4 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 @@ -1401,14 +1431,17 @@ pyecoforest==0.4.0 # homeassistant.components.econet pyeconet==0.1.22 +# homeassistant.components.ista_ecotrend +pyecotrend-ista==3.3.1 + # homeassistant.components.efergy -pyefergy==22.1.1 +pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 # homeassistant.components.enphase_envoy -pyenphase==1.20.1 +pyenphase==1.20.3 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1468,13 +1501,13 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.5.3 +pyinsteon==1.6.1 # homeassistant.components.ipma pyipma==3.0.7 # homeassistant.components.ipp -pyipp==0.15.0 +pyipp==0.16.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1513,7 +1546,7 @@ pykulersky==0.5.2 pylast==5.1.0 # homeassistant.components.launch_library -pylaunches==1.4.0 +pylaunches==2.0.0 # homeassistant.components.lg_netcast pylgnetcast==0.3.9 @@ -1531,7 +1564,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.20.0 # homeassistant.components.lutron -pylutron==0.2.12 +pylutron==0.2.13 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1567,7 +1600,7 @@ pynobo==1.8.1 pynuki==1.6.3 # homeassistant.components.nws -pynws[retry]==1.7.0 +pynws[retry]==1.8.2 # homeassistant.components.nx584 pynx584==0.5 @@ -1584,11 +1617,14 @@ pyoctoprintapi==0.1.12 # homeassistant.components.openuv pyopenuv==2023.02.0 +# homeassistant.components.openweathermap +pyopenweathermap==0.0.9 + # homeassistant.components.opnsense pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.3 +pyosoenergyapi==1.1.4 # homeassistant.components.opentherm_gw pyotgw==2.2.0 @@ -1599,10 +1635,7 @@ pyotgw==2.2.0 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.10 - -# homeassistant.components.openweathermap -pyowm==3.2.0 +pyoverkiz==1.13.11 # homeassistant.components.onewire pyownet==0.10.0.post1 @@ -1635,10 +1668,10 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.rainbird -pyrainbird==4.0.2 +pyrainbird==6.0.1 # homeassistant.components.risco -pyrisco==0.6.1 +pyrisco==0.6.4 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 @@ -1647,13 +1680,13 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.7 +pyrympro==0.0.8 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2024.2.0 +pyschlage==2024.6.0 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -1673,7 +1706,7 @@ pyserial==3.5 pysiaalarm==3.1.1 # homeassistant.components.signal_messenger -pysignalclirestapi==0.3.23 +pysignalclirestapi==0.3.24 # homeassistant.components.sma pysma==0.7.3 @@ -1718,7 +1751,7 @@ pytautulli==23.1.1 pytedee-async==0.2.17 # homeassistant.components.motionmount -python-MotionMount==1.0.0 +python-MotionMount==2.0.0 # homeassistant.components.awair python-awair==0.2.4 @@ -1730,7 +1763,7 @@ python-bsblan==0.5.18 python-ecobee-api==0.2.18 # homeassistant.components.fully_kiosk -python-fullykiosk==0.0.12 +python-fullykiosk==0.0.13 # homeassistant.components.sms # python-gammu==3.2.4 @@ -1739,7 +1772,7 @@ python-fullykiosk==0.0.12 python-homeassistant-analytics==0.6.0 # homeassistant.components.homewizard -python-homewizard-energy==v5.0.0 +python-homewizard-energy==v6.0.0 # homeassistant.components.izone python-izone==1.2.9 @@ -1751,11 +1784,14 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==5.10.0 +python-matter-server==6.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 +# homeassistant.components.mpd +python-mpd2==3.1.1 + # homeassistant.components.mystrom python-mystrom==2.2.0 @@ -1772,14 +1808,11 @@ python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 -# homeassistant.components.qbittorrent -python-qbittorrent==0.4.3 - # homeassistant.components.rabbitair python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.0.0 +python-roborock==2.3.0 # homeassistant.components.smarttub python-smarttub==0.0.36 @@ -1788,7 +1821,7 @@ python-smarttub==0.0.36 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.4 +python-tado==0.17.6 # homeassistant.components.technove python-technove==1.2.2 @@ -1816,14 +1849,11 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.10 # homeassistant.components.v2c -pytrydan==0.4.0 +pytrydan==0.7.0 # homeassistant.components.usb pyudev==0.24.1 -# homeassistant.components.unifiprotect -pyunifiprotect==5.1.2 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1869,6 +1899,9 @@ pyyardian==1.1.1 # homeassistant.components.zerproc pyzerproc==0.4.8 +# homeassistant.components.qbittorrent +qbittorrent-api==2024.2.59 + # homeassistant.components.qingping qingping-ble==0.10.0 @@ -1885,19 +1918,19 @@ radiotherm==2.1.0 rapt-ble==0.1.2 # homeassistant.components.refoss -refoss-ha==1.2.0 +refoss-ha==1.2.1 # homeassistant.components.rainmachine regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.2 +renault-api==0.2.3 # homeassistant.components.renson renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.9 +reolink-aio==0.9.3 # homeassistant.components.rflink rflink==0.0.66 @@ -1906,7 +1939,7 @@ rflink==0.0.66 ring-doorbell[listen]==0.8.11 # homeassistant.components.roku -rokuecp==0.19.2 +rokuecp==0.19.3 # homeassistant.components.romy romy==0.0.10 @@ -1991,10 +2024,13 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.3 +soco==0.30.4 + +# homeassistant.components.solarlog +solarlog_cli==0.1.5 # homeassistant.components.solax -solax==3.1.0 +solax==3.1.1 # homeassistant.components.somfy_mylink somfy-mylink-synergy==1.0.6 @@ -2048,13 +2084,10 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.9 - -# homeassistant.components.solarlog -sunwatcher==0.2.1 +subarulink==0.7.11 # homeassistant.components.sunweg -sunweg==2.1.1 +sunweg==3.0.1 # homeassistant.components.surepetcare surepy==0.9.0 @@ -2081,10 +2114,10 @@ temescal==0.5 temperusb==1.6.1 # homeassistant.components.teslemetry -tesla-fleet-api==0.4.9 +tesla-fleet-api==0.6.1 # homeassistant.components.powerwall -tesla-powerwall==0.5.1 +tesla-powerwall==0.5.2 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 @@ -2122,6 +2155,9 @@ transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 +# homeassistant.components.thethingsnetwork +ttn_client==1.0.0 + # homeassistant.components.tuya tuya-device-sharing-sdk==0.1.9 @@ -2137,6 +2173,9 @@ twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.unifiprotect +uiprotect==1.20.0 + # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 @@ -2144,13 +2183,13 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.8 # homeassistant.components.zha -universal-silabs-flasher==0.0.18 +universal-silabs-flasher==0.0.20 # homeassistant.components.upb upb-lib==0.5.6 # homeassistant.components.upcloud -upcloud-api==2.0.0 +upcloud-api==2.5.1 # homeassistant.components.huawei_lte # homeassistant.components.syncthru @@ -2170,7 +2209,7 @@ vallox-websocket-api==5.1.1 vehicle==2.2.1 # homeassistant.components.velbus -velbus-aio==2024.4.1 +velbus-aio==2024.5.1 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2181,6 +2220,10 @@ vilfo-api-client==0.5.0 # homeassistant.components.voip voip-utils==0.1.0 +# homeassistant.components.google_generative_ai_conversation +# homeassistant.components.openai_conversation +voluptuous-openapi==0.0.4 + # homeassistant.components.volvooncall volvooncall==0.10.3 @@ -2204,7 +2247,7 @@ wallbox==0.6.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.20 +weatherflow4py==0.2.21 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 @@ -2222,19 +2265,19 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wled -wled==0.17.0 +wled==0.18.0 # homeassistant.components.wolflink -wolf-comm==0.0.7 +wolf-comm==0.0.8 # homeassistant.components.wyoming -wyoming==1.5.3 +wyoming==1.5.4 # homeassistant.components.xbox xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.28.0 +xiaomi-ble==0.30.0 # homeassistant.components.knx xknx==2.12.2 @@ -2258,22 +2301,22 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.4.2 # homeassistant.components.august -yalexs==3.1.0 +yalexs==6.4.0 # homeassistant.components.yeelight yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.4.3 +yolink-api==0.4.4 # homeassistant.components.youless -youless-api==1.0.1 +youless-api==2.1.0 # homeassistant.components.youtube youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.04.09 +yt-dlp==2024.05.27 # homeassistant.components.zamg zamg==0.3.6 @@ -2285,7 +2328,7 @@ zeroconf==0.132.2 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.115 +zha-quirks==0.0.116 # homeassistant.components.zha zigpy-deconz==0.23.1 @@ -2300,10 +2343,10 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.64.0 +zigpy==0.64.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.4 +zwave-js-server-python==0.57.0 # 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 de3776d7416..a7e5c20d86c 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.6 -ruff==0.4.3 +codespell==2.3.0 +ruff==0.4.9 yamllint==1.35.1 diff --git a/script/bootstrap b/script/bootstrap index 46a5975eff5..e60342563ac 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -7,6 +7,6 @@ set -e cd "$(dirname "$0")/.." echo "Installing development dependencies..." -python3 -m pip install wheel --constraint homeassistant/package_constraints.txt --upgrade -python3 -m pip install colorlog pre-commit $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --upgrade -python3 -m pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --upgrade +uv pip install wheel --constraint homeassistant/package_constraints.txt --upgrade +uv pip install colorlog $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --upgrade +uv pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --upgrade diff --git a/script/countries.py b/script/countries.py index d67caa4da65..b6ec99c9e28 100644 --- a/script/countries.py +++ b/script/countries.py @@ -24,5 +24,6 @@ Path("homeassistant/generated/countries.py").write_text( "COUNTRIES": countries, }, generator=generator_string, + annotations={"COUNTRIES": "Final[set[str]]"}, ) ) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d0341aab03e..eff61d5c4e6 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -129,7 +129,7 @@ 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.3.0 +anyio==4.4.0 h11==0.14.0 httpcore==1.0.5 @@ -155,7 +155,7 @@ backoff>=2.0 # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.15 +pydantic==1.10.17 # Breaks asyncio # https://github.com/pubnub/python/issues/130 @@ -223,6 +223,9 @@ tuf>=4.0.0 # pyserial-asyncio does blocking I/O in asyncio loop, use pyserial-asyncio-fast # instead as pyserial-asyncio is not maintained pyserial-asyncio==1000000000.0.0 + +# https://github.com/jd/tenacity/issues/471 +tenacity<8.4.0 """ GENERATED_MESSAGE = ( diff --git a/script/hassfest/bluetooth.py b/script/hassfest/bluetooth.py index d724905f9cd..49480d1ed02 100644 --- a/script/hassfest/bluetooth.py +++ b/script/hassfest/bluetooth.py @@ -20,7 +20,9 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str: return format_python_namespace( {"BLUETOOTH": match_list}, - annotations={"BLUETOOTH": "list[dict[str, bool | str | int | list[int]]]"}, + annotations={ + "BLUETOOTH": "Final[list[dict[str, bool | str | int | list[int]]]]" + }, ) diff --git a/script/hassfest/coverage.py b/script/hassfest/coverage.py index 686a6697e49..388f2a1c761 100644 --- a/script/hassfest/coverage.py +++ b/script/hassfest/coverage.py @@ -19,6 +19,7 @@ DONT_IGNORE = ( "recorder.py", "scene.py", ) +FORCE_COVERAGE = ("gold", "platinum") CORE_PREFIX = """# Sorted by hassfest. # @@ -105,14 +106,22 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: integration = integrations[integration_path.name] - if ( - path.parts[-1] == "*" - and Path(f"tests/components/{integration.domain}/__init__.py").exists() - ): + if integration.quality_scale in FORCE_COVERAGE: integration.add_error( "coverage", - "has tests and should not use wildcard in .coveragerc file", + f"has quality scale {integration.quality_scale} and " + "should not be present in .coveragerc file", ) + continue + + if (last_part := path.parts[-1]) in {"*", "const.py"} and Path( + f"tests/components/{integration.domain}/__init__.py" + ).exists(): + integration.add_error( + "coverage", + f"has tests and should not use {last_part} in .coveragerc file", + ) + continue for check in DONT_IGNORE: if path.parts[-1] not in {"*", check}: diff --git a/script/hassfest/dhcp.py b/script/hassfest/dhcp.py index 67543a772fc..d1fd0474430 100644 --- a/script/hassfest/dhcp.py +++ b/script/hassfest/dhcp.py @@ -20,7 +20,7 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str: return format_python_namespace( {"DHCP": match_list}, - annotations={"DHCP": "list[dict[str, str | bool]]"}, + annotations={"DHCP": "Final[list[dict[str, str | bool]]]"}, ) diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index b7ba2fbb402..e7451dfd498 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -48,7 +48,7 @@ def ensure_not_same_as_default(value: dict) -> dict: def icon_schema(integration_type: str, no_entity_platform: bool) -> vol.Schema: - """Create a icon schema.""" + """Create an icon schema.""" state_validator = cv.schema_with_slug_keys( icon_value_validator, diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 0c7f48b9af3..8ff0750250f 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -113,6 +113,25 @@ NO_IOT_CLASS = [ "websocket_api", "zone", ] +# Grandfather rule for older integrations +# https://github.com/home-assistant/developers.home-assistant/pull/1512 +NO_DIAGNOSTICS = [ + "dlna_dms", + "gdacs", + "geonetnz_quakes", + "hyperion", + # Modbus is excluded because it doesn't have to have a config flow + # according to ADR-0010, since it's a protocol integration. This + # means that it can't implement diagnostics. + "modbus", + "nightscout", + "pvpc_hourly_pricing", + "risco", + "smarttub", + "songpal", + "vizio", + "yeelight", +] def documentation_url(value: str) -> str: @@ -348,15 +367,36 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No "Virtual integration points to non-existing supported_by integration", ) - if ( - (quality_scale := integration.manifest.get("quality_scale")) - and QualityScale[quality_scale.upper()] > QualityScale.SILVER - and not integration.manifest.get("codeowners") - ): - integration.add_error( - "manifest", - f"{quality_scale} integration does not have a code owner", - ) + if (quality_scale := integration.manifest.get("quality_scale")) and QualityScale[ + quality_scale.upper() + ] > QualityScale.SILVER: + if not integration.manifest.get("codeowners"): + integration.add_error( + "manifest", + f"{quality_scale} integration does not have a code owner", + ) + if ( + domain not in NO_DIAGNOSTICS + and not (integration.path / "diagnostics.py").exists() + ): + integration.add_error( + "manifest", + f"{quality_scale} integration does not implement diagnostics", + ) + + if domain in NO_DIAGNOSTICS: + if quality_scale and QualityScale[quality_scale.upper()] < QualityScale.GOLD: + integration.add_error( + "manifest", + "{quality_scale} integration should be " + "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py", + ) + elif (integration.path / "diagnostics.py").exists(): + integration.add_error( + "manifest", + "Implements diagnostics and can be " + "removed from NO_DIAGNOSTICS in script/hassfest/manifest.py", + ) if not integration.core: validate_version(integration) diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index fab3d5fcd7f..56734257f78 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -36,6 +36,11 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "plugins": "pydantic.mypy", "show_error_codes": "true", "follow_imports": "normal", + "enable_incomplete_feature": ",".join( # noqa: FLY002 + [ + "NewGenericSyntax", + ] + ), # Enable some checks globally. "local_partial_types": "true", "strict_equality": "true", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 2c4ed47b158..d35d96121c5 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -30,7 +30,6 @@ PIP_VERSION_RANGE_SEPARATOR = re.compile(r"^(==|>=|<=|~=|!=|<|>|===)?(.*)$") IGNORE_STANDARD_LIBRARY_VIOLATIONS = { # Integrations which have standard library requirements. - "electrasmart", "slide", "suez_water", } @@ -268,7 +267,7 @@ def install_requirements(integration: Integration, requirements: set[str]) -> bo if is_installed: continue - args = [sys.executable, "-m", "pip", "install", "--quiet"] + args = ["uv", "pip", "install", "--quiet"] if install_args: args.append(install_args) args.append(requirement_arg) diff --git a/script/hassfest/serializer.py b/script/hassfest/serializer.py index 1de4c48a0c4..d81a0621ecb 100644 --- a/script/hassfest/serializer.py +++ b/script/hassfest/serializer.py @@ -102,6 +102,6 @@ def format_python_namespace( for key, value in sorted(content.items()) ) if annotations: - # If we had any annotations, add the __future__ import. - code = f"from __future__ import annotations\n{code}" + # If we had any annotations, add __future__ and typing imports. + code = f"from __future__ import annotations\n\nfrom typing import Final\n{code}" return format_python(code, generator=generator) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index c962d84e6e1..ea4503d5410 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -78,7 +78,10 @@ CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( ) CORE_INTEGRATION_SERVICES_SCHEMA = vol.Schema( - {cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA} + { + vol.Remove(vol.All(str, service.starts_with_dot)): object, + cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA, + } ) CUSTOM_INTEGRATION_SERVICES_SCHEMA = vol.Schema( {cv.slug: CUSTOM_INTEGRATION_SERVICE_SCHEMA} diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index e815a66b4bb..04ea85ca5d5 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -284,6 +284,10 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("condition_type"): {str: translation_value_validator}, vol.Optional("trigger_type"): {str: translation_value_validator}, vol.Optional("trigger_subtype"): {str: translation_value_validator}, + vol.Optional("extra_fields"): {str: translation_value_validator}, + vol.Optional("extra_fields_descriptions"): { + str: translation_value_validator + }, }, vol.Optional("system_health"): { vol.Optional("info"): cv.schema_with_slug_keys( @@ -375,6 +379,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Required("done"): translation_value_validator, }, }, + vol.Optional("common"): vol.Schema({cv.slug: translation_value_validator}), } ) diff --git a/script/install_integration_requirements.py b/script/install_integration_requirements.py index fec893c008a..ab91ea71557 100644 --- a/script/install_integration_requirements.py +++ b/script/install_integration_requirements.py @@ -32,8 +32,7 @@ def main() -> int | None: requirements = gather_recursive_requirements(args.integration) cmd = [ - sys.executable, - "-m", + "uv", "pip", "install", "-c", diff --git a/script/lint_and_test.py b/script/lint_and_test.py index 393c5961c7a..e23870364b6 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -81,7 +81,7 @@ async def async_exec(*args, display=False): raise if not display: - # Readin stdout into log + # Reading stdout into log stdout, _ = await proc.communicate() else: # read child's stdout/stderr concurrently (capture and display) diff --git a/script/monkeytype b/script/monkeytype index dc1894c91ed..02ee46a3035 100755 --- a/script/monkeytype +++ b/script/monkeytype @@ -8,11 +8,11 @@ cd "$(dirname "$0")/.." command -v pytest >/dev/null 2>&1 || { echo >&2 "This script requires pytest but it's not installed." \ - "Aborting. Try: pip install pytest"; exit 1; } + "Aborting. Try: uv pip install pytest"; exit 1; } command -v monkeytype >/dev/null 2>&1 || { echo >&2 "This script requires monkeytype but it's not installed." \ - "Aborting. Try: pip install monkeytype"; exit 1; } + "Aborting. Try: uv pip install monkeytype"; exit 1; } if [ $# -eq 0 ] then diff --git a/script/run-in-env.sh b/script/run-in-env.sh index 085e07bef84..1c7f76ccc1f 100755 --- a/script/run-in-env.sh +++ b/script/run-in-env.sh @@ -13,14 +13,18 @@ if [ -s .python-version ]; then export PYENV_VERSION fi -# other common virtualenvs -my_path=$(git rev-parse --show-toplevel) +if [ -n "${VIRTUAL_ENV-}" ] && [ -f "${VIRTUAL_ENV}/bin/activate" ]; then + . "${VIRTUAL_ENV}/bin/activate" +else + # other common virtualenvs + my_path=$(git rev-parse --show-toplevel) -for venv in venv .venv .; do - if [ -f "${my_path}/${venv}/bin/activate" ]; then - . "${my_path}/${venv}/bin/activate" - break - fi -done + for venv in venv .venv .; do + if [ -f "${my_path}/${venv}/bin/activate" ]; then + . "${my_path}/${venv}/bin/activate" + break + fi + done +fi exec "$@" diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py index 87391f1733e..0b752e71013 100644 --- a/script/scaffold/templates/config_flow/integration/__init__.py +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -6,30 +6,30 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. PLATFORMS: list[Platform] = [Platform.LIGHT] +# TODO Create ConfigEntry type alias with API object +# TODO Rename type alias and update all entry annotations +type New_NameConfigEntry = ConfigEntry[MyApi] # noqa: F821 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +# TODO Update entry annotation +async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Set up NEW_NAME from a config entry.""" - hass.data.setdefault(DOMAIN, {}) # TODO 1. Create API instance # TODO 2. Validate the API connection (and authentication) # TODO 3. Store an API object for your platforms to access - # hass.data[DOMAIN][entry.entry_id] = MyApi(...) + # entry.runtime_data = MyAPI(...) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +# TODO Update entry annotation +async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 797ca5c7066..0bff976f288 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -85,7 +85,7 @@ class ConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except + except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: diff --git a/script/scaffold/templates/config_flow/tests/conftest.py b/script/scaffold/templates/config_flow/tests/conftest.py index 84b6bb381bf..fc217636705 100644 --- a/script/scaffold/templates/config_flow/tests/conftest.py +++ b/script/scaffold/templates/config_flow/tests/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the NEW_NAME tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py index 4d18fecc2fa..06b91f51949 100644 --- a/script/scaffold/templates/config_flow_discovery/integration/__init__.py +++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py @@ -6,30 +6,30 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] +# TODO Create ConfigEntry type alias with API object +# Alias name should be prefixed by integration name +type New_NameConfigEntry = ConfigEntry[MyApi] # noqa: F821 + +# TODO Update entry annotation async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NEW_NAME from a config entry.""" - hass.data.setdefault(DOMAIN, {}) # TODO 1. Create API instance # TODO 2. Validate the API connection (and authentication) # TODO 3. Store an API object for your platforms to access - # hass.data[DOMAIN][entry.entry_id] = MyApi(...) + # entry.runtime_data = MyAPI(...) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +# TODO Update entry annotation 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/script/scaffold/templates/config_flow_helper/integration/__init__.py b/script/scaffold/templates/config_flow_helper/integration/__init__.py index c8817fb76ad..e508e3b9869 100644 --- a/script/scaffold/templates/config_flow_helper/integration/__init__.py +++ b/script/scaffold/templates/config_flow_helper/integration/__init__.py @@ -6,13 +6,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NEW_NAME from a config entry.""" # TODO Optionally store an object for your platforms to access - # hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ... + # entry.runtime_data = ... # TODO Optionally validate config entry options before setting up platform @@ -32,9 +30,4 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) 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, (Platform.SENSOR,) - ): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,)) diff --git a/script/scaffold/templates/config_flow_helper/tests/conftest.py b/script/scaffold/templates/config_flow_helper/tests/conftest.py index 84b6bb381bf..fc217636705 100644 --- a/script/scaffold/templates/config_flow_helper/tests/conftest.py +++ b/script/scaffold/templates/config_flow_helper/tests/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the NEW_NAME tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True diff --git a/script/scaffold/templates/config_flow_helper/tests/test_init.py b/script/scaffold/templates/config_flow_helper/tests/test_init.py index 73ac28da059..3c1a3395b86 100644 --- a/script/scaffold/templates/config_flow_helper/tests/test_init.py +++ b/script/scaffold/templates/config_flow_helper/tests/test_init.py @@ -12,11 +12,11 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ["sensor"]) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - registry = er.async_get(hass) NEW_DOMAIN_entity_id = f"{platform}.my_NEW_DOMAIN" # Setup the config entry @@ -34,7 +34,7 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(NEW_DOMAIN_entity_id) is not None + assert entity_registry.async_get(NEW_DOMAIN_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(NEW_DOMAIN_entity_id) @@ -48,4 +48,4 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(NEW_DOMAIN_entity_id) is None - assert registry.async_get(NEW_DOMAIN_entity_id) is None + assert entity_registry.async_get(NEW_DOMAIN_entity_id) is None diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py index 7e7641a535b..b8403392471 100644 --- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -8,14 +8,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import DOMAIN # TODO List the platforms that you want to support. # For your initial PR, limit it to 1 platform. PLATFORMS: list[Platform] = [Platform.LIGHT] +# TODO Create ConfigEntry type alias with ConfigEntryAuth or AsyncConfigEntryAuth object +# TODO Rename type alias and update all entry annotations +type New_NameConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +# # TODO Update entry annotation +async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: """Set up NEW_NAME from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -26,12 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) # If using a requests-based API lib - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api.ConfigEntryAuth( - hass, session - ) + entry.runtime_data = api.ConfigEntryAuth(hass, session) # If using an aiohttp-based API lib - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = api.AsyncConfigEntryAuth( + entry.runtime_data = api.AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), session ) @@ -40,9 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +# TODO Update entry annotation +async def async_unload_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py index 6e3a2047c6e..27a6f34951d 100644 --- a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py @@ -44,7 +44,7 @@ async def test_full_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - state = config_entry_oauth2_flow._encode_jwt( + state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 hass, { "flow_id": result["flow_id"], diff --git a/script/scaffold/templates/device_condition/tests/test_device_condition.py b/script/scaffold/templates/device_condition/tests/test_device_condition.py index ad6d527bd65..5a0e7122571 100644 --- a/script/scaffold/templates/device_condition/tests/test_device_condition.py +++ b/script/scaffold/templates/device_condition/tests/test_device_condition.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pytest from pytest_unordered import unordered from homeassistant.components import automation @@ -13,17 +12,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_get_device_automations, - async_mock_service, -) - - -@pytest.fixture -def calls(hass: HomeAssistant) -> list[ServiceCall]: - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") +from tests.common import MockConfigEntry, async_get_device_automations async def test_get_conditions( @@ -63,7 +52,7 @@ async def test_get_conditions( assert conditions == unordered(expected_conditions) -async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: +async def test_if_state(hass: HomeAssistant, service_calls: list[ServiceCall]) -> None: """Test for turn_on and turn_off conditions.""" hass.states.async_set("NEW_DOMAIN.entity", STATE_ON) @@ -114,12 +103,12 @@ async def test_if_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "is_on - event - test_event1" + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == "is_on - event - test_event1" hass.states.async_set("NEW_DOMAIN.entity", STATE_OFF) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "is_off - event - test_event2" + assert len(service_calls) == 2 + assert service_calls[1].data["some"] == "is_off - event - test_event2" diff --git a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py index 54b202c978c..7e4f88261bc 100644 --- a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py +++ b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py @@ -1,6 +1,5 @@ """The tests for NEW_NAME device triggers.""" -import pytest from pytest_unordered import unordered from homeassistant.components import automation @@ -11,17 +10,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_get_device_automations, - async_mock_service, -) - - -@pytest.fixture -def calls(hass: HomeAssistant) -> list[ServiceCall]: - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") +from tests.common import MockConfigEntry, async_get_device_automations async def test_get_triggers( @@ -62,7 +51,7 @@ async def test_get_triggers( async def test_if_fires_on_state_change( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: """Test for turn_on and turn_off triggers firing.""" hass.states.async_set("NEW_DOMAIN.entity", STATE_OFF) @@ -119,15 +108,15 @@ async def test_if_fires_on_state_change( # Fake that the entity is turning on. hass.states.async_set("NEW_DOMAIN.entity", STATE_ON) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data[ + assert len(service_calls) == 1 + assert service_calls[0].data[ "some" ] == "turn_on - device - {} - off - on - None - 0".format("NEW_DOMAIN.entity") # Fake that the entity is turning off. hass.states.async_set("NEW_DOMAIN.entity", STATE_OFF) await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data[ + assert len(service_calls) == 2 + assert service_calls[1].data[ "some" ] == "turn_off - device - {} - on - off - None - 0".format("NEW_DOMAIN.entity") diff --git a/script/setup b/script/setup index a5c2d48b2b3..84ee074510a 100755 --- a/script/setup +++ b/script/setup @@ -16,15 +16,23 @@ fi mkdir -p config -if [ ! -n "$DEVCONTAINER" ] && [ ! -n "$VIRTUAL_ENV" ];then - python3 -m venv venv +if [ ! -n "$VIRTUAL_ENV" ]; then + if [ -x "$(command -v uv)" ]; then + uv venv venv + else + python3 -m venv venv + fi source venv/bin/activate fi +if ! [ -x "$(command -v uv)" ]; then + python3 -m pip install uv +fi + script/bootstrap pre-commit install -python3 -m pip install -e . --config-settings editable_mode=compat --constraint homeassistant/package_constraints.txt +uv pip install -e . --config-settings editable_mode=compat --constraint homeassistant/package_constraints.txt python3 -m script.translations develop --all hass --script ensure_config -c config diff --git a/script/translations/clean.py b/script/translations/clean.py index 0403e04f789..72bb79f1f0c 100644 --- a/script/translations/clean.py +++ b/script/translations/clean.py @@ -100,7 +100,7 @@ def run(): key_data = lokalise.keys_list({"filter_keys": ",".join(chunk), "limit": 1000}) if len(key_data) != len(chunk): print( - f"Lookin up key in Lokalise returns {len(key_data)} results, expected {len(chunk)}" + f"Looking up key in Lokalise returns {len(key_data)} results, expected {len(chunk)}" ) if not key_data: diff --git a/script/translations/migrate.py b/script/translations/migrate.py index 0f51e49c5a9..9ff45104b48 100644 --- a/script/translations/migrate.py +++ b/script/translations/migrate.py @@ -29,7 +29,7 @@ def rename_keys(project_id, to_migrate): from_key_data = lokalise.keys_list({"filter_keys": ",".join(to_migrate)}) if len(from_key_data) != len(to_migrate): print( - f"Lookin up keys in Lokalise returns {len(from_key_data)} results, expected {len(to_migrate)}" + f"Looking up keys in Lokalise returns {len(from_key_data)} results, expected {len(to_migrate)}" ) return @@ -72,7 +72,7 @@ def list_keys_helper(lokalise, keys, params={}, *, validate=True): continue print( - f"Lookin up keys in Lokalise returns {len(from_key_data)} results, expected {len(keys)}" + f"Looking up keys in Lokalise returns {len(from_key_data)} results, expected {len(keys)}" ) searched = set(filter_keys) returned = set(create_lookup(from_key_data)) diff --git a/script/version_bump.py b/script/version_bump.py index 6c24c40c4e3..fb4fe2f7868 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -104,7 +104,7 @@ def bump_version( raise ValueError(f"Unsupported type: {bump_type}") temp = Version("0") - temp._version = version._version._replace(**to_change) + temp._version = version._version._replace(**to_change) # noqa: SLF001 return Version(str(temp)) diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py deleted file mode 100644 index 9f1f98aeaf0..00000000000 --- a/tests/auth/providers/test_legacy_api_password.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Tests for the legacy_api_password auth provider.""" - -import pytest - -from homeassistant import auth, data_entry_flow -from homeassistant.auth import auth_store -from homeassistant.auth.providers import legacy_api_password -from homeassistant.core import HomeAssistant -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component - -from tests.common import ensure_auth_manager_loaded - -CONFIG = {"type": "legacy_api_password", "api_password": "test-password"} - - -@pytest.fixture -async def store(hass): - """Mock store.""" - store = auth_store.AuthStore(hass) - await store.async_load() - return store - - -@pytest.fixture -def provider(hass, store): - """Mock provider.""" - return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, CONFIG) - - -@pytest.fixture -def manager(hass, store, provider): - """Mock manager.""" - return auth.AuthManager(hass, store, {(provider.type, provider.id): provider}, {}) - - -async def test_create_new_credential(manager, provider) -> None: - """Test that we create a new credential.""" - credentials = await provider.async_get_or_create_credentials({}) - assert credentials.is_new is True - - user = await manager.async_get_or_create_user(credentials) - assert user.name == legacy_api_password.LEGACY_USER_NAME - assert user.is_active - - -async def test_only_one_credentials(manager, provider) -> None: - """Call create twice will return same credential.""" - credentials = await provider.async_get_or_create_credentials({}) - await manager.async_get_or_create_user(credentials) - credentials2 = await provider.async_get_or_create_credentials({}) - assert credentials2.id == credentials.id - assert credentials2.is_new is False - - -async def test_verify_login(hass: HomeAssistant, provider) -> None: - """Test login using legacy api password auth provider.""" - provider.async_validate_login("test-password") - with pytest.raises(legacy_api_password.InvalidAuthError): - provider.async_validate_login("invalid-password") - - -async def test_login_flow_works(hass: HomeAssistant, manager) -> None: - """Test wrong config.""" - result = await manager.login_flow.async_init(handler=("legacy_api_password", None)) - assert result["type"] == data_entry_flow.FlowResultType.FORM - - result = await manager.login_flow.async_configure( - flow_id=result["flow_id"], user_input={"password": "not-hello"} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"]["base"] == "invalid_auth" - - result = await manager.login_flow.async_configure( - flow_id=result["flow_id"], user_input={"password": "test-password"} - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - -async def test_create_repair_issue(hass: HomeAssistant): - """Test legacy api password auth provider creates a reapir issue.""" - hass.auth = await auth.auth_manager_from_config(hass, [CONFIG], []) - ensure_auth_manager_loaded(hass.auth) - await async_setup_component(hass, "auth", {}) - issue_registry: ir.IssueRegistry = ir.async_get(hass) - - assert issue_registry.async_get_issue( - domain="auth", issue_id="deprecated_legacy_api_password" - ) diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 3d62190eab6..65bc35a5ff8 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -1,17 +1,14 @@ """Tests for the auth store.""" import asyncio -from datetime import timedelta from typing import Any from unittest.mock import patch -from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import auth_store from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util MOCK_STORAGE_DATA = { "version": 1, @@ -220,68 +217,64 @@ async def test_loading_only_once(hass: HomeAssistant) -> None: assert results[0] == results[1] -async def test_add_expire_at_property( +async def test_dont_change_expire_at_on_load( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Test we correctly add expired_at property if not existing.""" - now = dt_util.utcnow() - with freeze_time(now): - hass_storage[auth_store.STORAGE_KEY] = { - "version": 1, - "data": { - "credentials": [], - "users": [ - { - "id": "user-id", - "is_active": True, - "is_owner": True, - "name": "Paulus", - "system_generated": False, - }, - { - "id": "system-id", - "is_active": True, - "is_owner": True, - "name": "Hass.io", - "system_generated": True, - }, - ], - "refresh_tokens": [ - { - "access_token_expiration": 1800.0, - "client_id": "http://localhost:8123/", - "created_at": "2018-10-03T13:43:19.774637+00:00", - "id": "user-token-id", - "jwt_key": "some-key", - "last_used_at": str(now - timedelta(days=10)), - "token": "some-token", - "user_id": "user-id", - "version": "1.2.3", - }, - { - "access_token_expiration": 1800.0, - "client_id": "http://localhost:8123/", - "created_at": "2018-10-03T13:43:19.774637+00:00", - "id": "user-token-id2", - "jwt_key": "some-key2", - "token": "some-token", - "user_id": "user-id", - }, - ], - }, - } + """Test we correctly don't modify expired_at store load.""" + hass_storage[auth_store.STORAGE_KEY] = { + "version": 1, + "data": { + "credentials": [], + "users": [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + }, + { + "id": "system-id", + "is_active": True, + "is_owner": True, + "name": "Hass.io", + "system_generated": True, + }, + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "token": "some-token", + "user_id": "user-id", + "version": "1.2.3", + }, + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id2", + "jwt_key": "some-key2", + "token": "some-token", + "user_id": "user-id", + "expire_at": 1724133771.079745, + }, + ], + }, + } - store = auth_store.AuthStore(hass) - await store.async_load() + store = auth_store.AuthStore(hass) + await store.async_load() users = await store.async_get_users() assert len(users[0].refresh_tokens) == 2 token1, token2 = users[0].refresh_tokens.values() - assert token1.expire_at - assert token1.expire_at == now.timestamp() + timedelta(days=80).total_seconds() - assert token2.expire_at - assert token2.expire_at == now.timestamp() + timedelta(days=90).total_seconds() + assert not token1.expire_at + assert token2.expire_at == 1724133771.079745 async def test_loading_does_not_write_right_away( @@ -305,3 +298,84 @@ async def test_loading_does_not_write_right_away( # Once for the task await hass.async_block_till_done() assert hass_storage[auth_store.STORAGE_KEY] != {} + + +async def test_add_remove_user_affects_tokens( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test adding and removing a user removes the tokens.""" + store = auth_store.AuthStore(hass) + await store.async_load() + user = await store.async_create_user("Test User") + assert user.name == "Test User" + refresh_token = await store.async_create_refresh_token( + user, "client_id", "access_token_expiration" + ) + assert user.refresh_tokens == {refresh_token.id: refresh_token} + assert await store.async_get_user(user.id) == user + assert store.async_get_refresh_token(refresh_token.id) == refresh_token + assert store.async_get_refresh_token_by_token(refresh_token.token) == refresh_token + await store.async_remove_user(user) + assert store.async_get_refresh_token(refresh_token.id) is None + assert store.async_get_refresh_token_by_token(refresh_token.token) is None + assert user.refresh_tokens == {} + + +async def test_set_expiry_date( + hass: HomeAssistant, hass_storage: dict[str, Any], freezer: FrozenDateTimeFactory +) -> None: + """Test set expiry date of a refresh token.""" + hass_storage[auth_store.STORAGE_KEY] = { + "version": 1, + "data": { + "credentials": [], + "users": [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + }, + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "token": "some-token", + "user_id": "user-id", + "expire_at": 1724133771.079745, + }, + ], + }, + } + + store = auth_store.AuthStore(hass) + await store.async_load() + + users = await store.async_get_users() + + assert len(users[0].refresh_tokens) == 1 + (token,) = users[0].refresh_tokens.values() + assert token.expire_at == 1724133771.079745 + + store.async_set_expiry(token, enable_expiry=False) + assert token.expire_at is None + + freezer.tick(auth_store.DEFAULT_SAVE_DELAY * 2) + # Once for scheduling the task + await hass.async_block_till_done() + # Once for the task + await hass.async_block_till_done() + + # verify token is saved without expire_at + assert ( + hass_storage[auth_store.STORAGE_KEY]["data"]["refresh_tokens"][0]["expire_at"] + is None + ) + + store.async_set_expiry(token, enable_expiry=True) + assert token.expire_at is not None diff --git a/tests/common.py b/tests/common.py index 8e220f59215..30c7cc2d971 100644 --- a/tests/common.py +++ b/tests/common.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Generator, Mapping, Sequence +from collections.abc import Callable, Coroutine, Mapping, Sequence from contextlib import asynccontextmanager, contextmanager from datetime import UTC, datetime, timedelta from enum import Enum @@ -17,12 +17,13 @@ import pathlib import threading import time from types import FrameType, ModuleType -from typing import Any, NoReturn, TypeVar +from typing import Any, Literal, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 import pytest from syrupy import SnapshotAssertion +from typing_extensions import AsyncGenerator, Generator import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -70,7 +71,6 @@ from homeassistant.helpers import ( issue_registry as ir, label_registry as lr, recorder as recorder_helper, - restore_state, restore_state as rs, storage, translation, @@ -95,11 +95,11 @@ from homeassistant.util.json import ( json_loads_object, ) from homeassistant.util.signal_type import SignalType +import homeassistant.util.ulid as ulid_util 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 ( +from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, ) @@ -161,7 +161,7 @@ def get_test_config_dir(*add_path): @contextmanager -def get_test_home_assistant() -> Generator[HomeAssistant, None, None]: +def get_test_home_assistant() -> Generator[HomeAssistant]: """Return a Home Assistant object pointing at test config directory.""" loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -174,6 +174,7 @@ def get_test_home_assistant() -> Generator[HomeAssistant, None, None]: """Run event loop.""" loop._thread_ident = threading.get_ident() + hass.loop_thread_id = loop._thread_ident loop.run_forever() loop_stop_event.set() @@ -194,15 +195,14 @@ def get_test_home_assistant() -> Generator[HomeAssistant, None, None]: threading.Thread(name="LoopThread", target=run_loop, daemon=False).start() - yield hass - loop.run_until_complete(context_manager.__aexit__(None, None, None)) - loop.close() + try: + yield hass + finally: + loop.run_until_complete(context_manager.__aexit__(None, None, None)) + loop.close() -_T = TypeVar("_T", bound=Mapping[str, Any] | Sequence[Any]) - - -class StoreWithoutWriteLoad(storage.Store[_T]): +class StoreWithoutWriteLoad[_T: (Mapping[str, Any] | Sequence[Any])](storage.Store[_T]): """Fake store that does not write or load. Used for testing.""" async def async_save(self, *args: Any, **kwargs: Any) -> None: @@ -224,7 +224,7 @@ async def async_test_home_assistant( event_loop: asyncio.AbstractEventLoop | None = None, load_registries: bool = True, config_dir: str | None = None, -) -> AsyncGenerator[HomeAssistant, None]: +) -> AsyncGenerator[HomeAssistant]: """Return a Home Assistant object pointing at test config dir.""" hass = HomeAssistant(config_dir or get_test_config_dir()) store = auth_store.AuthStore(hass) @@ -235,7 +235,7 @@ async def async_test_home_assistant( orig_async_add_job = hass.async_add_job orig_async_add_executor_job = hass.async_add_executor_job orig_async_create_task_internal = hass.async_create_task_internal - orig_tz = dt_util.DEFAULT_TIME_ZONE + orig_tz = dt_util.get_default_time_zone() def async_add_job(target, *args, eager_start: bool = False): """Add job.""" @@ -282,7 +282,7 @@ async def async_test_home_assistant( hass.config.latitude = 32.87336 hass.config.longitude = -117.22743 hass.config.elevation = 0 - hass.config.set_time_zone("US/Pacific") + await hass.config.async_set_time_zone("US/Pacific") hass.config.units = METRIC_SYSTEM hass.config.media_dirs = {"local": get_test_config_dir("media")} hass.config.skip_pip = True @@ -353,17 +353,19 @@ async def async_test_home_assistant( hass.set_state(CoreState.running) - async def clear_instance(event): + @callback + def clear_instance(event): """Clear global instance.""" - await asyncio.sleep(0) # Give aiohttp one loop iteration to close - INSTANCES.remove(hass) + # Give aiohttp one loop iteration to close + hass.loop.call_soon(INSTANCES.remove, hass) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance) - yield hass - - # Restore timezone, it is set when creating the hass object - dt_util.DEFAULT_TIME_ZONE = orig_tz + try: + yield hass + finally: + # Restore timezone, it is set when creating the hass object + dt_util.set_default_time_zone(orig_tz) def async_mock_service( @@ -414,10 +416,10 @@ def async_mock_intent(hass, intent_typ): class MockIntentHandler(intent.IntentHandler): intent_type = intent_typ - async def async_handle(self, intent): + async def async_handle(self, intent_obj): """Handle the intent.""" - intents.append(intent) - return intent.create_response() + intents.append(intent_obj) + return intent_obj.create_response() intent.async_register(hass, MockIntentHandler()) @@ -556,7 +558,7 @@ def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.P @lru_cache def load_fixture(filename: str, integration: str | None = None) -> str: """Load a fixture.""" - return get_fixture_path(filename, integration).read_text() + return get_fixture_path(filename, integration).read_text(encoding="utf8") def load_json_value_fixture( @@ -601,7 +603,7 @@ def mock_state_change_event( def mock_component(hass: HomeAssistant, component: str) -> None: """Mock a component is setup.""" if component in hass.config.components: - AssertionError(f"Integration {component} is already setup") + raise AssertionError(f"Integration {component} is already setup") hass.config.components.add(component) @@ -631,6 +633,7 @@ def mock_registry( registry.entities[key] = entry hass.data[er.DATA_REGISTRY] = registry + er.async_get.cache_clear() return registry @@ -654,6 +657,7 @@ def mock_area_registry( registry.areas[key] = entry hass.data[ar.DATA_REGISTRY] = registry + ar.async_get.cache_clear() return registry @@ -682,25 +686,26 @@ def mock_device_registry( registry.deleted_devices = dr.DeviceRegistryItems() hass.data[dr.DATA_REGISTRY] = registry + dr.async_get.cache_clear() return registry class MockGroup(auth_models.Group): """Mock a group in Home Assistant.""" - def __init__(self, id=None, name="Mock Group", policy=system_policies.ADMIN_POLICY): + def __init__(self, id: str | None = None, name: str | None = "Mock Group") -> None: """Mock a group.""" - kwargs = {"name": name, "policy": policy} + kwargs = {"name": name, "policy": system_policies.ADMIN_POLICY} if id is not None: kwargs["id"] = id super().__init__(**kwargs) - def add_to_hass(self, hass): + def add_to_hass(self, hass: HomeAssistant) -> MockGroup: """Test helper to add entry to hass.""" return self.add_to_auth_manager(hass.auth) - def add_to_auth_manager(self, auth_mgr): + def add_to_auth_manager(self, auth_mgr: auth.AuthManager) -> MockGroup: """Test helper to add entry to hass.""" ensure_auth_manager_loaded(auth_mgr) auth_mgr._store._groups[self.id] = self @@ -712,13 +717,13 @@ class MockUser(auth_models.User): def __init__( self, - id=None, - is_owner=False, - is_active=True, - name="Mock User", - system_generated=False, - groups=None, - ): + id: str | None = None, + is_owner: bool = False, + is_active: bool = True, + name: str | None = "Mock User", + system_generated: bool = False, + groups: list[auth_models.Group] | None = None, + ) -> None: """Initialize mock user.""" kwargs = { "is_owner": is_owner, @@ -732,17 +737,17 @@ class MockUser(auth_models.User): kwargs["id"] = id super().__init__(**kwargs) - def add_to_hass(self, hass): + def add_to_hass(self, hass: HomeAssistant) -> MockUser: """Test helper to add entry to hass.""" return self.add_to_auth_manager(hass.auth) - def add_to_auth_manager(self, auth_mgr): + def add_to_auth_manager(self, auth_mgr: auth.AuthManager) -> MockUser: """Test helper to add entry to hass.""" ensure_auth_manager_loaded(auth_mgr) auth_mgr._store._users[self.id] = self return self - def mock_policy(self, policy): + def mock_policy(self, policy: auth_permissions.PolicyType) -> None: """Mock a policy for a user.""" self.permissions = auth_permissions.PolicyPermissions(policy, self.perm_lookup) @@ -766,7 +771,7 @@ async def register_auth_provider( @callback -def ensure_auth_manager_loaded(auth_mgr): +def ensure_auth_manager_loaded(auth_mgr: auth.AuthManager) -> None: """Ensure an auth manager is considered loaded.""" store = auth_mgr._store if store._users is None: @@ -778,21 +783,38 @@ class MockModule: def __init__( self, - domain=None, - dependencies=None, - setup=None, - requirements=None, - config_schema=None, - platform_schema=None, - platform_schema_base=None, - async_setup=None, - async_setup_entry=None, - async_unload_entry=None, - async_migrate_entry=None, - async_remove_entry=None, - partial_manifest=None, - async_remove_config_entry_device=None, - ): + domain: str | None = None, + *, + dependencies: list[str] | None = None, + setup: Callable[[HomeAssistant, ConfigType], bool] | None = None, + requirements: list[str] | None = None, + config_schema: vol.Schema | None = None, + platform_schema: vol.Schema | None = None, + platform_schema_base: vol.Schema | None = None, + async_setup: Callable[[HomeAssistant, ConfigType], Coroutine[Any, Any, bool]] + | None = None, + async_setup_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool] + ] + | None = None, + async_unload_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool] + ] + | None = None, + async_migrate_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool] + ] + | None = None, + async_remove_entry: Callable[ + [HomeAssistant, ConfigEntry], Coroutine[Any, Any, None] + ] + | None = None, + partial_manifest: dict[str, Any] | None = None, + async_remove_config_entry_device: Callable[ + [HomeAssistant, ConfigEntry, dr.DeviceEntry], Coroutine[Any, Any, bool] + ] + | None = None, + ) -> None: """Initialize the mock module.""" self.__name__ = f"homeassistant.components.{domain}" self.__file__ = f"homeassistant/components/{domain}" @@ -813,6 +835,7 @@ class MockModule: if setup: # We run this in executor, wrap it in function + # pylint: disable-next=unnecessary-lambda self.setup = lambda *args: setup(*args) if async_setup is not None: @@ -852,13 +875,25 @@ class MockPlatform: def __init__( self, - setup_platform=None, - dependencies=None, - platform_schema=None, - async_setup_platform=None, - async_setup_entry=None, - scan_interval=None, - ): + *, + setup_platform: Callable[ + [HomeAssistant, ConfigType, AddEntitiesCallback, DiscoveryInfoType | None], + None, + ] + | None = None, + dependencies: list[str] | None = None, + platform_schema: vol.Schema | None = None, + async_setup_platform: Callable[ + [HomeAssistant, ConfigType, AddEntitiesCallback, DiscoveryInfoType | None], + Coroutine[Any, Any, None], + ] + | None = None, + async_setup_entry: Callable[ + [HomeAssistant, ConfigEntry, AddEntitiesCallback], Coroutine[Any, Any, None] + ] + | None = None, + scan_interval: timedelta | None = None, + ) -> None: """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] @@ -870,6 +905,7 @@ class MockPlatform: if setup_platform is not None: # We run this in executor, wrap it in function + # pylint: disable-next=unnecessary-lambda self.setup_platform = lambda *args: setup_platform(*args) if async_setup_platform is not None: @@ -894,7 +930,7 @@ class MockEntityPlatform(entity_platform.EntityPlatform): platform=None, scan_interval=timedelta(seconds=15), entity_namespace=None, - ): + ) -> None: """Initialize a mock entity platform.""" if logger is None: logger = logging.getLogger("homeassistant.helpers.entity_platform") @@ -923,41 +959,41 @@ class MockEntityPlatform(entity_platform.EntityPlatform): class MockToggleEntity(entity.ToggleEntity): """Provide a mock toggle device.""" - def __init__(self, name, state, unique_id=None): + def __init__(self, name: str | None, state: Literal["on", "off"] | None) -> None: """Initialize the mock entity.""" self._name = name or DEVICE_DEFAULT_NAME self._state = state - self.calls = [] + self.calls: list[tuple[str, dict[str, Any]]] = [] @property - def name(self): + def name(self) -> str: """Return the name of the entity if any.""" self.calls.append(("name", {})) return self._name @property - def state(self): + def state(self) -> Literal["on", "off"] | None: """Return the state of the entity if any.""" self.calls.append(("state", {})) return self._state @property - def is_on(self): + def is_on(self) -> bool: """Return true if entity is on.""" self.calls.append(("is_on", {})) return self._state == STATE_ON - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" self.calls.append(("turn_on", kwargs)) self._state = STATE_ON - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" self.calls.append(("turn_off", kwargs)) self._state = STATE_OFF - def last_call(self, method=None): + def last_call(self, method: str | None = None) -> tuple[str, dict[str, Any]]: """Return the last call.""" if not self.calls: return None @@ -975,34 +1011,34 @@ class MockConfigEntry(config_entries.ConfigEntry): def __init__( self, *, - domain="test", data=None, - version=1, - minor_version=1, + disabled_by=None, + domain="test", entry_id=None, - source=config_entries.SOURCE_USER, - title="Mock Title", - state=None, - options={}, + minor_version=1, + options=None, pref_disable_new_entities=None, pref_disable_polling=None, - unique_id=None, - disabled_by=None, reason=None, + source=config_entries.SOURCE_USER, + state=None, + title="Mock Title", + unique_id=None, + version=1, ) -> None: """Initialize a mock config entry.""" kwargs = { - "entry_id": entry_id or uuid_util.random_uuid_hex(), - "domain": domain, "data": data or {}, + "disabled_by": disabled_by, + "domain": domain, + "entry_id": entry_id or ulid_util.ulid_now(), + "minor_version": minor_version, + "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, "pref_disable_polling": pref_disable_polling, - "options": options, - "version": version, - "minor_version": minor_version, "title": title, "unique_id": unique_id, - "disabled_by": disabled_by, + "version": version, } if source is not None: kwargs["source"] = source @@ -1130,6 +1166,7 @@ def init_recorder_component(hass, add_config=None, db_url="sqlite://"): """Initialize the recorder.""" # Local import to avoid processing recorder and SQLite modules when running a # testcase which does not use the recorder. + # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder config = dict(add_config) if add_config else {} @@ -1151,8 +1188,8 @@ def init_recorder_component(hass, add_config=None, db_url="sqlite://"): def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE - data = restore_state.RestoreStateData(hass) + key = rs.DATA_RESTORE_STATE + data = rs.RestoreStateData(hass) now = dt_util.utcnow() last_states = {} @@ -1164,13 +1201,14 @@ def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: json.dumps(restored_state["attributes"], cls=JSONEncoder) ), } - last_states[state.entity_id] = restore_state.StoredState.from_dict( + last_states[state.entity_id] = rs.StoredState.from_dict( {"state": restored_state, "last_seen": now} ) data.last_states = last_states _LOGGER.debug("Restore cache: %s", data.last_states) assert len(data.last_states) == len(states), f"Duplicate entity_id? {states}" + rs.async_get.cache_clear() hass.data[key] = data @@ -1178,8 +1216,8 @@ def mock_restore_cache_with_extra_data( hass: HomeAssistant, states: Sequence[tuple[State, Mapping[str, Any]]] ) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE - data = restore_state.RestoreStateData(hass) + key = rs.DATA_RESTORE_STATE + data = rs.RestoreStateData(hass) now = dt_util.utcnow() last_states = {} @@ -1191,21 +1229,22 @@ def mock_restore_cache_with_extra_data( json.dumps(restored_state["attributes"], cls=JSONEncoder) ), } - last_states[state.entity_id] = restore_state.StoredState.from_dict( + last_states[state.entity_id] = rs.StoredState.from_dict( {"state": restored_state, "extra_data": extra_data, "last_seen": now} ) data.last_states = last_states _LOGGER.debug("Restore cache: %s", data.last_states) assert len(data.last_states) == len(states), f"Duplicate entity_id? {states}" + rs.async_get.cache_clear() hass.data[key] = data async def async_mock_restore_state_shutdown_restart( hass: HomeAssistant, -) -> restore_state.RestoreStateData: +) -> rs.RestoreStateData: """Mock shutting down and saving restore state and restoring.""" - data = restore_state.async_get(hass) + data = rs.async_get(hass) await data.async_dump_states() await async_mock_load_restore_state_from_storage(hass) return data @@ -1218,7 +1257,7 @@ async def async_mock_load_restore_state_from_storage( hass_storage must already be mocked. """ - await restore_state.async_get(hass).async_load() + await rs.async_get(hass).async_load() class MockEntity(entity.Entity): @@ -1319,9 +1358,7 @@ class MockEntity(entity.Entity): @contextmanager -def mock_storage( - data: dict[str, Any] | None = None, -) -> Generator[dict[str, Any], None, None]: +def mock_storage(data: dict[str, Any] | None = None) -> Generator[dict[str, Any]]: """Mock storage. Data is a dict {'key': {'version': version, 'data': data}} @@ -1429,7 +1466,10 @@ def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: def mock_integration( - hass: HomeAssistant, module: MockModule, built_in: bool = True + hass: HomeAssistant, + module: MockModule, + built_in: bool = True, + top_level_files: set[str] | None = None, ) -> loader.Integration: """Mock an integration.""" integration = loader.Integration( @@ -1439,7 +1479,7 @@ def mock_integration( else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}", pathlib.Path(""), module.mock_manifest(), - set(), + top_level_files, ) def mock_import_platform(platform_name: str) -> NoReturn: @@ -1568,6 +1608,7 @@ def async_get_persistent_notifications( def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> None: """Mock a signal the cloud disconnected.""" + # pylint: disable-next=import-outside-toplevel from homeassistant.components.cloud import ( SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState, @@ -1676,15 +1717,17 @@ def import_and_test_deprecated_alias( def help_test_all(module: ModuleType) -> None: """Test module.__all__ is correctly set.""" assert set(module.__all__) == { - itm for itm in module.__dir__() if not itm.startswith("_") + itm for itm in dir(module) if not itm.startswith("_") } def extract_stack_to_frame(extract_stack: list[Mock]) -> FrameType: """Convert an extract stack to a frame list.""" stack = list(extract_stack) + _globals = globals() for frame in stack: frame.f_back = None + frame.f_globals = _globals frame.f_code.co_filename = frame.filename frame.f_lineno = int(frame.lineno) @@ -1752,5 +1795,6 @@ async def snapshot_platform( for entity_entry in entity_entries: assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") assert entity_entry.disabled_by is None, "Please enable all entities." - assert (state := hass.states.get(entity_entry.entity_id)) + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/abode/conftest.py b/tests/components/abode/conftest.py index 0e5e24b24f4..21b236540d0 100644 --- a/tests/components/abode/conftest.py +++ b/tests/components/abode/conftest.py @@ -1,17 +1,18 @@ """Configuration for Abode tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from jaraco.abode.helpers import urls as URL import pytest +from requests_mock import Mocker +from typing_extensions import Generator from tests.common import load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.abode.async_setup_entry", return_value=True @@ -20,7 +21,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(autouse=True) -def requests_mock_fixture(requests_mock) -> None: +def requests_mock_fixture(requests_mock: Mocker) -> None: """Fixture to provide a requests mocker.""" # Mocks the login response for abodepy. requests_mock.post(URL.LOGIN, text=load_fixture("login.json", "abode")) diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index 58e9ccb2c41..9fca6dcbdd3 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -8,12 +8,7 @@ from jaraco.abode.exceptions import ( Exception as AbodeException, ) -from homeassistant.components.abode import ( - DOMAIN as ABODE_DOMAIN, - SERVICE_CAPTURE_IMAGE, - SERVICE_SETTINGS, - SERVICE_TRIGGER_AUTOMATION, -) +from homeassistant.components.abode import DOMAIN as ABODE_DOMAIN, SERVICE_SETTINGS from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_USERNAME @@ -62,12 +57,8 @@ async def test_unload_entry(hass: HomeAssistant) -> None: patch("jaraco.abode.event_controller.EventController.stop") as mock_events_stop, ): assert await hass.config_entries.async_unload(mock_entry.entry_id) - mock_logout.assert_called_once() - mock_events_stop.assert_called_once() - - assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_SETTINGS) - assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_CAPTURE_IMAGE) - assert not hass.services.has_service(ABODE_DOMAIN, SERVICE_TRIGGER_AUTOMATION) + mock_logout.assert_called_once() + mock_events_stop.assert_called_once() async def test_invalid_credentials(hass: HomeAssistant) -> None: diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index a08b894ebb4..0e5313ceb94 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -1,17 +1,12 @@ """Tests for AccuWeather.""" -from unittest.mock import PropertyMock, patch - from homeassistant.components.accuweather.const import DOMAIN +from homeassistant.core import HomeAssistant -from tests.common import ( - MockConfigEntry, - load_json_array_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry -async def init_integration(hass, unsupported_icon=False) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the AccuWeather integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, @@ -25,29 +20,8 @@ async def init_integration(hass, unsupported_icon=False) -> MockConfigEntry: }, ) - current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") - - if unsupported_icon: - current["WeatherIcon"] = 999 - - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/accuweather/conftest.py b/tests/components/accuweather/conftest.py new file mode 100644 index 00000000000..3b0006068ea --- /dev/null +++ b/tests/components/accuweather/conftest.py @@ -0,0 +1,36 @@ +"""Common fixtures for the AccuWeather tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.accuweather.const import DOMAIN + +from tests.common import load_json_array_fixture, load_json_object_fixture + + +@pytest.fixture +def mock_accuweather_client() -> Generator[AsyncMock]: + """Mock a AccuWeather client.""" + current = load_json_object_fixture("current_conditions_data.json", DOMAIN) + forecast = load_json_array_fixture("forecast_data.json", DOMAIN) + location = load_json_object_fixture("location_data.json", DOMAIN) + + with ( + patch( + "homeassistant.components.accuweather.AccuWeather", autospec=True + ) as mock_client, + patch( + "homeassistant.components.accuweather.config_flow.AccuWeather", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_location.return_value = location + client.async_get_current_conditions.return_value = current + client.async_get_daily_forecast.return_value = forecast + client.location_key = "0123456" + client.requests_remaining = 10 + + yield client diff --git a/tests/components/accuweather/fixtures/forecast_data.json b/tests/components/accuweather/fixtures/forecast_data.json index a7d57af113a..cd40705314b 100644 --- a/tests/components/accuweather/fixtures/forecast_data.json +++ b/tests/components/accuweather/fixtures/forecast_data.json @@ -76,6 +76,7 @@ "Unit": "C", "UnitType": 17 }, + "RelativeHumidityDay": { "Average": 60 }, "IconDay": 17, "IconPhraseDay": "Partly sunny w/ t-storms", "HasPrecipitationDay": true, @@ -286,6 +287,7 @@ "Unit": "C", "UnitType": 17 }, + "RelativeHumidityDay": { "Average": 58 }, "IconDay": 4, "IconPhraseDay": "Intermittent clouds", "HasPrecipitationDay": false, @@ -492,6 +494,7 @@ "Unit": "C", "UnitType": 17 }, + "RelativeHumidityDay": { "Average": 52 }, "IconDay": 4, "IconPhraseDay": "Intermittent clouds", "HasPrecipitationDay": false, @@ -698,6 +701,7 @@ "Unit": "C", "UnitType": 17 }, + "RelativeHumidityDay": { "Average": 65 }, "IconDay": 3, "IconPhraseDay": "Partly sunny", "HasPrecipitationDay": false, @@ -904,6 +908,7 @@ "Unit": "C", "UnitType": 17 }, + "RelativeHumidityDay": { "Average": 55 }, "IconDay": 4, "IconPhraseDay": "Intermittent clouds", "HasPrecipitationDay": false, diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 42783f375b0..5e28be5a72b 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -1,4 +1,69 @@ # serializer version: 1 +# name: test_sensor[sensor.home_air_quality_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': '0123456-airquality-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 0', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- # name: test_sensor[sensor.home_air_quality_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -31,12 +96,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:air-filter', + 'original_icon': None, 'original_name': 'Air quality day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'air_quality_1d', + 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-1', 'unit_of_measurement': None, }) @@ -47,7 +112,6 @@ 'attribution': 'Data provided by AccuWeather', 'device_class': 'enum', 'friendly_name': 'Home Air quality day 1', - 'icon': 'mdi:air-filter', 'options': list([ 'good', 'hazardous', @@ -97,12 +161,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:air-filter', + 'original_icon': None, 'original_name': 'Air quality day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'air_quality_2d', + 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-2', 'unit_of_measurement': None, }) @@ -113,7 +177,6 @@ 'attribution': 'Data provided by AccuWeather', 'device_class': 'enum', 'friendly_name': 'Home Air quality day 2', - 'icon': 'mdi:air-filter', 'options': list([ 'good', 'hazardous', @@ -163,12 +226,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:air-filter', + 'original_icon': None, 'original_name': 'Air quality day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'air_quality_3d', + 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-3', 'unit_of_measurement': None, }) @@ -179,7 +242,6 @@ 'attribution': 'Data provided by AccuWeather', 'device_class': 'enum', 'friendly_name': 'Home Air quality day 3', - 'icon': 'mdi:air-filter', 'options': list([ 'good', 'hazardous', @@ -229,12 +291,12 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:air-filter', + 'original_icon': None, 'original_name': 'Air quality day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'air_quality_4d', + 'translation_key': 'air_quality', 'unique_id': '0123456-airquality-4', 'unit_of_measurement': None, }) @@ -245,7 +307,6 @@ 'attribution': 'Data provided by AccuWeather', 'device_class': 'enum', 'friendly_name': 'Home Air quality day 4', - 'icon': 'mdi:air-filter', 'options': list([ 'good', 'hazardous', @@ -263,72 +324,6 @@ 'state': 'good', }) # --- -# name: test_sensor[sensor.home_air_quality_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'good', - 'hazardous', - 'high', - 'low', - 'moderate', - 'unhealthy', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_air_quality_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:air-filter', - 'original_name': 'Air quality today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'air_quality_0d', - 'unique_id': '0123456-airquality-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensor[sensor.home_air_quality_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'device_class': 'enum', - 'friendly_name': 'Home Air quality today', - 'icon': 'mdi:air-filter', - 'options': list([ - 'good', - 'hazardous', - 'high', - 'low', - 'moderate', - 'unhealthy', - ]), - }), - 'context': , - 'entity_id': 'sensor.home_air_quality_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'good', - }) -# --- # name: test_sensor[sensor.home_apparent_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -409,7 +404,7 @@ }), }), 'original_device_class': , - 'original_icon': 'mdi:weather-fog', + 'original_icon': None, 'original_name': 'Cloud ceiling', 'platform': 'accuweather', 'previous_unique_id': None, @@ -425,7 +420,6 @@ 'attribution': 'Data provided by AccuWeather', 'device_class': 'distance', 'friendly_name': 'Home Cloud ceiling', - 'icon': 'mdi:weather-fog', 'state_class': , 'unit_of_measurement': , }), @@ -462,7 +456,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover', 'platform': 'accuweather', 'previous_unique_id': None, @@ -477,7 +471,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover', - 'icon': 'mdi:weather-cloudy', 'state_class': , 'unit_of_measurement': '%', }), @@ -489,6 +482,54 @@ 'state': '10', }) # --- +# name: test_sensor[sensor.home_cloud_cover_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud cover day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day', + 'unique_id': '0123456-cloudcoverday-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 0', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '58', + }) +# --- # name: test_sensor[sensor.home_cloud_cover_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -512,12 +553,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_day_1d', + 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-1', 'unit_of_measurement': '%', }) @@ -527,7 +568,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover day 1', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -561,12 +601,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_day_2d', + 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-2', 'unit_of_measurement': '%', }) @@ -576,7 +616,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover day 2', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -610,12 +649,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_day_3d', + 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-3', 'unit_of_measurement': '%', }) @@ -625,7 +664,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover day 3', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -659,12 +697,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_day_4d', + 'translation_key': 'cloud_cover_day', 'unique_id': '0123456-cloudcoverday-4', 'unit_of_measurement': '%', }) @@ -674,7 +712,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover day 4', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -685,6 +722,54 @@ 'state': '50', }) # --- +# name: test_sensor[sensor.home_cloud_cover_night_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cloud cover night 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night', + 'unique_id': '0123456-cloudcovernight-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 0', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- # name: test_sensor[sensor.home_cloud_cover_night_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -708,12 +793,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover night 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_night_1d', + 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-1', 'unit_of_measurement': '%', }) @@ -723,7 +808,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover night 1', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -757,12 +841,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover night 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_night_2d', + 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-2', 'unit_of_measurement': '%', }) @@ -772,7 +856,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover night 2', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -806,12 +889,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover night 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_night_3d', + 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-3', 'unit_of_measurement': '%', }) @@ -821,7 +904,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover night 3', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -855,12 +937,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', + 'original_icon': None, 'original_name': 'Cloud cover night 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_night_4d', + 'translation_key': 'cloud_cover_night', 'unique_id': '0123456-cloudcovernight-4', 'unit_of_measurement': '%', }) @@ -870,7 +952,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Cloud cover night 4', - 'icon': 'mdi:weather-cloudy', 'unit_of_measurement': '%', }), 'context': , @@ -881,7 +962,7 @@ 'state': '13', }) # --- -# name: test_sensor[sensor.home_cloud_cover_today-entry] +# name: test_sensor[sensor.home_condition_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -893,7 +974,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_cloud_cover_today', + 'entity_id': 'sensor.home_condition_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -904,79 +985,28 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', - 'original_name': 'Cloud cover today', + 'original_icon': None, + 'original_name': 'Condition day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'cloud_cover_day_0d', - 'unique_id': '0123456-cloudcoverday-0', - 'unit_of_measurement': '%', + 'translation_key': 'condition_day', + 'unique_id': '0123456-longphraseday-0', + 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.home_cloud_cover_today-state] +# name: test_sensor[sensor.home_condition_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Cloud cover today', - 'icon': 'mdi:weather-cloudy', - 'unit_of_measurement': '%', + 'friendly_name': 'Home Condition day 0', }), 'context': , - 'entity_id': 'sensor.home_cloud_cover_today', + 'entity_id': 'sensor.home_condition_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '58', - }) -# --- -# name: test_sensor[sensor.home_cloud_cover_tonight-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_cloud_cover_tonight', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-cloudy', - 'original_name': 'Cloud cover tonight', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'cloud_cover_night_0d', - 'unique_id': '0123456-cloudcovernight-0', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensor[sensor.home_cloud_cover_tonight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Cloud cover tonight', - 'icon': 'mdi:weather-cloudy', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.home_cloud_cover_tonight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '65', + 'state': 'Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon', }) # --- # name: test_sensor[sensor.home_condition_day_1-entry] @@ -1007,7 +1037,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_day_1d', + 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-1', 'unit_of_measurement': None, }) @@ -1054,7 +1084,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_day_2d', + 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-2', 'unit_of_measurement': None, }) @@ -1101,7 +1131,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_day_3d', + 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-3', 'unit_of_measurement': None, }) @@ -1148,7 +1178,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_day_4d', + 'translation_key': 'condition_day', 'unique_id': '0123456-longphraseday-4', 'unit_of_measurement': None, }) @@ -1167,6 +1197,53 @@ 'state': 'Intervals of clouds and sunshine', }) # --- +# name: test_sensor[sensor.home_condition_night_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night', + 'unique_id': '0123456-longphrasenight-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 0', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Partly cloudy', + }) +# --- # name: test_sensor[sensor.home_condition_night_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1195,7 +1272,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_night_1d', + 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-1', 'unit_of_measurement': None, }) @@ -1242,7 +1319,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_night_2d', + 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-2', 'unit_of_measurement': None, }) @@ -1289,7 +1366,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_night_3d', + 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-3', 'unit_of_measurement': None, }) @@ -1336,7 +1413,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'condition_night_4d', + 'translation_key': 'condition_night', 'unique_id': '0123456-longphrasenight-4', 'unit_of_measurement': None, }) @@ -1355,100 +1432,6 @@ 'state': 'Mostly clear', }) # --- -# name: test_sensor[sensor.home_condition_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_condition_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Condition today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'condition_day_0d', - 'unique_id': '0123456-longphraseday-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensor[sensor.home_condition_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Condition today', - }), - 'context': , - 'entity_id': 'sensor.home_condition_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon', - }) -# --- -# name: test_sensor[sensor.home_condition_tonight-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_condition_tonight', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Condition tonight', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'condition_night_0d', - 'unique_id': '0123456-longphrasenight-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensor[sensor.home_condition_tonight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Condition tonight', - }), - 'context': , - 'entity_id': 'sensor.home_condition_tonight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Partly cloudy', - }) -# --- # name: test_sensor[sensor.home_dew_point-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1501,6 +1484,55 @@ 'state': '16.2', }) # --- +# name: test_sensor[sensor.home_grass_pollen_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grass pollen day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen', + 'unique_id': '0123456-grass-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 0', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensor[sensor.home_grass_pollen_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1524,12 +1556,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:grass', + 'original_icon': None, 'original_name': 'Grass pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'grass_pollen_1d', + 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-1', 'unit_of_measurement': 'p/m³', }) @@ -1539,7 +1571,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Grass pollen day 1', - 'icon': 'mdi:grass', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -1574,12 +1605,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:grass', + 'original_icon': None, 'original_name': 'Grass pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'grass_pollen_2d', + 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-2', 'unit_of_measurement': 'p/m³', }) @@ -1589,7 +1620,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Grass pollen day 2', - 'icon': 'mdi:grass', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -1624,12 +1654,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:grass', + 'original_icon': None, 'original_name': 'Grass pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'grass_pollen_3d', + 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-3', 'unit_of_measurement': 'p/m³', }) @@ -1639,7 +1669,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Grass pollen day 3', - 'icon': 'mdi:grass', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -1674,12 +1703,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:grass', + 'original_icon': None, 'original_name': 'Grass pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'grass_pollen_4d', + 'translation_key': 'grass_pollen', 'unique_id': '0123456-grass-4', 'unit_of_measurement': 'p/m³', }) @@ -1689,7 +1718,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Grass pollen day 4', - 'icon': 'mdi:grass', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -1701,7 +1729,7 @@ 'state': '0', }) # --- -# name: test_sensor[sensor.home_grass_pollen_today-entry] +# name: test_sensor[sensor.home_hours_of_sun_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1713,7 +1741,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_grass_pollen_today', + 'entity_id': 'sensor.home_hours_of_sun_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1724,31 +1752,29 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:grass', - 'original_name': 'Grass pollen today', + 'original_icon': None, + 'original_name': 'Hours of sun day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'grass_pollen_0d', - 'unique_id': '0123456-grass-0', - 'unit_of_measurement': 'p/m³', + 'translation_key': 'hours_of_sun', + 'unique_id': '0123456-hoursofsun-0', + 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.home_grass_pollen_today-state] +# name: test_sensor[sensor.home_hours_of_sun_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Grass pollen today', - 'icon': 'mdi:grass', - 'level': 'low', - 'unit_of_measurement': 'p/m³', + 'friendly_name': 'Home Hours of sun day 0', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.home_grass_pollen_today', + 'entity_id': 'sensor.home_hours_of_sun_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '7.2', }) # --- # name: test_sensor[sensor.home_hours_of_sun_day_1-entry] @@ -1774,12 +1800,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-partly-cloudy', + 'original_icon': None, 'original_name': 'Hours of sun day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'hours_of_sun_1d', + 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-1', 'unit_of_measurement': , }) @@ -1789,7 +1815,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Hours of sun day 1', - 'icon': 'mdi:weather-partly-cloudy', 'unit_of_measurement': , }), 'context': , @@ -1823,12 +1848,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-partly-cloudy', + 'original_icon': None, 'original_name': 'Hours of sun day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'hours_of_sun_2d', + 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-2', 'unit_of_measurement': , }) @@ -1838,7 +1863,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Hours of sun day 2', - 'icon': 'mdi:weather-partly-cloudy', 'unit_of_measurement': , }), 'context': , @@ -1872,12 +1896,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-partly-cloudy', + 'original_icon': None, 'original_name': 'Hours of sun day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'hours_of_sun_3d', + 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-3', 'unit_of_measurement': , }) @@ -1887,7 +1911,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Hours of sun day 3', - 'icon': 'mdi:weather-partly-cloudy', 'unit_of_measurement': , }), 'context': , @@ -1921,12 +1944,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-partly-cloudy', + 'original_icon': None, 'original_name': 'Hours of sun day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'hours_of_sun_4d', + 'translation_key': 'hours_of_sun', 'unique_id': '0123456-hoursofsun-4', 'unit_of_measurement': , }) @@ -1936,7 +1959,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Hours of sun day 4', - 'icon': 'mdi:weather-partly-cloudy', 'unit_of_measurement': , }), 'context': , @@ -1947,7 +1969,7 @@ 'state': '9.2', }) # --- -# name: test_sensor[sensor.home_hours_of_sun_today-entry] +# name: test_sensor[sensor.home_mold_pollen_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1959,7 +1981,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_hours_of_sun_today', + 'entity_id': 'sensor.home_mold_pollen_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1970,30 +1992,30 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-partly-cloudy', - 'original_name': 'Hours of sun today', + 'original_icon': None, + 'original_name': 'Mold pollen day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'hours_of_sun_0d', - 'unique_id': '0123456-hoursofsun-0', - 'unit_of_measurement': , + 'translation_key': 'mold_pollen', + 'unique_id': '0123456-mold-0', + 'unit_of_measurement': 'p/m³', }) # --- -# name: test_sensor[sensor.home_hours_of_sun_today-state] +# name: test_sensor[sensor.home_mold_pollen_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Hours of sun today', - 'icon': 'mdi:weather-partly-cloudy', - 'unit_of_measurement': , + 'friendly_name': 'Home Mold pollen day 0', + 'level': 'low', + 'unit_of_measurement': 'p/m³', }), 'context': , - 'entity_id': 'sensor.home_hours_of_sun_today', + 'entity_id': 'sensor.home_mold_pollen_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '7.2', + 'state': '0', }) # --- # name: test_sensor[sensor.home_mold_pollen_day_1-entry] @@ -2019,12 +2041,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:blur', + 'original_icon': None, 'original_name': 'Mold pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mold_pollen_1d', + 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-1', 'unit_of_measurement': 'p/m³', }) @@ -2034,7 +2056,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Mold pollen day 1', - 'icon': 'mdi:blur', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2069,12 +2090,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:blur', + 'original_icon': None, 'original_name': 'Mold pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mold_pollen_2d', + 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-2', 'unit_of_measurement': 'p/m³', }) @@ -2084,7 +2105,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Mold pollen day 2', - 'icon': 'mdi:blur', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2119,12 +2139,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:blur', + 'original_icon': None, 'original_name': 'Mold pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mold_pollen_3d', + 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-3', 'unit_of_measurement': 'p/m³', }) @@ -2134,7 +2154,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Mold pollen day 3', - 'icon': 'mdi:blur', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2169,12 +2188,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:blur', + 'original_icon': None, 'original_name': 'Mold pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'mold_pollen_4d', + 'translation_key': 'mold_pollen', 'unique_id': '0123456-mold-4', 'unit_of_measurement': 'p/m³', }) @@ -2184,7 +2203,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Mold pollen day 4', - 'icon': 'mdi:blur', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2196,56 +2214,6 @@ 'state': '0', }) # --- -# name: test_sensor[sensor.home_mold_pollen_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_mold_pollen_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:blur', - 'original_name': 'Mold pollen today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'mold_pollen_0d', - 'unique_id': '0123456-mold-0', - 'unit_of_measurement': 'p/m³', - }) -# --- -# name: test_sensor[sensor.home_mold_pollen_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Mold pollen today', - 'icon': 'mdi:blur', - 'level': 'low', - 'unit_of_measurement': 'p/m³', - }), - 'context': , - 'entity_id': 'sensor.home_mold_pollen_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_sensor[sensor.home_precipitation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2328,7 +2296,7 @@ 'options': dict({ }), 'original_device_class': , - 'original_icon': 'mdi:gauge', + 'original_icon': None, 'original_name': 'Pressure tendency', 'platform': 'accuweather', 'previous_unique_id': None, @@ -2344,7 +2312,6 @@ 'attribution': 'Data provided by AccuWeather', 'device_class': 'enum', 'friendly_name': 'Home Pressure tendency', - 'icon': 'mdi:gauge', 'options': list([ 'falling', 'rising', @@ -2359,6 +2326,55 @@ 'state': 'falling', }) # --- +# name: test_sensor[sensor.home_ragweed_pollen_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ragweed pollen day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen', + 'unique_id': '0123456-ragweed-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 0', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensor[sensor.home_ragweed_pollen_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2382,12 +2398,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:sprout', + 'original_icon': None, 'original_name': 'Ragweed pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'ragweed_pollen_1d', + 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-1', 'unit_of_measurement': 'p/m³', }) @@ -2397,7 +2413,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Ragweed pollen day 1', - 'icon': 'mdi:sprout', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2432,12 +2447,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:sprout', + 'original_icon': None, 'original_name': 'Ragweed pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'ragweed_pollen_2d', + 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-2', 'unit_of_measurement': 'p/m³', }) @@ -2447,7 +2462,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Ragweed pollen day 2', - 'icon': 'mdi:sprout', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2482,12 +2496,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:sprout', + 'original_icon': None, 'original_name': 'Ragweed pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'ragweed_pollen_3d', + 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-3', 'unit_of_measurement': 'p/m³', }) @@ -2497,7 +2511,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Ragweed pollen day 3', - 'icon': 'mdi:sprout', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2532,12 +2545,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:sprout', + 'original_icon': None, 'original_name': 'Ragweed pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'ragweed_pollen_4d', + 'translation_key': 'ragweed_pollen', 'unique_id': '0123456-ragweed-4', 'unit_of_measurement': 'p/m³', }) @@ -2547,7 +2560,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Ragweed pollen day 4', - 'icon': 'mdi:sprout', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -2559,56 +2571,6 @@ 'state': '0', }) # --- -# name: test_sensor[sensor.home_ragweed_pollen_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_ragweed_pollen_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:sprout', - 'original_name': 'Ragweed pollen today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'ragweed_pollen_0d', - 'unique_id': '0123456-ragweed-0', - 'unit_of_measurement': 'p/m³', - }) -# --- -# name: test_sensor[sensor.home_ragweed_pollen_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Ragweed pollen today', - 'icon': 'mdi:sprout', - 'level': 'low', - 'unit_of_measurement': 'p/m³', - }), - 'context': , - 'entity_id': 'sensor.home_ragweed_pollen_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_sensor[sensor.home_realfeel_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2661,6 +2623,55 @@ 'state': '25.1', }) # --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max', + 'unique_id': '0123456-realfeeltemperaturemax-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 0', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.8', + }) +# --- # name: test_sensor[sensor.home_realfeel_temperature_max_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2689,7 +2700,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_max_1d', + 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-1', 'unit_of_measurement': , }) @@ -2738,7 +2749,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_max_2d', + 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-2', 'unit_of_measurement': , }) @@ -2787,7 +2798,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_max_3d', + 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-3', 'unit_of_measurement': , }) @@ -2836,7 +2847,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_max_4d', + 'translation_key': 'realfeel_temperature_max', 'unique_id': '0123456-realfeeltemperaturemax-4', 'unit_of_measurement': , }) @@ -2857,7 +2868,7 @@ 'state': '22.2', }) # --- -# name: test_sensor[sensor.home_realfeel_temperature_max_today-entry] +# name: test_sensor[sensor.home_realfeel_temperature_min_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2869,7 +2880,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_realfeel_temperature_max_today', + 'entity_id': 'sensor.home_realfeel_temperature_min_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2881,29 +2892,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'RealFeel temperature max today', + 'original_name': 'RealFeel temperature min day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_max_0d', - 'unique_id': '0123456-realfeeltemperaturemax-0', + 'translation_key': 'realfeel_temperature_min', + 'unique_id': '0123456-realfeeltemperaturemin-0', 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.home_realfeel_temperature_max_today-state] +# name: test_sensor[sensor.home_realfeel_temperature_min_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'device_class': 'temperature', - 'friendly_name': 'Home RealFeel temperature max today', + 'friendly_name': 'Home RealFeel temperature min day 0', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.home_realfeel_temperature_max_today', + 'entity_id': 'sensor.home_realfeel_temperature_min_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '29.8', + 'state': '15.1', }) # --- # name: test_sensor[sensor.home_realfeel_temperature_min_day_1-entry] @@ -2934,7 +2945,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_min_1d', + 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-1', 'unit_of_measurement': , }) @@ -2983,7 +2994,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_min_2d', + 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-2', 'unit_of_measurement': , }) @@ -3032,7 +3043,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_min_3d', + 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-3', 'unit_of_measurement': , }) @@ -3081,7 +3092,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_min_4d', + 'translation_key': 'realfeel_temperature_min', 'unique_id': '0123456-realfeeltemperaturemin-4', 'unit_of_measurement': , }) @@ -3102,55 +3113,6 @@ 'state': '11.3', }) # --- -# name: test_sensor[sensor.home_realfeel_temperature_min_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_realfeel_temperature_min_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'RealFeel temperature min today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'realfeel_temperature_min_0d', - 'unique_id': '0123456-realfeeltemperaturemin-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.home_realfeel_temperature_min_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'device_class': 'temperature', - 'friendly_name': 'Home RealFeel temperature min today', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.home_realfeel_temperature_min_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '15.1', - }) -# --- # name: test_sensor[sensor.home_realfeel_temperature_shade-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3203,6 +3165,55 @@ 'state': '21.1', }) # --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max', + 'unique_id': '0123456-realfeeltemperatureshademax-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 0', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.0', + }) +# --- # name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3231,7 +3242,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_max_1d', + 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-1', 'unit_of_measurement': , }) @@ -3280,7 +3291,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_max_2d', + 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-2', 'unit_of_measurement': , }) @@ -3329,7 +3340,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_max_3d', + 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-3', 'unit_of_measurement': , }) @@ -3378,7 +3389,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_max_4d', + 'translation_key': 'realfeel_temperature_shade_max', 'unique_id': '0123456-realfeeltemperatureshademax-4', 'unit_of_measurement': , }) @@ -3399,7 +3410,7 @@ 'state': '19.5', }) # --- -# name: test_sensor[sensor.home_realfeel_temperature_shade_max_today-entry] +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3411,7 +3422,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_realfeel_temperature_shade_max_today', + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3423,29 +3434,29 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'RealFeel temperature shade max today', + 'original_name': 'RealFeel temperature shade min day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_max_0d', - 'unique_id': '0123456-realfeeltemperatureshademax-0', + 'translation_key': 'realfeel_temperature_shade_min', + 'unique_id': '0123456-realfeeltemperatureshademin-0', 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.home_realfeel_temperature_shade_max_today-state] +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'device_class': 'temperature', - 'friendly_name': 'Home RealFeel temperature shade max today', + 'friendly_name': 'Home RealFeel temperature shade min day 0', 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.home_realfeel_temperature_shade_max_today', + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '28.0', + 'state': '15.1', }) # --- # name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_1-entry] @@ -3476,7 +3487,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_min_1d', + 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-1', 'unit_of_measurement': , }) @@ -3525,7 +3536,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_min_2d', + 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-2', 'unit_of_measurement': , }) @@ -3574,7 +3585,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_min_3d', + 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-3', 'unit_of_measurement': , }) @@ -3623,7 +3634,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_min_4d', + 'translation_key': 'realfeel_temperature_shade_min', 'unique_id': '0123456-realfeeltemperatureshademin-4', 'unit_of_measurement': , }) @@ -3644,7 +3655,7 @@ 'state': '11.3', }) # --- -# name: test_sensor[sensor.home_realfeel_temperature_shade_min_today-entry] +# name: test_sensor[sensor.home_solar_irradiance_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3656,7 +3667,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_realfeel_temperature_shade_min_today', + 'entity_id': 'sensor.home_solar_irradiance_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3666,31 +3677,31 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'RealFeel temperature shade min today', + 'original_name': 'Solar irradiance day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'realfeel_temperature_shade_min_0d', - 'unique_id': '0123456-realfeeltemperatureshademin-0', - 'unit_of_measurement': , + 'translation_key': 'solar_irradiance_day', + 'unique_id': '0123456-solarirradianceday-0', + 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.home_realfeel_temperature_shade_min_today-state] +# name: test_sensor[sensor.home_solar_irradiance_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', - 'device_class': 'temperature', - 'friendly_name': 'Home RealFeel temperature shade min today', - 'unit_of_measurement': , + 'device_class': 'irradiance', + 'friendly_name': 'Home Solar irradiance day 0', + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.home_realfeel_temperature_shade_min_today', + 'entity_id': 'sensor.home_solar_irradiance_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15.1', + 'state': '7447.1', }) # --- # name: test_sensor[sensor.home_solar_irradiance_day_1-entry] @@ -3715,13 +3726,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_day_1d', + 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-1', 'unit_of_measurement': , }) @@ -3730,8 +3741,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance day 1', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -3764,13 +3775,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_day_2d', + 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-2', 'unit_of_measurement': , }) @@ -3779,8 +3790,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance day 2', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -3813,13 +3824,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_day_3d', + 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-3', 'unit_of_measurement': , }) @@ -3828,8 +3839,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance day 3', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -3862,13 +3873,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_day_4d', + 'translation_key': 'solar_irradiance_day', 'unique_id': '0123456-solarirradianceday-4', 'unit_of_measurement': , }) @@ -3877,8 +3888,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance day 4', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -3889,6 +3900,55 @@ 'state': '7447.1', }) # --- +# name: test_sensor[sensor.home_solar_irradiance_night_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar irradiance night 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night', + 'unique_id': '0123456-solarirradiancenight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', + 'friendly_name': 'Home Solar irradiance night 0', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '271.6', + }) +# --- # name: test_sensor[sensor.home_solar_irradiance_night_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3911,13 +3971,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance night 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_night_1d', + 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-1', 'unit_of_measurement': , }) @@ -3926,8 +3986,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance night 1', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -3960,13 +4020,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance night 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_night_2d', + 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-2', 'unit_of_measurement': , }) @@ -3975,8 +4035,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance night 2', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -4009,13 +4069,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance night 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_night_3d', + 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-3', 'unit_of_measurement': , }) @@ -4024,8 +4084,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance night 3', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -4058,13 +4118,13 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_device_class': , + 'original_icon': None, 'original_name': 'Solar irradiance night 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_night_4d', + 'translation_key': 'solar_irradiance_night', 'unique_id': '0123456-solarirradiancenight-4', 'unit_of_measurement': , }) @@ -4073,8 +4133,8 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', + 'device_class': 'irradiance', 'friendly_name': 'Home Solar irradiance night 4', - 'icon': 'mdi:weather-sunny', 'unit_of_measurement': , }), 'context': , @@ -4085,7 +4145,7 @@ 'state': '276.1', }) # --- -# name: test_sensor[sensor.home_solar_irradiance_today-entry] +# name: test_sensor[sensor.home_thunderstorm_probability_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4097,7 +4157,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_solar_irradiance_today', + 'entity_id': 'sensor.home_thunderstorm_probability_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4108,79 +4168,29 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', - 'original_name': 'Solar irradiance today', + 'original_icon': None, + 'original_name': 'Thunderstorm probability day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'solar_irradiance_day_0d', - 'unique_id': '0123456-solarirradianceday-0', - 'unit_of_measurement': , + 'translation_key': 'thunderstorm_probability_day', + 'unique_id': '0123456-thunderstormprobabilityday-0', + 'unit_of_measurement': '%', }) # --- -# name: test_sensor[sensor.home_solar_irradiance_today-state] +# name: test_sensor[sensor.home_thunderstorm_probability_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Solar irradiance today', - 'icon': 'mdi:weather-sunny', - 'unit_of_measurement': , + 'friendly_name': 'Home Thunderstorm probability day 0', + 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.home_solar_irradiance_today', + 'entity_id': 'sensor.home_thunderstorm_probability_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '7447.1', - }) -# --- -# name: test_sensor[sensor.home_solar_irradiance_tonight-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_solar_irradiance_tonight', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', - 'original_name': 'Solar irradiance tonight', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'solar_irradiance_night_0d', - 'unique_id': '0123456-solarirradiancenight-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.home_solar_irradiance_tonight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Solar irradiance tonight', - 'icon': 'mdi:weather-sunny', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.home_solar_irradiance_tonight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '271.6', + 'state': '40', }) # --- # name: test_sensor[sensor.home_thunderstorm_probability_day_1-entry] @@ -4206,12 +4216,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_day_1d', + 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-1', 'unit_of_measurement': '%', }) @@ -4221,7 +4231,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability day 1', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4255,12 +4264,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_day_2d', + 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-2', 'unit_of_measurement': '%', }) @@ -4270,7 +4279,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability day 2', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4304,12 +4312,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_day_3d', + 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-3', 'unit_of_measurement': '%', }) @@ -4319,7 +4327,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability day 3', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4353,12 +4360,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_day_4d', + 'translation_key': 'thunderstorm_probability_day', 'unique_id': '0123456-thunderstormprobabilityday-4', 'unit_of_measurement': '%', }) @@ -4368,7 +4375,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability day 4', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4379,6 +4385,54 @@ 'state': '0', }) # --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thunderstorm probability night 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night', + 'unique_id': '0123456-thunderstormprobabilitynight-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 0', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- # name: test_sensor[sensor.home_thunderstorm_probability_night_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4402,12 +4456,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability night 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_night_1d', + 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-1', 'unit_of_measurement': '%', }) @@ -4417,7 +4471,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability night 1', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4451,12 +4504,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability night 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_night_2d', + 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-2', 'unit_of_measurement': '%', }) @@ -4466,7 +4519,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability night 2', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4500,12 +4552,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability night 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_night_3d', + 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-3', 'unit_of_measurement': '%', }) @@ -4515,7 +4567,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability night 3', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4549,12 +4600,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', + 'original_icon': None, 'original_name': 'Thunderstorm probability night 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_night_4d', + 'translation_key': 'thunderstorm_probability_night', 'unique_id': '0123456-thunderstormprobabilitynight-4', 'unit_of_measurement': '%', }) @@ -4564,7 +4615,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Thunderstorm probability night 4', - 'icon': 'mdi:weather-lightning', 'unit_of_measurement': '%', }), 'context': , @@ -4575,7 +4625,7 @@ 'state': '0', }) # --- -# name: test_sensor[sensor.home_thunderstorm_probability_today-entry] +# name: test_sensor[sensor.home_tree_pollen_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4587,7 +4637,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.home_thunderstorm_probability_today', + 'entity_id': 'sensor.home_tree_pollen_day_0', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4598,79 +4648,30 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', - 'original_name': 'Thunderstorm probability today', + 'original_icon': None, + 'original_name': 'Tree pollen day 0', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_day_0d', - 'unique_id': '0123456-thunderstormprobabilityday-0', - 'unit_of_measurement': '%', + 'translation_key': 'tree_pollen', + 'unique_id': '0123456-tree-0', + 'unit_of_measurement': 'p/m³', }) # --- -# name: test_sensor[sensor.home_thunderstorm_probability_today-state] +# name: test_sensor[sensor.home_tree_pollen_day_0-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Thunderstorm probability today', - 'icon': 'mdi:weather-lightning', - 'unit_of_measurement': '%', + 'friendly_name': 'Home Tree pollen day 0', + 'level': 'low', + 'unit_of_measurement': 'p/m³', }), 'context': , - 'entity_id': 'sensor.home_thunderstorm_probability_today', + 'entity_id': 'sensor.home_tree_pollen_day_0', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '40', - }) -# --- -# name: test_sensor[sensor.home_thunderstorm_probability_tonight-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_thunderstorm_probability_tonight', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-lightning', - 'original_name': 'Thunderstorm probability tonight', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'thunderstorm_probability_night_0d', - 'unique_id': '0123456-thunderstormprobabilitynight-0', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensor[sensor.home_thunderstorm_probability_tonight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Thunderstorm probability tonight', - 'icon': 'mdi:weather-lightning', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.home_thunderstorm_probability_tonight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '40', + 'state': '0', }) # --- # name: test_sensor[sensor.home_tree_pollen_day_1-entry] @@ -4696,12 +4697,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:tree-outline', + 'original_icon': None, 'original_name': 'Tree pollen day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'tree_pollen_1d', + 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-1', 'unit_of_measurement': 'p/m³', }) @@ -4711,7 +4712,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Tree pollen day 1', - 'icon': 'mdi:tree-outline', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -4746,12 +4746,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:tree-outline', + 'original_icon': None, 'original_name': 'Tree pollen day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'tree_pollen_2d', + 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-2', 'unit_of_measurement': 'p/m³', }) @@ -4761,7 +4761,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Tree pollen day 2', - 'icon': 'mdi:tree-outline', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -4796,12 +4795,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:tree-outline', + 'original_icon': None, 'original_name': 'Tree pollen day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'tree_pollen_3d', + 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-3', 'unit_of_measurement': 'p/m³', }) @@ -4811,7 +4810,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Tree pollen day 3', - 'icon': 'mdi:tree-outline', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -4846,12 +4844,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:tree-outline', + 'original_icon': None, 'original_name': 'Tree pollen day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'tree_pollen_4d', + 'translation_key': 'tree_pollen', 'unique_id': '0123456-tree-4', 'unit_of_measurement': 'p/m³', }) @@ -4861,7 +4859,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home Tree pollen day 4', - 'icon': 'mdi:tree-outline', 'level': 'low', 'unit_of_measurement': 'p/m³', }), @@ -4873,56 +4870,6 @@ 'state': '0', }) # --- -# name: test_sensor[sensor.home_tree_pollen_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_tree_pollen_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:tree-outline', - 'original_name': 'Tree pollen today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tree_pollen_0d', - 'unique_id': '0123456-tree-0', - 'unit_of_measurement': 'p/m³', - }) -# --- -# name: test_sensor[sensor.home_tree_pollen_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home Tree pollen today', - 'icon': 'mdi:tree-outline', - 'level': 'low', - 'unit_of_measurement': 'p/m³', - }), - 'context': , - 'entity_id': 'sensor.home_tree_pollen_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_sensor[sensor.home_uv_index-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4948,7 +4895,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_icon': None, 'original_name': 'UV index', 'platform': 'accuweather', 'previous_unique_id': None, @@ -4963,7 +4910,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home UV index', - 'icon': 'mdi:weather-sunny', 'level': 'High', 'state_class': , 'unit_of_measurement': 'UV index', @@ -4976,6 +4922,55 @@ 'state': '6', }) # --- +# name: test_sensor[sensor.home_uv_index_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV index day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_forecast', + 'unique_id': '0123456-uvindex-0', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 0', + 'level': 'moderate', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_sensor[sensor.home_uv_index_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4999,12 +4994,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_icon': None, 'original_name': 'UV index day 1', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'uv_index_1d', + 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-1', 'unit_of_measurement': 'UV index', }) @@ -5014,7 +5009,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home UV index day 1', - 'icon': 'mdi:weather-sunny', 'level': 'high', 'unit_of_measurement': 'UV index', }), @@ -5049,12 +5043,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_icon': None, 'original_name': 'UV index day 2', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'uv_index_2d', + 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-2', 'unit_of_measurement': 'UV index', }) @@ -5064,7 +5058,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home UV index day 2', - 'icon': 'mdi:weather-sunny', 'level': 'high', 'unit_of_measurement': 'UV index', }), @@ -5099,12 +5092,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_icon': None, 'original_name': 'UV index day 3', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'uv_index_3d', + 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-3', 'unit_of_measurement': 'UV index', }) @@ -5114,7 +5107,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home UV index day 3', - 'icon': 'mdi:weather-sunny', 'level': 'high', 'unit_of_measurement': 'UV index', }), @@ -5149,12 +5141,12 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', + 'original_icon': None, 'original_name': 'UV index day 4', 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'uv_index_4d', + 'translation_key': 'uv_index_forecast', 'unique_id': '0123456-uvindex-4', 'unit_of_measurement': 'UV index', }) @@ -5164,7 +5156,6 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by AccuWeather', 'friendly_name': 'Home UV index day 4', - 'icon': 'mdi:weather-sunny', 'level': 'high', 'unit_of_measurement': 'UV index', }), @@ -5176,56 +5167,6 @@ 'state': '7', }) # --- -# name: test_sensor[sensor.home_uv_index_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_uv_index_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:weather-sunny', - 'original_name': 'UV index today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'uv_index_0d', - 'unique_id': '0123456-uvindex-0', - 'unit_of_measurement': 'UV index', - }) -# --- -# name: test_sensor[sensor.home_uv_index_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'friendly_name': 'Home UV index today', - 'icon': 'mdi:weather-sunny', - 'level': 'moderate', - 'unit_of_measurement': 'UV index', - }), - 'context': , - 'entity_id': 'sensor.home_uv_index_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '5', - }) -# --- # name: test_sensor[sensor.home_wet_bulb_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5382,6 +5323,56 @@ 'state': '20.3', }) # --- +# name: test_sensor[sensor.home_wind_gust_speed_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day', + 'unique_id': '0123456-windgustday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'S', + 'friendly_name': 'Home Wind gust speed day 0', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.6', + }) +# --- # name: test_sensor[sensor.home_wind_gust_speed_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5410,7 +5401,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_day_1d', + 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-1', 'unit_of_measurement': , }) @@ -5460,7 +5451,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_day_2d', + 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-2', 'unit_of_measurement': , }) @@ -5510,7 +5501,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_day_3d', + 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-3', 'unit_of_measurement': , }) @@ -5560,7 +5551,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_day_4d', + 'translation_key': 'wind_gust_speed_day', 'unique_id': '0123456-windgustday-4', 'unit_of_measurement': , }) @@ -5582,6 +5573,56 @@ 'state': '27.8', }) # --- +# name: test_sensor[sensor.home_wind_gust_speed_night_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night', + 'unique_id': '0123456-windgustnight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WSW', + 'friendly_name': 'Home Wind gust speed night 0', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- # name: test_sensor[sensor.home_wind_gust_speed_night_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5610,7 +5651,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_night_1d', + 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-1', 'unit_of_measurement': , }) @@ -5660,7 +5701,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_night_2d', + 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-2', 'unit_of_measurement': , }) @@ -5710,7 +5751,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_night_3d', + 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-3', 'unit_of_measurement': , }) @@ -5760,7 +5801,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_gust_speed_night_4d', + 'translation_key': 'wind_gust_speed_night', 'unique_id': '0123456-windgustnight-4', 'unit_of_measurement': , }) @@ -5782,106 +5823,6 @@ 'state': '18.5', }) # --- -# name: test_sensor[sensor.home_wind_gust_speed_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_wind_gust_speed_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wind gust speed today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wind_gust_speed_day_0d', - 'unique_id': '0123456-windgustday-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.home_wind_gust_speed_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'device_class': 'wind_speed', - 'direction': 'S', - 'friendly_name': 'Home Wind gust speed today', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.home_wind_gust_speed_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.6', - }) -# --- -# name: test_sensor[sensor.home_wind_gust_speed_tonight-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_wind_gust_speed_tonight', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wind gust speed tonight', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wind_gust_speed_night_0d', - 'unique_id': '0123456-windgustnight-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.home_wind_gust_speed_tonight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'device_class': 'wind_speed', - 'direction': 'WSW', - 'friendly_name': 'Home Wind gust speed tonight', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.home_wind_gust_speed_tonight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '18.5', - }) -# --- # name: test_sensor[sensor.home_wind_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5934,6 +5875,56 @@ 'state': '14.5', }) # --- +# name: test_sensor[sensor.home_wind_speed_day_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day', + 'unique_id': '0123456-windday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSE', + 'friendly_name': 'Home Wind speed day 0', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- # name: test_sensor[sensor.home_wind_speed_day_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5962,7 +5953,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_day_1d', + 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-1', 'unit_of_measurement': , }) @@ -6012,7 +6003,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_day_2d', + 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-2', 'unit_of_measurement': , }) @@ -6062,7 +6053,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_day_3d', + 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-3', 'unit_of_measurement': , }) @@ -6112,7 +6103,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_day_4d', + 'translation_key': 'wind_speed_day', 'unique_id': '0123456-windday-4', 'unit_of_measurement': , }) @@ -6134,6 +6125,56 @@ 'state': '18.5', }) # --- +# name: test_sensor[sensor.home_wind_speed_night_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 0', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night', + 'unique_id': '0123456-windnight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WNW', + 'friendly_name': 'Home Wind speed night 0', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4', + }) +# --- # name: test_sensor[sensor.home_wind_speed_night_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6162,7 +6203,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_night_1d', + 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-1', 'unit_of_measurement': , }) @@ -6212,7 +6253,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_night_2d', + 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-2', 'unit_of_measurement': , }) @@ -6262,7 +6303,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_night_3d', + 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-3', 'unit_of_measurement': , }) @@ -6312,7 +6353,7 @@ 'platform': 'accuweather', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wind_speed_night_4d', + 'translation_key': 'wind_speed_night', 'unique_id': '0123456-windnight-4', 'unit_of_measurement': , }) @@ -6334,103 +6375,3 @@ 'state': '9.3', }) # --- -# name: test_sensor[sensor.home_wind_speed_today-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_wind_speed_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wind speed today', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wind_speed_day_0d', - 'unique_id': '0123456-windday-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.home_wind_speed_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'device_class': 'wind_speed', - 'direction': 'SSE', - 'friendly_name': 'Home Wind speed today', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.home_wind_speed_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '13.0', - }) -# --- -# name: test_sensor[sensor.home_wind_speed_tonight-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.home_wind_speed_tonight', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wind speed tonight', - 'platform': 'accuweather', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'wind_speed_night_0d', - 'unique_id': '0123456-windnight-0', - 'unit_of_measurement': , - }) -# --- -# name: test_sensor[sensor.home_wind_speed_tonight-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by AccuWeather', - 'device_class': 'wind_speed', - 'direction': 'WNW', - 'friendly_name': 'Home Wind speed tonight', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.home_wind_speed_tonight', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '7.4', - }) -# --- diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index 1542d22aa7b..49bf4008884 100644 --- a/tests/components/accuweather/snapshots/test_weather.ambr +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -7,6 +7,7 @@ 'cloud_coverage': 58, 'condition': 'lightning-rainy', 'datetime': '2020-07-26T05:00:00+00:00', + 'humidity': 60, 'precipitation': 2.5, 'precipitation_probability': 60, 'temperature': 29.5, @@ -21,6 +22,7 @@ 'cloud_coverage': 52, 'condition': 'partlycloudy', 'datetime': '2020-07-27T05:00:00+00:00', + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 25, 'temperature': 26.2, @@ -35,6 +37,7 @@ 'cloud_coverage': 65, 'condition': 'partlycloudy', 'datetime': '2020-07-28T05:00:00+00:00', + 'humidity': 52, 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 31.7, @@ -49,6 +52,7 @@ 'cloud_coverage': 45, 'condition': 'partlycloudy', 'datetime': '2020-07-29T05:00:00+00:00', + 'humidity': 65, 'precipitation': 0.0, 'precipitation_probability': 9, 'temperature': 24.0, @@ -63,6 +67,7 @@ 'cloud_coverage': 50, 'condition': 'partlycloudy', 'datetime': '2020-07-30T05:00:00+00:00', + 'humidity': 55, 'precipitation': 0.0, 'precipitation_probability': 1, 'temperature': 21.4, @@ -84,6 +89,7 @@ 'cloud_coverage': 58, 'condition': 'lightning-rainy', 'datetime': '2020-07-26T05:00:00+00:00', + 'humidity': 60, 'precipitation': 2.5, 'precipitation_probability': 60, 'temperature': 29.5, @@ -98,6 +104,7 @@ 'cloud_coverage': 52, 'condition': 'partlycloudy', 'datetime': '2020-07-27T05:00:00+00:00', + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 25, 'temperature': 26.2, @@ -112,6 +119,7 @@ 'cloud_coverage': 65, 'condition': 'partlycloudy', 'datetime': '2020-07-28T05:00:00+00:00', + 'humidity': 52, 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 31.7, @@ -126,6 +134,7 @@ 'cloud_coverage': 45, 'condition': 'partlycloudy', 'datetime': '2020-07-29T05:00:00+00:00', + 'humidity': 65, 'precipitation': 0.0, 'precipitation_probability': 9, 'temperature': 24.0, @@ -140,6 +149,7 @@ 'cloud_coverage': 50, 'condition': 'partlycloudy', 'datetime': '2020-07-30T05:00:00+00:00', + 'humidity': 55, 'precipitation': 0.0, 'precipitation_probability': 1, 'temperature': 21.4, @@ -160,6 +170,7 @@ 'cloud_coverage': 58, 'condition': 'lightning-rainy', 'datetime': '2020-07-26T05:00:00+00:00', + 'humidity': 60, 'precipitation': 2.5, 'precipitation_probability': 60, 'temperature': 29.5, @@ -174,6 +185,7 @@ 'cloud_coverage': 52, 'condition': 'partlycloudy', 'datetime': '2020-07-27T05:00:00+00:00', + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 25, 'temperature': 26.2, @@ -188,6 +200,7 @@ 'cloud_coverage': 65, 'condition': 'partlycloudy', 'datetime': '2020-07-28T05:00:00+00:00', + 'humidity': 52, 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 31.7, @@ -202,6 +215,7 @@ 'cloud_coverage': 45, 'condition': 'partlycloudy', 'datetime': '2020-07-29T05:00:00+00:00', + 'humidity': 65, 'precipitation': 0.0, 'precipitation_probability': 9, 'temperature': 24.0, @@ -216,6 +230,7 @@ 'cloud_coverage': 50, 'condition': 'partlycloudy', 'datetime': '2020-07-30T05:00:00+00:00', + 'humidity': 55, 'precipitation': 0.0, 'precipitation_probability': 1, 'temperature': 21.4, @@ -234,6 +249,7 @@ 'cloud_coverage': 58, 'condition': 'lightning-rainy', 'datetime': '2020-07-26T05:00:00+00:00', + 'humidity': 60, 'precipitation': 2.5, 'precipitation_probability': 60, 'temperature': 29.5, @@ -248,6 +264,7 @@ 'cloud_coverage': 52, 'condition': 'partlycloudy', 'datetime': '2020-07-27T05:00:00+00:00', + 'humidity': 58, 'precipitation': 0.0, 'precipitation_probability': 25, 'temperature': 26.2, @@ -262,6 +279,7 @@ 'cloud_coverage': 65, 'condition': 'partlycloudy', 'datetime': '2020-07-28T05:00:00+00:00', + 'humidity': 52, 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 31.7, @@ -276,6 +294,7 @@ 'cloud_coverage': 45, 'condition': 'partlycloudy', 'datetime': '2020-07-29T05:00:00+00:00', + 'humidity': 65, 'precipitation': 0.0, 'precipitation_probability': 9, 'temperature': 24.0, @@ -290,6 +309,7 @@ 'cloud_coverage': 50, 'condition': 'partlycloudy', 'datetime': '2020-07-30T05:00:00+00:00', + 'humidity': 55, 'precipitation': 0.0, 'precipitation_probability': 1, 'temperature': 21.4, diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 07b126e0856..abe1be61905 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -1,6 +1,6 @@ """Define tests for the AccuWeather config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError @@ -10,7 +10,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry VALID_CONFIG = { CONF_NAME: "abcd", @@ -48,95 +48,90 @@ async def test_api_key_too_short(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} -async def test_invalid_api_key(hass: HomeAssistant) -> None: +async def test_invalid_api_key( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test that errors are shown when API key is invalid.""" - with patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - side_effect=InvalidApiKeyError("Invalid API key"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) + mock_accuweather_client.async_get_location.side_effect = InvalidApiKeyError( + "Invalid API key" + ) - assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} -async def test_api_error(hass: HomeAssistant) -> None: +async def test_api_error( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test API error.""" - with patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - side_effect=ApiError("Invalid response from AccuWeather API"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) + mock_accuweather_client.async_get_location.side_effect = ApiError( + "Invalid response from AccuWeather API" + ) - assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["errors"] == {"base": "cannot_connect"} -async def test_requests_exceeded_error(hass: HomeAssistant) -> None: +async def test_requests_exceeded_error( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test requests exceeded error.""" - with patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - side_effect=RequestsExceededError( - "The allowed number of requests has been exceeded" - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) + mock_accuweather_client.async_get_location.side_effect = RequestsExceededError( + "The allowed number of requests has been exceeded" + ) - assert result["errors"] == {CONF_API_KEY: "requests_exceeded"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["errors"] == {CONF_API_KEY: "requests_exceeded"} -async def test_integration_already_exists(hass: HomeAssistant) -> None: +async def test_integration_already_exists( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test we only allow a single config flow.""" - with patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - return_value=load_json_object_fixture("accuweather/location_data.json"), - ): - MockConfigEntry( - domain=DOMAIN, - unique_id="123456", - data=VALID_CONFIG, - ).add_to_hass(hass) + MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + data=VALID_CONFIG, + ).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" -async def test_create_entry(hass: HomeAssistant) -> None: +async def test_create_entry( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test that the user step works.""" - with ( - patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - return_value=load_json_object_fixture("accuweather/location_data.json"), - ), - patch( - "homeassistant.components.accuweather.async_setup_entry", return_value=True - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "abcd" - assert result["data"][CONF_NAME] == "abcd" - assert result["data"][CONF_LATITUDE] == 55.55 - assert result["data"][CONF_LONGITUDE] == 122.12 - assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "abcd" + assert result["data"][CONF_NAME] == "abcd" + assert result["data"][CONF_LATITUDE] == 55.55 + assert result["data"][CONF_LONGITUDE] == 122.12 + assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index 593cde0f0a3..bc97ae1fe14 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -1,5 +1,7 @@ """Test AccuWeather diagnostics.""" +from unittest.mock import AsyncMock + from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -13,6 +15,7 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + mock_accuweather_client: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index 08ad4a66dec..340676905d6 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -1,8 +1,9 @@ """Test init of AccuWeather integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from accuweather import ApiError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.accuweather.const import ( DOMAIN, @@ -14,19 +15,15 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from . import init_integration -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - load_json_array_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry, async_fire_time_changed -async def test_async_setup_entry(hass: HomeAssistant) -> None: +async def test_async_setup_entry( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test a successful setup entry.""" await init_integration(hass) @@ -36,7 +33,9 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None: assert state.state == "sunny" -async def test_config_not_ready(hass: HomeAssistant) -> None: +async def test_config_not_ready( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test for setup failure if connection to AccuWeather is missing.""" entry = MockConfigEntry( domain=DOMAIN, @@ -50,16 +49,18 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: }, ) - with patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - side_effect=ApiError("API Error"), - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + mock_accuweather_client.async_get_current_conditions.side_effect = ApiError( + "API Error" + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test successful unload of entry.""" entry = await init_integration(hass) @@ -73,41 +74,36 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_update_interval(hass: HomeAssistant) -> None: +async def test_update_interval( + hass: HomeAssistant, + mock_accuweather_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: """Test correct update interval.""" entry = await init_integration(hass) assert entry.state is ConfigEntryState.LOADED - current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") + assert mock_accuweather_client.async_get_current_conditions.call_count == 1 + assert mock_accuweather_client.async_get_daily_forecast.call_count == 1 - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ) as mock_current, - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ) as mock_forecast, - ): - assert mock_current.call_count == 0 - assert mock_forecast.call_count == 0 + freezer.tick(UPDATE_INTERVAL_OBSERVATION) + async_fire_time_changed(hass) + await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_OBSERVATION) - await hass.async_block_till_done() + assert mock_accuweather_client.async_get_current_conditions.call_count == 2 - assert mock_current.call_count == 1 + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST) + async_fire_time_changed(hass) + await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST) - await hass.async_block_till_done() - - assert mock_forecast.call_count == 1 + assert mock_accuweather_client.async_get_daily_forecast.call_count == 2 async def test_remove_ozone_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_accuweather_client: AsyncMock, ) -> None: """Test remove ozone sensors from registry.""" entity_registry.async_get_or_create( diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 127e4d74cd8..41c1c0d930a 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -1,14 +1,17 @@ """Test sensor of AccuWeather integration.""" -from datetime import timedelta -from unittest.mock import PropertyMock, patch +from unittest.mock import AsyncMock, patch from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp.client_exceptions import ClientConnectorError +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.accuweather.const import UPDATE_INTERVAL_DAILY_FORECAST +from homeassistant.components.accuweather.const import ( + UPDATE_INTERVAL_DAILY_FORECAST, + UPDATE_INTERVAL_OBSERVATION, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -21,23 +24,18 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import init_integration -from tests.common import ( - async_fire_time_changed, - load_json_array_fixture, - load_json_object_fixture, - snapshot_platform, -) +from tests.common import async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, + mock_accuweather_client: AsyncMock, snapshot: SnapshotAssertion, ) -> None: """Test states of the sensor.""" @@ -46,64 +44,59 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, + mock_accuweather_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: """Ensure that we mark the entities unavailable correctly when service is offline.""" + entity_id = "sensor.home_cloud_ceiling" await init_integration(hass) - state = hass.states.get("sensor.home_cloud_ceiling") + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE assert state.state == "3200.0" - future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - side_effect=ConnectionError(), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + mock_accuweather_client.async_get_current_conditions.side_effect = ConnectionError - state = hass.states.get("sensor.home_cloud_ceiling") - assert state - assert state.state == STATE_UNAVAILABLE + freezer.tick(UPDATE_INTERVAL_OBSERVATION) + async_fire_time_changed(hass) + await hass.async_block_till_done() - future = utcnow() + timedelta(minutes=120) - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=load_json_object_fixture( - "accuweather/current_conditions_data.json" - ), - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_cloud_ceiling") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "3200.0" + mock_accuweather_client.async_get_current_conditions.side_effect = None + + freezer.tick(UPDATE_INTERVAL_OBSERVATION) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "3200.0" @pytest.mark.parametrize( "exception", [ - ApiError, + ApiError("API Error"), ConnectionError, ClientConnectorError, - InvalidApiKeyError, - RequestsExceededError, + InvalidApiKeyError("Invalid API key"), + RequestsExceededError("Requests exceeded"), ], ) -async def test_availability_forecast(hass: HomeAssistant, exception: Exception) -> None: +async def test_availability_forecast( + hass: HomeAssistant, + exception: Exception, + mock_accuweather_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: """Ensure that we mark the entities unavailable correctly when service is offline.""" - current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") entity_id = "sensor.home_hours_of_sun_day_2" await init_integration(hass) @@ -113,45 +106,21 @@ async def test_availability_forecast(hass: HomeAssistant, exception: Exception) assert state.state != STATE_UNAVAILABLE assert state.state == "5.7" - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - side_effect=exception, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST) - await hass.async_block_till_done() + mock_accuweather_client.async_get_daily_forecast.side_effect = exception + + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state assert state.state == STATE_UNAVAILABLE - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST * 2) - await hass.async_block_till_done() + mock_accuweather_client.async_get_daily_forecast.side_effect = None + + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state @@ -159,35 +128,29 @@ async def test_availability_forecast(hass: HomeAssistant, exception: Exception) assert state.state == "5.7" -async def test_manual_update_entity(hass: HomeAssistant) -> None: +async def test_manual_update_entity( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test manual update entity via service homeassistant/update_entity.""" await init_integration(hass) await async_setup_component(hass, "homeassistant", {}) - current = load_json_object_fixture("accuweather/current_conditions_data.json") + assert mock_accuweather_client.async_get_current_conditions.call_count == 1 - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ) as mock_current, - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - await hass.services.async_call( - "homeassistant", - "update_entity", - {ATTR_ENTITY_ID: ["sensor.home_cloud_ceiling"]}, - blocking=True, - ) - assert mock_current.call_count == 1 + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.home_cloud_ceiling"]}, + blocking=True, + ) + + assert mock_accuweather_client.async_get_current_conditions.call_count == 2 -async def test_sensor_imperial_units(hass: HomeAssistant) -> None: +async def test_sensor_imperial_units( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test states of the sensor without forecast.""" hass.config.units = US_CUSTOMARY_SYSTEM await init_integration(hass) @@ -210,37 +173,30 @@ async def test_sensor_imperial_units(hass: HomeAssistant) -> None: ) -async def test_state_update(hass: HomeAssistant) -> None: +async def test_state_update( + hass: HomeAssistant, + mock_accuweather_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: """Ensure the sensor state changes after updating the data.""" + entity_id = "sensor.home_cloud_ceiling" + await init_integration(hass) - state = hass.states.get("sensor.home_cloud_ceiling") + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE assert state.state == "3200.0" - future = utcnow() + timedelta(minutes=60) + mock_accuweather_client.async_get_current_conditions.return_value["Ceiling"][ + "Metric" + ]["Value"] = 3300 - current_condition = load_json_object_fixture( - "accuweather/current_conditions_data.json" - ) - current_condition["Ceiling"]["Metric"]["Value"] = 3300 + freezer.tick(UPDATE_INTERVAL_OBSERVATION) + async_fire_time_changed(hass) + await hass.async_block_till_done() - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current_condition, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_cloud_ceiling") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "3300" + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "3300" diff --git a/tests/components/accuweather/test_system_health.py b/tests/components/accuweather/test_system_health.py index 562c572c830..3f00cf95242 100644 --- a/tests/components/accuweather/test_system_health.py +++ b/tests/components/accuweather/test_system_health.py @@ -1,34 +1,32 @@ """Test AccuWeather system health.""" import asyncio -from unittest.mock import Mock +from unittest.mock import AsyncMock from aiohttp import ClientError -from homeassistant.components.accuweather import AccuWeatherData from homeassistant.components.accuweather.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import init_integration + from tests.common import get_system_health_info from tests.test_util.aiohttp import AiohttpClientMocker async def test_accuweather_system_health( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_accuweather_client: AsyncMock, ) -> None: """Test AccuWeather system health.""" aioclient_mock.get("https://dataservice.accuweather.com/", text="") - hass.config.components.add(DOMAIN) + + await init_integration(hass) assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - hass.data[DOMAIN] = {} - hass.data[DOMAIN]["0123xyz"] = AccuWeatherData( - coordinator_observation=Mock(accuweather=Mock(requests_remaining="42")), - coordinator_daily_forecast=Mock(), - ) - info = await get_system_health_info(hass, DOMAIN) for key, val in info.items(): @@ -37,25 +35,22 @@ async def test_accuweather_system_health( assert info == { "can_reach_server": "ok", - "remaining_requests": "42", + "remaining_requests": 10, } async def test_accuweather_system_health_fail( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_accuweather_client: AsyncMock, ) -> None: """Test AccuWeather system health.""" aioclient_mock.get("https://dataservice.accuweather.com/", exc=ClientError) - hass.config.components.add(DOMAIN) + + await init_integration(hass) assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - hass.data[DOMAIN] = {} - hass.data[DOMAIN]["0123xyz"] = AccuWeatherData( - coordinator_observation=Mock(accuweather=Mock(requests_remaining="0")), - coordinator_daily_forecast=Mock(), - ) - info = await get_system_health_info(hass, DOMAIN) for key, val in info.items(): @@ -64,5 +59,5 @@ async def test_accuweather_system_health_fail( assert info == { "can_reach_server": {"type": "failed", "error": "unreachable"}, - "remaining_requests": "0", + "remaining_requests": 10, } diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index d97a5d3da3c..a23b09fec29 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -1,7 +1,7 @@ """Test weather of AccuWeather integration.""" from datetime import timedelta -from unittest.mock import PropertyMock, patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,28 +11,24 @@ from homeassistant.components.accuweather.const import UPDATE_INTERVAL_DAILY_FOR from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from . import init_integration -from tests.common import ( - async_fire_time_changed, - load_json_array_fixture, - load_json_object_fixture, - snapshot_platform, -) +from tests.common import async_fire_time_changed, snapshot_platform from tests.typing import WebSocketGenerator async def test_weather( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_accuweather_client: AsyncMock, ) -> None: """Test states of the weather without forecast.""" with patch("homeassistant.components.accuweather.PLATFORMS", [Platform.WEATHER]): @@ -40,81 +36,71 @@ async def test_weather( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, + mock_accuweather_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: """Ensure that we mark the entities unavailable correctly when service is offline.""" + entity_id = "weather.home" await init_integration(hass) - state = hass.states.get("weather.home") + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE assert state.state == "sunny" - future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - side_effect=ConnectionError(), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + mock_accuweather_client.async_get_current_conditions.side_effect = ConnectionError - state = hass.states.get("weather.home") - assert state - assert state.state == STATE_UNAVAILABLE + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST) + async_fire_time_changed(hass) + await hass.async_block_till_done() - future = utcnow() + timedelta(minutes=120) - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=load_json_object_fixture( - "accuweather/current_conditions_data.json" - ), - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE - state = hass.states.get("weather.home") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "sunny" + mock_accuweather_client.async_get_current_conditions.side_effect = None + + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "sunny" -async def test_manual_update_entity(hass: HomeAssistant) -> None: +async def test_manual_update_entity( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test manual update entity via service homeassistant/update_entity.""" await init_integration(hass) await async_setup_component(hass, "homeassistant", {}) - current = load_json_object_fixture("accuweather/current_conditions_data.json") + assert mock_accuweather_client.async_get_current_conditions.call_count == 1 - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ) as mock_current, - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - await hass.services.async_call( - "homeassistant", - "update_entity", - {ATTR_ENTITY_ID: ["weather.home"]}, - blocking=True, - ) - assert mock_current.call_count == 1 + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["weather.home"]}, + blocking=True, + ) + + assert mock_accuweather_client.async_get_current_conditions.call_count == 2 -async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: +async def test_unsupported_condition_icon_data( + hass: HomeAssistant, mock_accuweather_client: AsyncMock +) -> None: """Test with unsupported condition icon data.""" - await init_integration(hass, unsupported_icon=True) + mock_accuweather_client.async_get_current_conditions.return_value["WeatherIcon"] = ( + 999 + ) + + await init_integration(hass) state = hass.states.get("weather.home") assert state.attributes.get(ATTR_FORECAST_CONDITION) is None @@ -122,14 +108,12 @@ async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_forecast_service( hass: HomeAssistant, snapshot: SnapshotAssertion, + mock_accuweather_client: AsyncMock, service: str, ) -> None: """Test multiple forecast.""" @@ -153,6 +137,7 @@ async def test_forecast_subscription( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, + mock_accuweather_client: AsyncMock, ) -> None: """Test multiple forecast.""" client = await hass_ws_client(hass) @@ -179,27 +164,9 @@ async def test_forecast_subscription( assert forecast1 != [] assert forecast1 == snapshot - current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") - - with ( - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST + timedelta(seconds=1)) - await hass.async_block_till_done() - msg = await client.receive_json() + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() assert msg["id"] == subscription_id assert msg["type"] == "event" diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 2eb95c18b7d..13bbadb38f9 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock +from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -74,11 +75,18 @@ async def test_binary_sensor_async_setup_entry( async_fire_time_changed( hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), ) await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get.mock_calls) == 1 + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 2 + state = hass.states.get(entity_id) assert state assert state.state == STATE_ON @@ -96,6 +104,13 @@ async def test_binary_sensor_async_setup_entry( entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 + async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index 66f8f869ae1..fc9aaade634 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -254,13 +254,14 @@ async def test_climate_async_failed_update( ) -> None: """Test climate change failure.""" + mock_update.side_effect = ApiError + 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, ) - mock_update.assert_called_once() + + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index ced1ff3a9e7..06243921a64 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock +from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( ADVANTAGE_AIR_SERVICE_SET_TIME_TO, @@ -123,16 +124,23 @@ async def test_sensor_platform_disabled_entity( 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(wait_background_tasks=True) + mock_get.reset_mock() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), + ) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 1 + 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 4977a4cc31f..ecc652b3d9e 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -27,8 +27,6 @@ async def test_cover_async_setup_entry( await add_mock_config(hass) - registry = er.async_get(hass) - # Test Fresh Air Switch Entity entity_id = "switch.myzone_fresh_air" state = hass.states.get(entity_id) @@ -61,7 +59,7 @@ async def test_cover_async_setup_entry( entity_id = "switch.myzone_myfan" assert hass.states.get(entity_id) == snapshot(name=entity_id) - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-myfan" diff --git a/tests/components/aemet/conftest.py b/tests/components/aemet/conftest.py index ead27103348..aa4f537c7fb 100644 --- a/tests/components/aemet/conftest.py +++ b/tests/components/aemet/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for aemet.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.aemet.async_setup_entry", return_value=True diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr index a8660740001..f19f95a6e80 100644 --- a/tests/components/aemet/snapshots/test_weather.ambr +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -1,988 +1,4 @@ # serializer version: 1 -# name: test_forecast_service - dict({ - 'forecast': list([ - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-08T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 2.0, - 'templow': -1.0, - 'wind_bearing': 90.0, - 'wind_speed': 0.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-09T23:00:00+00:00', - 'precipitation_probability': 30, - 'temperature': 4.0, - 'templow': -4.0, - 'wind_bearing': 45.0, - 'wind_speed': 20.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 3.0, - 'templow': -7.0, - 'wind_bearing': 0.0, - 'wind_speed': 5.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-11T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': -1.0, - 'templow': -13.0, - 'wind_bearing': None, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2021-01-12T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 6.0, - 'templow': -11.0, - 'wind_bearing': None, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-13T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 6.0, - 'templow': -7.0, - 'wind_bearing': None, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-14T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 5.0, - 'templow': -4.0, - 'wind_bearing': None, - }), - ]), - }) -# --- -# name: test_forecast_service.1 - dict({ - 'forecast': list([ - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T12:00:00+00:00', - 'precipitation': 2.7, - 'precipitation_probability': 100, - 'temperature': 0.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 22.0, - 'wind_speed': 15.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T13:00:00+00:00', - 'precipitation': 0.6, - 'precipitation_probability': 100, - 'temperature': 0.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 14.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T14:00:00+00:00', - 'precipitation': 0.8, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 20.0, - 'wind_speed': 10.0, - }), - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T15:00:00+00:00', - 'precipitation': 1.4, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 14.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T16:00:00+00:00', - 'precipitation': 1.2, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 13.0, - 'wind_speed': 9.0, - }), - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T17:00:00+00:00', - 'precipitation': 0.4, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 90.0, - 'wind_gust_speed': 13.0, - 'wind_speed': 7.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T18:00:00+00:00', - 'precipitation': 0.3, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T19:00:00+00:00', - 'precipitation': 0.1, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-09T20:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 90.0, - 'wind_gust_speed': 8.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-09T21:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 9.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-09T22:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 90.0, - 'wind_gust_speed': 11.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-09T23:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'fog', - 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 10.0, - 'wind_speed': 5.0, - }), - dict({ - 'condition': 'fog', - 'datetime': '2021-01-10T01:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 0.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 11.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'fog', - 'datetime': '2021-01-10T02:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 0.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 9.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T03:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T04:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': -1.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 11.0, - 'wind_speed': 5.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T05:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -1.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 13.0, - 'wind_speed': 9.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 18.0, - 'wind_speed': 13.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T07:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T08:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 31.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T09:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 32.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T10:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': 2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T11:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 32.0, - 'wind_speed': 22.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 32.0, - 'wind_speed': 20.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T13:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 19.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T14:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 4.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 28.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T15:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T16:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T17:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 27.0, - 'wind_speed': 19.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 19.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 27.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 22.0, - 'wind_speed': 12.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 17.0, - 'wind_speed': 10.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 15.0, - 'wind_speed': 11.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -4.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 15.0, - 'wind_speed': 10.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, - 'temperature': -4.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 15.0, - 'wind_speed': 10.0, - }), - ]), - }) -# --- -# name: test_forecast_service[forecast] - dict({ - 'weather.aemet': dict({ - 'forecast': list([ - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-08T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 2.0, - 'templow': -1.0, - 'wind_bearing': 90.0, - 'wind_speed': 0.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-09T23:00:00+00:00', - 'precipitation_probability': 30, - 'temperature': 4.0, - 'templow': -4.0, - 'wind_bearing': 45.0, - 'wind_speed': 20.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 3.0, - 'templow': -7.0, - 'wind_bearing': 0.0, - 'wind_speed': 5.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-11T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': -1.0, - 'templow': -13.0, - 'wind_bearing': None, - }), - dict({ - 'condition': 'sunny', - 'datetime': '2021-01-12T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 6.0, - 'templow': -11.0, - 'wind_bearing': None, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-13T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 6.0, - 'templow': -7.0, - 'wind_bearing': None, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-14T23:00:00+00:00', - 'precipitation_probability': 0, - 'temperature': 5.0, - 'templow': -4.0, - 'wind_bearing': None, - }), - ]), - }), - }) -# --- -# name: test_forecast_service[forecast].1 - dict({ - 'weather.aemet': dict({ - 'forecast': list([ - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T12:00:00+00:00', - 'precipitation': 2.7, - 'precipitation_probability': 100, - 'temperature': 0.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 22.0, - 'wind_speed': 15.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T13:00:00+00:00', - 'precipitation': 0.6, - 'precipitation_probability': 100, - 'temperature': 0.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 14.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T14:00:00+00:00', - 'precipitation': 0.8, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 20.0, - 'wind_speed': 10.0, - }), - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T15:00:00+00:00', - 'precipitation': 1.4, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 14.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T16:00:00+00:00', - 'precipitation': 1.2, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 13.0, - 'wind_speed': 9.0, - }), - dict({ - 'condition': 'snowy', - 'datetime': '2021-01-09T17:00:00+00:00', - 'precipitation': 0.4, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 90.0, - 'wind_gust_speed': 13.0, - 'wind_speed': 7.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T18:00:00+00:00', - 'precipitation': 0.3, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'rainy', - 'datetime': '2021-01-09T19:00:00+00:00', - 'precipitation': 0.1, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 135.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-09T20:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 90.0, - 'wind_gust_speed': 8.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-09T21:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 9.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-09T22:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 100, - 'temperature': 1.0, - 'wind_bearing': 90.0, - 'wind_gust_speed': 11.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-09T23:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'fog', - 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 10.0, - 'wind_speed': 5.0, - }), - dict({ - 'condition': 'fog', - 'datetime': '2021-01-10T01:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 0.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 11.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'fog', - 'datetime': '2021-01-10T02:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': 0.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 9.0, - 'wind_speed': 6.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T03:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 12.0, - 'wind_speed': 8.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T04:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 10, - 'temperature': -1.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 11.0, - 'wind_speed': 5.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T05:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -1.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 13.0, - 'wind_speed': 9.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 18.0, - 'wind_speed': 13.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T07:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T08:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 31.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T09:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 32.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T10:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 15, - 'temperature': 2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T11:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 32.0, - 'wind_speed': 22.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 32.0, - 'wind_speed': 20.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T13:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 19.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T14:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 4.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 28.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T15:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T16:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 5, - 'temperature': 2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T17:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 25.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 24.0, - 'wind_speed': 17.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 27.0, - 'wind_speed': 19.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': 0.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 21.0, - }), - dict({ - 'condition': 'cloudy', - 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 30.0, - 'wind_speed': 19.0, - }), - dict({ - 'condition': 'partlycloudy', - 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -1.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 27.0, - 'wind_speed': 16.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 22.0, - 'wind_speed': 12.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -2.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 17.0, - 'wind_speed': 10.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -3.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 15.0, - 'wind_speed': 11.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation': 0.0, - 'precipitation_probability': 0, - 'temperature': -4.0, - 'wind_bearing': 45.0, - 'wind_gust_speed': 15.0, - 'wind_speed': 10.0, - }), - dict({ - 'condition': 'clear-night', - 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, - 'temperature': -4.0, - 'wind_bearing': 0.0, - 'wind_gust_speed': 15.0, - 'wind_speed': 10.0, - }), - ]), - }), - }) -# --- # name: test_forecast_service[get_forecast] dict({ 'forecast': list([ diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 45fec473396..0f3491b1c43 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -71,7 +71,7 @@ async def test_form_options( ) -> None: """Test the form options.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") with patch( "homeassistant.components.aemet.AEMET.api_call", @@ -112,7 +112,7 @@ async def test_form_duplicated_id( ) -> None: """Test setting up duplicated entry.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") with patch( "homeassistant.components.aemet.AEMET.api_call", diff --git a/tests/components/aemet/test_coordinator.py b/tests/components/aemet/test_coordinator.py index e830f50c54a..5e8938b6ba1 100644 --- a/tests/components/aemet/test_coordinator.py +++ b/tests/components/aemet/test_coordinator.py @@ -20,7 +20,7 @@ async def test_coordinator_error( ) -> None: """Test error on coordinator update.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await async_init_integration(hass) diff --git a/tests/components/aemet/test_diagnostics.py b/tests/components/aemet/test_diagnostics.py index f57ff8e89a1..0d94995a85b 100644 --- a/tests/components/aemet/test_diagnostics.py +++ b/tests/components/aemet/test_diagnostics.py @@ -23,7 +23,6 @@ async def test_config_entry_diagnostics( """Test config entry diagnostics.""" await async_init_integration(hass) - assert hass.data[DOMAIN] config_entry = hass.config_entries.async_entries(DOMAIN)[0] with patch( diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index df69349848b..cf3204782cd 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -28,7 +28,7 @@ async def test_unload_entry( ) -> None: """Test (un)loading the AEMET integration.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") with patch( "homeassistant.components.aemet.AEMET.api_call", @@ -54,7 +54,7 @@ async def test_init_town_not_found( ) -> None: """Test TownNotFound when loading the AEMET integration.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") with patch( "homeassistant.components.aemet.AEMET.api_call", @@ -80,7 +80,7 @@ async def test_init_api_timeout( ) -> None: """Test API timeouts when loading the AEMET integration.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") with patch( "homeassistant.components.aemet.AEMET.api_call", diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index c830310b856..d0f577c8068 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -15,7 +15,7 @@ async def test_aemet_forecast_create_sensors( ) -> None: """Test creation of forecast sensors.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await async_init_integration(hass) @@ -76,7 +76,7 @@ async def test_aemet_weather_create_sensors( ) -> None: """Test creation of weather sensors.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await async_init_integration(hass) diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index ec2c088fe6d..049fd6d18c7 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -18,7 +18,6 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.const import ATTR_ATTRIBUTION @@ -35,7 +34,7 @@ async def test_aemet_weather( ) -> None: """Test states of the weather.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await async_init_integration(hass) @@ -56,10 +55,7 @@ async def test_aemet_weather( @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_forecast_service( hass: HomeAssistant, @@ -69,7 +65,7 @@ async def test_forecast_service( ) -> None: """Test multiple forecast.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await async_init_integration(hass) @@ -109,7 +105,7 @@ async def test_forecast_subscription( """Test multiple forecast.""" client = await hass_ws_client(hass) - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await async_init_integration(hass) diff --git a/tests/components/aemet/util.py b/tests/components/aemet/util.py index 81a184864a4..bb8885f7b4c 100644 --- a/tests/components/aemet/util.py +++ b/tests/components/aemet/util.py @@ -5,7 +5,7 @@ from unittest.mock import patch from aemet_opendata.const import ATTR_DATA -from homeassistant.components.aemet import DOMAIN +from homeassistant.components.aemet.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant @@ -42,9 +42,12 @@ def mock_api_call(cmd: str, fetch_data: bool = False) -> dict[str, Any]: return TOWN_DATA_MOCK if cmd == "maestro/municipios": return TOWNS_DATA_MOCK - if cmd == "observacion/convencional/datos/estacion/3195": + if ( + cmd + == "observacion/convencional/datos/estacion/3195" # codespell:ignore convencional + ): return STATION_DATA_MOCK - if cmd == "observacion/convencional/todas": + if cmd == "observacion/convencional/todas": # codespell:ignore convencional return STATIONS_DATA_MOCK if cmd == "prediccion/especifica/municipio/diaria/28065": return FORECAST_DAILY_DATA_MOCK diff --git a/tests/components/aftership/conftest.py b/tests/components/aftership/conftest.py index 0bea797dce6..1704b099cc2 100644 --- a/tests/components/aftership/conftest.py +++ b/tests/components/aftership/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the AfterShip tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.aftership.async_setup_entry", return_value=True diff --git a/tests/components/agent_dvr/conftest.py b/tests/components/agent_dvr/conftest.py index e9f719a6eeb..a62e1738850 100644 --- a/tests/components/agent_dvr/conftest.py +++ b/tests/components/agent_dvr/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for Agent DVR.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.agent_dvr.async_setup_entry", return_value=True diff --git a/tests/components/agent_dvr/test_init.py b/tests/components/agent_dvr/test_init.py index 7f546a190a7..5e263c548c8 100644 --- a/tests/components/agent_dvr/test_init.py +++ b/tests/components/agent_dvr/test_init.py @@ -39,7 +39,6 @@ async def test_setup_config_and_unload( await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) async def test_async_setup_entry_not_ready(hass: HomeAssistant) -> None: diff --git a/tests/components/airgradient/__init__.py b/tests/components/airgradient/__init__.py new file mode 100644 index 00000000000..9c57dbf8225 --- /dev/null +++ b/tests/components/airgradient/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Airgradient integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airgradient/conftest.py b/tests/components/airgradient/conftest.py new file mode 100644 index 00000000000..c5cc46cc8eb --- /dev/null +++ b/tests/components/airgradient/conftest.py @@ -0,0 +1,80 @@ +"""AirGradient tests configuration.""" + +from unittest.mock import patch + +from airgradient import Config, Measures +import pytest +from typing_extensions import Generator + +from homeassistant.components.airgradient.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_fixture +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airgradient.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_airgradient_client() -> Generator[AsyncMock]: + """Mock an AirGradient client.""" + with ( + patch( + "homeassistant.components.airgradient.AirGradientClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.airgradient.config_flow.AirGradientClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.host = "10.0.0.131" + client.get_current_measures.return_value = Measures.from_json( + load_fixture("current_measures.json", DOMAIN) + ) + client.get_config.return_value = Config.from_json( + load_fixture("get_config_local.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_new_airgradient_client( + mock_airgradient_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock a new AirGradient client.""" + mock_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config.json", DOMAIN) + ) + return mock_airgradient_client + + +@pytest.fixture +def mock_cloud_airgradient_client( + mock_airgradient_client: AsyncMock, +) -> Generator[AsyncMock]: + """Mock a cloud AirGradient client.""" + mock_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config_cloud.json", DOMAIN) + ) + return mock_airgradient_client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Airgradient", + data={CONF_HOST: "10.0.0.131"}, + unique_id="84fce612f5b8", + ) diff --git a/tests/components/airgradient/fixtures/current_measures.json b/tests/components/airgradient/fixtures/current_measures.json new file mode 100644 index 00000000000..ef27e1af378 --- /dev/null +++ b/tests/components/airgradient/fixtures/current_measures.json @@ -0,0 +1,21 @@ +{ + "wifi": -52, + "serialno": "84fce612f5b8", + "rco2": 778, + "pm01": 22, + "pm02": 34, + "pm10": 41, + "pm003Count": 270, + "tvocIndex": 99, + "tvocRaw": 31792, + "noxIndex": 1, + "noxRaw": 16931, + "atmp": 27.96, + "rhum": 48, + "atmpCompensated": 22.17, + "rhumCompensated": 47, + "bootCount": 28, + "ledMode": "co2", + "firmware": "3.1.1", + "model": "I-9PSL" +} diff --git a/tests/components/airgradient/fixtures/current_measures_outdoor.json b/tests/components/airgradient/fixtures/current_measures_outdoor.json new file mode 100644 index 00000000000..f5e63a095c2 --- /dev/null +++ b/tests/components/airgradient/fixtures/current_measures_outdoor.json @@ -0,0 +1,24 @@ +{ + "wifi": -64, + "serialno": "84fce60bec38", + "channels": { + "1": { + "pm01": 3, + "pm02": 5, + "pm10": 5, + "pm003Count": 753, + "atmp": 18.8, + "rhum": 68, + "atmpCompensated": 17.09, + "rhumCompensated": 92 + } + }, + "tvocIndex": 49, + "tvocRaw": 30802, + "noxIndex": 1, + "noxRaw": 16359, + "bootCount": 1, + "ledMode": "co2", + "firmware": "3.1.1", + "model": "O-1PPT" +} diff --git a/tests/components/airgradient/fixtures/get_config.json b/tests/components/airgradient/fixtures/get_config.json new file mode 100644 index 00000000000..e922c4e221f --- /dev/null +++ b/tests/components/airgradient/fixtures/get_config.json @@ -0,0 +1,16 @@ +{ + "country": "DE", + "pmStandard": "ugm3", + "ledBarMode": "co2", + "abcDays": 8, + "tvocLearningOffset": 12, + "noxLearningOffset": 12, + "mqttBrokerUrl": "", + "temperatureUnit": "c", + "configurationControl": "both", + "postDataToAirGradient": true, + "ledBarBrightness": 100, + "displayBrightness": 0, + "offlineMode": false, + "model": "I-9PSL-DE" +} diff --git a/tests/components/airgradient/fixtures/get_config_cloud.json b/tests/components/airgradient/fixtures/get_config_cloud.json new file mode 100644 index 00000000000..8543fa27228 --- /dev/null +++ b/tests/components/airgradient/fixtures/get_config_cloud.json @@ -0,0 +1,16 @@ +{ + "country": "DE", + "pmStandard": "ugm3", + "ledBarMode": "co2", + "abcDays": 8, + "tvocLearningOffset": 12, + "noxLearningOffset": 12, + "mqttBrokerUrl": "", + "temperatureUnit": "c", + "configurationControl": "cloud", + "postDataToAirGradient": true, + "ledBarBrightness": 100, + "displayBrightness": 0, + "offlineMode": false, + "model": "I-9PSL-DE" +} diff --git a/tests/components/airgradient/fixtures/get_config_local.json b/tests/components/airgradient/fixtures/get_config_local.json new file mode 100644 index 00000000000..a9ac299c178 --- /dev/null +++ b/tests/components/airgradient/fixtures/get_config_local.json @@ -0,0 +1,16 @@ +{ + "country": "DE", + "pmStandard": "ugm3", + "ledBarMode": "co2", + "abcDays": 8, + "tvocLearningOffset": 12, + "noxLearningOffset": 12, + "mqttBrokerUrl": "", + "temperatureUnit": "c", + "configurationControl": "local", + "postDataToAirGradient": true, + "ledBarBrightness": 100, + "displayBrightness": 0, + "offlineMode": false, + "model": "I-9PSL-DE" +} diff --git a/tests/components/airgradient/fixtures/measures_after_boot.json b/tests/components/airgradient/fixtures/measures_after_boot.json new file mode 100644 index 00000000000..06bf8f75ef1 --- /dev/null +++ b/tests/components/airgradient/fixtures/measures_after_boot.json @@ -0,0 +1,8 @@ +{ + "wifi": -59, + "serialno": "84fce612f5b8", + "bootCount": 0, + "ledMode": "co2", + "firmware": "3.0.8", + "model": "I-9PSL" +} diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr new file mode 100644 index 00000000000..7109f603c9d --- /dev/null +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'airgradient', + '84fce612f5b8', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'AirGradient', + 'model': 'I-9PSL', + 'name': 'Airgradient', + 'name_by_user': None, + 'serial_number': '84fce612f5b8', + 'suggested_area': None, + 'sw_version': '3.1.1', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/airgradient/snapshots/test_select.ambr b/tests/components/airgradient/snapshots/test_select.ambr new file mode 100644 index 00000000000..d29c7d23923 --- /dev/null +++ b/tests/components/airgradient/snapshots/test_select.ambr @@ -0,0 +1,278 @@ +# serializer version: 1 +# name: test_all_entities[select.airgradient_configuration_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cloud', + 'local', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.airgradient_configuration_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Configuration source', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'configuration_control', + 'unique_id': '84fce612f5b8-configuration_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.airgradient_configuration_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Configuration source', + 'options': list([ + 'cloud', + 'local', + ]), + }), + 'context': , + 'entity_id': 'select.airgradient_configuration_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'local', + }) +# --- +# name: test_all_entities[select.airgradient_display_pm_standard-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ugm3', + 'us_aqi', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.airgradient_display_pm_standard', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display PM standard', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display_pm_standard', + 'unique_id': '84fce612f5b8-display_pm_standard', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.airgradient_display_pm_standard-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Display PM standard', + 'options': list([ + 'ugm3', + 'us_aqi', + ]), + }), + 'context': , + 'entity_id': 'select.airgradient_display_pm_standard', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ugm3', + }) +# --- +# name: test_all_entities[select.airgradient_display_temperature_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'c', + 'f', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.airgradient_display_temperature_unit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Display temperature unit', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'display_temperature_unit', + 'unique_id': '84fce612f5b8-display_temperature_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.airgradient_display_temperature_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Display temperature unit', + 'options': list([ + 'c', + 'f', + ]), + }), + 'context': , + 'entity_id': 'select.airgradient_display_temperature_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'c', + }) +# --- +# name: test_all_entities[select.airgradient_led_bar_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'co2', + 'pm', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.airgradient_led_bar_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED bar mode', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_bar_mode', + 'unique_id': '84fce612f5b8-led_bar_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.airgradient_led_bar_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient LED bar mode', + 'options': list([ + 'off', + 'co2', + 'pm', + ]), + }), + 'context': , + 'entity_id': 'select.airgradient_led_bar_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'co2', + }) +# --- +# name: test_all_entities_outdoor[select.airgradient_configuration_source-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cloud', + 'local', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.airgradient_configuration_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Configuration source', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'configuration_control', + 'unique_id': '84fce612f5b8-configuration_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities_outdoor[select.airgradient_configuration_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Configuration source', + 'options': list([ + 'cloud', + 'local', + ]), + }), + 'context': , + 'entity_id': 'select.airgradient_configuration_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'local', + }) +# --- diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..b0e22e7a9af --- /dev/null +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -0,0 +1,656 @@ +# serializer version: 1 +# name: test_all_entities[sensor.airgradient_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[sensor.airgradient_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Airgradient Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.airgradient_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '778', + }) +# --- +# name: test_all_entities[sensor.airgradient_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.airgradient_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Airgradient Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.airgradient_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_all_entities[sensor.airgradient_nox_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_nox_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'NOx index', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nitrogen_index', + 'unique_id': '84fce612f5b8-nitrogen_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.airgradient_nox_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient NOx index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.airgradient_nox_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm0_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm0_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PM0.3', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pm003_count', + 'unique_id': '84fce612f5b8-pm003', + 'unit_of_measurement': 'particles/dL', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm0_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient PM0.3', + 'state_class': , + 'unit_of_measurement': 'particles/dL', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm0_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '270', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm0_3_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm0_3_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PM0.3 count', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pm003_count', + 'unique_id': '84fce612f5b8-pm003', + 'unit_of_measurement': 'particles/dL', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm0_3_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient PM0.3 count', + 'state_class': , + 'unit_of_measurement': 'particles/dL', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm0_3_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '270', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-pm01', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Airgradient PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Airgradient PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-pm02', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Airgradient PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_nox-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_nox', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Raw NOx', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_nitrogen', + 'unique_id': '84fce612f5b8-nox_raw', + 'unit_of_measurement': 'ticks', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_nox-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Raw NOx', + 'state_class': , + 'unit_of_measurement': 'ticks', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_nox', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16931', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_voc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_voc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Raw VOC', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_total_volatile_organic_component', + 'unique_id': '84fce612f5b8-tvoc_raw', + 'unit_of_measurement': 'ticks', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_voc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Raw VOC', + 'state_class': , + 'unit_of_measurement': 'ticks', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_voc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31792', + }) +# --- +# name: test_all_entities[sensor.airgradient_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airgradient_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.airgradient_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Airgradient Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.airgradient_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-52', + }) +# --- +# name: test_all_entities[sensor.airgradient_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.airgradient_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airgradient Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airgradient_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.96', + }) +# --- +# name: test_all_entities[sensor.airgradient_voc_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_voc_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'VOC index', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_volatile_organic_component_index', + 'unique_id': '84fce612f5b8-tvoc', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.airgradient_voc_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient VOC index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.airgradient_voc_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py new file mode 100644 index 00000000000..217d2ac0e8c --- /dev/null +++ b/tests/components/airgradient/test_config_flow.py @@ -0,0 +1,254 @@ +"""Tests for the AirGradient config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from airgradient import AirGradientConnectionError, ConfigurationControl +from mashumaro import MissingField + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +OLD_ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("10.0.0.131"), + ip_addresses=[ip_address("10.0.0.131")], + hostname="airgradient_84fce612f5b8.local.", + name="airgradient_84fce612f5b8._airgradient._tcp.local.", + port=80, + type="_airgradient._tcp.local.", + properties={ + "vendor": "AirGradient", + "fw_ver": "3.0.8", + "serialno": "84fce612f5b8", + "model": "I-9PSL", + }, +) + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("10.0.0.131"), + ip_addresses=[ip_address("10.0.0.131")], + hostname="airgradient_84fce612f5b8.local.", + name="airgradient_84fce612f5b8._airgradient._tcp.local.", + port=80, + type="_airgradient._tcp.local.", + properties={ + "vendor": "AirGradient", + "fw_ver": "3.1.1", + "serialno": "84fce612f5b8", + "model": "I-9PSL", + }, +) + + +async def test_full_flow( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "I-9PSL" + assert result["data"] == { + CONF_HOST: "10.0.0.131", + } + assert result["result"].unique_id == "84fce612f5b8" + mock_new_airgradient_client.set_configuration_control.assert_awaited_once_with( + ConfigurationControl.LOCAL + ) + + +async def test_flow_with_registered_device( + hass: HomeAssistant, + mock_cloud_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we don't revert the cloud setting.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "84fce612f5b8" + mock_cloud_airgradient_client.set_configuration_control.assert_not_called() + + +async def test_flow_errors( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow errors.""" + mock_airgradient_client.get_current_measures.side_effect = ( + AirGradientConnectionError() + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_airgradient_client.get_current_measures.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_old_firmware_version( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow with old firmware version.""" + mock_airgradient_client.get_current_measures.side_effect = MissingField( + "", object, object + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_version" + + +async def test_duplicate( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_new_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "I-9PSL" + assert result["data"] == { + CONF_HOST: "10.0.0.131", + } + assert result["result"].unique_id == "84fce612f5b8" + mock_new_airgradient_client.set_configuration_control.assert_awaited_once_with( + ConfigurationControl.LOCAL + ) + + +async def test_zeroconf_flow_cloud_device( + hass: HomeAssistant, + mock_cloud_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow doesn't revert the cloud setting.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + mock_cloud_airgradient_client.set_configuration_control.assert_not_called() + + +async def test_zeroconf_flow_abort_old_firmware(hass: HomeAssistant) -> None: + """Test zeroconf flow aborts with old firmware.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=OLD_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_version" diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py new file mode 100644 index 00000000000..273f425f4fc --- /dev/null +++ b/tests/components/airgradient/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the AirGradient integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py new file mode 100644 index 00000000000..986295bd245 --- /dev/null +++ b/tests/components/airgradient/test_select.py @@ -0,0 +1,111 @@ +"""Tests for the AirGradient select platform.""" + +from unittest.mock import AsyncMock, patch + +from airgradient import ConfigurationControl, Measures +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, load_fixture, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities_outdoor( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + mock_airgradient_client.get_current_measures.return_value = Measures.from_json( + load_fixture("current_measures_outdoor.json", DOMAIN) + ) + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.airgradient_configuration_source", + ATTR_OPTION: "local", + }, + blocking=True, + ) + mock_airgradient_client.set_configuration_control.assert_called_once_with("local") + assert mock_airgradient_client.get_config.call_count == 2 + + +async def test_setting_protected_value( + hass: HomeAssistant, + mock_cloud_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting protected value.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.airgradient_display_temperature_unit", + ATTR_OPTION: "c", + }, + blocking=True, + ) + mock_cloud_airgradient_client.set_temperature_unit.assert_not_called() + + mock_cloud_airgradient_client.get_config.return_value.configuration_control = ( + ConfigurationControl.LOCAL + ) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.airgradient_display_temperature_unit", + ATTR_OPTION: "c", + }, + blocking=True, + ) + mock_cloud_airgradient_client.set_temperature_unit.assert_called_once_with("c") diff --git a/tests/components/airgradient/test_sensor.py b/tests/components/airgradient/test_sensor.py new file mode 100644 index 00000000000..65c96a0669f --- /dev/null +++ b/tests/components/airgradient/test_sensor.py @@ -0,0 +1,78 @@ +"""Tests for the AirGradient sensor platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from airgradient import AirGradientError, Measures +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_create_entities( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test creating entities.""" + mock_airgradient_client.get_current_measures.return_value = Measures.from_json( + load_fixture("measures_after_boot.json", DOMAIN) + ) + with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert len(hass.states.async_all()) == 0 + mock_airgradient_client.get_current_measures.return_value = Measures.from_json( + load_fixture("current_measures.json", DOMAIN) + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 9 + + +async def test_connection_error( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.get_current_measures.side_effect = AirGradientError() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.airgradient_humidity").state == STATE_UNAVAILABLE diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index cf76296d49a..c87c41b5162 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -1,16 +1,24 @@ """Tests for Airly.""" from homeassistant.components.airly.const import DOMAIN +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker API_NEAREST_URL = "https://airapi.airly.eu/v2/measurements/nearest?lat=123.000000&lng=456.000000&maxDistanceKM=5.000000" API_POINT_URL = ( "https://airapi.airly.eu/v2/measurements/point?lat=123.000000&lng=456.000000" ) +HEADERS = { + "X-RateLimit-Limit-day": "100", + "X-RateLimit-Remaining-day": "42", +} -async def init_integration(hass, aioclient_mock) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> MockConfigEntry: """Set up the Airly integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, @@ -25,7 +33,9 @@ async def init_integration(hass, aioclient_mock) -> MockConfigEntry: }, ) - aioclient_mock.get(API_POINT_URL, text=load_fixture("valid_station.json", "airly")) + aioclient_mock.get( + API_POINT_URL, text=load_fixture("valid_station.json", DOMAIN), headers=HEADERS + ) 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/airly/test_system_health.py b/tests/components/airly/test_system_health.py index 4ae94ca280c..429d20f7d33 100644 --- a/tests/components/airly/test_system_health.py +++ b/tests/components/airly/test_system_health.py @@ -1,7 +1,6 @@ """Test Airly system health.""" import asyncio -from unittest.mock import Mock from aiohttp import ClientError @@ -9,6 +8,8 @@ from homeassistant.components.airly.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import init_integration + from tests.common import get_system_health_info from tests.test_util.aiohttp import AiohttpClientMocker @@ -18,19 +19,11 @@ async def test_airly_system_health( ) -> None: """Test Airly system health.""" aioclient_mock.get("https://airapi.airly.eu/v2/", text="") - hass.config.components.add(DOMAIN) + + await init_integration(hass, aioclient_mock) assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - hass.data[DOMAIN] = {} - hass.data[DOMAIN]["0123xyz"] = Mock( - airly=Mock( - AIRLY_API_URL="https://airapi.airly.eu/v2/", - requests_remaining=42, - requests_per_day=100, - ) - ) - info = await get_system_health_info(hass, DOMAIN) for key, val in info.items(): @@ -47,19 +40,11 @@ async def test_airly_system_health_fail( ) -> None: """Test Airly system health.""" aioclient_mock.get("https://airapi.airly.eu/v2/", exc=ClientError) - hass.config.components.add(DOMAIN) + + await init_integration(hass, aioclient_mock) assert await async_setup_component(hass, "system_health", {}) await hass.async_block_till_done() - hass.data[DOMAIN] = {} - hass.data[DOMAIN]["0123xyz"] = Mock( - airly=Mock( - AIRLY_API_URL="https://airapi.airly.eu/v2/", - requests_remaining=0, - requests_per_day=1000, - ) - ) - info = await get_system_health_info(hass, DOMAIN) for key, val in info.items(): @@ -67,5 +52,5 @@ async def test_airly_system_health_fail( info[key] = await val assert info["can_reach_server"] == {"type": "failed", "error": "unreachable"} - assert info["requests_remaining"] == 0 - assert info["requests_per_day"] == 1000 + assert info["requests_remaining"] == 42 + assert info["requests_per_day"] == 100 diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index db4400f85d3..676595250f1 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -1,18 +1,23 @@ """Define fixtures for AirNow tests.""" -import json +from typing import Any from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.airnow import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_array_fixture @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, options): +def config_entry_fixture( + hass: HomeAssistant, config: dict[str, Any], options: dict[str, Any] +) -> MockConfigEntry: """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, @@ -27,7 +32,7 @@ def config_entry_fixture(hass, config, options): @pytest.fixture(name="config") -def config_fixture(hass): +def config_fixture() -> dict[str, Any]: """Define a config entry data fixture.""" return { CONF_API_KEY: "abc123", @@ -37,7 +42,7 @@ def config_fixture(hass): @pytest.fixture(name="options") -def options_fixture(hass): +def options_fixture() -> dict[str, Any]: """Define a config options data fixture.""" return { CONF_RADIUS: 150, @@ -45,19 +50,19 @@ def options_fixture(hass): @pytest.fixture(name="data", scope="package") -def data_fixture(): +def data_fixture() -> JsonArrayType: """Define a fixture for response data.""" - return json.loads(load_fixture("response.json", "airnow")) + return load_json_array_fixture("response.json", "airnow") @pytest.fixture(name="mock_api_get") -def mock_api_get_fixture(data): +def mock_api_get_fixture(data: JsonArrayType) -> AsyncMock: """Define a fixture for a mock "get" coroutine function.""" return AsyncMock(return_value=data) @pytest.fixture(name="setup_airnow") -async def setup_airnow_fixture(hass, config, mock_api_get): +def setup_airnow_fixture(mock_api_get: AsyncMock) -> Generator[None]: """Define a fixture to set up AirNow.""" with ( patch("pyairnow.WebServiceAPI._get", mock_api_get), diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 71fda040c1d..c2004d759a9 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -8,7 +8,7 @@ 'DateObserved': '2020-12-20', 'HourObserved': 15, 'Latitude': '**REDACTED**', - 'LocalTimeZone': 'PST', + 'LocalTimeZoneInfo': 'PST', 'Longitude': '**REDACTED**', 'O3': 0.048, 'PM10': 12, diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index b62cb43844b..6507eea1fcb 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -1,5 +1,6 @@ """Test the AirNow config flow.""" +from typing import Any from unittest.mock import AsyncMock, patch from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError @@ -14,7 +15,10 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, config, options, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_form( + hass: HomeAssistant, config: dict[str, Any], options: dict[str, Any] +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -29,7 +33,8 @@ async def test_form(hass: HomeAssistant, config, options, setup_airnow) -> None: @pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=InvalidKeyError)]) -async def test_form_invalid_auth(hass: HomeAssistant, config, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_form_invalid_auth(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -40,7 +45,10 @@ async def test_form_invalid_auth(hass: HomeAssistant, config, setup_airnow) -> N @pytest.mark.parametrize("data", [{}]) -async def test_form_invalid_location(hass: HomeAssistant, config, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_form_invalid_location( + hass: HomeAssistant, config: dict[str, Any] +) -> None: """Test we handle invalid location.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -51,7 +59,8 @@ async def test_form_invalid_location(hass: HomeAssistant, config, setup_airnow) @pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=AirNowError)]) -async def test_form_cannot_connect(hass: HomeAssistant, config, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_form_cannot_connect(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -62,7 +71,8 @@ async def test_form_cannot_connect(hass: HomeAssistant, config, setup_airnow) -> @pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=EmptyResponseError)]) -async def test_form_empty_result(hass: HomeAssistant, config, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_form_empty_result(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test we handle empty response error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -73,7 +83,8 @@ async def test_form_empty_result(hass: HomeAssistant, config, setup_airnow) -> N @pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=RuntimeError)]) -async def test_form_unexpected(hass: HomeAssistant, config, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_form_unexpected(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test we handle an unexpected error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -83,7 +94,10 @@ async def test_form_unexpected(hass: HomeAssistant, config, setup_airnow) -> Non assert result2["errors"] == {"base": "unknown"} -async def test_entry_already_exists(hass: HomeAssistant, config, config_entry) -> None: +@pytest.mark.usefixtures("config_entry") +async def test_entry_already_exists( + hass: HomeAssistant, config: dict[str, Any] +) -> None: """Test that the form aborts if the Lat/Lng is already configured.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -93,7 +107,8 @@ async def test_entry_already_exists(hass: HomeAssistant, config, config_entry) - assert result2["reason"] == "already_configured" -async def test_config_migration_v2(hass: HomeAssistant, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_config_migration_v2(hass: HomeAssistant) -> None: """Test that the config migration from Version 1 to Version 2 works.""" config_entry = MockConfigEntry( version=1, @@ -119,7 +134,8 @@ async def test_config_migration_v2(hass: HomeAssistant, setup_airnow) -> None: assert config_entry.options.get(CONF_RADIUS) == 25 -async def test_options_flow(hass: HomeAssistant, setup_airnow) -> None: +@pytest.mark.usefixtures("setup_airnow") +async def test_options_flow(hass: HomeAssistant) -> None: """Test that the options flow works.""" config_entry = MockConfigEntry( version=2, diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py index 78f6c410fdf..7329398e789 100644 --- a/tests/components/airnow/test_diagnostics.py +++ b/tests/components/airnow/test_diagnostics.py @@ -1,23 +1,33 @@ """Test AirNow diagnostics.""" +from unittest.mock import patch + +import pytest from syrupy 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 +@pytest.mark.usefixtures("setup_airnow") async def test_entry_diagnostics( hass: HomeAssistant, - config_entry, + config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, - setup_airnow, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await hass.config_entries.async_setup(config_entry.entry_id) - assert ( - await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - == snapshot - ) + + # Fake LocalTimeZoneInfo + with patch( + "homeassistant.util.dt.async_get_time_zone", + return_value="PST", + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py index 647569b63f0..5df032c0308 100644 --- a/tests/components/airq/conftest.py +++ b/tests/components/airq/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for air-Q.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.airq.async_setup_entry", return_value=True diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 8c85e017367..d70c1526510 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -7,7 +7,11 @@ from aiohttp.client_exceptions import ClientConnectionError import pytest from homeassistant import config_entries -from homeassistant.components.airq.const import DOMAIN +from homeassistant.components.airq.const import ( + CONF_CLIP_NEGATIVE, + CONF_RETURN_AVERAGE, + DOMAIN, +) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,6 +31,10 @@ TEST_DEVICE_INFO = DeviceInfo( sw_version="sw", hw_version="hw", ) +DEFAULT_OPTIONS = { + CONF_CLIP_NEGATIVE: True, + CONF_RETURN_AVERAGE: True, +} async def test_form(hass: HomeAssistant) -> None: @@ -103,3 +111,31 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "user_input", [{}, {CONF_RETURN_AVERAGE: False}, {CONF_CLIP_NEGATIVE: False}] +) +async def test_options_flow(hass: HomeAssistant, user_input) -> None: + """Test that the options flow works.""" + entry = MockConfigEntry( + domain=DOMAIN, data=TEST_USER_DATA, unique_id=TEST_DEVICE_INFO["id"] + ) + 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.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + assert entry.options == {} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=user_input + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == entry.options == DEFAULT_OPTIONS | user_input diff --git a/tests/components/airthings_ble/conftest.py b/tests/components/airthings_ble/conftest.py index 3df082c4361..44f68a1c8ae 100644 --- a/tests/components/airthings_ble/conftest.py +++ b/tests/components/airthings_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index f6a7098785b..79ae46500dd 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from airthings_ble import AirthingsDevice, AirthingsDeviceType from bleak import BleakError +import pytest from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER @@ -71,24 +72,25 @@ async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" +@pytest.mark.parametrize( + ("exc", "reason"), [(Exception(), "unknown"), (BleakError(), "cannot_connect")] +) async def test_bluetooth_discovery_airthings_ble_update_failed( - hass: HomeAssistant, + hass: HomeAssistant, exc: Exception, reason: str ) -> None: """Test discovery via bluetooth but there's an exception from airthings-ble.""" - for loop in [(Exception(), "unknown"), (BleakError(), "cannot_connect")]: - exc, reason = loop - with ( - patch_async_ble_device_from_address(WAVE_SERVICE_INFO), - patch_airthings_ble(side_effect=exc), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_BLUETOOTH}, - data=WAVE_SERVICE_INFO, - ) + with ( + patch_async_ble_device_from_address(WAVE_SERVICE_INFO), + patch_airthings_ble(side_effect=exc), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == reason async def test_bluetooth_discovery_already_setup(hass: HomeAssistant) -> None: diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index 9949528ccc7..a8acdf7ec7b 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -7,7 +7,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.components.airthings_ble import ( +from . import ( CO2_V1, CO2_V2, HUMIDITY_V2, @@ -21,6 +21,7 @@ from tests.components.airthings_ble import ( create_entry, patch_airthings_device_update, ) + from tests.components.bluetooth import inject_bluetooth_service_info _LOGGER = logging.getLogger(__name__) @@ -30,7 +31,7 @@ async def test_migration_from_v1_to_v3_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, -): +) -> None: """Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format.""" entry = create_entry(hass) device = create_device(entry, device_registry) @@ -71,7 +72,7 @@ async def test_migration_from_v2_to_v3_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, -): +) -> None: """Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format.""" entry = create_entry(hass) device = create_device(entry, device_registry) @@ -112,7 +113,7 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, -): +) -> None: """Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids.""" entry = create_entry(hass) device = create_device(entry, device_registry) @@ -162,7 +163,7 @@ async def test_migration_with_all_unique_ids( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, -): +) -> None: """Test if migration works when we have all unique ids.""" entry = create_entry(hass) device = create_device(entry, device_registry) diff --git a/tests/components/airtouch5/conftest.py b/tests/components/airtouch5/conftest.py index 016843e6874..d6d55689f17 100644 --- a/tests/components/airtouch5/conftest.py +++ b/tests/components/airtouch5/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the Airtouch 5 tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.airtouch5.async_setup_entry", return_value=True diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py index 1538af28a08..a82dc0ab78c 100644 --- a/tests/components/airvisual/conftest.py +++ b/tests/components/airvisual/conftest.py @@ -1,10 +1,10 @@ """Define test fixtures for AirVisual.""" -from collections.abc import Generator -import json +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.airvisual import ( CONF_CITY, @@ -21,8 +21,10 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, CONF_STATE, ) +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture TEST_API_KEY = "abcde12345" TEST_LATITUDE = 51.528308 @@ -55,7 +57,7 @@ NAME_CONFIG = { @pytest.fixture(name="cloud_api") -def cloud_api_fixture(data_cloud): +def cloud_api_fixture(data_cloud: JsonObjectType) -> Mock: """Define a mock CloudAPI object.""" return Mock( air_quality=Mock( @@ -66,7 +68,12 @@ def cloud_api_fixture(data_cloud): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config, config_entry_version, integration_type): +def config_entry_fixture( + hass: HomeAssistant, + config: dict[str, Any], + config_entry_version: int, + integration_type: str, +) -> MockConfigEntry: """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, @@ -81,37 +88,39 @@ def config_entry_fixture(hass, config, config_entry_version, integration_type): @pytest.fixture(name="config_entry_version") -def config_entry_version_fixture(): +def config_entry_version_fixture() -> int: """Define a config entry version fixture.""" return 2 @pytest.fixture(name="config") -def config_fixture(): +def config_fixture() -> dict[str, Any]: """Define a config entry data fixture.""" return COORDS_CONFIG @pytest.fixture(name="data_cloud", scope="package") -def data_cloud_fixture(): +def data_cloud_fixture() -> JsonObjectType: """Define an update coordinator data example.""" - return json.loads(load_fixture("data.json", "airvisual")) + return load_json_object_fixture("data.json", "airvisual") @pytest.fixture(name="data_pro", scope="package") -def data_pro_fixture(): +def data_pro_fixture() -> JsonObjectType: """Define an update coordinator data example for the Pro.""" - return json.loads(load_fixture("data.json", "airvisual_pro")) + return load_json_object_fixture("data.json", "airvisual_pro") @pytest.fixture(name="integration_type") -def integration_type_fixture(): +def integration_type_fixture() -> str: """Define an integration type.""" return INTEGRATION_TYPE_GEOGRAPHY_COORDS @pytest.fixture(name="mock_pyairvisual") -async def mock_pyairvisual_fixture(cloud_api, node_samba): +async def mock_pyairvisual_fixture( + cloud_api: Mock, node_samba: Mock +) -> AsyncGenerator[None]: """Define a fixture to patch pyairvisual.""" with ( patch( @@ -135,7 +144,7 @@ async def mock_pyairvisual_fixture(cloud_api, node_samba): @pytest.fixture(name="node_samba") -def node_samba_fixture(data_pro): +def node_samba_fixture(data_pro: JsonObjectType) -> Mock: """Define a mock NodeSamba object.""" return Mock( async_connect=AsyncMock(), @@ -145,14 +154,16 @@ def node_samba_fixture(data_pro): @pytest.fixture(name="setup_config_entry") -async def setup_config_entry_fixture(hass, config_entry, mock_pyairvisual): +async def setup_config_entry_fixture( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_pyairvisual: None +) -> None: """Define a fixture to set up airvisual.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.airvisual.async_setup_entry", return_value=True diff --git a/tests/components/airvisual/test_init.py b/tests/components/airvisual/test_init.py index e6cd5968cea..7fa9f4ca779 100644 --- a/tests/components/airvisual/test_init.py +++ b/tests/components/airvisual/test_init.py @@ -101,7 +101,10 @@ async def test_migration_1_2(hass: HomeAssistant, mock_pyairvisual) -> None: async def test_migration_2_3( - hass: HomeAssistant, mock_pyairvisual, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + mock_pyairvisual, + device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test migrating from version 2 to 3.""" entry = MockConfigEntry( @@ -134,5 +137,4 @@ async def test_migration_2_3( for domain, entry_count in ((DOMAIN, 0), (AIRVISUAL_PRO_DOMAIN, 1)): assert len(hass.config_entries.async_entries(domain)) == entry_count - issue_registry = ir.async_get(hass) assert len(issue_registry.issues) == 1 diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index 164264634b8..d25e9821d91 100644 --- a/tests/components/airvisual_pro/conftest.py +++ b/tests/components/airvisual_pro/conftest.py @@ -1,20 +1,22 @@ """Define test fixtures for AirVisual Pro.""" -from collections.abc import Generator -import json +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.airvisual_pro.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.json import JsonObjectType -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.airvisual_pro.async_setup_entry", return_value=True @@ -23,7 +25,9 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config): +def config_entry_fixture( + hass: HomeAssistant, config: dict[str, Any] +) -> MockConfigEntry: """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, @@ -36,7 +40,7 @@ def config_entry_fixture(hass, config): @pytest.fixture(name="config") -def config_fixture(hass): +def config_fixture() -> dict[str, Any]: """Define a config entry data fixture.""" return { CONF_IP_ADDRESS: "192.168.1.101", @@ -45,25 +49,27 @@ def config_fixture(hass): @pytest.fixture(name="connect") -def connect_fixture(): +def connect_fixture() -> AsyncMock: """Define a mocked async_connect method.""" return AsyncMock(return_value=True) @pytest.fixture(name="disconnect") -def disconnect_fixture(): +def disconnect_fixture() -> AsyncMock: """Define a mocked async_connect method.""" return AsyncMock() @pytest.fixture(name="data", scope="package") -def data_fixture(): +def data_fixture() -> JsonObjectType: """Define an update coordinator data example.""" - return json.loads(load_fixture("data.json", "airvisual_pro")) + return load_json_object_fixture("data.json", "airvisual_pro") @pytest.fixture(name="pro") -def pro_fixture(connect, data, disconnect): +def pro_fixture( + connect: AsyncMock, data: JsonObjectType, disconnect: AsyncMock +) -> Mock: """Define a mocked NodeSamba object.""" return Mock( async_connect=connect, @@ -73,7 +79,9 @@ def pro_fixture(connect, data, disconnect): @pytest.fixture(name="setup_airvisual_pro") -async def setup_airvisual_pro_fixture(hass, config, pro): +async def setup_airvisual_pro_fixture( + hass: HomeAssistant, config, pro +) -> AsyncGenerator[None]: """Define a fixture to set up AirVisual Pro.""" with ( patch( diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py index b64f346f27e..6a03b9f1985 100644 --- a/tests/components/airzone/test_diagnostics.py +++ b/tests/components/airzone/test_diagnostics.py @@ -26,7 +26,6 @@ async def test_config_entry_diagnostics( ) -> None: """Test config entry diagnostics.""" await async_init_integration(hass) - assert hass.data[DOMAIN] config_entry = hass.config_entries.async_entries(DOMAIN)[0] with patch( diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 3d4c54522fc..3d75599d2d2 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -4,6 +4,7 @@ import copy from unittest.mock import patch from aioairzone.const import API_DATA, API_SYSTEMS +import pytest from homeassistant.components.airzone.coordinator import SCAN_INTERVAL from homeassistant.const import STATE_UNAVAILABLE @@ -22,9 +23,8 @@ from .util import ( from tests.common import async_fire_time_changed -async def test_airzone_create_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_airzone_create_sensors(hass: HomeAssistant) -> None: """Test creation of sensors.""" await async_init_integration(hass) @@ -81,9 +81,8 @@ async def test_airzone_create_sensors( assert state is None -async def test_airzone_sensors_availability( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_airzone_sensors_availability(hass: HomeAssistant) -> None: """Test sensors availability.""" await async_init_integration(hass) diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index c5c2d5972d4..6e3e0eccc8f 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -55,7 +55,7 @@ from aioairzone.const import ( API_ZONE_ID, ) -from homeassistant.components.airzone import DOMAIN +from homeassistant.components.airzone.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 3309c175543..31065d68a47 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -438,6 +438,7 @@ 'zone1': dict({ 'action': 1, 'active': True, + 'air-demand': True, 'aq-active': False, 'aq-index': 1, 'aq-mode-conf': 'auto', @@ -453,6 +454,7 @@ 'aq-status': 'good', 'available': True, 'double-set-point': False, + 'floor-demand': False, 'humidity': 30, 'id': 'zone1', 'installation': 'installation1', @@ -499,6 +501,7 @@ 'zone2': dict({ 'action': 6, 'active': False, + 'air-demand': False, 'aq-active': False, 'aq-index': 1, 'aq-mode-conf': 'auto', @@ -514,6 +517,7 @@ 'aq-status': 'good', 'available': True, 'double-set-point': False, + 'floor-demand': False, 'humidity': 24, 'id': 'zone2', 'installation': 'installation1', diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index b81631728b4..8e065821057 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -41,9 +41,15 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: assert state.attributes.get("warnings") is None # Zones + state = hass.states.get("binary_sensor.dormitorio_air_demand") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.dormitorio_air_quality_active") assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.dormitorio_floor_demand") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.dormitorio_problem") assert state.state == STATE_OFF assert state.attributes.get("warnings") is None @@ -51,9 +57,15 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dormitorio_running") assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.salon_air_demand") + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.salon_air_quality_active") assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.salon_floor_demand") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.salon_problem") assert state.state == STATE_OFF assert state.attributes.get("warnings") is None diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index 9bfaf5683a1..37c5ff8e1af 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -16,6 +16,8 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODES, ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, @@ -95,7 +97,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes[ATTR_MAX_TEMP] == 30 assert state.attributes[ATTR_MIN_TEMP] == 15 assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_DEFAULT_TEMP_STEP - assert state.attributes[ATTR_TEMPERATURE] == 22.0 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 22.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 18.0 # Groups state = hass.states.get("climate.group") @@ -576,6 +579,27 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 20.5 + # Aidoo Pro with Double Setpoint + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.bron_pro", + ATTR_TARGET_TEMP_HIGH: 25.0, + ATTR_TARGET_TEMP_LOW: 20.0, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron_pro") + assert state.state == HVACMode.HEAT + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0 + async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: """Test error when setting the target temperature.""" diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index 2b2e3f33105..254dba16b09 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -104,7 +104,6 @@ async def test_config_entry_diagnostics( ) -> None: """Test config entry diagnostics.""" await async_init_integration(hass) - assert hass.data[DOMAIN] config_entry = hass.config_entries.async_entries(DOMAIN)[0] with patch( diff --git a/tests/components/airzone_cloud/test_select.py b/tests/components/airzone_cloud/test_select.py index 1375b052050..5a6b6104468 100644 --- a/tests/components/airzone_cloud/test_select.py +++ b/tests/components/airzone_cloud/test_select.py @@ -12,9 +12,8 @@ from homeassistant.exceptions import ServiceValidationError from .util import async_init_integration -async def test_airzone_create_selects( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_airzone_create_selects(hass: HomeAssistant) -> None: """Test creation of selects.""" await async_init_integration(hass) diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index 5000f1cabea..31fe52f3302 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -1,13 +1,14 @@ """The sensor tests for the Airzone Cloud platform.""" +import pytest + from homeassistant.core import HomeAssistant from .util import async_init_integration -async def test_airzone_create_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_airzone_create_sensors(hass: HomeAssistant) -> None: """Test creation of sensors.""" await async_init_integration(hass) diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 0583fad7c0e..6e7dad707f1 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -6,6 +6,7 @@ from unittest.mock import patch from aioairzone_cloud.common import OperationMode from aioairzone_cloud.const import ( API_ACTIVE, + API_AIR_ACTIVE, API_AQ_ACTIVE, API_AQ_MODE_CONF, API_AQ_MODE_VALUES, @@ -42,6 +43,7 @@ from aioairzone_cloud.const import ( API_OLD_ID, API_POWER, API_POWERFUL_MODE, + API_RAD_ACTIVE, API_RANGE_MAX_AIR, API_RANGE_MIN_AIR, API_RANGE_SP_MAX_ACS, @@ -91,7 +93,7 @@ from aioairzone_cloud.const import ( from aioairzone_cloud.device import Device from aioairzone_cloud.webserver import WebServer -from homeassistant.components.airzone_cloud import DOMAIN +from homeassistant.components.airzone_cloud.const import DOMAIN from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -353,6 +355,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "zone1": return { API_ACTIVE: True, + API_AIR_ACTIVE: True, API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], @@ -370,6 +373,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: OperationMode.VENTILATION.value, OperationMode.DRY.value, ], + API_RAD_ACTIVE: False, API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, @@ -398,6 +402,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "zone2": return { API_ACTIVE: False, + API_AIR_ACTIVE: False, API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], @@ -410,6 +415,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_HUMIDITY: 24, API_MODE: OperationMode.COOLING.value, API_MODE_AVAIL: [], + API_RAD_ACTIVE: False, API_RANGE_MAX_AIR: {API_CELSIUS: 30, API_FAH: 86}, API_RANGE_SP_MAX_COOL_AIR: {API_FAH: 86, API_CELSIUS: 30}, API_RANGE_SP_MAX_DRY_AIR: {API_FAH: 86, API_CELSIUS: 30}, diff --git a/tests/components/aladdin_connect/__init__.py b/tests/components/aladdin_connect/__init__.py index 6e108ed88df..aa5957dc392 100644 --- a/tests/components/aladdin_connect/__init__.py +++ b/tests/components/aladdin_connect/__init__.py @@ -1 +1 @@ -"""The tests for Aladdin Connect platforms.""" +"""Tests for the Aladdin Connect Garage Door integration.""" diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py index 979c30bdcea..c7e5190d527 100644 --- a/tests/components/aladdin_connect/conftest.py +++ b/tests/components/aladdin_connect/conftest.py @@ -1,48 +1,31 @@ -"""Fixtures for the Aladdin Connect integration tests.""" +"""Test fixtures for the Aladdin Connect Garage Door integration.""" -from unittest import mock -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator -DEVICE_CONFIG_OPEN = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", - "link_status": "Connected", - "serial": "12345", - "model": "02", - "rssi": -67, - "ble_strength": 0, - "vendor": "GENIE", - "battery_level": 0, -} +from homeassistant.components.aladdin_connect import DOMAIN + +from tests.common import MockConfigEntry -@pytest.fixture(name="mock_aladdinconnect_api") -def fixture_mock_aladdinconnect_api(): - """Set up aladdin connect API fixture.""" - with mock.patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient" - ) as mock_opener: - mock_opener.login = AsyncMock(return_value=True) - mock_opener.close = AsyncMock(return_value=True) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry - mock_opener.async_get_door_status = AsyncMock(return_value="open") - mock_opener.get_door_status.return_value = "open" - mock_opener.async_get_door_link_status = AsyncMock(return_value="connected") - mock_opener.get_door_link_status.return_value = "connected" - mock_opener.async_get_battery_status = AsyncMock(return_value="99") - mock_opener.get_battery_status.return_value = "99" - mock_opener.async_get_rssi_status = AsyncMock(return_value="-55") - mock_opener.get_rssi_status.return_value = "-55" - mock_opener.async_get_ble_strength = AsyncMock(return_value="-45") - mock_opener.get_ble_strength.return_value = "-45" - mock_opener.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) - mock_opener.doors = [DEVICE_CONFIG_OPEN] - mock_opener.register_callback = mock.Mock(return_value=True) - mock_opener.open_door = AsyncMock(return_value=True) - mock_opener.close_door = AsyncMock(return_value=True) - return mock_opener +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return an Aladdin Connect config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={}, + title="test@test.com", + unique_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + version=2, + ) diff --git a/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr deleted file mode 100644 index 8f96567a49f..00000000000 --- a/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr +++ /dev/null @@ -1,20 +0,0 @@ -# serializer version: 1 -# name: test_entry_diagnostics - dict({ - 'doors': list([ - dict({ - 'battery_level': 0, - 'ble_strength': 0, - 'device_id': '**REDACTED**', - 'door_number': 1, - 'link_status': 'Connected', - 'model': '02', - 'name': 'home', - 'rssi': -67, - 'serial': '**REDACTED**', - 'status': 'open', - 'vendor': 'GENIE', - }), - ]), - }) -# --- diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index 65b8b24a59d..1537e0f35da 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -1,278 +1,225 @@ -"""Test the Aladdin Connect config flow.""" +"""Test the Aladdin Connect Garage Door config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock -from AIOAladdinConnect.session_manager import InvalidPasswordError -from aiohttp.client_exceptions import ClientConnectionError +import pytest -from homeassistant import config_entries -from homeassistant.components.aladdin_connect.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.aladdin_connect.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +EXAMPLE_TOKEN = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhYWFhYWFhYS1iYmJiLWNjY2MtZGRk" + "ZC1lZWVlZWVlZWVlZWUiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsInVzZXJuYW" + "1lIjoidGVzdEB0ZXN0LmNvbSJ9.CTU1YItIrUl8nSM3koJxlFJr5CjLghgc9gS6h45D8dE" +) -async def test_form(hass: HomeAssistant, mock_aladdinconnect_api: MagicMock) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - with ( - patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ), - patch( - "homeassistant.components.aladdin_connect.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Aladdin Connect" - assert result2["data"] == { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } +async def _oauth_actions( + hass: HomeAssistant, + result: ConfigFlowResult, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": EXAMPLE_TOKEN, + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + mock_setup_entry: AsyncMock, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@test.com" + assert result["data"]["token"]["access_token"] == EXAMPLE_TOKEN + assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].unique_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_failed_auth( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + mock_config_entry: MockConfigEntry, ) -> None: - """Test we handle failed authentication error.""" + """Test we abort with duplicate entry.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - mock_aladdinconnect_api.login.return_value = False - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" -async def test_form_connection_timeout( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, ) -> None: - """Test we handle http timeout error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - mock_aladdinconnect_api.login.side_effect = ClientConnectionError - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_already_configured( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test we handle already configured error.""" - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, - unique_id="test-username", - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_reauth_flow( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test a successful reauth flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-username", "password": "test-password"}, - unique_id="test-username", - ) - mock_entry.add_to_hass(hass) - + """Test reauthentication.""" + mock_config_entry.add_to_hass(hass) 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, + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, }, - data={"username": "test-username", "password": "new-password"}, + data=mock_config_entry.data, ) - - assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["step_id"] == "reauth_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) - with ( - patch( - "homeassistant.components.aladdin_connect.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert mock_entry.data == { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "new-password", - } + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" -async def test_reauth_flow_auth_error( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + mock_setup_entry: AsyncMock, ) -> None: - """Test an authorization error reauth flow.""" - - mock_entry = MockConfigEntry( + """Test reauthentication with wrong account.""" + config_entry = MockConfigEntry( domain=DOMAIN, - data={"username": "test-username", "password": "test-password"}, - unique_id="test-username", + data={}, + title="test@test.com", + unique_id="aaaaaaaa-bbbb-ffff-dddd-eeeeeeeeeeee", + version=2, ) - mock_entry.add_to_hass(hass) - + config_entry.add_to_hass(hass) 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, + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, }, - data={"username": "test-username", "password": "new-password"}, + data=config_entry.data, ) - - assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - mock_aladdinconnect_api.login.return_value = False - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with ( - patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ), - patch( - "homeassistant.components.aladdin_connect.cover.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.aladdin_connect.cover.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + assert result["step_id"] == "reauth_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" -async def test_reauth_flow_connnection_error( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_old_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + mock_setup_entry: AsyncMock, ) -> None: - """Test a connection error reauth flow.""" - - mock_entry = MockConfigEntry( + """Test reauthentication with old account.""" + config_entry = MockConfigEntry( domain=DOMAIN, - data={"username": "test-username", "password": "test-password"}, - unique_id="test-username", + data={}, + title="test@test.com", + unique_id="test@test.com", + version=2, ) - mock_entry.add_to_hass(hass) - + config_entry.add_to_hass(hass) 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, + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, }, - data={"username": "test-username", "password": "new-password"}, + data=config_entry.data, ) - - assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - mock_aladdinconnect_api.login.side_effect = ClientConnectionError + assert result["step_id"] == "reauth_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.unique_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py deleted file mode 100644 index 082ade75ab9..00000000000 --- a/tests/components/aladdin_connect/test_cover.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Test the Aladdin Connect Cover.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -from AIOAladdinConnect import session_manager -import pytest - -from homeassistant.components.aladdin_connect.const import DOMAIN -from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_ENTITY_ID, - SERVICE_CLOSE_COVER, - SERVICE_OPEN_COVER, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPEN, - STATE_OPENING, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow - -from tests.common import MockConfigEntry, async_fire_time_changed - -YAML_CONFIG = {"username": "test-user", "password": "test-password"} - -DEVICE_CONFIG_OPEN = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_OPENING = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "opening", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_CLOSED = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "closed", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_CLOSING = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "closing", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_DISCONNECTED = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", - "link_status": "Disconnected", - "serial": "12345", -} - -DEVICE_CONFIG_BAD = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", -} -DEVICE_CONFIG_BAD_NO_DOOR = { - "device_id": 533255, - "door_number": 2, - "name": "home", - "status": "open", - "link_status": "Disconnected", -} - - -async def test_cover_operation( - hass: HomeAssistant, - mock_aladdinconnect_api: MagicMock, -) -> None: - """Test Cover Operation states (open,close,opening,closing) cover.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - assert await async_setup_component(hass, "homeassistant", {}) - await hass.async_block_till_done() - - mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_OPEN) - mock_aladdinconnect_api.get_door_status.return_value = STATE_OPEN - - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert COVER_DOMAIN in hass.config.components - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - assert hass.states.get("cover.home").state == STATE_OPEN - - mock_aladdinconnect_api.open_door.return_value = False - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - - mock_aladdinconnect_api.open_door.return_value = True - - mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) - mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - assert hass.states.get("cover.home").state == STATE_CLOSED - - mock_aladdinconnect_api.close_door.return_value = False - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - - mock_aladdinconnect_api.close_door.return_value = True - - mock_aladdinconnect_api.async_get_door_status = AsyncMock( - return_value=STATE_CLOSING - ) - mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.home").state == STATE_CLOSING - - mock_aladdinconnect_api.async_get_door_status = AsyncMock( - return_value=STATE_OPENING - ) - mock_aladdinconnect_api.get_door_status.return_value = STATE_OPENING - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.home").state == STATE_OPENING - - mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=None) - mock_aladdinconnect_api.get_door_status.return_value = None - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - assert hass.states.get("cover.home").state == STATE_UNKNOWN - - mock_aladdinconnect_api.get_doors.side_effect = session_manager.ConnectionError - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - assert hass.states.get("cover.home").state == STATE_UNAVAILABLE - - mock_aladdinconnect_api.get_doors.side_effect = session_manager.InvalidPasswordError - mock_aladdinconnect_api.login.return_value = False - mock_aladdinconnect_api.login.side_effect = session_manager.InvalidPasswordError - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.home").state == STATE_UNAVAILABLE diff --git a/tests/components/aladdin_connect/test_diagnostics.py b/tests/components/aladdin_connect/test_diagnostics.py deleted file mode 100644 index 48741c77cd1..00000000000 --- a/tests/components/aladdin_connect/test_diagnostics.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Test AccuWeather diagnostics.""" - -from unittest.mock import MagicMock, patch - -from syrupy import SnapshotAssertion - -from homeassistant.components.aladdin_connect.const import DOMAIN -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 - -YAML_CONFIG = {"username": "test-user", "password": "test-password"} - - -async def test_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, - mock_aladdinconnect_api: MagicMock, -) -> None: - """Test config entry diagnostics.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - - assert result == snapshot diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py deleted file mode 100644 index 623c121957b..00000000000 --- a/tests/components/aladdin_connect/test_init.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Test for Aladdin Connect init logic.""" - -from unittest.mock import MagicMock, patch - -from AIOAladdinConnect.session_manager import InvalidPasswordError -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: - """Test component setup Get Doors Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), - patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=None, - ), - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is True - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 - - -async def test_setup_login_error( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test component setup Login Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - mock_aladdinconnect_api.login.return_value = False - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False - - -async def test_setup_connection_error( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test component setup Login Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - mock_aladdinconnect_api.login.side_effect = ClientConnectionError - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False - - -async def test_setup_component_no_error(hass: HomeAssistant) -> None: - """Test component setup No Error.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -async def test_entry_password_fail( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test password fail during entry.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-user", "password": "test-password"}, - ) - entry.add_to_hass(hass) - mock_aladdinconnect_api.login = AsyncMock(return_value=False) - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_ERROR - - -async def test_load_and_unload( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test loading and unloading Aladdin Connect entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - assert await config_entry.async_unload(hass) - await hass.async_block_till_done() - assert config_entry.state is 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 is 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 is 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 is 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/aladdin_connect/test_model.py b/tests/components/aladdin_connect/test_model.py deleted file mode 100644 index 84b1c9ae40a..00000000000 --- a/tests/components/aladdin_connect/test_model.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Test the Aladdin Connect model class.""" - -from homeassistant.components.aladdin_connect.model import DoorDevice -from homeassistant.core import HomeAssistant - - -async def test_model(hass: HomeAssistant) -> None: - """Test model for Aladdin Connect Model.""" - test_values = { - "device_id": "1", - "door_number": "2", - "name": "my door", - "status": "good", - } - result2 = DoorDevice(test_values) - assert result2["device_id"] == "1" - assert result2["door_number"] == "2" - assert result2["name"] == "my door" - assert result2["status"] == "good" diff --git a/tests/components/aladdin_connect/test_sensor.py b/tests/components/aladdin_connect/test_sensor.py deleted file mode 100644 index 9c229e2ac5e..00000000000 --- a/tests/components/aladdin_connect/test_sensor.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Test the Aladdin Connect Sensors.""" - -from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch - -from homeassistant.components.aladdin_connect.const import DOMAIN -from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow - -from tests.common import MockConfigEntry, async_fire_time_changed - -DEVICE_CONFIG_MODEL_01 = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "closed", - "link_status": "Connected", - "serial": "12345", - "model": "01", -} - - -CONFIG = {"username": "test-user", "password": "test-password"} -RELOAD_AFTER_UPDATE_DELAY = timedelta(seconds=31) - - -async def test_sensors( - hass: HomeAssistant, - mock_aladdinconnect_api: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test Sensors for AladdinConnect.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - await hass.async_block_till_done() - - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entry = entity_registry.async_get("sensor.home_battery") - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - assert update_entry != entry - assert update_entry.disabled is False - state = hass.states.get("sensor.home_battery") - assert state is None - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - state = hass.states.get("sensor.home_battery") - assert state - - entry = entity_registry.async_get("sensor.home_wi_fi_rssi") - await hass.async_block_till_done() - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - assert update_entry != entry - assert update_entry.disabled is False - state = hass.states.get("sensor.home_wi_fi_rssi") - assert state is None - - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_wi_fi_rssi") - assert state - - -async def test_sensors_model_01( - hass: HomeAssistant, - mock_aladdinconnect_api: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test Sensors for AladdinConnect.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - await hass.async_block_till_done() - - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - mock_aladdinconnect_api.get_doors = AsyncMock( - return_value=[DEVICE_CONFIG_MODEL_01] - ) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entry = entity_registry.async_get("sensor.home_battery") - assert entry - assert entry.disabled is False - assert entry.disabled_by is None - state = hass.states.get("sensor.home_battery") - assert state - - entry = entity_registry.async_get("sensor.home_wi_fi_rssi") - await hass.async_block_till_done() - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - assert update_entry != entry - assert update_entry.disabled is False - state = hass.states.get("sensor.home_wi_fi_rssi") - assert state is None - - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_wi_fi_rssi") - assert state - - entry = entity_registry.async_get("sensor.home_ble_strength") - await hass.async_block_till_done() - assert entry - assert entry.disabled is False - assert entry.disabled_by is None - state = hass.states.get("sensor.home_ble_strength") - assert state diff --git a/tests/components/alarm_control_panel/conftest.py b/tests/components/alarm_control_panel/conftest.py index cda3d81b26e..620b74dd80e 100644 --- a/tests/components/alarm_control_panel/conftest.py +++ b/tests/components/alarm_control_panel/conftest.py @@ -1,8 +1,33 @@ """Fixturs for Alarm Control Panel tests.""" -import pytest +from unittest.mock import MagicMock -from tests.components.alarm_control_panel.common import MockAlarm +import pytest +from typing_extensions import Generator + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) +from homeassistant.components.alarm_control_panel.const import CodeFormat +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 .common import MockAlarm + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" @pytest.fixture @@ -20,3 +45,156 @@ def mock_alarm_control_panel_entities() -> dict[str, MockAlarm]: unique_id="unique_no_arm_code", ), } + + +class MockAlarmControlPanel(AlarmControlPanelEntity): + """Mocked alarm control entity.""" + + def __init__( + self, + supported_features: AlarmControlPanelEntityFeature = AlarmControlPanelEntityFeature( + 0 + ), + code_format: CodeFormat | None = None, + code_arm_required: bool = True, + ) -> None: + """Initialize the alarm control.""" + self.calls_disarm = MagicMock() + self.calls_arm_home = MagicMock() + self.calls_arm_away = MagicMock() + self.calls_arm_night = MagicMock() + self.calls_arm_vacation = MagicMock() + self.calls_trigger = MagicMock() + self.calls_arm_custom = MagicMock() + self._attr_code_format = code_format + self._attr_supported_features = supported_features + self._attr_code_arm_required = code_arm_required + self._attr_has_entity_name = True + self._attr_name = "test_alarm_control_panel" + self._attr_unique_id = "very_unique_alarm_control_panel_id" + super().__init__() + + def alarm_disarm(self, code: str | None = None) -> None: + """Mock alarm disarm calls.""" + self.calls_disarm(code) + + def alarm_arm_home(self, code: str | None = None) -> None: + """Mock arm home calls.""" + self.calls_arm_home(code) + + def alarm_arm_away(self, code: str | None = None) -> None: + """Mock arm away calls.""" + self.calls_arm_away(code) + + def alarm_arm_night(self, code: str | None = None) -> None: + """Mock arm night calls.""" + self.calls_arm_night(code) + + def alarm_arm_vacation(self, code: str | None = None) -> None: + """Mock arm vacation calls.""" + self.calls_arm_vacation(code) + + def alarm_trigger(self, code: str | None = None) -> None: + """Mock trigger calls.""" + self.calls_trigger(code) + + def alarm_arm_custom_bypass(self, code: str | None = None) -> None: + """Mock arm custom bypass calls.""" + self.calls_arm_custom(code) + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[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() -> CodeFormat | None: + """Return the code format for the test alarm control panel entity.""" + return CodeFormat.NUMBER + + +@pytest.fixture +async def code_arm_required() -> bool: + """Return if code required for arming.""" + return True + + +@pytest.fixture(name="supported_features") +async def lock_supported_features() -> AlarmControlPanelEntityFeature: + """Return the supported features for the test alarm control panel entity.""" + return ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + | AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION + | AlarmControlPanelEntityFeature.TRIGGER + ) + + +@pytest.fixture(name="mock_alarm_control_panel_entity") +async def setup_lock_platform_test_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + code_format: CodeFormat | None, + supported_features: AlarmControlPanelEntityFeature, + code_arm_required: bool, +) -> MagicMock: + """Set up alarm control panel 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_setups( + config_entry, [ALARM_CONTROL_PANEL_DOMAIN] + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed sensor without device class -> no name + entity = MockAlarmControlPanel( + supported_features=supported_features, + code_format=code_format, + code_arm_required=code_arm_required, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test alarm control panel platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_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/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 5d142ab277b..9c5aaffd733 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -24,13 +24,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from .common import MockAlarm + from tests.common import ( MockConfigEntry, async_get_device_automation_capabilities, async_get_device_automations, setup_test_component_platform, ) -from tests.components.alarm_control_panel.common import MockAlarm @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -173,7 +174,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["disarm", "arm_away"] + for action in ("disarm", "arm_away") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index b6ee6b2faaa..da1d77f50a3 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -23,11 +23,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_get_device_automations, - async_mock_service, -) +from tests.common import MockConfigEntry, async_get_device_automations @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -35,12 +31,6 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" -@pytest.fixture -def calls(hass: HomeAssistant) -> list[ServiceCall]: - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - @pytest.mark.parametrize( ("set_state", "features_reg", "features_state", "expected_condition_types"), [ @@ -177,7 +167,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_disarmed", "is_triggered"] + for condition in ("is_disarmed", "is_triggered") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -189,7 +179,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls: list[ServiceCall], + service_calls: list[ServiceCall], ) -> None: """Test for all conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -373,8 +363,8 @@ async def test_if_state( hass.bus.async_fire("test_event6") hass.bus.async_fire("test_event7") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "is_triggered - event - test_event1" + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == "is_triggered - event - test_event1" hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) hass.bus.async_fire("test_event1") @@ -385,8 +375,8 @@ async def test_if_state( hass.bus.async_fire("test_event6") hass.bus.async_fire("test_event7") await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "is_disarmed - event - test_event2" + assert len(service_calls) == 2 + assert service_calls[1].data["some"] == "is_disarmed - event - test_event2" hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_HOME) hass.bus.async_fire("test_event1") @@ -397,8 +387,8 @@ async def test_if_state( hass.bus.async_fire("test_event6") hass.bus.async_fire("test_event7") await hass.async_block_till_done() - assert len(calls) == 3 - assert calls[2].data["some"] == "is_armed_home - event - test_event3" + assert len(service_calls) == 3 + assert service_calls[2].data["some"] == "is_armed_home - event - test_event3" hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_AWAY) hass.bus.async_fire("test_event1") @@ -409,8 +399,8 @@ async def test_if_state( hass.bus.async_fire("test_event6") hass.bus.async_fire("test_event7") await hass.async_block_till_done() - assert len(calls) == 4 - assert calls[3].data["some"] == "is_armed_away - event - test_event4" + assert len(service_calls) == 4 + assert service_calls[3].data["some"] == "is_armed_away - event - test_event4" hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_NIGHT) hass.bus.async_fire("test_event1") @@ -421,8 +411,8 @@ async def test_if_state( hass.bus.async_fire("test_event6") hass.bus.async_fire("test_event7") await hass.async_block_till_done() - assert len(calls) == 5 - assert calls[4].data["some"] == "is_armed_night - event - test_event5" + assert len(service_calls) == 5 + assert service_calls[4].data["some"] == "is_armed_night - event - test_event5" hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_VACATION) hass.bus.async_fire("test_event1") @@ -433,8 +423,8 @@ async def test_if_state( hass.bus.async_fire("test_event6") hass.bus.async_fire("test_event7") await hass.async_block_till_done() - assert len(calls) == 6 - assert calls[5].data["some"] == "is_armed_vacation - event - test_event6" + assert len(service_calls) == 6 + assert service_calls[5].data["some"] == "is_armed_vacation - event - test_event6" hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_CUSTOM_BYPASS) hass.bus.async_fire("test_event1") @@ -445,15 +435,17 @@ async def test_if_state( hass.bus.async_fire("test_event6") hass.bus.async_fire("test_event7") await hass.async_block_till_done() - assert len(calls) == 7 - assert calls[6].data["some"] == "is_armed_custom_bypass - event - test_event7" + assert len(service_calls) == 7 + assert ( + service_calls[6].data["some"] == "is_armed_custom_bypass - event - test_event7" + ) async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls: list[ServiceCall], + service_calls: list[ServiceCall], ) -> None: """Test for all conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -499,5 +491,5 @@ async def test_if_state_legacy( hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) hass.bus.async_fire("test_event1") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "is_triggered - event - test_event1" + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == "is_triggered - event - test_event1" diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index fb2d4e0a504..46eba314dc1 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -31,7 +31,6 @@ from tests.common import ( async_fire_time_changed, async_get_device_automation_capabilities, async_get_device_automations, - async_mock_service, ) @@ -40,12 +39,6 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" -@pytest.fixture -def calls(hass: HomeAssistant) -> list[ServiceCall]: - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - @pytest.mark.parametrize( ("set_state", "features_reg", "features_state", "expected_trigger_types"), [ @@ -169,7 +162,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entry.id, "metadata": {"secondary": True}, } - for trigger in ["triggered", "disarmed", "arming"] + for trigger in ("triggered", "disarmed", "arming") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -250,8 +243,8 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls: list[ServiceCall], -): + service_calls: list[ServiceCall], +) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -409,54 +402,54 @@ async def test_if_fires_on_state_change( # Fake that the entity is triggered. hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 assert ( - calls[0].data["some"] + service_calls[0].data["some"] == f"triggered - device - {entry.entity_id} - pending - triggered - None" ) # Fake that the entity is disarmed. hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 assert ( - calls[1].data["some"] + service_calls[1].data["some"] == f"disarmed - device - {entry.entity_id} - triggered - disarmed - None" ) # Fake that the entity is armed home. hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_HOME) await hass.async_block_till_done() - assert len(calls) == 3 + assert len(service_calls) == 3 assert ( - calls[2].data["some"] + service_calls[2].data["some"] == f"armed_home - device - {entry.entity_id} - disarmed - armed_home - None" ) # Fake that the entity is armed away. hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_AWAY) await hass.async_block_till_done() - assert len(calls) == 4 + assert len(service_calls) == 4 assert ( - calls[3].data["some"] + service_calls[3].data["some"] == f"armed_away - device - {entry.entity_id} - armed_home - armed_away - None" ) # Fake that the entity is armed night. hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_NIGHT) await hass.async_block_till_done() - assert len(calls) == 5 + assert len(service_calls) == 5 assert ( - calls[4].data["some"] + service_calls[4].data["some"] == f"armed_night - device - {entry.entity_id} - armed_away - armed_night - None" ) # Fake that the entity is armed vacation. hass.states.async_set(entry.entity_id, STATE_ALARM_ARMED_VACATION) await hass.async_block_till_done() - assert len(calls) == 6 + assert len(service_calls) == 6 assert ( - calls[5].data["some"] + service_calls[5].data["some"] == f"armed_vacation - device - {entry.entity_id} - armed_night - armed_vacation - None" ) @@ -465,7 +458,7 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls: list[ServiceCall], + service_calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -511,17 +504,17 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await hass.async_block_till_done() assert ( - calls[0].data["some"] + service_calls[0].data["some"] == f"turn_off device - {entry.entity_id} - disarmed - triggered - 0:00:05" ) @@ -530,7 +523,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls: list[ServiceCall], + service_calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -575,12 +568,12 @@ async def test_if_fires_on_state_change_legacy( }, ) await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 hass.states.async_set(entry.entity_id, STATE_ALARM_TRIGGERED) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 assert ( - calls[0].data["some"] + service_calls[0].data["some"] == f"turn_off device - {entry.entity_id} - disarmed - triggered - None" ) diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 42a532cbb1a..06724978ce3 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -1,14 +1,52 @@ """Test for the alarm control panel const module.""" from types import ModuleType +from typing import Any import pytest from homeassistant.components import alarm_control_panel +from homeassistant.components.alarm_control_panel.const import ( + AlarmControlPanelEntityFeature, + CodeFormat, +) +from homeassistant.const import ( + ATTR_CODE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import UNDEFINED, UndefinedType + +from .conftest import MockAlarmControlPanel from tests.common import help_test_all, import_and_test_deprecated_constant_enum +async def help_test_async_alarm_control_panel_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 + + await hass.services.async_call( + alarm_control_panel.DOMAIN, service, data, blocking=True + ) + await hass.async_block_till_done() + + @pytest.mark.parametrize( "module", [alarm_control_panel, alarm_control_panel.const], @@ -77,3 +115,171 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> is alarm_control_panel.AlarmControlPanelEntityFeature(1) ) assert "is using deprecated supported features values" not in caplog.text + + +async def test_set_mock_alarm_control_panel_options( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_alarm_control_panel_entity: MockAlarmControlPanel, +) -> None: + """Test mock attributes and default code stored in the registry.""" + entity_registry.async_update_entity_options( + "alarm_control_panel.test_alarm_control_panel", + "alarm_control_panel", + {alarm_control_panel.CONF_DEFAULT_CODE: "1234"}, + ) + await hass.async_block_till_done() + + assert ( + mock_alarm_control_panel_entity._alarm_control_panel_option_default_code + == "1234" + ) + state = hass.states.get(mock_alarm_control_panel_entity.entity_id) + assert state is not None + assert state.attributes["code_format"] == CodeFormat.NUMBER + assert ( + state.attributes["supported_features"] + == AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + | AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION + | AlarmControlPanelEntityFeature.TRIGGER + ) + + +async def test_default_code_option_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_alarm_control_panel_entity: MockAlarmControlPanel, +) -> None: + """Test default code stored in the registry is updated.""" + + assert ( + mock_alarm_control_panel_entity._alarm_control_panel_option_default_code is None + ) + + entity_registry.async_update_entity_options( + "alarm_control_panel.test_alarm_control_panel", + "alarm_control_panel", + {alarm_control_panel.CONF_DEFAULT_CODE: "4321"}, + ) + await hass.async_block_till_done() + + assert ( + mock_alarm_control_panel_entity._alarm_control_panel_option_default_code + == "4321" + ) + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(CodeFormat.TEXT, AlarmControlPanelEntityFeature.ARM_AWAY)], +) +async def test_alarm_control_panel_arm_with_code( + hass: HomeAssistant, mock_alarm_control_panel_entity: MockAlarmControlPanel +) -> None: + """Test alarm control panel entity with open service.""" + state = hass.states.get(mock_alarm_control_panel_entity.entity_id) + assert state.attributes["code_format"] == CodeFormat.TEXT + + with pytest.raises(ServiceValidationError): + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_AWAY + ) + with pytest.raises(ServiceValidationError): + await help_test_async_alarm_control_panel_service( + hass, + mock_alarm_control_panel_entity.entity_id, + SERVICE_ALARM_ARM_AWAY, + code="", + ) + await help_test_async_alarm_control_panel_service( + hass, + mock_alarm_control_panel_entity.entity_id, + SERVICE_ALARM_ARM_AWAY, + code="1234", + ) + assert mock_alarm_control_panel_entity.calls_arm_away.call_count == 1 + mock_alarm_control_panel_entity.calls_arm_away.assert_called_with("1234") + + +@pytest.mark.parametrize( + ("code_format", "code_arm_required"), + [(CodeFormat.NUMBER, False)], +) +async def test_alarm_control_panel_with_no_code( + hass: HomeAssistant, mock_alarm_control_panel_entity: MockAlarmControlPanel +) -> None: + """Test alarm control panel entity without code.""" + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_AWAY + ) + mock_alarm_control_panel_entity.calls_arm_away.assert_called_with(None) + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_CUSTOM_BYPASS + ) + mock_alarm_control_panel_entity.calls_arm_custom.assert_called_with(None) + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_HOME + ) + mock_alarm_control_panel_entity.calls_arm_home.assert_called_with(None) + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_NIGHT + ) + mock_alarm_control_panel_entity.calls_arm_night.assert_called_with(None) + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_VACATION + ) + mock_alarm_control_panel_entity.calls_arm_vacation.assert_called_with(None) + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_DISARM + ) + mock_alarm_control_panel_entity.calls_disarm.assert_called_with(None) + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_TRIGGER + ) + mock_alarm_control_panel_entity.calls_trigger.assert_called_with(None) + + +@pytest.mark.parametrize( + ("code_format", "code_arm_required"), + [(CodeFormat.NUMBER, True)], +) +async def test_alarm_control_panel_with_default_code( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_alarm_control_panel_entity: MockAlarmControlPanel, +) -> None: + """Test alarm control panel entity without code.""" + entity_registry.async_update_entity_options( + "alarm_control_panel.test_alarm_control_panel", + "alarm_control_panel", + {alarm_control_panel.CONF_DEFAULT_CODE: "1234"}, + ) + await hass.async_block_till_done() + + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_AWAY + ) + mock_alarm_control_panel_entity.calls_arm_away.assert_called_with("1234") + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_CUSTOM_BYPASS + ) + mock_alarm_control_panel_entity.calls_arm_custom.assert_called_with("1234") + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_HOME + ) + mock_alarm_control_panel_entity.calls_arm_home.assert_called_with("1234") + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_NIGHT + ) + mock_alarm_control_panel_entity.calls_arm_night.assert_called_with("1234") + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_ARM_VACATION + ) + mock_alarm_control_panel_entity.calls_arm_vacation.assert_called_with("1234") + await help_test_async_alarm_control_panel_service( + hass, mock_alarm_control_panel_entity.entity_id, SERVICE_ALARM_DISARM + ) + mock_alarm_control_panel_entity.calls_disarm.assert_called_with("1234") diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 7efc851a9c5..15a4bd6d9a1 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -817,7 +817,7 @@ async def test_report_climate_state(hass: HomeAssistant) -> None: {"value": 34.0, "scale": "CELSIUS"}, ) - for off_modes in [HVACMode.OFF]: + for off_modes in (HVACMode.OFF,): hass.states.async_set( "climate.downstairs", off_modes, @@ -954,7 +954,7 @@ async def test_report_on_off_climate_state(hass: HomeAssistant) -> None: {"value": 34.0, "scale": "CELSIUS"}, ) - for off_modes in [HVACMode.OFF]: + for off_modes in (HVACMode.OFF,): hass.states.async_set( "climate.onoff", off_modes, @@ -1002,7 +1002,7 @@ async def test_report_water_heater_state(hass: HomeAssistant) -> None: {"value": 34.0, "scale": "CELSIUS"}, ) - for off_mode in [STATE_OFF]: + for off_mode in (STATE_OFF,): hass.states.async_set( "water_heater.boyler", off_mode, diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 9ec490c4f83..6998b2acc97 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -130,7 +130,7 @@ async def test_serialize_discovery_partly_fails( } assert all( entity in endpoint_ids - for entity in ["switch#bla", "fan#bla", "humidifier#bla", "sensor#bla"] + for entity in ("switch#bla", "fan#bla", "humidifier#bla", "sensor#bla") ) # Simulate fetching the interfaces fails for fan entity @@ -147,7 +147,7 @@ async def test_serialize_discovery_partly_fails( } assert all( entity in endpoint_ids - for entity in ["switch#bla", "humidifier#bla", "sensor#bla"] + for entity in ("switch#bla", "humidifier#bla", "sensor#bla") ) assert "Unable to serialize fan.bla for discovery" in caplog.text caplog.clear() @@ -166,7 +166,7 @@ async def test_serialize_discovery_partly_fails( } assert all( entity in endpoint_ids - for entity in ["switch#bla", "humidifier#bla", "fan#bla"] + for entity in ("switch#bla", "humidifier#bla", "fan#bla") ) assert "Unable to serialize sensor.bla for discovery" in caplog.text caplog.clear() diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index c6c2b3cc421..e76ed4ba6d0 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -1,15 +1,19 @@ """The tests for the Alexa component.""" +from asyncio import AbstractEventLoop import datetime from http import HTTPStatus +from aiohttp.test_utils import TestClient import pytest from homeassistant.components import alexa from homeassistant.components.alexa import const -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" @@ -20,7 +24,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client(event_loop, hass, hass_client): +def alexa_client( + event_loop: AbstractEventLoop, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> TestClient: """Initialize a Home Assistant server for testing this module.""" loop = event_loop diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 4670db4ffa9..b82048dca9b 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -1,8 +1,10 @@ """The tests for the Alexa component.""" +from asyncio import AbstractEventLoop from http import HTTPStatus import json +from aiohttp.test_utils import TestClient import pytest from homeassistant.components import alexa @@ -11,6 +13,8 @@ from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" APPLICATION_ID_SESSION_OPEN = ( @@ -26,7 +30,11 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @pytest.fixture -def alexa_client(event_loop, hass, hass_client): +def alexa_client( + event_loop: AbstractEventLoop, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> TestClient: """Initialize a Home Assistant server for testing this module.""" loop = event_loop diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index fa8d7a2c9fb..d502dce7d01 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -882,7 +882,7 @@ async def test_direction_fan(hass: HomeAssistant) -> None: payload={}, instance=None, ) - assert call.data + assert call.data async def test_preset_mode_fan( @@ -1823,12 +1823,6 @@ async def test_media_player_seek_error(hass: HomeAssistant) -> None: payload={"deltaPositionMilliseconds": 30000}, ) - assert "event" in msg - msg = msg["event"] - assert msg["header"]["name"] == "ErrorResponse" - assert msg["header"]["namespace"] == "Alexa.Video" - assert msg["payload"]["type"] == "ACTION_NOT_PERMITTED_FOR_CONTENT" - @pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_alert(hass: HomeAssistant) -> None: @@ -3827,7 +3821,6 @@ async def test_disabled(hass: HomeAssistant) -> None: await smart_home.async_handle_message( hass, get_default_config(hass), request, enabled=False ) - await hass.async_block_till_done() async def test_endpoint_good_health(hass: HomeAssistant) -> None: @@ -5650,6 +5643,6 @@ async def test_alexa_config( with patch.object(test_config, "_auth", AsyncMock()): test_config._auth.async_invalidate_access_token = MagicMock() test_config.async_invalidate_access_token() - assert len(test_config._auth.async_invalidate_access_token.mock_calls) + assert len(test_config._auth.async_invalidate_access_token.mock_calls) == 1 await test_config.async_accept_grant("grant_code") test_config._auth.async_do_auth.assert_called_once_with("grant_code") diff --git a/tests/components/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py index 8912c248a71..9de865fae6c 100644 --- a/tests/components/amberelectric/conftest.py +++ b/tests/components/amberelectric/conftest.py @@ -1,13 +1,13 @@ """Provide common Amber fixtures.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.amberelectric.async_setup_entry", return_value=True diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py index 92877c57c61..1e5eb572e07 100644 --- a/tests/components/amberelectric/test_binary_sensor.py +++ b/tests/components/amberelectric/test_binary_sensor.py @@ -28,7 +28,7 @@ MOCK_API_TOKEN = "psk_0000000000000000" @pytest.fixture -async def setup_no_spike(hass) -> AsyncGenerator: +async def setup_no_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: """Set up general channel.""" MockConfigEntry( domain="amberelectric", @@ -51,7 +51,7 @@ async def setup_no_spike(hass) -> AsyncGenerator: @pytest.fixture -async def setup_potential_spike(hass) -> AsyncGenerator: +async def setup_potential_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: """Set up general channel.""" MockConfigEntry( domain="amberelectric", @@ -80,7 +80,7 @@ async def setup_potential_spike(hass) -> AsyncGenerator: @pytest.fixture -async def setup_spike(hass) -> AsyncGenerator: +async def setup_spike(hass: HomeAssistant) -> AsyncGenerator[Mock]: """Set up general channel.""" MockConfigEntry( domain="amberelectric", @@ -108,7 +108,8 @@ async def setup_spike(hass) -> AsyncGenerator: yield mock_update.return_value -def test_no_spike_sensor(hass: HomeAssistant, setup_no_spike) -> None: +@pytest.mark.usefixtures("setup_no_spike") +def test_no_spike_sensor(hass: HomeAssistant) -> None: """Testing the creation of the Amber renewables sensor.""" assert len(hass.states.async_all()) == 5 sensor = hass.states.get("binary_sensor.mock_title_price_spike") @@ -118,7 +119,8 @@ def test_no_spike_sensor(hass: HomeAssistant, setup_no_spike) -> None: assert sensor.attributes["spike_status"] == "none" -def test_potential_spike_sensor(hass: HomeAssistant, setup_potential_spike) -> None: +@pytest.mark.usefixtures("setup_potential_spike") +def test_potential_spike_sensor(hass: HomeAssistant) -> None: """Testing the creation of the Amber renewables sensor.""" assert len(hass.states.async_all()) == 5 sensor = hass.states.get("binary_sensor.mock_title_price_spike") @@ -128,7 +130,8 @@ def test_potential_spike_sensor(hass: HomeAssistant, setup_potential_spike) -> N assert sensor.attributes["spike_status"] == "potential" -def test_spike_sensor(hass: HomeAssistant, setup_spike) -> None: +@pytest.mark.usefixtures("setup_spike") +def test_spike_sensor(hass: HomeAssistant) -> None: """Testing the creation of the Amber renewables sensor.""" assert len(hass.states.async_all()) == 5 sensor = hass.states.get("binary_sensor.mock_title_price_spike") diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index c2d4886bbe9..3c0910f0afc 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -31,7 +31,7 @@ MOCK_API_TOKEN = "psk_0000000000000000" @pytest.fixture -async def setup_general(hass) -> AsyncGenerator: +async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]: """Set up general channel.""" MockConfigEntry( domain="amberelectric", @@ -54,7 +54,9 @@ async def setup_general(hass) -> AsyncGenerator: @pytest.fixture -async def setup_general_and_controlled_load(hass) -> AsyncGenerator: +async def setup_general_and_controlled_load( + hass: HomeAssistant, +) -> AsyncGenerator[Mock]: """Set up general channel and controller load channel.""" MockConfigEntry( domain="amberelectric", @@ -78,7 +80,7 @@ async def setup_general_and_controlled_load(hass) -> AsyncGenerator: @pytest.fixture -async def setup_general_and_feed_in(hass) -> AsyncGenerator: +async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]: """Set up general channel and feed in channel.""" MockConfigEntry( domain="amberelectric", @@ -138,9 +140,8 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_max") == 0.12 -async def test_general_and_controlled_load_price_sensor( - hass: HomeAssistant, setup_general_and_controlled_load: Mock -) -> None: +@pytest.mark.usefixtures("setup_general_and_controlled_load") +async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> None: """Test the Controlled Price sensor.""" assert len(hass.states.async_all()) == 8 price = hass.states.get("sensor.mock_title_controlled_load_price") @@ -161,9 +162,8 @@ async def test_general_and_controlled_load_price_sensor( assert attributes["attribution"] == "Data provided by Amber Electric" -async def test_general_and_feed_in_price_sensor( - hass: HomeAssistant, setup_general_and_feed_in: Mock -) -> None: +@pytest.mark.usefixtures("setup_general_and_feed_in") +async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: """Test the Feed In sensor.""" assert len(hass.states.async_all()) == 8 price = hass.states.get("sensor.mock_title_feed_in_price") @@ -227,9 +227,8 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_max") == 0.12 -async def test_controlled_load_forecast_sensor( - hass: HomeAssistant, setup_general_and_controlled_load: Mock -) -> None: +@pytest.mark.usefixtures("setup_general_and_controlled_load") +async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: """Test the Controlled Load Forecast sensor.""" assert len(hass.states.async_all()) == 8 price = hass.states.get("sensor.mock_title_controlled_load_forecast") @@ -252,9 +251,8 @@ async def test_controlled_load_forecast_sensor( assert first_forecast["descriptor"] == "very_low" -async def test_feed_in_forecast_sensor( - hass: HomeAssistant, setup_general_and_feed_in: Mock -) -> None: +@pytest.mark.usefixtures("setup_general_and_feed_in") +async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: """Test the Feed In Forecast sensor.""" assert len(hass.states.async_all()) == 8 price = hass.states.get("sensor.mock_title_feed_in_forecast") @@ -277,7 +275,8 @@ async def test_feed_in_forecast_sensor( assert first_forecast["descriptor"] == "very_low" -def test_renewable_sensor(hass: HomeAssistant, setup_general) -> None: +@pytest.mark.usefixtures("setup_general") +def test_renewable_sensor(hass: HomeAssistant) -> None: """Testing the creation of the Amber renewables sensor.""" assert len(hass.states.async_all()) == 5 sensor = hass.states.get("sensor.mock_title_renewables") @@ -285,9 +284,8 @@ def test_renewable_sensor(hass: HomeAssistant, setup_general) -> None: assert sensor.state == "51" -def test_general_price_descriptor_descriptor_sensor( - hass: HomeAssistant, setup_general: Mock -) -> None: +@pytest.mark.usefixtures("setup_general") +def test_general_price_descriptor_descriptor_sensor(hass: HomeAssistant) -> None: """Test the General Price Descriptor sensor.""" assert len(hass.states.async_all()) == 5 price = hass.states.get("sensor.mock_title_general_price_descriptor") @@ -295,8 +293,9 @@ def test_general_price_descriptor_descriptor_sensor( assert price.state == "extremely_low" +@pytest.mark.usefixtures("setup_general_and_controlled_load") def test_general_and_controlled_load_price_descriptor_sensor( - hass: HomeAssistant, setup_general_and_controlled_load: Mock + hass: HomeAssistant, ) -> None: """Test the Controlled Price Descriptor sensor.""" assert len(hass.states.async_all()) == 8 @@ -305,9 +304,8 @@ def test_general_and_controlled_load_price_descriptor_sensor( assert price.state == "extremely_low" -def test_general_and_feed_in_price_descriptor_sensor( - hass: HomeAssistant, setup_general_and_feed_in: Mock -) -> None: +@pytest.mark.usefixtures("setup_general_and_feed_in") +def test_general_and_feed_in_price_descriptor_sensor(hass: HomeAssistant) -> None: """Test the Feed In Price Descriptor sensor.""" assert len(hass.states.async_all()) == 8 price = hass.states.get("sensor.mock_title_feed_in_price_descriptor") diff --git a/tests/components/ambiclimate/__init__.py b/tests/components/ambiclimate/__init__.py deleted file mode 100644 index b3f9a5ad3a6..00000000000 --- a/tests/components/ambiclimate/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Ambiclimate component.""" diff --git a/tests/components/ambiclimate/test_config_flow.py b/tests/components/ambiclimate/test_config_flow.py deleted file mode 100644 index 67c67aba4a8..00000000000 --- a/tests/components/ambiclimate/test_config_flow.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Tests for the Ambiclimate config flow.""" - -from unittest.mock import AsyncMock, patch - -import ambiclimate -import pytest - -from homeassistant import config_entries -from homeassistant.components.ambiclimate import config_flow -from homeassistant.components.http import KEY_HASS -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResultType -from homeassistant.setup import async_setup_component -from homeassistant.util import aiohttp - -from tests.common import MockConfigEntry - - -async def init_config_flow(hass): - """Init a configuration flow.""" - await async_process_ha_core_config( - hass, - {"external_url": "https://example.com"}, - ) - await async_setup_component(hass, "http", {}) - - config_flow.register_flow_implementation(hass, "id", "secret") - flow = config_flow.AmbiclimateFlowHandler() - - flow.hass = hass - return flow - - -async def test_abort_if_no_implementation_registered(hass: HomeAssistant) -> None: - """Test we abort if no implementation is registered.""" - flow = config_flow.AmbiclimateFlowHandler() - flow.hass = hass - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "missing_configuration" - - -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: - """Test we abort if Ambiclimate is already setup.""" - flow = await init_config_flow(hass) - - MockConfigEntry(domain=config_flow.DOMAIN).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - with pytest.raises(AbortFlow): - result = await flow.async_step_code() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_full_flow_implementation(hass: HomeAssistant) -> None: - """Test registering an implementation and finishing flow works.""" - config_flow.register_flow_implementation(hass, None, None) - flow = await init_config_flow(hass) - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert ( - result["description_placeholders"]["cb_url"] - == "https://example.com/api/ambiclimate" - ) - - url = result["description_placeholders"]["authorization_url"] - assert "https://api.ambiclimate.com/oauth2/authorize" in url - assert "client_id=id" in url - assert "response_type=code" in url - assert "redirect_uri=https%3A%2F%2Fexample.com%2Fapi%2Fambiclimate" in url - - with patch("ambiclimate.AmbiclimateOAuth.get_access_token", return_value="test"): - result = await flow.async_step_code("123ABC") - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Ambiclimate" - assert result["data"]["callback_url"] == "https://example.com/api/ambiclimate" - assert result["data"][CONF_CLIENT_SECRET] == "secret" - assert result["data"][CONF_CLIENT_ID] == "id" - - with patch("ambiclimate.AmbiclimateOAuth.get_access_token", return_value=None): - result = await flow.async_step_code("123ABC") - assert result["type"] is FlowResultType.ABORT - - with patch( - "ambiclimate.AmbiclimateOAuth.get_access_token", - side_effect=ambiclimate.AmbiclimateOauthError(), - ): - result = await flow.async_step_code("123ABC") - assert result["type"] is FlowResultType.ABORT - - -async def test_abort_invalid_code(hass: HomeAssistant) -> None: - """Test if no code is given to step_code.""" - config_flow.register_flow_implementation(hass, None, None) - flow = await init_config_flow(hass) - - with patch("ambiclimate.AmbiclimateOAuth.get_access_token", return_value=None): - result = await flow.async_step_code("invalid") - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "access_token" - - -async def test_already_setup(hass: HomeAssistant) -> None: - """Test when already setup.""" - MockConfigEntry(domain=config_flow.DOMAIN).add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_view(hass: HomeAssistant) -> None: - """Test view.""" - hass.config_entries.flow.async_init = AsyncMock() - - request = aiohttp.MockRequest( - b"", query_string="code=test_code", mock_source="test" - ) - request.app = {KEY_HASS: hass} - view = config_flow.AmbiclimateAuthCallbackView() - assert await view.get(request) == "OK!" - - request = aiohttp.MockRequest(b"", query_string="", mock_source="test") - request.app = {KEY_HASS: hass} - view = config_flow.AmbiclimateAuthCallbackView() - assert await view.get(request) == "No code" diff --git a/tests/components/ambiclimate/test_init.py b/tests/components/ambiclimate/test_init.py deleted file mode 100644 index aaf806dba5b..00000000000 --- a/tests/components/ambiclimate/test_init.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for the Ambiclimate integration.""" - -from unittest.mock import patch - -import pytest - -from homeassistant.components.ambiclimate import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from tests.common import MockConfigEntry - - -@pytest.fixture(name="disable_platforms") -async def disable_platforms_fixture(hass): - """Disable ambiclimate platforms.""" - with patch("homeassistant.components.ambiclimate.PLATFORMS", []): - yield - - -async def test_repair_issue( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - disable_platforms, -) -> None: - """Test the Ambiclimate configuration entry loading handles the repair.""" - config_entry = MockConfigEntry( - title="Example 1", - domain=DOMAIN, - ) - 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 issue_registry.async_get_issue(DOMAIN, DOMAIN) - - # Remove the entry - await hass.config_entries.async_remove(config_entry.entry_id) - await hass.async_block_till_done() - - # Ambiclimate does not implement unload - assert config_entry.state is ConfigEntryState.FAILED_UNLOAD - assert issue_registry.async_get_issue(DOMAIN, DOMAIN) diff --git a/tests/components/ambient_network/conftest.py b/tests/components/ambient_network/conftest.py index ede44b5d92f..2900f8ae5fe 100644 --- a/tests/components/ambient_network/conftest.py +++ b/tests/components/ambient_network/conftest.py @@ -1,11 +1,11 @@ """Common fixtures for the Ambient Weather Network integration tests.""" -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch from aioambient import OpenAPI import pytest +from typing_extensions import Generator from homeassistant.components import ambient_network from homeassistant.core import HomeAssistant @@ -18,7 +18,7 @@ from tests.common import ( @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.ambient_network.async_setup_entry", return_value=True @@ -66,7 +66,7 @@ async def mock_aioambient(open_api: OpenAPI): @pytest.fixture(name="config_entry") -def config_entry_fixture(request) -> MockConfigEntry: +def config_entry_fixture(request: pytest.FixtureRequest) -> MockConfigEntry: """Mock config entry.""" return MockConfigEntry( domain=ambient_network.DOMAIN, diff --git a/tests/components/ambient_network/fixtures/device_details_response_b.json b/tests/components/ambient_network/fixtures/device_details_response_b.json index 8249f6f0c30..75fbfe0b31c 100644 --- a/tests/components/ambient_network/fixtures/device_details_response_b.json +++ b/tests/components/ambient_network/fixtures/device_details_response_b.json @@ -3,5 +3,8 @@ "macAddress": "BB:BB:BB:BB:BB:BB", "info": { "name": "Station B" + }, + "lastData": { + "tempf": 82.9 } } diff --git a/tests/components/ambient_network/fixtures/device_details_response_c.json b/tests/components/ambient_network/fixtures/device_details_response_c.json index 8e171f35374..cbd97e0a811 100644 --- a/tests/components/ambient_network/fixtures/device_details_response_c.json +++ b/tests/components/ambient_network/fixtures/device_details_response_c.json @@ -3,7 +3,7 @@ "macAddress": "CC:CC:CC:CC:CC:CC", "lastData": { "stationtype": "AMBWeatherPro_V5.0.6", - "dateutc": 1699474320000, + "dateutc": 1717687683000, "tempf": 82.9, "dewPoint": 82.0, "feelsLike": 85.0, diff --git a/tests/components/ambient_network/fixtures/device_details_response_d.json b/tests/components/ambient_network/fixtures/device_details_response_d.json new file mode 100644 index 00000000000..60b4918b8c2 --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_d.json @@ -0,0 +1,30 @@ +{ + "_id": "dddddddddddddddddddddddddddddddd", + "macAddress": "DD:DD:DD:DD:DD:DD", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "tempf": 82.9, + "dewPoint": 82.0, + "feelsLike": 85.0, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "tz": "America/Chicago" + }, + "info": { + "name": "Station D" + } +} diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index fadb15ad015..fd48184ca0b 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -46,6 +46,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'pressure', 'friendly_name': 'Station A Absolute pressure', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -104,6 +105,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'precipitation', 'friendly_name': 'Station A Daily rain', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -159,6 +161,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'temperature', 'friendly_name': 'Station A Dew point', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -214,6 +217,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'temperature', 'friendly_name': 'Station A Feels like', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -272,6 +276,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'precipitation_intensity', 'friendly_name': 'Station A Hourly rain', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -327,6 +332,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'humidity', 'friendly_name': 'Station A Humidity', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': '%', }), @@ -382,6 +388,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'irradiance', 'friendly_name': 'Station A Irradiance', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -432,6 +439,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'timestamp', 'friendly_name': 'Station A Last rain', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), }), 'context': , 'entity_id': 'sensor.station_a_last_rain', @@ -488,6 +496,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'wind_speed', 'friendly_name': 'Station A Max daily gust', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -546,6 +555,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'precipitation', 'friendly_name': 'Station A Monthly rain', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -604,6 +614,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'pressure', 'friendly_name': 'Station A Relative pressure', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -659,6 +670,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'temperature', 'friendly_name': 'Station A Temperature', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -713,6 +725,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', 'friendly_name': 'Station A UV index', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': 'index', }), @@ -771,6 +784,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'precipitation', 'friendly_name': 'Station A Weekly rain', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -823,6 +837,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', 'friendly_name': 'Station A Wind direction', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'unit_of_measurement': '°', }), 'context': , @@ -880,6 +895,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'wind_speed', 'friendly_name': 'Station A Wind gust', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -938,6 +954,7 @@ 'attribution': 'Data provided by ambientnetwork.net', 'device_class': 'wind_speed', 'friendly_name': 'Station A Wind speed', + 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'state_class': , 'unit_of_measurement': , }), @@ -949,3 +966,1872 @@ 'state': '14.03347968', }) # --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_absolute_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_absolute_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Absolute pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'absolute_pressure', + 'unique_id': 'CC:CC:CC:CC:CC:CC_baromabsin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_absolute_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'pressure', + 'friendly_name': 'Station C Absolute pressure', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_absolute_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '977.616536580043', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_daily_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_daily_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_rain', + 'unique_id': 'CC:CC:CC:CC:CC:CC_dailyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_daily_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station C Daily rain', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_daily_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point', + 'unique_id': 'CC:CC:CC:CC:CC:CC_dewPoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station C Dew point', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.7777777777778', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_feels_like-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feels like', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like', + 'unique_id': 'CC:CC:CC:CC:CC:CC_feelsLike', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station C Feels like', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4444444444444', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_hourly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_hourly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hourly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hourly_rain', + 'unique_id': 'CC:CC:CC:CC:CC:CC_hourlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_hourly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Station C Hourly rain', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_hourly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'CC:CC:CC:CC:CC:CC_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'humidity', + 'friendly_name': 'Station C Humidity', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.station_c_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_irradiance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_irradiance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irradiance', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'CC:CC:CC:CC:CC:CC_solarradiation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_irradiance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'irradiance', + 'friendly_name': 'Station C Irradiance', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_irradiance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.64', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_last_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_last_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_rain', + 'unique_id': 'CC:CC:CC:CC:CC:CC_lastRain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_last_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'timestamp', + 'friendly_name': 'Station C Last rain', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + }), + 'context': , + 'entity_id': 'sensor.station_c_last_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-30T09:45:00+00:00', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_max_daily_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_max_daily_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max daily gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_daily_gust', + 'unique_id': 'CC:CC:CC:CC:CC:CC_maxdailygust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_max_daily_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station C Max daily gust', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_max_daily_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.72523008', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_monthly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_monthly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monthly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_rain', + 'unique_id': 'CC:CC:CC:CC:CC:CC_monthlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_monthly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station C Monthly rain', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_monthly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_relative_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_relative_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relative pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_pressure', + 'unique_id': 'CC:CC:CC:CC:CC:CC_baromrelin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_relative_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'pressure', + 'friendly_name': 'Station C Relative pressure', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_relative_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1001.89694313129', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'CC:CC:CC:CC:CC:CC_tempf', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station C Temperature', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.2777777777778', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV index', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'CC:CC:CC:CC:CC:CC_uv', + 'unit_of_measurement': 'index', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'friendly_name': 'Station C UV index', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': 'index', + }), + 'context': , + 'entity_id': 'sensor.station_c_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_weekly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_weekly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weekly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weekly_rain', + 'unique_id': 'CC:CC:CC:CC:CC:CC_weeklyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_weekly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station C Weekly rain', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_weekly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': 'CC:CC:CC:CC:CC:CC_winddir', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'friendly_name': 'Station C Wind direction', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.station_c_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust', + 'unique_id': 'CC:CC:CC:CC:CC:CC_windgustmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station C Wind gust', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.75768448', + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_c_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'CC:CC:CC:CC:CC:CC_windspeedmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[CC:CC:CC:CC:CC:CC][sensor.station_c_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station C Wind speed', + 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_c_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.03347968', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_absolute_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_absolute_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Absolute pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'absolute_pressure', + 'unique_id': 'DD:DD:DD:DD:DD:DD_baromabsin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_absolute_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'pressure', + 'friendly_name': 'Station D Absolute pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_absolute_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '977.616536580043', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_daily_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_daily_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_rain', + 'unique_id': 'DD:DD:DD:DD:DD:DD_dailyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_daily_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station D Daily rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_daily_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point', + 'unique_id': 'DD:DD:DD:DD:DD:DD_dewPoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station D Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.7777777777778', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_feels_like-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feels like', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like', + 'unique_id': 'DD:DD:DD:DD:DD:DD_feelsLike', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station D Feels like', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4444444444444', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_hourly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_hourly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hourly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hourly_rain', + 'unique_id': 'DD:DD:DD:DD:DD:DD_hourlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_hourly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Station D Hourly rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_hourly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DD:DD:DD:DD:DD:DD_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'humidity', + 'friendly_name': 'Station D Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.station_d_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_irradiance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_irradiance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irradiance', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DD:DD:DD:DD:DD:DD_solarradiation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_irradiance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'irradiance', + 'friendly_name': 'Station D Irradiance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_irradiance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37.64', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_max_daily_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_max_daily_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max daily gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_daily_gust', + 'unique_id': 'DD:DD:DD:DD:DD:DD_maxdailygust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_max_daily_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station D Max daily gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_max_daily_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.72523008', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_monthly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_monthly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monthly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_rain', + 'unique_id': 'DD:DD:DD:DD:DD:DD_monthlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_monthly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station D Monthly rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_monthly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_relative_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_relative_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relative pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_pressure', + 'unique_id': 'DD:DD:DD:DD:DD:DD_baromrelin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_relative_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'pressure', + 'friendly_name': 'Station D Relative pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_relative_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1001.89694313129', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DD:DD:DD:DD:DD:DD_tempf', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station D Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.2777777777778', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV index', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'DD:DD:DD:DD:DD:DD_uv', + 'unit_of_measurement': 'index', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'friendly_name': 'Station D UV index', + 'state_class': , + 'unit_of_measurement': 'index', + }), + 'context': , + 'entity_id': 'sensor.station_d_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_weekly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_weekly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weekly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weekly_rain', + 'unique_id': 'DD:DD:DD:DD:DD:DD_weeklyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_weekly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station D Weekly rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_weekly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': 'DD:DD:DD:DD:DD:DD_winddir', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'friendly_name': 'Station D Wind direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.station_d_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust', + 'unique_id': 'DD:DD:DD:DD:DD:DD_windgustmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station D Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.75768448', + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_d_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DD:DD:DD:DD:DD:DD_windspeedmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[DD:DD:DD:DD:DD:DD][sensor.station_d_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station D Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_d_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.03347968', + }) +# --- diff --git a/tests/components/ambient_network/test_sensor.py b/tests/components/ambient_network/test_sensor.py index 35aa90ffe05..ab4facd0cb9 100644 --- a/tests/components/ambient_network/test_sensor.py +++ b/tests/components/ambient_network/test_sensor.py @@ -1,7 +1,7 @@ """Test Ambient Weather Network sensors.""" from datetime import datetime, timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aioambient import OpenAPI from aioambient.errors import RequestError @@ -9,6 +9,7 @@ from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -17,66 +18,48 @@ from .conftest import setup_platform from tests.common import async_fire_time_changed, snapshot_platform -@freeze_time("2023-11-08") -@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +@freeze_time("2023-11-9") +@pytest.mark.parametrize( + "config_entry", + ["AA:AA:AA:AA:AA:AA", "CC:CC:CC:CC:CC:CC", "DD:DD:DD:DD:DD:DD"], + indirect=True, +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, open_api: OpenAPI, - aioambient, - config_entry, + aioambient: AsyncMock, + config_entry: ConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test all sensors under normal operation.""" await setup_platform(True, hass, config_entry) - await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) -@freeze_time("2023-11-09") -@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) -async def test_sensors_with_stale_data( - hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry -) -> None: - """Test that the sensors are not populated if the data is stale.""" - await setup_platform(False, hass, config_entry) - - sensor = hass.states.get("sensor.station_a_absolute_pressure") - assert sensor is None - - -@freeze_time("2023-11-08") @pytest.mark.parametrize("config_entry", ["BB:BB:BB:BB:BB:BB"], indirect=True) async def test_sensors_with_no_data( - hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry + hass: HomeAssistant, + open_api: OpenAPI, + aioambient: AsyncMock, + config_entry: ConfigEntry, ) -> None: """Test that the sensors are not populated if the last data is absent.""" - await setup_platform(False, hass, config_entry) + await setup_platform(True, hass, config_entry) - sensor = hass.states.get("sensor.station_b_absolute_pressure") - assert sensor is None - - -@freeze_time("2023-11-08") -@pytest.mark.parametrize("config_entry", ["CC:CC:CC:CC:CC:CC"], indirect=True) -async def test_sensors_with_no_update_time( - hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry -) -> None: - """Test that the sensors are not populated if the update time is missing.""" - await setup_platform(False, hass, config_entry) - - sensor = hass.states.get("sensor.station_c_absolute_pressure") - assert sensor is None + sensor = hass.states.get("sensor.station_b_temperature") + assert sensor is not None + assert "last_measured" not in sensor.attributes @pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) async def test_sensors_disappearing( hass: HomeAssistant, open_api: OpenAPI, - aioambient, - config_entry, - caplog, + aioambient: AsyncMock, + config_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that we log errors properly.""" diff --git a/tests/components/ambient_station/conftest.py b/tests/components/ambient_station/conftest.py index adbd6777727..e4f067108a5 100644 --- a/tests/components/ambient_station/conftest.py +++ b/tests/components/ambient_station/conftest.py @@ -1,24 +1,31 @@ """Define test fixtures for Ambient PWS.""" -import json +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.ambient_station.const import CONF_APP_KEY, DOMAIN from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType, JsonObjectType -from tests.common import MockConfigEntry, load_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) @pytest.fixture(name="api") -def api_fixture(hass, data_devices): +def api_fixture(data_devices: JsonArrayType) -> Mock: """Define a mock API object.""" return Mock(get_devices=AsyncMock(return_value=data_devices)) @pytest.fixture(name="config") -def config_fixture(hass): +def config_fixture() -> dict[str, Any]: """Define a config entry data fixture.""" return { CONF_API_KEY: "12345abcde12345abcde", @@ -27,7 +34,9 @@ def config_fixture(hass): @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config): +def config_entry_fixture( + hass: HomeAssistant, config: dict[str, Any] +) -> MockConfigEntry: """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, @@ -39,19 +48,19 @@ def config_entry_fixture(hass, config): @pytest.fixture(name="data_devices", scope="package") -def data_devices_fixture(): +def data_devices_fixture() -> JsonArrayType: """Define devices data.""" - return json.loads(load_fixture("devices.json", "ambient_station")) + return load_json_array_fixture("devices.json", "ambient_station") @pytest.fixture(name="data_station", scope="package") -def data_station_fixture(): +def data_station_fixture() -> JsonObjectType: """Define station data.""" - return json.loads(load_fixture("station_data.json", "ambient_station")) + return load_json_object_fixture("station_data.json", "ambient_station") @pytest.fixture(name="mock_aioambient") -async def mock_aioambient_fixture(api): +def mock_aioambient_fixture(api: Mock) -> Generator[None]: """Define a fixture to patch aioambient.""" with ( patch( @@ -64,7 +73,9 @@ async def mock_aioambient_fixture(api): @pytest.fixture(name="setup_config_entry") -async def setup_config_entry_fixture(hass, config_entry, mock_aioambient): +async def setup_config_entry_fixture( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_aioambient: None +) -> None: """Define a fixture to set up ambient_station.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index da8d45d41ad..60882cda874 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -246,7 +246,7 @@ async def test_send_usage( assert analytics.preferences[ATTR_BASE] assert analytics.preferences[ATTR_USAGE] - hass.config.components = ["default_config"] + hass.config.components.add("default_config") with patch( "homeassistant.config.load_yaml_config_file", @@ -266,11 +266,11 @@ async def test_send_usage( assert snapshot == submitted_data +@pytest.mark.usefixtures("mock_hass_config") async def test_send_usage_with_supervisor( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, snapshot: SnapshotAssertion, ) -> None: """Test send usage with supervisor preferences are defined.""" @@ -280,7 +280,7 @@ async def test_send_usage_with_supervisor( await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) assert analytics.preferences[ATTR_BASE] assert analytics.preferences[ATTR_USAGE] - hass.config.components = ["default_config"] + hass.config.components.add("default_config") with ( patch( @@ -344,7 +344,7 @@ async def test_send_statistics( await analytics.save_preferences({ATTR_BASE: True, ATTR_STATISTICS: True}) assert analytics.preferences[ATTR_BASE] assert analytics.preferences[ATTR_STATISTICS] - hass.config.components = ["default_config"] + hass.config.components.add("default_config") with patch( "homeassistant.config.load_yaml_config_file", @@ -359,11 +359,9 @@ async def test_send_statistics( assert snapshot == submitted_data +@pytest.mark.usefixtures("mock_hass_config") async def test_send_statistics_one_integration_fails( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -384,11 +382,11 @@ async def test_send_statistics_one_integration_fails( assert post_call[2]["integration_count"] == 0 +@pytest.mark.usefixtures("mock_hass_config") async def test_send_statistics_disabled_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, installation_type_mock: Generator[Any, Any, None], snapshot: SnapshotAssertion, ) -> None: @@ -422,11 +420,11 @@ async def test_send_statistics_disabled_integration( assert snapshot == submitted_data +@pytest.mark.usefixtures("mock_hass_config") async def test_send_statistics_ignored_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, installation_type_mock: Generator[Any, Any, None], snapshot: SnapshotAssertion, ) -> None: @@ -466,11 +464,9 @@ async def test_send_statistics_ignored_integration( assert snapshot == submitted_data +@pytest.mark.usefixtures("mock_hass_config") async def test_send_statistics_async_get_integration_unknown_exception( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -490,11 +486,11 @@ async def test_send_statistics_async_get_integration_unknown_exception( await analytics.send_analytics() +@pytest.mark.usefixtures("mock_hass_config") async def test_send_statistics_with_supervisor( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, snapshot: SnapshotAssertion, ) -> None: """Test send statistics preferences are defined.""" @@ -570,10 +566,10 @@ async def test_reusing_uuid( assert analytics.uuid == "NOT_MOCK_UUID" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_custom_integrations( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - enable_custom_integrations: None, caplog: pytest.LogCaptureFixture, installation_type_mock: Generator[Any, Any, None], snapshot: SnapshotAssertion, @@ -655,10 +651,10 @@ async def test_nightly_endpoint( assert str(payload[1]) == ANALYTICS_ENDPOINT_URL +@pytest.mark.usefixtures("mock_hass_config") async def test_send_with_no_energy( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, caplog: pytest.LogCaptureFixture, installation_type_mock: Generator[Any, Any, None], snapshot: SnapshotAssertion, @@ -692,11 +688,10 @@ async def test_send_with_no_energy( assert snapshot == submitted_data +@pytest.mark.usefixtures("recorder_mock", "mock_hass_config") async def test_send_with_no_energy_config( - recorder_mock: Recorder, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, caplog: pytest.LogCaptureFixture, installation_type_mock: Generator[Any, Any, None], snapshot: SnapshotAssertion, @@ -725,11 +720,10 @@ async def test_send_with_no_energy_config( ) +@pytest.mark.usefixtures("recorder_mock", "mock_hass_config") async def test_send_with_energy_config( - recorder_mock: Recorder, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, caplog: pytest.LogCaptureFixture, installation_type_mock: Generator[Any, Any, None], snapshot: SnapshotAssertion, @@ -758,11 +752,11 @@ async def test_send_with_energy_config( ) +@pytest.mark.usefixtures("mock_hass_config") async def test_send_usage_with_certificate( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, installation_type_mock: Generator[Any, Any, None], snapshot: SnapshotAssertion, ) -> None: @@ -836,11 +830,11 @@ async def test_send_with_problems_loading_yaml( assert len(aioclient_mock.mock_calls) == 0 +@pytest.mark.usefixtures("mock_hass_config") async def test_timeout_while_sending( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, - mock_hass_config: None, ) -> None: """Test timeout error while sending analytics.""" analytics = Analytics(hass) diff --git a/tests/components/analytics_insights/conftest.py b/tests/components/analytics_insights/conftest.py index 51d25f0a2cc..75d47c41f4e 100644 --- a/tests/components/analytics_insights/conftest.py +++ b/tests/components/analytics_insights/conftest.py @@ -1,11 +1,11 @@ """Common fixtures for the Homeassistant Analytics tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from python_homeassistant_analytics import CurrentAnalytics from python_homeassistant_analytics.models import CustomIntegration, Integration +from typing_extensions import Generator from homeassistant.components.analytics_insights.const import ( CONF_TRACKED_CUSTOM_INTEGRATIONS, @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.analytics_insights.async_setup_entry", @@ -27,7 +27,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_analytics_client() -> Generator[AsyncMock, None, None]: +def mock_analytics_client() -> Generator[AsyncMock]: """Mock a Homeassistant Analytics client.""" with ( patch( diff --git a/tests/components/analytics_insights/test_config_flow.py b/tests/components/analytics_insights/test_config_flow.py index 77264eb2439..0c9d4c074f8 100644 --- a/tests/components/analytics_insights/test_config_flow.py +++ b/tests/components/analytics_insights/test_config_flow.py @@ -6,17 +6,18 @@ from unittest.mock import AsyncMock import pytest from python_homeassistant_analytics import HomeassistantAnalyticsConnectionError -from homeassistant import config_entries from homeassistant.components.analytics_insights.const import ( CONF_TRACKED_CUSTOM_INTEGRATIONS, CONF_TRACKED_INTEGRATIONS, DOMAIN, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry -from tests.components.analytics_insights import setup_integration @pytest.mark.parametrize( @@ -61,7 +62,7 @@ async def test_form( ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -96,7 +97,7 @@ async def test_submitting_empty_form( ) -> None: """Test we can't submit an empty form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -128,20 +129,28 @@ async def test_submitting_empty_form( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + ("exception", "reason"), + [ + (HomeassistantAnalyticsConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) async def test_form_cannot_connect( - hass: HomeAssistant, mock_analytics_client: AsyncMock + hass: HomeAssistant, + mock_analytics_client: AsyncMock, + exception: Exception, + reason: str, ) -> None: """Test we handle cannot connect error.""" - mock_analytics_client.get_integrations.side_effect = ( - HomeassistantAnalyticsConnectionError - ) + mock_analytics_client.get_integrations.side_effect = exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == reason async def test_form_already_configured( @@ -159,7 +168,7 @@ async def test_form_already_configured( entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/analytics_insights/test_init.py b/tests/components/analytics_insights/test_init.py index 8543a02c025..b75266b45ca 100644 --- a/tests/components/analytics_insights/test_init.py +++ b/tests/components/analytics_insights/test_init.py @@ -8,8 +8,9 @@ from homeassistant.components.analytics_insights.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry -from tests.components.analytics_insights import setup_integration async def test_load_unload_entry( diff --git a/tests/components/android_ip_webcam/conftest.py b/tests/components/android_ip_webcam/conftest.py index 17fc3e451a3..eea8e00a1a8 100644 --- a/tests/components/android_ip_webcam/conftest.py +++ b/tests/components/android_ip_webcam/conftest.py @@ -7,10 +7,11 @@ import pytest from homeassistant.const import CONTENT_TYPE_JSON from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture -def aioclient_mock_fixture(aioclient_mock) -> None: +def aioclient_mock_fixture(aioclient_mock: AiohttpClientMocker) -> None: """Fixture to provide a aioclient mocker.""" aioclient_mock.get( "http://1.1.1.1:8080/status.json?show_avail=1", diff --git a/tests/components/androidtv/common.py b/tests/components/androidtv/common.py new file mode 100644 index 00000000000..23e048e4d52 --- /dev/null +++ b/tests/components/androidtv/common.py @@ -0,0 +1,114 @@ +"""Test code shared between test files.""" + +from typing import Any + +from homeassistant.components.androidtv.const import ( + CONF_ADB_SERVER_IP, + CONF_ADB_SERVER_PORT, + CONF_ADBKEY, + DEFAULT_ADB_SERVER_PORT, + DEFAULT_PORT, + DEVICE_ANDROIDTV, + DEVICE_FIRETV, + DOMAIN, +) +from homeassistant.components.androidtv.entity import PREFIX_ANDROIDTV, PREFIX_FIRETV +from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.util import slugify + +from . import patchers + +from tests.common import MockConfigEntry + +ADB_PATCH_KEY = "patch_key" +TEST_ENTITY_NAME = "entity_name" +TEST_HOST_NAME = "127.0.0.1" + +SHELL_RESPONSE_OFF = "" +SHELL_RESPONSE_STANDBY = "1" + +# Android device with Python ADB implementation +CONFIG_ANDROID_PYTHON_ADB = { + ADB_PATCH_KEY: patchers.KEY_PYTHON, + TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {TEST_HOST_NAME}", + DOMAIN: { + CONF_HOST: TEST_HOST_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, + }, +} + +# Android device with Python ADB implementation imported from YAML +CONFIG_ANDROID_PYTHON_ADB_YAML = { + ADB_PATCH_KEY: patchers.KEY_PYTHON, + TEST_ENTITY_NAME: "ADB yaml import", + DOMAIN: { + CONF_NAME: "ADB yaml import", + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], + }, +} + +# Android device with Python ADB implementation with custom adbkey +CONFIG_ANDROID_PYTHON_ADB_KEY = { + ADB_PATCH_KEY: patchers.KEY_PYTHON, + TEST_ENTITY_NAME: CONFIG_ANDROID_PYTHON_ADB[TEST_ENTITY_NAME], + DOMAIN: { + **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], + CONF_ADBKEY: "user_provided_adbkey", + }, +} + +# Android device with ADB server +CONFIG_ANDROID_ADB_SERVER = { + ADB_PATCH_KEY: patchers.KEY_SERVER, + TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {TEST_HOST_NAME}", + DOMAIN: { + CONF_HOST: TEST_HOST_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, + CONF_ADB_SERVER_IP: patchers.ADB_SERVER_HOST, + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, + }, +} + +# Fire TV device with Python ADB implementation +CONFIG_FIRETV_PYTHON_ADB = { + ADB_PATCH_KEY: patchers.KEY_PYTHON, + TEST_ENTITY_NAME: f"{PREFIX_FIRETV} {TEST_HOST_NAME}", + DOMAIN: { + CONF_HOST: TEST_HOST_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE_CLASS: DEVICE_FIRETV, + }, +} + +# Fire TV device with ADB server +CONFIG_FIRETV_ADB_SERVER = { + ADB_PATCH_KEY: patchers.KEY_SERVER, + TEST_ENTITY_NAME: f"{PREFIX_FIRETV} {TEST_HOST_NAME}", + DOMAIN: { + CONF_HOST: TEST_HOST_NAME, + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE_CLASS: DEVICE_FIRETV, + CONF_ADB_SERVER_IP: patchers.ADB_SERVER_HOST, + CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, + }, +} + +CONFIG_ANDROID_DEFAULT = CONFIG_ANDROID_PYTHON_ADB +CONFIG_FIRETV_DEFAULT = CONFIG_FIRETV_PYTHON_ADB + + +def setup_mock_entry( + config: dict[str, Any], entity_domain: str +) -> tuple[str, str, MockConfigEntry]: + """Prepare mock entry for entities tests.""" + patch_key = config[ADB_PATCH_KEY] + entity_id = f"{entity_domain}.{slugify(config[TEST_ENTITY_NAME])}" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config[DOMAIN], + unique_id="a1:b1:c1:d1:e1:f1", + ) + + return patch_key, entity_id, config_entry diff --git a/tests/components/androidtv/conftest.py b/tests/components/androidtv/conftest.py new file mode 100644 index 00000000000..befb9db7a8c --- /dev/null +++ b/tests/components/androidtv/conftest.py @@ -0,0 +1,38 @@ +"""Fixtures for the Android TV integration tests.""" + +from unittest.mock import Mock, patch + +import pytest +from typing_extensions import Generator + +from . import patchers + + +@pytest.fixture(autouse=True) +def adb_device_tcp_fixture() -> Generator[None]: + """Patch ADB Device TCP.""" + with patch( + "androidtv.adb_manager.adb_manager_async.AdbDeviceTcpAsync", + patchers.AdbDeviceTcpAsyncFake, + ): + yield + + +@pytest.fixture(autouse=True) +def load_adbkey_fixture() -> Generator[None]: + """Patch load_adbkey.""" + with patch( + "homeassistant.components.androidtv.ADBPythonSync.load_adbkey", + return_value="signer for testing", + ): + yield + + +@pytest.fixture(autouse=True) +def keygen_fixture() -> Generator[None]: + """Patch keygen.""" + with patch( + "homeassistant.components.androidtv.keygen", + return_value=Mock(), + ): + yield diff --git a/tests/components/androidtv/test_diagnostics.py b/tests/components/androidtv/test_diagnostics.py new file mode 100644 index 00000000000..7d1801514af --- /dev/null +++ b/tests/components/androidtv/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Tests for the diagnostics data provided by the AndroidTV integration.""" + +from homeassistant.components.asuswrt.diagnostics import TO_REDACT +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import patchers +from .common import CONFIG_ANDROID_DEFAULT, SHELL_RESPONSE_OFF, setup_mock_entry + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test diagnostics.""" + patch_key, _, mock_config_entry = setup_mock_entry( + CONFIG_ANDROID_DEFAULT, MP_DOMAIN + ) + mock_config_entry.add_to_hass(hass) + + with ( + patchers.patch_connect(True)[patch_key], + patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key], + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + entry_dict = async_redact_data(mock_config_entry.as_dict(), TO_REDACT) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result["entry"] == entry_dict diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index fe6b9962d14..ef0d0c63b06 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch from adb_shell.exceptions import TcpTimeoutException as AdbShellTimeoutException from androidtv.constants import APPS as ANDROIDTV_APPS, KEYS @@ -11,9 +11,6 @@ from androidtv.exceptions import LockNotAcquiredException import pytest from homeassistant.components.androidtv.const import ( - CONF_ADB_SERVER_IP, - CONF_ADB_SERVER_PORT, - CONF_ADBKEY, CONF_APPS, CONF_EXCLUDE_UNNAMED_APPS, CONF_SCREENCAP, @@ -22,11 +19,8 @@ from homeassistant.components.androidtv.const import ( CONF_TURN_ON_COMMAND, DEFAULT_ADB_SERVER_PORT, DEFAULT_PORT, - DEVICE_ANDROIDTV, - DEVICE_FIRETV, DOMAIN, ) -from homeassistant.components.androidtv.entity import PREFIX_ANDROIDTV, PREFIX_FIRETV from homeassistant.components.androidtv.media_player import ( ATTR_DEVICE_PATH, ATTR_LOCAL_PATH, @@ -57,9 +51,6 @@ from homeassistant.const import ( ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, - CONF_HOST, - CONF_NAME, - CONF_PORT, EVENT_HOMEASSISTANT_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -74,142 +65,40 @@ from homeassistant.util import slugify from homeassistant.util.dt import utcnow from . import patchers +from .common import ( + CONFIG_ANDROID_ADB_SERVER, + CONFIG_ANDROID_DEFAULT, + CONFIG_ANDROID_PYTHON_ADB, + CONFIG_ANDROID_PYTHON_ADB_KEY, + CONFIG_ANDROID_PYTHON_ADB_YAML, + CONFIG_FIRETV_ADB_SERVER, + CONFIG_FIRETV_DEFAULT, + CONFIG_FIRETV_PYTHON_ADB, + SHELL_RESPONSE_OFF, + SHELL_RESPONSE_STANDBY, + TEST_ENTITY_NAME, + TEST_HOST_NAME, + setup_mock_entry, +) from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator -HOST = "127.0.0.1" - -ADB_PATCH_KEY = "patch_key" -TEST_ENTITY_NAME = "entity_name" - MSG_RECONNECT = { patchers.KEY_PYTHON: ( - f"ADB connection to {HOST}:{DEFAULT_PORT} successfully established" + f"ADB connection to {TEST_HOST_NAME}:{DEFAULT_PORT} successfully established" ), patchers.KEY_SERVER: ( - f"ADB connection to {HOST}:{DEFAULT_PORT} via ADB server" + f"ADB connection to {TEST_HOST_NAME}:{DEFAULT_PORT} via ADB server" f" {patchers.ADB_SERVER_HOST}:{DEFAULT_ADB_SERVER_PORT} successfully" " established" ), } -SHELL_RESPONSE_OFF = "" -SHELL_RESPONSE_STANDBY = "1" -# Android device with Python ADB implementation -CONFIG_ANDROID_PYTHON_ADB = { - ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", - DOMAIN: { - CONF_HOST: HOST, - CONF_PORT: DEFAULT_PORT, - CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, - }, -} - -# Android device with Python ADB implementation imported from YAML -CONFIG_ANDROID_PYTHON_ADB_YAML = { - ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: "ADB yaml import", - DOMAIN: { - CONF_NAME: "ADB yaml import", - **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], - }, -} - -# Android device with Python ADB implementation with custom adbkey -CONFIG_ANDROID_PYTHON_ADB_KEY = { - ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: CONFIG_ANDROID_PYTHON_ADB[TEST_ENTITY_NAME], - DOMAIN: { - **CONFIG_ANDROID_PYTHON_ADB[DOMAIN], - CONF_ADBKEY: "user_provided_adbkey", - }, -} - -# Android device with ADB server -CONFIG_ANDROID_ADB_SERVER = { - ADB_PATCH_KEY: patchers.KEY_SERVER, - TEST_ENTITY_NAME: f"{PREFIX_ANDROIDTV} {HOST}", - DOMAIN: { - CONF_HOST: HOST, - CONF_PORT: DEFAULT_PORT, - CONF_DEVICE_CLASS: DEVICE_ANDROIDTV, - CONF_ADB_SERVER_IP: patchers.ADB_SERVER_HOST, - CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, - }, -} - -# Fire TV device with Python ADB implementation -CONFIG_FIRETV_PYTHON_ADB = { - ADB_PATCH_KEY: patchers.KEY_PYTHON, - TEST_ENTITY_NAME: f"{PREFIX_FIRETV} {HOST}", - DOMAIN: { - CONF_HOST: HOST, - CONF_PORT: DEFAULT_PORT, - CONF_DEVICE_CLASS: DEVICE_FIRETV, - }, -} - -# Fire TV device with ADB server -CONFIG_FIRETV_ADB_SERVER = { - ADB_PATCH_KEY: patchers.KEY_SERVER, - TEST_ENTITY_NAME: f"{PREFIX_FIRETV} {HOST}", - DOMAIN: { - CONF_HOST: HOST, - CONF_PORT: DEFAULT_PORT, - CONF_DEVICE_CLASS: DEVICE_FIRETV, - CONF_ADB_SERVER_IP: patchers.ADB_SERVER_HOST, - CONF_ADB_SERVER_PORT: DEFAULT_ADB_SERVER_PORT, - }, -} - -CONFIG_ANDROID_DEFAULT = CONFIG_ANDROID_PYTHON_ADB -CONFIG_FIRETV_DEFAULT = CONFIG_FIRETV_PYTHON_ADB - - -@pytest.fixture(autouse=True) -def adb_device_tcp_fixture() -> None: - """Patch ADB Device TCP.""" - with patch( - "androidtv.adb_manager.adb_manager_async.AdbDeviceTcpAsync", - patchers.AdbDeviceTcpAsyncFake, - ): - yield - - -@pytest.fixture(autouse=True) -def load_adbkey_fixture() -> None: - """Patch load_adbkey.""" - with patch( - "homeassistant.components.androidtv.ADBPythonSync.load_adbkey", - return_value="signer for testing", - ): - yield - - -@pytest.fixture(autouse=True) -def keygen_fixture() -> None: - """Patch keygen.""" - with patch( - "homeassistant.components.androidtv.keygen", - return_value=Mock(), - ): - yield - - -def _setup(config) -> tuple[str, str, MockConfigEntry]: - """Perform common setup tasks for the tests.""" - patch_key = config[ADB_PATCH_KEY] - entity_id = f"{MP_DOMAIN}.{slugify(config[TEST_ENTITY_NAME])}" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=config[DOMAIN], - unique_id="a1:b1:c1:d1:e1:f1", - ) - - return patch_key, entity_id, config_entry +def _setup(config: dict[str, Any]) -> tuple[str, str, MockConfigEntry]: + """Prepare mock entry for the media player tests.""" + return setup_mock_entry(config, MP_DOMAIN) @pytest.mark.parametrize( @@ -1181,7 +1070,7 @@ async def test_connection_closed_on_ha_stop(hass: HomeAssistant) -> None: assert adb_close.called -async def test_exception(hass: HomeAssistant) -> None: +async def test_exception(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test that the ADB connection gets closed when there is an unforeseen exception. HA will attempt to reconnect on the next update. @@ -1201,12 +1090,21 @@ async def test_exception(hass: HomeAssistant) -> None: assert state is not None assert state.state == STATE_OFF + caplog.clear() + caplog.set_level(logging.ERROR) + # When an unforeseen exception occurs, we close the ADB connection and raise the exception - with patchers.PATCH_ANDROIDTV_UPDATE_EXCEPTION, pytest.raises(Exception): + with patchers.PATCH_ANDROIDTV_UPDATE_EXCEPTION: await async_update_entity(hass, entity_id) - state = hass.states.get(entity_id) - assert state is not None - assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert len(caplog.record_tuples) == 1 + assert caplog.record_tuples[0][1] == logging.ERROR + assert caplog.record_tuples[0][2].startswith( + "Unexpected exception executing an ADB command" + ) # On the next update, HA will reconnect to the device await async_update_entity(hass, entity_id) diff --git a/tests/components/androidtv/test_remote.py b/tests/components/androidtv/test_remote.py new file mode 100644 index 00000000000..d18e08d4df8 --- /dev/null +++ b/tests/components/androidtv/test_remote.py @@ -0,0 +1,164 @@ +"""The tests for the androidtv remote platform.""" + +from typing import Any +from unittest.mock import call, patch + +from androidtv.constants import KEYS +import pytest + +from homeassistant.components.androidtv.const import ( + CONF_TURN_OFF_COMMAND, + CONF_TURN_ON_COMMAND, +) +from homeassistant.components.remote import ( + ATTR_NUM_REPEATS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ( + ATTR_COMMAND, + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import patchers +from .common import ( + CONFIG_ANDROID_DEFAULT, + CONFIG_FIRETV_DEFAULT, + SHELL_RESPONSE_OFF, + SHELL_RESPONSE_STANDBY, + setup_mock_entry, +) + +from tests.common import MockConfigEntry + + +def _setup(config: dict[str, Any]) -> tuple[str, str, MockConfigEntry]: + """Prepare mock entry for the media player tests.""" + return setup_mock_entry(config, REMOTE_DOMAIN) + + +async def _test_service( + hass: HomeAssistant, + entity_id, + ha_service_name, + androidtv_method, + additional_service_data=None, + expected_call_args=None, +) -> None: + """Test generic Android media player entity service.""" + if expected_call_args is None: + expected_call_args = [None] + + service_data = {ATTR_ENTITY_ID: entity_id} + if additional_service_data: + service_data.update(additional_service_data) + + androidtv_patch = ( + "androidtv.androidtv_async.AndroidTVAsync" + if "android" in entity_id + else "firetv.firetv_async.FireTVAsync" + ) + with patch(f"androidtv.{androidtv_patch}.{androidtv_method}") as api_call: + await hass.services.async_call( + REMOTE_DOMAIN, + ha_service_name, + service_data=service_data, + blocking=True, + ) + assert api_call.called + assert api_call.call_count == len(expected_call_args) + expected_calls = [call(s) if s else call() for s in expected_call_args] + assert api_call.call_args_list == expected_calls + + +@pytest.mark.parametrize("config", [CONFIG_ANDROID_DEFAULT, CONFIG_FIRETV_DEFAULT]) +async def test_services_remote(hass: HomeAssistant, config) -> None: + """Test services for remote entity.""" + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) + + with patchers.patch_connect(True)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with ( + patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key], + patchers.PATCH_SCREENCAP, + ): + await _test_service(hass, entity_id, SERVICE_TURN_OFF, "turn_off") + await _test_service(hass, entity_id, SERVICE_TURN_ON, "turn_on") + await _test_service( + hass, + entity_id, + SERVICE_SEND_COMMAND, + "adb_shell", + {ATTR_COMMAND: ["BACK", "test"], ATTR_NUM_REPEATS: 2}, + [ + f"input keyevent {KEYS["BACK"]}", + "test", + f"input keyevent {KEYS["BACK"]}", + "test", + ], + ) + + +@pytest.mark.parametrize("config", [CONFIG_ANDROID_DEFAULT, CONFIG_FIRETV_DEFAULT]) +async def test_services_remote_custom(hass: HomeAssistant, config) -> None: + """Test services with custom options for remote entity.""" + patch_key, entity_id, config_entry = _setup(config) + config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + config_entry, + options={ + CONF_TURN_OFF_COMMAND: "test off", + CONF_TURN_ON_COMMAND: "test on", + }, + ) + + with patchers.patch_connect(True)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with ( + patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key], + patchers.PATCH_SCREENCAP, + ): + await _test_service( + hass, entity_id, SERVICE_TURN_OFF, "adb_shell", None, ["test off"] + ) + await _test_service( + hass, entity_id, SERVICE_TURN_ON, "adb_shell", None, ["test on"] + ) + + +async def test_remote_unicode_decode_error(hass: HomeAssistant) -> None: + """Test sending a command via the send_command remote service that raises a UnicodeDecodeError exception.""" + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) + config_entry.add_to_hass(hass) + response = b"test response" + + with patchers.patch_connect(True)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_shell", + side_effect=UnicodeDecodeError("utf-8", response, 0, len(response), "TEST"), + ) as api_call: + try: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + service_data={ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: "BACK"}, + blocking=True, + ) + pytest.fail("Exception not raised") + except ServiceValidationError: + assert api_call.call_count == 1 diff --git a/tests/components/androidtv_remote/conftest.py b/tests/components/androidtv_remote/conftest.py index 3b69da6d742..aa5583927d1 100644 --- a/tests/components/androidtv_remote/conftest.py +++ b/tests/components/androidtv_remote/conftest.py @@ -1,9 +1,10 @@ """Fixtures for the Android TV Remote integration tests.""" -from collections.abc import Callable, Generator +from collections.abc import Callable from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.androidtv_remote.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -12,7 +13,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.androidtv_remote.async_setup_entry", @@ -22,7 +23,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_unload_entry() -> Generator[AsyncMock, None, None]: +def mock_unload_entry() -> Generator[AsyncMock]: """Mock unloading a config entry.""" with patch( "homeassistant.components.androidtv_remote.async_unload_entry", @@ -32,7 +33,7 @@ def mock_unload_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_api() -> Generator[None, MagicMock, None]: +def mock_api() -> Generator[MagicMock]: """Return a mocked AndroidTVRemote.""" with patch( "homeassistant.components.androidtv_remote.helpers.AndroidTVRemote", diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 062b9a4a55c..93c9067d1c8 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -7,7 +7,18 @@ from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.components.androidtv_remote.const import DOMAIN +from homeassistant.components.androidtv_remote.config_flow import ( + APPS_NEW_ID, + CONF_APP_DELETE, + CONF_APP_ID, +) +from homeassistant.components.androidtv_remote.const import ( + CONF_APP_ICON, + CONF_APP_NAME, + CONF_APPS, + CONF_ENABLE_IME, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -886,14 +897,14 @@ async def test_options_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" data_schema = result["data_schema"].schema - assert set(data_schema) == {"enable_ime"} + assert set(data_schema) == {CONF_APPS, CONF_ENABLE_IME} result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"enable_ime": False}, + user_input={CONF_ENABLE_IME: False}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert mock_config_entry.options == {"enable_ime": False} + assert mock_config_entry.options == {CONF_ENABLE_IME: False} await hass.async_block_till_done() assert mock_api.disconnect.call_count == 1 @@ -903,10 +914,10 @@ async def test_options_flow( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"enable_ime": False}, + user_input={CONF_ENABLE_IME: False}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert mock_config_entry.options == {"enable_ime": False} + assert mock_config_entry.options == {CONF_ENABLE_IME: False} await hass.async_block_till_done() assert mock_api.disconnect.call_count == 1 @@ -916,11 +927,92 @@ async def test_options_flow( result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"enable_ime": True}, + user_input={CONF_ENABLE_IME: True}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert mock_config_entry.options == {"enable_ime": True} + assert mock_config_entry.options == {CONF_ENABLE_IME: True} await hass.async_block_till_done() assert mock_api.disconnect.call_count == 2 assert mock_api.async_connect.call_count == 3 + + # test app form with new app + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APPS: APPS_NEW_ID, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "apps" + + # test save value for new app + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APP_ID: "app1", + CONF_APP_NAME: "App1", + CONF_APP_ICON: "Icon1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # test app form with existing app + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APPS: "app1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "apps" + + # test change value in apps form + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APP_NAME: "Application1", + CONF_APP_ICON: "Icon1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.options == { + CONF_APPS: {"app1": {CONF_APP_NAME: "Application1", CONF_APP_ICON: "Icon1"}}, + CONF_ENABLE_IME: True, + } + await hass.async_block_till_done() + + # test app form for delete + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APPS: "app1", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "apps" + + # test delete app1 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_APP_DELETE: True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.options == {CONF_ENABLE_IME: True} diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index c7937e9e02d..ad7c049e32f 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator MEDIA_PLAYER_ENTITY = "media_player.my_android_tv" @@ -19,6 +20,9 @@ async def test_media_player_receives_push_updates( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote media player receives push updates and state is updated.""" + mock_config_entry.options = { + "apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}} + } mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -39,6 +43,13 @@ async def test_media_player_receives_push_updates( == "com.google.android.tvlauncher" ) + mock_api._on_current_app_updated("com.google.android.youtube.tv") + assert ( + hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_id") + == "com.google.android.youtube.tv" + ) + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_name") == "YouTube" + mock_api._on_volume_info_updated({"level": 35, "muted": False, "max": 100}) assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") == 0.35 @@ -267,6 +278,18 @@ async def test_media_player_play_media( ) mock_api.send_launch_app_command.assert_called_with("https://www.youtube.com") + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "app", + "media_content_id": "tv.twitch.android.app", + }, + blocking=True, + ) + mock_api.send_launch_app_command.assert_called_with("tv.twitch.android.app") + with pytest.raises(ValueError): await hass.services.async_call( "media_player", @@ -292,6 +315,71 @@ async def test_media_player_play_media( ) +async def test_browse_media( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Test the Android TV Remote media player browse media.""" + mock_config_entry.options = { + "apps": { + "com.google.android.youtube.tv": { + "app_name": "YouTube", + "app_icon": "https://www.youtube.com/icon.png", + }, + "org.xbmc.kodi": {"app_name": "Kodi"}, + } + } + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": MEDIA_PLAYER_ENTITY, + } + ) + response = await client.receive_json() + assert response["success"] + assert { + "title": "Applications", + "media_class": "directory", + "media_content_type": "apps", + "media_content_id": "apps", + "children_media_class": "app", + "can_play": False, + "can_expand": True, + "thumbnail": None, + "not_shown": 0, + "children": [ + { + "title": "YouTube", + "media_class": "app", + "media_content_type": "app", + "media_content_id": "com.google.android.youtube.tv", + "children_media_class": None, + "can_play": False, + "can_expand": False, + "thumbnail": "https://www.youtube.com/icon.png", + }, + { + "title": "Kodi", + "media_class": "app", + "media_content_type": "app", + "media_content_id": "org.xbmc.kodi", + "children_media_class": None, + "can_play": False, + "can_expand": False, + "thumbnail": "", + }, + ], + } == response["result"] + + async def test_media_player_connection_closed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index eba955a6aba..7ca63685747 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -19,6 +19,9 @@ async def test_remote_receives_push_updates( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote receives push updates and state is updated.""" + mock_config_entry.options = { + "apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}} + } mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -34,6 +37,11 @@ async def test_remote_receives_push_updates( hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1" ) + mock_api._on_current_app_updated("com.google.android.youtube.tv") + assert ( + hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "YouTube" + ) + mock_api._on_is_available_updated(False) assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE) @@ -45,6 +53,9 @@ async def test_remote_toggles( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote toggles.""" + mock_config_entry.options = { + "apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}} + } mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -81,6 +92,17 @@ async def test_remote_toggles( assert mock_api.send_key_command.call_count == 2 assert mock_api.send_launch_app_command.call_count == 1 + await hass.services.async_call( + "remote", + "turn_on", + {"entity_id": REMOTE_ENTITY, "activity": "YouTube"}, + blocking=True, + ) + + mock_api.send_key_command.send_launch_app_command("com.google.android.youtube.tv") + assert mock_api.send_key_command.call_count == 2 + assert mock_api.send_launch_app_command.call_count == 2 + async def test_remote_send_command( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py index 03cfb7589d0..887f5b3b05b 100644 --- a/tests/components/anova/__init__.py +++ b/tests/components/anova/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from anova_wifi import AnovaPrecisionCooker, APCUpdate, APCUpdateBinary, APCUpdateSensor +from anova_wifi import APCUpdate, APCUpdateBinary, APCUpdateSensor from homeassistant.components.anova.const import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -21,7 +21,7 @@ ONLINE_UPDATE = APCUpdate( sensor=APCUpdateSensor( 0, "Low water", "No state", 23.33, 0, "2.2.0", 20.87, 21.79, 21.33 ), - binary_sensor=APCUpdateBinary(False, False, False, True, False, True, False), + binary_sensor=APCUpdateBinary(False, False, False, True, False, True, False, False), ) @@ -33,9 +33,9 @@ def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> Conf data={ CONF_USERNAME: "sample@gmail.com", CONF_PASSWORD: "sample", - "devices": [(device_id, "type_sample")], }, unique_id="sample@gmail.com", + version=1, ) entry.add_to_hass(hass) return entry @@ -44,23 +44,10 @@ def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> Conf async def async_init_integration( hass: HomeAssistant, skip_setup: bool = False, - error: str | None = None, ) -> ConfigEntry: """Set up the Anova integration in Home Assistant.""" - with ( - patch( - "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" - ) as update_patch, - patch("homeassistant.components.anova.AnovaApi.authenticate"), - patch( - "homeassistant.components.anova.AnovaApi.get_devices", - ) as device_patch, - ): - update_patch.return_value = ONLINE_UPDATE - device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] + with patch("homeassistant.components.anova.AnovaApi.authenticate"): entry = create_entry(hass) if not skip_setup: diff --git a/tests/components/anova/conftest.py b/tests/components/anova/conftest.py index 3e904bb1415..e652893d474 100644 --- a/tests/components/anova/conftest.py +++ b/tests/components/anova/conftest.py @@ -1,13 +1,180 @@ """Common fixtures for Anova.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import json +from typing import Any from unittest.mock import AsyncMock, patch -from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +from aiohttp import ClientSession +from anova_wifi import ( + AnovaApi, + AnovaWebsocketHandler, + InvalidLogin, + NoDevicesFound, + WebsocketFailure, +) import pytest from homeassistant.core import HomeAssistant -from . import DEVICE_UNIQUE_ID +DUMMY_ID = "anova_id" + + +@dataclass +class MockedanovaWebsocketMessage: + """Mock the websocket message for Anova.""" + + input_data: dict[str, Any] + data: str = "" + + def __post_init__(self) -> None: + """Set up data after creation.""" + self.data = json.dumps(self.input_data) + + +class MockedAnovaWebsocketStream: + """Mock the websocket stream for Anova.""" + + def __init__(self, messages: list[MockedanovaWebsocketMessage]) -> None: + """Initialize a Anova Websocket Stream that can be manipulated for tests.""" + self.messages = messages + + def __aiter__(self) -> MockedAnovaWebsocketStream: + """Handle async iteration.""" + return self + + async def __anext__(self) -> MockedanovaWebsocketMessage: + """Get the next message in the websocket stream.""" + if self.messages: + return self.messages.pop(0) + raise StopAsyncIteration + + def clear(self) -> None: + """Clear the Websocket stream.""" + self.messages.clear() + + +class MockedAnovaWebsocketHandler(AnovaWebsocketHandler): + """Mock the Anova websocket handler.""" + + def __init__( + self, + firebase_jwt: str, + jwt: str, + session: ClientSession, + connect_messages: list[MockedanovaWebsocketMessage], + post_connect_messages: list[MockedanovaWebsocketMessage], + ) -> None: + """Initialize the websocket handler with whatever messages you want.""" + super().__init__(firebase_jwt, jwt, session) + self.connect_messages = connect_messages + self.post_connect_messages = post_connect_messages + + async def connect(self) -> None: + """Create a future for the message listener.""" + self.ws = MockedAnovaWebsocketStream(self.connect_messages) + await self.message_listener() + self.ws = MockedAnovaWebsocketStream(self.post_connect_messages) + # RUF006 ignored as it replicates the parent library + # https://github.com/Lash-L/anova_wifi/issues/35 + asyncio.ensure_future(self.message_listener()) # noqa: RUF006 + + +def anova_api_mock( + connect_messages: list[MockedanovaWebsocketMessage] | None = None, + post_connect_messages: list[MockedanovaWebsocketMessage] | None = None, +) -> AsyncMock: + """Mock the api for Anova.""" + api_mock = AsyncMock() + + async def authenticate_side_effect() -> None: + api_mock.jwt = "my_test_jwt" + api_mock._firebase_jwt = "my_test_firebase_jwt" + + async def create_websocket_side_effect() -> None: + api_mock.websocket_handler = MockedAnovaWebsocketHandler( + firebase_jwt=api_mock._firebase_jwt, + jwt=api_mock.jwt, + session=AsyncMock(), + connect_messages=connect_messages + if connect_messages is not None + else [ + MockedanovaWebsocketMessage( + { + "command": "EVENT_APC_WIFI_LIST", + "payload": [ + { + "cookerId": DUMMY_ID, + "type": "a5", + "pairedAt": "2023-08-12T02:33:20.917716Z", + "name": "Anova Precision Cooker", + } + ], + } + ), + ], + post_connect_messages=post_connect_messages + if post_connect_messages is not None + else [ + MockedanovaWebsocketMessage( + { + "command": "EVENT_APC_STATE", + "payload": { + "cookerId": DUMMY_ID, + "state": { + "boot-id": "8620610049456548422", + "job": { + "cook-time-seconds": 0, + "id": "8759286e3125b0c547", + "mode": "IDLE", + "ota-url": "", + "target-temperature": 54.72, + "temperature-unit": "F", + }, + "job-status": { + "cook-time-remaining": 0, + "job-start-systick": 599679, + "provisioning-pairing-code": 7514, + "state": "", + "state-change-systick": 599679, + }, + "pin-info": { + "device-safe": 0, + "water-leak": 0, + "water-level-critical": 0, + "water-temp-too-high": 0, + }, + "system-info": { + "class": "A5", + "firmware-version": "2.2.0", + "type": "RA2L1-128", + }, + "system-info-details": { + "firmware-version-raw": "VM178_A_02.02.00_MKE15-128", + "systick": 607026, + "version-string": "VM171_A_02.02.00 RA2L1-128", + }, + "temperature-info": { + "heater-temperature": 22.37, + "triac-temperature": 36.04, + "water-temperature": 18.33, + }, + }, + }, + } + ), + ], + ) + await api_mock.websocket_handler.connect() + if not api_mock.websocket_handler.devices: + raise NoDevicesFound("No devices were found on the websocket.") + + api_mock.authenticate.side_effect = authenticate_side_effect + api_mock.create_websocket.side_effect = create_websocket_side_effect + return api_mock @pytest.fixture @@ -15,23 +182,14 @@ async def anova_api( hass: HomeAssistant, ) -> AnovaApi: """Mock the api for Anova.""" - api_mock = AsyncMock() + api_mock = anova_api_mock() - new_device = AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - - async def authenticate_side_effect(): - api_mock.jwt = "my_test_jwt" - - async def get_devices_side_effect(): - if not api_mock.existing_devices: - api_mock.existing_devices = [] - api_mock.existing_devices = [*api_mock.existing_devices, new_device] - return [new_device] - - api_mock.authenticate.side_effect = authenticate_side_effect - api_mock.get_devices.side_effect = get_devices_side_effect - - with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + with ( + patch("homeassistant.components.anova.AnovaApi", return_value=api_mock), + patch( + "homeassistant.components.anova.config_flow.AnovaApi", return_value=api_mock + ), + ): api = AnovaApi( None, "sample@gmail.com", @@ -45,18 +203,14 @@ async def anova_api_no_devices( hass: HomeAssistant, ) -> AnovaApi: """Mock the api for Anova with no online devices.""" - api_mock = AsyncMock() + api_mock = anova_api_mock(connect_messages=[], post_connect_messages=[]) - async def authenticate_side_effect(): - api_mock.jwt = "my_test_jwt" - - async def get_devices_side_effect(): - raise NoDevicesFound - - api_mock.authenticate.side_effect = authenticate_side_effect - api_mock.get_devices.side_effect = get_devices_side_effect - - with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + with ( + patch("homeassistant.components.anova.AnovaApi", return_value=api_mock), + patch( + "homeassistant.components.anova.config_flow.AnovaApi", return_value=api_mock + ), + ): api = AnovaApi( None, "sample@gmail.com", @@ -70,7 +224,7 @@ async def anova_api_wrong_login( hass: HomeAssistant, ) -> AnovaApi: """Mock the api for Anova with a wrong login.""" - api_mock = AsyncMock() + api_mock = anova_api_mock() async def authenticate_side_effect(): raise InvalidLogin @@ -84,3 +238,40 @@ async def anova_api_wrong_login( "sample", ) yield api + + +@pytest.fixture +async def anova_api_no_data( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with a wrong login.""" + api_mock = anova_api_mock(post_connect_messages=[]) + + with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api + + +@pytest.fixture +async def anova_api_websocket_failure( + hass: HomeAssistant, +) -> AnovaApi: + """Mock the api for Anova with a websocket failure.""" + api_mock = anova_api_mock() + + async def create_websocket_side_effect(): + raise WebsocketFailure + + api_mock.create_websocket.side_effect = create_websocket_side_effect + + with patch("homeassistant.components.anova.AnovaApi", return_value=api_mock): + api = AnovaApi( + None, + "sample@gmail.com", + "sample", + ) + yield api diff --git a/tests/components/anova/test_config_flow.py b/tests/components/anova/test_config_flow.py index b92c50c40b0..0f93b869296 100644 --- a/tests/components/anova/test_config_flow.py +++ b/tests/components/anova/test_config_flow.py @@ -2,83 +2,33 @@ from unittest.mock import patch -from anova_wifi import AnovaPrecisionCooker, InvalidLogin, NoDevicesFound +from anova_wifi import AnovaApi, InvalidLogin from homeassistant import config_entries from homeassistant.components.anova.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import CONF_INPUT, DEVICE_UNIQUE_ID, create_entry +from . import CONF_INPUT -async def test_flow_user( - hass: HomeAssistant, -) -> None: +async def test_flow_user(hass: HomeAssistant, anova_api: AnovaApi) -> None: """Test user initialized flow.""" - with ( - patch( - "homeassistant.components.anova.config_flow.AnovaApi.authenticate", - ) as auth_patch, - patch("homeassistant.components.anova.AnovaApi.get_devices") as device_patch, - patch("homeassistant.components.anova.AnovaApi.authenticate"), - patch( - "homeassistant.components.anova.config_flow.AnovaApi.get_devices" - ) as config_flow_device_patch, - ): - auth_patch.return_value = True - device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - config_flow_device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - 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"], - user_input=CONF_INPUT, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_USERNAME: "sample@gmail.com", - CONF_PASSWORD: "sample", - "devices": [(DEVICE_UNIQUE_ID, "type_sample")], - } - - -async def test_flow_user_already_configured(hass: HomeAssistant) -> None: - """Test user initialized flow with duplicate device.""" - with ( - patch( - "homeassistant.components.anova.config_flow.AnovaApi.authenticate", - ) as auth_patch, - patch("homeassistant.components.anova.AnovaApi.get_devices") as device_patch, - patch( - "homeassistant.components.anova.config_flow.AnovaApi.get_devices" - ) as config_flow_device_patch, - ): - auth_patch.return_value = True - device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - config_flow_device_patch.return_value = [ - AnovaPrecisionCooker(None, DEVICE_UNIQUE_ID, "type_sample", None) - ] - create_entry(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"], - user_input=CONF_INPUT, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + 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"], + user_input=CONF_INPUT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_USERNAME: "sample@gmail.com", + CONF_PASSWORD: "sample", + CONF_DEVICES: [], + } async def test_flow_wrong_login(hass: HomeAssistant) -> None: @@ -115,24 +65,3 @@ async def test_flow_unknown_error(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "unknown"} - - -async def test_flow_no_devices(hass: HomeAssistant) -> None: - """Test unknown error throwing error.""" - with ( - patch("homeassistant.components.anova.config_flow.AnovaApi.authenticate"), - patch( - "homeassistant.components.anova.config_flow.AnovaApi.get_devices", - side_effect=NoDevicesFound(), - ), - ): - 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"], - user_input=CONF_INPUT, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_devices_found"} diff --git a/tests/components/anova/test_init.py b/tests/components/anova/test_init.py index 631a69e103b..5fc63fcaf93 100644 --- a/tests/components/anova/test_init.py +++ b/tests/components/anova/test_init.py @@ -1,15 +1,12 @@ """Test init for Anova.""" -from unittest.mock import patch - from anova_wifi import AnovaApi from homeassistant.components.anova import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import ONLINE_UPDATE, async_init_integration, create_entry +from . import async_init_integration, create_entry async def test_async_setup_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: @@ -17,8 +14,7 @@ async def test_async_setup_entry(hass: HomeAssistant, anova_api: AnovaApi) -> No await async_init_integration(hass) state = hass.states.get("sensor.anova_precision_cooker_mode") assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == "Low water" + assert state.state == "idle" async def test_wrong_login( @@ -30,37 +26,6 @@ async def test_wrong_login( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_new_devices(hass: HomeAssistant, anova_api: AnovaApi) -> None: - """Test for if we find a new device on init.""" - entry = create_entry(hass, "test_device_2") - with patch( - "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" - ) as update_patch: - update_patch.return_value = ONLINE_UPDATE - assert len(entry.data["devices"]) == 1 - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(entry.data["devices"]) == 2 - - -async def test_device_cached_but_offline( - hass: HomeAssistant, anova_api_no_devices: AnovaApi -) -> None: - """Test if we have previously seen a device, but it was offline on startup.""" - entry = create_entry(hass) - - with patch( - "homeassistant.components.anova.coordinator.AnovaPrecisionCooker.update" - ) as update_patch: - update_patch.return_value = ONLINE_UPDATE - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(entry.data["devices"]) == 1 - state = hass.states.get("sensor.anova_precision_cooker_mode") - assert state is not None - assert state.state == "Low water" - - async def test_unload_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: """Test successful unload of entry.""" entry = await async_init_integration(hass) @@ -72,3 +37,21 @@ async def test_unload_entry(hass: HomeAssistant, anova_api: AnovaApi) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_no_devices_found( + hass: HomeAssistant, + anova_api_no_devices: AnovaApi, +) -> None: + """Test when there don't seem to be any devices on the account.""" + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_websocket_failure( + hass: HomeAssistant, + anova_api_websocket_failure: AnovaApi, +) -> None: + """Test that we successfully handle a websocket failure on setup.""" + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py index 0ce5c7a4d0a..459af55e2c4 100644 --- a/tests/components/anova/test_sensor.py +++ b/tests/components/anova/test_sensor.py @@ -1,19 +1,14 @@ """Test the Anova sensors.""" -from datetime import timedelta import logging -from unittest.mock import patch -from anova_wifi import AnovaApi, AnovaOffline +from anova_wifi import AnovaApi +import pytest -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import async_init_integration -from tests.common import async_fire_time_changed - LOGGER = logging.getLogger(__name__) @@ -28,34 +23,26 @@ async def test_sensors(hass: HomeAssistant, anova_api: AnovaApi) -> None: assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0" assert ( hass.states.get("sensor.anova_precision_cooker_heater_temperature").state - == "20.87" + == "22.37" ) - assert hass.states.get("sensor.anova_precision_cooker_mode").state == "Low water" - assert hass.states.get("sensor.anova_precision_cooker_state").state == "No state" + assert hass.states.get("sensor.anova_precision_cooker_mode").state == "idle" + assert hass.states.get("sensor.anova_precision_cooker_state").state == "no_state" assert ( hass.states.get("sensor.anova_precision_cooker_target_temperature").state - == "23.33" + == "54.72" ) assert ( hass.states.get("sensor.anova_precision_cooker_water_temperature").state - == "21.33" + == "18.33" ) assert ( hass.states.get("sensor.anova_precision_cooker_triac_temperature").state - == "21.79" + == "36.04" ) -async def test_update_failed(hass: HomeAssistant, anova_api: AnovaApi) -> None: - """Test updating data after the coordinator has been set up, but anova is offline.""" +@pytest.mark.usefixtures("anova_api_no_data") +async def test_no_data_sensors(hass: HomeAssistant) -> None: + """Test that if we have no data for the device, and we have not set it up previously, It is not immediately set up.""" await async_init_integration(hass) - await hass.async_block_till_done() - with patch( - "homeassistant.components.anova.AnovaPrecisionCooker.update", - side_effect=AnovaOffline(), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) - await hass.async_block_till_done() - - state = hass.states.get("sensor.anova_precision_cooker_water_temperature") - assert state.state == STATE_UNAVAILABLE + assert hass.states.get("sensor.anova_precision_cooker_triac_temperature") is None diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index 74e995deaf1..d67ae1ea627 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -1,6 +1,5 @@ """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 @@ -15,6 +14,7 @@ from py_aosmith.models import ( SupportedOperationModeInfo, ) import pytest +from typing_extensions import Generator from homeassistant.components.aosmith.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD @@ -128,7 +128,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.aosmith.async_setup_entry", return_value=True @@ -166,7 +166,7 @@ async def mock_client( get_devices_fixture_mode_pending: bool, get_devices_fixture_setpoint_pending: bool, get_devices_fixture_has_vacation_mode: bool, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Return a mocked client.""" get_devices_fixture = [ build_device_fixture( diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py index 991d4129392..0027986f3d1 100644 --- a/tests/components/aosmith/test_config_flow.py +++ b/tests/components/aosmith/test_config_flow.py @@ -18,8 +18,9 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import FIXTURE_USER_INPUT + 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: diff --git a/tests/components/aosmith/test_sensor.py b/tests/components/aosmith/test_sensor.py index d6acd8865d8..a77e4e4576d 100644 --- a/tests/components/aosmith/test_sensor.py +++ b/tests/components/aosmith/test_sensor.py @@ -1,10 +1,10 @@ """Tests for the sensor platform of the A. O. Smith integration.""" -from collections.abc import AsyncGenerator from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import AsyncGenerator from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) -async def platforms() -> AsyncGenerator[list[str], None]: +async def platforms() -> AsyncGenerator[list[str]]: """Return the platforms to be loaded for this test.""" with patch("homeassistant.components.aosmith.PLATFORMS", [Platform.SENSOR]): yield diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py index 567121ac0b0..ab4a4a33bca 100644 --- a/tests/components/aosmith/test_water_heater.py +++ b/tests/components/aosmith/test_water_heater.py @@ -1,11 +1,11 @@ """Tests for the water heater platform of the A. O. Smith integration.""" -from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch from py_aosmith.models import OperationMode import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import AsyncGenerator from homeassistant.components.water_heater import ( ATTR_AWAY_MODE, @@ -29,7 +29,7 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.fixture(autouse=True) -async def platforms() -> AsyncGenerator[list[str], None]: +async def platforms() -> AsyncGenerator[list[str]]: """Return the platforms to be loaded for this test.""" with patch("homeassistant.components.aosmith.PLATFORMS", [Platform.WATER_HEATER]): yield diff --git a/tests/components/apcupsd/test_diagnostics.py b/tests/components/apcupsd/test_diagnostics.py index 5dfce28a989..67946a928f8 100644 --- a/tests/components/apcupsd/test_diagnostics.py +++ b/tests/components/apcupsd/test_diagnostics.py @@ -4,7 +4,8 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from tests.components.apcupsd import async_init_integration +from . import async_init_integration + from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 5443d48452f..a1453315dbf 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -12,9 +12,6 @@ import voluptuous as vol from homeassistant import const from homeassistant.auth.models import Credentials -from homeassistant.auth.providers.legacy_api_password import ( - LegacyApiPasswordAuthProvider, -) from homeassistant.bootstrap import DATA_LOGGING import homeassistant.core as ha from homeassistant.core import HomeAssistant @@ -731,22 +728,6 @@ async def test_rendering_template_admin( assert resp.status == HTTPStatus.UNAUTHORIZED -async def test_rendering_template_legacy_user( - hass: HomeAssistant, - mock_api_client: TestClient, - aiohttp_client: ClientSessionGenerator, - legacy_auth: LegacyApiPasswordAuthProvider, -) -> None: - """Test rendering a template with legacy API password.""" - hass.states.async_set("sensor.temperature", 10) - client = await aiohttp_client(hass.http.app) - resp = await client.post( - const.URL_API_TEMPLATE, - json={"template": "{{ states.sensor.temperature.state }}"}, - ) - assert resp.status == HTTPStatus.UNAUTHORIZED - - async def test_api_call_service_not_found( hass: HomeAssistant, mock_api_client: TestClient ) -> None: diff --git a/tests/components/apple_tv/conftest.py b/tests/components/apple_tv/conftest.py index b516cc5e71e..36061924db5 100644 --- a/tests/components/apple_tv/conftest.py +++ b/tests/components/apple_tv/conftest.py @@ -1,17 +1,18 @@ """Fixtures for component.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch from pyatv import conf from pyatv.const import PairingRequirement, Protocol from pyatv.support import http import pytest +from typing_extensions import Generator from .common import MockPairingHandler, airplay_service, create_conf, mrp_service @pytest.fixture(autouse=True, name="mock_scan") -def mock_scan_fixture(): +def mock_scan_fixture() -> Generator[AsyncMock]: """Mock pyatv.scan.""" with patch("homeassistant.components.apple_tv.config_flow.scan") as mock_scan: @@ -29,7 +30,7 @@ def mock_scan_fixture(): @pytest.fixture(name="dmap_pin") -def dmap_pin_fixture(): +def dmap_pin_fixture() -> Generator[MagicMock]: """Mock pyatv.scan.""" with patch("homeassistant.components.apple_tv.config_flow.randrange") as mock_pin: mock_pin.side_effect = lambda start, stop: 1111 @@ -37,7 +38,7 @@ def dmap_pin_fixture(): @pytest.fixture -def pairing(): +def pairing() -> Generator[AsyncMock]: """Mock pyatv.scan.""" with patch("homeassistant.components.apple_tv.config_flow.pair") as mock_pair: @@ -54,7 +55,7 @@ def pairing(): @pytest.fixture -def pairing_mock(): +def pairing_mock() -> Generator[AsyncMock]: """Mock pyatv.scan.""" with patch("homeassistant.components.apple_tv.config_flow.pair") as mock_pair: @@ -75,7 +76,7 @@ def pairing_mock(): @pytest.fixture -def full_device(mock_scan, dmap_pin): +def full_device(mock_scan: AsyncMock, dmap_pin: MagicMock) -> AsyncMock: """Mock pyatv.scan.""" mock_scan.result.append( create_conf( @@ -96,7 +97,7 @@ def full_device(mock_scan, dmap_pin): @pytest.fixture -def mrp_device(mock_scan): +def mrp_device(mock_scan: AsyncMock) -> AsyncMock: """Mock pyatv.scan.""" mock_scan.result.extend( [ @@ -116,7 +117,7 @@ def mrp_device(mock_scan): @pytest.fixture -def airplay_with_disabled_mrp(mock_scan): +def airplay_with_disabled_mrp(mock_scan: AsyncMock) -> AsyncMock: """Mock pyatv.scan.""" mock_scan.result.append( create_conf( @@ -136,7 +137,7 @@ def airplay_with_disabled_mrp(mock_scan): @pytest.fixture -def dmap_device(mock_scan): +def dmap_device(mock_scan: AsyncMock) -> AsyncMock: """Mock pyatv.scan.""" mock_scan.result.append( create_conf( @@ -156,7 +157,7 @@ def dmap_device(mock_scan): @pytest.fixture -def dmap_device_with_credentials(mock_scan): +def dmap_device_with_credentials(mock_scan: AsyncMock) -> AsyncMock: """Mock pyatv.scan.""" mock_scan.result.append( create_conf( @@ -176,7 +177,7 @@ def dmap_device_with_credentials(mock_scan): @pytest.fixture -def airplay_device_with_password(mock_scan): +def airplay_device_with_password(mock_scan: AsyncMock) -> AsyncMock: """Mock pyatv.scan.""" mock_scan.result.append( create_conf( @@ -191,7 +192,9 @@ def airplay_device_with_password(mock_scan): @pytest.fixture -def dmap_with_requirement(mock_scan, pairing_requirement): +def dmap_with_requirement( + mock_scan: AsyncMock, pairing_requirement: PairingRequirement +) -> AsyncMock: """Mock pyatv.scan.""" mock_scan.result.append( create_conf( diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index e7bfa68bdaf..b8f49e7c8f5 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -1,11 +1,12 @@ """Test config flow.""" from ipaddress import IPv4Address, ip_address -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch from pyatv import exceptions from pyatv.const import PairingRequirement, Protocol import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components import zeroconf @@ -45,19 +46,19 @@ RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( @pytest.fixture(autouse=True) -def zero_aggregation_time(): +def zero_aggregation_time() -> Generator[None]: """Prevent the aggregation time from delaying the tests.""" with patch.object(config_flow, "DISCOVERY_AGGREGATION_TIME", 0): yield @pytest.fixture(autouse=True) -def use_mocked_zeroconf(mock_async_zeroconf): +def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Mock zeroconf in all tests.""" @pytest.fixture(autouse=True) -def mock_setup_entry(): +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.apple_tv.async_setup_entry", return_value=True @@ -68,7 +69,8 @@ def mock_setup_entry(): # User Flows -async def test_user_input_device_not_found(hass: HomeAssistant, mrp_device) -> None: +@pytest.mark.usefixtures("mrp_device") +async def test_user_input_device_not_found(hass: HomeAssistant) -> None: """Test when user specifies a non-existing device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -85,7 +87,9 @@ async def test_user_input_device_not_found(hass: HomeAssistant, mrp_device) -> N assert result2["errors"] == {"base": "no_devices_found"} -async def test_user_input_unexpected_error(hass: HomeAssistant, mock_scan) -> None: +async def test_user_input_unexpected_error( + hass: HomeAssistant, mock_scan: AsyncMock +) -> None: """Test that unexpected error yields an error message.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -101,7 +105,8 @@ async def test_user_input_unexpected_error(hass: HomeAssistant, mock_scan) -> No assert result2["errors"] == {"base": "unknown"} -async def test_user_adds_full_device(hass: HomeAssistant, full_device, pairing) -> None: +@pytest.mark.usefixtures("full_device", "pairing") +async def test_user_adds_full_device(hass: HomeAssistant) -> None: """Test adding device with all services.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -149,9 +154,8 @@ async def test_user_adds_full_device(hass: HomeAssistant, full_device, pairing) } -async def test_user_adds_dmap_device( - hass: HomeAssistant, dmap_device, dmap_pin, pairing -) -> None: +@pytest.mark.usefixtures("dmap_device", "dmap_pin", "pairing") +async def test_user_adds_dmap_device(hass: HomeAssistant) -> None: """Test adding device with only DMAP service.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -183,8 +187,9 @@ async def test_user_adds_dmap_device( } +@pytest.mark.usefixtures("dmap_device", "dmap_pin") async def test_user_adds_dmap_device_failed( - hass: HomeAssistant, dmap_device, dmap_pin, pairing + hass: HomeAssistant, pairing: AsyncMock ) -> None: """Test adding DMAP device where remote device did not attempt to pair.""" pairing.always_fail = True @@ -205,9 +210,8 @@ async def test_user_adds_dmap_device_failed( assert result2["reason"] == "device_did_not_pair" -async def test_user_adds_device_with_ip_filter( - hass: HomeAssistant, dmap_device_with_credentials, mock_scan -) -> None: +@pytest.mark.usefixtures("dmap_device_with_credentials", "mock_scan") +async def test_user_adds_device_with_ip_filter(hass: HomeAssistant) -> None: """Test add device filtering by IP.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -225,9 +229,8 @@ async def test_user_adds_device_with_ip_filter( @pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.NotNeeded)]) -async def test_user_pair_no_interaction( - hass: HomeAssistant, dmap_with_requirement, pairing_mock -) -> None: +@pytest.mark.usefixtures("dmap_with_requirement", "pairing_mock") +async def test_user_pair_no_interaction(hass: HomeAssistant) -> None: """Test pairing service without user interaction.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -251,7 +254,7 @@ async def test_user_pair_no_interaction( async def test_user_adds_device_by_ip_uses_unicast_scan( - hass: HomeAssistant, mock_scan + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test add device by IP-address, verify unicast scan is used.""" result = await hass.config_entries.flow.async_init( @@ -266,7 +269,8 @@ async def test_user_adds_device_by_ip_uses_unicast_scan( assert str(mock_scan.hosts[0]) == "127.0.0.1" -async def test_user_adds_existing_device(hass: HomeAssistant, mrp_device) -> None: +@pytest.mark.usefixtures("mrp_device") +async def test_user_adds_existing_device(hass: HomeAssistant) -> None: """Test that it is not possible to add existing device.""" MockConfigEntry(domain="apple_tv", unique_id="mrpid").add_to_hass(hass) @@ -282,8 +286,9 @@ async def test_user_adds_existing_device(hass: HomeAssistant, mrp_device) -> Non assert result2["errors"] == {"base": "already_configured"} +@pytest.mark.usefixtures("mrp_device") async def test_user_connection_failed( - hass: HomeAssistant, mrp_device, pairing_mock + hass: HomeAssistant, pairing_mock: AsyncMock ) -> None: """Test error message when connection to device fails.""" pairing_mock.begin.side_effect = exceptions.ConnectionFailedError @@ -310,8 +315,9 @@ async def test_user_connection_failed( assert result2["reason"] == "setup_failed" +@pytest.mark.usefixtures("mrp_device") async def test_user_start_pair_error_failed( - hass: HomeAssistant, mrp_device, pairing_mock + hass: HomeAssistant, pairing_mock: AsyncMock ) -> None: """Test initiating pairing fails.""" pairing_mock.begin.side_effect = exceptions.PairingError @@ -333,9 +339,8 @@ async def test_user_start_pair_error_failed( assert result2["reason"] == "invalid_auth" -async def test_user_pair_service_with_password( - hass: HomeAssistant, airplay_device_with_password, pairing_mock -) -> None: +@pytest.mark.usefixtures("airplay_device_with_password", "pairing_mock") +async def test_user_pair_service_with_password(hass: HomeAssistant) -> None: """Test pairing with service requiring a password (not supported).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -362,9 +367,8 @@ async def test_user_pair_service_with_password( @pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.Disabled)]) -async def test_user_pair_disabled_service( - hass: HomeAssistant, dmap_with_requirement, pairing_mock -) -> None: +@pytest.mark.usefixtures("dmap_with_requirement", "pairing_mock") +async def test_user_pair_disabled_service(hass: HomeAssistant) -> None: """Test pairing with disabled service (is ignored with message).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -391,9 +395,8 @@ async def test_user_pair_disabled_service( @pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.Unsupported)]) -async def test_user_pair_ignore_unsupported( - hass: HomeAssistant, dmap_with_requirement, pairing_mock -) -> None: +@pytest.mark.usefixtures("dmap_with_requirement", "pairing_mock") +async def test_user_pair_ignore_unsupported(hass: HomeAssistant) -> None: """Test pairing with disabled service (is ignored silently).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -412,8 +415,9 @@ async def test_user_pair_ignore_unsupported( assert result["reason"] == "setup_failed" +@pytest.mark.usefixtures("mrp_device") async def test_user_pair_invalid_pin( - hass: HomeAssistant, mrp_device, pairing_mock + hass: HomeAssistant, pairing_mock: AsyncMock ) -> None: """Test pairing with invalid pin.""" pairing_mock.finish.side_effect = exceptions.PairingError @@ -440,8 +444,9 @@ async def test_user_pair_invalid_pin( assert result2["errors"] == {"base": "invalid_auth"} +@pytest.mark.usefixtures("mrp_device") async def test_user_pair_unexpected_error( - hass: HomeAssistant, mrp_device, pairing_mock + hass: HomeAssistant, pairing_mock: AsyncMock ) -> None: """Test unexpected error when entering PIN code.""" @@ -468,8 +473,9 @@ async def test_user_pair_unexpected_error( assert result2["errors"] == {"base": "unknown"} +@pytest.mark.usefixtures("mrp_device") async def test_user_pair_backoff_error( - hass: HomeAssistant, mrp_device, pairing_mock + hass: HomeAssistant, pairing_mock: AsyncMock ) -> None: """Test that backoff error is displayed in case device requests it.""" pairing_mock.begin.side_effect = exceptions.BackOffError @@ -491,8 +497,9 @@ async def test_user_pair_backoff_error( assert result2["reason"] == "backoff" +@pytest.mark.usefixtures("mrp_device") async def test_user_pair_begin_unexpected_error( - hass: HomeAssistant, mrp_device, pairing_mock + hass: HomeAssistant, pairing_mock: AsyncMock ) -> None: """Test unexpected error during start of pairing.""" pairing_mock.begin.side_effect = Exception @@ -514,9 +521,8 @@ async def test_user_pair_begin_unexpected_error( assert result2["reason"] == "unknown" -async def test_ignores_disabled_service( - hass: HomeAssistant, airplay_with_disabled_mrp, pairing -) -> None: +@pytest.mark.usefixtures("airplay_with_disabled_mrp", "pairing") +async def test_ignores_disabled_service(hass: HomeAssistant) -> None: """Test adding device with only DMAP service.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -573,9 +579,8 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: assert result["reason"] == "unknown" -async def test_zeroconf_add_mrp_device( - hass: HomeAssistant, mrp_device, pairing -) -> None: +@pytest.mark.usefixtures("mrp_device", "pairing") +async def test_zeroconf_add_mrp_device(hass: HomeAssistant) -> None: """Test add MRP device discovered by zeroconf.""" unrelated_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -630,9 +635,8 @@ async def test_zeroconf_add_mrp_device( } -async def test_zeroconf_add_dmap_device( - hass: HomeAssistant, dmap_device, dmap_pin, pairing -) -> None: +@pytest.mark.usefixtures("dmap_device", "dmap_pin", "pairing") +async def test_zeroconf_add_dmap_device(hass: HomeAssistant) -> None: """Test add DMAP device discovered by zeroconf.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DMAP_SERVICE @@ -660,7 +664,7 @@ async def test_zeroconf_add_dmap_device( } -async def test_zeroconf_ip_change(hass: HomeAssistant, mock_scan) -> None: +async def test_zeroconf_ip_change(hass: HomeAssistant, mock_scan: AsyncMock) -> None: """Test that the config entry gets updated when the ip changes and reloads.""" entry = MockConfigEntry( domain="apple_tv", unique_id="mrpid", data={CONF_ADDRESS: "127.0.0.2"} @@ -694,7 +698,7 @@ async def test_zeroconf_ip_change(hass: HomeAssistant, mock_scan) -> None: async def test_zeroconf_ip_change_after_ip_conflict_with_ignored_entry( - hass: HomeAssistant, mock_scan + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test that the config entry gets updated when the ip changes and reloads.""" entry = MockConfigEntry( @@ -732,7 +736,7 @@ async def test_zeroconf_ip_change_after_ip_conflict_with_ignored_entry( async def test_zeroconf_ip_change_via_secondary_identifier( - hass: HomeAssistant, mock_scan + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test that the config entry gets updated when the ip changes and reloads. @@ -774,7 +778,7 @@ async def test_zeroconf_ip_change_via_secondary_identifier( async def test_zeroconf_updates_identifiers_for_ignored_entries( - hass: HomeAssistant, mock_scan + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test that an ignored config entry gets updated when the ip changes. @@ -818,7 +822,8 @@ async def test_zeroconf_updates_identifiers_for_ignored_entries( assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"} -async def test_zeroconf_add_existing_aborts(hass: HomeAssistant, dmap_device) -> None: +@pytest.mark.usefixtures("dmap_device") +async def test_zeroconf_add_existing_aborts(hass: HomeAssistant) -> None: """Test start new zeroconf flow while existing flow is active aborts.""" await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DMAP_SERVICE @@ -831,9 +836,8 @@ async def test_zeroconf_add_existing_aborts(hass: HomeAssistant, dmap_device) -> assert result["reason"] == "already_in_progress" -async def test_zeroconf_add_but_device_not_found( - hass: HomeAssistant, mock_scan -) -> None: +@pytest.mark.usefixtures("mock_scan") +async def test_zeroconf_add_but_device_not_found(hass: HomeAssistant) -> None: """Test add device which is not found with another scan.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DMAP_SERVICE @@ -842,7 +846,8 @@ async def test_zeroconf_add_but_device_not_found( assert result["reason"] == "no_devices_found" -async def test_zeroconf_add_existing_device(hass: HomeAssistant, dmap_device) -> None: +@pytest.mark.usefixtures("dmap_device") +async def test_zeroconf_add_existing_device(hass: HomeAssistant) -> None: """Test add already existing device from zeroconf.""" MockConfigEntry(domain="apple_tv", unique_id="dmapid").add_to_hass(hass) @@ -853,7 +858,9 @@ async def test_zeroconf_add_existing_device(hass: HomeAssistant, dmap_device) -> assert result["reason"] == "already_configured" -async def test_zeroconf_unexpected_error(hass: HomeAssistant, mock_scan) -> None: +async def test_zeroconf_unexpected_error( + hass: HomeAssistant, mock_scan: AsyncMock +) -> None: """Test unexpected error aborts in zeroconf.""" mock_scan.side_effect = Exception @@ -865,7 +872,7 @@ async def test_zeroconf_unexpected_error(hass: HomeAssistant, mock_scan) -> None async def test_zeroconf_abort_if_other_in_progress( - hass: HomeAssistant, mock_scan + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test discovering unsupported zeroconf service.""" mock_scan.result = [ @@ -912,8 +919,9 @@ async def test_zeroconf_abort_if_other_in_progress( assert result2["reason"] == "already_in_progress" +@pytest.mark.usefixtures("pairing", "mock_zeroconf") async def test_zeroconf_missing_device_during_protocol_resolve( - hass: HomeAssistant, mock_scan, pairing, mock_zeroconf: None + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test discovery after service been added to existing flow with missing device.""" mock_scan.result = [ @@ -970,8 +978,9 @@ async def test_zeroconf_missing_device_during_protocol_resolve( assert result2["reason"] == "device_not_found" +@pytest.mark.usefixtures("pairing", "mock_zeroconf") async def test_zeroconf_additional_protocol_resolve_failure( - hass: HomeAssistant, mock_scan, pairing, mock_zeroconf: None + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test discovery with missing service.""" mock_scan.result = [ @@ -1030,8 +1039,9 @@ async def test_zeroconf_additional_protocol_resolve_failure( assert result2["reason"] == "inconsistent_device" +@pytest.mark.usefixtures("pairing", "mock_zeroconf") async def test_zeroconf_pair_additionally_found_protocols( - hass: HomeAssistant, mock_scan, pairing, mock_zeroconf: None + hass: HomeAssistant, mock_scan: AsyncMock ) -> None: """Test discovered protocols are merged to original flow.""" mock_scan.result = [ @@ -1132,9 +1142,8 @@ async def test_zeroconf_pair_additionally_found_protocols( assert result5["type"] is FlowResultType.CREATE_ENTRY -async def test_zeroconf_mismatch( - hass: HomeAssistant, mock_scan, pairing, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("pairing", "mock_zeroconf") +async def test_zeroconf_mismatch(hass: HomeAssistant, mock_scan: AsyncMock) -> None: """Test the technically possible case where a protocol has no service. This could happen in case of mDNS issues. @@ -1172,9 +1181,8 @@ async def test_zeroconf_mismatch( # Re-configuration -async def test_reconfigure_update_credentials( - hass: HomeAssistant, mrp_device, pairing -) -> None: +@pytest.mark.usefixtures("mrp_device", "pairing") +async def test_reconfigure_update_credentials(hass: HomeAssistant) -> None: """Test that reconfigure flow updates config entry.""" config_entry = MockConfigEntry( domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]} diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 523abc7fd84..c427b1d07e0 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import Callable import logging from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant import config_entries, data_entry_flow from homeassistant.components.application_credentials import ( @@ -113,8 +114,8 @@ class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): @pytest.fixture(autouse=True) def config_flow_handler( - hass: HomeAssistant, current_request_with_host: Any -) -> Generator[FakeConfigFlow, None, None]: + hass: HomeAssistant, current_request_with_host: None +) -> Generator[None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): @@ -175,7 +176,7 @@ class OAuthFixture: async def oauth_fixture( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: Any, + aioclient_mock: AiohttpClientMocker, ) -> OAuthFixture: """Fixture for testing the OAuth flow.""" return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) @@ -213,7 +214,7 @@ class Client: return resp.get("result") -ClientFixture = Callable[[], Client] +type ClientFixture = Callable[[], Client] @pytest.fixture diff --git a/tests/components/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py index 92081111c8b..4cdff41598f 100644 --- a/tests/components/aprs/test_device_tracker.py +++ b/tests/components/aprs/test_device_tracker.py @@ -1,11 +1,11 @@ """Test APRS device tracker.""" -from collections.abc import Generator from unittest.mock import MagicMock, Mock, patch import aprslib from aprslib import IS import pytest +from typing_extensions import Generator from homeassistant.components.aprs import device_tracker from homeassistant.core import HomeAssistant @@ -20,7 +20,7 @@ TEST_PASSWORD = "testpass" @pytest.fixture(name="mock_ais") -def mock_ais() -> Generator[MagicMock, None, None]: +def mock_ais() -> Generator[MagicMock]: """Mock aprslib.""" with patch("aprslib.IS") as mock_ais: yield mock_ais @@ -302,6 +302,37 @@ def test_aprs_listener_rx_msg_no_position(mock_ais: MagicMock) -> None: see.assert_not_called() +def test_aprs_listener_rx_msg_object(mock_ais: MagicMock) -> None: + """Test rx_msg with object.""" + callsign = TEST_CALLSIGN + password = TEST_PASSWORD + host = TEST_HOST + server_filter = TEST_FILTER + see = Mock() + + sample_msg = aprslib.parse( + "CEEWO2-14>APLWS2,qAU,CEEWO2-15:;V4310251 *121203h5105.72N/00131.89WO085/024/A=033178!w&,!Clb=3.5m/s calibration 21% 404.40MHz Type=RS41 batt=2.7V Details on http://radiosondy.info/" + ) + + listener = device_tracker.AprsListenerThread( + callsign, password, host, server_filter, see + ) + listener.run() + listener.rx_msg(sample_msg) + + see.assert_called_with( + dev_id=device_tracker.slugify("V4310251"), + gps=(51.09534249084249, -1.5315201465201465), + attributes={ + "gps_accuracy": 0, + "altitude": 10112.654400000001, + "comment": "Clb=3.5m/s calibration 21% 404.40MHz Type=RS41 batt=2.7V Details on http://radiosondy.info/", + "course": 85, + "speed": 44.448, + }, + ) + + async def test_setup_scanner(hass: HomeAssistant) -> None: """Test setup_scanner.""" with patch( diff --git a/tests/components/apsystems/__init__.py b/tests/components/apsystems/__init__.py new file mode 100644 index 00000000000..ad86df667ba --- /dev/null +++ b/tests/components/apsystems/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the APsystems Local API integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/apsystems/conftest.py b/tests/components/apsystems/conftest.py new file mode 100644 index 00000000000..cd04346c070 --- /dev/null +++ b/tests/components/apsystems/conftest.py @@ -0,0 +1,67 @@ +"""Common fixtures for the APsystems Local API tests.""" + +from unittest.mock import AsyncMock, patch + +from APsystemsEZ1 import ReturnDeviceInfo, ReturnOutputData +import pytest +from typing_extensions import Generator + +from homeassistant.components.apsystems.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.apsystems.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_apsystems() -> Generator[AsyncMock, None, None]: + """Mock APSystems lib.""" + with ( + patch( + "homeassistant.components.apsystems.APsystemsEZ1M", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.apsystems.config_flow.APsystemsEZ1M", + new=mock_client, + ), + ): + mock_api = mock_client.return_value + mock_api.get_device_info.return_value = ReturnDeviceInfo( + deviceId="MY_SERIAL_NUMBER", + devVer="1.0.0", + ssid="MY_SSID", + ipAddr="127.0.01", + minPower=0, + maxPower=1000, + ) + mock_api.get_output_data.return_value = ReturnOutputData( + p1=2.0, + e1=3.0, + te1=4.0, + p2=5.0, + e2=6.0, + te2=7.0, + ) + yield mock_api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + }, + unique_id="MY_SERIAL_NUMBER", + ) diff --git a/tests/components/apsystems/snapshots/test_sensor.ambr b/tests/components/apsystems/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..669e89fda17 --- /dev/null +++ b/tests/components/apsystems/snapshots/test_sensor.ambr @@ -0,0 +1,460 @@ +# serializer version: 1 +# name: test_all_entities[sensor.mock_title_lifetime_production_of_p1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_lifetime_production_of_p1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime production of P1', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_production_p1', + 'unique_id': 'MY_SERIAL_NUMBER_lifetime_production_p1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_lifetime_production_of_p1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Mock Title Lifetime production of P1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_lifetime_production_of_p1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_lifetime_production_of_p2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_lifetime_production_of_p2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime production of P2', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_production_p2', + 'unique_id': 'MY_SERIAL_NUMBER_lifetime_production_p2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_lifetime_production_of_p2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Mock Title Lifetime production of P2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_lifetime_production_of_p2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_power_of_p1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_power_of_p1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power of P1', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_p1', + 'unique_id': 'MY_SERIAL_NUMBER_total_power_p1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_power_of_p1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Mock Title Power of P1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_power_of_p1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_power_of_p2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_power_of_p2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power of P2', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power_p2', + 'unique_id': 'MY_SERIAL_NUMBER_total_power_p2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_power_of_p2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Mock Title Power of P2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_power_of_p2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_production_of_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_production_of_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Production of today', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'today_production', + 'unique_id': 'MY_SERIAL_NUMBER_today_production', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_production_of_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Mock Title Production of today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_production_of_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_production_of_today_from_p1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_production_of_today_from_p1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Production of today from P1', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'today_production_p1', + 'unique_id': 'MY_SERIAL_NUMBER_today_production_p1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_production_of_today_from_p1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Mock Title Production of today from P1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_production_of_today_from_p1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_production_of_today_from_p2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_production_of_today_from_p2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Production of today from P2', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'today_production_p2', + 'unique_id': 'MY_SERIAL_NUMBER_today_production_p2', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_production_of_today_from_p2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Mock Title Production of today from P2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_production_of_today_from_p2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_total_lifetime_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_total_lifetime_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total lifetime production', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_production', + 'unique_id': 'MY_SERIAL_NUMBER_lifetime_production', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_total_lifetime_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Mock Title Total lifetime production', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_total_lifetime_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_all_entities[sensor.mock_title_total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'apsystems', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'MY_SERIAL_NUMBER_total_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.mock_title_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Mock Title Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- diff --git a/tests/components/apsystems/test_config_flow.py b/tests/components/apsystems/test_config_flow.py new file mode 100644 index 00000000000..e3fcdf67dcc --- /dev/null +++ b/tests/components/apsystems/test_config_flow.py @@ -0,0 +1,77 @@ +"""Test the APsystems Local API config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.apsystems.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form_create_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_apsystems: AsyncMock +) -> None: + """Test we handle creatinw with success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result["result"].unique_id == "MY_SERIAL_NUMBER" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result["data"].get(CONF_IP_ADDRESS) == "127.0.0.1" + + +async def test_form_cannot_connect_and_recover( + hass: HomeAssistant, mock_apsystems: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + + mock_apsystems.get_device_info.side_effect = TimeoutError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_IP_ADDRESS: "127.0.0.2", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_apsystems.get_device_info.side_effect = None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "127.0.0.1", + }, + ) + assert result2["result"].unique_id == "MY_SERIAL_NUMBER" + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2["data"].get(CONF_IP_ADDRESS) == "127.0.0.1" + + +async def test_form_unique_id_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle cannot connect error.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_IP_ADDRESS: "127.0.0.2", + }, + ) + assert result["reason"] == "already_configured" + assert result.get("type") is FlowResultType.ABORT diff --git a/tests/components/apsystems/test_sensor.py b/tests/components/apsystems/test_sensor.py new file mode 100644 index 00000000000..810ad3e7bdf --- /dev/null +++ b/tests/components/apsystems/test_sensor.py @@ -0,0 +1,31 @@ +"""Test the APSystem sensor module.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_apsystems: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.apsystems.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) diff --git a/tests/components/aquacell/__init__.py b/tests/components/aquacell/__init__.py new file mode 100644 index 00000000000..c54bc539496 --- /dev/null +++ b/tests/components/aquacell/__init__.py @@ -0,0 +1,33 @@ +"""Tests for the Aquacell integration.""" + +from homeassistant.components.aquacell.const import ( + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_CREATION_TIME, +) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_CONFIG_ENTRY = { + CONF_EMAIL: "test@test.com", + CONF_PASSWORD: "test-password", + CONF_REFRESH_TOKEN: "refresh-token", + CONF_REFRESH_TOKEN_CREATION_TIME: 0, +} + +TEST_USER_INPUT = { + CONF_EMAIL: "test@test.com", + CONF_PASSWORD: "test-password", +} + +DSN = "DSN" + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() diff --git a/tests/components/aquacell/conftest.py b/tests/components/aquacell/conftest.py new file mode 100644 index 00000000000..db27f51dc03 --- /dev/null +++ b/tests/components/aquacell/conftest.py @@ -0,0 +1,78 @@ +"""Common fixtures for the Aquacell tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from aioaquacell import AquacellApi, Softener +import pytest + +from homeassistant.components.aquacell.const import ( + CONF_REFRESH_TOKEN_CREATION_TIME, + DOMAIN, +) +from homeassistant.const import CONF_EMAIL + +from . import TEST_CONFIG_ENTRY + +from tests.common import MockConfigEntry, load_json_array_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aquacell.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aquacell_api() -> Generator[AsyncMock, None, None]: + """Build a fixture for the Aquacell API that authenticates successfully and returns a single softener.""" + with ( + patch( + "homeassistant.components.aquacell.AquacellApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.aquacell.config_flow.AquacellApi", + new=mock_client, + ), + ): + mock_aquacell_api: AquacellApi = mock_client.return_value + mock_aquacell_api.authenticate.return_value = "refresh-token" + + softeners_dict = load_json_array_fixture( + "aquacell/get_all_softeners_one_softener.json" + ) + + softeners = [Softener(softener) for softener in softeners_dict] + mock_aquacell_api.get_all_softeners.return_value = softeners + + yield mock_aquacell_api + + +@pytest.fixture +def mock_config_entry_expired() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Aquacell", + unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], + data=TEST_CONFIG_ENTRY, + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Aquacell", + unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], + data={ + **TEST_CONFIG_ENTRY, + CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), + }, + ) diff --git a/tests/components/aquacell/fixtures/get_all_softeners_one_softener.json b/tests/components/aquacell/fixtures/get_all_softeners_one_softener.json new file mode 100644 index 00000000000..c8c61011c99 --- /dev/null +++ b/tests/components/aquacell/fixtures/get_all_softeners_one_softener.json @@ -0,0 +1,40 @@ +[ + { + "halfLevelNotificationEnabled": false, + "thresholds": {}, + "on_boarding_date": 1672751375085, + "dummy": "D", + "name": "AquaCell name", + "ssn": "SSN", + "dsn": "DSN", + "salt": { + "leftPercent": 100, + "rightPercent": 100, + "leftDays": 30, + "rightDays": 30, + "leftBlocks": 2, + "rightBlocks": 2, + "daysLeft": 30 + }, + "wifiLevel": "high", + "fwVersion": "HSWS 1.0 v1.0 Apr 16 2021 15:10:32", + "lastUpdate": 1715327070000, + "battery": 40, + "lidInPlace": true, + "buzzerNotificationEnabled": false, + "brand": "harvey", + "numberOfPeople": 1, + "location": { + "address": "address", + "postcode": "postal", + "country": "country" + }, + "dealer": { + "website": "", + "dealerId": "", + "shop": {}, + "name": "", + "support": {} + } + } +] diff --git a/tests/components/aquacell/snapshots/test_sensor.ambr b/tests/components/aquacell/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a237f59881a --- /dev/null +++ b/tests/components/aquacell/snapshots/test_sensor.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_sensors[sensor.aquacell_name_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DSN-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.aquacell_name_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'AquaCell name Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_left_side_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt left side percentage', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_left_side_percentage', + 'unique_id': 'DSN-salt_left_side_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AquaCell name Salt left side percentage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_left_side_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_left_side_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt left side time remaining', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_left_side_time_remaining', + 'unique_id': 'DSN-salt_left_side_time_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_left_side_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AquaCell name Salt left side time remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_left_side_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_percentage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_right_side_percentage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt right side percentage', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_right_side_percentage', + 'unique_id': 'DSN-salt_right_side_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_percentage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AquaCell name Salt right side percentage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_right_side_percentage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_time_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_salt_right_side_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Salt right side time remaining', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt_right_side_time_remaining', + 'unique_id': 'DSN-salt_right_side_time_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.aquacell_name_salt_right_side_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AquaCell name Salt right side time remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_salt_right_side_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensors[sensor.aquacell_name_wi_fi_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'high', + 'medium', + 'low', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.aquacell_name_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'aquacell', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wi_fi_strength', + 'unique_id': 'DSN-wi_fi_strength', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.aquacell_name_wi_fi_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'AquaCell name Wi-Fi strength', + 'options': list([ + 'high', + 'medium', + 'low', + ]), + }), + 'context': , + 'entity_id': 'sensor.aquacell_name_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- diff --git a/tests/components/aquacell/test_config_flow.py b/tests/components/aquacell/test_config_flow.py new file mode 100644 index 00000000000..b6bcb82293c --- /dev/null +++ b/tests/components/aquacell/test_config_flow.py @@ -0,0 +1,112 @@ +"""Test the Aquacell config flow.""" + +from unittest.mock import AsyncMock + +from aioaquacell import ApiException, AuthenticationFailed +import pytest + +from homeassistant.components.aquacell.const import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import TEST_CONFIG_ENTRY, TEST_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_config_flow_already_configured(hass: HomeAssistant) -> None: + """Test already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + **TEST_CONFIG_ENTRY, + }, + unique_id=TEST_CONFIG_ENTRY[CONF_EMAIL], + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aquacell_api: AsyncMock +) -> None: + """Test the full config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result2["data"][CONF_EMAIL] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result2["data"][CONF_PASSWORD] == TEST_CONFIG_ENTRY[CONF_PASSWORD] + assert result2["data"][CONF_REFRESH_TOKEN] == TEST_CONFIG_ENTRY[CONF_REFRESH_TOKEN] + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ApiException, "cannot_connect"), + (AuthenticationFailed, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_setup_entry: AsyncMock, + mock_aquacell_api: AsyncMock, +) -> None: + """Test we handle form exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_aquacell_api.authenticate.side_effect = exception + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": error} + + mock_aquacell_api.authenticate.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result3["data"][CONF_EMAIL] == TEST_CONFIG_ENTRY[CONF_EMAIL] + assert result3["data"][CONF_PASSWORD] == TEST_CONFIG_ENTRY[CONF_PASSWORD] + assert result3["data"][CONF_REFRESH_TOKEN] == TEST_CONFIG_ENTRY[CONF_REFRESH_TOKEN] + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/aquacell/test_init.py b/tests/components/aquacell/test_init.py new file mode 100644 index 00000000000..a70d077e180 --- /dev/null +++ b/tests/components/aquacell/test_init.py @@ -0,0 +1,103 @@ +"""Test the Aquacell init module.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from aioaquacell import AquacellApiException, AuthenticationFailed +import pytest + +from homeassistant.components.aquacell.const import ( + CONF_REFRESH_TOKEN, + CONF_REFRESH_TOKEN_CREATION_TIME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_coordinator_update_valid_refresh_token( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is ConfigEntryState.LOADED + + assert len(mock_aquacell_api.authenticate.mock_calls) == 0 + assert len(mock_aquacell_api.authenticate_refresh.mock_calls) == 1 + assert len(mock_aquacell_api.get_all_softeners.mock_calls) == 1 + + +async def test_coordinator_update_expired_refresh_token( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry_expired: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + mock_aquacell_api.authenticate.return_value = "new-refresh-token" + + now = datetime.now() + with patch( + "homeassistant.components.aquacell.coordinator.datetime" + ) as datetime_mock: + datetime_mock.now.return_value = now + await setup_integration(hass, mock_config_entry_expired) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is ConfigEntryState.LOADED + + assert len(mock_aquacell_api.authenticate.mock_calls) == 1 + assert len(mock_aquacell_api.authenticate_refresh.mock_calls) == 0 + assert len(mock_aquacell_api.get_all_softeners.mock_calls) == 1 + + assert entry.data[CONF_REFRESH_TOKEN] == "new-refresh-token" + assert entry.data[CONF_REFRESH_TOKEN_CREATION_TIME] == now.timestamp() + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (AuthenticationFailed, ConfigEntryState.SETUP_ERROR), + (AquacellApiException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_load_exceptions( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test load and unload entry.""" + mock_aquacell_api.authenticate_refresh.side_effect = exception + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.state is expected_state diff --git a/tests/components/aquacell/test_sensor.py b/tests/components/aquacell/test_sensor.py new file mode 100644 index 00000000000..0c59dcc40e9 --- /dev/null +++ b/tests/components/aquacell/test_sensor.py @@ -0,0 +1,26 @@ +"""Test the Aquacell init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_aquacell_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of Aquacell sensors.""" + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/aranet/conftest.py b/tests/components/aranet/conftest.py index fca081d2e2a..da5c3c81404 100644 --- a/tests/components/aranet/conftest.py +++ b/tests/components/aranet/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py index 0d57f00fdf4..c932a92c1e8 100644 --- a/tests/components/aranet/test_sensor.py +++ b/tests/components/aranet/test_sensor.py @@ -1,5 +1,7 @@ """Test the Aranet sensors.""" +import pytest + from homeassistant.components.aranet.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT @@ -16,9 +18,8 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors_aranet_radiation( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_aranet_radiation(hass: HomeAssistant) -> None: """Test setting up creates the sensors for Aranet Radiation device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -75,9 +76,8 @@ async def test_sensors_aranet_radiation( await hass.async_block_till_done() -async def test_sensors_aranet2( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_aranet2(hass: HomeAssistant) -> None: """Test setting up creates the sensors for Aranet2 device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -125,9 +125,8 @@ async def test_sensors_aranet2( await hass.async_block_till_done() -async def test_sensors_aranet4( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_aranet4(hass: HomeAssistant) -> None: """Test setting up creates the sensors for Aranet4 device.""" entry = MockConfigEntry( domain=DOMAIN, @@ -189,9 +188,8 @@ async def test_sensors_aranet4( await hass.async_block_till_done() -async def test_smart_home_integration_disabled( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_smart_home_integration_disabled(hass: HomeAssistant) -> None: """Test disabling smart home integration marks entities as unavailable.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index f5a9ab6315a..66850933cc7 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -5,10 +5,12 @@ from unittest.mock import Mock, patch from arcam.fmj.client import Client from arcam.fmj.state import State import pytest +from typing_extensions import AsyncGenerator from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockEntityPlatform @@ -27,7 +29,7 @@ MOCK_CONFIG_ENTRY = {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT} @pytest.fixture(name="client") -def client_fixture(): +def client_fixture() -> Mock: """Get a mocked client.""" client = Mock(Client) client.host = MOCK_HOST @@ -36,7 +38,7 @@ def client_fixture(): @pytest.fixture(name="state_1") -def state_1_fixture(client): +def state_1_fixture(client: Mock) -> State: """Get a mocked state.""" state = Mock(State) state.client = client @@ -51,7 +53,7 @@ def state_1_fixture(client): @pytest.fixture(name="state_2") -def state_2_fixture(client): +def state_2_fixture(client: Mock) -> State: """Get a mocked state.""" state = Mock(State) state.client = client @@ -66,13 +68,13 @@ def state_2_fixture(client): @pytest.fixture(name="state") -def state_fixture(state_1): +def state_fixture(state_1: State) -> State: """Get a mocked state.""" return state_1 @pytest.fixture(name="player") -def player_fixture(hass, state): +def player_fixture(hass: HomeAssistant, state: State) -> ArcamFmj: """Get standard player.""" player = ArcamFmj(MOCK_NAME, state, MOCK_UUID) player.entity_id = MOCK_ENTITY_ID @@ -83,7 +85,9 @@ def player_fixture(hass, state): @pytest.fixture(name="player_setup") -async def player_setup_fixture(hass, state_1, state_2, client): +async def player_setup_fixture( + hass: HomeAssistant, state_1: State, state_2: State, client: Mock +) -> AsyncGenerator[str]: """Get standard player.""" config_entry = MockConfigEntry( domain="arcam_fmj", data=MOCK_CONFIG_ENTRY, title=MOCK_NAME diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py index 65991c313ee..26e93354900 100644 --- a/tests/components/arcam_fmj/test_config_flow.py +++ b/tests/components/arcam_fmj/test_config_flow.py @@ -1,10 +1,11 @@ """Tests for the Arcam FMJ config flow module.""" from dataclasses import replace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from arcam.fmj.client import ConnectionFailed import pytest +from typing_extensions import Generator from homeassistant.components import ssdp from homeassistant.components.arcam_fmj.config_flow import get_entry_client @@ -53,7 +54,7 @@ MOCK_DISCOVER = ssdp.SsdpServiceInfo( @pytest.fixture(name="dummy_client", autouse=True) -def dummy_client_fixture(hass): +def dummy_client_fixture() -> Generator[MagicMock]: """Mock out the real client.""" with patch("homeassistant.components.arcam_fmj.config_flow.Client") as client: client.return_value.start.side_effect = AsyncMock(return_value=None) @@ -61,7 +62,7 @@ def dummy_client_fixture(hass): yield client.return_value -async def test_ssdp(hass: HomeAssistant, dummy_client) -> None: +async def test_ssdp(hass: HomeAssistant) -> None: """Test a ssdp import flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -93,7 +94,9 @@ async def test_ssdp_abort(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_ssdp_unable_to_connect(hass: HomeAssistant, dummy_client) -> None: +async def test_ssdp_unable_to_connect( + hass: HomeAssistant, dummy_client: MagicMock +) -> None: """Test a ssdp import flow.""" dummy_client.start.side_effect = AsyncMock(side_effect=ConnectionFailed) @@ -110,7 +113,7 @@ async def test_ssdp_unable_to_connect(hass: HomeAssistant, dummy_client) -> None assert result["reason"] == "cannot_connect" -async def test_ssdp_invalid_id(hass: HomeAssistant, dummy_client) -> None: +async def test_ssdp_invalid_id(hass: HomeAssistant) -> None: """Test a ssdp with invalid UDN.""" discover = replace( MOCK_DISCOVER, upnp=MOCK_DISCOVER.upnp | {ssdp.ATTR_UPNP_UDN: "invalid"} diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 1b43d27281c..eb5cf1d7892 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -5,15 +5,11 @@ import pytest from homeassistant.components import automation from homeassistant.components.arcam_fmj.const import DOMAIN from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_get_device_automations, - async_mock_service, -) +from tests.common import MockConfigEntry, async_get_device_automations @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -21,12 +17,6 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - async def test_get_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -67,7 +57,11 @@ async def test_get_triggers( async def test_if_fires_on_turn_on_request( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls, player_setup, state + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + service_calls: list[ServiceCall], + player_setup, + state, ) -> None: """Test for turn_on and turn_off triggers firing.""" entry = entity_registry.async_get(player_setup) @@ -107,13 +101,17 @@ async def test_if_fires_on_turn_on_request( ) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == player_setup - assert calls[0].data["id"] == 0 + assert len(service_calls) == 2 + assert service_calls[1].data["some"] == player_setup + assert service_calls[1].data["id"] == 0 async def test_if_fires_on_turn_on_request_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls, player_setup, state + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + service_calls: list[ServiceCall], + player_setup, + state, ) -> None: """Test for turn_on and turn_off triggers firing.""" entry = entity_registry.async_get(player_setup) @@ -153,6 +151,6 @@ async def test_if_fires_on_turn_on_request_legacy( ) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == player_setup - assert calls[0].data["id"] == 0 + assert len(service_calls) == 2 + assert service_calls[1].data["some"] == player_setup + assert service_calls[1].data["id"] == 0 diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py index 0baa8ba6870..1fa67691895 100644 --- a/tests/components/arcam_fmj/test_media_player.py +++ b/tests/components/arcam_fmj/test_media_player.py @@ -6,6 +6,12 @@ from unittest.mock import ANY, PropertyMock, patch from arcam.fmj import ConnectionFailed, DecodeMode2CH, DecodeModeMCH, SourceCodes import pytest +from homeassistant.components.arcam_fmj.const import ( + SIGNAL_CLIENT_DATA, + SIGNAL_CLIENT_STARTED, + SIGNAL_CLIENT_STOPPED, +) +from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, @@ -338,7 +344,6 @@ async def test_media_artist(player, state, source, dls, artist) -> None: ) async def test_media_title(player, state, source, channel, title) -> None: """Test media title.""" - from homeassistant.components.arcam_fmj.media_player import ArcamFmj state.get_source.return_value = source with patch.object( @@ -354,11 +359,6 @@ async def test_media_title(player, state, source, channel, title) -> None: async def test_added_to_hass(player, state) -> None: """Test addition to hass.""" - from homeassistant.components.arcam_fmj.const import ( - SIGNAL_CLIENT_DATA, - SIGNAL_CLIENT_STARTED, - SIGNAL_CLIENT_STOPPED, - ) with patch( "homeassistant.components.arcam_fmj.media_player.async_dispatcher_connect" diff --git a/tests/components/arve/conftest.py b/tests/components/arve/conftest.py index f1dfee8ba41..40a5f98291b 100644 --- a/tests/components/arve/conftest.py +++ b/tests/components/arve/conftest.py @@ -1,10 +1,10 @@ """Common fixtures for the Arve tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from asyncarve import ArveCustomer, ArveDevices, ArveSensPro, ArveSensProData import pytest +from typing_extensions import Generator from homeassistant.components.arve.const import DOMAIN from homeassistant.core import HomeAssistant @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.arve.async_setup_entry", return_value=True diff --git a/tests/components/arve/snapshots/test_sensor.ambr b/tests/components/arve/snapshots/test_sensor.ambr index 5c5c4c84d08..5c7888c41de 100644 --- a/tests/components/arve/snapshots/test_sensor.ambr +++ b/tests/components/arve/snapshots/test_sensor.ambr @@ -209,317 +209,6 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[entry_test-serial-number_air_quality_index] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_sensor_air_quality_index', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Air quality index', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-serial-number_AQI', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[entry_test-serial-number_carbon_dioxide] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_sensor_carbon_dioxide', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Carbon dioxide', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-serial-number_CO2', - 'unit_of_measurement': 'ppm', - }) -# --- -# name: test_sensors[entry_test-serial-number_humidity] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_sensor_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-serial-number_Humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensors[entry_test-serial-number_none] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.my_arve_none', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'TVOC', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tvoc', - 'unique_id': 'test-serial-number_tvoc', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[entry_test-serial-number_pm10] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_sensor_pm10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM10', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-serial-number_PM10', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_sensors[entry_test-serial-number_pm2_5] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_sensor_pm2_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'PM2.5', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-serial-number_PM25', - 'unit_of_measurement': 'µg/m³', - }) -# --- -# name: test_sensors[entry_test-serial-number_temperature] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_sensor_temperature', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Temperature', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'test-serial-number_Temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[entry_test-serial-number_total_volatile_organic_compounds] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_sensor_total_volatile_organic_compounds', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Total volatile organic compounds', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tvoc', - 'unique_id': 'test-serial-number_TVOC', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[entry_test-serial-number_tvoc] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.my_arve_tvoc', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'TVOC', - 'platform': 'arve', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'tvoc', - 'unique_id': 'test-serial-number_tvoc', - 'unit_of_measurement': None, - }) -# --- # name: test_sensors[entry_total_volatile_organic_compounds] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -555,113 +244,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[my_arve_air_quality_index] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'aqi', - 'friendly_name': 'My Arve AQI', - }), - 'context': , - 'entity_id': 'sensor.my_arve_air_quality_index', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- -# name: test_sensors[my_arve_carbon_dioxide] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'carbon_dioxide', - 'friendly_name': 'My Arve CO2', - 'unit_of_measurement': 'ppm', - }), - 'context': , - 'entity_id': 'sensor.my_arve_carbon_dioxide', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- -# name: test_sensors[my_arve_humidity] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'My Arve Humidity', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.my_arve_humidity', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- -# name: test_sensors[my_arve_none] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'My Arve TVOC', - }), - 'context': , - 'entity_id': 'sensor.my_arve_none', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- -# name: test_sensors[my_arve_pm10] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm10', - 'friendly_name': 'My Arve PM10', - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.my_arve_pm10', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- -# name: test_sensors[my_arve_pm2_5] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'pm25', - 'friendly_name': 'My Arve PM25', - 'unit_of_measurement': 'µg/m³', - }), - 'context': , - 'entity_id': 'sensor.my_arve_pm2_5', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- -# name: test_sensors[my_arve_temperature] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'My Arve Temperature', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.my_arve_temperature', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- -# name: test_sensors[my_arve_tvoc] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'My Arve TVOC', - }), - 'context': , - 'entity_id': 'sensor.my_arve_tvoc', - 'last_changed': , - 'last_updated': , - 'state': "", - }) -# --- # name: test_sensors[test_sensor_air_quality_index] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/assist_pipeline/__init__.py b/tests/components/assist_pipeline/__init__.py index 7400fe32d70..dd0f80e52ad 100644 --- a/tests/components/assist_pipeline/__init__.py +++ b/tests/components/assist_pipeline/__init__.py @@ -45,7 +45,7 @@ MANY_LANGUAGES = [ "sr", "sv", "sw", - "te", + "te", # codespell:ignore te "tr", "uk", "ur", diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 9f098150288..f19e70a8ec1 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -2,11 +2,13 @@ from __future__ import annotations -from collections.abc import AsyncIterable, Generator +from collections.abc import AsyncIterable +from pathlib import Path from typing import Any from unittest.mock import AsyncMock import pytest +from typing_extensions import Generator from homeassistant.components import stt, tts, wake_word from homeassistant.components.assist_pipeline import DOMAIN, select as assist_select @@ -34,7 +36,7 @@ _TRANSCRIPT = "test transcript" @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir @@ -152,7 +154,7 @@ class MockTTSPlatform(MockPlatform): @pytest.fixture -async def mock_tts_provider(hass) -> MockTTSProvider: +async def mock_tts_provider() -> MockTTSProvider: """Mock TTS provider.""" return MockTTSProvider() @@ -255,13 +257,13 @@ class MockWakeWordEntity2(wake_word.WakeWordDetectionEntity): @pytest.fixture -async def mock_wake_word_provider_entity(hass) -> MockWakeWordEntity: +async def mock_wake_word_provider_entity() -> MockWakeWordEntity: """Mock wake word provider.""" return MockWakeWordEntity() @pytest.fixture -async def mock_wake_word_provider_entity2(hass) -> MockWakeWordEntity2: +async def mock_wake_word_provider_entity2() -> MockWakeWordEntity2: """Mock wake word provider.""" return MockWakeWordEntity2() @@ -271,7 +273,7 @@ class MockFlow(ConfigFlow): @pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, "test.config_flow") @@ -378,13 +380,14 @@ async def init_components(hass: HomeAssistant, init_supporting_components): @pytest.fixture -async def assist_device(hass: HomeAssistant, init_components) -> dr.DeviceEntry: +async def assist_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, init_components +) -> dr.DeviceEntry: """Create an assist device.""" config_entry = MockConfigEntry(domain="test_assist_device") config_entry.add_to_hass(hass) - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( name="Test Device", config_entry_id=config_entry.entry_id, identifiers={("test_assist_device", "test")}, diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index f952e3b7286..2c506215c68 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -254,105 +254,6 @@ # name: test_audio_pipeline_with_enhancements.7 None # --- -# name: test_audio_pipeline_with_wake_word - dict({ - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': 1, - 'timeout': 30, - }), - }) -# --- -# name: test_audio_pipeline_with_wake_word.1 - dict({ - 'entity_id': 'wake_word.test', - 'metadata': dict({ - 'bit_rate': 16, - 'channel': 1, - 'codec': 'pcm', - 'format': 'wav', - 'sample_rate': 16000, - }), - }) -# --- -# name: test_audio_pipeline_with_wake_word.2 - dict({ - 'wake_word_output': dict({ - 'queued_audio': None, - 'timestamp': 1000, - 'wake_word_id': 'test_ww', - }), - }) -# --- -# name: test_audio_pipeline_with_wake_word.3 - dict({ - 'engine': 'test', - 'metadata': dict({ - 'bit_rate': 16, - 'channel': 1, - 'codec': 'pcm', - 'format': 'wav', - 'language': 'en-US', - 'sample_rate': 16000, - }), - }) -# --- -# name: test_audio_pipeline_with_wake_word.4 - dict({ - 'stt_output': dict({ - 'text': 'test transcript', - }), - }) -# --- -# name: test_audio_pipeline_with_wake_word.5 - dict({ - 'conversation_id': None, - 'device_id': None, - 'engine': 'homeassistant', - 'intent_input': 'test transcript', - 'language': 'en', - }) -# --- -# name: test_audio_pipeline_with_wake_word.6 - dict({ - 'intent_output': dict({ - 'conversation_id': None, - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'code': 'no_intent_match', - }), - 'language': 'en', - 'response_type': 'error', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': "Sorry, I couldn't understand that", - }), - }), - }), - }), - }) -# --- -# name: test_audio_pipeline_with_wake_word.7 - dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': "Sorry, I couldn't understand that", - 'voice': 'james_earl_jones', - }) -# --- -# name: test_audio_pipeline_with_wake_word.8 - dict({ - 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", - 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', - }), - }) -# --- # name: test_audio_pipeline_with_wake_word_no_timeout dict({ 'language': 'en', @@ -736,29 +637,6 @@ }), }) # --- -# name: test_stt_provider_missing - dict({ - 'language': 'en', - 'pipeline': 'en', - 'runner_data': dict({ - 'stt_binary_handler_id': 1, - 'timeout': 30, - }), - }) -# --- -# name: test_stt_provider_missing.1 - dict({ - 'engine': 'default', - 'metadata': dict({ - 'bit_rate': 16, - 'channel': 1, - 'codec': 'pcm', - 'format': 'wav', - 'language': 'en', - 'sample_rate': 16000, - }), - }) -# --- # name: test_stt_stream_failed dict({ 'language': 'en', @@ -856,66 +734,6 @@ # name: test_tts_failed.2 None # --- -# name: test_wake_word_cooldown - dict({ - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': 1, - 'timeout': 300, - }), - }) -# --- -# name: test_wake_word_cooldown.1 - dict({ - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': 1, - 'timeout': 300, - }), - }) -# --- -# name: test_wake_word_cooldown.2 - dict({ - 'entity_id': 'wake_word.test', - 'metadata': dict({ - 'bit_rate': 16, - 'channel': 1, - 'codec': 'pcm', - 'format': 'wav', - 'sample_rate': 16000, - }), - 'timeout': 3, - }) -# --- -# name: test_wake_word_cooldown.3 - dict({ - 'entity_id': 'wake_word.test', - 'metadata': dict({ - 'bit_rate': 16, - 'channel': 1, - 'codec': 'pcm', - 'format': 'wav', - 'sample_rate': 16000, - }), - 'timeout': 3, - }) -# --- -# name: test_wake_word_cooldown.4 - dict({ - 'wake_word_output': dict({ - 'timestamp': 0, - 'wake_word_id': 'test_ww', - }), - }) -# --- -# name: test_wake_word_cooldown.5 - dict({ - 'code': 'wake_word_detection_aborted', - 'message': '', - }) -# --- # name: test_wake_word_cooldown_different_entities dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index cf3afff0172..3e1e99412d8 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1,10 +1,10 @@ """Websocket tests for Voice Assistant integration.""" -from collections.abc import AsyncGenerator from typing import Any from unittest.mock import ANY, patch import pytest +from typing_extensions import AsyncGenerator from homeassistant.components import conversation from homeassistant.components.assist_pipeline.const import DOMAIN @@ -32,19 +32,20 @@ from tests.common import flush_store @pytest.fixture(autouse=True) -async def delay_save_fixture() -> AsyncGenerator[None, None]: +async def delay_save_fixture() -> AsyncGenerator[None]: """Load the homeassistant integration.""" with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): yield @pytest.fixture(autouse=True) -async def load_homeassistant(hass) -> None: +async def load_homeassistant(hass: HomeAssistant) -> None: """Load the homeassistant integration.""" assert await async_setup_component(hass, "homeassistant", {}) -async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: +@pytest.mark.usefixtures("init_components") +async def test_load_pipelines(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" pipelines = [ @@ -247,9 +248,8 @@ async def test_migrate_pipeline_store( assert store.async_get_preferred_item() == "01GX8ZWBAQYWNB1XV3EXEZ75DY" -async def test_create_default_pipeline( - hass: HomeAssistant, init_supporting_components -) -> None: +@pytest.mark.usefixtures("init_supporting_components") +async def test_create_default_pipeline(hass: HomeAssistant) -> None: """Test async_create_default_pipeline.""" assert await async_setup_component(hass, "assist_pipeline", {}) @@ -395,9 +395,9 @@ async def test_default_pipeline_no_stt_tts( ("pt", "br", "pt-br", "pt", "pt-br", "pt-br"), ], ) +@pytest.mark.usefixtures("init_supporting_components") async def test_default_pipeline( hass: HomeAssistant, - init_supporting_components, mock_stt_provider: MockSttProvider, mock_tts_provider: MockTTSProvider, ha_language: str, @@ -439,10 +439,9 @@ async def test_default_pipeline( ) +@pytest.mark.usefixtures("init_supporting_components") async def test_default_pipeline_unsupported_stt_language( - hass: HomeAssistant, - init_supporting_components, - mock_stt_provider: MockSttProvider, + hass: HomeAssistant, mock_stt_provider: MockSttProvider ) -> None: """Test async_get_pipeline.""" with patch.object(mock_stt_provider, "_supported_languages", ["smurfish"]): @@ -470,10 +469,9 @@ async def test_default_pipeline_unsupported_stt_language( ) +@pytest.mark.usefixtures("init_supporting_components") async def test_default_pipeline_unsupported_tts_language( - hass: HomeAssistant, - init_supporting_components, - mock_tts_provider: MockTTSProvider, + hass: HomeAssistant, mock_tts_provider: MockTTSProvider ) -> None: """Test async_get_pipeline.""" with patch.object(mock_tts_provider, "_supported_languages", ["smurfish"]): @@ -502,8 +500,7 @@ async def test_default_pipeline_unsupported_tts_language( async def test_update_pipeline( - hass: HomeAssistant, - hass_storage: dict[str, Any], + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test async_update_pipeline.""" assert await async_setup_component(hass, "assist_pipeline", {}) @@ -623,9 +620,8 @@ async def test_update_pipeline( } -async def test_migrate_after_load( - hass: HomeAssistant, init_supporting_components -) -> None: +@pytest.mark.usefixtures("init_supporting_components") +async def test_migrate_after_load(hass: HomeAssistant) -> None: """Test migrating an engine after done loading.""" assert await async_setup_component(hass, "assist_pipeline", {}) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 73c069ddd04..9fb02e228d8 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -15,7 +15,7 @@ from homeassistant.components.assist_pipeline.select import ( VadSensitivitySelect, ) from homeassistant.components.assist_pipeline.vad import VadSensitivity -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -49,9 +49,11 @@ class SelectPlatform(MockPlatform): async def init_select(hass: HomeAssistant, init_components) -> ConfigEntry: """Initialize select entity.""" mock_platform(hass, "assist_pipeline.select", SelectPlatform()) - config_entry = MockConfigEntry(domain="assist_pipeline") + config_entry = MockConfigEntry( + domain="assist_pipeline", state=ConfigEntryState.LOADED + ) config_entry.add_to_hass(hass) - assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") + await hass.config_entries.async_forward_entry_setups(config_entry, ["select"]) return config_entry @@ -123,13 +125,14 @@ async def test_select_entity_registering_device( async def test_select_entity_changing_pipelines( hass: HomeAssistant, - init_select: ConfigEntry, + init_select: MockConfigEntry, pipeline_1: Pipeline, pipeline_2: Pipeline, pipeline_storage: PipelineStorageCollection, ) -> None: """Test entity tracking pipeline changes.""" config_entry = init_select # nicer naming + config_entry.mock_state(hass, ConfigEntryState.LOADED) state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None @@ -158,7 +161,7 @@ async def test_select_entity_changing_pipelines( # Reload config entry to test selected option persists assert await hass.config_entries.async_forward_entry_unload(config_entry, "select") - assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") + await hass.config_entries.async_forward_entry_setups(config_entry, ["select"]) state = hass.states.get("select.assist_pipeline_test_prefix_pipeline") assert state is not None @@ -179,10 +182,11 @@ async def test_select_entity_changing_pipelines( async def test_select_entity_changing_vad_sensitivity( hass: HomeAssistant, - init_select: ConfigEntry, + init_select: MockConfigEntry, ) -> None: """Test entity tracking pipeline changes.""" config_entry = init_select # nicer naming + config_entry.mock_state(hass, ConfigEntryState.LOADED) state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") assert state is not None @@ -205,7 +209,7 @@ async def test_select_entity_changing_vad_sensitivity( # Reload config entry to test selected option persists assert await hass.config_entries.async_forward_entry_unload(config_entry, "select") - assert await hass.config_entries.async_forward_entry_setup(config_entry, "select") + await hass.config_entries.async_forward_entry_setups(config_entry, ["select"]) state = hass.states.get("select.assist_pipeline_test_vad_sensitivity") assert state is not None diff --git a/tests/components/asterisk_mbox/test_init.py b/tests/components/asterisk_mbox/test_init.py index 9c6bbf01f0e..4800ada0ec4 100644 --- a/tests/components/asterisk_mbox/test_init.py +++ b/tests/components/asterisk_mbox/test_init.py @@ -1,9 +1,9 @@ """Test mailbox.""" -from collections.abc import Generator from unittest.mock import Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.asterisk_mbox import DOMAIN from homeassistant.core import HomeAssistant @@ -14,7 +14,7 @@ from .const import CONFIG @pytest.fixture -def client() -> Generator[Mock, None, None]: +def client() -> Generator[Mock]: """Mock client.""" with patch( "homeassistant.components.asterisk_mbox.asteriskClient", autospec=True diff --git a/tests/components/atag/conftest.py b/tests/components/atag/conftest.py index 567b835d8b4..83ba3e37aad 100644 --- a/tests/components/atag/conftest.py +++ b/tests/components/atag/conftest.py @@ -1,14 +1,14 @@ """Provide common Atag fixtures.""" import asyncio -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.atag.async_setup_entry", return_value=True diff --git a/tests/components/august/conftest.py b/tests/components/august/conftest.py index 8640ffeecd4..052cde7d2a2 100644 --- a/tests/components/august/conftest.py +++ b/tests/components/august/conftest.py @@ -9,6 +9,6 @@ import pytest def mock_discovery_fixture(): """Mock discovery to avoid loading the whole bluetooth stack.""" with patch( - "homeassistant.components.august.discovery_flow.async_create_flow" + "homeassistant.components.august.data.discovery_flow.async_create_flow" ) as mock_discovery: yield mock_discovery diff --git a/tests/components/august/fixtures/get_lock.online_with_keys.json b/tests/components/august/fixtures/get_lock.online_with_keys.json index 7fa12fa8bcb..4efcba44d09 100644 --- a/tests/components/august/fixtures/get_lock.online_with_keys.json +++ b/tests/components/august/fixtures/get_lock.online_with_keys.json @@ -3,7 +3,7 @@ "Type": 2, "Created": "2017-12-10T03:12:09.210Z", "Updated": "2017-12-10T03:12:09.210Z", - "LockID": "A6697750D607098BAE8D6BAA11EF8063", + "LockID": "A6697750D607098BAE8D6BAA11EF8064", "HouseID": "000000000000", "HouseName": "My House", "Calibrated": false, @@ -30,9 +30,9 @@ "operative": true }, "keypad": { - "_id": "5bc65c24e6ef2a263e1450a8", - "serialNumber": "K1GXB0054Z", - "lockID": "92412D1B44004595B5DEB134E151A8D3", + "_id": "5bc65c24e6ef2a263e1450a9", + "serialNumber": "K1GXB0054L", + "lockID": "92412D1B44004595B5DEB134E151A8D4", "currentFirmwareVersion": "2.27.0", "battery": {}, "batteryLevel": "Medium", diff --git a/tests/components/august/fixtures/get_lock.online_with_unlatch.json b/tests/components/august/fixtures/get_lock.online_with_unlatch.json new file mode 100644 index 00000000000..288ab1a2f28 --- /dev/null +++ b/tests/components/august/fixtures/get_lock.online_with_unlatch.json @@ -0,0 +1,94 @@ +{ + "LockName": "Lock online with unlatch supported", + "Type": 17, + "Created": "2024-03-14T18:03:09.003Z", + "Updated": "2024-03-14T18:03:09.003Z", + "LockID": "online_with_unlatch", + "HouseID": "mockhouseid1", + "HouseName": "Zuhause", + "Calibrated": false, + "timeZone": "Europe/Berlin", + "battery": 0.61, + "batteryInfo": { + "level": 0.61, + "warningState": "lock_state_battery_warning_none", + "infoUpdatedDate": "2024-04-30T17:55:09.045Z", + "lastChangeDate": "2024-03-15T07:04:00.000Z", + "lastChangeVoltage": 8350, + "state": "Mittel", + "icon": "https://app-resources.aaecosystem.com/images/lock_battery_state_medium.png" + }, + "hostHardwareID": "xxx", + "supportsEntryCodes": true, + "remoteOperateSecret": "xxxx", + "skuNumber": "NONE", + "macAddress": "DE:AD:BE:00:00:00", + "SerialNumber": "LPOC000000", + "LockStatus": { + "status": "locked", + "dateTime": "2024-04-30T18:41:25.673Z", + "isLockStatusChanged": false, + "valid": true, + "doorState": "init" + }, + "currentFirmwareVersion": "1.0.4", + "homeKitEnabled": false, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "65f33445529187c78a100000", + "mfgBridgeID": "LPOCH0004Y", + "deviceModel": "august-lock", + "firmwareVersion": "1.0.4", + "operative": true, + "status": { + "current": "online", + "lastOnline": "2024-04-30T18:41:27.971Z", + "updated": "2024-04-30T18:41:27.971Z", + "lastOffline": "2024-04-25T14:41:40.118Z" + }, + "locks": [ + { + "_id": "656858c182e6c7c555faf758", + "LockID": "68895DD075A1444FAD4C00B273EEEF28", + "macAddress": "DE:AD:BE:EF:0B:BC" + } + ], + "hyperBridge": true + }, + "OfflineKeys": { + "created": [], + "loaded": [ + { + "created": "2024-03-14T18:03:09.034Z", + "key": "055281d4aa9bd7b68c7b7bb78e2f34ca", + "slot": 1, + "UserID": "b4b44424-0000-0000-0000-25c224dad337", + "loaded": "2024-03-14T18:03:33.470Z" + } + ], + "deleted": [] + }, + "parametersToSet": {}, + "users": { + "b4b44424-0000-0000-0000-25c224dad337": { + "UserType": "superuser", + "FirstName": "m10x", + "LastName": "m10x", + "identifiers": ["phone:+494444444", "email:m10x@example.com"] + } + }, + "pubsubChannel": "pubsub", + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + }, + "accessSchedulesAllowed": true +} diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index 75145df2509..62c01d38d0c 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -58,8 +58,8 @@ def _mock_authenticator(auth_state): return authenticator -@patch("homeassistant.components.august.gateway.ApiAsync") -@patch("homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate") +@patch("yalexs.manager.gateway.ApiAsync") +@patch("yalexs.manager.gateway.AuthenticatorAsync.async_authenticate") async def _mock_setup_august( hass, api_instance, pubnub_mock, authenticate_mock, api_mock, brand ): @@ -77,8 +77,11 @@ async def _mock_setup_august( ) entry.add_to_hass(hass) with ( - patch("homeassistant.components.august.async_create_pubnub"), - patch("homeassistant.components.august.AugustPubNub", return_value=pubnub_mock), + patch( + "yalexs.manager.data.async_create_pubnub", + return_value=AsyncMock(), + ), + patch("yalexs.manager.data.AugustPubNub", return_value=pubnub_mock), ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -191,6 +194,9 @@ async def _create_august_api_with_devices( api_call_side_effects.setdefault( "unlock_return_activities", unlock_return_activities_side_effect ) + api_call_side_effects.setdefault( + "async_unlatch_return_activities", unlock_return_activities_side_effect + ) api_instance, entry = await _mock_setup_august_with_api_side_effects( hass, api_call_side_effects, pubnub, brand @@ -207,7 +213,7 @@ async def _create_august_api_with_devices( async def _mock_setup_august_with_api_side_effects( hass, api_call_side_effects, pubnub, brand=Brand.AUGUST ): - api_instance = MagicMock(name="Api") + api_instance = MagicMock(name="Api", brand=brand) if api_call_side_effects["get_lock_detail"]: type(api_instance).async_get_lock_detail = AsyncMock( @@ -244,10 +250,17 @@ async def _mock_setup_august_with_api_side_effects( side_effect=api_call_side_effects["unlock_return_activities"] ) + if api_call_side_effects["async_unlatch_return_activities"]: + type(api_instance).async_unlatch_return_activities = AsyncMock( + side_effect=api_call_side_effects["async_unlatch_return_activities"] + ) + api_instance.async_unlock_async = AsyncMock() api_instance.async_lock_async = AsyncMock() api_instance.async_status_async = AsyncMock() api_instance.async_get_user = AsyncMock(return_value={"UserID": "abc"}) + api_instance.async_unlatch_async = AsyncMock() + api_instance.async_unlatch = AsyncMock() return api_instance, await _mock_setup_august( hass, api_instance, pubnub, brand=brand @@ -366,6 +379,10 @@ async def _mock_doorsense_missing_august_lock_detail(hass): return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json") +async def _mock_lock_with_unlatch(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online_with_unlatch.json") + + def _mock_lock_operation_activity(lock, action, offset): return LockOperationActivity( SOURCE_LOCK_OPERATE, diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py index e1e6f622c2e..aec08864c65 100644 --- a/tests/components/august/test_config_flow.py +++ b/tests/components/august/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from yalexs.authenticator import ValidationResult +from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation from homeassistant import config_entries from homeassistant.components.august.const import ( @@ -13,11 +14,6 @@ from homeassistant.components.august.const import ( DOMAIN, VERIFICATION_CODE_KEY, ) -from homeassistant.components.august.exceptions import ( - CannotConnect, - InvalidAuth, - RequireValidation, -) from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -151,7 +147,7 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None: side_effect=RequireValidation, ), patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code, ): @@ -176,11 +172,11 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None: side_effect=RequireValidation, ), patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code", return_value=ValidationResult.INVALID_VERIFICATION_CODE, ) as mock_validate_verification_code, patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code, ): @@ -204,11 +200,11 @@ async def test_form_needs_validate(hass: HomeAssistant) -> None: return_value=True, ), patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code", return_value=ValidationResult.VALIDATED, ) as mock_validate_verification_code, patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code, patch( @@ -310,7 +306,7 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None: side_effect=RequireValidation, ), patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code, ): @@ -334,11 +330,11 @@ async def test_form_reauth_with_2fa(hass: HomeAssistant) -> None: return_value=True, ), patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_validate_verification_code", return_value=ValidationResult.VALIDATED, ) as mock_validate_verification_code, patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", + "yalexs.manager.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code, patch( diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py index 535e547d915..e605fd74f0a 100644 --- a/tests/components/august/test_gateway.py +++ b/tests/components/august/test_gateway.py @@ -1,5 +1,6 @@ """The gateway tests for the august platform.""" +from pathlib import Path from unittest.mock import MagicMock, patch from yalexs.authenticator_common import AuthenticationState @@ -16,12 +17,10 @@ async def test_refresh_access_token(hass: HomeAssistant) -> None: await _patched_refresh_access_token(hass, "new_token", 5678) -@patch("homeassistant.components.august.gateway.ApiAsync.async_get_operable_locks") -@patch("homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate") -@patch("homeassistant.components.august.gateway.AuthenticatorAsync.should_refresh") -@patch( - "homeassistant.components.august.gateway.AuthenticatorAsync.async_refresh_access_token" -) +@patch("yalexs.manager.gateway.ApiAsync.async_get_operable_locks") +@patch("yalexs.manager.gateway.AuthenticatorAsync.async_authenticate") +@patch("yalexs.manager.gateway.AuthenticatorAsync.should_refresh") +@patch("yalexs.manager.gateway.AuthenticatorAsync.async_refresh_access_token") async def _patched_refresh_access_token( hass, new_token, @@ -36,7 +35,7 @@ async def _patched_refresh_access_token( "original_token", 1234, AuthenticationState.AUTHENTICATED ) ) - august_gateway = AugustGateway(hass, MagicMock()) + august_gateway = AugustGateway(Path(hass.config.config_dir), MagicMock()) mocked_config = _mock_get_config() await august_gateway.async_setup(mocked_config[DOMAIN]) await august_gateway.async_authenticate() diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index c62a5b55ac3..8261e32d668 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch from aiohttp import ClientResponseError +import pytest from yalexs.authenticator_common import AuthenticationState from yalexs.exceptions import AugustApiAIOHTTPError @@ -12,6 +13,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, STATE_ON, @@ -162,6 +164,17 @@ async def test_lock_throws_august_api_http_error(hass: HomeAssistant) -> None: ) +async def test_open_throws_hass_service_not_supported_error( + hass: HomeAssistant, +) -> None: + """Test open throws correct error on entity does not support this service error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + await _create_august_with_devices(hass, [mocked_lock_detail]) + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) + + async def test_inoperative_locks_are_filtered_out(hass: HomeAssistant) -> None: """Ensure inoperative locks do not get setup.""" august_operative_lock = await _mock_operative_august_lock_detail(hass) diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 4de931e6979..8bb71826d24 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,9 +6,9 @@ from unittest.mock import Mock from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest +from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from yalexs.pubnub_async import AugustPubNub -from homeassistant.components.august.activity import INITIAL_LOCK_RESYNC_TIME from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, STATE_JAMMED, @@ -18,6 +18,7 @@ from homeassistant.components.lock import ( from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, STATE_UNAVAILABLE, @@ -25,6 +26,7 @@ from homeassistant.const import ( STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util @@ -33,6 +35,8 @@ from .mocks import ( _mock_activities_from_fixture, _mock_doorsense_enabled_august_lock_detail, _mock_lock_from_fixture, + _mock_lock_with_unlatch, + _mock_operative_august_lock_detail, ) from tests.common import async_fire_time_changed @@ -156,6 +160,60 @@ async def test_one_lock_operation( ) +async def test_open_lock_operation(hass: HomeAssistant) -> None: + """Test open lock operation using the open service.""" + lock_with_unlatch = await _mock_lock_with_unlatch(hass) + await _create_august_with_devices(hass, [lock_with_unlatch]) + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_LOCKED + + data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) + await hass.async_block_till_done() + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + + +async def test_open_lock_operation_pubnub_connected( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test open lock operation using the open service when pubnub is connected.""" + lock_with_unlatch = await _mock_lock_with_unlatch(hass) + assert lock_with_unlatch.pubsub_channel == "pubsub" + + pubnub = AugustPubNub() + await _create_august_with_devices(hass, [lock_with_unlatch], pubnub=pubnub) + pubnub.connected = True + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_LOCKED + + data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) + await hass.async_block_till_done() + + pubnub.message( + pubnub, + Mock( + channel=lock_with_unlatch.pubsub_channel, + timetoken=(dt_util.utcnow().timestamp() + 2) * 10000000, + message={ + "status": "kAugLockState_Unlocked", + }, + ), + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + await hass.async_block_till_done() + + async def test_one_lock_operation_pubnub_connected( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -315,7 +373,6 @@ async def test_lock_throws_exception_on_unknown_status_code( data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} with pytest.raises(ClientResponseError): await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() async def test_one_lock_unknown_state(hass: HomeAssistant) -> None: @@ -449,3 +506,14 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() + + +async def test_open_throws_hass_service_not_supported_error( + hass: HomeAssistant, +) -> None: + """Test open throws correct error on entity does not support this service error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + await _create_august_with_devices(hass, [mocked_lock_detail]) + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} + with pytest.raises(HomeAssistantError, match="does not support this service"): + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) diff --git a/tests/components/aurora/__init__.py b/tests/components/aurora/__init__.py index 4ce9649eff9..eca5281f631 100644 --- a/tests/components/aurora/__init__.py +++ b/tests/components/aurora/__init__.py @@ -1 +1,12 @@ -"""The tests for the Aurora sensor platform.""" +"""The tests for the Aurora integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/aurora/conftest.py b/tests/components/aurora/conftest.py new file mode 100644 index 00000000000..916f0925c4a --- /dev/null +++ b/tests/components/aurora/conftest.py @@ -0,0 +1,55 @@ +"""Common fixtures for the Aurora tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.aurora.const import CONF_THRESHOLD, DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aurora.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_aurora_client() -> Generator[AsyncMock]: + """Mock a Homeassistant Analytics client.""" + with ( + patch( + "homeassistant.components.aurora.coordinator.AuroraForecast", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.aurora.config_flow.AuroraForecast", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_forecast_data.return_value = 42 + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Aurora visibility", + data={ + CONF_LATITUDE: -10, + CONF_LONGITUDE: 10.2, + }, + options={ + CONF_THRESHOLD: 75, + }, + ) diff --git a/tests/components/aurora/test_config_flow.py b/tests/components/aurora/test_config_flow.py index a91c4eb8bc9..710f4d607d2 100644 --- a/tests/components/aurora/test_config_flow.py +++ b/tests/components/aurora/test_config_flow.py @@ -1,117 +1,100 @@ """Test the Aurora config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from aiohttp import ClientError +import pytest -from homeassistant import config_entries -from homeassistant.components.aurora.const import DOMAIN +from homeassistant.components.aurora.const import CONF_THRESHOLD, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry DATA = { - "latitude": -10, - "longitude": 10.2, + CONF_LATITUDE: -10, + CONF_LONGITUDE: 10.2, } -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_aurora_client: AsyncMock +) -> None: + """Test full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.aurora.config_flow.AuroraForecast.get_forecast_data", - return_value=True, - ), - patch( - "homeassistant.components.aurora.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"], DATA) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Aurora visibility" - assert result2["data"] == DATA + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Aurora visibility" + assert result["data"] == DATA assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aurora_client: AsyncMock, + side_effect: Exception, + error: str, +) -> None: """Test if invalid response or no connection returned from the API.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.aurora.AuroraForecast.get_forecast_data", - side_effect=ClientError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - DATA, - ) + mock_aurora_client.get_forecast_data.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"], DATA) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} + + mock_aurora_client.get_forecast_data.side_effect = None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], DATA) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_with_unknown_error(hass: HomeAssistant) -> None: - """Test with unknown error response from the API.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.aurora.AuroraForecast.get_forecast_data", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - DATA, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "unknown"} - - -async def test_option_flow(hass: HomeAssistant) -> None: +async def test_option_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_aurora_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test option flow.""" - entry = MockConfigEntry(domain=DOMAIN, data=DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - assert not entry.options - - with patch("homeassistant.components.aurora.async_setup_entry", return_value=True): - 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, - data=None, - ) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"forecast_threshold": 65}, + user_input={CONF_THRESHOLD: 65}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["forecast_threshold"] == 65 + assert result["data"][CONF_THRESHOLD] == 65 diff --git a/tests/components/aussie_broadband/common.py b/tests/components/aussie_broadband/common.py index 1c992d116d1..a2bc79a42a6 100644 --- a/tests/components/aussie_broadband/common.py +++ b/tests/components/aussie_broadband/common.py @@ -1,12 +1,15 @@ """Aussie Broadband common helpers for tests.""" +from typing import Any from unittest.mock import patch from homeassistant.components.aussie_broadband.const import ( CONF_SERVICES, DOMAIN as AUSSIE_BROADBAND_DOMAIN, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry @@ -38,7 +41,11 @@ FAKE_DATA = { async def setup_platform( - hass, platforms=[], side_effect=None, usage={}, usage_effect=None + hass: HomeAssistant, + platforms: list[Platform] | UndefinedType = UNDEFINED, + side_effect=None, + usage: dict[str, Any] | UndefinedType = UNDEFINED, + usage_effect=None, ): """Set up the Aussie Broadband platform.""" mock_entry = MockConfigEntry( @@ -51,7 +58,10 @@ async def setup_platform( mock_entry.add_to_hass(hass) with ( - patch("homeassistant.components.aussie_broadband.PLATFORMS", platforms), + patch( + "homeassistant.components.aussie_broadband.PLATFORMS", + [] if platforms is UNDEFINED else platforms, + ), patch("aussiebb.asyncio.AussieBB.__init__", return_value=None), patch( "aussiebb.asyncio.AussieBB.login", @@ -65,7 +75,7 @@ async def setup_platform( ), patch( "aussiebb.asyncio.AussieBB.get_usage", - return_value=usage, + return_value={} if usage is UNDEFINED else usage, side_effect=usage_effect, ), ): diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index 18904cb2710..7b48855493e 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -4,6 +4,7 @@ from typing import Any from homeassistant import auth from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_setup_component from tests.common import ensure_auth_manager_loaded @@ -26,14 +27,16 @@ EMPTY_CONFIG = [] async def async_setup_auth( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, - provider_configs: list[dict[str, Any]] = BASE_CONFIG, - module_configs=EMPTY_CONFIG, + provider_configs: list[dict[str, Any]] | UndefinedType = UNDEFINED, + module_configs: list[dict[str, Any]] | UndefinedType = UNDEFINED, setup_api: bool = False, custom_ip: str | None = None, ): """Set up authentication and create an HTTP client.""" hass.auth = await auth.auth_manager_from_config( - hass, provider_configs, module_configs + hass, + BASE_CONFIG if provider_configs is UNDEFINED else provider_configs, + EMPTY_CONFIG if module_configs is UNDEFINED else module_configs, ) ensure_auth_manager_loaded(hass.auth) await async_setup_component(hass, "auth", {}) diff --git a/tests/components/auth/conftest.py b/tests/components/auth/conftest.py index a17661f5635..c7c92411ce8 100644 --- a/tests/components/auth/conftest.py +++ b/tests/components/auth/conftest.py @@ -1,9 +1,17 @@ """Test configuration for auth.""" +from asyncio import AbstractEventLoop + import pytest +from tests.typing import ClientSessionGenerator + @pytest.fixture -def aiohttp_client(event_loop, aiohttp_client, socket_enabled): +def aiohttp_client( + event_loop: AbstractEventLoop, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, +) -> ClientSessionGenerator: """Return aiohttp_client and allow opening sockets.""" return aiohttp_client diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index c6f03f8bd64..d0ca4699e0e 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -546,20 +546,21 @@ async def test_ws_delete_all_refresh_tokens_error( tokens = result["result"] - await ws_client.send_json( - { - "id": 6, - "type": "auth/delete_all_refresh_tokens", - } - ) + with patch("homeassistant.components.auth.DELETE_CURRENT_TOKEN_DELAY", 0.001): + await ws_client.send_json( + { + "id": 6, + "type": "auth/delete_all_refresh_tokens", + } + ) - caplog.clear() - result = await ws_client.receive_json() - assert result, result["success"] is False - assert result["error"] == { - "code": "token_removing_error", - "message": "During removal, an error was raised.", - } + caplog.clear() + result = await ws_client.receive_json() + assert result, result["success"] is False + assert result["error"] == { + "code": "token_removing_error", + "message": "During removal, an error was raised.", + } records = [ record @@ -571,6 +572,7 @@ async def test_ws_delete_all_refresh_tokens_error( assert records[0].exc_info and str(records[0].exc_info[1]) == "I'm bad" assert records[0].name == "homeassistant.components.auth" + await hass.async_block_till_done() for token in tokens: refresh_token = hass.auth.async_get_refresh_token(token["id"]) assert refresh_token is None @@ -629,18 +631,20 @@ async def test_ws_delete_all_refresh_tokens( result = await ws_client.receive_json() assert result["success"], result - await ws_client.send_json( - { - "id": 6, - "type": "auth/delete_all_refresh_tokens", - **delete_token_type, - **delete_current_token, - } - ) + with patch("homeassistant.components.auth.DELETE_CURRENT_TOKEN_DELAY", 0.001): + await ws_client.send_json( + { + "id": 6, + "type": "auth/delete_all_refresh_tokens", + **delete_token_type, + **delete_current_token, + } + ) - result = await ws_client.receive_json() - assert result, result["success"] + result = await ws_client.receive_json() + assert result, result["success"] + await hass.async_block_till_done() # We need to enumerate the user since we may remove the token # that is used to authenticate the user which will prevent the websocket # connection from working @@ -686,3 +690,72 @@ async def test_ws_sign_path( hass, path, expires = mock_sign.mock_calls[0][1] assert path == "/api/hello" assert expires.total_seconds() == 20 + + +async def test_ws_refresh_token_set_expiry( + hass: HomeAssistant, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, + hass_ws_client: WebSocketGenerator, + hass_access_token: str, +) -> None: + """Test setting expiry of a refresh token.""" + assert await async_setup_component(hass, "auth", {"http": {}}) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + assert refresh_token.expire_at is not None + ws_client = await hass_ws_client(hass, hass_access_token) + + await ws_client.send_json_auto_id( + { + "type": "auth/refresh_token_set_expiry", + "refresh_token_id": refresh_token.id, + "enable_expiry": False, + } + ) + + result = await ws_client.receive_json() + assert result["success"], result + refresh_token = hass.auth.async_get_refresh_token(refresh_token.id) + assert refresh_token.expire_at is None + + await ws_client.send_json_auto_id( + { + "type": "auth/refresh_token_set_expiry", + "refresh_token_id": refresh_token.id, + "enable_expiry": True, + } + ) + + result = await ws_client.receive_json() + assert result["success"], result + refresh_token = hass.auth.async_get_refresh_token(refresh_token.id) + assert refresh_token.expire_at is not None + + +async def test_ws_refresh_token_set_expiry_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_access_token: str, +) -> None: + """Test setting expiry of a invalid refresh token returns error.""" + assert await async_setup_component(hass, "auth", {"http": {}}) + + ws_client = await hass_ws_client(hass, hass_access_token) + + await ws_client.send_json_auto_id( + { + "type": "auth/refresh_token_set_expiry", + "refresh_token_id": "invalid", + "enable_expiry": False, + } + ) + + result = await ws_client.receive_json() + assert result, result["success"] is False + assert result["error"] == { + "code": "invalid_token_id", + "message": "Received invalid token", + } diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index 7e29c134462..ee3fa631d00 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -4,6 +4,7 @@ import asyncio import contextlib from datetime import timedelta import pathlib +from typing import Any from unittest.mock import patch import pytest @@ -56,12 +57,12 @@ async def test_notify_leaving_zone( connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, ) - def set_person_state(state, extra={}): + def set_person_state(state: str, extra: dict[str, Any]) -> None: hass.states.async_set( "person.test_person", state, {"friendly_name": "Paulus", **extra} ) - set_person_state("School") + set_person_state("School", {}) assert await async_setup_component( hass, "zone", {"zone": {"name": "School", "latitude": 1, "longitude": 2}} @@ -92,7 +93,7 @@ async def test_notify_leaving_zone( "homeassistant.components.mobile_app.device_action.async_call_action_from_config" ) as mock_call_action: # Leaving zone to no zone - set_person_state("not_home") + set_person_state("not_home", {}) await hass.async_block_till_done() assert len(mock_call_action.mock_calls) == 1 @@ -108,13 +109,13 @@ async def test_notify_leaving_zone( assert message_tpl.async_render(variables) == "Paulus has left School" # Should not increase when we go to another zone - set_person_state("bla") + set_person_state("bla", {}) await hass.async_block_till_done() assert len(mock_call_action.mock_calls) == 1 # Should not increase when we go into the zone - set_person_state("School") + set_person_state("School", {}) await hass.async_block_till_done() assert len(mock_call_action.mock_calls) == 1 @@ -126,7 +127,7 @@ async def test_notify_leaving_zone( assert len(mock_call_action.mock_calls) == 1 # Should increase when leaving zone for another zone - set_person_state("Just Outside School") + set_person_state("Just Outside School", {}) await hass.async_block_till_done() assert len(mock_call_action.mock_calls) == 2 diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index edf0eff878b..0c300540644 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -4,7 +4,7 @@ import asyncio from datetime import timedelta import logging from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest @@ -72,13 +72,13 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") async def test_service_data_not_a_dict( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, calls + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, calls: list[ServiceCall] ) -> None: """Test service data not dict.""" with assert_setup_component(1, automation.DOMAIN): @@ -99,7 +99,9 @@ async def test_service_data_not_a_dict( assert "Result is not a Dictionary" in caplog.text -async def test_service_data_single_template(hass: HomeAssistant, calls) -> None: +async def test_service_data_single_template( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test service data not dict.""" with assert_setup_component(1, automation.DOMAIN): assert await async_setup_component( @@ -122,7 +124,9 @@ async def test_service_data_single_template(hass: HomeAssistant, calls) -> None: assert calls[0].data["foo"] == "bar" -async def test_service_specify_data(hass: HomeAssistant, calls) -> None: +async def test_service_specify_data( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test service data.""" assert await async_setup_component( hass, @@ -156,7 +160,9 @@ async def test_service_specify_data(hass: HomeAssistant, calls) -> None: assert state.attributes.get("last_triggered") == time -async def test_service_specify_entity_id(hass: HomeAssistant, calls) -> None: +async def test_service_specify_entity_id( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test service data.""" assert await async_setup_component( hass, @@ -175,7 +181,9 @@ async def test_service_specify_entity_id(hass: HomeAssistant, calls) -> None: assert ["hello.world"] == calls[0].data.get(ATTR_ENTITY_ID) -async def test_service_specify_entity_id_list(hass: HomeAssistant, calls) -> None: +async def test_service_specify_entity_id_list( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test service data.""" assert await async_setup_component( hass, @@ -197,7 +205,7 @@ async def test_service_specify_entity_id_list(hass: HomeAssistant, calls) -> Non assert ["hello.world", "hello.world2"] == calls[0].data.get(ATTR_ENTITY_ID) -async def test_two_triggers(hass: HomeAssistant, calls) -> None: +async def test_two_triggers(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test triggers.""" assert await async_setup_component( hass, @@ -222,7 +230,7 @@ async def test_two_triggers(hass: HomeAssistant, calls) -> None: async def test_trigger_service_ignoring_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, calls + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, calls: list[ServiceCall] ) -> None: """Test triggers.""" assert await async_setup_component( @@ -274,7 +282,9 @@ async def test_trigger_service_ignoring_condition( assert len(calls) == 2 -async def test_two_conditions_with_and(hass: HomeAssistant, calls) -> None: +async def test_two_conditions_with_and( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test two and conditions.""" entity_id = "test.entity" assert await async_setup_component( @@ -312,7 +322,9 @@ async def test_two_conditions_with_and(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_shorthand_conditions_template(hass: HomeAssistant, calls) -> None: +async def test_shorthand_conditions_template( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test shorthand nation form in conditions.""" assert await async_setup_component( hass, @@ -337,7 +349,9 @@ async def test_shorthand_conditions_template(hass: HomeAssistant, calls) -> None assert len(calls) == 1 -async def test_automation_list_setting(hass: HomeAssistant, calls) -> None: +async def test_automation_list_setting( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Event is not a valid condition.""" assert await async_setup_component( hass, @@ -365,7 +379,9 @@ async def test_automation_list_setting(hass: HomeAssistant, calls) -> None: assert len(calls) == 2 -async def test_automation_calling_two_actions(hass: HomeAssistant, calls) -> None: +async def test_automation_calling_two_actions( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test if we can call two actions from automation async definition.""" assert await async_setup_component( hass, @@ -389,7 +405,7 @@ async def test_automation_calling_two_actions(hass: HomeAssistant, calls) -> Non assert calls[1].data["position"] == 1 -async def test_shared_context(hass: HomeAssistant, calls) -> None: +async def test_shared_context(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test that the shared context is passed down the chain.""" assert await async_setup_component( hass, @@ -456,7 +472,7 @@ async def test_shared_context(hass: HomeAssistant, calls) -> None: assert calls[0].context is second_trigger_context -async def test_services(hass: HomeAssistant, calls) -> None: +async def test_services(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the automation services for turning entities on/off.""" entity_id = "automation.hello" @@ -539,7 +555,10 @@ async def test_services(hass: HomeAssistant, calls) -> None: async def test_reload_config_service( - hass: HomeAssistant, calls, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + calls: list[ServiceCall], + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test the reload config service.""" assert await async_setup_component( @@ -618,7 +637,9 @@ async def test_reload_config_service( assert calls[1].data.get("event") == "test_event2" -async def test_reload_config_when_invalid_config(hass: HomeAssistant, calls) -> None: +async def test_reload_config_when_invalid_config( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the reload config service handling invalid config.""" with assert_setup_component(1, automation.DOMAIN): assert await async_setup_component( @@ -657,7 +678,9 @@ async def test_reload_config_when_invalid_config(hass: HomeAssistant, calls) -> assert len(calls) == 1 -async def test_reload_config_handles_load_fails(hass: HomeAssistant, calls) -> None: +async def test_reload_config_handles_load_fails( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the reload config service.""" assert await async_setup_component( hass, @@ -697,7 +720,9 @@ async def test_reload_config_handles_load_fails(hass: HomeAssistant, calls) -> N @pytest.mark.parametrize( "service", ["turn_off_stop", "turn_off_no_stop", "reload", "reload_single"] ) -async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: +async def test_automation_stops( + hass: HomeAssistant, calls: list[ServiceCall], service: str +) -> None: """Test that turning off / reloading stops any running actions as appropriate.""" entity_id = "automation.hello" test_entity = "test.entity" @@ -774,7 +799,7 @@ async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: @pytest.mark.parametrize("extra_config", [{}, {"id": "sun"}]) async def test_reload_unchanged_does_not_stop( - hass: HomeAssistant, calls, extra_config + hass: HomeAssistant, calls: list[ServiceCall], extra_config: dict[str, str] ) -> None: """Test that reloading stops any running actions as appropriate.""" test_entity = "test.entity" @@ -820,7 +845,7 @@ async def test_reload_unchanged_does_not_stop( async def test_reload_single_unchanged_does_not_stop( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test that reloading stops any running actions as appropriate.""" test_entity = "test.entity" @@ -870,7 +895,9 @@ async def test_reload_single_unchanged_does_not_stop( assert len(calls) == 1 -async def test_reload_single_add_automation(hass: HomeAssistant, calls) -> None: +async def test_reload_single_add_automation( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test that reloading a single automation.""" config1 = {automation.DOMAIN: {}} config2 = { @@ -904,7 +931,9 @@ async def test_reload_single_add_automation(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_reload_single_parallel_calls(hass: HomeAssistant, calls) -> None: +async def test_reload_single_parallel_calls( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test reloading single automations in parallel.""" config1 = {automation.DOMAIN: {}} config2 = { @@ -1017,7 +1046,9 @@ async def test_reload_single_parallel_calls(hass: HomeAssistant, calls) -> None: assert len(calls) == 4 -async def test_reload_single_remove_automation(hass: HomeAssistant, calls) -> None: +async def test_reload_single_remove_automation( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test that reloading a single automation.""" config1 = { automation.DOMAIN: { @@ -1052,7 +1083,7 @@ async def test_reload_single_remove_automation(hass: HomeAssistant, calls) -> No async def test_reload_moved_automation_without_alias( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test that changing the order of automations without alias triggers reload.""" with patch( @@ -1107,7 +1138,7 @@ async def test_reload_moved_automation_without_alias( async def test_reload_identical_automations_without_id( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test reloading of identical automations without id.""" with patch( @@ -1282,7 +1313,7 @@ async def test_reload_identical_automations_without_id( ], ) async def test_reload_unchanged_automation( - hass: HomeAssistant, calls, automation_config + hass: HomeAssistant, calls: list[ServiceCall], automation_config: dict[str, Any] ) -> None: """Test an unmodified automation is not reloaded.""" with patch( @@ -1317,7 +1348,7 @@ async def test_reload_unchanged_automation( @pytest.mark.parametrize("extra_config", [{}, {"id": "sun"}]) async def test_reload_automation_when_blueprint_changes( - hass: HomeAssistant, calls, extra_config + hass: HomeAssistant, calls: list[ServiceCall], extra_config: dict[str, str] ) -> None: """Test an automation is updated at reload if the blueprint has changed.""" with patch( @@ -1614,12 +1645,13 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("broken_config", "problem", "details"), + ("broken_config", "problem", "details", "issue"), [ ( {}, "could not be validated", "required key not provided @ data['action']", + "validation_failed_schema", ), ( { @@ -1628,6 +1660,7 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: }, "failed to setup triggers", "Integration 'automation' does not provide trigger support.", + "validation_failed_triggers", ), ( { @@ -1642,6 +1675,7 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: }, "failed to setup conditions", "Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.", + "validation_failed_conditions", ), ( { @@ -1655,15 +1689,19 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: }, "failed to setup actions", "Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.", + "validation_failed_actions", ), ], ) async def test_automation_bad_config_validation( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, - broken_config, - problem, - details, + hass_admin_user: MockUser, + broken_config: dict[str, Any], + problem: str, + details: str, + issue: str, ) -> None: """Test bad automation configuration which can be detected during validation.""" assert await async_setup_component( @@ -1684,11 +1722,22 @@ async def test_automation_bad_config_validation( }, ) - # Check we get the expected error message + # Check we get the expected error message and issue assert ( f"Automation with alias 'bad_automation' {problem} and has been disabled:" f" {details}" ) in caplog.text + issues = await get_repairs(hass, hass_ws_client) + assert len(issues) == 1 + assert issues[0]["issue_id"] == f"automation.bad_automation_{issue}" + assert issues[0]["translation_key"] == issue + assert issues[0]["translation_placeholders"] == { + "edit": "/config/automation/edit/None", + "entity_id": "automation.bad_automation", + "error": ANY, + "name": "bad_automation", + } + assert issues[0]["translation_placeholders"]["error"].startswith(details) # Make sure both automations are setup assert set(hass.states.async_entity_ids("automation")) == { @@ -1698,6 +1747,30 @@ async def test_automation_bad_config_validation( # The automation failing validation should be unavailable assert hass.states.get("automation.bad_automation").state == STATE_UNAVAILABLE + # Reloading the automation with fixed config should clear the issue + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value={ + automation.DOMAIN: { + "alias": "bad_automation", + "trigger": {"platform": "event", "event_type": "test_event2"}, + "action": { + "service": "test.automation", + "data_template": {"event": "{{ trigger.event.event_type }}"}, + }, + } + }, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + context=Context(user_id=hass_admin_user.id), + blocking=True, + ) + issues = await get_repairs(hass, hass_ws_client) + assert len(issues) == 0 + async def test_automation_with_error_in_script( hass: HomeAssistant, @@ -2409,7 +2482,9 @@ async def test_automation_this_var_always( assert "Error rendering variables" not in caplog.text -async def test_blueprint_automation(hass: HomeAssistant, calls) -> None: +async def test_blueprint_automation( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test blueprint automation.""" assert await async_setup_component( hass, @@ -2474,6 +2549,7 @@ async def test_blueprint_automation(hass: HomeAssistant, calls) -> None: ) async def test_blueprint_automation_bad_config( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, blueprint_inputs, problem, @@ -2495,9 +2571,24 @@ async def test_blueprint_automation_bad_config( assert problem in caplog.text assert details in caplog.text + issues = await get_repairs(hass, hass_ws_client) + assert len(issues) == 1 + issue = "validation_failed_blueprint" + assert issues[0]["issue_id"] == f"automation.automation_0_{issue}" + assert issues[0]["translation_key"] == issue + assert issues[0]["translation_placeholders"] == { + "edit": "/config/automation/edit/None", + "entity_id": "automation.automation_0", + "error": ANY, + "name": "automation 0", + } + assert issues[0]["translation_placeholders"]["error"].startswith(details) + async def test_blueprint_automation_fails_substitution( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test blueprint automation with bad inputs.""" with patch( @@ -2526,8 +2617,20 @@ async def test_blueprint_automation_fails_substitution( " 'a_number': 5}: No substitution found for input blah" ) in caplog.text + issues = await get_repairs(hass, hass_ws_client) + assert len(issues) == 1 + issue = "validation_failed_blueprint" + assert issues[0]["issue_id"] == f"automation.automation_0_{issue}" + assert issues[0]["translation_key"] == issue + assert issues[0]["translation_placeholders"] == { + "edit": "/config/automation/edit/None", + "entity_id": "automation.automation_0", + "error": "No substitution found for input blah", + "name": "automation 0", + } -async def test_trigger_service(hass: HomeAssistant, calls) -> None: + +async def test_trigger_service(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the automation trigger service.""" assert await async_setup_component( hass, @@ -2557,7 +2660,9 @@ async def test_trigger_service(hass: HomeAssistant, calls) -> None: assert calls[0].context.parent_id is context.id -async def test_trigger_condition_implicit_id(hass: HomeAssistant, calls) -> None: +async def test_trigger_condition_implicit_id( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test triggers.""" assert await async_setup_component( hass, @@ -2607,7 +2712,9 @@ async def test_trigger_condition_implicit_id(hass: HomeAssistant, calls) -> None assert calls[-1].data.get("param") == "one" -async def test_trigger_condition_explicit_id(hass: HomeAssistant, calls) -> None: +async def test_trigger_condition_explicit_id( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test triggers.""" assert await async_setup_component( hass, @@ -2734,6 +2841,7 @@ async def test_recursive_automation_starting_script( ], "action": [ {"service": "test.automation_started"}, + {"delay": 0.001}, {"service": "script.script1"}, ], } @@ -2780,7 +2888,10 @@ async def test_recursive_automation_starting_script( assert script_warning_msg in caplog.text -@pytest.mark.parametrize("automation_mode", SCRIPT_MODE_CHOICES) +@pytest.mark.parametrize( + "automation_mode", + [mode for mode in SCRIPT_MODE_CHOICES if mode != SCRIPT_MODE_RESTART], +) @pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_automation( hass: HomeAssistant, automation_mode, caplog: pytest.LogCaptureFixture @@ -2841,6 +2952,68 @@ async def test_recursive_automation( assert "Disallowed recursion detected" not in caplog.text +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) +async def test_recursive_automation_restart_mode( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test automation restarting itself. + + The automation is an infinite loop since it keeps restarting itself + + - Illegal recursion detection should not be triggered + - Home Assistant should not hang on shut down + """ + stop_scripts_at_shutdown_called = asyncio.Event() + real_stop_scripts_at_shutdown = _async_stop_scripts_at_shutdown + + async def stop_scripts_at_shutdown(*args): + await real_stop_scripts_at_shutdown(*args) + stop_scripts_at_shutdown_called.set() + + with patch( + "homeassistant.helpers.script._async_stop_scripts_at_shutdown", + wraps=stop_scripts_at_shutdown, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "mode": SCRIPT_MODE_RESTART, + "trigger": [ + {"platform": "event", "event_type": "trigger_automation"}, + ], + "action": [ + {"event": "trigger_automation"}, + {"service": "test.automation_done"}, + ], + } + }, + ) + + service_called = asyncio.Event() + + async def async_service_handler(service): + if service.service == "automation_done": + service_called.set() + + hass.services.async_register("test", "automation_done", async_service_handler) + + hass.bus.async_fire("trigger_automation") + await asyncio.sleep(0) + + # Trigger 1st stage script shutdown + hass.set_state(CoreState.stopping) + hass.bus.async_fire("homeassistant_stop") + await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 1) + + # Trigger 2nd stage script shutdown + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) + await hass.async_block_till_done() + + assert "Disallowed recursion detected" not in caplog.text + + async def test_websocket_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -2903,9 +3076,7 @@ def test_deprecated_constants( ) -async def test_automation_turns_off_other_automation( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> None: """Test an automation that turns off another automation.""" hass.set_state(CoreState.not_running) calls = async_mock_service(hass, "persistent_notification", "create") @@ -2984,7 +3155,7 @@ async def test_automation_turns_off_other_automation( async def test_two_automations_call_restart_script_same_time( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Test two automations that call a restart mode script at the same.""" hass.states.async_set("binary_sensor.presence", "off") @@ -3060,3 +3231,72 @@ async def test_two_automations_call_restart_script_same_time( await hass.async_block_till_done() assert len(events) == 2 cancel() + + +async def test_two_automation_call_restart_script_right_after_each_other( + hass: HomeAssistant, +) -> None: + """Test two automations call a restart script right after each other.""" + + events = async_capture_events(hass, "repeat_test_script_finished") + + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + { + input_boolean.DOMAIN: { + "test_1": None, + "test_2": None, + } + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "state", + "entity_id": ["input_boolean.test_1", "input_boolean.test_1"], + "from": "off", + "to": "on", + }, + "action": [ + { + "repeat": { + "count": 2, + "sequence": [ + { + "delay": { + "hours": 0, + "minutes": 0, + "seconds": 0, + "milliseconds": 100, + } + } + ], + } + }, + {"event": "repeat_test_script_finished", "event_data": {}}, + ], + "id": "automation_0", + "mode": "restart", + }, + ] + }, + ) + hass.states.async_set("input_boolean.test_1", "off") + hass.states.async_set("input_boolean.test_2", "off") + await hass.async_block_till_done() + hass.states.async_set("input_boolean.test_1", "on") + hass.states.async_set("input_boolean.test_2", "on") + await asyncio.sleep(0) + hass.states.async_set("input_boolean.test_1", "off") + hass.states.async_set("input_boolean.test_2", "off") + await asyncio.sleep(0) + hass.states.async_set("input_boolean.test_1", "on") + hass.states.async_set("input_boolean.test_2", "on") + await hass.async_block_till_done() + assert len(events) == 1 diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index c983cc949ad..fc45e6aee5b 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -15,7 +15,7 @@ from homeassistant.components.automation import ( from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -24,13 +24,13 @@ from tests.components.recorder.common import async_wait_recording_done @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, calls + recorder_mock: Recorder, hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test automation registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 7a4e446a0cc..b306e25c434 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import Callable from copy import deepcopy from types import MappingProxyType from typing import Any @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, patch from axis.rtsp import Signal, State import pytest import respx +from typing_extensions import Generator from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -49,7 +50,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.axis.async_setup_entry", return_value=True @@ -110,10 +111,10 @@ def config_entry_options_fixture() -> MappingProxyType[str, Any]: @pytest.fixture(name="mock_vapix_requests") def default_request_fixture( - respx_mock: respx, + respx_mock: respx.MockRouter, port_management_payload: dict[str, Any], - param_properties_payload: dict[str, Any], - param_ports_payload: dict[str, Any], + param_properties_payload: str, + param_ports_payload: str, mqtt_status_code: int, ) -> Callable[[str], None]: """Mock default Vapix requests responses.""" @@ -229,19 +230,19 @@ def io_port_management_data_fixture() -> dict[str, Any]: @pytest.fixture(name="param_properties_payload") -def param_properties_data_fixture() -> dict[str, Any]: +def param_properties_data_fixture() -> str: """Property parameter data.""" return PROPERTIES_RESPONSE @pytest.fixture(name="param_ports_payload") -def param_ports_data_fixture() -> dict[str, Any]: +def param_ports_data_fixture() -> str: """Property parameter data.""" return PORTS_RESPONSE @pytest.fixture(name="mqtt_status_code") -def mqtt_status_code_fixture(): +def mqtt_status_code_fixture() -> int: """Property parameter data.""" return 200 @@ -280,7 +281,7 @@ async def setup_config_entry_fixture( @pytest.fixture(autouse=True) -def mock_axis_rtspclient() -> Generator[Callable[[dict | None, str], None], None, None]: +def mock_axis_rtspclient() -> Generator[Callable[[dict | None, str], None]]: """No real RTSP communication allowed.""" with patch("axis.stream_manager.RTSPClient") as rtsp_client_mock: rtsp_client_mock.return_value.session.state = State.STOPPED diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py index dd7674d7d3f..99a530724e3 100644 --- a/tests/components/axis/test_binary_sensor.py +++ b/tests/components/axis/test_binary_sensor.py @@ -1,6 +1,7 @@ """Axis binary sensor platform tests.""" from collections.abc import Callable +from typing import Any import pytest @@ -8,7 +9,6 @@ from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -173,12 +173,12 @@ from .const import NAME ), ], ) +@pytest.mark.usefixtures("setup_config_entry") async def test_binary_sensors( hass: HomeAssistant, - setup_config_entry: ConfigEntry, mock_rtsp_event: Callable[[str, str, str, str, str, str], None], event: dict[str, str], - entity: dict[str, str], + entity: dict[str, Any], ) -> None: """Test that sensors are loaded properly.""" mock_rtsp_event(**event) @@ -225,9 +225,9 @@ async def test_binary_sensors( }, ], ) +@pytest.mark.usefixtures("setup_config_entry") async def test_unsupported_events( hass: HomeAssistant, - setup_config_entry: ConfigEntry, mock_rtsp_event: Callable[[str, str, str, str, str, str], None], event: dict[str, str], ) -> None: diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index e184f2014b3..7d26cc7a3bc 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -30,7 +30,8 @@ async def test_platform_manually_configured(hass: HomeAssistant) -> None: assert AXIS_DOMAIN not in hass.data -async def test_camera(hass: HomeAssistant, setup_config_entry: ConfigEntry) -> None: +@pytest.mark.usefixtures("setup_config_entry") +async def test_camera(hass: HomeAssistant) -> None: """Test that Axis camera platform is loaded properly.""" assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 1 @@ -50,9 +51,8 @@ async def test_camera(hass: HomeAssistant, setup_config_entry: ConfigEntry) -> N @pytest.mark.parametrize("config_entry_options", [{CONF_STREAM_PROFILE: "profile_1"}]) -async def test_camera_with_stream_profile( - hass: HomeAssistant, setup_config_entry: ConfigEntry -) -> None: +@pytest.mark.usefixtures("setup_config_entry") +async def test_camera_with_stream_profile(hass: HomeAssistant) -> None: """Test that Axis camera entity is using the correct path with stream profike.""" assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 1 @@ -74,7 +74,7 @@ async def test_camera_with_stream_profile( ) -property_data = f"""root.Properties.API.HTTP.Version=3 +PROPERTY_DATA = f"""root.Properties.API.HTTP.Version=3 root.Properties.API.Metadata.Metadata=yes root.Properties.API.Metadata.Version=1.0 root.Properties.EmbeddedDevelopment.Version=2.16 @@ -85,7 +85,7 @@ root.Properties.System.SerialNumber={MAC} """ -@pytest.mark.parametrize("param_properties_payload", [property_data]) +@pytest.mark.parametrize("param_properties_payload", [PROPERTY_DATA]) async def test_camera_disabled( hass: HomeAssistant, prepare_config_entry: Callable[[], ConfigEntry] ) -> None: diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 68dca3539c5..055c74cc9a5 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -1,7 +1,8 @@ """Test Axis config flow.""" +from collections.abc import Callable from ipaddress import ip_address -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -22,6 +23,7 @@ from homeassistant.config_entries import ( SOURCE_SSDP, SOURCE_USER, SOURCE_ZEROCONF, + ConfigEntry, ) from homeassistant.const import ( CONF_HOST, @@ -33,7 +35,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType from homeassistant.helpers import device_registry as dr from .const import DEFAULT_HOST, MAC, MODEL, NAME @@ -44,16 +46,17 @@ DHCP_FORMATTED_MAC = dr.format_mac(MAC).replace(":", "") @pytest.fixture(name="mock_config_entry") -async def mock_config_entry_fixture(hass, config_entry, mock_setup_entry): +async def mock_config_entry_fixture( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_setup_entry: AsyncMock +) -> MockConfigEntry: """Mock config entry and setup entry.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry -async def test_flow_manual_configuration( - hass: HomeAssistant, setup_default_vapix_requests, mock_setup_entry -) -> None: +@pytest.mark.usefixtures("setup_default_vapix_requests", "mock_setup_entry") +async def test_flow_manual_configuration(hass: HomeAssistant) -> None: """Test that config flow works.""" MockConfigEntry(domain=AXIS_DOMAIN, source=SOURCE_IGNORE).add_to_hass(hass) @@ -89,7 +92,9 @@ async def test_flow_manual_configuration( async def test_manual_configuration_update_configuration( - hass: HomeAssistant, mock_config_entry, mock_vapix_requests + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vapix_requests: Callable[[str], None], ) -> None: """Test that config flow fails on already configured device.""" assert mock_config_entry.data[CONF_HOST] == "1.2.3.4" @@ -173,8 +178,9 @@ async def test_flow_fails_cannot_connect(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} +@pytest.mark.usefixtures("setup_default_vapix_requests", "mock_setup_entry") async def test_flow_create_entry_multiple_existing_entries_of_same_model( - hass: HomeAssistant, setup_default_vapix_requests, mock_setup_entry + hass: HomeAssistant, ) -> None: """Test that create entry can generate a name with other entries.""" entry = MockConfigEntry( @@ -222,7 +228,9 @@ async def test_flow_create_entry_multiple_existing_entries_of_same_model( async def test_reauth_flow_update_configuration( - hass: HomeAssistant, mock_config_entry, mock_vapix_requests + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vapix_requests: Callable[[str], None], ) -> None: """Test that config flow fails on already configured device.""" assert mock_config_entry.data[CONF_HOST] == "1.2.3.4" @@ -261,7 +269,9 @@ async def test_reauth_flow_update_configuration( async def test_reconfiguration_flow_update_configuration( - hass: HomeAssistant, mock_config_entry, mock_vapix_requests + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_vapix_requests: Callable[[str], None], ) -> None: """Test that config flow reconfiguration updates configured device.""" assert mock_config_entry.data[CONF_HOST] == "1.2.3.4" @@ -362,12 +372,11 @@ async def test_reconfiguration_flow_update_configuration( ), ], ) +@pytest.mark.usefixtures("setup_default_vapix_requests", "mock_setup_entry") async def test_discovery_flow( hass: HomeAssistant, - setup_default_vapix_requests, source: str, - discovery_info: dict, - mock_setup_entry, + discovery_info: BaseServiceInfo, ) -> None: """Test the different discovery flows for new devices work.""" result = await hass.config_entries.flow.async_init( @@ -445,7 +454,10 @@ async def test_discovery_flow( ], ) async def test_discovered_device_already_configured( - hass: HomeAssistant, mock_config_entry, source: str, discovery_info: dict + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + source: str, + discovery_info: BaseServiceInfo, ) -> None: """Test that discovery doesn't setup already configured devices.""" assert mock_config_entry.data[CONF_HOST] == DEFAULT_HOST @@ -501,10 +513,10 @@ async def test_discovered_device_already_configured( ) async def test_discovery_flow_updated_configuration( hass: HomeAssistant, - mock_config_entry, - mock_vapix_requests, + mock_config_entry: MockConfigEntry, + mock_vapix_requests: Callable[[str], None], source: str, - discovery_info: dict, + discovery_info: BaseServiceInfo, expected_port: int, ) -> None: """Test that discovery flow update configuration with new parameters.""" @@ -573,7 +585,7 @@ async def test_discovery_flow_updated_configuration( ], ) async def test_discovery_flow_ignore_non_axis_device( - hass: HomeAssistant, source: str, discovery_info: dict + hass: HomeAssistant, source: str, discovery_info: BaseServiceInfo ) -> None: """Test that discovery flow ignores devices with non Axis OUI.""" result = await hass.config_entries.flow.async_init( @@ -622,7 +634,7 @@ async def test_discovery_flow_ignore_non_axis_device( ], ) async def test_discovery_flow_ignore_link_local_address( - hass: HomeAssistant, source: str, discovery_info: dict + hass: HomeAssistant, source: str, discovery_info: BaseServiceInfo ) -> None: """Test that discovery flow ignores devices with link local addresses.""" result = await hass.config_entries.flow.async_init( @@ -633,7 +645,9 @@ async def test_discovery_flow_ignore_link_local_address( assert result["reason"] == "link_local_address" -async def test_option_flow(hass: HomeAssistant, setup_config_entry) -> None: +async def test_option_flow( + hass: HomeAssistant, setup_config_entry: ConfigEntry +) -> None: """Test config flow options.""" assert CONF_STREAM_PROFILE not in setup_config_entry.options assert CONF_VIDEO_SOURCE not in setup_config_entry.options diff --git a/tests/components/axis/test_diagnostics.py b/tests/components/axis/test_diagnostics.py index 026e1ae4d22..c3e1faf4277 100644 --- a/tests/components/axis/test_diagnostics.py +++ b/tests/components/axis/test_diagnostics.py @@ -3,6 +3,7 @@ import pytest from syrupy import SnapshotAssertion +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import API_DISCOVERY_BASIC_DEVICE_INFO @@ -15,7 +16,7 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - setup_config_entry, + setup_config_entry: ConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 11ef1ef1cdf..fb0a28bb262 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -1,16 +1,20 @@ """Test Axis device.""" +from collections.abc import Callable from ipaddress import ip_address +from types import MappingProxyType +from typing import Any from unittest import mock -from unittest.mock import Mock, call, patch +from unittest.mock import ANY, AsyncMock, Mock, call, patch import axis as axislib import pytest +from typing_extensions import Generator from homeassistant.components import axis, zeroconf from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.config_entries import SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_MODEL, @@ -35,7 +39,7 @@ from tests.typing import MqttMockHAClient @pytest.fixture(name="forward_entry_setups") -def hass_mock_forward_entry_setup(hass): +def hass_mock_forward_entry_setup(hass: HomeAssistant) -> Generator[AsyncMock]: """Mock async_forward_entry_setups.""" with patch.object( hass.config_entries, "async_forward_entry_setups" @@ -44,10 +48,9 @@ def hass_mock_forward_entry_setup(hass): async def test_device_setup( - hass: HomeAssistant, - forward_entry_setups, - config_entry_data, - setup_config_entry, + forward_entry_setups: AsyncMock, + config_entry_data: MappingProxyType[str, Any], + setup_config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" @@ -75,7 +78,7 @@ async def test_device_setup( @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_BASIC_DEVICE_INFO]) -async def test_device_info(hass: HomeAssistant, setup_config_entry) -> None: +async def test_device_info(setup_config_entry: ConfigEntry) -> None: """Verify other path of device information works.""" hub = setup_config_entry.runtime_data @@ -86,11 +89,12 @@ async def test_device_info(hass: HomeAssistant, setup_config_entry) -> None: @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT]) +@pytest.mark.usefixtures("setup_config_entry") async def test_device_support_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_config_entry + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Successful setup.""" - mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8") + mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8", ANY) assert mqtt_call in mqtt_mock.async_subscribe.call_args_list topic = f"axis/{MAC}/event/tns:onvif/Device/tns:axis/Sensor/PIR/$source/sensor/0" @@ -111,16 +115,17 @@ async def test_device_support_mqtt( @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT]) @pytest.mark.parametrize("mqtt_status_code", [401]) -async def test_device_support_mqtt_low_privilege( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_config_entry -) -> None: +@pytest.mark.usefixtures("setup_config_entry") +async def test_device_support_mqtt_low_privilege(mqtt_mock: MqttMockHAClient) -> None: """Successful setup.""" mqtt_call = call(f"{MAC}/#", mock.ANY, 0, "utf-8") assert mqtt_call not in mqtt_mock.async_subscribe.call_args_list async def test_update_address( - hass: HomeAssistant, setup_config_entry, mock_vapix_requests + hass: HomeAssistant, + setup_config_entry: ConfigEntry, + mock_vapix_requests: Callable[[str], None], ) -> None: """Test update address works.""" hub = setup_config_entry.runtime_data @@ -145,8 +150,11 @@ async def test_update_address( assert hub.api.config.host == "2.3.4.5" +@pytest.mark.usefixtures("setup_config_entry") async def test_device_unavailable( - hass: HomeAssistant, setup_config_entry, mock_rtsp_event, mock_rtsp_signal_state + hass: HomeAssistant, + mock_rtsp_event: Callable[[str, str, str, str, str, str], None], + mock_rtsp_signal_state: Callable[[bool], None], ) -> None: """Successful setup.""" # Provide an entity that can be used to verify connection state on @@ -179,8 +187,9 @@ async def test_device_unavailable( assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.{NAME}_sound_1").state == STATE_OFF +@pytest.mark.usefixtures("setup_default_vapix_requests") async def test_device_not_accessible( - hass: HomeAssistant, config_entry, setup_default_vapix_requests + hass: HomeAssistant, config_entry: ConfigEntry ) -> None: """Failed setup schedules a retry of setup.""" with patch.object(axis, "get_axis_api", side_effect=axis.errors.CannotConnect): @@ -189,8 +198,9 @@ async def test_device_not_accessible( assert hass.data[AXIS_DOMAIN] == {} +@pytest.mark.usefixtures("setup_default_vapix_requests") async def test_device_trigger_reauth_flow( - hass: HomeAssistant, config_entry, setup_default_vapix_requests + hass: HomeAssistant, config_entry: ConfigEntry ) -> None: """Failed authentication trigger a reauthentication flow.""" with ( @@ -205,8 +215,9 @@ async def test_device_trigger_reauth_flow( assert hass.data[AXIS_DOMAIN] == {} +@pytest.mark.usefixtures("setup_default_vapix_requests") async def test_device_unknown_error( - hass: HomeAssistant, config_entry, setup_default_vapix_requests + hass: HomeAssistant, config_entry: ConfigEntry ) -> None: """Unknown errors are handled.""" with patch.object(axis, "get_axis_api", side_effect=Exception): @@ -215,7 +226,7 @@ async def test_device_unknown_error( assert hass.data[AXIS_DOMAIN] == {} -async def test_shutdown(config_entry_data) -> None: +async def test_shutdown(config_entry_data: MappingProxyType[str, Any]) -> None: """Successful shutdown.""" hass = Mock() entry = Mock() @@ -230,7 +241,9 @@ async def test_shutdown(config_entry_data) -> None: assert len(axis_device.api.stream.stop.mock_calls) == 1 -async def test_get_device_fails(hass: HomeAssistant, config_entry_data) -> None: +async def test_get_device_fails( + hass: HomeAssistant, config_entry_data: MappingProxyType[str, Any] +) -> None: """Device unauthorized yields authentication required error.""" with ( patch( @@ -242,7 +255,7 @@ async def test_get_device_fails(hass: HomeAssistant, config_entry_data) -> None: async def test_get_device_device_unavailable( - hass: HomeAssistant, config_entry_data + hass: HomeAssistant, config_entry_data: MappingProxyType[str, Any] ) -> None: """Device unavailable yields cannot connect error.""" with ( @@ -252,7 +265,9 @@ async def test_get_device_device_unavailable( await axis.hub.get_axis_api(hass, config_entry_data) -async def test_get_device_unknown_error(hass: HomeAssistant, config_entry_data) -> None: +async def test_get_device_unknown_error( + hass: HomeAssistant, config_entry_data: MappingProxyType[str, Any] +) -> None: """Device yield unknown error.""" with ( patch("axis.interfaces.vapix.Vapix.request", side_effect=axislib.AxisException), diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py index 607508b985a..e4dc7cd1eef 100644 --- a/tests/components/axis/test_init.py +++ b/tests/components/axis/test_init.py @@ -5,16 +5,18 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant.components import axis -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -async def test_setup_entry(hass: HomeAssistant, setup_config_entry) -> None: +async def test_setup_entry(setup_config_entry: ConfigEntry) -> None: """Test successful setup of entry.""" assert setup_config_entry.state is ConfigEntryState.LOADED -async def test_setup_entry_fails(hass: HomeAssistant, config_entry) -> None: +async def test_setup_entry_fails( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Test successful setup of entry.""" mock_device = Mock() mock_device.async_setup = AsyncMock(return_value=False) @@ -27,7 +29,9 @@ async def test_setup_entry_fails(hass: HomeAssistant, config_entry) -> None: assert config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_entry(hass: HomeAssistant, setup_config_entry) -> None: +async def test_unload_entry( + hass: HomeAssistant, setup_config_entry: ConfigEntry +) -> None: """Test successful unload of entry.""" assert setup_config_entry.state is ConfigEntryState.LOADED @@ -36,7 +40,7 @@ async def test_unload_entry(hass: HomeAssistant, setup_config_entry) -> None: @pytest.mark.parametrize("config_entry_version", [1]) -async def test_migrate_entry(hass: HomeAssistant, config_entry) -> None: +async def test_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Test successful migration of entry data.""" assert config_entry.version == 1 diff --git a/tests/components/axis/test_light.py b/tests/components/axis/test_light.py index 5cde6b74fc4..a5ae66afee0 100644 --- a/tests/components/axis/test_light.py +++ b/tests/components/axis/test_light.py @@ -9,7 +9,6 @@ import pytest import respx from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -70,9 +69,9 @@ def light_control_fixture(light_control_items: list[dict[str, Any]]) -> None: @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_LIGHT_CONTROL]) @pytest.mark.parametrize("light_control_items", [[]]) +@pytest.mark.usefixtures("setup_config_entry") async def test_no_light_entity_without_light_control_representation( hass: HomeAssistant, - setup_config_entry: ConfigEntry, mock_rtsp_event: Callable[[str, str, str, str, str, str], None], ) -> None: """Verify no lights entities get created without light control representation.""" @@ -89,12 +88,10 @@ async def test_no_light_entity_without_light_control_representation( @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_LIGHT_CONTROL]) +@pytest.mark.usefixtures("setup_config_entry") async def test_lights( hass: HomeAssistant, - respx_mock: respx, - setup_config_entry: ConfigEntry, mock_rtsp_event: Callable[[str, str, str, str, str, str], None], - api_discovery_items: dict[str, Any], ) -> None: """Test that lights are loaded properly.""" # Add light diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py index b9202d42e25..479830783b1 100644 --- a/tests/components/axis/test_switch.py +++ b/tests/components/axis/test_switch.py @@ -7,7 +7,6 @@ from axis.models.api import CONTEXT import pytest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -31,9 +30,9 @@ root.IOPort.I1.Output.Active=open @pytest.mark.parametrize("param_ports_payload", [PORT_DATA]) +@pytest.mark.usefixtures("setup_config_entry") async def test_switches_with_port_cgi( hass: HomeAssistant, - setup_config_entry: ConfigEntry, mock_rtsp_event: Callable[[str, str, str, str, str, str], None], ) -> None: """Test that switches are loaded properly using port.cgi.""" @@ -116,9 +115,9 @@ PORT_MANAGEMENT_RESPONSE = { @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_PORT_MANAGEMENT]) @pytest.mark.parametrize("port_management_payload", [PORT_MANAGEMENT_RESPONSE]) +@pytest.mark.usefixtures("setup_config_entry") async def test_switches_with_port_management( hass: HomeAssistant, - setup_config_entry: ConfigEntry, mock_rtsp_event: Callable[[str, str, str, str, str, str], None], ) -> None: """Test that switches are loaded properly using port management.""" diff --git a/tests/components/azure_data_explorer/__init__.py b/tests/components/azure_data_explorer/__init__.py new file mode 100644 index 00000000000..8cabf7a22a5 --- /dev/null +++ b/tests/components/azure_data_explorer/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the azure_data_explorer integration.""" + +# fixtures for both init and config flow tests +from dataclasses import dataclass + + +@dataclass +class FilterTest: + """Class for capturing a filter test.""" + + entity_id: str + expect_called: bool diff --git a/tests/components/azure_data_explorer/conftest.py b/tests/components/azure_data_explorer/conftest.py new file mode 100644 index 00000000000..4168021b333 --- /dev/null +++ b/tests/components/azure_data_explorer/conftest.py @@ -0,0 +1,133 @@ +"""Test fixtures for Azure Data Explorer.""" + +from datetime import timedelta +import logging +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.azure_data_explorer.const import ( + CONF_FILTER, + CONF_SEND_INTERVAL, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from .const import ( + AZURE_DATA_EXPLORER_PATH, + BASE_CONFIG_FREE, + BASE_CONFIG_FULL, + BASIC_OPTIONS, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(name="filter_schema") +def mock_filter_schema() -> dict[str, Any]: + """Return an empty filter.""" + return {} + + +@pytest.fixture(name="entry_managed") +async def mock_entry_fixture_managed( + hass: HomeAssistant, filter_schema: dict[str, Any] +) -> MockConfigEntry: + """Create the setup in HA.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=BASE_CONFIG_FULL, + title="test-instance", + options=BASIC_OPTIONS, + ) + await _entry(hass, filter_schema, entry) + return entry + + +@pytest.fixture(name="entry_queued") +async def mock_entry_fixture_queued( + hass: HomeAssistant, filter_schema: dict[str, Any] +) -> MockConfigEntry: + """Create the setup in HA.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=BASE_CONFIG_FREE, + title="test-instance", + options=BASIC_OPTIONS, + ) + await _entry(hass, filter_schema, entry) + return entry + + +async def _entry(hass: HomeAssistant, filter_schema: dict[str, Any], entry) -> None: + entry.add_to_hass(hass) + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_FILTER: filter_schema}} + ) + assert entry.state == ConfigEntryState.LOADED + + # Clear the component_loaded event from the queue. + async_fire_time_changed( + hass, + utcnow() + timedelta(seconds=entry.options[CONF_SEND_INTERVAL]), + ) + await hass.async_block_till_done() + + +@pytest.fixture(name="entry_with_one_event") +async def mock_entry_with_one_event( + hass: HomeAssistant, entry_managed +) -> MockConfigEntry: + """Use the entry and add a single test event to the queue.""" + assert entry_managed.state == ConfigEntryState.LOADED + hass.states.async_set("sensor.test", STATE_ON) + return entry_managed + + +# Fixtures for config_flow tests +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock the setup entry call, used for config flow tests.""" + with patch( + f"{AZURE_DATA_EXPLORER_PATH}.async_setup_entry", return_value=True + ) as setup_entry: + yield setup_entry + + +# Fixtures for mocking the Azure Data Explorer SDK calls. +@pytest.fixture(autouse=True) +def mock_managed_streaming() -> Generator[MagicMock]: + """mock_azure_data_explorer_ManagedStreamingIngestClient_ingest_data.""" + with patch( + "azure.kusto.ingest.ManagedStreamingIngestClient.ingest_from_stream", + return_value=True, + ) as ingest_from_stream: + yield ingest_from_stream + + +@pytest.fixture(autouse=True) +def mock_queued_ingest() -> Generator[MagicMock]: + """mock_azure_data_explorer_QueuedIngestClient_ingest_data.""" + with patch( + "azure.kusto.ingest.QueuedIngestClient.ingest_from_stream", + return_value=True, + ) as ingest_from_stream: + yield ingest_from_stream + + +@pytest.fixture(autouse=True) +def mock_execute_query() -> Generator[MagicMock]: + """Mock KustoClient execute_query.""" + with patch( + "azure.kusto.data.KustoClient.execute_query", + return_value=True, + ) as execute_query: + yield execute_query diff --git a/tests/components/azure_data_explorer/const.py b/tests/components/azure_data_explorer/const.py new file mode 100644 index 00000000000..d20be1584a1 --- /dev/null +++ b/tests/components/azure_data_explorer/const.py @@ -0,0 +1,48 @@ +"""Constants for testing Azure Data Explorer.""" + +from homeassistant.components.azure_data_explorer.const import ( + CONF_ADX_CLUSTER_INGEST_URI, + CONF_ADX_DATABASE_NAME, + CONF_ADX_TABLE_NAME, + CONF_APP_REG_ID, + CONF_APP_REG_SECRET, + CONF_AUTHORITY_ID, + CONF_SEND_INTERVAL, + CONF_USE_QUEUED_CLIENT, +) + +AZURE_DATA_EXPLORER_PATH = "homeassistant.components.azure_data_explorer" +CLIENT_PATH = f"{AZURE_DATA_EXPLORER_PATH}.AzureDataExplorer" + + +BASE_DB = { + CONF_ADX_DATABASE_NAME: "test-database-name", + CONF_ADX_TABLE_NAME: "test-table-name", + CONF_APP_REG_ID: "test-app-reg-id", + CONF_APP_REG_SECRET: "test-app-reg-secret", + CONF_AUTHORITY_ID: "test-auth-id", +} + + +BASE_CONFIG_URI = { + CONF_ADX_CLUSTER_INGEST_URI: "https://cluster.region.kusto.windows.net" +} + +BASIC_OPTIONS = { + CONF_USE_QUEUED_CLIENT: False, + CONF_SEND_INTERVAL: 5, +} + +BASE_CONFIG = BASE_DB | BASE_CONFIG_URI +BASE_CONFIG_FULL = BASE_CONFIG | BASIC_OPTIONS | BASE_CONFIG_URI + + +BASE_CONFIG_IMPORT = { + CONF_ADX_CLUSTER_INGEST_URI: "https://cluster.region.kusto.windows.net", + CONF_USE_QUEUED_CLIENT: False, + CONF_SEND_INTERVAL: 5, +} + +FREE_OPTIONS = {CONF_USE_QUEUED_CLIENT: True, CONF_SEND_INTERVAL: 5} + +BASE_CONFIG_FREE = BASE_CONFIG | FREE_OPTIONS diff --git a/tests/components/azure_data_explorer/test_config_flow.py b/tests/components/azure_data_explorer/test_config_flow.py new file mode 100644 index 00000000000..a700299be33 --- /dev/null +++ b/tests/components/azure_data_explorer/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Azure Data Explorer config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from azure.kusto.data.exceptions import KustoAuthenticationError, KustoServiceError +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.azure_data_explorer.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .const import BASE_CONFIG + + +async def test_config_flow(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}, data=None + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + BASE_CONFIG.copy(), + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "cluster.region.kusto.windows.net" + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (KustoServiceError("test"), "cannot_connect"), + (KustoAuthenticationError("test", Exception), "invalid_auth"), + ], +) +async def test_config_flow_errors( + test_input: Exception, + expected: str, + hass: HomeAssistant, + mock_execute_query: MagicMock, +) -> None: + """Test we handle connection KustoServiceError.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=None, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + # Test error handling with error + + mock_execute_query.side_effect = test_input + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + BASE_CONFIG.copy(), + ) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": expected} + + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Retest error handling if error is corrected and connection is successful + + mock_execute_query.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + BASE_CONFIG.copy(), + ) + + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/azure_data_explorer/test_init.py b/tests/components/azure_data_explorer/test_init.py new file mode 100644 index 00000000000..4d339728d09 --- /dev/null +++ b/tests/components/azure_data_explorer/test_init.py @@ -0,0 +1,290 @@ +"""Test the init functions for Azure Data Explorer.""" + +from datetime import datetime, timedelta +import logging +from unittest.mock import MagicMock, Mock, patch + +from azure.kusto.data.exceptions import KustoAuthenticationError, KustoServiceError +from azure.kusto.ingest import StreamDescriptor +import pytest + +from homeassistant.components import azure_data_explorer +from homeassistant.components.azure_data_explorer.const import ( + CONF_SEND_INTERVAL, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import FilterTest +from .const import AZURE_DATA_EXPLORER_PATH, BASE_CONFIG_FULL, BASIC_OPTIONS + +from tests.common import MockConfigEntry, async_fire_time_changed + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.freeze_time("2024-01-01 00:00:00") +@pytest.mark.usefixtures("entry_managed") +async def test_put_event_on_queue_with_managed_client( + hass: HomeAssistant, mock_managed_streaming: Mock +) -> None: + """Test listening to events from Hass. and writing to ADX with managed client.""" + + hass.states.async_set("sensor.test_sensor", STATE_ON) + + await hass.async_block_till_done() + + async_fire_time_changed(hass, datetime(2024, 1, 1, 0, 1, 0)) + + await hass.async_block_till_done() + + assert type(mock_managed_streaming.call_args.args[0]) is StreamDescriptor + + +@pytest.mark.freeze_time("2024-01-01 00:00:00") +@pytest.mark.parametrize( + ("sideeffect", "log_message"), + [ + (KustoServiceError("test"), "Could not find database or table"), + ( + KustoAuthenticationError("test", Exception), + ("Could not authenticate to Azure Data Explorer"), + ), + ], + ids=["KustoServiceError", "KustoAuthenticationError"], +) +@pytest.mark.usefixtures("entry_managed") +async def test_put_event_on_queue_with_managed_client_with_errors( + hass: HomeAssistant, + mock_managed_streaming: Mock, + sideeffect: Exception, + log_message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test listening to events from Hass. and writing to ADX with managed client.""" + + mock_managed_streaming.side_effect = sideeffect + + hass.states.async_set("sensor.test_sensor", STATE_ON) + await hass.async_block_till_done() + + async_fire_time_changed(hass, datetime(2024, 1, 1, 0, 0, 0)) + + await hass.async_block_till_done() + + assert log_message in caplog.text + + +async def test_put_event_on_queue_with_queueing_client( + hass: HomeAssistant, + entry_queued: MockConfigEntry, + mock_queued_ingest: Mock, +) -> None: + """Test listening to events from Hass. and writing to ADX with managed client.""" + + hass.states.async_set("sensor.test_sensor", STATE_ON) + + await hass.async_block_till_done() + + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=entry_queued.options[CONF_SEND_INTERVAL]) + ) + + await hass.async_block_till_done() + mock_queued_ingest.assert_called_once() + assert type(mock_queued_ingest.call_args.args[0]) is StreamDescriptor + + +async def test_import(hass: HomeAssistant) -> None: + """Test the popping of the filter and further import of the config.""" + config = { + DOMAIN: { + "filter": { + "include_domains": ["light"], + "include_entity_globs": ["sensor.included_*"], + "include_entities": ["binary_sensor.included"], + "exclude_domains": ["light"], + "exclude_entity_globs": ["sensor.excluded_*"], + "exclude_entities": ["binary_sensor.excluded"], + }, + } + } + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert "filter" in hass.data[DOMAIN] + + +async def test_unload_entry( + hass: HomeAssistant, + entry_managed: MockConfigEntry, + mock_managed_streaming: Mock, +) -> None: + """Test being able to unload an entry. + + Queue should be empty, so adding events to the batch should not be called, + this verifies that the unload, calls async_stop, which calls async_send and + shuts down the hub. + """ + assert entry_managed.state == ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry_managed.entry_id) + mock_managed_streaming.assert_not_called() + assert entry_managed.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.freeze_time("2024-01-01 00:00:00") +@pytest.mark.usefixtures("entry_with_one_event") +async def test_late_event(hass: HomeAssistant, mock_managed_streaming: Mock) -> None: + """Test the check on late events.""" + with patch( + f"{AZURE_DATA_EXPLORER_PATH}.utcnow", + return_value=utcnow() + timedelta(hours=1), + ): + async_fire_time_changed(hass, datetime(2024, 1, 2, 00, 00, 00)) + await hass.async_block_till_done() + mock_managed_streaming.add.assert_not_called() + + +@pytest.mark.parametrize( + ("filter_schema", "tests"), + [ + ( + { + "include_domains": ["light"], + "include_entity_globs": ["sensor.included_*"], + "include_entities": ["binary_sensor.included"], + }, + [ + FilterTest("climate.excluded", expect_called=False), + FilterTest("light.included", expect_called=True), + FilterTest("sensor.excluded_test", expect_called=False), + FilterTest("sensor.included_test", expect_called=True), + FilterTest("binary_sensor.included", expect_called=True), + FilterTest("binary_sensor.excluded", expect_called=False), + ], + ), + ( + { + "exclude_domains": ["climate"], + "exclude_entity_globs": ["sensor.excluded_*"], + "exclude_entities": ["binary_sensor.excluded"], + }, + [ + FilterTest("climate.excluded", expect_called=False), + FilterTest("light.included", expect_called=True), + FilterTest("sensor.excluded_test", expect_called=False), + FilterTest("sensor.included_test", expect_called=True), + FilterTest("binary_sensor.included", expect_called=True), + FilterTest("binary_sensor.excluded", expect_called=False), + ], + ), + ( + { + "include_domains": ["light"], + "include_entity_globs": ["*.included_*"], + "exclude_domains": ["climate"], + "exclude_entity_globs": ["*.excluded_*"], + "exclude_entities": ["light.excluded"], + }, + [ + FilterTest("light.included", expect_called=True), + FilterTest("light.excluded_test", expect_called=False), + FilterTest("light.excluded", expect_called=False), + FilterTest("sensor.included_test", expect_called=True), + FilterTest("climate.included_test", expect_called=True), + ], + ), + ( + { + "include_entities": ["climate.included", "sensor.excluded_test"], + "exclude_domains": ["climate"], + "exclude_entity_globs": ["*.excluded_*"], + "exclude_entities": ["light.excluded"], + }, + [ + FilterTest("climate.excluded", expect_called=False), + FilterTest("climate.included", expect_called=True), + FilterTest("switch.excluded_test", expect_called=False), + FilterTest("sensor.excluded_test", expect_called=True), + FilterTest("light.excluded", expect_called=False), + FilterTest("light.included", expect_called=True), + ], + ), + ], + ids=["allowlist", "denylist", "filtered_allowlist", "filtered_denylist"], +) +async def test_filter( + hass: HomeAssistant, + entry_managed: MockConfigEntry, + tests: list[FilterTest], + mock_managed_streaming: Mock, +) -> None: + """Test different filters. + + Filter_schema is also a fixture which is replaced by the filter_schema + in the parametrize and added to the entry fixture. + """ + for test in tests: + mock_managed_streaming.reset_mock() + hass.states.async_set(test.entity_id, STATE_ON) + await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + timedelta(seconds=entry_managed.options[CONF_SEND_INTERVAL]), + ) + await hass.async_block_till_done() + assert mock_managed_streaming.called == test.expect_called + assert "filter" in hass.data[DOMAIN] + + +@pytest.mark.parametrize( + ("event"), + [(None), ("______\nMicrosof}")], + ids=["None_event", "Mailformed_event"], +) +async def test_event( + hass: HomeAssistant, + entry_managed: MockConfigEntry, + mock_managed_streaming: Mock, + event: str | None, +) -> None: + """Test listening to events from Hass. and getting an event with a newline in the state.""" + + hass.states.async_set("sensor.test_sensor", event) + + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=entry_managed.options[CONF_SEND_INTERVAL]) + ) + + await hass.async_block_till_done() + mock_managed_streaming.add.assert_not_called() + + +@pytest.mark.parametrize( + ("sideeffect"), + [ + (KustoServiceError("test")), + (KustoAuthenticationError("test", Exception)), + (Exception), + ], + ids=["KustoServiceError", "KustoAuthenticationError", "Exception"], +) +async def test_connection( + hass: HomeAssistant, mock_execute_query: MagicMock, sideeffect: Exception +) -> None: + """Test Error when no getting proper connection with Exception.""" + entry = MockConfigEntry( + domain=azure_data_explorer.DOMAIN, + data=BASE_CONFIG_FULL, + title="cluster", + options=BASIC_OPTIONS, + ) + entry.add_to_hass(hass) + mock_execute_query.side_effect = sideeffect + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.SETUP_ERROR diff --git a/tests/components/azure_devops/__init__.py b/tests/components/azure_devops/__init__.py index fb0817671b5..d636a6fda6d 100644 --- a/tests/components/azure_devops/__init__.py +++ b/tests/components/azure_devops/__init__.py @@ -2,8 +2,8 @@ from typing import Final -from aioazuredevops.builds import DevOpsBuild, DevOpsBuildDefinition -from aioazuredevops.core import DevOpsProject +from aioazuredevops.models.builds import Build, BuildDefinition +from aioazuredevops.models.core import Project from homeassistant.components.azure_devops.const import CONF_ORG, CONF_PAT, CONF_PROJECT from homeassistant.core import HomeAssistant @@ -28,22 +28,21 @@ FIXTURE_REAUTH_INPUT = { } -DEVOPS_PROJECT = DevOpsProject( - project_id="1234", +DEVOPS_PROJECT = Project( + id="1234", name=PROJECT, description="Test Description", url=f"https://dev.azure.com/{ORGANIZATION}/{PROJECT}", state="wellFormed", revision=1, visibility="private", - last_updated=None, default_team=None, links=None, ) -DEVOPS_BUILD_DEFINITION = DevOpsBuildDefinition( +DEVOPS_BUILD_DEFINITION = BuildDefinition( build_id=9876, - name="Test Build", + name="CI", url=f"https://dev.azure.com/{ORGANIZATION}/{PROJECT}/_apis/build/definitions/1", path="", build_type="build", @@ -51,7 +50,7 @@ DEVOPS_BUILD_DEFINITION = DevOpsBuildDefinition( revision=1, ) -DEVOPS_BUILD = DevOpsBuild( +DEVOPS_BUILD = Build( build_id=5678, build_number="1", status="completed", @@ -68,6 +67,16 @@ DEVOPS_BUILD = DevOpsBuild( links=None, ) +DEVOPS_BUILD_MISSING_DATA = Build( + build_id=6789, + definition=DEVOPS_BUILD_DEFINITION, + project=DEVOPS_PROJECT, +) + +DEVOPS_BUILD_MISSING_PROJECT_DEFINITION = Build( + build_id=9876, +) + async def setup_integration( hass: HomeAssistant, diff --git a/tests/components/azure_devops/conftest.py b/tests/components/azure_devops/conftest.py index d51142cdced..c65adaa4da5 100644 --- a/tests/components/azure_devops/conftest.py +++ b/tests/components/azure_devops/conftest.py @@ -13,12 +13,13 @@ from tests.common import MockConfigEntry @pytest.fixture -async def mock_devops_client() -> AsyncGenerator[MagicMock, None]: +async def mock_devops_client() -> AsyncGenerator[MagicMock]: """Mock the Azure DevOps client.""" with ( patch( - "homeassistant.components.azure_devops.DevOpsClient", autospec=True + "homeassistant.components.azure_devops.coordinator.DevOpsClient", + autospec=True, ) as mock_client, patch( "homeassistant.components.azure_devops.config_flow.DevOpsClient", @@ -32,7 +33,7 @@ async def mock_devops_client() -> AsyncGenerator[MagicMock, None]: devops_client.get_project.return_value = DEVOPS_PROJECT devops_client.get_builds.return_value = [DEVOPS_BUILD] devops_client.get_build.return_value = DEVOPS_BUILD - devops_client.get_work_items_ids_all.return_value = None + devops_client.get_work_item_ids.return_value = None devops_client.get_work_items.return_value = None yield devops_client @@ -49,10 +50,10 @@ async def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.azure_devops.async_setup_entry", return_value=True, - ) as mock_setup_entry: - yield mock_setup_entry + ) as mock_entry: + yield mock_entry diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index b99d2c4e49d..0ce82cae1e8 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -1,4 +1,1034 @@ # serializer version: 1 +# name: test_sensors[sensor.testproject_ci_build_finish_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_finish_time', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CI build finish time', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'finish_time', + 'unique_id': 'testorg_1234_9876_finish_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_finish_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'testproject CI build finish time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_finish_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T00:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_id-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_id', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI build id', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'build_id', + 'unique_id': 'testorg_1234_9876_build_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_id-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build id', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5678', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_queue_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_queue_time', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CI build queue time', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'queue_time', + 'unique_id': 'testorg_1234_9876_queue_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_queue_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'testproject CI build queue time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_queue_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T00:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_reason', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI build reason', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reason', + 'unique_id': 'testorg_1234_9876_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build reason', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_result-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_result', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI build result', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'result', + 'unique_id': 'testorg_1234_9876_result', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_result-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build result', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_result', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'succeeded', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_source_branch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_source_branch', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI build source branch', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'source_branch', + 'unique_id': 'testorg_1234_9876_source_branch', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_source_branch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build source branch', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_source_branch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'main', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_source_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_source_version', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI build source version', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'source_version', + 'unique_id': 'testorg_1234_9876_source_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_source_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build source version', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_source_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_start_time', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CI build start time', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'testorg_1234_9876_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'testproject CI build start time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T00:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_status', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI build status', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'testorg_1234_9876_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build status', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'completed', + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_url-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_build_url', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI build url', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'url', + 'unique_id': 'testorg_1234_9876_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_build_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build url', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'latest_build', + 'unique_id': 'testorg_1234_9876_latest_build', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'definition_id': 9876, + 'definition_name': 'CI', + 'finish_time': '2021-01-01T00:00:00Z', + 'friendly_name': 'testproject CI latest build', + 'id': 5678, + 'queue_time': '2021-01-01T00:00:00Z', + 'reason': 'manual', + 'result': 'succeeded', + 'source_branch': 'main', + 'source_version': '123', + 'start_time': '2021-01-01T00:00:00Z', + 'status': 'completed', + 'url': None, + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_finish_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_finish_time', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CI latest build finish time', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'finish_time', + 'unique_id': 'testorg_1234_9876_finish_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_finish_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testproject CI latest build finish time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_finish_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T00:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_id-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_id', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build id', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'build_id', + 'unique_id': 'testorg_1234_9876_build_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_id-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build id', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5678', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_queue_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_queue_time', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CI latest build queue time', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'queue_time', + 'unique_id': 'testorg_1234_9876_queue_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_queue_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testproject CI latest build queue time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_queue_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T00:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_reason', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build reason', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reason', + 'unique_id': 'testorg_1234_9876_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build reason', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'manual', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_result-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_result', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build result', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'result', + 'unique_id': 'testorg_1234_9876_result', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_result-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build result', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_result', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'succeeded', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_source_branch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_source_branch', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build source branch', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'source_branch', + 'unique_id': 'testorg_1234_9876_source_branch', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_source_branch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build source branch', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_source_branch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'main', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_source_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_source_version', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build source version', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'source_version', + 'unique_id': 'testorg_1234_9876_source_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_source_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build source version', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_source_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_start_time', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CI latest build start time', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'testorg_1234_9876_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testproject CI latest build start time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T00:00:00+00:00', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_status', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build status', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'testorg_1234_9876_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build status', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'completed', + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_url-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_ci_latest_build_url', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'CI latest build url', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'url', + 'unique_id': 'testorg_1234_9876_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_ci_latest_build_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build url', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.testproject_test_build_build_id-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testproject_test_build_build_id', + 'has_entity_name': True, + 'hidden_by': , + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test Build build id', + 'platform': 'azure_devops', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'build_id', + 'unique_id': 'testorg_1234_9876_build_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testproject_test_build_build_id-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject Test Build build id', + }), + 'context': , + 'entity_id': 'sensor.testproject_test_build_build_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5678', + }) +# --- # name: test_sensors[sensor.testproject_test_build_latest_build-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -57,3 +1087,294 @@ 'state': '1', }) # --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_finish_time-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'testproject CI build finish time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_finish_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_id-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build id', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6789', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_queue_time-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'testproject CI build queue time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_queue_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_reason-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build reason', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_result-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build result', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_result', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_source_branch-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build source branch', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_source_branch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_source_version-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build source version', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_source_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_start_time-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'testproject CI build start time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_status-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build status', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_build_url-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI build url', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_build_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'definition_id': 9876, + 'definition_name': 'CI', + 'finish_time': None, + 'friendly_name': 'testproject CI latest build', + 'id': 6789, + 'queue_time': None, + 'reason': None, + 'result': None, + 'source_branch': None, + 'source_version': None, + 'start_time': None, + 'status': None, + 'url': None, + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_finish_time-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testproject CI latest build finish time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_finish_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_id-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build id', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6789', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_queue_time-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testproject CI latest build queue time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_queue_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_reason-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build reason', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_result-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build result', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_result', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_source_branch-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build source branch', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_source_branch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_source_version-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build source version', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_source_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_start_time-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testproject CI latest build start time', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_status-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build status', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors_missing_data[sensor.testproject_ci_latest_build_url-state-missing-data] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testproject CI latest build url', + }), + 'context': , + 'entity_id': 'sensor.testproject_ci_latest_build_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/azure_devops/test_config_flow.py b/tests/components/azure_devops/test_config_flow.py index acb610a78be..45dc10802b9 100644 --- a/tests/components/azure_devops/test_config_flow.py +++ b/tests/components/azure_devops/test_config_flow.py @@ -2,7 +2,6 @@ from unittest.mock import AsyncMock -from aioazuredevops.core import DevOpsProject import aiohttp from homeassistant import config_entries @@ -218,9 +217,6 @@ async def test_reauth_flow( mock_devops_client.authorize.return_value = True mock_devops_client.authorized = True - mock_devops_client.get_project.return_value = DevOpsProject( - "abcd-abcd-abcd-abcd", FIXTURE_USER_INPUT[CONF_PROJECT] - ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/azure_devops/test_init.py b/tests/components/azure_devops/test_init.py index a35acb375ec..a7655042f25 100644 --- a/tests/components/azure_devops/test_init.py +++ b/tests/components/azure_devops/test_init.py @@ -22,7 +22,7 @@ async def test_load_unload_entry( assert mock_devops_client.authorized assert mock_devops_client.authorize.call_count == 1 - assert mock_devops_client.get_builds.call_count == 2 + assert mock_devops_client.get_builds.call_count == 1 assert mock_config_entry.state is ConfigEntryState.LOADED @@ -48,7 +48,22 @@ async def test_auth_failed( assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_update_failed( +async def test_update_failed_project( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_project.side_effect = aiohttp.ClientError + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_project.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_update_failed_builds( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_devops_client: MagicMock, diff --git a/tests/components/azure_devops/test_sensor.py b/tests/components/azure_devops/test_sensor.py index 1c518d919c2..cb49c3d67cd 100644 --- a/tests/components/azure_devops/test_sensor.py +++ b/tests/components/azure_devops/test_sensor.py @@ -8,10 +8,28 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import ( + DEVOPS_BUILD_MISSING_DATA, + DEVOPS_BUILD_MISSING_PROJECT_DEFINITION, + setup_integration, +) from tests.common import MockConfigEntry +BASE_ENTITY_ID = "sensor.testproject_ci" +SENSOR_KEYS = [ + "latest_build", + "latest_build_id", + "latest_build_reason", + "latest_build_result", + "latest_build_source_branch", + "latest_build_source_version", + "latest_build_queue_time", + "latest_build_start_time", + "latest_build_finish_time", + "latest_build_url", +] + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( @@ -21,13 +39,53 @@ async def test_sensors( mock_config_entry: MockConfigEntry, mock_devops_client: AsyncMock, ) -> None: - """Test the sensor entities.""" + """Test sensor entities.""" assert await setup_integration(hass, mock_config_entry) - assert ( - entry := entity_registry.async_get("sensor.testproject_test_build_latest_build") - ) + for sensor_key in SENSOR_KEYS: + assert (entry := entity_registry.async_get(f"{BASE_ENTITY_ID}_{sensor_key}")) - assert entry == snapshot(name=f"{entry.entity_id}-entry") + assert entry == snapshot(name=f"{entry.entity_id}-entry") - assert hass.states.get(entry.entity_id) == snapshot(name=f"{entry.entity_id}-state") + assert hass.states.get(entry.entity_id) == snapshot( + name=f"{entry.entity_id}-state" + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_missing_data( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_devops_client: AsyncMock, +) -> None: + """Test sensor entities with missing data.""" + mock_devops_client.get_builds.return_value = [DEVOPS_BUILD_MISSING_DATA] + + assert await setup_integration(hass, mock_config_entry) + + for sensor_key in SENSOR_KEYS: + assert (entry := entity_registry.async_get(f"{BASE_ENTITY_ID}_{sensor_key}")) + + assert hass.states.get(entry.entity_id) == snapshot( + name=f"{entry.entity_id}-state-missing-data" + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_missing_project_definition( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_devops_client: AsyncMock, +) -> None: + """Test sensor entities with missing project and definition.""" + mock_devops_client.get_builds.return_value = [ + DEVOPS_BUILD_MISSING_PROJECT_DEFINITION + ] + + assert await setup_integration(hass, mock_config_entry) + + for sensor_key in SENSOR_KEYS: + assert not entity_registry.async_get(f"{BASE_ENTITY_ID}_{sensor_key}") diff --git a/tests/components/azure_event_hub/conftest.py b/tests/components/azure_event_hub/conftest.py index 99bf054dbb1..a34f2e646f2 100644 --- a/tests/components/azure_event_hub/conftest.py +++ b/tests/components/azure_event_hub/conftest.py @@ -3,10 +3,12 @@ from dataclasses import dataclass from datetime import timedelta import logging -from unittest.mock import MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from azure.eventhub.aio import EventHubProducerClient import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.azure_event_hub.const import ( CONF_FILTER, @@ -15,6 +17,7 @@ from homeassistant.components.azure_event_hub.const import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -27,20 +30,25 @@ _LOGGER = logging.getLogger(__name__) # fixtures for both init and config flow tests @pytest.fixture(autouse=True, name="mock_get_eventhub_properties") -def mock_get_eventhub_properties_fixture(): +def mock_get_eventhub_properties_fixture() -> Generator[AsyncMock]: """Mock azure event hub properties, used to test the connection.""" with patch(f"{PRODUCER_PATH}.get_eventhub_properties") as get_eventhub_properties: yield get_eventhub_properties @pytest.fixture(name="filter_schema") -def mock_filter_schema(): +def mock_filter_schema() -> dict[str, Any]: """Return an empty filter.""" return {} @pytest.fixture(name="entry") -async def mock_entry_fixture(hass, filter_schema, mock_create_batch, mock_send_batch): +async def mock_entry_fixture( + hass: HomeAssistant, + filter_schema: dict[str, Any], + mock_create_batch: MagicMock, + mock_send_batch: AsyncMock, +) -> AsyncGenerator[MockConfigEntry]: """Create the setup in HA.""" entry = MockConfigEntry( domain=DOMAIN, @@ -63,12 +71,14 @@ async def mock_entry_fixture(hass, filter_schema, mock_create_batch, mock_send_b yield entry - await entry.async_unload(hass) + await hass.config_entries.async_unload(entry.entry_id) # fixtures for init tests @pytest.fixture(name="entry_with_one_event") -async def mock_entry_with_one_event(hass, entry): +def mock_entry_with_one_event( + hass: HomeAssistant, entry: MockConfigEntry +) -> MockConfigEntry: """Use the entry and add a single test event to the queue.""" assert entry.state is ConfigEntryState.LOADED hass.states.async_set("sensor.test", STATE_ON) @@ -84,14 +94,16 @@ class FilterTest: @pytest.fixture(name="mock_send_batch") -def mock_send_batch_fixture(): +def mock_send_batch_fixture() -> Generator[AsyncMock]: """Mock send_batch.""" with patch(f"{PRODUCER_PATH}.send_batch") as mock_send_batch: yield mock_send_batch @pytest.fixture(autouse=True, name="mock_client") -def mock_client_fixture(mock_send_batch): +def mock_client_fixture( + mock_send_batch: AsyncMock, +) -> Generator[tuple[AsyncMock, AsyncMock]]: """Mock the azure event hub producer client.""" with patch(f"{PRODUCER_PATH}.close") as mock_close: yield ( @@ -101,7 +113,7 @@ def mock_client_fixture(mock_send_batch): @pytest.fixture(name="mock_create_batch") -def mock_create_batch_fixture(): +def mock_create_batch_fixture() -> Generator[MagicMock]: """Mock batch creator and return mocked batch object.""" mock_batch = MagicMock() with patch(f"{PRODUCER_PATH}.create_batch", return_value=mock_batch): @@ -110,7 +122,7 @@ def mock_create_batch_fixture(): # fixtures for config flow tests @pytest.fixture(name="mock_from_connection_string") -def mock_from_connection_string_fixture(): +def mock_from_connection_string_fixture() -> Generator[MagicMock]: """Mock AEH from connection string creation.""" mock_aeh = MagicMock(spec=EventHubProducerClient) mock_aeh.__aenter__.return_value = mock_aeh @@ -122,7 +134,7 @@ def mock_from_connection_string_fixture(): @pytest.fixture -def mock_setup_entry(): +def mock_setup_entry() -> Generator[AsyncMock]: """Mock the setup entry call, used for config flow tests.""" with patch( f"{AZURE_EVENT_HUB_PATH}.async_setup_entry", return_value=True diff --git a/tests/components/azure_event_hub/test_config_flow.py b/tests/components/azure_event_hub/test_config_flow.py index cedbc5b43d6..52685c36bbe 100644 --- a/tests/components/azure_event_hub/test_config_flow.py +++ b/tests/components/azure_event_hub/test_config_flow.py @@ -1,7 +1,8 @@ """Test the AEH config flow.""" import logging -from unittest.mock import AsyncMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock from azure.eventhub.exceptions import EventHubError import pytest @@ -43,14 +44,14 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") ], ids=["connection_string", "sas"], ) +@pytest.mark.usefixtures("mock_from_connection_string") async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_from_connection_string, - step1_config, - step_id, - step2_config, - data_config, + step1_config: dict[str, Any], + step_id: str, + step2_config: dict[str, str], + data_config: dict[str, str], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -101,7 +102,7 @@ async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: [config_entries.SOURCE_USER, config_entries.SOURCE_IMPORT], ids=["user", "import"], ) -async def test_single_instance(hass: HomeAssistant, source) -> None: +async def test_single_instance(hass: HomeAssistant, source: str) -> None: """Test uniqueness of username.""" entry = MockConfigEntry( domain=DOMAIN, @@ -126,9 +127,9 @@ async def test_single_instance(hass: HomeAssistant, source) -> None: ) async def test_connection_error_sas( hass: HomeAssistant, - mock_get_eventhub_properties, - side_effect, - error_message, + mock_get_eventhub_properties: AsyncMock, + side_effect: Exception, + error_message: str, ) -> None: """Test we handle connection errors.""" result = await hass.config_entries.flow.async_init( @@ -155,9 +156,9 @@ async def test_connection_error_sas( ) async def test_connection_error_cs( hass: HomeAssistant, - mock_from_connection_string, - side_effect, - error_message, + mock_from_connection_string: MagicMock, + side_effect: Exception, + error_message: str, ) -> None: """Test we handle connection errors.""" result = await hass.config_entries.flow.async_init( @@ -178,7 +179,7 @@ async def test_connection_error_cs( assert result2["errors"] == {"base": error_message} -async def test_options_flow(hass: HomeAssistant, entry) -> None: +async def test_options_flow(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test options flow.""" result = await hass.config_entries.options.async_init(entry.entry_id) diff --git a/tests/components/azure_event_hub/test_init.py b/tests/components/azure_event_hub/test_init.py index 1440bc2ede9..1b0550b147b 100644 --- a/tests/components/azure_event_hub/test_init.py +++ b/tests/components/azure_event_hub/test_init.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch from azure.eventhub.exceptions import EventHubError import pytest @@ -60,7 +60,9 @@ async def test_filter_only_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, DOMAIN, config) -async def test_unload_entry(hass: HomeAssistant, entry, mock_create_batch) -> None: +async def test_unload_entry( + hass: HomeAssistant, entry: MockConfigEntry, mock_create_batch: MagicMock +) -> None: """Test being able to unload an entry. Queue should be empty, so adding events to the batch should not be called, @@ -73,7 +75,7 @@ async def test_unload_entry(hass: HomeAssistant, entry, mock_create_batch) -> No async def test_failed_test_connection( - hass: HomeAssistant, mock_get_eventhub_properties + hass: HomeAssistant, mock_get_eventhub_properties: AsyncMock ) -> None: """Test being able to unload an entry.""" entry = MockConfigEntry( @@ -89,7 +91,9 @@ async def test_failed_test_connection( async def test_send_batch_error( - hass: HomeAssistant, entry_with_one_event, mock_send_batch + hass: HomeAssistant, + entry_with_one_event: MockConfigEntry, + mock_send_batch: AsyncMock, ) -> None: """Test a error in send_batch, including recovering at the next interval.""" mock_send_batch.reset_mock() @@ -111,7 +115,9 @@ async def test_send_batch_error( async def test_late_event( - hass: HomeAssistant, entry_with_one_event, mock_create_batch + hass: HomeAssistant, + entry_with_one_event: MockConfigEntry, + mock_create_batch: MagicMock, ) -> None: """Test the check on late events.""" with patch( @@ -128,7 +134,9 @@ async def test_late_event( async def test_full_batch( - hass: HomeAssistant, entry_with_one_event, mock_create_batch + hass: HomeAssistant, + entry_with_one_event: MockConfigEntry, + mock_create_batch: MagicMock, ) -> None: """Test the full batch behaviour.""" mock_create_batch.add.side_effect = [ValueError, None] @@ -208,7 +216,12 @@ async def test_full_batch( ], ids=["allowlist", "denylist", "filtered_allowlist", "filtered_denylist"], ) -async def test_filter(hass: HomeAssistant, entry, tests, mock_create_batch) -> None: +async def test_filter( + hass: HomeAssistant, + entry: MockConfigEntry, + tests: list[FilterTest], + mock_create_batch: MagicMock, +) -> None: """Test different filters. Filter_schema is also a fixture which is replaced by the filter_schema diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 79d682c69fe..e11278202e0 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -120,7 +120,6 @@ async def test_backup_end( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, - request: pytest.FixtureRequest, sync_access_token_proxy: str, *, access_token_fixture_name: str, diff --git a/tests/components/baf/__init__.py b/tests/components/baf/__init__.py index 09288c4a874..f1074a87cee 100644 --- a/tests/components/baf/__init__.py +++ b/tests/components/baf/__init__.py @@ -11,6 +11,7 @@ MOCK_NAME = "Living Room Fan" class MockBAFDevice(Device): """A simple mock for a BAF Device.""" + # pylint: disable-next=super-init-not-called def __init__(self, async_wait_available_side_effect=None): """Init simple mock.""" self._async_wait_available_side_effect = async_wait_available_side_effect diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 7f679773f93..fbdc2f8a759 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -2,11 +2,12 @@ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import Callable from unittest.mock import AsyncMock, MagicMock, patch from pybalboa.enums import HeatMode, LowHighRange import pytest +from typing_extensions import Generator from homeassistant.core import HomeAssistant @@ -22,7 +23,7 @@ async def integration_fixture(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture(name="client") -def client_fixture() -> Generator[MagicMock, None, None]: +def client_fixture() -> Generator[MagicMock]: """Mock balboa spa client.""" with patch( "homeassistant.components.balboa.SpaClient", autospec=True diff --git a/tests/components/balboa/snapshots/test_binary_sensor.ambr b/tests/components/balboa/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..c37c8a20d4b --- /dev/null +++ b/tests/components/balboa/snapshots/test_binary_sensor.ambr @@ -0,0 +1,142 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.fakespa_circulation_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fakespa_circulation_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Circulation pump', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'circ_pump', + 'unique_id': 'FakeSpa-Circ Pump-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.fakespa_circulation_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'FakeSpa Circulation pump', + }), + 'context': , + 'entity_id': 'binary_sensor.fakespa_circulation_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.fakespa_filter_cycle_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fakespa_filter_cycle_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter cycle 1', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_1', + 'unique_id': 'FakeSpa-Filter1-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.fakespa_filter_cycle_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'FakeSpa Filter cycle 1', + }), + 'context': , + 'entity_id': 'binary_sensor.fakespa_filter_cycle_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.fakespa_filter_cycle_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.fakespa_filter_cycle_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter cycle 2', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_2', + 'unique_id': 'FakeSpa-Filter2-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.fakespa_filter_cycle_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'FakeSpa Filter cycle 2', + }), + 'context': , + 'entity_id': 'binary_sensor.fakespa_filter_cycle_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/balboa/snapshots/test_climate.ambr b/tests/components/balboa/snapshots/test_climate.ambr new file mode 100644 index 00000000000..d3060077341 --- /dev/null +++ b/tests/components/balboa/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate[climate.fakespa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 10.0, + 'preset_modes': list([ + 'ready', + 'rest', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fakespa', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'balboa', + 'unique_id': 'FakeSpa-Climate-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.fakespa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 10.0, + 'friendly_name': 'FakeSpa', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 10.0, + 'preset_mode': 'ready', + 'preset_modes': list([ + 'ready', + 'rest', + ]), + 'supported_features': , + 'temperature': 40.0, + }), + 'context': , + 'entity_id': 'climate.fakespa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/balboa/snapshots/test_fan.ambr b/tests/components/balboa/snapshots/test_fan.ambr new file mode 100644 index 00000000000..2b87a961906 --- /dev/null +++ b/tests/components/balboa/snapshots/test_fan.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_fan[fan.fakespa_pump_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fakespa_pump_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pump 1', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'pump', + 'unique_id': 'FakeSpa-Pump 1-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan[fan.fakespa_pump_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Pump 1', + 'percentage': 0, + 'percentage_step': 50.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.fakespa_pump_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/balboa/snapshots/test_light.ambr b/tests/components/balboa/snapshots/test_light.ambr new file mode 100644 index 00000000000..31777744740 --- /dev/null +++ b/tests/components/balboa/snapshots/test_light.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_lights[light.fakespa_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.fakespa_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'only_light', + 'unique_id': 'FakeSpa-Light-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_lights[light.fakespa_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'FakeSpa Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.fakespa_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/balboa/snapshots/test_select.ambr b/tests/components/balboa/snapshots/test_select.ambr new file mode 100644 index 00000000000..a0cfd68d009 --- /dev/null +++ b/tests/components/balboa/snapshots/test_select.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_selects[select.fakespa_temperature_range-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.fakespa_temperature_range', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature range', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_range', + 'unique_id': 'FakeSpa-TempHiLow-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.fakespa_temperature_range-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Temperature range', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.fakespa_temperature_range', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- diff --git a/tests/components/balboa/test_binary_sensor.py b/tests/components/balboa/test_binary_sensor.py index bcce2b96a0b..5990c73bb68 100644 --- a/tests/components/balboa/test_binary_sensor.py +++ b/tests/components/balboa/test_binary_sensor.py @@ -1,17 +1,35 @@ -"""Tests of the climate entity of the balboa integration.""" +"""Tests of the binary sensors of the balboa integration.""" from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch -from homeassistant.const import STATE_OFF, STATE_ON +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform ENTITY_BINARY_SENSOR = "binary_sensor.fakespa_" +async def test_binary_sensors( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa binary sensors.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.BINARY_SENSOR]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + async def test_filters( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry ) -> None: diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index c75244ecb94..c877f2858cd 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import HeatMode, OffLowMediumHighState import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_FAN_MODE, @@ -25,13 +26,14 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er from . import client_update, init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform from tests.components.climate import common HVAC_SETTINGS = [ @@ -43,25 +45,17 @@ HVAC_SETTINGS = [ ENTITY_CLIMATE = "climate.fakespa" -async def test_spa_defaults( - hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry +async def test_climate( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test supported features flags.""" - state = hass.states.get(ENTITY_CLIMATE) + """Test spa climate.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.CLIMATE]): + entry = await init_integration(hass) - assert state - assert ( - state.attributes["supported_features"] - == ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_MIN_TEMP] == 10.0 - assert state.attributes[ATTR_MAX_TEMP] == 40.0 - assert state.attributes[ATTR_PRESET_MODE] == "ready" - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_spa_defaults_fake_tscale( diff --git a/tests/components/balboa/test_fan.py b/tests/components/balboa/test_fan.py index 878a14784f7..3eacb0d08c0 100644 --- a/tests/components/balboa/test_fan.py +++ b/tests/components/balboa/test_fan.py @@ -2,24 +2,27 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import OffLowHighState, UnknownState import pytest +from syrupy import SnapshotAssertion from homeassistant.components.fan import ATTR_PERCENTAGE -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import client_update, init_integration +from tests.common import snapshot_platform from tests.components.fan import common ENTITY_FAN = "fan.fakespa_pump_1" -@pytest.fixture +@pytest.fixture(autouse=True) def mock_pump(client: MagicMock): """Return a mock pump.""" pump = MagicMock(SpaControl) @@ -28,6 +31,7 @@ def mock_pump(client: MagicMock): pump.state = state pump.client = client + pump.name = "Pump 1" pump.index = 0 pump.state = OffLowHighState.OFF pump.set_state = set_state @@ -37,6 +41,19 @@ def mock_pump(client: MagicMock): return pump +async def test_fan( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa fans.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.FAN]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + async def test_pump(hass: HomeAssistant, client: MagicMock, mock_pump) -> None: """Test spa pump.""" await init_integration(hass) diff --git a/tests/components/balboa/test_light.py b/tests/components/balboa/test_light.py index da969a7e2d8..01469416da5 100644 --- a/tests/components/balboa/test_light.py +++ b/tests/components/balboa/test_light.py @@ -2,23 +2,26 @@ from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pybalboa import SpaControl from pybalboa.enums import OffOnState, UnknownState import pytest +from syrupy import SnapshotAssertion -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import client_update, init_integration +from tests.common import snapshot_platform from tests.components.light import common ENTITY_LIGHT = "light.fakespa_light" -@pytest.fixture +@pytest.fixture(autouse=True) def mock_light(client: MagicMock): """Return a mock light.""" light = MagicMock(SpaControl) @@ -26,6 +29,7 @@ def mock_light(client: MagicMock): async def set_state(state: OffOnState): light.state = state + light.name = "Light" light.client = client light.index = 0 light.state = OffOnState.OFF @@ -36,6 +40,19 @@ def mock_light(client: MagicMock): return light +async def test_lights( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa light.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.LIGHT]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + async def test_light(hass: HomeAssistant, client: MagicMock, mock_light) -> None: """Test spa light.""" await init_integration(hass) diff --git a/tests/components/balboa/test_select.py b/tests/components/balboa/test_select.py index bd79f024817..da57ee8f22e 100644 --- a/tests/components/balboa/test_select.py +++ b/tests/components/balboa/test_select.py @@ -2,26 +2,30 @@ from __future__ import annotations -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock, call, patch from pybalboa import SpaControl from pybalboa.enums import LowHighRange import pytest +from syrupy import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import client_update, init_integration +from tests.common import snapshot_platform + ENTITY_SELECT = "select.fakespa_temperature_range" -@pytest.fixture +@pytest.fixture(autouse=True) def mock_select(client: MagicMock): """Return a mock switch.""" select = MagicMock(SpaControl) @@ -36,6 +40,19 @@ def mock_select(client: MagicMock): return select +async def test_selects( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa climate.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.SELECT]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + async def test_select(hass: HomeAssistant, client: MagicMock, mock_select) -> None: """Test spa temperature range select.""" await init_integration(hass) diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index d076316e36c..1fbcbe0fe69 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for bang_olufsen.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from mozart_api.models import BeolinkPeer import pytest @@ -31,7 +31,7 @@ def mock_config_entry(): @pytest.fixture -def mock_mozart_client() -> Generator[AsyncMock, None, None]: +def mock_mozart_client() -> Generator[AsyncMock]: """Mock MozartClient.""" with ( @@ -44,10 +44,19 @@ def mock_mozart_client() -> Generator[AsyncMock, None, None]: ), ): client = mock_client.return_value + + # REST API client methods client.get_beolink_self = AsyncMock() client.get_beolink_self.return_value = BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME, jid=TEST_JID_1 ) + + # Non-REST API client methods + client.check_device_connection = AsyncMock() + client.close_api_client = AsyncMock() + client.connect_notifications = AsyncMock() + client.disconnect_notifications = Mock() + yield client diff --git a/tests/components/bang_olufsen/test_init.py b/tests/components/bang_olufsen/test_init.py new file mode 100644 index 00000000000..11742b846ae --- /dev/null +++ b/tests/components/bang_olufsen/test_init.py @@ -0,0 +1,90 @@ +"""Test the bang_olufsen __init__.""" + +from aiohttp.client_exceptions import ServerTimeoutError + +from homeassistant.components.bang_olufsen import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + +from .const import TEST_MODEL_BALANCE, TEST_NAME, TEST_SERIAL_NUMBER + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry, + mock_mozart_client, + device_registry: DeviceRegistry, +) -> None: + """Test async_setup_entry.""" + + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + + # Load entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state == ConfigEntryState.LOADED + + # Check that the device has been registered properly + device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device is not None + assert device.name == TEST_NAME + assert device.model == TEST_MODEL_BALANCE + + # Ensure that the connection has been checked WebSocket connection has been initialized + assert mock_mozart_client.check_device_connection.call_count == 1 + assert mock_mozart_client.close_api_client.call_count == 0 + assert mock_mozart_client.connect_notifications.call_count == 1 + + +async def test_setup_entry_failed( + hass: HomeAssistant, mock_config_entry, mock_mozart_client +) -> None: + """Test failed async_setup_entry.""" + + # Set the device connection check to fail + mock_mozart_client.check_device_connection.side_effect = ExceptionGroup( + "", (ServerTimeoutError(), TimeoutError()) + ) + + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + + # Load entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + # Ensure that the connection has been checked, API client correctly closed + # and WebSocket connection has not been initialized + assert mock_mozart_client.check_device_connection.call_count == 1 + assert mock_mozart_client.close_api_client.call_count == 1 + assert mock_mozart_client.connect_notifications.call_count == 0 + + +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry, mock_mozart_client +) -> None: + """Test unload_entry.""" + + # Load entry + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state == ConfigEntryState.LOADED + + # Unload entry + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + # Ensure WebSocket notification listener and REST API client have been closed + assert mock_mozart_client.disconnect_notifications.call_count == 1 + assert mock_mozart_client.close_api_client.call_count == 1 + + # Ensure that the entry is not loaded and has been removed from hass + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index ac80878c836..e4f646572cb 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -20,15 +20,16 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity_registry import async_get as async_get_entities +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.issue_registry import async_get from homeassistant.setup import async_setup_component from tests.common import get_fixture_path -async def test_load_values_when_added_to_hass(hass: HomeAssistant) -> None: +async def test_load_values_when_added_to_hass( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that sensor initializes with observations of relevant entities.""" config = { @@ -57,7 +58,6 @@ async def test_load_values_when_added_to_hass(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() - entity_registry = async_get_entities(hass) assert ( entity_registry.entities["binary_sensor.test_binary"].unique_id == "bayesian-3b4c9563-5e84-4167-8fe7-8f507e796d72" @@ -104,7 +104,9 @@ async def test_unknown_state_does_not_influence_probability( assert state.attributes.get("probability") == prior -async def test_sensor_numeric_state(hass: HomeAssistant) -> None: +async def test_sensor_numeric_state( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test sensor on numeric state platform observations.""" config = { "binary_sensor": { @@ -200,7 +202,7 @@ async def test_sensor_numeric_state(hass: HomeAssistant) -> None: assert state.state == "off" - assert len(async_get(hass).issues) == 0 + assert len(issue_registry.issues) == 0 async def test_sensor_state(hass: HomeAssistant) -> None: @@ -329,7 +331,7 @@ async def test_sensor_value_template(hass: HomeAssistant) -> None: assert state.state == "off" -async def test_threshold(hass: HomeAssistant) -> None: +async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: """Test sensor on probability threshold limits.""" config = { "binary_sensor": { @@ -359,7 +361,7 @@ async def test_threshold(hass: HomeAssistant) -> None: assert round(abs(1.0 - state.attributes.get("probability")), 7) == 0 assert state.state == "on" - assert len(async_get(hass).issues) == 0 + assert len(issue_registry.issues) == 0 async def test_multiple_observations(hass: HomeAssistant) -> None: @@ -513,7 +515,9 @@ async def test_multiple_numeric_observations(hass: HomeAssistant) -> None: assert state.attributes.get("observations")[1]["platform"] == "numeric_state" -async def test_mirrored_observations(hass: HomeAssistant) -> None: +async def test_mirrored_observations( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test whether mirrored entries are detected and appropriate issues are created.""" config = { @@ -586,22 +590,24 @@ async def test_mirrored_observations(hass: HomeAssistant) -> None: "prior": 0.1, } } - assert len(async_get(hass).issues) == 0 + assert len(issue_registry.issues) == 0 assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored2", "on") await hass.async_block_till_done() - assert len(async_get(hass).issues) == 3 + assert len(issue_registry.issues) == 3 assert ( - async_get(hass).issues[ + issue_registry.issues[ ("bayesian", "mirrored_entry/Test_Binary/sensor.test_monitored1") ] is not None ) -async def test_missing_prob_given_false(hass: HomeAssistant) -> None: +async def test_missing_prob_given_false( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test whether missing prob_given_false are detected and appropriate issues are created.""" config = { @@ -630,15 +636,15 @@ async def test_missing_prob_given_false(hass: HomeAssistant) -> None: "prior": 0.1, } } - assert len(async_get(hass).issues) == 0 + assert len(issue_registry.issues) == 0 assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored2", "on") await hass.async_block_till_done() - assert len(async_get(hass).issues) == 3 + assert len(issue_registry.issues) == 3 assert ( - async_get(hass).issues[ + issue_registry.issues[ ("bayesian", "no_prob_given_false/missingpgf/sensor.test_monitored1") ] is not None @@ -1006,7 +1012,10 @@ async def test_template_triggers(hass: HomeAssistant) -> None: events = [] async_track_state_change_event( - hass, "binary_sensor.test_binary", callback(lambda event: events.append(event)) + hass, + "binary_sensor.test_binary", + # pylint: disable-next=unnecessary-lambda + callback(lambda event: events.append(event)), ) context = Context() @@ -1045,7 +1054,10 @@ async def test_state_triggers(hass: HomeAssistant) -> None: events = [] async_track_state_change_event( - hass, "binary_sensor.test_binary", callback(lambda event: events.append(event)) + hass, + "binary_sensor.test_binary", + # pylint: disable-next=unnecessary-lambda + callback(lambda event: events.append(event)), ) context = Context() diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index 6837c882a01..c2bd29fad36 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceCla from homeassistant.components.binary_sensor.device_condition import ENTITY_CONDITIONS from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -33,7 +33,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -122,7 +122,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_on", "is_off"] + for condition in ("is_on", "is_off") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -239,7 +239,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_binary_sensor_entities: dict[str, MockBinarySensor], ) -> None: """Test for turn_on and turn_off conditions.""" @@ -327,7 +327,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_binary_sensor_entities: dict[str, MockBinarySensor], ) -> None: """Test for turn_on and turn_off conditions.""" @@ -387,7 +387,7 @@ async def test_if_fires_on_for_condition( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_binary_sensor_entities: dict[str, MockBinarySensor], ) -> None: """Test for firing if condition is on with delay.""" diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index dd55682fc8d..f91a336061d 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceCla from homeassistant.components.binary_sensor.device_trigger import ENTITY_TRIGGERS from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -33,7 +33,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -122,7 +122,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entry.id, "metadata": {"secondary": True}, } - for trigger in ["turned_on", "turned_off"] + for trigger in ("turned_on", "turned_off") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -240,7 +240,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_binary_sensor_entities: dict[str, MockBinarySensor], ) -> None: """Test for on and off triggers firing.""" @@ -335,7 +335,7 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_binary_sensor_entities: dict[str, MockBinarySensor], ) -> None: """Test for triggers firing with delay.""" @@ -407,7 +407,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_binary_sensor_entities: dict[str, MockBinarySensor], ) -> None: """Test for triggers firing.""" diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 335b9b40d50..8f14063e011 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -1,9 +1,9 @@ """The tests for the Binary sensor component.""" -from collections.abc import Generator from unittest import mock import pytest +from typing_extensions import Generator from homeassistant.components import binary_sensor from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -48,7 +48,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -63,8 +63,8 @@ async def test_name(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup( - config_entry, binary_sensor.DOMAIN + await hass.config_entries.async_forward_entry_setups( + config_entry, [binary_sensor.DOMAIN] ) return True @@ -143,8 +143,8 @@ async def test_entity_category_config_raises_error( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup( - config_entry, binary_sensor.DOMAIN + await hass.config_entries.async_forward_entry_setups( + config_entry, [binary_sensor.DOMAIN] ) return True diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py index 3b0465ef208..ec5a37f72ad 100644 --- a/tests/components/blackbird/test_media_player.py +++ b/tests/components/blackbird/test_media_player.py @@ -12,7 +12,10 @@ from homeassistant.components.blackbird.media_player import ( PLATFORM_SCHEMA, setup_platform, ) -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import ( + MediaPlayerEntity, + MediaPlayerEntityFeature, +) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -166,13 +169,13 @@ def test_invalid_schemas() -> None: @pytest.fixture -def mock_blackbird(): +def mock_blackbird() -> MockBlackbird: """Return a mock blackbird instance.""" return MockBlackbird() @pytest.fixture -async def setup_blackbird(hass, mock_blackbird): +async def setup_blackbird(hass: HomeAssistant, mock_blackbird: MockBlackbird) -> None: """Set up blackbird.""" with mock.patch( "homeassistant.components.blackbird.media_player.get_blackbird", @@ -198,7 +201,9 @@ async def setup_blackbird(hass, mock_blackbird): @pytest.fixture -def media_player_entity(hass, setup_blackbird): +def media_player_entity( + hass: HomeAssistant, setup_blackbird: None +) -> MediaPlayerEntity: """Return the media player entity.""" media_player = hass.data[DATA_BLACKBIRD]["/dev/ttyUSB0-3"] media_player.hass = hass @@ -206,7 +211,8 @@ def media_player_entity(hass, setup_blackbird): return media_player -async def test_setup_platform(hass: HomeAssistant, setup_blackbird) -> None: +@pytest.mark.usefixtures("setup_blackbird") +async def test_setup_platform(hass: HomeAssistant) -> None: """Test setting up platform.""" # One service must be registered assert hass.services.has_service(DOMAIN, SERVICE_SETALLZONES) @@ -215,7 +221,9 @@ async def test_setup_platform(hass: HomeAssistant, setup_blackbird) -> None: async def test_setallzones_service_call_with_entity_id( - hass: HomeAssistant, media_player_entity, mock_blackbird + hass: HomeAssistant, + media_player_entity: MediaPlayerEntity, + mock_blackbird: MockBlackbird, ) -> None: """Test set all zone source service call with entity id.""" await hass.async_add_executor_job(media_player_entity.update) @@ -238,7 +246,9 @@ async def test_setallzones_service_call_with_entity_id( async def test_setallzones_service_call_without_entity_id( - mock_blackbird, hass: HomeAssistant, media_player_entity + mock_blackbird: MockBlackbird, + hass: HomeAssistant, + media_player_entity: MediaPlayerEntity, ) -> None: """Test set all zone source service call without entity id.""" await hass.async_add_executor_job(media_player_entity.update) @@ -257,7 +267,9 @@ async def test_setallzones_service_call_without_entity_id( assert media_player_entity.source == "three" -async def test_update(hass: HomeAssistant, media_player_entity) -> None: +async def test_update( + hass: HomeAssistant, media_player_entity: MediaPlayerEntity +) -> None: """Test updating values from blackbird.""" assert media_player_entity.state is None assert media_player_entity.source is None @@ -268,12 +280,16 @@ async def test_update(hass: HomeAssistant, media_player_entity) -> None: assert media_player_entity.source == "one" -async def test_name(media_player_entity) -> None: +async def test_name(media_player_entity: MediaPlayerEntity) -> None: """Test name property.""" assert media_player_entity.name == "Zone name" -async def test_state(hass: HomeAssistant, media_player_entity, mock_blackbird) -> None: +async def test_state( + hass: HomeAssistant, + media_player_entity: MediaPlayerEntity, + mock_blackbird: MockBlackbird, +) -> None: """Test state property.""" assert media_player_entity.state is None @@ -285,7 +301,7 @@ async def test_state(hass: HomeAssistant, media_player_entity, mock_blackbird) - assert media_player_entity.state == STATE_OFF -async def test_supported_features(media_player_entity) -> None: +async def test_supported_features(media_player_entity: MediaPlayerEntity) -> None: """Test supported features property.""" assert ( media_player_entity.supported_features @@ -295,28 +311,34 @@ async def test_supported_features(media_player_entity) -> None: ) -async def test_source(hass: HomeAssistant, media_player_entity) -> None: +async def test_source( + hass: HomeAssistant, media_player_entity: MediaPlayerEntity +) -> None: """Test source property.""" assert media_player_entity.source is None await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.source == "one" -async def test_media_title(hass: HomeAssistant, media_player_entity) -> None: +async def test_media_title( + hass: HomeAssistant, media_player_entity: MediaPlayerEntity +) -> None: """Test media title property.""" assert media_player_entity.media_title is None await hass.async_add_executor_job(media_player_entity.update) assert media_player_entity.media_title == "one" -async def test_source_list(media_player_entity) -> None: +async def test_source_list(media_player_entity: MediaPlayerEntity) -> None: """Test source list property.""" # Note, the list is sorted! assert media_player_entity.source_list == ["one", "two", "three"] async def test_select_source( - hass: HomeAssistant, media_player_entity, mock_blackbird + hass: HomeAssistant, + media_player_entity: MediaPlayerEntity, + mock_blackbird: MockBlackbird, ) -> None: """Test source selection methods.""" await hass.async_add_executor_job(media_player_entity.update) @@ -336,7 +358,9 @@ async def test_select_source( async def test_turn_on( - hass: HomeAssistant, media_player_entity, mock_blackbird + hass: HomeAssistant, + media_player_entity: MediaPlayerEntity, + mock_blackbird: MockBlackbird, ) -> None: """Testing turning on the zone.""" mock_blackbird.zones[3].power = False @@ -350,7 +374,9 @@ async def test_turn_on( async def test_turn_off( - hass: HomeAssistant, media_player_entity, mock_blackbird + hass: HomeAssistant, + media_player_entity: MediaPlayerEntity, + mock_blackbird: MockBlackbird, ) -> None: """Testing turning off the zone.""" mock_blackbird.zones[3].power = True diff --git a/tests/components/blebox/conftest.py b/tests/components/blebox/conftest.py index 868d936d83a..89229575a0b 100644 --- a/tests/components/blebox/conftest.py +++ b/tests/components/blebox/conftest.py @@ -1,5 +1,6 @@ """PyTest fixtures and test helpers.""" +from typing import Any from unittest import mock from unittest.mock import AsyncMock, PropertyMock, patch @@ -71,7 +72,7 @@ def config_fixture(): @pytest.fixture(name="feature") -def feature_fixture(request): +def feature_fixture(request: pytest.FixtureRequest) -> Any: """Return an entity wrapper from given fixture name.""" return request.getfixturevalue(request.param) diff --git a/tests/components/blebox/test_button.py b/tests/components/blebox/test_button.py index fe596c41e33..03d8b22f149 100644 --- a/tests/components/blebox/test_button.py +++ b/tests/components/blebox/test_button.py @@ -21,7 +21,7 @@ query_icon_matching = [ @pytest.fixture(name="tvliftbox") -def tv_lift_box_fixture(caplog): +def tv_lift_box_fixture(caplog: pytest.LogCaptureFixture): """Return simple button entity mock.""" caplog.set_level(logging.ERROR) diff --git a/tests/components/blebox/test_helpers.py b/tests/components/blebox/test_helpers.py index bf355612f14..2acfb8d3b36 100644 --- a/tests/components/blebox/test_helpers.py +++ b/tests/components/blebox/test_helpers.py @@ -6,13 +6,13 @@ from homeassistant.components.blebox.helpers import get_maybe_authenticated_sess from homeassistant.core import HomeAssistant -async def test_get_maybe_authenticated_session_none(hass: HomeAssistant): +async def test_get_maybe_authenticated_session_none(hass: HomeAssistant) -> None: """Tests if session auth is None.""" session = get_maybe_authenticated_session(hass=hass, username="", password="") assert session.auth is None -async def test_get_maybe_authenticated_session_auth(hass: HomeAssistant): +async def test_get_maybe_authenticated_session_auth(hass: HomeAssistant) -> None: """Tests if session have BasicAuth.""" session = get_maybe_authenticated_session( hass=hass, username="user", password="password" diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 82ea847dcf2..9c3193ec7d6 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -49,6 +49,7 @@ async def test_form(hass: HomeAssistant) -> None: "account_id": None, "client_id": None, "region_id": None, + "user_id": None, } assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/blink/test_init.py b/tests/components/blink/test_init.py index 46806ef3349..3cd2cd51ebd 100644 --- a/tests/components/blink/test_init.py +++ b/tests/components/blink/test_init.py @@ -8,7 +8,6 @@ import pytest from homeassistant.components.blink.const import ( DOMAIN, - SERVICE_REFRESH, SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) @@ -82,7 +81,6 @@ async def test_unload_entry_multiple( assert mock_config_entry.state is ConfigEntryState.LOADED assert await hass.config_entries.async_unload(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - assert hass.services.has_service(DOMAIN, SERVICE_REFRESH) assert hass.services.has_service(DOMAIN, SERVICE_SAVE_VIDEO) assert hass.services.has_service(DOMAIN, SERVICE_SEND_PIN) diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index d2685bd04eb..856d9e6e8a0 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -7,14 +7,12 @@ import pytest from homeassistant.components.blink.const import ( ATTR_CONFIG_ENTRY_ID, DOMAIN, - SERVICE_REFRESH, SERVICE_SEND_PIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN +from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -23,43 +21,6 @@ FILENAME = "blah" PIN = "1234" -async def test_refresh_service_calls( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test refrest 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 - - await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH, - {ATTR_DEVICE_ID: [device_entry.id]}, - blocking=True, - ) - - assert mock_blink_api.refresh.call_count == 2 - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH, - {ATTR_DEVICE_ID: ["bad-device_id"]}, - blocking=True, - ) - - async def test_pin_service_calls( hass: HomeAssistant, mock_blink_api: MagicMock, @@ -128,47 +89,6 @@ async def test_service_pin_called_with_non_blink_device( ) -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 - ) - other_mock_config_entry.add_to_hass(hass) - - device_entry = device_registry.async_get_or_create( - config_entry_id=other_config_id, - identifiers={ - (other_domain, 1), - }, - ) - - hass.config.is_allowed_path = Mock(return_value=True) - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - - parameters = {ATTR_DEVICE_ID: [device_entry.id]} - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH, - parameters, - blocking=True, - ) - - async def test_service_pin_called_with_unloaded_entry( hass: HomeAssistant, mock_blink_api: MagicMock, @@ -193,34 +113,3 @@ async def test_service_pin_called_with_unloaded_entry( 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, -) -> None: - """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() - - mock_config_entry.mock_state(hass, 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]} - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH, - parameters, - blocking=True, - ) diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py index 723dd993006..b740e6c91f9 100644 --- a/tests/components/blue_current/test_init.py +++ b/tests/components/blue_current/test_init.py @@ -62,6 +62,8 @@ async def test_config_exceptions( config_error: IntegrationError, ) -> None: """Test if the correct config error is raised when connecting to the api fails.""" + config_entry.add_to_hass(hass) + with ( patch( "homeassistant.components.blue_current.Client.validate_api_token", @@ -69,7 +71,6 @@ async def test_config_exceptions( ), pytest.raises(config_error), ): - config_entry.add_to_hass(hass) await async_setup_entry(hass, config_entry) diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py index 5213cc0ff72..cf20b7334b4 100644 --- a/tests/components/blue_current/test_sensor.py +++ b/tests/components/blue_current/test_sensor.py @@ -88,7 +88,9 @@ grid_entity_ids = { async def test_sensors_created( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test if all sensors are created.""" await init_integration( @@ -100,8 +102,6 @@ async def test_sensors_created( grid, ) - entity_registry = er.async_get(hass) - sensors = er.async_entries_for_config_entry(entity_registry, "uuid") assert len(charge_point_status) + len(charge_point_status_timestamps) + len( grid @@ -109,13 +109,16 @@ async def test_sensors_created( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensors(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: """Test the underlying sensors.""" await init_integration( hass, config_entry, "sensor", charge_point, charge_point_status, 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 @@ -138,14 +141,15 @@ async def test_sensors(hass: HomeAssistant, config_entry: MockConfigEntry) -> No async def test_timestamp_sensors( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test the underlying sensors.""" await init_integration( hass, config_entry, "sensor", status=charge_point_status_timestamps ) - entity_registry = er.async_get(hass) for entity_id, key in charge_point_timestamp_entity_ids.items(): entry = entity_registry.async_get(f"sensor.101_{entity_id}") assert entry diff --git a/tests/components/bluemaestro/conftest.py b/tests/components/bluemaestro/conftest.py index e40cf1e30f4..f35ff087ed3 100644 --- a/tests/components/bluemaestro/conftest.py +++ b/tests/components/bluemaestro/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/blueprint/common.py b/tests/components/blueprint/common.py index f1ccf63b26a..dd59b6df082 100644 --- a/tests/components/blueprint/common.py +++ b/tests/components/blueprint/common.py @@ -1,11 +1,11 @@ """Blueprints test helpers.""" -from collections.abc import Generator -from typing import Any from unittest.mock import patch +from typing_extensions import Generator -def stub_blueprint_populate_fixture_helper() -> Generator[None, Any, None]: + +def stub_blueprint_populate_fixture_helper() -> Generator[None]: """Stub copying the blueprints to the config folder.""" with patch( "homeassistant.components.blueprint.models.DomainBlueprints.async_populate" diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 002d5204dc8..38cb3b485d4 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -1,6 +1,6 @@ # serializer version: 1 # name: test_extract_blueprint_from_community_topic - NodeDictClass({ + dict({ 'brightness': NodeDictClass({ 'default': 50, 'description': 'Brightness of the light(s) when turning on', @@ -97,7 +97,7 @@ }) # --- # name: test_fetch_blueprint_from_community_url - NodeDictClass({ + dict({ 'brightness': NodeDictClass({ 'default': 50, 'description': 'Brightness of the light(s) when turning on', @@ -194,7 +194,7 @@ }) # --- # name: test_fetch_blueprint_from_github_gist_url - NodeDictClass({ + dict({ 'light_entity': NodeDictClass({ 'name': 'Light', 'selector': dict({ diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py index 275ee08863e..f135bbf23b8 100644 --- a/tests/components/blueprint/test_importer.py +++ b/tests/components/blueprint/test_importer.py @@ -4,6 +4,7 @@ import json from pathlib import Path import pytest +from syrupy import SnapshotAssertion from homeassistant.components.blueprint import importer from homeassistant.core import HomeAssistant @@ -53,7 +54,9 @@ def test_get_github_import_url() -> None: ) -def test_extract_blueprint_from_community_topic(community_post, snapshot) -> None: +def test_extract_blueprint_from_community_topic( + community_post, snapshot: SnapshotAssertion +) -> None: """Test extracting blueprint.""" imported_blueprint = importer._extract_blueprint_from_community_topic( "http://example.com", json.loads(community_post) @@ -94,7 +97,10 @@ def test_extract_blueprint_from_community_topic_wrong_lang() -> None: async def test_fetch_blueprint_from_community_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, community_post, snapshot + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + community_post, + snapshot: SnapshotAssertion, ) -> None: """Test fetching blueprint from url.""" aioclient_mock.get( @@ -132,7 +138,7 @@ async def test_fetch_blueprint_from_github_url( "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml", text=Path( hass.config.path("blueprints/automation/test_event_service.yaml") - ).read_text(), + ).read_text(encoding="utf8"), ) imported_blueprint = await importer.fetch_blueprint_from_url(hass, url) @@ -148,7 +154,9 @@ async def test_fetch_blueprint_from_github_url( async def test_fetch_blueprint_from_github_gist_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, snapshot + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, ) -> None: """Test fetching blueprint from url.""" aioclient_mock.get( @@ -173,7 +181,7 @@ async def test_fetch_blueprint_from_website_url( "https://www.home-assistant.io/blueprints/awesome.yaml", text=Path( hass.config.path("blueprints/automation/test_event_service.yaml") - ).read_text(), + ).read_text(encoding="utf8"), ) url = "https://www.home-assistant.io/blueprints/awesome.yaml" diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index 96e72e2b4cc..45e35474e4c 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -11,7 +11,7 @@ from homeassistant.util.yaml import Input @pytest.fixture -def blueprint_1(): +def blueprint_1() -> models.Blueprint: """Blueprint fixture.""" return models.Blueprint( { @@ -26,28 +26,42 @@ def blueprint_1(): ) -@pytest.fixture -def blueprint_2(): +@pytest.fixture(params=[False, True]) +def blueprint_2(request: pytest.FixtureRequest) -> models.Blueprint: """Blueprint fixture with default inputs.""" - return models.Blueprint( - { - "blueprint": { - "name": "Hello", - "domain": "automation", - "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + blueprint = { + "blueprint": { + "name": "Hello", + "domain": "automation", + "source_url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", + "input": { + "test-input": {"name": "Name", "description": "Description"}, + "test-input-default": {"default": "test"}, + }, + }, + "example": Input("test-input"), + "example-default": Input("test-input-default"), + } + if request.param: + # Replace the inputs with inputs in sections. Test should otherwise behave the same. + blueprint["blueprint"]["input"] = { + "section-1": { + "name": "Section 1", "input": { "test-input": {"name": "Name", "description": "Description"}, - "test-input-default": {"default": "test"}, }, }, - "example": Input("test-input"), - "example-default": Input("test-input-default"), + "section-2": { + "input": { + "test-input-default": {"default": "test"}, + } + }, } - ) + return models.Blueprint(blueprint) @pytest.fixture -def domain_bps(hass): +def domain_bps(hass: HomeAssistant) -> models.DomainBlueprints: """Domain blueprints fixture.""" return models.DomainBlueprints( hass, "automation", logging.getLogger(__name__), None, AsyncMock() @@ -78,7 +92,7 @@ def test_blueprint_model_init() -> None: ) -def test_blueprint_properties(blueprint_1) -> None: +def test_blueprint_properties(blueprint_1: models.Blueprint) -> None: """Test properties.""" assert blueprint_1.metadata == { "name": "Hello", @@ -133,7 +147,7 @@ def test_blueprint_validate() -> None: ).validate() == ["Requires at least Home Assistant 100000.0.0"] -def test_blueprint_inputs(blueprint_2) -> None: +def test_blueprint_inputs(blueprint_2: models.Blueprint) -> None: """Test blueprint inputs.""" inputs = models.BlueprintInputs( blueprint_2, @@ -153,7 +167,7 @@ def test_blueprint_inputs(blueprint_2) -> None: } -def test_blueprint_inputs_validation(blueprint_1) -> None: +def test_blueprint_inputs_validation(blueprint_1: models.Blueprint) -> None: """Test blueprint input validation.""" inputs = models.BlueprintInputs( blueprint_1, @@ -163,7 +177,7 @@ def test_blueprint_inputs_validation(blueprint_1) -> None: inputs.validate() -def test_blueprint_inputs_default(blueprint_2) -> None: +def test_blueprint_inputs_default(blueprint_2: models.Blueprint) -> None: """Test blueprint inputs.""" inputs = models.BlueprintInputs( blueprint_2, @@ -178,7 +192,7 @@ def test_blueprint_inputs_default(blueprint_2) -> None: assert inputs.async_substitute() == {"example": 1, "example-default": "test"} -def test_blueprint_inputs_override_default(blueprint_2) -> None: +def test_blueprint_inputs_override_default(blueprint_2: models.Blueprint) -> None: """Test blueprint inputs.""" inputs = models.BlueprintInputs( blueprint_2, @@ -202,7 +216,7 @@ def test_blueprint_inputs_override_default(blueprint_2) -> None: async def test_domain_blueprints_get_blueprint_errors( - hass: HomeAssistant, domain_bps + hass: HomeAssistant, domain_bps: models.DomainBlueprints ) -> None: """Test domain blueprints.""" assert hass.data["blueprint"]["automation"] is domain_bps @@ -222,7 +236,7 @@ async def test_domain_blueprints_get_blueprint_errors( await domain_bps.async_get_blueprint("non-existing-path") -async def test_domain_blueprints_caching(domain_bps) -> None: +async def test_domain_blueprints_caching(domain_bps: models.DomainBlueprints) -> None: """Test domain blueprints cache blueprints.""" obj = object() with patch.object(domain_bps, "_load_blueprint", return_value=obj): @@ -239,7 +253,9 @@ async def test_domain_blueprints_caching(domain_bps) -> None: assert await domain_bps.async_get_blueprint("something") is obj_2 -async def test_domain_blueprints_inputs_from_config(domain_bps, blueprint_1) -> None: +async def test_domain_blueprints_inputs_from_config( + domain_bps: models.DomainBlueprints, blueprint_1: models.Blueprint +) -> None: """Test DomainBlueprints.async_inputs_from_config.""" with pytest.raises(errors.InvalidBlueprintInputs): await domain_bps.async_inputs_from_config({"not-referencing": "use_blueprint"}) @@ -260,7 +276,9 @@ async def test_domain_blueprints_inputs_from_config(domain_bps, blueprint_1) -> assert inputs.inputs == {"test-input": None} -async def test_domain_blueprints_add_blueprint(domain_bps, blueprint_1) -> None: +async def test_domain_blueprints_add_blueprint( + domain_bps: models.DomainBlueprints, blueprint_1: models.Blueprint +) -> None: """Test DomainBlueprints.async_add_blueprint.""" with patch.object(domain_bps, "_create_file") as create_file_mock: await domain_bps.async_add_blueprint(blueprint_1, "something.yaml") @@ -272,7 +290,9 @@ async def test_domain_blueprints_add_blueprint(domain_bps, blueprint_1) -> None: assert not mock_load.mock_calls -async def test_inputs_from_config_nonexisting_blueprint(domain_bps) -> None: +async def test_inputs_from_config_nonexisting_blueprint( + domain_bps: models.DomainBlueprints, +) -> None: """Test referring non-existing blueprint.""" with pytest.raises(errors.FailedToLoad): await domain_bps.async_inputs_from_config( diff --git a/tests/components/blueprint/test_schemas.py b/tests/components/blueprint/test_schemas.py index 0440a759f2f..70d599c9d01 100644 --- a/tests/components/blueprint/test_schemas.py +++ b/tests/components/blueprint/test_schemas.py @@ -52,6 +52,24 @@ _LOGGER = logging.getLogger(__name__) }, } }, + # With input sections + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "section_a": { + "input": {"some_placeholder": None}, + }, + "section_b": { + "name": "Section", + "description": "A section with no inputs", + "input": {}, + }, + "some_placeholder_2": None, + }, + } + }, ], ) def test_blueprint_schema(blueprint) -> None: @@ -94,6 +112,34 @@ def test_blueprint_schema(blueprint) -> None: }, } }, + # Duplicate inputs in sections (1 of 2) + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "section_a": { + "input": {"some_placeholder": None}, + }, + "section_b": { + "input": {"some_placeholder": None}, + }, + }, + } + }, + # Duplicate inputs in sections (2 of 2) + { + "blueprint": { + "name": "Test Name", + "domain": "automation", + "input": { + "section_a": { + "input": {"some_placeholder": None}, + }, + "some_placeholder": None, + }, + } + }, ], ) def test_blueprint_schema_invalid(blueprint) -> None: diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 93d97dfd036..1f684b451ed 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -1,6 +1,7 @@ """Test websocket API.""" from pathlib import Path +from typing import Any from unittest.mock import Mock, patch import pytest @@ -15,19 +16,23 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def automation_config(): +def automation_config() -> dict[str, Any]: """Automation config.""" return {} @pytest.fixture -def script_config(): +def script_config() -> dict[str, Any]: """Script config.""" return {} @pytest.fixture(autouse=True) -async def setup_bp(hass, automation_config, script_config): +async def setup_bp( + hass: HomeAssistant, + automation_config: dict[str, Any], + script_config: dict[str, Any], +) -> None: """Fixture to set up the blueprint component.""" assert await async_setup_component(hass, "blueprint", {}) @@ -41,11 +46,10 @@ async def test_list_blueprints( ) -> None: """Test listing blueprints.""" client = await hass_ws_client(hass) - await client.send_json({"id": 5, "type": "blueprint/list", "domain": "automation"}) + await client.send_json_auto_id({"type": "blueprint/list", "domain": "automation"}) msg = await client.receive_json() - assert msg["id"] == 5 assert msg["success"] blueprints = msg["result"] assert blueprints == { @@ -75,13 +79,10 @@ async def test_list_blueprints_non_existing_domain( ) -> None: """Test listing blueprints.""" client = await hass_ws_client(hass) - await client.send_json( - {"id": 5, "type": "blueprint/list", "domain": "not_existing"} - ) + await client.send_json_auto_id({"type": "blueprint/list", "domain": "not_existing"}) msg = await client.receive_json() - assert msg["id"] == 5 assert msg["success"] blueprints = msg["result"] assert blueprints == {} @@ -95,7 +96,7 @@ async def test_import_blueprint( """Test importing blueprints.""" raw_data = Path( hass.config.path("blueprints/automation/test_event_service.yaml") - ).read_text() + ).read_text(encoding="utf8") aioclient_mock.get( "https://raw.githubusercontent.com/balloob/home-assistant-config/main/blueprints/automation/motion_light.yaml", @@ -103,9 +104,8 @@ async def test_import_blueprint( ) client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, "type": "blueprint/import", "url": "https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml", } @@ -113,7 +113,6 @@ async def test_import_blueprint( msg = await client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] == { "suggested_filename": "balloob/motion_light", @@ -135,16 +134,16 @@ async def test_import_blueprint( } +@pytest.mark.usefixtures("setup_bp") async def test_import_blueprint_update( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, - setup_bp, ) -> None: """Test importing blueprints.""" raw_data = Path( hass.config.path("blueprints/automation/in_folder/in_folder_blueprint.yaml") - ).read_text() + ).read_text(encoding="utf8") aioclient_mock.get( "https://raw.githubusercontent.com/in_folder/home-assistant-config/main/blueprints/automation/in_folder_blueprint.yaml", @@ -152,9 +151,8 @@ async def test_import_blueprint_update( ) client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 5, "type": "blueprint/import", "url": "https://github.com/in_folder/home-assistant-config/blob/main/blueprints/automation/in_folder_blueprint.yaml", } @@ -162,7 +160,6 @@ async def test_import_blueprint_update( msg = await client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] == { "suggested_filename": "in_folder/in_folder_blueprint", @@ -182,19 +179,17 @@ async def test_import_blueprint_update( async def test_save_blueprint( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test saving blueprints.""" raw_data = Path( hass.config.path("blueprints/automation/test_event_service.yaml") - ).read_text() + ).read_text(encoding="utf8") with patch("pathlib.Path.write_text") as write_mock: client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": "blueprint/save", "path": "test_save", "yaml": raw_data, @@ -205,7 +200,6 @@ async def test_save_blueprint( msg = await client.receive_json() - assert msg["id"] == 6 assert msg["success"] assert write_mock.mock_calls # There are subtle differences in the dumper quoting @@ -236,15 +230,13 @@ async def test_save_blueprint( async def test_save_existing_file( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test saving blueprints.""" client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 7, "type": "blueprint/save", "path": "test_event_service", "yaml": 'blueprint: {name: "name", domain: "automation"}', @@ -255,23 +247,20 @@ async def test_save_existing_file( msg = await client.receive_json() - assert msg["id"] == 7 assert not msg["success"] assert msg["error"] == {"code": "already_exists", "message": "File already exists"} async def test_save_existing_file_override( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test saving blueprints.""" client = await hass_ws_client(hass) with patch("pathlib.Path.write_text") as write_mock: - await client.send_json( + await client.send_json_auto_id( { - "id": 7, "type": "blueprint/save", "path": "test_event_service", "yaml": 'blueprint: {name: "name", domain: "automation"}', @@ -283,7 +272,6 @@ async def test_save_existing_file_override( msg = await client.receive_json() - assert msg["id"] == 7 assert msg["success"] assert msg["result"] == {"overrides_existing": True} assert yaml.safe_load(write_mock.mock_calls[0][1][0]) == { @@ -298,15 +286,13 @@ async def test_save_existing_file_override( async def test_save_file_error( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test saving blueprints with OS error.""" with patch("pathlib.Path.write_text", side_effect=OSError): client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 8, "type": "blueprint/save", "path": "test_save", "yaml": "raw_data", @@ -317,21 +303,18 @@ async def test_save_file_error( msg = await client.receive_json() - assert msg["id"] == 8 assert not msg["success"] async def test_save_invalid_blueprint( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test saving invalid blueprints.""" client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 8, "type": "blueprint/save", "path": "test_wrong", "yaml": "wrong_blueprint", @@ -342,7 +325,6 @@ async def test_save_invalid_blueprint( msg = await client.receive_json() - assert msg["id"] == 8 assert not msg["success"] assert msg["error"] == { "code": "invalid_format", @@ -352,16 +334,14 @@ async def test_save_invalid_blueprint( async def test_delete_blueprint( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test deleting blueprints.""" with patch("pathlib.Path.unlink", return_value=Mock()) as unlink_mock: client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 9, "type": "blueprint/delete", "path": "test_delete", "domain": "automation", @@ -371,21 +351,18 @@ async def test_delete_blueprint( msg = await client.receive_json() assert unlink_mock.mock_calls - assert msg["id"] == 9 assert msg["success"] async def test_delete_non_exist_file_blueprint( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test deleting non existing blueprints.""" client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 9, "type": "blueprint/delete", "path": "none_existing", "domain": "automation", @@ -394,7 +371,6 @@ async def test_delete_non_exist_file_blueprint( msg = await client.receive_json() - assert msg["id"] == 9 assert not msg["success"] @@ -417,16 +393,14 @@ async def test_delete_non_exist_file_blueprint( ) async def test_delete_blueprint_in_use_by_automation( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test deleting a blueprint which is in use.""" with patch("pathlib.Path.unlink", return_value=Mock()) as unlink_mock: client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 9, "type": "blueprint/delete", "path": "test_event_service.yaml", "domain": "automation", @@ -436,7 +410,6 @@ async def test_delete_blueprint_in_use_by_automation( msg = await client.receive_json() assert not unlink_mock.mock_calls - assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { "code": "home_assistant_error", @@ -463,7 +436,6 @@ async def test_delete_blueprint_in_use_by_automation( ) async def test_delete_blueprint_in_use_by_script( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: """Test deleting a blueprint which is in use.""" diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 17fbb318248..4373ec3f915 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -6,6 +6,7 @@ from bleak_retry_connector import bleak_manager from dbus_fast.aio import message_bus import habluetooth.util as habluetooth_utils import pytest +from typing_extensions import Generator @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") @@ -74,7 +75,7 @@ def mock_operating_system_90(): @pytest.fixture(name="macos_adapter") -def macos_adapter(): +def macos_adapter() -> Generator[None]: """Fixture that mocks the macos adapter.""" with ( patch("bleak.get_platform_scanner_backend_type"), @@ -109,7 +110,7 @@ def windows_adapter(): @pytest.fixture(name="no_adapters") -def no_adapter_fixture(): +def no_adapter_fixture() -> Generator[None]: """Fixture that mocks no adapters on Linux.""" with ( patch( @@ -137,7 +138,7 @@ def no_adapter_fixture(): @pytest.fixture(name="one_adapter") -def one_adapter_fixture(): +def one_adapter_fixture() -> Generator[None]: """Fixture that mocks one adapter on Linux.""" with ( patch( @@ -176,7 +177,7 @@ def one_adapter_fixture(): @pytest.fixture(name="two_adapters") -def two_adapters_fixture(): +def two_adapters_fixture() -> Generator[None]: """Fixture that mocks two adapters on Linux.""" with ( patch( diff --git a/tests/components/bluetooth/snapshots/test_init.ambr b/tests/components/bluetooth/snapshots/test_init.ambr deleted file mode 100644 index 70a7b7cbb48..00000000000 --- a/tests/components/bluetooth/snapshots/test_init.ambr +++ /dev/null @@ -1,10 +0,0 @@ -# serializer version: 1 -# name: test_issue_outdated_haos - IssueRegistryItemSnapshot({ - 'created': , - 'dismissed_version': None, - 'domain': 'bluetooth', - 'is_persistent': False, - 'issue_id': 'haos_outdated', - }) -# --- diff --git a/tests/components/bluetooth/test_active_update_coordinator.py b/tests/components/bluetooth/test_active_update_coordinator.py index e3178f84336..38726143ea5 100644 --- a/tests/components/bluetooth/test_active_update_coordinator.py +++ b/tests/components/bluetooth/test_active_update_coordinator.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import MagicMock from bleak.exc import BleakError +import pytest from homeassistant.components.bluetooth import ( DOMAIN, @@ -17,7 +18,6 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, ) from homeassistant.components.bluetooth.active_update_coordinator import ( - _T, ActiveBluetoothDataUpdateCoordinator, ) from homeassistant.core import CoreState, HomeAssistant @@ -68,7 +68,7 @@ class MyCoordinator(ActiveBluetoothDataUpdateCoordinator[dict[str, Any]]): needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool], poll_method: Callable[ [BluetoothServiceInfoBleak], - Coroutine[Any, Any, _T], + Coroutine[Any, Any, dict[str, Any]], ] | None = None, poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, @@ -97,11 +97,8 @@ class MyCoordinator(ActiveBluetoothDataUpdateCoordinator[dict[str, Any]]): super()._async_handle_bluetooth_event(service_info, change) -async def test_basic_usage( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_basic_usage(hass: HomeAssistant) -> None: """Test basic usage of the ActiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -137,11 +134,8 @@ async def test_basic_usage( unregister_listener() -async def test_bleak_error_during_polling( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_bleak_error_during_polling(hass: HomeAssistant) -> None: """Test bleak error during polling ActiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) poll_count = 0 @@ -190,11 +184,8 @@ async def test_bleak_error_during_polling( unregister_listener() -async def test_generic_exception_during_polling( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_generic_exception_during_polling(hass: HomeAssistant) -> None: """Test generic exception during polling ActiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) poll_count = 0 @@ -243,11 +234,8 @@ async def test_generic_exception_during_polling( unregister_listener() -async def test_polling_debounce( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_polling_debounce(hass: HomeAssistant) -> None: """Test basic usage of the ActiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) poll_count = 0 @@ -289,11 +277,8 @@ async def test_polling_debounce( unregister_listener() -async def test_polling_debounce_with_custom_debouncer( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_polling_debounce_with_custom_debouncer(hass: HomeAssistant) -> None: """Test basic usage of the ActiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) poll_count = 0 @@ -338,11 +323,8 @@ async def test_polling_debounce_with_custom_debouncer( unregister_listener() -async def test_polling_rejecting_the_first_time( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_polling_rejecting_the_first_time(hass: HomeAssistant) -> None: """Test need_poll rejects the first time ActiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) attempt = 0 @@ -400,11 +382,8 @@ async def test_polling_rejecting_the_first_time( unregister_listener() -async def test_no_polling_after_stop_event( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_no_polling_after_stop_event(hass: HomeAssistant) -> None: """Test we do not poll after the stop event.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) needs_poll_calls = 0 diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index e854233451e..e19ef1fd6f8 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -49,11 +49,8 @@ GENERIC_BLUETOOTH_SERVICE_INFO_2 = BluetoothServiceInfo( ) -async def test_basic_usage( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_basic_usage(hass: HomeAssistant) -> None: """Test basic usage of the ActiveBluetoothProcessorCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -98,11 +95,8 @@ async def test_basic_usage( cancel() -async def test_poll_can_be_skipped( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_poll_can_be_skipped(hass: HomeAssistant) -> None: """Test need_poll callback works and can skip a poll if its not needed.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -157,11 +151,9 @@ async def test_poll_can_be_skipped( cancel() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_bleak_error_and_recover( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test bleak error handling and recovery.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -222,11 +214,8 @@ async def test_bleak_error_and_recover( cancel() -async def test_poll_failure_and_recover( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_poll_failure_and_recover(hass: HomeAssistant) -> None: """Test error handling and recovery.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -281,11 +270,8 @@ async def test_poll_failure_and_recover( cancel() -async def test_second_poll_needed( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_second_poll_needed(hass: HomeAssistant) -> None: """If a poll is queued, by the time it starts it may no longer be needed.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -332,11 +318,8 @@ async def test_second_poll_needed( cancel() -async def test_rate_limit( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_rate_limit(hass: HomeAssistant) -> None: """Test error handling and recovery.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -384,11 +367,8 @@ async def test_rate_limit( cancel() -async def test_no_polling_after_stop_event( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_no_polling_after_stop_event(hass: HomeAssistant) -> None: """Test we do not poll after the stop event.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) needs_poll_calls = 0 diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 12d34e0a7bc..57fd8354148 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -3,6 +3,7 @@ from datetime import timedelta import time +# pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest @@ -33,11 +34,9 @@ from tests.common import async_fire_time_changed ONE_HOUR_SECONDS = 3600 +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_advertisment_interval_shorter_than_adapter_stack_timeout( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, ) -> None: """Test we can determine the advertisement interval.""" start_monotonic_time = time.monotonic() @@ -83,11 +82,9 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( switchbot_device_unavailable_cancel() +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectable( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, ) -> None: """Test device with a long advertisement interval.""" start_monotonic_time = time.monotonic() @@ -135,11 +132,9 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab switchbot_device_unavailable_cancel() +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_connectable( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, ) -> None: """Test device with a long advertisement interval with an adapter change.""" start_monotonic_time = time.monotonic() @@ -200,11 +195,9 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c switchbot_device_unavailable_cancel() +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_connectable( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, ) -> None: """Test device with a long advertisement interval that is not connectable not reaching the advertising interval.""" start_monotonic_time = time.monotonic() @@ -255,11 +248,9 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne switchbot_device_unavailable_cancel() +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_change_not_connectable( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, ) -> None: """Test device with a short advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() @@ -330,11 +321,9 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ switchbot_device_unavailable_cancel() +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_change_not_connectable( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, ) -> None: """Test device with a long advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() @@ -436,11 +425,9 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c switchbot_device_unavailable_cancel() +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeout_adapter_change_not_connectable( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, ) -> None: """Test device with a increasing advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index a3ec3814a92..1468367fd9a 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -24,7 +24,8 @@ from . import ( ) -async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_by_source(hass: HomeAssistant) -> None: """Test we can get a scanner by source.""" hci2_scanner = FakeScanner("hci2", "hci2") @@ -40,16 +41,16 @@ 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: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_get_advertisement_callback(hass: HomeAssistant) -> None: """Test getting advertisement callback.""" callback = bluetooth.async_get_advertisement_callback(hass) assert callback is not None +@pytest.mark.usefixtures("enable_bluetooth") async def test_async_scanner_devices_by_address_connectable( - hass: HomeAssistant, enable_bluetooth: None + hass: HomeAssistant, ) -> None: """Test getting scanner devices by address with connectable devices.""" manager = _get_manager() @@ -105,8 +106,9 @@ async def test_async_scanner_devices_by_address_connectable( cancel() +@pytest.mark.usefixtures("enable_bluetooth") async def test_async_scanner_devices_by_address_non_connectable( - hass: HomeAssistant, enable_bluetooth: None + hass: HomeAssistant, ) -> None: """Test getting scanner devices by address with non-connectable devices.""" manager = _get_manager() diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 0839c9c56a4..abfbbaa15ab 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -9,6 +9,8 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData + +# pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest @@ -66,9 +68,8 @@ class FakeScanner(BaseHaRemoteScanner): @pytest.mark.parametrize("name_2", [None, "w"]) -async def test_remote_scanner( - hass: HomeAssistant, enable_bluetooth: None, name_2: str | None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() @@ -159,9 +160,8 @@ async def test_remote_scanner( unsetup() -async def test_remote_scanner_expires_connectable( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_remote_scanner_expires_connectable(hass: HomeAssistant) -> None: """Test the remote scanner expires stale connectable data.""" manager = _get_manager() @@ -213,9 +213,8 @@ async def test_remote_scanner_expires_connectable( unsetup() -async def test_remote_scanner_expires_non_connectable( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_remote_scanner_expires_non_connectable(hass: HomeAssistant) -> None: """Test the remote scanner expires stale non connectable data.""" manager = _get_manager() @@ -287,9 +286,8 @@ async def test_remote_scanner_expires_non_connectable( unsetup() -async def test_base_scanner_connecting_behavior( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_base_scanner_connecting_behavior(hass: HomeAssistant) -> None: """Test that the default behavior is to mark the scanner as not scanning when connecting.""" manager = _get_manager() @@ -392,9 +390,8 @@ async def test_restore_history_remote_adapter( unsetup() -async def test_device_with_ten_minute_advertising_interval( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_device_with_ten_minute_advertising_interval(hass: HomeAssistant) -> None: """Test a device with a 10 minute advertising interval.""" manager = _get_manager() @@ -496,9 +493,8 @@ async def test_device_with_ten_minute_advertising_interval( unsetup() -async def test_scanner_stops_responding( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_stops_responding(hass: HomeAssistant) -> None: """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 33474280ec4..0a0cb3fa8e0 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -1,8 +1,9 @@ """Test the bluetooth config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch from bluetooth_adapters import DEFAULT_ADDRESS, AdapterDetails +import pytest from homeassistant import config_entries from homeassistant.components.bluetooth.const import ( @@ -19,12 +20,11 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +@pytest.mark.usefixtures( + "macos_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) async def test_options_flow_disabled_not_setup( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - macos_adapter: None, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test options are disabled if the integration has not been setup.""" await async_setup_component(hass, "config", {}) @@ -49,7 +49,8 @@ async def test_options_flow_disabled_not_setup( await hass.config_entries.async_unload(entry.entry_id) -async def test_async_step_user_macos(hass: HomeAssistant, macos_adapter: None) -> None: +@pytest.mark.usefixtures("macos_adapter") +async def test_async_step_user_macos(hass: HomeAssistant) -> None: """Test setting up manually with one adapter on MacOS.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -73,9 +74,8 @@ async def test_async_step_user_macos(hass: HomeAssistant, macos_adapter: None) - assert len(mock_setup_entry.mock_calls) == 1 -async def test_async_step_user_linux_one_adapter( - hass: HomeAssistant, one_adapter: None -) -> None: +@pytest.mark.usefixtures("one_adapter") +async def test_async_step_user_linux_one_adapter(hass: HomeAssistant) -> None: """Test setting up manually with one adapter on Linux.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -117,9 +117,8 @@ async def test_async_step_user_linux_crashed_adapter( assert result["reason"] == "no_adapters" -async def test_async_step_user_linux_two_adapters( - hass: HomeAssistant, two_adapters: None -) -> None: +@pytest.mark.usefixtures("two_adapters") +async def test_async_step_user_linux_two_adapters(hass: HomeAssistant) -> None: """Test setting up manually with two adapters on Linux.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -147,9 +146,8 @@ async def test_async_step_user_linux_two_adapters( assert len(mock_setup_entry.mock_calls) == 1 -async def test_async_step_user_only_allows_one( - hass: HomeAssistant, macos_adapter: None -) -> None: +@pytest.mark.usefixtures("macos_adapter") +async def test_async_step_user_only_allows_one(hass: HomeAssistant) -> None: """Test setting up manually with an existing entry.""" entry = MockConfigEntry(domain=DOMAIN, unique_id=DEFAULT_ADDRESS) entry.add_to_hass(hass) @@ -199,8 +197,9 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("one_adapter") async def test_async_step_integration_discovery_during_onboarding_one_adapter( - hass: HomeAssistant, one_adapter: None + hass: HomeAssistant, ) -> None: """Test setting up from integration discovery during onboarding.""" details = AdapterDetails( @@ -232,8 +231,9 @@ async def test_async_step_integration_discovery_during_onboarding_one_adapter( assert len(mock_onboarding.mock_calls) == 1 +@pytest.mark.usefixtures("two_adapters") async def test_async_step_integration_discovery_during_onboarding_two_adapters( - hass: HomeAssistant, two_adapters: None + hass: HomeAssistant, ) -> None: """Test setting up from integration discovery during onboarding.""" details1 = AdapterDetails( @@ -281,8 +281,9 @@ async def test_async_step_integration_discovery_during_onboarding_two_adapters( assert len(mock_onboarding.mock_calls) == 2 +@pytest.mark.usefixtures("macos_adapter") async def test_async_step_integration_discovery_during_onboarding( - hass: HomeAssistant, macos_adapter: None + hass: HomeAssistant, ) -> None: """Test setting up from integration discovery during onboarding.""" details = AdapterDetails( @@ -336,12 +337,10 @@ async def test_async_step_integration_discovery_already_exists( assert result["reason"] == "already_configured" -async def test_options_flow_linux( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - one_adapter: None, -) -> None: +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) +async def test_options_flow_linux(hass: HomeAssistant) -> None: """Test options on Linux.""" entry = MockConfigEntry( domain=DOMAIN, @@ -390,12 +389,11 @@ async def test_options_flow_linux( await hass.config_entries.async_unload(entry.entry_id) +@pytest.mark.usefixtures( + "macos_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) async def test_options_flow_disabled_macos( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - macos_adapter: None, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test options are disabled on MacOS.""" await async_setup_component(hass, "config", {}) @@ -420,12 +418,11 @@ async def test_options_flow_disabled_macos( await hass.config_entries.async_unload(entry.entry_id) +@pytest.mark.usefixtures( + "one_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) async def test_options_flow_enabled_linux( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - one_adapter: None, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test options are enabled on Linux.""" await async_setup_component(hass, "config", {}) @@ -453,9 +450,8 @@ async def test_options_flow_enabled_linux( await hass.config_entries.async_unload(entry.entry_id) -async def test_async_step_user_linux_adapter_is_ignored( - hass: HomeAssistant, one_adapter: None -) -> None: +@pytest.mark.usefixtures("one_adapter") +async def test_async_step_user_linux_adapter_is_ignored(hass: HomeAssistant) -> None: """Test we give a hint that the adapter is ignored.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 462c43380a8..be4412db4d8 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -4,6 +4,7 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -43,12 +44,11 @@ class FakeHaScanner(FakeScannerMixin, HaScanner): @patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_bleak_scanner_start: MagicMock, - enable_bluetooth: None, - two_adapters: None, ) -> None: """Test we can setup and unsetup bluetooth with multiple adapters.""" # Normally we do not want to patch our classes, but since bleak will import @@ -237,12 +237,11 @@ async def test_diagnostics( @patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) +@pytest.mark.usefixtures( + "macos_adapter", "mock_bleak_scanner_start", "mock_bluetooth_adapters" +) async def test_diagnostics_macos( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - macos_adapter, + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test diagnostics for macos.""" # Normally we do not want to patch our classes, but since bleak will import @@ -414,13 +413,14 @@ async def test_diagnostics_macos( @patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) +@pytest.mark.usefixtures( + "enable_bluetooth", + "one_adapter", + "mock_bleak_scanner_start", + "mock_bluetooth_adapters", +) async def test_diagnostics_remote_adapter( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - enable_bluetooth: None, - one_adapter: None, + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test diagnostics for remote adapter.""" manager = _get_manager() diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index ebc50779c9c..bd38c9cfbae 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -40,7 +40,7 @@ from homeassistant.components.bluetooth.match import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -59,8 +59,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("enable_bluetooth") async def test_setup_and_stop( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we and setup and stop the scanner.""" mock_bt = [ @@ -84,8 +85,9 @@ async def test_setup_and_stop( assert len(mock_bleak_scanner_start.mock_calls) == 1 +@pytest.mark.usefixtures("one_adapter") async def test_setup_and_stop_passive( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, one_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we and setup and stop the scanner the passive scanner.""" entry = MockConfigEntry( @@ -127,7 +129,7 @@ async def test_setup_and_stop_passive( assert init_kwargs == { "adapter": "hci0", - "bluez": scanner.PASSIVE_SCANNER_ARGS, + "bluez": scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member "scanning_mode": "passive", "detection_callback": ANY, } @@ -183,8 +185,9 @@ async def test_setup_and_stop_old_bluez( } +@pytest.mark.usefixtures("one_adapter") async def test_setup_and_stop_no_bluetooth( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, one_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we fail gracefully when bluetooth is not available.""" mock_bt = [ @@ -211,8 +214,9 @@ async def test_setup_and_stop_no_bluetooth( assert "Failed to initialize Bluetooth" in caplog.text +@pytest.mark.usefixtures("macos_adapter") async def test_setup_and_stop_broken_bluetooth( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, macos_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] @@ -236,8 +240,9 @@ async def test_setup_and_stop_broken_bluetooth( assert len(bluetooth.async_discovered_service_info(hass)) == 0 +@pytest.mark.usefixtures("macos_adapter") async def test_setup_and_stop_broken_bluetooth_hanging( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, macos_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we fail gracefully when bluetooth/dbus is hanging.""" mock_bt = [] @@ -265,8 +270,9 @@ async def test_setup_and_stop_broken_bluetooth_hanging( assert "Timed out starting Bluetooth" in caplog.text +@pytest.mark.usefixtures("macos_adapter") async def test_setup_and_retry_adapter_not_yet_available( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, macos_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we retry if the adapter is not yet available.""" mock_bt = [] @@ -304,8 +310,9 @@ async def test_setup_and_retry_adapter_not_yet_available( await hass.async_block_till_done() +@pytest.mark.usefixtures("macos_adapter") async def test_no_race_during_manual_reload_in_retry_state( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, macos_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] @@ -344,8 +351,9 @@ async def test_no_race_during_manual_reload_in_retry_state( await hass.async_block_till_done() +@pytest.mark.usefixtures("macos_adapter") async def test_calling_async_discovered_devices_no_bluetooth( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, macos_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] @@ -370,8 +378,9 @@ async def test_calling_async_discovered_devices_no_bluetooth( assert not bluetooth.async_address_present(hass, "aa:bb:bb:dd:ee:ff") +@pytest.mark.usefixtures("enable_bluetooth") async def test_discovery_match_by_service_uuid( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by service_uuid.""" mock_bt = [ @@ -423,11 +432,11 @@ async def test_discovery_match_by_service_uuid( } ], ) +@pytest.mark.usefixtures("mock_bluetooth_adapters") async def test_discovery_match_by_service_uuid_and_short_local_name( mock_async_get_bluetooth: AsyncMock, hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, ) -> None: """Test bluetooth discovery match by service_uuid and short local name.""" entry = MockConfigEntry(domain="bluetooth", unique_id="00:00:00:00:00:01") @@ -467,8 +476,9 @@ def _domains_from_mock_config_flow(mock_config_flow: Mock) -> list[str]: return [call[1][0] for call in mock_config_flow.mock_calls if call[1][0] != DOMAIN] +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_service_uuid_connectable( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by service_uuid and the ble device is connectable.""" mock_bt = [ @@ -518,8 +528,9 @@ async def test_discovery_match_by_service_uuid_connectable( assert called_domains == ["switchbot"] +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_service_uuid_not_connectable( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by service_uuid and the ble device is not connectable.""" mock_bt = [ @@ -567,8 +578,9 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_name_connectable_false( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by name and the integration will take non-connectable devices.""" mock_bt = [ @@ -645,8 +657,9 @@ async def test_discovery_match_by_name_connectable_false( assert _domains_from_mock_config_flow(mock_config_flow) == ["qingping"] +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_local_name( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by local_name.""" mock_bt = [{"domain": "switchbot", "local_name": "wohand"}] @@ -683,8 +696,9 @@ async def test_discovery_match_by_local_name( assert mock_config_flow.mock_calls[0][1][0] == "switchbot" +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by manufacturer_id and manufacturer_data_start.""" mock_bt = [ @@ -759,8 +773,9 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_service_data_uuid_then_others( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by service_data_uuid and then other fields.""" mock_bt = [ @@ -913,8 +928,9 @@ async def test_discovery_match_by_service_data_uuid_then_others( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_service_data_uuid_when_format_changes( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by service_data_uuid when format changes.""" mock_bt = [ @@ -996,8 +1012,9 @@ async def test_discovery_match_by_service_data_uuid_when_format_changes( mock_config_flow.reset_mock() +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_by_service_data_uuid_bthome( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery match by service_data_uuid for bthome.""" mock_bt = [ @@ -1038,8 +1055,9 @@ async def test_discovery_match_by_service_data_uuid_bthome( mock_config_flow.reset_mock() +@pytest.mark.usefixtures("macos_adapter") async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery matches twice for service_uuid and then manufacturer_id.""" mock_bt = [ @@ -1102,8 +1120,9 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( assert len(mock_config_flow.mock_calls) == 0 +@pytest.mark.usefixtures("enable_bluetooth") async def test_rediscovery( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test bluetooth discovery can be re-enabled for a given domain.""" mock_bt = [ @@ -1149,8 +1168,9 @@ async def test_rediscovery( assert mock_config_flow.mock_calls[1][1][0] == "switchbot" +@pytest.mark.usefixtures("macos_adapter") async def test_async_discovered_device_api( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test the async_discovered_device API.""" mock_bt = [] @@ -1255,8 +1275,9 @@ async def test_async_discovered_device_api( assert bluetooth.async_address_present(hass, "44:44:33:11:23:45") is True +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callbacks( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback.""" mock_bt = [] @@ -1336,10 +1357,10 @@ async def test_register_callbacks( assert service_info.manufacturer_id == 89 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callbacks_raises_exception( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, - enable_bluetooth: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test registering a callback that raises ValueError.""" @@ -1401,8 +1422,9 @@ async def test_register_callbacks_raises_exception( assert "ValueError" in caplog.text +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_address( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by address.""" mock_bt = [] @@ -1492,8 +1514,9 @@ async def test_register_callback_by_address( assert service_info.manufacturer_id == 89 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_address_connectable_only( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by address connectable only.""" mock_bt = [] @@ -1571,8 +1594,9 @@ async def test_register_callback_by_address_connectable_only( assert len(non_connectable_callbacks) == 2 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_manufacturer_id( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by manufacturer_id.""" mock_bt = [] @@ -1626,8 +1650,9 @@ async def test_register_callback_by_manufacturer_id( assert service_info.manufacturer_id == 21 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_connectable( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by connectable.""" mock_bt = [] @@ -1681,8 +1706,9 @@ async def test_register_callback_by_connectable( assert service_info.name == "empty" +@pytest.mark.usefixtures("enable_bluetooth") async def test_not_filtering_wanted_apple_devices( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test filtering noisy apple devices.""" mock_bt = [] @@ -1741,8 +1767,9 @@ async def test_not_filtering_wanted_apple_devices( assert len(callbacks) == 3 +@pytest.mark.usefixtures("enable_bluetooth") async def test_filtering_noisy_apple_devices( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test filtering noisy apple devices.""" mock_bt = [] @@ -1791,8 +1818,9 @@ async def test_filtering_noisy_apple_devices( assert len(callbacks) == 0 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_address_connectable_manufacturer_id( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by address, manufacturer_id, and connectable.""" mock_bt = [] @@ -1845,8 +1873,9 @@ async def test_register_callback_by_address_connectable_manufacturer_id( assert service_info.manufacturer_id == 21 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_manufacturer_id_and_address( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by manufacturer_id and address.""" mock_bt = [] @@ -1910,8 +1939,9 @@ async def test_register_callback_by_manufacturer_id_and_address( assert service_info.manufacturer_id == 21 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_service_uuid_and_address( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by service_uuid and address.""" mock_bt = [] @@ -1983,8 +2013,9 @@ async def test_register_callback_by_service_uuid_and_address( assert service_info.name == "switchbot" +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_service_data_uuid_and_address( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by service_data_uuid and address.""" mock_bt = [] @@ -2056,8 +2087,9 @@ async def test_register_callback_by_service_data_uuid_and_address( assert service_info.name == "switchbot" +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_local_name( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by local_name.""" mock_bt = [] @@ -2119,11 +2151,9 @@ async def test_register_callback_by_local_name( assert service_info.manufacturer_id == 21 +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_local_name_overly_broad( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - enable_bluetooth: None, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by local_name that is too broad.""" mock_bt = [] @@ -2147,8 +2177,9 @@ async def test_register_callback_by_local_name_overly_broad( ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_by_service_data_uuid( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by service_data_uuid.""" mock_bt = [] @@ -2202,8 +2233,9 @@ async def test_register_callback_by_service_data_uuid( assert service_info.name == "xiaomi" +@pytest.mark.usefixtures("enable_bluetooth") async def test_register_callback_survives_reload( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test registering a callback by address survives bluetooth being reloaded.""" mock_bt = [] @@ -2265,8 +2297,9 @@ async def test_register_callback_survives_reload( cancel() +@pytest.mark.usefixtures("enable_bluetooth") async def test_process_advertisements_bail_on_good_advertisement( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test as soon as we see a 'good' advertisement we return it.""" done = asyncio.Future() @@ -2304,8 +2337,9 @@ async def test_process_advertisements_bail_on_good_advertisement( assert result.name == "wohand" +@pytest.mark.usefixtures("enable_bluetooth") async def test_process_advertisements_ignore_bad_advertisement( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Check that we ignore bad advertisements.""" done = asyncio.Event() @@ -2358,8 +2392,9 @@ async def test_process_advertisements_ignore_bad_advertisement( assert result.service_data["00000d00-0000-1000-8000-00805f9b34fa"] == b"H\x10c" +@pytest.mark.usefixtures("enable_bluetooth") async def test_process_advertisements_timeout( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we timeout if no advertisements at all.""" @@ -2372,8 +2407,9 @@ async def test_process_advertisements_timeout( ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_instance_with_filter( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test consumers can use the wrapped instance with a filter as if it was normal BleakScanner.""" with patch( @@ -2444,8 +2480,9 @@ async def test_wrapped_instance_with_filter( assert len(detected) == 4 +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_instance_with_service_uuids( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test consumers can use the wrapped instance with a service_uuids list as if it was normal BleakScanner.""" with patch( @@ -2500,8 +2537,9 @@ async def test_wrapped_instance_with_service_uuids( assert len(detected) == 2 +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_instance_with_service_uuids_with_coro_callback( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test consumers can use the wrapped instance with a service_uuids list as if it was normal BleakScanner. @@ -2559,8 +2597,9 @@ async def test_wrapped_instance_with_service_uuids_with_coro_callback( assert len(detected) == 2 +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_instance_with_broken_callbacks( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test broken callbacks do not cause the scanner to fail.""" with ( @@ -2606,8 +2645,9 @@ async def test_wrapped_instance_with_broken_callbacks( assert len(detected) == 1 +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_instance_changes_uuids( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test consumers can use the wrapped instance can change the uuids later.""" with patch( @@ -2661,8 +2701,9 @@ async def test_wrapped_instance_changes_uuids( assert len(detected) == 2 +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_instance_changes_filters( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test consumers can use the wrapped instance can change the filter later.""" with patch( @@ -2717,11 +2758,11 @@ async def test_wrapped_instance_changes_filters( assert len(detected) == 2 +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_instance_unsupported_filter( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, ) -> None: """Test we want when their filter is ineffective.""" with patch( @@ -2743,8 +2784,9 @@ async def test_wrapped_instance_unsupported_filter( assert "Only UUIDs filters are supported" in caplog.text +@pytest.mark.usefixtures("macos_adapter") async def test_async_ble_device_from_address( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test the async_ble_device_from_address api.""" set_manager(None) @@ -2800,8 +2842,9 @@ async def test_async_ble_device_from_address( ) +@pytest.mark.usefixtures("macos_adapter") async def test_can_unsetup_bluetooth_single_adapter_macos( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we can setup and unsetup bluetooth.""" entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={}, unique_id=DEFAULT_ADDRESS) @@ -2815,10 +2858,10 @@ async def test_can_unsetup_bluetooth_single_adapter_macos( await hass.async_block_till_done() +@pytest.mark.usefixtures("one_adapter") async def test_default_address_config_entries_removed_linux( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, - one_adapter: None, ) -> None: """Test default address entries are removed on linux.""" entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={}, unique_id=DEFAULT_ADDRESS) @@ -2828,11 +2871,9 @@ async def test_default_address_config_entries_removed_linux( assert not hass.config_entries.async_entries(bluetooth.DOMAIN) +@pytest.mark.usefixtures("enable_bluetooth", "one_adapter") async def test_can_unsetup_bluetooth_single_adapter_linux( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - enable_bluetooth: None, - one_adapter: None, + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we can setup and unsetup bluetooth.""" entry = MockConfigEntry( @@ -2848,11 +2889,10 @@ async def test_can_unsetup_bluetooth_single_adapter_linux( await hass.async_block_till_done() +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_can_unsetup_bluetooth_multiple_adapters( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, - enable_bluetooth: None, - two_adapters: None, ) -> None: """Test we can setup and unsetup bluetooth with multiple adapters.""" entry1 = MockConfigEntry( @@ -2874,11 +2914,10 @@ async def test_can_unsetup_bluetooth_multiple_adapters( await hass.async_block_till_done() +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_three_adapters_one_missing( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, - enable_bluetooth: None, - two_adapters: None, ) -> None: """Test three adapters but one is missing results in a retry on setup.""" entry = MockConfigEntry( @@ -2890,9 +2929,8 @@ async def test_three_adapters_one_missing( assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_auto_detect_bluetooth_adapters_linux( - hass: HomeAssistant, one_adapter: None -) -> None: +@pytest.mark.usefixtures("one_adapter") +async def test_auto_detect_bluetooth_adapters_linux(hass: HomeAssistant) -> None: """Test we auto detect bluetooth adapters on linux.""" assert await async_setup_component(hass, bluetooth.DOMAIN, {}) await hass.async_block_till_done() @@ -2900,8 +2938,9 @@ async def test_auto_detect_bluetooth_adapters_linux( assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 1 +@pytest.mark.usefixtures("two_adapters") async def test_auto_detect_bluetooth_adapters_linux_multiple( - hass: HomeAssistant, two_adapters: None + hass: HomeAssistant, ) -> None: """Test we auto detect bluetooth adapters on linux with multiple adapters.""" assert await async_setup_component(hass, bluetooth.DOMAIN, {}) @@ -2959,17 +2998,17 @@ async def test_no_auto_detect_bluetooth_adapters_windows(hass: HomeAssistant) -> assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 0 +@pytest.mark.usefixtures("enable_bluetooth") async def test_getting_the_scanner_returns_the_wrapped_instance( - hass: HomeAssistant, enable_bluetooth: None + hass: HomeAssistant, ) -> None: """Test getting the scanner returns the wrapped instance.""" scanner = bluetooth.async_get_scanner(hass) assert isinstance(scanner, HaBleakScannerWrapper) -async def test_scanner_count_connectable( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_count_connectable(hass: HomeAssistant) -> None: """Test getting the connectable scanner count.""" scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner) @@ -2977,7 +3016,8 @@ async def test_scanner_count_connectable( cancel() -async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_scanner_count(hass: HomeAssistant) -> None: """Test getting the connectable and non-connectable scanner count.""" scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner) @@ -2985,8 +3025,9 @@ async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> Non cancel() +@pytest.mark.usefixtures("macos_adapter") async def test_migrate_single_entry_macos( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we can migrate a single entry on MacOS.""" entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={}) @@ -2996,8 +3037,9 @@ async def test_migrate_single_entry_macos( assert entry.unique_id == DEFAULT_ADDRESS +@pytest.mark.usefixtures("one_adapter") async def test_migrate_single_entry_linux( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, one_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we can migrate a single entry on Linux.""" entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={}) @@ -3007,8 +3049,9 @@ async def test_migrate_single_entry_linux( assert entry.unique_id == "00:00:00:00:00:01" +@pytest.mark.usefixtures("one_adapter") async def test_discover_new_usb_adapters( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, one_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we can discover new usb adapters.""" entry = MockConfigEntry( @@ -3067,8 +3110,9 @@ async def test_discover_new_usb_adapters( assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 1 +@pytest.mark.usefixtures("one_adapter") async def test_discover_new_usb_adapters_with_firmware_fallback_delay( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, one_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we can discover new usb adapters with a firmware fallback delay.""" entry = MockConfigEntry( @@ -3146,11 +3190,12 @@ async def test_discover_new_usb_adapters_with_firmware_fallback_delay( assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 1 +@pytest.mark.usefixtures("no_adapters") async def test_issue_outdated_haos_removed( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, - no_adapters: None, operating_system_85: None, + issue_registry: ir.IssueRegistry, ) -> None: """Test we do not create an issue on outdated haos anymore.""" assert await async_setup_component(hass, bluetooth.DOMAIN, {}) @@ -3158,16 +3203,16 @@ async def test_issue_outdated_haos_removed( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "haos_outdated") + issue = issue_registry.async_get_issue(DOMAIN, "haos_outdated") assert issue is None +@pytest.mark.usefixtures("one_adapter") async def test_haos_9_or_later( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, - one_adapter: None, operating_system_90: None, + issue_registry: ir.IssueRegistry, ) -> None: """Test we do not create issues for haos 9.x or later.""" entry = MockConfigEntry( @@ -3178,13 +3223,13 @@ async def test_haos_9_or_later( await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "haos_outdated") + issue = issue_registry.async_get_issue(DOMAIN, "haos_outdated") assert issue is None +@pytest.mark.usefixtures("one_adapter") async def test_title_updated_if_mac_address( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, one_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test the title is updated if it is the mac address.""" entry = MockConfigEntry( diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index cb2be8a0e8d..4bff7cbe94d 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,6 +1,5 @@ """Tests for the Bluetooth integration manager.""" -from collections.abc import Generator from datetime import timedelta import time from typing import Any @@ -8,8 +7,11 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory + +# pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest +from typing_extensions import Generator from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -54,7 +56,7 @@ from tests.common import async_fire_time_changed, load_fixture @pytest.fixture -def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: +def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: """Register an hci0 scanner.""" hci0_scanner = FakeScanner("hci0", "hci0") cancel = bluetooth.async_register_scanner(hass, hci0_scanner) @@ -63,7 +65,7 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture -def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: +def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: """Register an hci1 scanner.""" hci1_scanner = FakeScanner("hci1", "hci1") cancel = bluetooth.async_register_scanner(hass, hci1_scanner) @@ -71,9 +73,9 @@ def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: cancel() +@pytest.mark.usefixtures("enable_bluetooth") async def test_advertisements_do_not_switch_adapters_for_no_reason( hass: HomeAssistant, - enable_bluetooth: None, register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: @@ -128,9 +130,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_switching_adapters_based_on_rssi( hass: HomeAssistant, - enable_bluetooth: None, register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: @@ -189,9 +191,9 @@ async def test_switching_adapters_based_on_rssi( ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_switching_adapters_based_on_zero_rssi( hass: HomeAssistant, - enable_bluetooth: None, register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: @@ -250,9 +252,9 @@ async def test_switching_adapters_based_on_zero_rssi( ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_switching_adapters_based_on_stale( hass: HomeAssistant, - enable_bluetooth: None, register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: @@ -317,9 +319,9 @@ async def test_switching_adapters_based_on_stale( ) +@pytest.mark.usefixtures("enable_bluetooth") 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: @@ -400,8 +402,9 @@ async def test_switching_adapters_based_on_stale_with_discovered_interval( ) +@pytest.mark.usefixtures("one_adapter") async def test_restore_history_from_dbus( - hass: HomeAssistant, one_adapter: None, disable_new_discovery_flows + hass: HomeAssistant, disable_new_discovery_flows ) -> None: """Test we can restore history from dbus.""" address = "AA:BB:CC:CC:CC:FF" @@ -423,9 +426,9 @@ async def test_restore_history_from_dbus( assert bluetooth.async_ble_device_from_address(hass, address) is ble_device +@pytest.mark.usefixtures("one_adapter") async def test_restore_history_from_dbus_and_remote_adapters( hass: HomeAssistant, - one_adapter: None, hass_storage: dict[str, Any], disable_new_discovery_flows, ) -> None: @@ -463,9 +466,9 @@ async def test_restore_history_from_dbus_and_remote_adapters( assert disable_new_discovery_flows.call_count > 1 +@pytest.mark.usefixtures("one_adapter") async def test_restore_history_from_dbus_and_corrupted_remote_adapters( hass: HomeAssistant, - one_adapter: None, hass_storage: dict[str, Any], disable_new_discovery_flows, ) -> None: @@ -501,9 +504,9 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( assert disable_new_discovery_flows.call_count >= 1 +@pytest.mark.usefixtures("enable_bluetooth") async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( hass: HomeAssistant, - enable_bluetooth: None, register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: @@ -589,9 +592,9 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_connectable( hass: HomeAssistant, - enable_bluetooth: None, register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: @@ -640,8 +643,9 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_switching_adapters_when_one_goes_away( - hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None + hass: HomeAssistant, register_hci0_scanner: None ) -> None: """Test switching adapters when one goes away.""" cancel_hci2 = bluetooth.async_register_scanner(hass, FakeScanner("hci2", "hci2")) @@ -689,8 +693,9 @@ async def test_switching_adapters_when_one_goes_away( ) +@pytest.mark.usefixtures("enable_bluetooth") async def test_switching_adapters_when_one_stop_scanning( - hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None + hass: HomeAssistant, register_hci0_scanner: None ) -> None: """Test switching adapters when stops scanning.""" hci2_scanner = FakeScanner("hci2", "hci2") @@ -741,8 +746,9 @@ async def test_switching_adapters_when_one_stop_scanning( cancel_hci2() +@pytest.mark.usefixtures("mock_bluetooth_adapters") async def test_goes_unavailable_connectable_only_and_recovers( - hass: HomeAssistant, mock_bluetooth_adapters: None + hass: HomeAssistant, ) -> None: """Test all connectable scanners go unavailable, and than recover when there is a non-connectable scanner.""" assert await async_setup_component(hass, bluetooth.DOMAIN, {}) @@ -904,8 +910,9 @@ async def test_goes_unavailable_connectable_only_and_recovers( unsetup_not_connectable_scanner() +@pytest.mark.usefixtures("mock_bluetooth_adapters") async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( - hass: HomeAssistant, mock_bluetooth_adapters: None + hass: HomeAssistant, ) -> None: """Test that unavailable will dismiss any active discoveries and make device discoverable again.""" mock_bt = [ @@ -1076,9 +1083,9 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( cancel_connectable_scanner() +@pytest.mark.usefixtures("enable_bluetooth") async def test_debug_logging( hass: HomeAssistant, - enable_bluetooth: None, register_hci0_scanner: None, register_hci1_scanner: None, caplog: pytest.LogCaptureFixture, @@ -1135,12 +1142,8 @@ async def test_debug_logging( assert "wohand_good_signal_hci0" not in caplog.text -async def test_set_fallback_interval_small( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") +async def test_set_fallback_interval_small(hass: HomeAssistant) -> None: """Test we can set the fallback advertisement interval.""" assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None @@ -1193,12 +1196,8 @@ async def test_set_fallback_interval_small( assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None -async def test_set_fallback_interval_big( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") +async def test_set_fallback_interval_big(hass: HomeAssistant) -> None: """Test we can set the fallback advertisement interval.""" assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 087d443c5a0..d36741b4d5d 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -29,9 +29,8 @@ from . import ( ) -async def test_wrapped_bleak_scanner( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_wrapped_bleak_scanner(hass: HomeAssistant) -> None: """Test wrapped bleak scanner dispatches calls as expected.""" scanner = HaBleakScannerWrapper() switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") @@ -43,9 +42,8 @@ async def test_wrapped_bleak_scanner( assert await scanner.discover() == [switchbot_device] -async def test_wrapped_bleak_client_raises_device_missing( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_wrapped_bleak_client_raises_device_missing(hass: HomeAssistant) -> None: """Test wrapped bleak client dispatches calls as expected.""" switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") client = HaBleakClientWrapper(switchbot_device) @@ -57,8 +55,9 @@ async def test_wrapped_bleak_client_raises_device_missing( assert await client.clear_cache() is False +@pytest.mark.usefixtures("enable_bluetooth") async def test_wrapped_bleak_client_set_disconnected_callback_before_connected( - hass: HomeAssistant, enable_bluetooth: None + hass: HomeAssistant, ) -> None: """Test wrapped bleak client can set a disconnected callback before connected.""" switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") @@ -66,9 +65,8 @@ async def test_wrapped_bleak_client_set_disconnected_callback_before_connected( client.set_disconnected_callback(lambda client: None) -async def test_wrapped_bleak_client_local_adapter_only( - hass: HomeAssistant, enable_bluetooth: None, one_adapter: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "one_adapter") +async def test_wrapped_bleak_client_local_adapter_only(hass: HomeAssistant) -> None: """Test wrapped bleak client with only a local adapter.""" manager = _get_manager() @@ -109,6 +107,7 @@ async def test_wrapped_bleak_client_local_adapter_only( "00:00:00:00:00:01", "hci0", ) + # pylint: disable-next=attribute-defined-outside-init scanner.connectable = True cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( @@ -132,8 +131,9 @@ async def test_wrapped_bleak_client_local_adapter_only( cancel() +@pytest.mark.usefixtures("enable_bluetooth", "one_adapter") async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( - hass: HomeAssistant, enable_bluetooth: None, one_adapter: None + hass: HomeAssistant, ) -> None: """Test wrapped bleak client can set a disconnected callback after connected.""" manager = _get_manager() @@ -222,8 +222,9 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( cancel() +@pytest.mark.usefixtures("enable_bluetooth", "one_adapter") async def test_ble_device_with_proxy_client_out_of_connections_no_scanners( - hass: HomeAssistant, enable_bluetooth: None, one_adapter: None + hass: HomeAssistant, ) -> None: """Test we switch to the next available proxy when one runs out of connections with no scanners.""" manager = _get_manager() @@ -260,8 +261,9 @@ async def test_ble_device_with_proxy_client_out_of_connections_no_scanners( await client.disconnect() +@pytest.mark.usefixtures("enable_bluetooth", "one_adapter") async def test_ble_device_with_proxy_client_out_of_connections( - hass: HomeAssistant, enable_bluetooth: None, one_adapter: None + hass: HomeAssistant, ) -> None: """Test handling all scanners are out of connection slots.""" manager = _get_manager() @@ -326,9 +328,8 @@ async def test_ble_device_with_proxy_client_out_of_connections( cancel() -async def test_ble_device_with_proxy_clear_cache( - hass: HomeAssistant, enable_bluetooth: None, one_adapter: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "one_adapter") +async def test_ble_device_with_proxy_clear_cache(hass: HomeAssistant) -> None: """Test we can clear cache on the proxy.""" manager = _get_manager() @@ -388,8 +389,9 @@ async def test_ble_device_with_proxy_clear_cache( cancel() +@pytest.mark.usefixtures("enable_bluetooth", "one_adapter") async def test_ble_device_with_proxy_client_out_of_connections_uses_best_available( - hass: HomeAssistant, enable_bluetooth: None, one_adapter: None + hass: HomeAssistant, ) -> None: """Test we switch to the next available proxy when one runs out of connections.""" manager = _get_manager() @@ -495,8 +497,9 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab cancel() +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_ble_device_with_proxy_client_out_of_connections_uses_best_available_macos( - hass: HomeAssistant, enable_bluetooth: None, macos_adapter: None + hass: HomeAssistant, ) -> None: """Test we switch to the next available proxy when one runs out of connections on MacOS.""" manager = _get_manager() diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 54d4f8d5662..9b668b97177 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -8,6 +8,8 @@ import time from typing import Any from unittest.mock import MagicMock, patch +import pytest + from homeassistant.components.bluetooth import ( DOMAIN, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -50,7 +52,13 @@ GENERIC_BLUETOOTH_SERVICE_INFO = BluetoothServiceInfo( class MyCoordinator(PassiveBluetoothDataUpdateCoordinator): """An example coordinator that subclasses PassiveBluetoothDataUpdateCoordinator.""" - def __init__(self, hass, logger, device_id, mode) -> None: + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + device_id: str, + mode: BluetoothScanningMode, + ) -> None: """Initialize the coordinator.""" super().__init__(hass, logger, device_id, mode) self.data: dict[str, Any] = {} @@ -65,11 +73,8 @@ class MyCoordinator(PassiveBluetoothDataUpdateCoordinator): super()._async_handle_bluetooth_event(service_info, change) -async def test_basic_usage( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_basic_usage(hass: HomeAssistant) -> None: """Test basic usage of the PassiveBluetoothDataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) coordinator = MyCoordinator( @@ -97,10 +102,9 @@ async def test_basic_usage( cancel() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_context_compatiblity_with_data_update_coordinator( hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, ) -> None: """Test contexts can be passed for compatibility with DataUpdateCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -135,10 +139,9 @@ async def test_context_compatiblity_with_data_update_coordinator( assert not set(coordinator.async_contexts()) +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_unavailable_callbacks_mark_the_coordinator_unavailable( hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, ) -> None: """Test that the coordinator goes unavailable when the bluetooth stack no longer sees the device.""" start_monotonic = time.monotonic() @@ -196,11 +199,8 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( assert coordinator.available is False -async def test_passive_bluetooth_coordinator_entity( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_passive_bluetooth_coordinator_entity(hass: HomeAssistant) -> None: """Test integration of PassiveBluetoothDataUpdateCoordinator with PassiveBluetoothCoordinatorEntity.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) coordinator = MyCoordinator( diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 047034bbf63..8e1163c0bdb 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -174,11 +174,8 @@ GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_DEVICE_NAME_AND_TEMP_CHANGE = ( ) -async def test_basic_usage( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_basic_usage(hass: HomeAssistant) -> None: """Test basic usage of the PassiveBluetoothProcessorCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -276,10 +273,9 @@ async def test_basic_usage( cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_entity_key_is_dispatched_on_entity_key_change( hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, ) -> None: """Test entity key listeners are only dispatched on change.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -398,11 +394,8 @@ async def test_entity_key_is_dispatched_on_entity_key_change( cancel_coordinator() -async def test_unavailable_after_no_data( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_unavailable_after_no_data(hass: HomeAssistant) -> None: """Test that the coordinator is unavailable after no data for a while.""" start_monotonic = time.monotonic() @@ -513,11 +506,8 @@ async def test_unavailable_after_no_data( cancel_coordinator() -async def test_no_updates_once_stopping( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_no_updates_once_stopping(hass: HomeAssistant) -> None: """Test updates are ignored once hass is stopping.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -570,11 +560,9 @@ async def test_no_updates_once_stopping( cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_exception_from_update_method( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we handle exceptions from the update method.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -595,6 +583,7 @@ async def test_exception_from_update_method( nonlocal run_count run_count += 1 if run_count == 2: + # pylint: disable-next=broad-exception-raised raise Exception("Test exception") return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE @@ -639,11 +628,8 @@ async def test_exception_from_update_method( cancel_coordinator() -async def test_bad_data_from_update_method( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_bad_data_from_update_method(hass: HomeAssistant) -> None: """Test we handle bad data from the update method.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -996,11 +982,8 @@ GOVEE_B5178_PRIMARY_AND_REMOTE_PASSIVE_BLUETOOTH_DATA_UPDATE = ( ) -async def test_integration_with_entity( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_integration_with_entity(hass: HomeAssistant) -> None: """Test integration of PassiveBluetoothProcessorCoordinator with PassiveBluetoothCoordinatorEntity.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -1158,11 +1141,8 @@ NO_DEVICES_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( ) -async def test_integration_with_entity_without_a_device( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_integration_with_entity_without_a_device(hass: HomeAssistant) -> None: """Test integration with PassiveBluetoothCoordinatorEntity with no device.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -1224,10 +1204,9 @@ async def test_integration_with_entity_without_a_device( cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_passive_bluetooth_entity_with_entity_platform( hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, ) -> None: """Test with a mock entity platform.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -1331,11 +1310,8 @@ DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( ) -async def test_integration_multiple_entity_platforms( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_integration_multiple_entity_platforms(hass: HomeAssistant) -> None: """Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -1426,11 +1402,9 @@ async def test_integration_multiple_entity_platforms( cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_exception_from_coordinator_update_method( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we handle exceptions from the update method.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -1444,6 +1418,7 @@ async def test_exception_from_coordinator_update_method( nonlocal run_count run_count += 1 if run_count == 2: + # pylint: disable-next=broad-exception-raised raise Exception("Test exception") return {"test": "data"} @@ -1485,11 +1460,9 @@ async def test_exception_from_coordinator_update_method( cancel_coordinator() +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") async def test_integration_multiple_entity_platforms_with_reload_and_restart( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, - hass_storage: dict[str, Any], + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms with reload.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -1791,11 +1764,8 @@ NAMING_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( ) -async def test_naming( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - mock_bluetooth_adapters: None, -) -> None: +@pytest.mark.usefixtures("mock_bleak_scanner_start", "mock_bluetooth_adapters") +async def test_naming(hass: HomeAssistant) -> None: """Test basic usage of the PassiveBluetoothProcessorCoordinator.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 5658aea523b..dc25f29111c 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -39,11 +39,9 @@ NEED_RESET_ERRORS = [ ] +@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") async def test_config_entry_can_be_reloaded_when_stop_raises( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_bluetooth: None, - macos_adapter: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we can reload if stopping the scanner raises.""" entry = hass.config_entries.async_entries(bluetooth.DOMAIN)[0] @@ -60,8 +58,9 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert "Error stopping scanner" in caplog.text +@pytest.mark.usefixtures("one_adapter") async def test_dbus_socket_missing_in_container( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, one_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we handle dbus being missing in the container.""" @@ -83,8 +82,9 @@ async def test_dbus_socket_missing_in_container( assert "docker" in caplog.text +@pytest.mark.usefixtures("one_adapter") async def test_dbus_socket_missing( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, one_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we handle dbus being missing.""" @@ -106,8 +106,9 @@ async def test_dbus_socket_missing( assert "docker" not in caplog.text +@pytest.mark.usefixtures("one_adapter") async def test_dbus_broken_pipe_in_container( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, one_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we handle dbus broken pipe in the container.""" @@ -130,8 +131,9 @@ async def test_dbus_broken_pipe_in_container( assert "container" in caplog.text +@pytest.mark.usefixtures("one_adapter") async def test_dbus_broken_pipe( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, one_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we handle dbus broken pipe.""" @@ -154,8 +156,9 @@ async def test_dbus_broken_pipe( assert "container" not in caplog.text +@pytest.mark.usefixtures("one_adapter") async def test_invalid_dbus_message( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, one_adapter: None + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we handle invalid dbus message.""" @@ -174,9 +177,8 @@ async def test_invalid_dbus_message( @pytest.mark.parametrize("error", NEED_RESET_ERRORS) -async def test_adapter_needs_reset_at_start( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, one_adapter: None, error: str -) -> None: +@pytest.mark.usefixtures("one_adapter") +async def test_adapter_needs_reset_at_start(hass: HomeAssistant, error: str) -> None: """Test we cycle the adapter when it needs a restart.""" with ( @@ -199,9 +201,8 @@ async def test_adapter_needs_reset_at_start( await hass.async_block_till_done() -async def test_recovery_from_dbus_restart( - hass: HomeAssistant, one_adapter: None -) -> None: +@pytest.mark.usefixtures("one_adapter") +async def test_recovery_from_dbus_restart(hass: HomeAssistant) -> None: """Test we can recover when DBus gets restarted out from under us.""" called_start = 0 @@ -281,7 +282,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 2 -async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: +@pytest.mark.usefixtures("one_adapter") +async def test_adapter_recovery(hass: HomeAssistant) -> None: """Test we can recover when the adapter stops responding.""" called_start = 0 @@ -365,9 +367,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 2 -async def test_adapter_scanner_fails_to_start_first_time( - hass: HomeAssistant, one_adapter: None -) -> None: +@pytest.mark.usefixtures("one_adapter") +async def test_adapter_scanner_fails_to_start_first_time(hass: HomeAssistant) -> None: """Test we can recover when the adapter stops responding and the first recovery fails.""" called_start = 0 @@ -474,8 +475,9 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 5 +@pytest.mark.usefixtures("one_adapter") async def test_adapter_fails_to_start_and_takes_a_bit_to_init( - hass: HomeAssistant, one_adapter: None, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" assert await async_setup_component(hass, "logger", {}) @@ -545,8 +547,9 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( assert "Waiting for adapter to initialize" in caplog.text +@pytest.mark.usefixtures("one_adapter") async def test_restart_takes_longer_than_watchdog_time( - hass: HomeAssistant, one_adapter: None, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we do not try to recover the adapter again if the restart is still in progress.""" @@ -614,8 +617,9 @@ async def test_restart_takes_longer_than_watchdog_time( @pytest.mark.skipif("platform.system() != 'Darwin'") +@pytest.mark.usefixtures("macos_adapter") async def test_setup_and_stop_macos( - hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock ) -> None: """Test we enable use_bdaddr on MacOS.""" entry = MockConfigEntry( diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index 35aa0eb9022..d5d4e7ad9d0 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -8,6 +8,7 @@ from habluetooth.usage import ( uninstall_multiple_bleak_catcher, ) from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper +import pytest from homeassistant.core import HomeAssistant @@ -38,9 +39,8 @@ async def test_multiple_bleak_scanner_instances(hass: HomeAssistant) -> None: assert not isinstance(instance, HaBleakScannerWrapper) -async def test_wrapping_bleak_client( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_wrapping_bleak_client(hass: HomeAssistant) -> None: """Test we wrap BleakClient.""" install_multiple_bleak_catcher() diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index 2acc2b0ddfc..0c5645b3f71 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -72,7 +72,7 @@ class FakeScanner(BaseHaRemoteScanner): class BaseFakeBleakClient: """Base class for fake bleak clients.""" - def __init__(self, address_or_ble_device: BLEDevice | str, **kwargs): + def __init__(self, address_or_ble_device: BLEDevice | str, **kwargs) -> None: """Initialize the fake bleak client.""" self._device_path = "/dev/test" self._device = address_or_ble_device @@ -194,10 +194,9 @@ def _generate_scanners_with_fake_devices(hass): return hci0_device_advs, cancel_hci0, cancel_hci1 +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_test_switch_adapters_when_out_of_slots( hass: HomeAssistant, - two_adapters: None, - enable_bluetooth: None, install_bleak_catcher, mock_platform_client, ) -> None: @@ -254,10 +253,9 @@ async def test_test_switch_adapters_when_out_of_slots( cancel_hci1() +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_release_slot_on_connect_failure( hass: HomeAssistant, - two_adapters: None, - enable_bluetooth: None, install_bleak_catcher, mock_platform_client_that_fails_to_connect, ) -> None: @@ -283,10 +281,9 @@ async def test_release_slot_on_connect_failure( cancel_hci1() +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_release_slot_on_connect_exception( hass: HomeAssistant, - two_adapters: None, - enable_bluetooth: None, install_bleak_catcher, mock_platform_client_that_raises_on_connect, ) -> None: @@ -314,10 +311,9 @@ async def test_release_slot_on_connect_exception( cancel_hci1() +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_we_switch_adapters_on_failure( hass: HomeAssistant, - two_adapters: None, - enable_bluetooth: None, install_bleak_catcher, ) -> None: """Ensure we try the next best adapter after a failure.""" @@ -374,10 +370,9 @@ async def test_we_switch_adapters_on_failure( cancel_hci1() +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_passing_subclassed_str_as_address( hass: HomeAssistant, - two_adapters: None, - enable_bluetooth: None, install_bleak_catcher, ) -> None: """Ensure the client wrapper can handle a subclassed str as the address.""" @@ -406,10 +401,9 @@ async def test_passing_subclassed_str_as_address( cancel_hci1() +@pytest.mark.usefixtures("enable_bluetooth", "two_adapters") async def test_raise_after_shutdown( hass: HomeAssistant, - two_adapters: None, - enable_bluetooth: None, install_bleak_catcher, mock_platform_client_that_raises_on_connect, ) -> None: diff --git a/tests/components/bluetooth_adapters/conftest.py b/tests/components/bluetooth_adapters/conftest.py index 9e56959209e..c0a5766d032 100644 --- a/tests/components/bluetooth_adapters/conftest.py +++ b/tests/components/bluetooth_adapters/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/bluetooth_le_tracker/conftest.py b/tests/components/bluetooth_le_tracker/conftest.py index 9fce8e85ea8..5a839a9d6b8 100644 --- a/tests/components/bluetooth_le_tracker/conftest.py +++ b/tests/components/bluetooth_le_tracker/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index 6346b094eab..f183f987cde 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -5,6 +5,7 @@ from unittest.mock import patch from bleak import BleakError from freezegun import freeze_time +import pytest from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth_le_tracker import device_tracker @@ -17,7 +18,6 @@ from homeassistant.components.device_tracker import ( CONF_SCAN_INTERVAL, CONF_TRACK_NEW, DOMAIN, - legacy, ) from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant @@ -66,11 +66,8 @@ class MockBleakClientBattery5(MockBleakClient): return b"\x05" -async def test_do_not_see_device_if_time_not_updated( - hass: HomeAssistant, - mock_bluetooth: None, - mock_device_tracker_conf: list[legacy.Device], -) -> None: +@pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") +async def test_do_not_see_device_if_time_not_updated(hass: HomeAssistant) -> None: """Test device going not_home after consider_home threshold from first scan if the subsequent scans have not incremented last seen time.""" address = "DE:AD:BE:EF:13:37" @@ -132,11 +129,8 @@ async def test_do_not_see_device_if_time_not_updated( assert state.state == "not_home" -async def test_see_device_if_time_updated( - hass: HomeAssistant, - mock_bluetooth: None, - mock_device_tracker_conf: list[legacy.Device], -) -> None: +@pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") +async def test_see_device_if_time_updated(hass: HomeAssistant) -> None: """Test device remaining home after consider_home threshold from first scan if the subsequent scans have incremented last seen time.""" address = "DE:AD:BE:EF:13:37" @@ -214,11 +208,8 @@ async def test_see_device_if_time_updated( assert state.state == "home" -async def test_preserve_new_tracked_device_name( - hass: HomeAssistant, - mock_bluetooth: None, - mock_device_tracker_conf: list[legacy.Device], -) -> None: +@pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") +async def test_preserve_new_tracked_device_name(hass: HomeAssistant) -> None: """Test preserving tracked device name across new seens.""" address = "DE:AD:BE:EF:13:37" @@ -284,11 +275,8 @@ async def test_preserve_new_tracked_device_name( assert state.name == name -async def test_tracking_battery_times_out( - hass: HomeAssistant, - mock_bluetooth: None, - mock_device_tracker_conf: list[legacy.Device], -) -> None: +@pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") +async def test_tracking_battery_times_out(hass: HomeAssistant) -> None: """Test tracking the battery times out.""" address = "DE:AD:BE:EF:13:37" @@ -353,11 +341,8 @@ async def test_tracking_battery_times_out( assert "battery" not in state.attributes -async def test_tracking_battery_fails( - hass: HomeAssistant, - mock_bluetooth: None, - mock_device_tracker_conf: list[legacy.Device], -) -> None: +@pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") +async def test_tracking_battery_fails(hass: HomeAssistant) -> None: """Test tracking the battery fails.""" address = "DE:AD:BE:EF:13:37" @@ -421,11 +406,8 @@ async def test_tracking_battery_fails( assert "battery" not in state.attributes -async def test_tracking_battery_successful( - hass: HomeAssistant, - mock_bluetooth: None, - mock_device_tracker_conf: list[legacy.Device], -) -> None: +@pytest.mark.usefixtures("mock_bluetooth", "mock_device_tracker_conf") +async def test_tracking_battery_successful(hass: HomeAssistant) -> None: """Test tracking the battery gets a value.""" address = "DE:AD:BE:EF:13:37" diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index e737fce6897..c11d5ef0021 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -43,6 +43,7 @@ FIXTURE_CONFIG_ENTRY = { async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: """Mock a fully setup config entry and all components based on fixtures.""" + # Mock config entry and add to HA mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index f43a7c089c7..f69763dae77 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,18 +1,15 @@ """Fixtures for BMW tests.""" -from collections.abc import Generator - from bimmer_connected.tests import ALL_CHARGING_SETTINGS, ALL_PROFILES, ALL_STATES from bimmer_connected.tests.common import MyBMWMockRouter from bimmer_connected.vehicle import remote_services import pytest import respx +from typing_extensions import Generator @pytest.fixture -def bmw_fixture( - request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch -) -> Generator[respx.MockRouter, None, None]: +def bmw_fixture(monkeypatch: pytest.MonkeyPatch) -> Generator[respx.MockRouter]: """Patch MyBMW login API calls.""" # we use the library's mock router to mock the API calls, but only with a subset of vehicles diff --git a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..610e194c0e5 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1523 @@ +# serializer version: 1 +# name: test_entity_state_attrs[binary_sensor.i3_rex_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBY00000000REXI01-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery_charging', + 'friendly_name': 'i3 (+ REX) Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_check_control_messages-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_check_control_messages', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Check control messages', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'check_control_messages', + 'unique_id': 'WBY00000000REXI01-check_control_messages', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_check_control_messages-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i3 (+ REX)', + 'device_class': 'problem', + 'friendly_name': 'i3 (+ REX) Check control messages', + 'vin': 'WBY00000000REXI01', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_check_control_messages', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_condition_based_services-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_condition_based_services', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condition based services', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_based_services', + 'unique_id': 'WBY00000000REXI01-condition_based_services', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_condition_based_services-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'brake_fluid': 'OK', + 'brake_fluid_date': '2022-10-01', + 'car': 'i3 (+ REX)', + 'device_class': 'problem', + 'friendly_name': 'i3 (+ REX) Condition based services', + 'vehicle_check': 'OK', + 'vehicle_check_date': '2023-05-01', + 'vehicle_tuv': 'OK', + 'vehicle_tuv_date': '2023-05-01', + 'vin': 'WBY00000000REXI01', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_condition_based_services', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': 'WBY00000000REXI01-connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'plug', + 'friendly_name': 'i3 (+ REX) Connection status', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_door_lock_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_door_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door lock state', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_state', + 'unique_id': 'WBY00000000REXI01-door_lock_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_door_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i3 (+ REX)', + 'device_class': 'lock', + 'door_lock_state': 'UNLOCKED', + 'friendly_name': 'i3 (+ REX) Door lock state', + 'vin': 'WBY00000000REXI01', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_door_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_lids-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_lids', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lids', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lids', + 'unique_id': 'WBY00000000REXI01-lids', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_lids-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i3 (+ REX)', + 'device_class': 'opening', + 'friendly_name': 'i3 (+ REX) Lids', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'sunRoof': 'CLOSED', + 'trunk': 'CLOSED', + 'vin': 'WBY00000000REXI01', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_lids', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_pre_entry_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_pre_entry_climatization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre entry climatization', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pre_entry_climatization_enabled', + 'unique_id': 'WBY00000000REXI01-is_pre_entry_climatization_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_pre_entry_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Pre entry climatization', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_pre_entry_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i3_rex_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'windows', + 'unique_id': 'WBY00000000REXI01-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i3_rex_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i3 (+ REX)', + 'device_class': 'opening', + 'friendly_name': 'i3 (+ REX) Windows', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + 'vin': 'WBY00000000REXI01', + }), + 'context': , + 'entity_id': 'binary_sensor.i3_rex_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBA00000000DEMO02-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery_charging', + 'friendly_name': 'i4 eDrive40 Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_check_control_messages-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_check_control_messages', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Check control messages', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'check_control_messages', + 'unique_id': 'WBA00000000DEMO02-check_control_messages', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_check_control_messages-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i4 eDrive40', + 'device_class': 'problem', + 'friendly_name': 'i4 eDrive40 Check control messages', + 'tire_pressure': 'LOW', + 'vin': 'WBA00000000DEMO02', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_check_control_messages', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_condition_based_services-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_condition_based_services', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condition based services', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_based_services', + 'unique_id': 'WBA00000000DEMO02-condition_based_services', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_condition_based_services-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'brake_fluid': 'OK', + 'brake_fluid_date': '2024-12-01', + 'brake_fluid_distance': '50000 km', + 'car': 'i4 eDrive40', + 'device_class': 'problem', + 'friendly_name': 'i4 eDrive40 Condition based services', + 'tire_wear_front': 'OK', + 'tire_wear_rear': 'OK', + 'vehicle_check': 'OK', + 'vehicle_check_date': '2024-12-01', + 'vehicle_check_distance': '50000 km', + 'vehicle_tuv': 'OK', + 'vehicle_tuv_date': '2024-12-01', + 'vehicle_tuv_distance': '50000 km', + 'vin': 'WBA00000000DEMO02', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_condition_based_services', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': 'WBA00000000DEMO02-connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'plug', + 'friendly_name': 'i4 eDrive40 Connection status', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_door_lock_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_door_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door lock state', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_state', + 'unique_id': 'WBA00000000DEMO02-door_lock_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_door_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i4 eDrive40', + 'device_class': 'lock', + 'door_lock_state': 'LOCKED', + 'friendly_name': 'i4 eDrive40 Door lock state', + 'vin': 'WBA00000000DEMO02', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_door_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_lids-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_lids', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lids', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lids', + 'unique_id': 'WBA00000000DEMO02-lids', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_lids-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i4 eDrive40', + 'device_class': 'opening', + 'friendly_name': 'i4 eDrive40 Lids', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + 'vin': 'WBA00000000DEMO02', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_lids', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_pre_entry_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_pre_entry_climatization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre entry climatization', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pre_entry_climatization_enabled', + 'unique_id': 'WBA00000000DEMO02-is_pre_entry_climatization_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_pre_entry_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Pre entry climatization', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_pre_entry_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.i4_edrive40_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'windows', + 'unique_id': 'WBA00000000DEMO02-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.i4_edrive40_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i4 eDrive40', + 'device_class': 'opening', + 'friendly_name': 'i4 eDrive40 Windows', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'vin': 'WBA00000000DEMO02', + }), + 'context': , + 'entity_id': 'binary_sensor.i4_edrive40_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBA00000000DEMO01-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery_charging', + 'friendly_name': 'iX xDrive50 Charging status', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_check_control_messages-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_check_control_messages', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Check control messages', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'check_control_messages', + 'unique_id': 'WBA00000000DEMO01-check_control_messages', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_check_control_messages-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'iX xDrive50', + 'device_class': 'problem', + 'friendly_name': 'iX xDrive50 Check control messages', + 'tire_pressure': 'LOW', + 'vin': 'WBA00000000DEMO01', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_check_control_messages', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_condition_based_services-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_condition_based_services', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condition based services', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_based_services', + 'unique_id': 'WBA00000000DEMO01-condition_based_services', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_condition_based_services-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'brake_fluid': 'OK', + 'brake_fluid_date': '2024-12-01', + 'brake_fluid_distance': '50000 km', + 'car': 'iX xDrive50', + 'device_class': 'problem', + 'friendly_name': 'iX xDrive50 Condition based services', + 'tire_wear_front': 'OK', + 'tire_wear_rear': 'OK', + 'vehicle_check': 'OK', + 'vehicle_check_date': '2024-12-01', + 'vehicle_check_distance': '50000 km', + 'vehicle_tuv': 'OK', + 'vehicle_tuv_date': '2024-12-01', + 'vehicle_tuv_distance': '50000 km', + 'vin': 'WBA00000000DEMO01', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_condition_based_services', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_connection_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_connection_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_status', + 'unique_id': 'WBA00000000DEMO01-connection_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_connection_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'plug', + 'friendly_name': 'iX xDrive50 Connection status', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_connection_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_door_lock_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_door_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door lock state', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_state', + 'unique_id': 'WBA00000000DEMO01-door_lock_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_door_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'iX xDrive50', + 'device_class': 'lock', + 'door_lock_state': 'LOCKED', + 'friendly_name': 'iX xDrive50 Door lock state', + 'vin': 'WBA00000000DEMO01', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_door_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_lids-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_lids', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lids', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lids', + 'unique_id': 'WBA00000000DEMO01-lids', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_lids-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'iX xDrive50', + 'device_class': 'opening', + 'friendly_name': 'iX xDrive50 Lids', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'sunRoof': 'CLOSED', + 'trunk': 'CLOSED', + 'vin': 'WBA00000000DEMO01', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_lids', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_pre_entry_climatization-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_pre_entry_climatization', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pre entry climatization', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pre_entry_climatization_enabled', + 'unique_id': 'WBA00000000DEMO01-is_pre_entry_climatization_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_pre_entry_climatization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Pre entry climatization', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_pre_entry_climatization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ix_xdrive50_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'windows', + 'unique_id': 'WBA00000000DEMO01-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.ix_xdrive50_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'iX xDrive50', + 'device_class': 'opening', + 'friendly_name': 'iX xDrive50 Windows', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'vin': 'WBA00000000DEMO01', + }), + 'context': , + 'entity_id': 'binary_sensor.ix_xdrive50_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_check_control_messages-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.m340i_xdrive_check_control_messages', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Check control messages', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'check_control_messages', + 'unique_id': 'WBA00000000DEMO03-check_control_messages', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_check_control_messages-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'M340i xDrive', + 'device_class': 'problem', + 'engine_oil': 'LOW', + 'friendly_name': 'M340i xDrive Check control messages', + 'tire_pressure': 'LOW', + 'vin': 'WBA00000000DEMO03', + }), + 'context': , + 'entity_id': 'binary_sensor.m340i_xdrive_check_control_messages', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_condition_based_services-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.m340i_xdrive_condition_based_services', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Condition based services', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_based_services', + 'unique_id': 'WBA00000000DEMO03-condition_based_services', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_condition_based_services-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'brake_fluid': 'OK', + 'brake_fluid_date': '2024-12-01', + 'brake_fluid_distance': '50000 km', + 'car': 'M340i xDrive', + 'device_class': 'problem', + 'friendly_name': 'M340i xDrive Condition based services', + 'oil': 'OK', + 'oil_date': '2024-12-01', + 'oil_distance': '50000 km', + 'tire_wear_front': 'OK', + 'tire_wear_rear': 'OK', + 'vehicle_check': 'OK', + 'vehicle_check_date': '2024-12-01', + 'vehicle_check_distance': '50000 km', + 'vehicle_tuv': 'OK', + 'vehicle_tuv_date': '2024-12-01', + 'vehicle_tuv_distance': '50000 km', + 'vin': 'WBA00000000DEMO03', + }), + 'context': , + 'entity_id': 'binary_sensor.m340i_xdrive_condition_based_services', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_door_lock_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.m340i_xdrive_door_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door lock state', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door_lock_state', + 'unique_id': 'WBA00000000DEMO03-door_lock_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_door_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'M340i xDrive', + 'device_class': 'lock', + 'door_lock_state': 'LOCKED', + 'friendly_name': 'M340i xDrive Door lock state', + 'vin': 'WBA00000000DEMO03', + }), + 'context': , + 'entity_id': 'binary_sensor.m340i_xdrive_door_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_lids-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.m340i_xdrive_lids', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lids', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lids', + 'unique_id': 'WBA00000000DEMO03-lids', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_lids-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'M340i xDrive', + 'device_class': 'opening', + 'friendly_name': 'M340i xDrive Lids', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + 'vin': 'WBA00000000DEMO03', + }), + 'context': , + 'entity_id': 'binary_sensor.m340i_xdrive_lids', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.m340i_xdrive_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'windows', + 'unique_id': 'WBA00000000DEMO03-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[binary_sensor.m340i_xdrive_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'M340i xDrive', + 'device_class': 'opening', + 'friendly_name': 'M340i xDrive Windows', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'vin': 'WBA00000000DEMO03', + }), + 'context': , + 'entity_id': 'binary_sensor.m340i_xdrive_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr index 17866878ba3..cd3f94c7e5e 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_button.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr @@ -1,233 +1,894 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Flash lights', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_entity_state_attrs[button.i3_rex_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Sound horn', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Deactivate air conditioning', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Find vehicle', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Flash lights', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Sound horn', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Deactivate air conditioning', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_deactivate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Find vehicle', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Flash lights', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Sound horn', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Deactivate air conditioning', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Find vehicle', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Flash lights', - }), - 'context': , - 'entity_id': 'button.i3_rex_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Sound horn', - }), - 'context': , - 'entity_id': 'button.i3_rex_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.i3_rex_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Find vehicle', - }), - 'context': , - 'entity_id': 'button.i3_rex_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBY00000000REXI01-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.i3_rex_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBY00000000REXI01-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Find vehicle', + }), + 'context': , + 'entity_id': 'button.i3_rex_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBY00000000REXI01-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Flash lights', + }), + 'context': , + 'entity_id': 'button.i3_rex_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBY00000000REXI01-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Sound horn', + }), + 'context': , + 'entity_id': 'button.i3_rex_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBA00000000DEMO02-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_deactivate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_deactivate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deactivate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deactivate_air_conditioning', + 'unique_id': 'WBA00000000DEMO02-deactivate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_deactivate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Deactivate air conditioning', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_deactivate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBA00000000DEMO02-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Find vehicle', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBA00000000DEMO02-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Flash lights', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBA00000000DEMO02-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Sound horn', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBA00000000DEMO01-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_deactivate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deactivate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deactivate_air_conditioning', + 'unique_id': 'WBA00000000DEMO01-deactivate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_deactivate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Deactivate air conditioning', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBA00000000DEMO01-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Find vehicle', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBA00000000DEMO01-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Flash lights', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBA00000000DEMO01-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Sound horn', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBA00000000DEMO03-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_deactivate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deactivate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deactivate_air_conditioning', + 'unique_id': 'WBA00000000DEMO03-deactivate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_deactivate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Deactivate air conditioning', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBA00000000DEMO03-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Find vehicle', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBA00000000DEMO03-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Flash lights', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBA00000000DEMO03-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Sound horn', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 351c0f062fd..477cd24376d 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -1706,7 +1706,23 @@ 'windows', ]), 'brand': 'bmw', - 'charging_profile': None, + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'IMMEDIATE_CHARGING', + 'charging_preferences': 'NO_PRESELECTION', + 'charging_preferences_service_pack': None, + 'departure_times': list([ + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + }), + 'end_time': '00:00:00', + 'start_time': '00:00:00', + }), + 'timer_type': 'UNKNOWN', + }), 'check_control_messages': dict({ 'has_check_control_messages': False, 'messages': list([ @@ -2861,7 +2877,7 @@ ]), 'fuel_and_battery': dict({ 'charging_end_time': None, - 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time': '2022-07-10T18:01:00', 'charging_status': 'WAITING_FOR_CHARGING', 'charging_target': 100, 'is_charger_connected': True, @@ -5263,7 +5279,7 @@ ]), 'fuel_and_battery': dict({ 'charging_end_time': None, - 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time': '2022-07-10T18:01:00', 'charging_status': 'WAITING_FOR_CHARGING', 'charging_target': 100, 'is_charger_connected': True, diff --git a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr new file mode 100644 index 00000000000..17e6b118011 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr @@ -0,0 +1,205 @@ +# serializer version: 1 +# name: test_entity_state_attrs[lock.i3_rex_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.i3_rex_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'WBY00000000REXI01-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[lock.i3_rex_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i3 (+ REX)', + 'door_lock_state': 'UNLOCKED', + 'friendly_name': 'i3 (+ REX) Lock', + 'supported_features': , + 'vin': 'WBY00000000REXI01', + }), + 'context': , + 'entity_id': 'lock.i3_rex_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_entity_state_attrs[lock.i4_edrive40_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.i4_edrive40_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'WBA00000000DEMO02-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[lock.i4_edrive40_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'i4 eDrive40', + 'door_lock_state': 'LOCKED', + 'friendly_name': 'i4 eDrive40 Lock', + 'supported_features': , + 'vin': 'WBA00000000DEMO02', + }), + 'context': , + 'entity_id': 'lock.i4_edrive40_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_entity_state_attrs[lock.ix_xdrive50_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.ix_xdrive50_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'WBA00000000DEMO01-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[lock.ix_xdrive50_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'iX xDrive50', + 'door_lock_state': 'LOCKED', + 'friendly_name': 'iX xDrive50 Lock', + 'supported_features': , + 'vin': 'WBA00000000DEMO01', + }), + 'context': , + 'entity_id': 'lock.ix_xdrive50_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_entity_state_attrs[lock.m340i_xdrive_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.m340i_xdrive_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': 'WBA00000000DEMO03-lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[lock.m340i_xdrive_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'car': 'M340i xDrive', + 'door_lock_state': 'LOCKED', + 'friendly_name': 'M340i xDrive Lock', + 'supported_features': , + 'vin': 'WBA00000000DEMO03', + }), + 'context': , + 'entity_id': 'lock.m340i_xdrive_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr index 93580ddc7b7..f24ea43d8e8 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_number.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -1,39 +1,115 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'iX xDrive50 Target SoC', - 'max': 100.0, - 'min': 20.0, - 'mode': , - 'step': 5.0, - }), - 'context': , - 'entity_id': 'number.ix_xdrive50_target_soc', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', +# name: test_entity_state_attrs[number.i4_edrive40_target_soc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'i4 eDrive40 Target SoC', - 'max': 100.0, - 'min': 20.0, - 'mode': , - 'step': 5.0, - }), - 'context': , - 'entity_id': 'number.i4_edrive40_target_soc', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.i4_edrive40_target_soc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target SoC', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_soc', + 'unique_id': 'WBA00000000DEMO02-target_soc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[number.i4_edrive40_target_soc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.i4_edrive40_target_soc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[number.ix_xdrive50_target_soc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ix_xdrive50_target_soc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target SoC', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_soc', + 'unique_id': 'WBA00000000DEMO01-target_soc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[number.ix_xdrive50_target_soc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Target SoC', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.ix_xdrive50_target_soc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index e72708345b1..34a8817c8db 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -1,109 +1,327 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 AC Charging Limit', - 'options': list([ - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '20', - '32', - ]), - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'select.ix_xdrive50_ac_charging_limit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '16', +# name: test_entity_state_attrs[select.i3_rex_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging Mode', - 'options': list([ - 'IMMEDIATE_CHARGING', - 'DELAYED_CHARGING', - ]), - }), - 'context': , - 'entity_id': 'select.ix_xdrive50_charging_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'IMMEDIATE_CHARGING', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'immediate_charging', + 'delayed_charging', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 AC Charging Limit', - 'options': list([ - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '20', - '32', - ]), - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'select.i4_edrive40_ac_charging_limit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '16', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.i3_rex_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Charging Mode', - 'options': list([ - 'IMMEDIATE_CHARGING', - 'DELAYED_CHARGING', - ]), - }), - 'context': , - 'entity_id': 'select.i4_edrive40_charging_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'IMMEDIATE_CHARGING', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Charging Mode', - 'options': list([ - 'IMMEDIATE_CHARGING', - 'DELAYED_CHARGING', - ]), - }), - 'context': , - 'entity_id': 'select.i3_rex_charging_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'DELAYED_CHARGING', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging Mode', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'WBY00000000REXI01-charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[select.i3_rex_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging Mode', + 'options': list([ + 'immediate_charging', + 'delayed_charging', + ]), + }), + 'context': , + 'entity_id': 'select.i3_rex_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'delayed_charging', + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.i4_edrive40_ac_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Charging Limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_limit', + 'unique_id': 'WBA00000000DEMO02-ac_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 AC Charging Limit', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.i4_edrive40_ac_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'immediate_charging', + 'delayed_charging', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.i4_edrive40_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging Mode', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'WBA00000000DEMO02-charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging Mode', + 'options': list([ + 'immediate_charging', + 'delayed_charging', + ]), + }), + 'context': , + 'entity_id': 'select.i4_edrive40_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'immediate_charging', + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ix_xdrive50_ac_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Charging Limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_limit', + 'unique_id': 'WBA00000000DEMO01-ac_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 AC Charging Limit', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.ix_xdrive50_ac_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'immediate_charging', + 'delayed_charging', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ix_xdrive50_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging Mode', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'WBA00000000DEMO01-charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging Mode', + 'options': list([ + 'immediate_charging', + 'delayed_charging', + ]), + }), + 'context': , + 'entity_id': 'select.ix_xdrive50_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'immediate_charging', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index dcf68622fdc..6ba87c029ee 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -1,459 +1,2113 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'iX xDrive50 Charging end time', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_charging_end_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-06-22T10:40:00+00:00', +# name: test_entity_state_attrs[sensor.i3_rex_ac_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging status', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_charging_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'CHARGING', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_ac_current_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging target', - 'unit_of_measurement': '%', + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_charging_target', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'iX xDrive50 Remaining battery percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '70', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1121', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '340', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Remaining range electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '340', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'enum', - 'friendly_name': 'iX xDrive50 Climate status', - 'options': list([ - 'cooling', - 'heating', - 'inactive', - 'standby', - ]), - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_climate_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'inactive', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'i4 eDrive40 Charging end time', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_charging_end_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-06-22T10:40:00+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Charging status', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_charging_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'NOT_CHARGING', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Charging target', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_charging_target', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'i4 eDrive40 Remaining battery percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1121', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '472', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Remaining range electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '472', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'enum', - 'friendly_name': 'i4 eDrive40 Climate status', - 'options': list([ - 'cooling', - 'heating', - 'inactive', - 'standby', - ]), - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_climate_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heating', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1121', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '629', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining range fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '629', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '40', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining fuel percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'enum', - 'friendly_name': 'M340i xDrive Climate status', - 'options': list([ - 'cooling', - 'heating', - 'inactive', - 'standby', - ]), - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_climate_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'inactive', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'i3 (+ REX) Charging end time', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_charging_end_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Charging status', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_charging_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'WAITING_FOR_CHARGING', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Charging target', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_charging_target', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'i3 (+ REX) Remaining battery percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_battery_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '82', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '137009', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '279', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining range electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_range_electric', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '174', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining range fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_range_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '105', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '6', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining fuel percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current_limit', + 'unique_id': 'WBY00000000REXI01-ac_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_ac_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', + 'friendly_name': 'i3 (+ REX) AC current limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_ac_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging end time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_end_time', + 'unique_id': 'WBY00000000REXI01-charging_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i3 (+ REX) Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging start time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_start_time', + 'unique_id': 'WBY00000000REXI01-charging_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i3 (+ REX) Charging start time', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-23T01:01:00+00:00', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'default', + 'charging', + 'error', + 'complete', + 'fully_charged', + 'finished_fully_charged', + 'finished_not_full', + 'invalid', + 'not_charging', + 'plugged_in', + 'waiting_for_charging', + 'target_reached', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBY00000000REXI01-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'i3 (+ REX) Charging status', + 'options': list([ + 'default', + 'charging', + 'error', + 'complete', + 'fully_charged', + 'finished_fully_charged', + 'finished_not_full', + 'invalid', + 'not_charging', + 'plugged_in', + 'waiting_for_charging', + 'target_reached', + ]), + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting_for_charging', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging target', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_target', + 'unique_id': 'WBY00000000REXI01-charging_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i3 (+ REX) Charging target', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBY00000000REXI01-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'i3 (+ REX) Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '137009', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining battery percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_battery_percent', + 'unique_id': 'WBY00000000REXI01-remaining_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i3 (+ REX) Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_battery_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel', + 'unique_id': 'WBY00000000REXI01-remaining_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'volume', + 'friendly_name': 'i3 (+ REX) Remaining fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining fuel percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel_percent', + 'unique_id': 'WBY00000000REXI01-remaining_fuel_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_electric-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_range_electric', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range electric', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_electric', + 'unique_id': 'WBY00000000REXI01-remaining_range_electric', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_electric-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'i3 (+ REX) Remaining range electric', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_electric', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '174', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_range_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_fuel', + 'unique_id': 'WBY00000000REXI01-remaining_range_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'i3 (+ REX) Remaining range fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '105', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBY00000000REXI01-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'i3 (+ REX) Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '279', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_ac_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_ac_current_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current_limit', + 'unique_id': 'WBA00000000DEMO02-ac_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_ac_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', + 'friendly_name': 'i4 eDrive40 AC current limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_ac_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging end time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_end_time', + 'unique_id': 'WBA00000000DEMO02-charging_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i4 eDrive40 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging start time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_start_time', + 'unique_id': 'WBA00000000DEMO02-charging_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i4 eDrive40 Charging start time', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'default', + 'charging', + 'error', + 'complete', + 'fully_charged', + 'finished_fully_charged', + 'finished_not_full', + 'invalid', + 'not_charging', + 'plugged_in', + 'waiting_for_charging', + 'target_reached', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBA00000000DEMO02-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'i4 eDrive40 Charging status', + 'options': list([ + 'default', + 'charging', + 'error', + 'complete', + 'fully_charged', + 'finished_fully_charged', + 'finished_not_full', + 'invalid', + 'not_charging', + 'plugged_in', + 'waiting_for_charging', + 'target_reached', + ]), + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_charging', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging target', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_target', + 'unique_id': 'WBA00000000DEMO02-charging_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Charging target', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_climate_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_climate_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_status', + 'unique_id': 'WBA00000000DEMO02-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_climate_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'i4 eDrive40 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBA00000000DEMO02-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'i4 eDrive40 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1121', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining battery percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_battery_percent', + 'unique_id': 'WBA00000000DEMO02-remaining_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_electric-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range electric', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_electric', + 'unique_id': 'WBA00000000DEMO02-remaining_range_electric', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_electric-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'i4 eDrive40 Remaining range electric', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '472', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBA00000000DEMO02-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'i4 eDrive40 Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '472', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_ac_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_ac_current_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current_limit', + 'unique_id': 'WBA00000000DEMO01-ac_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_ac_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', + 'friendly_name': 'iX xDrive50 AC current limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_ac_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging end time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_end_time', + 'unique_id': 'WBA00000000DEMO01-charging_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'iX xDrive50 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging start time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_start_time', + 'unique_id': 'WBA00000000DEMO01-charging_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'iX xDrive50 Charging start time', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'default', + 'charging', + 'error', + 'complete', + 'fully_charged', + 'finished_fully_charged', + 'finished_not_full', + 'invalid', + 'not_charging', + 'plugged_in', + 'waiting_for_charging', + 'target_reached', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBA00000000DEMO01-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'iX xDrive50 Charging status', + 'options': list([ + 'default', + 'charging', + 'error', + 'complete', + 'fully_charged', + 'finished_fully_charged', + 'finished_not_full', + 'invalid', + 'not_charging', + 'plugged_in', + 'waiting_for_charging', + 'target_reached', + ]), + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'charging', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging target', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_target', + 'unique_id': 'WBA00000000DEMO01-charging_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Charging target', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_climate_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_climate_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_status', + 'unique_id': 'WBA00000000DEMO01-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_climate_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'iX xDrive50 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBA00000000DEMO01-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'iX xDrive50 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1121', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining battery percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_battery_percent', + 'unique_id': 'WBA00000000DEMO01-remaining_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_electric-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range electric', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_electric', + 'unique_id': 'WBA00000000DEMO01-remaining_range_electric', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_electric-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'iX xDrive50 Remaining range electric', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '340', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBA00000000DEMO01-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'iX xDrive50 Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '340', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_climate_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_climate_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_status', + 'unique_id': 'WBA00000000DEMO03-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_climate_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'M340i xDrive Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBA00000000DEMO03-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'M340i xDrive Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1121', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel', + 'unique_id': 'WBA00000000DEMO03-remaining_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'volume', + 'friendly_name': 'M340i xDrive Remaining fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining fuel percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel_percent', + 'unique_id': 'WBA00000000DEMO03-remaining_fuel_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_fuel', + 'unique_id': 'WBA00000000DEMO03-remaining_range_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'M340i xDrive Remaining range fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '629', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBA00000000DEMO03-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', + 'friendly_name': 'M340i xDrive Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '629', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr index a3c8ffb6d3b..5a87a6ddd84 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -1,53 +1,189 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Climate', - }), - 'context': , - 'entity_id': 'switch.ix_xdrive50_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', +# name: test_entity_state_attrs[switch.i4_edrive40_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging', - }), - 'context': , - 'entity_id': 'switch.ix_xdrive50_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.i4_edrive40_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Climate', - }), - 'context': , - 'entity_id': 'switch.i4_edrive40_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Climate', - }), - 'context': , - 'entity_id': 'switch.m340i_xdrive_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate', + 'unique_id': 'WBA00000000DEMO02-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.i4_edrive40_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Climate', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ix_xdrive50_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'WBA00000000DEMO01-charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging', + }), + 'context': , + 'entity_id': 'switch.ix_xdrive50_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ix_xdrive50_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate', + 'unique_id': 'WBA00000000DEMO01-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Climate', + }), + 'context': , + 'entity_id': 'switch.ix_xdrive50_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[switch.m340i_xdrive_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.m340i_xdrive_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate', + 'unique_id': 'WBA00000000DEMO03-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.m340i_xdrive_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Climate', + }), + 'context': , + 'entity_id': 'switch.m340i_xdrive_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/bmw_connected_drive/test_binary_sensor.py b/tests/components/bmw_connected_drive/test_binary_sensor.py new file mode 100644 index 00000000000..a1b3d69bbbf --- /dev/null +++ b/tests/components/bmw_connected_drive/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Test BMW binary sensors.""" + +from unittest.mock import patch + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_mocked_integration + +from tests.common import snapshot_platform + + +@freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity_state_attrs( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test binary sensor states and attributes.""" + + # Setup component + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + mock_config_entry = await setup_mocked_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py index f55e199682f..99cabc900fa 100644 --- a/tests/components/bmw_connected_drive/test_button.py +++ b/tests/components/bmw_connected_drive/test_button.py @@ -1,6 +1,6 @@ """Test BMW buttons.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,24 +8,33 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test button options and values.""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.BUTTON], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all button entities - assert hass.states.async_all("button") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -56,9 +65,9 @@ async def test_service_call_success( check_remote_service_call(bmw_fixture, remote_service) +@pytest.mark.usefixtures("bmw_fixture") async def test_service_call_fail( hass: HomeAssistant, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test failed button press.""" diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index c449a9c4a59..5b3f99a9414 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -5,10 +5,12 @@ from unittest.mock import patch from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError from freezegun.api import FrozenDateTimeFactory -import respx +import pytest -from homeassistant.core import HomeAssistant +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import UpdateFailed from . import FIXTURE_CONFIG_ENTRY @@ -16,7 +18,8 @@ from . import FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry, async_fire_time_changed -async def test_update_success(hass: HomeAssistant, bmw_fixture: respx.Router) -> None: +@pytest.mark.usefixtures("bmw_fixture") +async def test_update_success(hass: HomeAssistant) -> None: """Test the reauth form.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) @@ -30,8 +33,10 @@ async def test_update_success(hass: HomeAssistant, bmw_fixture: respx.Router) -> ) +@pytest.mark.usefixtures("bmw_fixture") async def test_update_failed( - hass: HomeAssistant, bmw_fixture: respx.Router, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, ) -> None: """Test the reauth form.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) @@ -57,8 +62,10 @@ async def test_update_failed( assert isinstance(coordinator.last_exception, UpdateFailed) is True +@pytest.mark.usefixtures("bmw_fixture") async def test_update_reauth( - hass: HomeAssistant, bmw_fixture: respx.Router, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, ) -> None: """Test the reauth form.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) @@ -92,3 +99,28 @@ async def test_update_reauth( assert coordinator.last_update_success is False assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_init_reauth( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the reauth form.""" + + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + config_entry.add_to_hass(hass) + + assert len(issue_registry.issues) == 0 + + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWAuthError("Test error"), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reauth_issue = issue_registry.async_get_issue( + HA_DOMAIN, f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}" + ) + assert reauth_issue.active is True diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index 2f58bc0e4a0..984275eab6a 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -19,10 +19,11 @@ from tests.typing import ClientSessionGenerator @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - bmw_fixture, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -37,11 +38,12 @@ async def test_config_entry_diagnostics( @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, - bmw_fixture, snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics.""" @@ -61,11 +63,12 @@ async def test_device_diagnostics( @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_diagnostics_vehicle_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, - bmw_fixture, snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics when the vehicle cannot be found.""" diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index b8081d8d119..d648ad65f5d 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -3,7 +3,6 @@ from unittest.mock import patch import pytest -import respx from homeassistant.components.bmw_connected_drive.const import DOMAIN as BMW_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -137,10 +136,10 @@ async def test_dont_migrate_unique_ids( assert entity_migrated != entity_not_changed +@pytest.mark.usefixtures("bmw_fixture") async def test_remove_stale_devices( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - bmw_fixture: respx.Router, ) -> None: """Test remove stale device registry entries.""" mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) diff --git a/tests/components/bmw_connected_drive/test_lock.py b/tests/components/bmw_connected_drive/test_lock.py new file mode 100644 index 00000000000..2fa694d426b --- /dev/null +++ b/tests/components/bmw_connected_drive/test_lock.py @@ -0,0 +1,139 @@ +"""Test BMW locks.""" + +from unittest.mock import AsyncMock, patch + +from bimmer_connected.models import MyBMWRemoteServiceError +from bimmer_connected.vehicle.remote_services import RemoteServices +from freezegun import freeze_time +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import check_remote_service_call, setup_mocked_integration + +from tests.common import snapshot_platform +from tests.components.recorder.common import async_wait_recording_done + + +@freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity_state_attrs( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test lock states and attributes.""" + + # Setup component + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", [Platform.LOCK] + ): + mock_config_entry = await setup_mocked_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize( + ("entity_id", "new_value", "old_value", "service", "remote_service"), + [ + ( + "lock.m340i_xdrive_lock", + "locked", + "unlocked", + "lock", + "door-lock", + ), + ("lock.m340i_xdrive_lock", "unlocked", "locked", "unlock", "door-unlock"), + ], +) +async def test_service_call_success( + hass: HomeAssistant, + entity_id: str, + new_value: str, + old_value: str, + service: str, + remote_service: str, + bmw_fixture: respx.Router, +) -> None: + """Test successful service call.""" + + # Setup component + assert await setup_mocked_integration(hass) + hass.states.async_set(entity_id, old_value) + assert hass.states.get(entity_id).state == old_value + + now = dt_util.utcnow() + + # Test + await hass.services.async_call( + "lock", + service, + blocking=True, + target={"entity_id": entity_id}, + ) + check_remote_service_call(bmw_fixture, remote_service) + assert hass.states.get(entity_id).state == new_value + + # wait for the recorder to really store the data + await async_wait_recording_done(hass) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, [entity_id] + ) + assert any(s for s in states[entity_id] if s.state == STATE_UNKNOWN) is False + + +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("recorder_mock") +@pytest.mark.parametrize( + ("entity_id", "service"), + [ + ("lock.m340i_xdrive_lock", "lock"), + ("lock.m340i_xdrive_lock", "unlock"), + ], +) +async def test_service_call_fail( + hass: HomeAssistant, + entity_id: str, + service: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test failed service call.""" + + # Setup component + assert await setup_mocked_integration(hass) + old_value = hass.states.get(entity_id).state + + now = dt_util.utcnow() + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=MyBMWRemoteServiceError), + ) + + # Test + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + service, + blocking=True, + target={"entity_id": entity_id}, + ) + assert hass.states.get(entity_id).state == old_value + + # wait for the recorder to really store the data + await async_wait_recording_done(hass) + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, [entity_id] + ) + assert states[entity_id][-2].state == STATE_UNKNOWN diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index 30214555b92..f2a50ce4df6 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -1,6 +1,6 @@ """Test BMW numbers.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,24 +8,33 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: - """Test number options and values..""" + """Test number options and values.""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.NUMBER], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all number entities - assert hass.states.async_all("number") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -61,6 +70,7 @@ async def test_service_call_success( assert hass.states.get(entity_id).state == new_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("entity_id", "value"), [ @@ -71,7 +81,6 @@ async def test_service_call_invalid_input( hass: HomeAssistant, entity_id: str, value: str, - bmw_fixture: respx.Router, ) -> None: """Test not allowed values for number inputs.""" @@ -91,6 +100,7 @@ async def test_service_call_invalid_input( assert hass.states.get(entity_id).state == old_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("raised", "expected"), [ @@ -103,7 +113,6 @@ async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index cb20805c809..a270f38ee01 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -1,6 +1,6 @@ """Test BMW selects.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,24 +8,36 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.bmw_connected_drive.select import SELECT_TYPES +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.translation import async_get_translations from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test select options and values..""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.SELECT], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all select entities - assert hass.states.async_all("select") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -33,15 +45,15 @@ async def test_entity_state_attrs( [ ( "select.i3_rex_charging_mode", - "IMMEDIATE_CHARGING", - "DELAYED_CHARGING", + "immediate_charging", + "delayed_charging", "charging-profile", ), ("select.i4_edrive40_ac_charging_limit", "12", "16", "charging-settings"), ( "select.i4_edrive40_charging_mode", - "DELAYED_CHARGING", - "IMMEDIATE_CHARGING", + "delayed_charging", + "immediate_charging", "charging-profile", ), ], @@ -73,18 +85,18 @@ async def test_service_call_success( assert hass.states.get(entity_id).state == new_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("entity_id", "value"), [ ("select.i4_edrive40_ac_charging_limit", "17"), - ("select.i4_edrive40_charging_mode", "BONKERS_MODE"), + ("select.i4_edrive40_charging_mode", "bonkers_mode"), ], ) async def test_service_call_invalid_input( hass: HomeAssistant, entity_id: str, value: str, - bmw_fixture: respx.Router, ) -> None: """Test not allowed values for select inputs.""" @@ -104,6 +116,7 @@ async def test_service_call_invalid_input( assert hass.states.get(entity_id).state == old_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("raised", "expected"), [ @@ -116,7 +129,6 @@ async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" @@ -143,3 +155,29 @@ async def test_service_call_fail( target={"entity_id": entity_id}, ) assert hass.states.get(entity_id).state == old_value + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_entity_option_translations( + hass: HomeAssistant, +) -> None: + """Ensure all enum sensor values are translated.""" + + # Setup component to load translations + assert await setup_mocked_integration(hass) + + prefix = f"component.{BMW_DOMAIN}.entity.{Platform.SELECT.value}" + + translations = await async_get_translations(hass, "en", "entity", [BMW_DOMAIN]) + translation_states = { + k for k in translations if k.startswith(prefix) and ".state." in k + } + + sensor_options = { + f"{prefix}.{entity_description.translation_key}.state.{option}" + for entity_description in SELECT_TYPES + if entity_description.options + for option in entity_description.options + } + + assert sensor_options == translation_states diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index a066b967250..6607bed280d 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -1,11 +1,17 @@ """Test BMW sensors.""" -from freezegun import freeze_time +from unittest.mock import patch + import pytest -import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.components.bmw_connected_drive.sensor import SENSOR_TYPES +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.translation import async_get_translations from homeassistant.util.unit_system import ( METRIC_SYSTEM as METRIC, US_CUSTOMARY_SYSTEM as IMPERIAL, @@ -14,37 +20,44 @@ from homeassistant.util.unit_system import ( from . import setup_mocked_integration +from tests.common import snapshot_platform -@freeze_time("2023-06-22 10:30:00+00:00") + +@pytest.mark.freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test sensor options and values..""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", [Platform.SENSOR] + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all select entities - assert hass.states.async_all("sensor") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("entity_id", "unit_system", "value", "unit_of_measurement"), [ ("sensor.i3_rex_remaining_range_total", METRIC, "279", "km"), - ("sensor.i3_rex_remaining_range_total", IMPERIAL, "173.36", "mi"), + ("sensor.i3_rex_remaining_range_total", IMPERIAL, "173.362562634216", "mi"), ("sensor.i3_rex_mileage", METRIC, "137009", "km"), - ("sensor.i3_rex_mileage", IMPERIAL, "85133.45", "mi"), + ("sensor.i3_rex_mileage", IMPERIAL, "85133.4456772449", "mi"), ("sensor.i3_rex_remaining_battery_percent", METRIC, "82", "%"), ("sensor.i3_rex_remaining_battery_percent", IMPERIAL, "82", "%"), ("sensor.i3_rex_remaining_range_electric", METRIC, "174", "km"), - ("sensor.i3_rex_remaining_range_electric", IMPERIAL, "108.12", "mi"), + ("sensor.i3_rex_remaining_range_electric", IMPERIAL, "108.118587449296", "mi"), ("sensor.i3_rex_remaining_fuel", METRIC, "6", "L"), - ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.59", "gal"), + ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.58503231414889", "gal"), ("sensor.i3_rex_remaining_range_fuel", METRIC, "105", "km"), - ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.24", "mi"), + ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.2439751849201", "mi"), ("sensor.m340i_xdrive_remaining_fuel_percent", METRIC, "80", "%"), ("sensor.m340i_xdrive_remaining_fuel_percent", IMPERIAL, "80", "%"), ], @@ -55,7 +68,6 @@ async def test_unit_conversion( unit_system: UnitSystem, value: str, unit_of_measurement: str, - bmw_fixture, ) -> None: """Test conversion between metric and imperial units for sensors.""" @@ -69,3 +81,29 @@ async def test_unit_conversion( entity = hass.states.get(entity_id) assert entity.state == value assert entity.attributes.get("unit_of_measurement") == unit_of_measurement + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_entity_option_translations( + hass: HomeAssistant, +) -> None: + """Ensure all enum sensor values are translated.""" + + # Setup component to load translations + assert await setup_mocked_integration(hass) + + prefix = f"component.{BMW_DOMAIN}.entity.{Platform.SENSOR.value}" + + translations = await async_get_translations(hass, "en", "entity", [BMW_DOMAIN]) + translation_states = { + k for k in translations if k.startswith(prefix) and ".state." in k + } + + sensor_options = { + f"{prefix}.{entity_description.translation_key}.state.{option}" + for entity_description in SENSOR_TYPES + if entity_description.device_class == SensorDeviceClass.ENUM + for option in entity_description.options + } + + assert sensor_options == translation_states diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index b759c33ca3b..58bddbfc937 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -1,6 +1,6 @@ """Test BMW switches.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,24 +8,33 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test switch options and values..""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.SWITCH], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all switch entities - assert hass.states.async_all("switch") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -64,6 +73,7 @@ async def test_service_call_success( assert hass.states.get(entity_id).state == new_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("raised", "expected"), [ @@ -76,7 +86,6 @@ async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 6a0160fbec9..6a7ec6d1615 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -396,7 +396,6 @@ async def test_set_speed_belief_speed_api_error(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "fan.name_1", "speed": 100}, blocking=True, ) - await hass.async_block_till_done() async def test_set_speed_belief_speed_100(hass: HomeAssistant) -> None: diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 37cd82fc321..ce245c838ba 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -341,7 +341,6 @@ async def test_light_set_brightness_belief_api_error(hass: HomeAssistant) -> Non {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) - await hass.async_block_till_done() async def test_fp_light_set_brightness_belief_full(hass: HomeAssistant) -> None: @@ -387,7 +386,6 @@ async def test_fp_light_set_brightness_belief_api_error(hass: HomeAssistant) -> {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) - await hass.async_block_till_done() async def test_light_set_brightness_belief_brightness_not_supported( @@ -408,7 +406,6 @@ async def test_light_set_brightness_belief_brightness_not_supported( {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) - await hass.async_block_till_done() async def test_light_set_brightness_belief_zero(hass: HomeAssistant) -> None: @@ -500,7 +497,6 @@ async def test_light_set_power_belief_api_error(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, blocking=True, ) - await hass.async_block_till_done() async def test_fp_light_set_power_belief(hass: HomeAssistant) -> None: @@ -546,7 +542,6 @@ async def test_fp_light_set_power_belief_api_error(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "light.name_1", ATTR_POWER_STATE: False}, blocking=True, ) - await hass.async_block_till_done() async def test_fp_light_set_brightness_belief_brightness_not_supported( @@ -567,7 +562,6 @@ async def test_fp_light_set_brightness_belief_brightness_not_supported( {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 255}, blocking=True, ) - await hass.async_block_till_done() async def test_light_start_increasing_brightness(hass: HomeAssistant) -> None: @@ -608,7 +602,6 @@ async def test_light_start_increasing_brightness_missing_service( {ATTR_ENTITY_ID: "light.name_1"}, blocking=True, ) - await hass.async_block_till_done() async def test_light_start_decreasing_brightness(hass: HomeAssistant) -> None: @@ -652,7 +645,6 @@ async def test_light_start_decreasing_brightness_missing_service( {ATTR_ENTITY_ID: "light.name_1"}, blocking=True, ) - await hass.async_block_till_done() async def test_light_stop(hass: HomeAssistant) -> None: @@ -694,7 +686,6 @@ async def test_light_stop_missing_service( {ATTR_ENTITY_ID: "light.name_1"}, blocking=True, ) - await hass.async_block_till_done() async def test_turn_on_light(hass: HomeAssistant) -> None: diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 3d3ad663656..3155ec0b167 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -123,7 +123,6 @@ async def test_switch_set_power_belief_api_error(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "switch.name_1", ATTR_POWER_STATE: False}, blocking=True, ) - await hass.async_block_till_done() async def test_update_reports_switch_is_on(hass: HomeAssistant) -> None: diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 6a3797ad094..1f45623e30f 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -1,8 +1,10 @@ """bosch_shc session fixtures.""" +from unittest.mock import MagicMock + import pytest @pytest.fixture(autouse=True) -def bosch_shc_mock_async_zeroconf(mock_async_zeroconf): +def bosch_shc_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index b3a28151c93..2c43ec0a370 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -10,6 +10,7 @@ from boschshcpy.exceptions import ( SHCSessionError, ) from boschshcpy.information import SHCInformation +import pytest from homeassistant import config_entries from homeassistant.components import zeroconf @@ -35,7 +36,8 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( ) -async def test_form_user(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_user(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -107,9 +109,8 @@ async def test_form_user(hass: HomeAssistant, mock_zeroconf: None) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_get_info_connection_error( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_get_info_connection_error(hass: HomeAssistant) -> None: """Test we handle connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -153,7 +154,8 @@ async def test_form_get_info_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_form_pairing_error(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_pairing_error(hass: HomeAssistant) -> None: """Test we handle pairing error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -199,7 +201,8 @@ async def test_form_pairing_error(hass: HomeAssistant, mock_zeroconf: None) -> N assert result3["errors"] == {"base": "pairing_failed"} -async def test_form_user_invalid_auth(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_user_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -257,9 +260,8 @@ async def test_form_user_invalid_auth(hass: HomeAssistant, mock_zeroconf: None) assert result3["errors"] == {"base": "invalid_auth"} -async def test_form_validate_connection_error( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_validate_connection_error(hass: HomeAssistant) -> None: """Test we handle connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -317,9 +319,8 @@ async def test_form_validate_connection_error( assert result3["errors"] == {"base": "cannot_connect"} -async def test_form_validate_session_error( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_validate_session_error(hass: HomeAssistant) -> None: """Test we handle session error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -377,9 +378,8 @@ async def test_form_validate_session_error( assert result3["errors"] == {"base": "session_error"} -async def test_form_validate_exception( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_validate_exception(hass: HomeAssistant) -> None: """Test we handle exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -437,9 +437,8 @@ async def test_form_validate_exception( assert result3["errors"] == {"base": "unknown"} -async def test_form_already_configured( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_form_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( @@ -479,7 +478,8 @@ async def test_form_already_configured( assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_zeroconf(hass: HomeAssistant) -> None: """Test we get the form.""" with ( @@ -557,9 +557,8 @@ async def test_zeroconf(hass: HomeAssistant, mock_zeroconf: None) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_already_configured( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( @@ -596,9 +595,8 @@ async def test_zeroconf_already_configured( assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf_cannot_connect( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: """Test we get the form.""" with patch( "boschshcpy.session.SHCSession.mdns_info", side_effect=SHCConnectionError @@ -612,7 +610,8 @@ async def test_zeroconf_cannot_connect( assert result["reason"] == "cannot_connect" -async def test_zeroconf_not_bosch_shc(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_zeroconf_not_bosch_shc(hass: HomeAssistant) -> None: """Test we filter out non-bosch_shc devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -631,7 +630,8 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant, mock_zeroconf: None) assert result["reason"] == "not_bosch_shc" -async def test_reauth(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_reauth(hass: HomeAssistant) -> None: """Test we get the form.""" mock_config = MockConfigEntry( diff --git a/tests/components/braviatv/conftest.py b/tests/components/braviatv/conftest.py index 33f55fbb390..186f4e12337 100644 --- a/tests/components/braviatv/conftest.py +++ b/tests/components/braviatv/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for Bravia TV.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.braviatv.async_setup_entry", return_value=True diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index e399e18dfbe..25330c10ba4 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -1,9 +1,11 @@ """Common fixtures for the Bring! tests.""" -from collections.abc import Generator +from typing import cast from unittest.mock import AsyncMock, patch +from bring_api.types import BringAuthResponse import pytest +from typing_extensions import Generator from homeassistant.components.bring import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD @@ -17,7 +19,7 @@ UUID = "00000000-00000000-00000000-00000000" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.bring.async_setup_entry", return_value=True @@ -26,7 +28,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_bring_client() -> Generator[AsyncMock, None, None]: +def mock_bring_client() -> Generator[AsyncMock]: """Mock a Bring client.""" with ( patch( @@ -40,7 +42,7 @@ def mock_bring_client() -> Generator[AsyncMock, None, None]: ): client = mock_client.return_value client.uuid = UUID - client.login.return_value = True + client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) client.load_lists.return_value = {"lists": []} yield client diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 351ba533101..d307e0ccbbe 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -9,8 +9,8 @@ from bring_api.exceptions import ( ) import pytest -from homeassistant import config_entries from homeassistant.components.bring.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -30,7 +30,7 @@ async def test_form( ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -45,7 +45,7 @@ async def test_form( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_DATA_STEP["email"] + assert result["title"] == "Bring" assert result["data"] == MOCK_DATA_STEP assert len(mock_setup_entry.mock_calls) == 1 @@ -66,7 +66,7 @@ async def test_flow_user_init_data_unknown_error_and_recover( mock_bring_client.login.side_effect = raise_error result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"} + DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -87,7 +87,7 @@ async def test_flow_user_init_data_unknown_error_and_recover( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].title == MOCK_DATA_STEP["email"] + assert result["result"].title == "Bring" assert result["data"] == MOCK_DATA_STEP @@ -112,3 +112,95 @@ async def test_flow_user_init_data_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_flow_reauth( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + + bring_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": bring_config_entry.entry_id, + "unique_id": bring_config_entry.unique_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert bring_config_entry.data == { + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (BringRequestException(), "cannot_connect"), + (BringAuthException(), "invalid_auth"), + (BringParseException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reauth_error_and_recover( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, + raise_error, + text_error, +) -> None: + """Test reauth flow.""" + + bring_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": bring_config_entry.entry_id, + "unique_id": bring_config_entry.unique_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_bring_client.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_bring_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index db402bdd6d1..f1b1f78e775 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.bring import ( from homeassistant.components.bring.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from tests.common import MockConfigEntry @@ -70,7 +70,7 @@ async def test_init_failure( ("exception", "expected"), [ (BringRequestException, ConfigEntryNotReady), - (BringAuthException, ConfigEntryError), + (BringAuthException, ConfigEntryAuthFailed), (BringParseException, ConfigEntryNotReady), ], ) diff --git a/tests/components/brother/__init__.py b/tests/components/brother/__init__.py index b5a3f8ed5ef..7b4e937a9f8 100644 --- a/tests/components/brother/__init__.py +++ b/tests/components/brother/__init__.py @@ -1,37 +1,15 @@ """Tests for Brother Printer integration.""" -import json -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 +from tests.common import MockConfigEntry async def init_integration( - hass: HomeAssistant, skip_setup: bool = False + hass: HomeAssistant, entry: MockConfigEntry ) -> MockConfigEntry: """Set up the Brother integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="HL-L2340DW 0123456789", - unique_id="0123456789", - data={CONF_HOST: "localhost", CONF_TYPE: "laser"}, - ) - entry.add_to_hass(hass) - if not skip_setup: - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py index 1834cb2c36b..5fadca5314d 100644 --- a/tests/components/brother/conftest.py +++ b/tests/components/brother/conftest.py @@ -1,15 +1,126 @@ """Test fixtures for brother.""" -from collections.abc import Generator +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch +from brother import BrotherSensors import pytest +from typing_extensions import Generator + +from homeassistant.components.brother.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TYPE + +from tests.common import MockConfigEntry + +BROTHER_DATA = BrotherSensors( + belt_unit_remaining_life=97, + belt_unit_remaining_pages=48436, + black_counter=None, + black_drum_counter=1611, + black_drum_remaining_life=92, + black_drum_remaining_pages=16389, + black_ink_remaining=None, + black_ink_status=None, + black_ink=None, + black_toner_remaining=75, + black_toner_status=1, + black_toner=80, + bw_counter=709, + color_counter=902, + cyan_counter=None, + cyan_drum_counter=1611, + cyan_drum_remaining_life=92, + cyan_drum_remaining_pages=16389, + cyan_ink_remaining=None, + cyan_ink_status=None, + cyan_ink=None, + cyan_toner_remaining=10, + cyan_toner_status=1, + cyan_toner=10, + drum_counter=986, + drum_remaining_life=92, + drum_remaining_pages=11014, + drum_status=1, + duplex_unit_pages_counter=538, + fuser_remaining_life=97, + fuser_unit_remaining_pages=None, + image_counter=None, + laser_remaining_life=None, + laser_unit_remaining_pages=48389, + magenta_counter=None, + magenta_drum_counter=1611, + magenta_drum_remaining_life=92, + magenta_drum_remaining_pages=16389, + magenta_ink_remaining=None, + magenta_ink_status=None, + magenta_ink=None, + magenta_toner_remaining=8, + magenta_toner_status=2, + magenta_toner=10, + page_counter=986, + pf_kit_1_remaining_life=98, + pf_kit_1_remaining_pages=48741, + pf_kit_mp_remaining_life=None, + pf_kit_mp_remaining_pages=None, + status="waiting", + uptime=datetime(2024, 3, 3, 15, 4, 24, tzinfo=UTC), + yellow_counter=None, + yellow_drum_counter=1611, + yellow_drum_remaining_life=92, + yellow_drum_remaining_pages=16389, + yellow_ink_remaining=None, + yellow_ink_status=None, + yellow_ink=None, + yellow_toner_remaining=2, + yellow_toner_status=2, + yellow_toner=10, +) @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.brother.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_unload_entry() -> Generator[AsyncMock, None, None]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.brother.async_unload_entry", return_value=True + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture +def mock_brother_client() -> Generator[AsyncMock, None, None]: + """Mock Brother client.""" + with ( + patch("homeassistant.components.brother.Brother", autospec=True) as mock_client, + patch( + "homeassistant.components.brother.config_flow.Brother", + new=mock_client, + ), + ): + client = mock_client.create.return_value + client.async_update.return_value = BROTHER_DATA + client.serial = "0123456789" + client.mac = "AA:BB:CC:DD:EE:FF" + client.model = "HL-L2340DW" + client.firmware = "1.2.3" + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="HL-L2340DW 0123456789", + unique_id="0123456789", + data={CONF_HOST: "localhost", CONF_TYPE: "laser"}, + ) diff --git a/tests/components/brother/fixtures/printer_data.json b/tests/components/brother/fixtures/printer_data.json deleted file mode 100644 index aa9ce8cac62..00000000000 --- a/tests/components/brother/fixtures/printer_data.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "1.3.6.1.2.1.1.3.0": "413613515", - "1.3.6.1.2.1.43.10.2.1.4.1.1": "986", - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.10.0": [ - "000104000003da", - "010104000002c5", - "02010400000386", - "0601040000021a", - "0701040000012d", - "080104000000ed" - ], - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.17.0": "1.17", - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.8.0": [ - "110104000003da", - "31010400000001", - "32010400000001", - "33010400000002", - "34010400000002", - "35010400000001", - "410104000023f0", - "54010400000001", - "55010400000001", - "63010400000001", - "68010400000001", - "690104000025e4", - "6a0104000025e4", - "6d010400002648", - "6f010400001d4c", - "700104000003e8", - "71010400000320", - "720104000000c8", - "7301040000064b", - "7401040000064b", - "7501040000064b", - "76010400000001", - "77010400000001", - "78010400000001", - "790104000023f0", - "7a0104000023f0", - "7b0104000023f0", - "7e01040000064b", - "800104000023f0", - "81010400000050", - "8201040000000a", - "8301040000000a", - "8401040000000a", - "8601040000000a" - ], - "1.3.6.1.4.1.2435.2.3.9.1.1.7.0": "MFG:Brother;CMD:PJL,HBP,URF;MDL:HL-L2340DW series;CLS:PRINTER;CID:Brother Laser Type1;URF:W8,CP1,IS4-1,MT1-3-4-5-8,OB10,PQ4,RS300-600,V1.3,DM1;", - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.11.0": [ - "7301040000bd05", - "7701040000be65", - "82010400002b06", - "8801040000bd34", - "a4010400004005", - "a5010400004005", - "a6010400004005", - "a7010400004005" - ], - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.21.0": [ - "00002302000025", - "00020016010200", - "00210200022202", - "020000a1040000" - ], - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.20.0": [ - "00a40100a50100", - "0100a301008801", - "01017301007701", - "870100a10100a2", - "a60100a70100a0" - ], - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.1.0": "0123456789", - "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.4.5.2.0": "WAITING ", - "1.3.6.1.2.1.43.7.1.1.4.1.1": "2004", - "1.3.6.1.2.1.2.2.1.6.1": "aa:bb:cc:dd:ee:ff" -} diff --git a/tests/components/brother/snapshots/test_diagnostics.ambr b/tests/components/brother/snapshots/test_diagnostics.ambr index 262f9c75fd6..614588bf829 100644 --- a/tests/components/brother/snapshots/test_diagnostics.ambr +++ b/tests/components/brother/snapshots/test_diagnostics.ambr @@ -52,7 +52,7 @@ 'pf_kit_mp_remaining_life': None, 'pf_kit_mp_remaining_pages': None, 'status': 'waiting', - 'uptime': '2019-09-24T12:14:56+00:00', + 'uptime': '2024-03-03T15:04:24+00:00', 'yellow_counter': None, 'yellow_drum_counter': 1611, 'yellow_drum_remaining_life': 92, @@ -64,7 +64,7 @@ 'yellow_toner_remaining': 2, 'yellow_toner_status': 2, }), - 'firmware': '1.17', + 'firmware': '1.2.3', 'info': dict({ 'host': 'localhost', 'type': 'laser', diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index a476ec8f579..ac7af4cc912 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -1,24 +1,29 @@ """Define tests for the Brother Printer config flow.""" from ipaddress import ip_address -import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from brother import SnmpError, UnsupportedModelError import pytest from homeassistant.components import zeroconf from homeassistant.components.brother.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + SOURCE_ZEROCONF, +) from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry, load_fixture +from . import init_integration + +from tests.common import MockConfigEntry CONFIG = {CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"} -pytestmark = pytest.mark.usefixtures("mock_setup_entry") +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry") async def test_show_form(hass: HomeAssistant) -> None: @@ -31,65 +36,21 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_create_entry_with_hostname(hass: HomeAssistant) -> None: - """Test that the user step works with printer hostname.""" - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "example.local", CONF_TYPE: "laser"}, - ) +@pytest.mark.parametrize("host", ["example.local", "127.0.0.1", "2001:db8::1428:57ab"]) +async def test_create_entry( + hass: HomeAssistant, host: str, mock_brother_client: AsyncMock +) -> None: + """Test that the user step works with printer hostname/IPv4/IPv6.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: host, CONF_TYPE: "laser"}, + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "HL-L2340DW 0123456789" - assert result["data"][CONF_HOST] == "example.local" - assert result["data"][CONF_TYPE] == "laser" - - -async def test_create_entry_with_ipv4_address(hass: HomeAssistant) -> None: - """Test that the user step works with printer IPv4 address.""" - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "HL-L2340DW 0123456789" - assert result["data"][CONF_HOST] == "127.0.0.1" - assert result["data"][CONF_TYPE] == "laser" - - -async def test_create_entry_with_ipv6_address(hass: HomeAssistant) -> None: - """Test that the user step works with printer IPv6 address.""" - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "2001:db8::1428:57ab", CONF_TYPE: "laser"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "HL-L2340DW 0123456789" - assert result["data"][CONF_HOST] == "2001:db8::1428:57ab" - assert result["data"][CONF_TYPE] == "laser" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "HL-L2340DW 0123456789" + assert result["data"][CONF_HOST] == host + assert result["data"][CONF_TYPE] == "laser" async def test_invalid_hostname(hass: HomeAssistant) -> None: @@ -103,97 +64,87 @@ async def test_invalid_hostname(hass: HomeAssistant) -> None: assert result["errors"] == {CONF_HOST: "wrong_host"} -@pytest.mark.parametrize("exc", [ConnectionError, TimeoutError]) -async def test_connection_error(hass: HomeAssistant, exc: Exception) -> None: +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (SnmpError("SNMP error"), "snmp_error"), + ], +) +async def test_errors( + hass: HomeAssistant, exc: Exception, base_error: str, mock_brother_client: AsyncMock +) -> None: """Test connection to host error.""" - with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data", side_effect=exc), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) + mock_brother_client.async_update.side_effect = exc - assert result["errors"] == {"base": "cannot_connect"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) - -async def test_snmp_error(hass: HomeAssistant) -> None: - """Test SNMP error.""" - with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data", side_effect=SnmpError("error")), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - - assert result["errors"] == {"base": "snmp_error"} + assert result["errors"] == {"base": base_error} async def test_unsupported_model_error(hass: HomeAssistant) -> None: """Test unsupported printer model error.""" - with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data", side_effect=UnsupportedModelError("error")), + with patch( + "homeassistant.components.brother.Brother.create", + new=AsyncMock(side_effect=UnsupportedModelError("error")), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_model" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_model" -async def test_device_exists_abort(hass: HomeAssistant) -> None: +async def test_device_exists_abort( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test we abort config flow if Brother printer already configured.""" - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), - ), - ): - MockConfigEntry(domain=DOMAIN, unique_id="0123456789", data=CONFIG).add_to_hass( - hass - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) + await init_integration(hass, mock_config_entry) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize("exc", [ConnectionError, TimeoutError, SnmpError("error")]) -async def test_zeroconf_exception(hass: HomeAssistant, exc: Exception) -> None: +async def test_zeroconf_exception( + hass: HomeAssistant, exc: Exception, mock_brother_client: AsyncMock +) -> None: """Test we abort zeroconf flow on exception.""" - with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data", side_effect=exc), - ): - 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")], - hostname="example.local.", - name="Brother Printer", - port=None, - properties={}, - type="mock_type", - ), - ) + mock_brother_client.async_update.side_effect = exc - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + 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")], + hostname="example.local.", + name="Brother Printer", + port=None, + properties={}, + type="mock_type", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: """Test unsupported printer model error.""" - with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data") as mock_get_data, + with patch( + "homeassistant.components.brother.Brother.create", + new=AsyncMock(side_effect=UnsupportedModelError("error")), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -209,46 +160,37 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: ), ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_model" - assert len(mock_get_data.mock_calls) == 0 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_model" -async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: +async def test_zeroconf_device_exists_abort( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test we abort zeroconf flow if Brother printer already configured.""" - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), + await init_integration(hass, mock_config_entry) + + 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")], + hostname="example.local.", + name="Brother Printer", + port=None, + properties={}, + type="mock_type", ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="0123456789", - data={CONF_HOST: "example.local", CONF_TYPE: "laser"}, - ) - entry.add_to_hass(hass) + ) - 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")], - hostname="example.local.", - name="Brother Printer", - port=None, - properties={}, - type="mock_type", - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" # Test config entry got updated with latest IP - assert entry.data["host"] == "127.0.0.1" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: @@ -256,8 +198,8 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: entry = MockConfigEntry(domain=DOMAIN, unique_id="0123456789", data=CONFIG) entry.add_to_hass(hass) with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data") as mock_get_data, + patch("homeassistant.components.brother.Brother.initialize"), + patch("homeassistant.components.brother.Brother._get_data") as mock_get_data, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -279,39 +221,185 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: assert len(mock_get_data.mock_calls) == 0 -async def test_zeroconf_confirm_create_entry(hass: HomeAssistant) -> None: +async def test_zeroconf_confirm_create_entry( + hass: HomeAssistant, mock_brother_client: AsyncMock +) -> None: """Test zeroconf confirmation and create config entry.""" - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), + 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")], + hostname="example.local.", + name="Brother Printer", + port=None, + properties={}, + type="mock_type", ), - ): - 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")], - hostname="example.local.", - name="Brother Printer", - port=None, - properties={}, - type="mock_type", - ), - ) + ) - assert result["step_id"] == "zeroconf_confirm" - assert result["description_placeholders"]["model"] == "HL-L2340DW" - assert result["description_placeholders"]["serial_number"] == "0123456789" - assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + assert result["description_placeholders"]["model"] == "HL-L2340DW" + assert result["description_placeholders"]["serial_number"] == "0123456789" + assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_TYPE: "laser"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TYPE: "laser"} + ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "HL-L2340DW 0123456789" - assert result["data"][CONF_HOST] == "127.0.0.1" - assert result["data"][CONF_TYPE] == "laser" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "HL-L2340DW 0123456789" + assert result["data"][CONF_HOST] == "127.0.0.1" + assert result["data"][CONF_TYPE] == "laser" + + +async def test_reconfigure_successful( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a reconfigure flow.""" + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.10.10.10", + CONF_TYPE: "laser", + } + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (SnmpError("error"), "snmp_error"), + ], +) +async def test_reconfigure_not_successful( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a reconfigure flow but no connection found.""" + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + mock_brother_client.async_update.side_effect = exc + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {"base": base_error} + + mock_brother_client.async_update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "10.10.10.10", + CONF_TYPE: "laser", + } + + +async def test_reconfigure_invalid_hostname( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a reconfigure flow but no connection found.""" + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "invalid/hostname"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {CONF_HOST: "wrong_host"} + + +async def test_reconfigure_not_the_same_device( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting the reconfiguration process, but with a different printer.""" + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + mock_brother_client.serial = "9876543210" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {"base": "another_device"} diff --git a/tests/components/brother/test_diagnostics.py b/tests/components/brother/test_diagnostics.py index 2ea9faa151e..117990b6470 100644 --- a/tests/components/brother/test_diagnostics.py +++ b/tests/components/brother/test_diagnostics.py @@ -1,17 +1,14 @@ """Test Brother diagnostics.""" -from datetime import datetime -import json -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant -from homeassistant.util.dt import UTC from . import init_integration -from tests.common import load_fixture +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,23 +16,15 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - entry = await init_integration(hass, skip_setup=True) + await init_integration(hass, mock_config_entry) - test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) - with ( - patch("brother.Brother.initialize"), - patch("brother.datetime", now=Mock(return_value=test_time)), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), - ), - ): - 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) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) assert result == snapshot diff --git a/tests/components/brother/test_init.py b/tests/components/brother/test_init.py index 582e64c71ae..1a2c6bf23f2 100644 --- a/tests/components/brother/test_init.py +++ b/tests/components/brother/test_init.py @@ -1,13 +1,12 @@ """Test init of Brother integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from brother import SnmpError import pytest from homeassistant.components.brother.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_TYPE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import init_integration @@ -15,59 +14,57 @@ from . import init_integration from tests.common import MockConfigEntry -async def test_async_setup_entry(hass: HomeAssistant) -> None: +async def test_async_setup_entry( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test a successful setup entry.""" - await init_integration(hass) + await init_integration(hass, mock_config_entry) - state = hass.states.get("sensor.hl_l2340dw_status") - assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == "waiting" + assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_config_not_ready(hass: HomeAssistant) -> None: +async def test_config_not_ready( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test for setup failure if connection to broker is missing.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="HL-L2340DW 0123456789", - unique_id="0123456789", - data={CONF_HOST: "localhost", CONF_TYPE: "laser"}, - ) + mock_brother_client.async_update.side_effect = ConnectionError - with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data", side_effect=ConnectionError()), - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.parametrize("exc", [(SnmpError("SNMP Error")), (ConnectionError)]) -async def test_error_on_init(hass: HomeAssistant, exc: Exception) -> None: +async def test_error_on_init( + hass: HomeAssistant, exc: Exception, mock_config_entry: MockConfigEntry +) -> None: """Test for error on init.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="HL-L2340DW 0123456789", - unique_id="0123456789", - data={CONF_HOST: "localhost", CONF_TYPE: "laser"}, - ) + with patch( + "homeassistant.components.brother.Brother.create", + new=AsyncMock(side_effect=exc), + ): + await init_integration(hass, mock_config_entry) - with patch("brother.Brother.initialize", side_effect=exc): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test successful unload of entry.""" - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED + assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 069a5ddc152..8069b27e307 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -1,102 +1,77 @@ """Test sensor of Brother integration.""" -from datetime import timedelta -import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion -from homeassistant.components.brother.const import DOMAIN +from homeassistant.components.brother.const import DOMAIN, UPDATE_INTERVAL from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from . import init_integration -from tests.common import async_fire_time_changed, load_fixture, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, - freezer: FrozenDateTimeFactory, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test states of the sensors.""" - hass.config.set_time_zone("UTC") - freezer.move_to("2024-04-20 12:00:00+00:00") - with patch("homeassistant.components.brother.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + await init_integration(hass, mock_config_entry) - await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_brother_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Ensure that we mark the entities unavailable correctly when device is offline.""" - await init_integration(hass) + entity_id = "sensor.hl_l2340dw_status" + await init_integration(hass, mock_config_entry) - state = hass.states.get("sensor.hl_l2340dw_status") + state = hass.states.get(entity_id) assert state assert state.state != STATE_UNAVAILABLE assert state.state == "waiting" - future = utcnow() + timedelta(minutes=5) - with ( - patch("brother.Brother.initialize"), - patch("brother.Brother._get_data", side_effect=ConnectionError()), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + mock_brother_client.async_update.side_effect = ConnectionError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state = hass.states.get("sensor.hl_l2340dw_status") - assert state - assert state.state == STATE_UNAVAILABLE + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=10) - with ( - patch("brother.Brother.initialize"), - patch( - "brother.Brother._get_data", - return_value=json.loads(load_fixture("printer_data.json", "brother")), - ), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + mock_brother_client.async_update.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() - state = hass.states.get("sensor.hl_l2340dw_status") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "waiting" - - -async def test_manual_update_entity(hass: HomeAssistant) -> None: - """Test manual update entity via service homeassistant/update_entity.""" - await init_integration(hass) - - data = json.loads(load_fixture("printer_data.json", "brother")) - - await async_setup_component(hass, "homeassistant", {}) - with patch( - "homeassistant.components.brother.Brother.async_update", return_value=data - ) as mock_update: - await hass.services.async_call( - "homeassistant", - "update_entity", - {ATTR_ENTITY_ID: ["sensor.hl_l2340dw_status"]}, - blocking=True, - ) - - assert len(mock_update.mock_calls) == 1 + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "waiting" async def test_unique_id_migration( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_brother_client: AsyncMock, ) -> None: """Test states of the unique_id migration.""" @@ -108,7 +83,7 @@ async def test_unique_id_migration( disabled_by=None, ) - await init_integration(hass) + await init_integration(hass, mock_config_entry) entry = entity_registry.async_get("sensor.hl_l2340dw_b_w_counter") assert entry diff --git a/tests/components/brottsplatskartan/conftest.py b/tests/components/brottsplatskartan/conftest.py index 6d3769edd71..c10093f18b9 100644 --- a/tests/components/brottsplatskartan/conftest.py +++ b/tests/components/brottsplatskartan/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for Brottplatskartan.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.brottsplatskartan.async_setup_entry", @@ -17,7 +17,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(autouse=True) -def uuid_generator() -> Generator[AsyncMock, None, None]: +def uuid_generator() -> Generator[AsyncMock]: """Generate uuid for app-id.""" with patch( "homeassistant.components.brottsplatskartan.config_flow.uuid.getnode", diff --git a/tests/components/brunt/conftest.py b/tests/components/brunt/conftest.py index f9a518292ac..bfbca238446 100644 --- a/tests/components/brunt/conftest.py +++ b/tests/components/brunt/conftest.py @@ -1,13 +1,13 @@ """Configuration for brunt tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.brunt.async_setup_entry", return_value=True diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index a9120832ac4..224e0e0b157 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -1,10 +1,10 @@ """Fixtures for BSBLAN integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from bsblan import Device, Info, State import pytest +from typing_extensions import Generator from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -31,7 +31,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.bsblan.async_setup_entry", return_value=True @@ -40,7 +40,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_bsblan(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_bsblan() -> Generator[MagicMock]: """Return a mocked BSBLAN client.""" with ( diff --git a/tests/components/bthome/conftest.py b/tests/components/bthome/conftest.py index 9fce8e85ea8..5a839a9d6b8 100644 --- a/tests/components/bthome/conftest.py +++ b/tests/components/bthome/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/bthome/test_device_trigger.py b/tests/components/bthome/test_device_trigger.py index 240eb7ab3d8..459654826f9 100644 --- a/tests/components/bthome/test_device_trigger.py +++ b/tests/components/bthome/test_device_trigger.py @@ -1,17 +1,12 @@ """Test BTHome BLE events.""" -import pytest - from homeassistant.components import automation -from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN +from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.bthome.const import CONF_SUBTYPE, DOMAIN from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - async_get as async_get_dev_reg, -) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import make_bthome_v2_adv @@ -20,7 +15,6 @@ from tests.common import ( MockConfigEntry, async_capture_events, async_get_device_automations, - async_mock_service, ) from tests.components.bluetooth import inject_bluetooth_service_info_bleak @@ -31,13 +25,7 @@ def get_device_id(mac: str) -> tuple[str, str]: return (BLUETOOTH_DOMAIN, mac) -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - -async def _async_setup_bthome_device(hass, mac: str): +async def _async_setup_bthome_device(hass: HomeAssistant, mac: str) -> MockConfigEntry: config_entry = MockConfigEntry( domain=DOMAIN, unique_id=mac, @@ -96,7 +84,9 @@ async def test_event_rotate_dimmer(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_button(hass: HomeAssistant) -> None: +async def test_get_triggers_button( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we get the expected triggers from a BTHome BLE sensor.""" mac = "A4:C1:38:8D:18:B2" entry = await _async_setup_bthome_device(hass, mac) @@ -112,8 +102,7 @@ async def test_get_triggers_button(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -132,7 +121,9 @@ async def test_get_triggers_button(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_dimmer(hass: HomeAssistant) -> None: +async def test_get_triggers_dimmer( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we get the expected triggers from a BTHome BLE sensor.""" mac = "A4:C1:38:8D:18:B2" entry = await _async_setup_bthome_device(hass, mac) @@ -148,8 +139,7 @@ async def test_get_triggers_dimmer(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -168,7 +158,9 @@ async def test_get_triggers_dimmer(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_for_invalid_bthome_ble_device(hass: HomeAssistant) -> None: +async def test_get_triggers_for_invalid_bthome_ble_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we don't get triggers for an invalid device.""" mac = "A4:C1:38:8D:18:B2" entry = await _async_setup_bthome_device(hass, mac) @@ -184,8 +176,7 @@ async def test_get_triggers_for_invalid_bthome_ble_device(hass: HomeAssistant) - await hass.async_block_till_done() assert len(events) == 0 - dev_reg = async_get_dev_reg(hass) - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "invdevmac")}, ) @@ -199,7 +190,9 @@ async def test_get_triggers_for_invalid_bthome_ble_device(hass: HomeAssistant) - await hass.async_block_till_done() -async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: +async def test_get_triggers_for_invalid_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we don't get triggers when using an invalid device_id.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_bthome_device(hass, mac) @@ -213,11 +206,9 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert invalid_device triggers = await async_get_device_automations( @@ -229,7 +220,11 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_motion_detected( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + service_calls: list[ServiceCall], +) -> None: """Test for motion event trigger firing.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_bthome_device(hass, mac) @@ -243,8 +238,7 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: # # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -276,8 +270,8 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: ) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test_trigger_button_long_press" + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == "test_trigger_button_long_press" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/buienradar/conftest.py b/tests/components/buienradar/conftest.py index 616976b292f..7c9027c7715 100644 --- a/tests/components/buienradar/conftest.py +++ b/tests/components/buienradar/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for buienradar2.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.buienradar.async_setup_entry", return_value=True diff --git a/tests/components/buienradar/test_camera.py b/tests/components/buienradar/test_camera.py index 799fa37c7e3..9ef986b094c 100644 --- a/tests/components/buienradar/test_camera.py +++ b/tests/components/buienradar/test_camera.py @@ -10,7 +10,7 @@ from aiohttp.client_exceptions import ClientResponseError 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.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -32,7 +32,7 @@ def radar_map_url(country_code: str = "NL") -> str: async def _setup_config_entry(hass, entry): - entity_registry = async_get(hass) + entity_registry = er.async_get(hass) entity_registry.async_get_or_create( domain="camera", platform="buienradar", diff --git a/tests/components/buienradar/test_sensor.py b/tests/components/buienradar/test_sensor.py index ea5ef74f72e..09121a885c0 100644 --- a/tests/components/buienradar/test_sensor.py +++ b/tests/components/buienradar/test_sensor.py @@ -5,7 +5,7 @@ from http import HTTPStatus from homeassistant.components.buienradar.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -18,7 +18,9 @@ TEST_CFG_DATA = {CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE} async def test_smoke_test_setup_component( - aioclient_mock: AiohttpClientMocker, hass: HomeAssistant + aioclient_mock: AiohttpClientMocker, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Smoke test for successfully set-up with default config.""" aioclient_mock.get( @@ -28,7 +30,6 @@ async def test_smoke_test_setup_component( mock_entry.add_to_hass(hass) - entity_registry = async_get(hass) for cond in CONDITIONS: entity_registry.async_get_or_create( domain="sensor", diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py index f0d34e25e37..837a433c87c 100644 --- a/tests/components/button/test_device_action.py +++ b/tests/components/button/test_device_action.py @@ -63,7 +63,7 @@ async def test_get_actions_hidden_auxiliary( entity_registry: er.EntityRegistry, hidden_by: er.RegistryEntryHider | None, entity_category: EntityCategory | None, -): +) -> None: """Test we get the expected actions from a hidden or auxiliary entity.""" config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -88,7 +88,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["press"] + for action in ("press",) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py index 034b8ed7e6e..dee8045a71f 100644 --- a/tests/components/button/test_device_trigger.py +++ b/tests/components/button/test_device_trigger.py @@ -67,12 +67,12 @@ async def test_get_triggers( ], ) async def test_get_triggers_hidden_auxiliary( - hass, + hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, hidden_by: er.RegistryEntryHider | None, entity_category: EntityCategory | None, -): +) -> None: """Test we get the expected triggers from a hidden or auxiliary entity.""" config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -97,7 +97,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["pressed"] + for trigger in ("pressed",) ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -109,7 +109,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -169,7 +169,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py index 0641bbe29dc..583c625e1b2 100644 --- a/tests/components/button/test_init.py +++ b/tests/components/button/test_init.py @@ -1,11 +1,11 @@ """The tests for the Button component.""" -from collections.abc import Generator from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest +from typing_extensions import Generator from homeassistant.components.button import ( DOMAIN, @@ -55,12 +55,11 @@ async def test_button(hass: HomeAssistant) -> None: assert button.press.called +@pytest.mark.usefixtures("enable_custom_integrations", "setup_platform") async def test_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, freezer: FrozenDateTimeFactory, - setup_platform: None, ) -> None: """Test we integration.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) @@ -95,9 +94,8 @@ async def test_custom_integration( assert hass.states.get("button.button_1").state == new_time_isoformat -async def test_restore_state( - hass: HomeAssistant, enable_custom_integrations: None, setup_platform: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "setup_platform") +async def test_restore_state(hass: HomeAssistant) -> None: """Test we restore state integration.""" mock_restore_cache(hass, (State("button.button_1", "2021-01-01T23:59:59+00:00"),)) @@ -107,9 +105,8 @@ async def test_restore_state( assert hass.states.get("button.button_1").state == "2021-01-01T23:59:59+00:00" -async def test_restore_state_does_not_restore_unavailable( - hass: HomeAssistant, enable_custom_integrations: None, setup_platform: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "setup_platform") +async def test_restore_state_does_not_restore_unavailable(hass: HomeAssistant) -> None: """Test we restore state integration except for unavailable.""" mock_restore_cache(hass, (State("button.button_1", STATE_UNAVAILABLE),)) @@ -124,7 +121,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -139,7 +136,7 @@ async def test_name(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True mock_platform(hass, f"{TEST_DOMAIN}.config_flow") diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 942a4913f6e..e1a681e12fe 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -315,10 +315,10 @@ def mock_tz() -> str | None: @pytest.fixture(autouse=True) -def set_tz(hass: HomeAssistant, tz: str | None) -> None: +async def set_tz(hass: HomeAssistant, tz: str | None) -> None: """Fixture to set the default TZ to the one requested.""" if tz is not None: - hass.config.set_time_zone(tz) + await hass.config.async_set_time_zone(tz) @pytest.fixture(autouse=True) @@ -721,7 +721,7 @@ async def test_all_day_event( target_datetime: datetime.datetime, ) -> None: """Test that the event lasting the whole day is returned, if it's early in the local day.""" - freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + freezer.move_to(target_datetime.replace(tzinfo=dt_util.get_default_time_zone())) assert await async_setup_component( hass, "calendar", @@ -895,7 +895,7 @@ async def test_event_rrule_all_day_early( target_datetime: datetime.datetime, ) -> None: """Test that the recurring all day event is returned early in the local day, and not on the first occurrence.""" - freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + freezer.move_to(target_datetime.replace(tzinfo=dt_util.get_default_time_zone())) assert await async_setup_component( hass, "calendar", diff --git a/tests/components/caldav/test_config_flow.py b/tests/components/caldav/test_config_flow.py index c6d5552c874..7c47ea14607 100644 --- a/tests/components/caldav/test_config_flow.py +++ b/tests/components/caldav/test_config_flow.py @@ -1,11 +1,11 @@ """Test the CalDAV config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch from caldav.lib.error import AuthorizationError, DAVError import pytest import requests +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.caldav.const import DOMAIN @@ -19,7 +19,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index bea4725856e..66f6e975453 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -91,9 +91,9 @@ def platforms() -> list[Platform]: @pytest.fixture(autouse=True) -def set_tz(hass: HomeAssistant) -> None: +async def set_tz(hass: HomeAssistant) -> None: """Fixture to set timezone with fixed offset year round.""" - hass.config.set_time_zone("America/Regina") + await hass.config.async_set_time_zone("America/Regina") @pytest.fixture(name="todos") diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index 7a3f27c8e08..83ecaca97d3 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -1,12 +1,12 @@ """Test fixtures for calendar sensor platforms.""" -from collections.abc import Generator import datetime import secrets from typing import Any from unittest.mock import AsyncMock import pytest +from typing_extensions import Generator from homeassistant.components.calendar import DOMAIN, CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -28,11 +28,11 @@ TEST_DOMAIN = "test" @pytest.fixture -def set_time_zone(hass: HomeAssistant) -> None: +async def set_time_zone(hass: HomeAssistant) -> None: """Set the time zone for the tests.""" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round - hass.config.set_time_zone("America/Regina") + await hass.config.async_set_time_zone("America/Regina") class MockFlow(ConfigFlow): @@ -92,7 +92,7 @@ class MockCalendarEntity(CalendarEntity): @pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -120,7 +120,7 @@ def mock_setup_integration( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True async def async_unload_entry_init( diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index c2842eafb2c..116ca70f15e 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator from datetime import timedelta from http import HTTPStatus from typing import Any @@ -10,19 +9,15 @@ from typing import Any from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator import voluptuous as vol -from homeassistant.components.calendar import ( - DOMAIN, - LEGACY_SERVICE_LIST_EVENTS, - SERVICE_GET_EVENTS, -) +from homeassistant.components.calendar import DOMAIN, SERVICE_GET_EVENTS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.issue_registry import IssueRegistry import homeassistant.util.dt as dt_util -from .conftest import TEST_DOMAIN, MockCalendarEntity, MockConfigEntry +from .conftest import MockCalendarEntity, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -37,7 +32,7 @@ def mock_frozen_time() -> None: @pytest.fixture(autouse=True) -def mock_set_frozen_time(frozen_time: Any) -> Generator[None, None, None]: +def mock_set_frozen_time(frozen_time: Any) -> Generator[None]: """Fixture to freeze time that also can work for other fixtures.""" if not frozen_time: yield @@ -415,20 +410,6 @@ async def test_create_event_service_invalid_params( @pytest.mark.parametrize( ("service", "expected"), [ - ( - LEGACY_SERVICE_LIST_EVENTS, - { - "events": [ - { - "start": "2023-06-22T05:00:00-06:00", - "end": "2023-06-22T06:00:00-06:00", - "summary": "Future Event", - "description": "Future Description", - "location": "Future Location", - } - ] - }, - ), ( SERVICE_GET_EVENTS, { @@ -486,7 +467,6 @@ async def test_list_events_service( @pytest.mark.parametrize( ("service"), [ - (LEGACY_SERVICE_LIST_EVENTS), SERVICE_GET_EVENTS, ], ) @@ -568,37 +548,3 @@ async def test_list_events_missing_fields(hass: HomeAssistant) -> None: blocking=True, return_response=True, ) - - -async def test_issue_deprecated_service_calendar_list_events( - hass: HomeAssistant, - issue_registry: IssueRegistry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the issue is raised on deprecated service weather.get_forecast.""" - - _ = await hass.services.async_call( - DOMAIN, - LEGACY_SERVICE_LIST_EVENTS, - target={"entity_id": ["calendar.calendar_1"]}, - service_data={ - "entity_id": "calendar.calendar_1", - "duration": "01:00:00", - }, - blocking=True, - return_response=True, - ) - - issue = issue_registry.async_get_issue( - "calendar", "deprecated_service_calendar_list_events" - ) - assert issue - assert issue.issue_domain == TEST_DOMAIN - assert issue.issue_id == "deprecated_service_calendar_list_events" - assert issue.translation_key == "deprecated_service_calendar_list_events" - - assert ( - "Detected use of service 'calendar.list_events'. " - "This is deprecated and will stop working in Home Assistant 2024.6. " - "Use 'calendar.get_events' instead which supports multiple entities" - ) in caplog.text diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 54cfd353618..3b415d46e63 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -9,7 +9,7 @@ forward exercising the triggers. from __future__ import annotations -from collections.abc import AsyncIterator, Callable, Generator +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager import datetime import logging @@ -19,6 +19,7 @@ import zoneinfo from freezegun.api import FrozenDateTimeFactory import pytest +from typing_extensions import Generator from homeassistant.components import automation, calendar from homeassistant.components.calendar.trigger import EVENT_END, EVENT_START @@ -86,7 +87,7 @@ class FakeSchedule: @pytest.fixture def fake_schedule( hass: HomeAssistant, freezer: FrozenDateTimeFactory -) -> Generator[FakeSchedule, None, None]: +) -> Generator[FakeSchedule]: """Fixture that tests can use to make fake events.""" # Setup start time for all tests @@ -150,7 +151,7 @@ async def create_automation( @pytest.fixture -def calls(hass: HomeAssistant) -> Callable[[], list[dict[str, Any]]]: +def calls_data(hass: HomeAssistant) -> Callable[[], list[dict[str, Any]]]: """Fixture to return payload data for automation calls.""" service_calls = async_mock_service(hass, "test", "automation") @@ -161,7 +162,7 @@ def calls(hass: HomeAssistant) -> Callable[[], list[dict[str, Any]]]: @pytest.fixture(autouse=True) -def mock_update_interval() -> Generator[None, None, None]: +def mock_update_interval() -> Generator[None]: """Fixture to override the update interval for refreshing events.""" with patch( "homeassistant.components.calendar.trigger.UPDATE_INTERVAL", @@ -172,7 +173,7 @@ def mock_update_interval() -> Generator[None, None, None]: async def test_event_start_trigger( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -182,13 +183,13 @@ async def test_event_start_trigger( end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) async with create_automation(hass, EVENT_START): - assert len(calls()) == 0 + assert len(calls_data()) == 0 await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -206,7 +207,7 @@ async def test_event_start_trigger( ) async def test_event_start_trigger_with_offset( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, offset_str, @@ -222,13 +223,13 @@ async def test_event_start_trigger_with_offset( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:55:00+00:00") + offset_delta, ) - assert len(calls()) == 0 + assert len(calls_data()) == 0 # Event has started w/ offset await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -239,7 +240,7 @@ async def test_event_start_trigger_with_offset( async def test_event_end_trigger( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -253,13 +254,13 @@ async def test_event_end_trigger( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:10:00+00:00") ) - assert len(calls()) == 0 + assert len(calls_data()) == 0 # Event ends await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 12:10:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_END, @@ -277,7 +278,7 @@ async def test_event_end_trigger( ) async def test_event_end_trigger_with_offset( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, offset_str, @@ -293,13 +294,13 @@ async def test_event_end_trigger_with_offset( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta, ) - assert len(calls()) == 0 + assert len(calls_data()) == 0 # Event has started w/ offset await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 12:35:00+00:00") + offset_delta, ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_END, @@ -310,7 +311,7 @@ async def test_event_end_trigger_with_offset( async def test_calendar_trigger_with_no_events( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, ) -> None: """Test a calendar trigger setup with no events.""" @@ -320,12 +321,12 @@ async def test_calendar_trigger_with_no_events( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00") ) - assert len(calls()) == 0 + assert len(calls_data()) == 0 async def test_multiple_start_events( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -343,7 +344,7 @@ async def test_multiple_start_events( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -359,7 +360,7 @@ async def test_multiple_start_events( async def test_multiple_end_events( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -378,7 +379,7 @@ async def test_multiple_end_events( datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_END, @@ -394,7 +395,7 @@ async def test_multiple_end_events( async def test_multiple_events_sharing_start_time( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -413,7 +414,7 @@ async def test_multiple_events_sharing_start_time( datetime.datetime.fromisoformat("2022-04-19 11:35:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -429,7 +430,7 @@ async def test_multiple_events_sharing_start_time( async def test_overlap_events( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -448,7 +449,7 @@ async def test_overlap_events( datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -506,7 +507,7 @@ async def test_legacy_entity_type( async def test_update_next_event( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -521,7 +522,7 @@ async def test_update_next_event( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00") ) - assert len(calls()) == 0 + assert len(calls_data()) == 0 # Create a new event between now and when the event fires event_data2 = test_entity.create_event( @@ -533,7 +534,7 @@ async def test_update_next_event( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -549,7 +550,7 @@ async def test_update_next_event( async def test_update_missed( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, ) -> None: @@ -565,7 +566,7 @@ async def test_update_missed( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 10:38:00+00:00") ) - assert len(calls()) == 0 + assert len(calls_data()) == 0 test_entity.create_event( start=datetime.datetime.fromisoformat("2022-04-19 10:40:00+00:00"), @@ -576,7 +577,7 @@ async def test_update_missed( await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -639,7 +640,7 @@ async def test_update_missed( ) async def test_event_payload( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, set_time_zone: None, @@ -650,10 +651,10 @@ async def test_event_payload( """Test the fields in the calendar event payload are set.""" test_entity.create_event(**create_data) async with create_automation(hass, EVENT_START): - assert len(calls()) == 0 + assert len(calls_data()) == 0 await fake_schedule.fire_until(fire_time) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -664,7 +665,7 @@ async def test_event_payload( async def test_trigger_timestamp_window_edge( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, @@ -678,12 +679,12 @@ async def test_trigger_timestamp_window_edge( end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), ) async with create_automation(hass, EVENT_START): - assert len(calls()) == 0 + assert len(calls_data()) == 0 await fake_schedule.fire_until( datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -694,14 +695,14 @@ async def test_trigger_timestamp_window_edge( async def test_event_start_trigger_dst( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entity: MockCalendarEntity, freezer: FrozenDateTimeFactory, ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" + await hass.config.async_set_time_zone("America/Los_Angeles") tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") - hass.config.set_time_zone("America/Los_Angeles") freezer.move_to("2023-03-12 01:00:00-08:00") # Before DST transition starts @@ -723,13 +724,13 @@ async def test_event_start_trigger_dst( end=datetime.datetime(2023, 3, 12, 3, 45, tzinfo=tzinfo), ) async with create_automation(hass, EVENT_START): - assert len(calls()) == 0 + assert len(calls_data()) == 0 await fake_schedule.fire_until( datetime.datetime.fromisoformat("2023-03-12 05:00:00-08:00"), ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -750,7 +751,7 @@ async def test_event_start_trigger_dst( async def test_config_entry_reload( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entities: list[MockCalendarEntity], setup_platform: None, @@ -764,7 +765,7 @@ async def test_config_entry_reload( invalid after a config entry was reloaded. """ async with create_automation(hass, EVENT_START): - assert len(calls()) == 0 + assert len(calls_data()) == 0 assert await hass.config_entries.async_reload(config_entry.entry_id) @@ -779,7 +780,7 @@ async def test_config_entry_reload( datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), ) - assert calls() == [ + assert calls_data() == [ { "platform": "calendar", "event": EVENT_START, @@ -790,7 +791,7 @@ async def test_config_entry_reload( async def test_config_entry_unload( hass: HomeAssistant, - calls: Callable[[], list[dict[str, Any]]], + calls_data: Callable[[], list[dict[str, Any]]], fake_schedule: FakeSchedule, test_entities: list[MockCalendarEntity], setup_platform: None, @@ -799,7 +800,7 @@ async def test_config_entry_unload( ) -> None: """Test an automation that references a calendar entity that is unloaded.""" async with create_automation(hass, EVENT_START): - assert len(calls()) == 0 + assert len(calls_data()) == 0 assert await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index ee8c5df7d65..524b56c2303 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import PropertyMock, patch import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.components import camera from homeassistant.components.camera.const import StreamType @@ -15,13 +16,13 @@ from .common import WEBRTC_ANSWER @pytest.fixture(autouse=True) -async def setup_homeassistant(hass: HomeAssistant): +async def setup_homeassistant(hass: HomeAssistant) -> None: """Set up the homeassistant integration.""" await async_setup_component(hass, "homeassistant", {}) @pytest.fixture(autouse=True) -async def camera_only() -> None: +def camera_only() -> Generator[None]: """Enable only the camera platform.""" with patch( "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", @@ -31,7 +32,7 @@ async def camera_only() -> None: @pytest.fixture(name="mock_camera") -async def mock_camera_fixture(hass): +async def mock_camera_fixture(hass: HomeAssistant) -> AsyncGenerator[None]: """Initialize a demo camera platform.""" assert await async_setup_component( hass, "camera", {camera.DOMAIN: {"platform": "demo"}} @@ -46,7 +47,7 @@ async def mock_camera_fixture(hass): @pytest.fixture(name="mock_camera_hls") -async def mock_camera_hls_fixture(mock_camera): +def mock_camera_hls_fixture(mock_camera: None) -> Generator[None]: """Initialize a demo camera platform with HLS.""" with patch( "homeassistant.components.camera.Camera.frontend_stream_type", @@ -56,7 +57,7 @@ async def mock_camera_hls_fixture(mock_camera): @pytest.fixture(name="mock_camera_web_rtc") -async def mock_camera_web_rtc_fixture(hass): +async def mock_camera_web_rtc_fixture(hass: HomeAssistant) -> AsyncGenerator[None]: """Initialize a demo camera platform with WebRTC.""" assert await async_setup_component( hass, "camera", {camera.DOMAIN: {"platform": "demo"}} @@ -77,7 +78,7 @@ async def mock_camera_web_rtc_fixture(hass): @pytest.fixture(name="mock_camera_with_device") -async def mock_camera_with_device_fixture(): +def mock_camera_with_device_fixture() -> Generator[None]: """Initialize a demo camera platform with a device.""" dev_info = DeviceInfo( identifiers={("camera", "test_unique_id")}, @@ -103,7 +104,7 @@ async def mock_camera_with_device_fixture(): @pytest.fixture(name="mock_camera_with_no_name") -async def mock_camera_with_no_name_fixture(mock_camera_with_device): +def mock_camera_with_no_name_fixture(mock_camera_with_device: None) -> Generator[None]: """Initialize a demo camera platform with a device and no name.""" with patch( "homeassistant.components.camera.Camera._attr_name", diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index dffc7e5aa53..7da6cd91a7a 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -6,6 +6,7 @@ from types import ModuleType from unittest.mock import AsyncMock, Mock, PropertyMock, mock_open, patch import pytest +from typing_extensions import Generator from homeassistant.components import camera from homeassistant.components.camera.const import ( @@ -13,7 +14,7 @@ from homeassistant.components.camera.const import ( PREF_ORIENTATION, PREF_PRELOAD_STREAM, ) -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ENTITY_ID, @@ -41,7 +42,7 @@ WEBRTC_OFFER = "v=0\r\n" @pytest.fixture(name="mock_stream") -def mock_stream_fixture(hass): +def mock_stream_fixture(hass: HomeAssistant) -> None: """Initialize a demo camera platform with streaming.""" assert hass.loop.run_until_complete( async_setup_component(hass, "stream", {"stream": {}}) @@ -49,7 +50,7 @@ def mock_stream_fixture(hass): @pytest.fixture(name="image_mock_url") -async def image_mock_url_fixture(hass): +async def image_mock_url_fixture(hass: HomeAssistant) -> None: """Fixture for get_image tests.""" await async_setup_component( hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} @@ -58,7 +59,7 @@ async def image_mock_url_fixture(hass): @pytest.fixture(name="mock_stream_source") -async def mock_stream_source_fixture(): +def mock_stream_source_fixture() -> Generator[AsyncMock]: """Fixture to create an RTSP stream source.""" with patch( "homeassistant.components.camera.Camera.stream_source", @@ -68,7 +69,7 @@ async def mock_stream_source_fixture(): @pytest.fixture(name="mock_hls_stream_source") -async def mock_hls_stream_source_fixture(): +async def mock_hls_stream_source_fixture() -> Generator[AsyncMock]: """Fixture to create an HLS stream source.""" with patch( "homeassistant.components.camera.Camera.stream_source", @@ -85,7 +86,7 @@ async def provide_web_rtc_answer(stream_source: str, offer: str, stream_id: str) @pytest.fixture(name="mock_rtsp_to_web_rtc") -async def mock_rtsp_to_web_rtc_fixture(hass): +def mock_rtsp_to_web_rtc_fixture(hass: HomeAssistant) -> Generator[Mock]: """Fixture that registers a mock rtsp to web_rtc provider.""" mock_provider = Mock(side_effect=provide_web_rtc_answer) unsub = camera.async_register_rtsp_to_web_rtc_provider( @@ -95,7 +96,8 @@ async def mock_rtsp_to_web_rtc_fixture(hass): unsub() -async def test_get_image_from_camera(hass: HomeAssistant, image_mock_url) -> None: +@pytest.mark.usefixtures("image_mock_url") +async def test_get_image_from_camera(hass: HomeAssistant) -> None: """Grab an image from camera entity.""" with patch( @@ -109,9 +111,8 @@ async def test_get_image_from_camera(hass: HomeAssistant, image_mock_url) -> Non assert image.content == b"Test" -async def test_get_image_from_camera_with_width_height( - hass: HomeAssistant, image_mock_url -) -> None: +@pytest.mark.usefixtures("image_mock_url") +async def test_get_image_from_camera_with_width_height(hass: HomeAssistant) -> None: """Grab an image from camera entity with width and height.""" turbo_jpeg = mock_turbo_jpeg( @@ -136,8 +137,9 @@ async def test_get_image_from_camera_with_width_height( assert image.content == b"Test" +@pytest.mark.usefixtures("image_mock_url") async def test_get_image_from_camera_with_width_height_scaled( - hass: HomeAssistant, image_mock_url + hass: HomeAssistant, ) -> None: """Grab an image from camera entity with width and height and scale it.""" @@ -164,9 +166,8 @@ async def test_get_image_from_camera_with_width_height_scaled( assert image.content == EMPTY_8_6_JPEG -async def test_get_image_from_camera_not_jpeg( - hass: HomeAssistant, image_mock_url -) -> None: +@pytest.mark.usefixtures("image_mock_url") +async def test_get_image_from_camera_not_jpeg(hass: HomeAssistant) -> None: """Grab an image from camera entity that we cannot scale.""" turbo_jpeg = mock_turbo_jpeg( @@ -192,8 +193,9 @@ async def test_get_image_from_camera_not_jpeg( assert image.content == b"png" +@pytest.mark.usefixtures("mock_camera") async def test_get_stream_source_from_camera( - hass: HomeAssistant, mock_camera, mock_stream_source + hass: HomeAssistant, mock_stream_source: AsyncMock ) -> None: """Fetch stream source from camera entity.""" @@ -203,9 +205,8 @@ async def test_get_stream_source_from_camera( assert stream_source == STREAM_SOURCE -async def test_get_image_without_exists_camera( - hass: HomeAssistant, image_mock_url -) -> None: +@pytest.mark.usefixtures("image_mock_url") +async def test_get_image_without_exists_camera(hass: HomeAssistant) -> None: """Try to get image without exists camera.""" with ( patch( @@ -217,7 +218,8 @@ async def test_get_image_without_exists_camera( await camera.async_get_image(hass, "camera.demo_camera") -async def test_get_image_with_timeout(hass: HomeAssistant, image_mock_url) -> None: +@pytest.mark.usefixtures("image_mock_url") +async def test_get_image_with_timeout(hass: HomeAssistant) -> None: """Try to get image with timeout.""" with ( patch( @@ -229,7 +231,8 @@ async def test_get_image_with_timeout(hass: HomeAssistant, image_mock_url) -> No await camera.async_get_image(hass, "camera.demo_camera") -async def test_get_image_fails(hass: HomeAssistant, image_mock_url) -> None: +@pytest.mark.usefixtures("image_mock_url") +async def test_get_image_fails(hass: HomeAssistant) -> None: """Try to get image with timeout.""" with ( patch( @@ -241,7 +244,8 @@ async def test_get_image_fails(hass: HomeAssistant, image_mock_url) -> None: await camera.async_get_image(hass, "camera.demo_camera") -async def test_snapshot_service(hass: HomeAssistant, mock_camera) -> None: +@pytest.mark.usefixtures("mock_camera") +async def test_snapshot_service(hass: HomeAssistant) -> None: """Test snapshot service.""" mopen = mock_open() @@ -268,9 +272,8 @@ async def test_snapshot_service(hass: HomeAssistant, mock_camera) -> None: assert mock_write.mock_calls[0][1][0] == b"Test" -async def test_snapshot_service_not_allowed_path( - hass: HomeAssistant, mock_camera -) -> None: +@pytest.mark.usefixtures("mock_camera") +async def test_snapshot_service_not_allowed_path(hass: HomeAssistant) -> None: """Test snapshot service with a not allowed path.""" mopen = mock_open() @@ -292,8 +295,9 @@ async def test_snapshot_service_not_allowed_path( ) +@pytest.mark.usefixtures("mock_camera", "mock_stream") async def test_websocket_stream_no_source( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera, mock_stream + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test camera/stream websocket command with camera with no source.""" await async_setup_component(hass, "camera", {}) @@ -311,8 +315,9 @@ async def test_websocket_stream_no_source( assert not msg["success"] +@pytest.mark.usefixtures("mock_camera", "mock_stream") async def test_websocket_camera_stream( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera, mock_stream + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test camera/stream websocket command.""" await async_setup_component(hass, "camera", {}) @@ -342,8 +347,9 @@ async def test_websocket_camera_stream( assert msg["result"]["url"][-13:] == "playlist.m3u8" +@pytest.mark.usefixtures("mock_camera") async def test_websocket_get_prefs( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test get camera preferences websocket command.""" await async_setup_component(hass, "camera", {}) @@ -359,8 +365,9 @@ async def test_websocket_get_prefs( assert msg["success"] +@pytest.mark.usefixtures("mock_camera") async def test_websocket_update_preload_prefs( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test updating camera preferences.""" @@ -396,11 +403,11 @@ async def test_websocket_update_preload_prefs( assert msg["result"][PREF_PRELOAD_STREAM] is True +@pytest.mark.usefixtures("mock_camera") async def test_websocket_update_orientation_prefs( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entity_registry: er.EntityRegistry, - mock_camera, ) -> None: """Test updating camera preferences.""" await async_setup_component(hass, "homeassistant", {}) @@ -454,9 +461,8 @@ async def test_websocket_update_orientation_prefs( assert msg["result"]["orientation"] == camera.Orientation.ROTATE_180 -async def test_play_stream_service_no_source( - hass: HomeAssistant, mock_camera, mock_stream -) -> None: +@pytest.mark.usefixtures("mock_camera", "mock_stream") +async def test_play_stream_service_no_source(hass: HomeAssistant) -> None: """Test camera play_stream service.""" data = { ATTR_ENTITY_ID: "camera.demo_camera", @@ -469,9 +475,8 @@ async def test_play_stream_service_no_source( ) -async def test_handle_play_stream_service( - hass: HomeAssistant, mock_camera, mock_stream -) -> None: +@pytest.mark.usefixtures("mock_camera", "mock_stream") +async def test_handle_play_stream_service(hass: HomeAssistant) -> None: """Test camera play_stream service.""" await async_process_ha_core_config( hass, @@ -502,7 +507,8 @@ async def test_handle_play_stream_service( assert mock_request_stream.called -async def test_no_preload_stream(hass: HomeAssistant, mock_stream) -> None: +@pytest.mark.usefixtures("mock_stream") +async def test_no_preload_stream(hass: HomeAssistant) -> None: """Test camera preload preference.""" demo_settings = camera.DynamicStreamSettings() with ( @@ -525,7 +531,8 @@ async def test_no_preload_stream(hass: HomeAssistant, mock_stream) -> None: assert not mock_request_stream.called -async def test_preload_stream(hass: HomeAssistant, mock_stream) -> None: +@pytest.mark.usefixtures("mock_stream") +async def test_preload_stream(hass: HomeAssistant) -> None: """Test camera preload preference.""" demo_settings = camera.DynamicStreamSettings(preload_stream=True) with ( @@ -549,7 +556,8 @@ async def test_preload_stream(hass: HomeAssistant, mock_stream) -> None: assert mock_create_stream.called -async def test_record_service_invalid_path(hass: HomeAssistant, mock_camera) -> None: +@pytest.mark.usefixtures("mock_camera") +async def test_record_service_invalid_path(hass: HomeAssistant) -> None: """Test record service with invalid path.""" with ( patch.object(hass.config, "is_allowed_path", return_value=False), @@ -567,7 +575,8 @@ async def test_record_service_invalid_path(hass: HomeAssistant, mock_camera) -> ) -async def test_record_service(hass: HomeAssistant, mock_camera, mock_stream) -> None: +@pytest.mark.usefixtures("mock_camera", "mock_stream") +async def test_record_service(hass: HomeAssistant) -> None: """Test record service.""" with ( patch( @@ -591,9 +600,8 @@ async def test_record_service(hass: HomeAssistant, mock_camera, mock_stream) -> assert mock_record.called -async def test_camera_proxy_stream( - hass: HomeAssistant, mock_camera, hass_client: ClientSessionGenerator -) -> None: +@pytest.mark.usefixtures("mock_camera") +async def test_camera_proxy_stream(hass_client: ClientSessionGenerator) -> None: """Test record service.""" client = await hass_client() @@ -611,10 +619,9 @@ async def test_camera_proxy_stream( assert response.status == HTTPStatus.BAD_GATEWAY +@pytest.mark.usefixtures("mock_camera_web_rtc") async def test_websocket_web_rtc_offer( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera_web_rtc, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test initiating a WebRTC stream with offer and answer.""" client = await hass_ws_client(hass) @@ -634,10 +641,9 @@ async def test_websocket_web_rtc_offer( assert response["result"]["answer"] == WEBRTC_ANSWER +@pytest.mark.usefixtures("mock_camera_web_rtc") async def test_websocket_web_rtc_offer_invalid_entity( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera_web_rtc, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC with a camera entity that does not exist.""" client = await hass_ws_client(hass) @@ -656,10 +662,9 @@ async def test_websocket_web_rtc_offer_invalid_entity( assert not response["success"] +@pytest.mark.usefixtures("mock_camera_web_rtc") async def test_websocket_web_rtc_offer_missing_offer( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera_web_rtc, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC stream with missing required fields.""" client = await hass_ws_client(hass) @@ -678,10 +683,9 @@ async def test_websocket_web_rtc_offer_missing_offer( assert response["error"]["code"] == "invalid_format" +@pytest.mark.usefixtures("mock_camera_web_rtc") async def test_websocket_web_rtc_offer_failure( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera_web_rtc, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC stream that fails handling the offer.""" client = await hass_ws_client(hass) @@ -707,10 +711,9 @@ async def test_websocket_web_rtc_offer_failure( assert response["error"]["message"] == "offer failed" +@pytest.mark.usefixtures("mock_camera_web_rtc") async def test_websocket_web_rtc_offer_timeout( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera_web_rtc, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC stream with timeout handling the offer.""" client = await hass_ws_client(hass) @@ -736,10 +739,9 @@ async def test_websocket_web_rtc_offer_timeout( assert response["error"]["message"] == "Timeout handling WebRTC offer" +@pytest.mark.usefixtures("mock_camera") async def test_websocket_web_rtc_offer_invalid_stream_type( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC initiating for a camera with a different stream_type.""" client = await hass_ws_client(hass) @@ -759,17 +761,17 @@ async def test_websocket_web_rtc_offer_invalid_stream_type( assert response["error"]["code"] == "web_rtc_offer_failed" -async def test_state_streaming( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera -) -> None: +@pytest.mark.usefixtures("mock_camera") +async def test_state_streaming(hass: HomeAssistant) -> None: """Camera state.""" demo_camera = hass.states.get("camera.demo_camera") assert demo_camera is not None assert demo_camera.state == camera.STATE_STREAMING +@pytest.mark.usefixtures("mock_camera", "mock_stream") async def test_stream_unavailable( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera, mock_stream + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Camera state.""" await async_setup_component(hass, "camera", {}) @@ -820,12 +822,11 @@ async def test_stream_unavailable( assert demo_camera.state == camera.STATE_STREAMING +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_rtsp_to_web_rtc_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_camera, - mock_stream_source, - mock_rtsp_to_web_rtc, + mock_rtsp_to_web_rtc: Mock, ) -> None: """Test creating a web_rtc offer from an rstp provider.""" client = await hass_ws_client(hass) @@ -848,13 +849,14 @@ async def test_rtsp_to_web_rtc_offer( assert mock_rtsp_to_web_rtc.called +@pytest.mark.usefixtures( + "mock_camera", + "mock_hls_stream_source", # Not an RTSP stream source + "mock_rtsp_to_web_rtc", +) async def test_unsupported_rtsp_to_web_rtc_stream_type( - hass, - hass_ws_client, - mock_camera, - mock_hls_stream_source, # Not an RTSP stream source - mock_rtsp_to_web_rtc, -): + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Test rtsp-to-webrtc is not registered for non-RTSP streams.""" client = await hass_ws_client(hass) await client.send_json( @@ -873,11 +875,9 @@ async def test_unsupported_rtsp_to_web_rtc_stream_type( assert not response["success"] +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_rtsp_to_web_rtc_provider_unregistered( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera, - mock_stream_source, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test creating a web_rtc offer from an rstp provider.""" mock_provider = Mock(side_effect=provide_web_rtc_answer) @@ -924,11 +924,9 @@ async def test_rtsp_to_web_rtc_provider_unregistered( assert not mock_provider.called +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_rtsp_to_web_rtc_offer_not_accepted( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_camera, - mock_stream_source, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test a provider that can't satisfy the rtsp to webrtc offer.""" @@ -962,10 +960,9 @@ async def test_rtsp_to_web_rtc_offer_not_accepted( unsub() +@pytest.mark.usefixtures("mock_camera") async def test_use_stream_for_stills( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_camera, + hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test that the component can grab images from stream.""" @@ -1080,9 +1077,8 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> assert "is using deprecated supported features values" not in caplog.text -async def test_entity_picture_url_changes_on_token_update( - hass: HomeAssistant, mock_camera -) -> None: +@pytest.mark.usefixtures("mock_camera") +async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" await async_setup_component(hass, "camera", {}) await hass.async_block_till_done() diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index 3dd0399a710..0780ecc2a9c 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -12,14 +12,13 @@ from homeassistant.setup import async_setup_component @pytest.fixture(autouse=True) -async def setup_media_source(hass): +async def setup_media_source(hass: HomeAssistant) -> None: """Set up media source.""" assert await async_setup_component(hass, "media_source", {}) -async def test_device_with_device( - hass: HomeAssistant, mock_camera_with_device, mock_camera -) -> None: +@pytest.mark.usefixtures("mock_camera_with_device", "mock_camera") +async def test_device_with_device(hass: HomeAssistant) -> None: """Test browsing when camera has a device and a name.""" item = await media_source.async_browse_media(hass, "media-source://camera") assert item.not_shown == 2 @@ -27,9 +26,8 @@ async def test_device_with_device( assert item.children[0].title == "Test Camera Device Demo camera without stream" -async def test_device_with_no_name( - hass: HomeAssistant, mock_camera_with_no_name, mock_camera -) -> None: +@pytest.mark.usefixtures("mock_camera_with_no_name", "mock_camera") +async def test_device_with_no_name(hass: HomeAssistant) -> None: """Test browsing when camera has device and name == None.""" item = await media_source.async_browse_media(hass, "media-source://camera") assert item.not_shown == 2 @@ -37,7 +35,8 @@ async def test_device_with_no_name( assert item.children[0].title == "Test Camera Device Demo camera without stream" -async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None: +@pytest.mark.usefixtures("mock_camera_hls") +async def test_browsing_hls(hass: HomeAssistant) -> None: """Test browsing HLS camera media source.""" item = await media_source.async_browse_media(hass, "media-source://camera") assert item is not None @@ -54,7 +53,8 @@ async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None: assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"] -async def test_browsing_mjpeg(hass: HomeAssistant, mock_camera) -> None: +@pytest.mark.usefixtures("mock_camera") +async def test_browsing_mjpeg(hass: HomeAssistant) -> None: """Test browsing MJPEG camera media source.""" item = await media_source.async_browse_media(hass, "media-source://camera") assert item is not None @@ -65,7 +65,8 @@ async def test_browsing_mjpeg(hass: HomeAssistant, mock_camera) -> None: assert item.children[0].title == "Demo camera without stream" -async def test_browsing_web_rtc(hass: HomeAssistant, mock_camera_web_rtc) -> None: +@pytest.mark.usefixtures("mock_camera_web_rtc") +async def test_browsing_web_rtc(hass: HomeAssistant) -> None: """Test browsing WebRTC camera media source.""" # 3 cameras: # one only supports WebRTC (no stream source) @@ -90,7 +91,8 @@ async def test_browsing_web_rtc(hass: HomeAssistant, mock_camera_web_rtc) -> Non assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"] -async def test_resolving(hass: HomeAssistant, mock_camera_hls) -> None: +@pytest.mark.usefixtures("mock_camera_hls") +async def test_resolving(hass: HomeAssistant) -> None: """Test resolving.""" # Adding stream enables HLS camera hass.config.components.add("stream") @@ -107,7 +109,8 @@ async def test_resolving(hass: HomeAssistant, mock_camera_hls) -> None: assert item.mime_type == FORMAT_CONTENT_TYPE["hls"] -async def test_resolving_errors(hass: HomeAssistant, mock_camera_hls) -> None: +@pytest.mark.usefixtures("mock_camera_hls") +async def test_resolving_errors(hass: HomeAssistant) -> None: """Test resolving.""" with pytest.raises(media_source.Unresolvable) as exc_info: diff --git a/tests/components/canary/__init__.py b/tests/components/canary/__init__.py index 8aed2fa1337..13c4b84ab94 100644 --- a/tests/components/canary/__init__.py +++ b/tests/components/canary/__init__.py @@ -54,12 +54,10 @@ def _patch_async_setup_entry(return_value=True): async def init_integration( hass: HomeAssistant, *, - data: dict = ENTRY_CONFIG, - options: dict = ENTRY_OPTIONS, skip_entry_setup: bool = False, ) -> MockConfigEntry: """Set up the Canary integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=data, options=options) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS) entry.add_to_hass(hass) if not skip_entry_setup: diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py index 74ab776ec3b..c9e311bb024 100644 --- a/tests/components/cast/test_home_assistant_cast.py +++ b/tests/components/cast/test_home_assistant_cast.py @@ -12,7 +12,8 @@ from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry, async_mock_signal -async def test_service_show_view(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_service_show_view(hass: HomeAssistant) -> None: """Test showing a view.""" entry = MockConfigEntry(domain=DOMAIN) entry.add_to_hass(hass) @@ -51,9 +52,8 @@ async def test_service_show_view(hass: HomeAssistant, mock_zeroconf: None) -> No assert url_path is None -async def test_service_show_view_dashboard( - hass: HomeAssistant, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_service_show_view_dashboard(hass: HomeAssistant) -> None: """Test casting a specific dashboard.""" await async_process_ha_core_config( hass, @@ -82,7 +82,8 @@ async def test_service_show_view_dashboard( assert url_path == "mock-dashboard" -async def test_use_cloud_url(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_use_cloud_url(hass: HomeAssistant) -> None: """Test that we fall back to cloud url.""" await async_process_ha_core_config( hass, @@ -111,7 +112,8 @@ async def test_use_cloud_url(hass: HomeAssistant, mock_zeroconf: None) -> None: assert controller_data["hass_url"] == "https://something.nabu.casa" -async def test_remove_entry(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_remove_entry(hass: HomeAssistant) -> None: """Test removing config entry removes user.""" entry = MockConfigEntry( data={}, diff --git a/tests/components/ccm15/conftest.py b/tests/components/ccm15/conftest.py index 6098a95b3ce..d6cc66d77dc 100644 --- a/tests/components/ccm15/conftest.py +++ b/tests/components/ccm15/conftest.py @@ -1,14 +1,14 @@ """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 +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.ccm15.async_setup_entry", return_value=True @@ -17,7 +17,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def ccm15_device() -> Generator[AsyncMock, None, None]: +def ccm15_device() -> Generator[AsyncMock]: """Mock ccm15 device.""" ccm15_devices = { 0: CCM15SlaveDevice(bytes.fromhex("000000b0b8001b")), @@ -32,7 +32,7 @@ def ccm15_device() -> Generator[AsyncMock, None, None]: @pytest.fixture -def network_failure_ccm15_device() -> Generator[AsyncMock, None, None]: +def network_failure_ccm15_device() -> Generator[AsyncMock]: """Mock empty set of ccm15 device.""" device_state = CCM15DeviceState(devices={}) with patch( diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr index 10423919187..27dcbcb3405 100644 --- a/tests/components/ccm15/snapshots/test_climate.ambr +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -16,6 +16,7 @@ , , , + , , ]), 'max_temp': 35, @@ -70,6 +71,7 @@ , , , + , , ]), 'max_temp': 35, @@ -125,6 +127,7 @@ , , , + , , ]), 'max_temp': 35, @@ -164,6 +167,7 @@ , , , + , , ]), 'max_temp': 35, @@ -202,6 +206,7 @@ , , , + , , ]), 'max_temp': 35, @@ -256,6 +261,7 @@ , , , + , , ]), 'max_temp': 35, @@ -308,6 +314,7 @@ , , , + , , ]), 'max_temp': 35, @@ -342,6 +349,7 @@ , , , + , , ]), 'max_temp': 35, diff --git a/tests/components/cert_expiry/conftest.py b/tests/components/cert_expiry/conftest.py index 41c2d90b1a0..2a86c669970 100644 --- a/tests/components/cert_expiry/conftest.py +++ b/tests/components/cert_expiry/conftest.py @@ -1,13 +1,13 @@ """Configuration for cert_expiry tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.cert_expiry.async_setup_entry", return_value=True diff --git a/tests/components/climate/common.py b/tests/components/climate/common.py index 20f6bfd880d..c890d3a7bb5 100644 --- a/tests/components/climate/common.py +++ b/tests/components/climate/common.py @@ -86,13 +86,13 @@ async def async_set_temperature( """Set new target temperature.""" kwargs = { key: value - for key, value in [ + for key, value in ( (ATTR_TEMPERATURE, temperature), (ATTR_TARGET_TEMP_HIGH, target_temp_high), (ATTR_TARGET_TEMP_LOW, target_temp_low), (ATTR_ENTITY_ID, entity_id), (ATTR_HVAC_MODE, hvac_mode), - ] + ) if value is not None } _LOGGER.debug("set_temperature start data=%s", kwargs) @@ -113,13 +113,13 @@ def set_temperature( """Set new target temperature.""" kwargs = { key: value - for key, value in [ + for key, value in ( (ATTR_TEMPERATURE, temperature), (ATTR_TARGET_TEMP_HIGH, target_temp_high), (ATTR_TARGET_TEMP_LOW, target_temp_low), (ATTR_ENTITY_ID, entity_id), (ATTR_HVAC_MODE, hvac_mode), - ] + ) if value is not None } _LOGGER.debug("set_temperature start data=%s", kwargs) diff --git a/tests/components/climate/conftest.py b/tests/components/climate/conftest.py index c65414ea68d..a3a6af6e8a3 100644 --- a/tests/components/climate/conftest.py +++ b/tests/components/climate/conftest.py @@ -1,8 +1,7 @@ """Fixtures for Climate platform tests.""" -from collections.abc import Generator - import pytest +from typing_extensions import Generator from homeassistant.config_entries import ConfigFlow from homeassistant.core import HomeAssistant @@ -15,7 +14,7 @@ class MockFlow(ConfigFlow): @pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, "test.config_flow") diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index 850f8b6c843..361aeaec867 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -136,7 +136,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["set_hvac_mode"] + for action in ("set_hvac_mode",) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index e44802f7d4d..0961bd3dc73 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -8,7 +8,7 @@ from homeassistant.components import automation from homeassistant.components.climate import DOMAIN, HVACMode, const, device_condition from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -139,7 +139,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_hvac_mode"] + for condition in ("is_hvac_mode",) ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -151,7 +151,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -272,7 +272,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index af14c42c086..e8e5b577bf4 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.components.climate import ( ) from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import EntityCategory, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -36,7 +36,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -74,11 +74,11 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in [ + for trigger in ( "hvac_mode_changed", "current_temperature_changed", "current_humidity_changed", - ] + ) ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -135,11 +135,11 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in [ + for trigger in ( "hvac_mode_changed", "current_temperature_changed", "current_humidity_changed", - ] + ) ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -151,7 +151,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -272,7 +272,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 0d6927ae0f9..a459b991203 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -823,6 +823,7 @@ async def test_issue_aux_property_deprecated( translation_placeholders_extra: dict[str, str], report: str, module: str, + issue_registry: ir.IssueRegistry, ) -> None: """Test the issue is raised on deprecated auxiliary heater attributes.""" @@ -894,8 +895,7 @@ async def test_issue_aux_property_deprecated( assert climate_entity.state == HVACMode.HEAT - issues = ir.async_get(hass) - issue = issues.async_get_issue("climate", "deprecated_climate_aux_test") + issue = issue_registry.async_get_issue("climate", "deprecated_climate_aux_test") assert issue assert issue.issue_domain == "test" assert issue.issue_id == "deprecated_climate_aux_test" @@ -954,6 +954,7 @@ async def test_no_issue_aux_property_deprecated_for_core( translation_placeholders_extra: dict[str, str], report: str, module: str, + issue_registry: ir.IssueRegistry, ) -> None: """Test the no issue on deprecated auxiliary heater attributes for core integrations.""" @@ -1023,8 +1024,7 @@ async def test_no_issue_aux_property_deprecated_for_core( assert climate_entity.state == HVACMode.HEAT - issues = ir.async_get(hass) - issue = issues.async_get_issue("climate", "deprecated_climate_aux_test") + issue = issue_registry.async_get_issue("climate", "deprecated_climate_aux_test") assert not issue assert ( @@ -1038,6 +1038,7 @@ async def test_no_issue_no_aux_property( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_flow_fixture: None, + issue_registry: ir.IssueRegistry, ) -> None: """Test the issue is raised on deprecated auxiliary heater attributes.""" @@ -1082,8 +1083,7 @@ async def test_no_issue_no_aux_property( assert climate_entity.state == HVACMode.HEAT - issues = ir.async_get(hass) - assert len(issues.issues) == 0 + assert len(issue_registry.issues) == 0 assert ( "test::MockClimateEntityWithAux implements the `is_aux_heat` property or uses " diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index e4f92759793..ab1e3629ef8 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,21 +1,22 @@ """Test climate intents.""" -from collections.abc import Generator -from unittest.mock import patch - import pytest +from typing_extensions import Generator +from homeassistant.components import conversation from homeassistant.components.climate import ( DOMAIN, ClimateEntity, HVACMode, intent as climate_intent, ) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, entity_registry as er, intent from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -34,7 +35,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -50,7 +51,7 @@ def mock_setup_integration(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True async def async_unload_entry_init( @@ -113,6 +114,7 @@ async def test_get_temperature( entity_registry: er.EntityRegistry, ) -> None: """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) climate_1 = MockClimateEntity() @@ -148,10 +150,14 @@ async def test_get_temperature( # First climate entity will be selected (no area) response = await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 + assert response.matched_states assert response.matched_states[0].entity_id == climate_1.entity_id state = response.matched_states[0] assert state.attributes["current_temperature"] == 10.0 @@ -162,6 +168,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 @@ -175,6 +182,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 @@ -183,23 +191,26 @@ async def test_get_temperature( assert state.attributes["current_temperature"] == 22.0 # Check area with no climate entities - with pytest.raises(intent.NoStatesMatchedError) as error: + with pytest.raises(intent.MatchFailedError) as error: response = await intent.async_handle( hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, ) # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.NoStatesMatchedError) - assert error.value.name is None - assert error.value.area == office_area.name - assert error.value.domains == {DOMAIN} - assert error.value.device_classes is None + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name is None + assert constraints.area_name == office_area.name + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) + assert constraints.device_classes is None # Check wrong name - with pytest.raises(intent.NoStatesMatchedError) as error: + with pytest.raises(intent.MatchFailedError) as error: response = await intent.async_handle( hass, "test", @@ -207,14 +218,16 @@ async def test_get_temperature( {"name": {"value": "Does not exist"}}, ) - assert isinstance(error.value, intent.NoStatesMatchedError) - assert error.value.name == "Does not exist" - assert error.value.area is None - assert error.value.domains == {DOMAIN} - assert error.value.device_classes is None + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME + constraints = error.value.constraints + assert constraints.name == "Does not exist" + assert constraints.area_name is None + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) + assert constraints.device_classes is None # Check wrong name with area - with pytest.raises(intent.NoStatesMatchedError) as error: + with pytest.raises(intent.MatchFailedError) as error: response = await intent.async_handle( hass, "test", @@ -222,71 +235,203 @@ async def test_get_temperature( {"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}}, ) - assert isinstance(error.value, intent.NoStatesMatchedError) - assert error.value.name == "Climate 1" - assert error.value.area == bedroom_area.name - assert error.value.domains == {DOMAIN} - assert error.value.device_classes is None + assert isinstance(error.value, intent.MatchFailedError) + assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA + constraints = error.value.constraints + assert constraints.name == "Climate 1" + assert constraints.area_name == bedroom_area.name + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) + assert constraints.device_classes is None async def test_get_temperature_no_entities( hass: HomeAssistant, ) -> None: """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) await create_mock_platform(hass, []) - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError) as err: await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN -async def test_get_temperature_no_state( +async def test_not_exposed( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test HassClimateGetTemperature intent when states are missing.""" + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) climate_1 = MockClimateEntity() climate_1._attr_name = "Climate 1" climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 entity_registry.async_get_or_create( DOMAIN, "test", "1234", suggested_object_id="climate_1" ) - await create_mock_platform(hass, [climate_1]) + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") entity_registry.async_update_entity( climate_1.entity_id, area_id=living_room_area.id ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) - with ( - patch("homeassistant.core.StateMachine.get", return_value=None), - pytest.raises(intent.IntentHandleError), - ): - await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} - ) - - with ( - patch("homeassistant.core.StateMachine.async_all", return_value=[]), - pytest.raises(intent.NoStatesMatchedError) as error, - ): + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): await intent.async_handle( hass, "test", climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": "Living Room"}}, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, ) - # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.NoStatesMatchedError) - assert error.value.name is None - assert error.value.area == "Living Room" - assert error.value.domains == {DOMAIN} - assert error.value.device_classes is None + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 2b4a95a61d9..d527cbbeec2 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -69,7 +69,7 @@ async def mock_cloud(hass, config=None): await cloud_inst.initialize() -def mock_cloud_prefs(hass, prefs={}): +def mock_cloud_prefs(hass, prefs): """Fixture for cloud component.""" prefs_to_set = { const.PREF_ALEXA_SETTINGS_VERSION: cloud_prefs.ALEXA_SETTINGS_VERSION, diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 0147556a888..ebd9ea6663e 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -1,6 +1,7 @@ """Fixtures for cloud tests.""" -from collections.abc import AsyncGenerator, Callable, Coroutine +from collections.abc import Callable, Coroutine +from pathlib import Path from typing import Any from unittest.mock import DEFAULT, MagicMock, PropertyMock, patch @@ -14,6 +15,7 @@ from hass_nabucasa.remote import RemoteUI from hass_nabucasa.voice import Voice import jwt import pytest +from typing_extensions import AsyncGenerator from homeassistant.components.cloud import CloudClient, const, prefs from homeassistant.core import HomeAssistant @@ -33,7 +35,7 @@ async def load_homeassistant(hass: HomeAssistant) -> None: @pytest.fixture(name="cloud") -async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: +async def cloud_fixture() -> AsyncGenerator[MagicMock]: """Mock the cloud object. See the real hass_nabucasa.Cloud class for how to configure the mock. @@ -180,13 +182,13 @@ def set_cloud_prefs_fixture( @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """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): +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @@ -201,7 +203,7 @@ def mock_user_data(): def mock_cloud_fixture(hass): """Fixture for cloud component.""" hass.loop.run_until_complete(mock_cloud(hass)) - return mock_cloud_prefs(hass) + return mock_cloud_prefs(hass, {}) @pytest.fixture diff --git a/tests/components/cloud/test_account_link.py b/tests/components/cloud/test_account_link.py index 024118eaabf..3f108961bc5 100644 --- a/tests/components/cloud/test_account_link.py +++ b/tests/components/cloud/test_account_link.py @@ -178,9 +178,8 @@ async def test_get_services_error(hass: HomeAssistant) -> None: assert account_link.DATA_SERVICES not in hass.data -async def test_implementation( - hass: HomeAssistant, flow_handler, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_implementation(hass: HomeAssistant, flow_handler) -> None: """Test Cloud OAuth2 implementation.""" hass.data["cloud"] = None diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py index 5e83fa34c3c..789947f3c7d 100644 --- a/tests/components/cloud/test_binary_sensor.py +++ b/tests/components/cloud/test_binary_sensor.py @@ -1,10 +1,10 @@ """Tests for the cloud binary sensor.""" -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 typing_extensions import Generator from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -12,7 +12,7 @@ from homeassistant.setup import async_setup_component @pytest.fixture(autouse=True) -def mock_wait_until() -> Generator[None, None, None]: +def mock_wait_until() -> Generator[None]: """Mock WAIT_UNTIL_CHANGE to execute callback immediately.""" with patch("homeassistant.components.cloud.binary_sensor.WAIT_UNTIL_CHANGE", 0): yield diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index bcddc32f107..7c04373c261 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -24,11 +24,9 @@ from homeassistant.components.homeassistant.exposed_entities import ( ExposedEntities, async_expose_entity, ) -from homeassistant.components.http.const import StrictConnectionMode 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.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -119,7 +117,7 @@ async def test_handler_google_actions(hass: HomeAssistant) -> None: }, ) - mock_cloud_prefs(hass) + mock_cloud_prefs(hass, {}) cloud = hass.data["cloud"] reqid = "5711642932632160983" @@ -388,7 +386,6 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: "connected": False, "enabled": False, "instance_domain": None, - "strict_connection": StrictConnectionMode.DISABLED, }, "version": HA_VERSION, } @@ -401,7 +398,7 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: async def test_async_create_repair_issue_known( cloud: MagicMock, mock_cloud_setup: None, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, translation_key: str, ) -> None: """Test create repair issue for known repairs.""" @@ -419,7 +416,7 @@ async def test_async_create_repair_issue_known( async def test_async_create_repair_issue_unknown( cloud: MagicMock, mock_cloud_setup: None, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test not creating repair issue for unknown repairs.""" identifier = "abc123" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d9d2b5c6742..5ee9af88681 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -19,7 +19,6 @@ 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.components.http.const import StrictConnectionMode from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er @@ -783,7 +782,6 @@ async def test_websocket_status( "google_report_state": True, "remote_allow_remote_enable": True, "remote_enabled": False, - "strict_connection": "disabled", "tts_default_voice": ["en-US", "JennyNeural"], }, "alexa_entities": { @@ -903,7 +901,6 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.alexa_enabled assert cloud.client.prefs.google_secure_devices_pin is None assert cloud.client.prefs.remote_allow_remote_enable is True - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DISABLED client = await hass_ws_client(hass) @@ -915,7 +912,6 @@ async def test_websocket_update_preferences( "google_secure_devices_pin": "1234", "tts_default_voice": ["en-GB", "RyanNeural"], "remote_allow_remote_enable": False, - "strict_connection": StrictConnectionMode.DROP_CONNECTION, } ) response = await client.receive_json() @@ -926,7 +922,6 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.remote_allow_remote_enable is False assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural") - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DROP_CONNECTION @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index bc4526975da..9cc1324ebc1 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Coroutine from typing import Any from unittest.mock import MagicMock, patch -from urllib.parse import quote_plus from hass_nabucasa import Cloud import pytest @@ -14,16 +13,11 @@ from homeassistant.components.cloud import ( CloudNotConnected, async_get_or_create_cloudhook, ) -from homeassistant.components.cloud.const import ( - DOMAIN, - PREF_CLOUDHOOKS, - PREF_STRICT_CONNECTION, -) +from homeassistant.components.cloud.const import DOMAIN, PREF_CLOUDHOOKS from homeassistant.components.cloud.prefs import STORAGE_KEY -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import ServiceValidationError, Unauthorized +from homeassistant.exceptions import Unauthorized from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockUser @@ -301,77 +295,3 @@ async def test_cloud_logout( await hass.async_block_till_done() assert cloud.is_logged_in is False - - -async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( - hass: HomeAssistant, -) -> None: - """Test service create_temporary_strict_connection_url with strict_connection not enabled.""" - mock_config_entry = MockConfigEntry(domain=DOMAIN) - mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) - await hass.async_block_till_done() - with pytest.raises( - ServiceValidationError, - match="Strict connection is not enabled for cloud requests", - ): - await hass.services.async_call( - cloud.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - -@pytest.mark.parametrize( - ("mode"), - [ - StrictConnectionMode.DROP_CONNECTION, - StrictConnectionMode.GUARD_PAGE, - ], -) -async def test_service_create_temporary_strict_connection( - hass: HomeAssistant, - set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], - mode: StrictConnectionMode, -) -> None: - """Test service create_temporary_strict_connection_url.""" - mock_config_entry = MockConfigEntry(domain=DOMAIN) - mock_config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) - await hass.async_block_till_done() - - await set_cloud_prefs( - { - PREF_STRICT_CONNECTION: mode, - } - ) - - # No cloud url set - with pytest.raises(ServiceValidationError, match="No cloud URL available"): - await hass.services.async_call( - cloud.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - # Patch cloud url - url = "https://example.com" - with patch( - "homeassistant.helpers.network._get_cloud_url", - return_value=url, - ): - response = await hass.services.async_call( - cloud.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - assert isinstance(response, dict) - direct_url_prefix = f"{url}/auth/strict_connection/temp_token?authSig=" - assert response.pop("direct_url").startswith(direct_url_prefix) - assert response.pop("url").startswith( - f"https://login.home-assistant.io?u={quote_plus(direct_url_prefix)}" - ) - assert response == {} # No more keys in response diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 57715fe2bdf..9b0fa4c01d7 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -6,13 +6,8 @@ from unittest.mock import ANY, MagicMock, patch import pytest from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components.cloud.const import ( - DOMAIN, - PREF_STRICT_CONNECTION, - PREF_TTS_DEFAULT_VOICE, -) +from homeassistant.components.cloud.const import DOMAIN, PREF_TTS_DEFAULT_VOICE from homeassistant.components.cloud.prefs import STORAGE_KEY, CloudPreferences -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -179,39 +174,3 @@ async def test_tts_default_voice_legacy_gender( await hass.async_block_till_done() assert cloud.client.prefs.tts_default_voice == (expected_language, voice) - - -@pytest.mark.parametrize("mode", list(StrictConnectionMode)) -async def test_strict_connection_convertion( - hass: HomeAssistant, - cloud: MagicMock, - hass_storage: dict[str, Any], - mode: StrictConnectionMode, -) -> None: - """Test strict connection string value will be converted to the enum.""" - hass_storage[STORAGE_KEY] = { - "version": 1, - "data": {PREF_STRICT_CONNECTION: mode.value}, - } - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - - assert cloud.client.prefs.strict_connection is mode - - -@pytest.mark.parametrize("storage_data", [{}, {PREF_STRICT_CONNECTION: None}]) -async def test_strict_connection_default( - hass: HomeAssistant, - cloud: MagicMock, - hass_storage: dict[str, Any], - storage_data: dict[str, Any], -) -> None: - """Test strict connection default values.""" - hass_storage[STORAGE_KEY] = { - "version": 1, - "data": storage_data, - } - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() - - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DISABLED diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index abfc917016d..7ca20d84bce 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -1,9 +1,10 @@ """Test cloud repairs.""" -from collections.abc import Generator from datetime import timedelta from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import patch + +import pytest from homeassistant.components.cloud import DOMAIN import homeassistant.components.cloud.repairs as cloud_repairs @@ -36,12 +37,12 @@ async def test_do_not_create_repair_issues_at_startup_if_not_logged_in( ) +@pytest.mark.usefixtures("mock_auth") 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, -): +) -> None: """Test that we create repair issue at startup if we are logged in.""" aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", @@ -75,13 +76,13 @@ async def test_legacy_subscription_delete_issue_if_no_longer_legacy( ) +@pytest.mark.usefixtures("mock_auth") async def test_legacy_subscription_repair_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_auth: Generator[None, AsyncMock, None], hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, -): +) -> None: """Test desired flow of the fix flow for legacy subscription.""" aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", @@ -160,13 +161,13 @@ async def test_legacy_subscription_repair_flow( ) +@pytest.mark.usefixtures("mock_auth") async def test_legacy_subscription_repair_flow_timeout( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_auth: Generator[None, AsyncMock, None], aioclient_mock: AiohttpClientMocker, issue_registry: ir.IssueRegistry, -): +) -> None: """Test timeout flow of the fix flow for legacy subscription.""" aioclient_mock.post( "https://accounts.nabucasa.com/payments/migrate_paypal_agreement", diff --git a/tests/components/cloud/test_strict_connection.py b/tests/components/cloud/test_strict_connection.py deleted file mode 100644 index f275bc4d2dd..00000000000 --- a/tests/components/cloud/test_strict_connection.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Test strict connection mode for cloud.""" - -from collections.abc import Awaitable, Callable, Coroutine, Generator -from contextlib import contextmanager -from datetime import timedelta -from http import HTTPStatus -from typing import Any -from unittest.mock import MagicMock, Mock, patch - -from aiohttp import ServerDisconnectedError, web -from aiohttp.test_utils import TestClient -from aiohttp_session import get_session -import pytest -from yarl import URL - -from homeassistant.auth.models import RefreshToken -from homeassistant.auth.session import SESSION_ID, TEMP_TIMEOUT -from homeassistant.components.cloud.const import PREF_STRICT_CONNECTION -from homeassistant.components.http import KEY_HASS -from homeassistant.components.http.auth import ( - STRICT_CONNECTION_GUARD_PAGE, - async_setup_auth, - async_sign_path, -) -from homeassistant.components.http.const import KEY_AUTHENTICATED, StrictConnectionMode -from homeassistant.components.http.session import COOKIE_NAME, PREFIXED_COOKIE_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.network import is_cloud_connection -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow - -from tests.common import async_fire_time_changed -from tests.typing import ClientSessionGenerator - - -@pytest.fixture -async def refresh_token(hass: HomeAssistant, hass_access_token: str) -> RefreshToken: - """Return a refresh token.""" - refresh_token = hass.auth.async_validate_access_token(hass_access_token) - assert refresh_token - session = hass.auth.session - assert session._strict_connection_sessions == {} - assert session._temp_sessions == {} - return refresh_token - - -@contextmanager -def simulate_cloud_request() -> Generator[None, None, None]: - """Simulate a cloud request.""" - with patch( - "hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True)) - ): - yield - - -@pytest.fixture -def app_strict_connection( - hass: HomeAssistant, refresh_token: RefreshToken -) -> web.Application: - """Fixture to set up a web.Application.""" - - async def handler(request): - """Return if request was authenticated.""" - return web.json_response(data={"authenticated": request[KEY_AUTHENTICATED]}) - - app = web.Application() - app[KEY_HASS] = hass - app.router.add_get("/", handler) - - async def set_cookie(request: web.Request) -> web.Response: - hass = request.app[KEY_HASS] - # Clear all sessions - hass.auth.session._temp_sessions.clear() - hass.auth.session._strict_connection_sessions.clear() - - if request.query["token"] == "refresh": - await hass.auth.session.async_create_session(request, refresh_token) - else: - await hass.auth.session.async_create_temp_unauthorized_session(request) - session = await get_session(request) - return web.Response(text=session[SESSION_ID]) - - app.router.add_get("/test/cookie", set_cookie) - return app - - -@pytest.fixture(name="client") -async def set_up_fixture( - hass: HomeAssistant, - aiohttp_client: ClientSessionGenerator, - app_strict_connection: web.Application, - cloud: MagicMock, - socket_enabled: None, -) -> TestClient: - """Set up the fixture.""" - - await async_setup_auth(hass, app_strict_connection, StrictConnectionMode.DISABLED) - assert await async_setup_component(hass, "cloud", {"cloud": {}}) - await hass.async_block_till_done() - return await aiohttp_client(app_strict_connection) - - -@pytest.mark.parametrize( - "strict_connection_mode", [e.value for e in StrictConnectionMode] -) -async def test_strict_connection_cloud_authenticated_requests( - hass: HomeAssistant, - client: TestClient, - hass_access_token: str, - set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], - refresh_token: RefreshToken, - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test authenticated requests with strict connection.""" - assert hass.auth.session._strict_connection_sessions == {} - - signed_path = async_sign_path( - hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id - ) - - await set_cloud_prefs( - { - PREF_STRICT_CONNECTION: strict_connection_mode, - } - ) - - with simulate_cloud_request(): - assert is_cloud_connection(hass) - req = await client.get( - "/", headers={"Authorization": f"Bearer {hass_access_token}"} - ) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - - -async def _test_strict_connection_cloud_enabled_external_unauthenticated_requests( - hass: HomeAssistant, - client: TestClient, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - _: RefreshToken, -) -> None: - """Test external unauthenticated requests with strict connection cloud enabled.""" - with simulate_cloud_request(): - assert is_cloud_connection(hass) - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_cloud_enabled_external_unauthenticated_requests_refresh_token( - hass: HomeAssistant, - client: TestClient, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - refresh_token: RefreshToken, -) -> None: - """Test external unauthenticated requests with strict connection cloud enabled and refresh token cookie.""" - session = hass.auth.session - - # set strict connection cookie with refresh token - session_id = await _modify_cookie_for_cloud(client, "refresh") - assert session._strict_connection_sessions == {session_id: refresh_token.id} - with simulate_cloud_request(): - assert is_cloud_connection(hass) - req = await client.get("/") - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": False} - - # Invalidate refresh token, which should also invalidate session - hass.auth.async_remove_refresh_token(refresh_token) - assert session._strict_connection_sessions == {} - - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_cloud_enabled_external_unauthenticated_requests_temp_session( - hass: HomeAssistant, - client: TestClient, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - _: RefreshToken, -) -> None: - """Test external unauthenticated requests with strict connection cloud enabled and temp cookie.""" - session = hass.auth.session - - # set strict connection cookie with temp session - assert session._temp_sessions == {} - session_id = await _modify_cookie_for_cloud(client, "temp") - assert session_id in session._temp_sessions - with simulate_cloud_request(): - assert is_cloud_connection(hass) - resp = await client.get("/") - assert resp.status == HTTPStatus.OK - assert await resp.json() == {"authenticated": False} - - async_fire_time_changed(hass, utcnow() + TEMP_TIMEOUT + timedelta(minutes=1)) - await hass.async_block_till_done(wait_background_tasks=True) - assert session._temp_sessions == {} - - await perform_unauthenticated_request(hass, client) - - -async def _drop_connection_unauthorized_request( - _: HomeAssistant, client: TestClient -) -> None: - with pytest.raises(ServerDisconnectedError): - # unauthorized requests should raise ServerDisconnectedError - await client.get("/") - - -async def _guard_page_unauthorized_request( - hass: HomeAssistant, client: TestClient -) -> None: - req = await client.get("/") - assert req.status == HTTPStatus.IM_A_TEAPOT - - def read_guard_page() -> str: - with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file: - return file.read() - - assert await req.text() == await hass.async_add_executor_job(read_guard_page) - - -@pytest.mark.parametrize( - "test_func", - [ - _test_strict_connection_cloud_enabled_external_unauthenticated_requests, - _test_strict_connection_cloud_enabled_external_unauthenticated_requests_refresh_token, - _test_strict_connection_cloud_enabled_external_unauthenticated_requests_temp_session, - ], - ids=[ - "no cookie", - "refresh token cookie", - "temp session cookie", - ], -) -@pytest.mark.parametrize( - ("strict_connection_mode", "request_func"), - [ - (StrictConnectionMode.DROP_CONNECTION, _drop_connection_unauthorized_request), - (StrictConnectionMode.GUARD_PAGE, _guard_page_unauthorized_request), - ], - ids=["drop connection", "static page"], -) -async def test_strict_connection_cloud_external_unauthenticated_requests( - hass: HomeAssistant, - client: TestClient, - refresh_token: RefreshToken, - set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], - test_func: Callable[ - [ - HomeAssistant, - TestClient, - Callable[[HomeAssistant, TestClient], Awaitable[None]], - RefreshToken, - ], - Awaitable[None], - ], - strict_connection_mode: StrictConnectionMode, - request_func: Callable[[HomeAssistant, TestClient], Awaitable[None]], -) -> None: - """Test external unauthenticated requests with strict connection cloud.""" - await set_cloud_prefs( - { - PREF_STRICT_CONNECTION: strict_connection_mode, - } - ) - - await test_func( - hass, - client, - request_func, - refresh_token, - ) - - -async def _modify_cookie_for_cloud(client: TestClient, token_type: str) -> str: - """Modify cookie for cloud.""" - # Cloud cookie has set secure=true and will not set on unsecure connection - # As we test with unsecure connection, we need to set it manually - # We get the session via http and modify the cookie name to the secure one - session_id = await (await client.get(f"/test/cookie?token={token_type}")).text() - cookie_jar = client.session.cookie_jar - localhost = URL("http://127.0.0.1") - cookie = cookie_jar.filter_cookies(localhost)[COOKIE_NAME].value - assert cookie - cookie_jar.clear() - cookie_jar.update_cookies({PREFIXED_COOKIE_NAME: cookie}, localhost) - return session_id diff --git a/tests/components/cloud/test_stt.py b/tests/components/cloud/test_stt.py index 540aa173beb..a20325d6dc3 100644 --- a/tests/components/cloud/test_stt.py +++ b/tests/components/cloud/test_stt.py @@ -1,6 +1,5 @@ """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 @@ -8,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from hass_nabucasa.voice import STTResponse, VoiceError import pytest +from typing_extensions import AsyncGenerator from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud import DOMAIN @@ -21,7 +21,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -async def delay_save_fixture() -> AsyncGenerator[None, None]: +async def delay_save_fixture() -> AsyncGenerator[None]: """Load the homeassistant integration.""" with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): yield diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 06dbcf174a7..00466d0d177 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,6 +1,6 @@ """Tests for cloud tts.""" -from collections.abc import AsyncGenerator, Callable, Coroutine +from collections.abc import Callable, Coroutine from copy import deepcopy from http import HTTPStatus from typing import Any @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from hass_nabucasa.voice import TTS_VOICES, VoiceError, VoiceTokenError import pytest +from typing_extensions import AsyncGenerator import voluptuous as vol from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY @@ -27,8 +28,8 @@ from homeassistant.components.tts.helper import get_engine_instance from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_registry import EntityRegistry -from homeassistant.helpers.issue_registry import IssueRegistry, IssueSeverity from homeassistant.setup import async_setup_component from . import PIPELINE_DATA @@ -39,7 +40,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -async def delay_save_fixture() -> AsyncGenerator[None, None]: +async def delay_save_fixture() -> AsyncGenerator[None]: """Load the homeassistant integration.""" with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): yield @@ -143,7 +144,7 @@ async def test_prefs_default_voice( async def test_deprecated_platform_config( hass: HomeAssistant, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, cloud: MagicMock, ) -> None: """Test cloud provider uses the preferences.""" @@ -157,7 +158,7 @@ async def test_deprecated_platform_config( assert issue.breaks_in_ha_version == "2024.9.0" assert issue.is_fixable is False assert issue.is_persistent is False - assert issue.severity == IssueSeverity.WARNING + assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_key == "deprecated_tts_platform_config" @@ -463,7 +464,7 @@ async def test_migrating_pipelines( ) async def test_deprecated_voice( hass: HomeAssistant, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, cloud: MagicMock, hass_client: ClientSessionGenerator, data: dict[str, Any], @@ -555,7 +556,7 @@ async def test_deprecated_voice( assert issue.breaks_in_ha_version == "2024.8.0" assert issue.is_fixable is True assert issue.is_persistent is True - assert issue.severity == IssueSeverity.WARNING + assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_key == "deprecated_voice" assert issue.translation_placeholders == { "deprecated_voice": deprecated_voice, @@ -613,7 +614,7 @@ async def test_deprecated_voice( ) async def test_deprecated_gender( hass: HomeAssistant, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, cloud: MagicMock, hass_client: ClientSessionGenerator, data: dict[str, Any], @@ -700,7 +701,7 @@ async def test_deprecated_gender( assert issue.breaks_in_ha_version == "2024.10.0" assert issue.is_fixable is True assert issue.is_persistent is True - assert issue.severity == IssueSeverity.WARNING + assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_key == "deprecated_gender" assert issue.translation_placeholders == { "integration_name": "Home Assistant Cloud", diff --git a/tests/components/cloudflare/__init__.py b/tests/components/cloudflare/__init__.py index ce9c6844f5a..5e1529a9da8 100644 --- a/tests/components/cloudflare/__init__.py +++ b/tests/components/cloudflare/__init__.py @@ -2,12 +2,15 @@ from __future__ import annotations +from typing import Any from unittest.mock import AsyncMock, patch import pycfdns from homeassistant.components.cloudflare.const import CONF_RECORDS, DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry @@ -54,18 +57,18 @@ MOCK_ZONE_RECORDS: list[pycfdns.RecordModel] = [ async def init_integration( - hass, + hass: HomeAssistant, *, - data: dict = ENTRY_CONFIG, - options: dict = ENTRY_OPTIONS, + data: dict[str, Any] | UndefinedType = UNDEFINED, + options: dict[str, Any] | UndefinedType = UNDEFINED, unique_id: str = MOCK_ZONE["name"], skip_setup: bool = False, ) -> MockConfigEntry: """Set up the Cloudflare integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, - data=data, - options=options, + data=ENTRY_CONFIG if data is UNDEFINED else data, + options=ENTRY_OPTIONS if options is UNDEFINED else options, unique_id=unique_id, ) entry.add_to_hass(hass) @@ -77,11 +80,18 @@ async def init_integration( return entry -def _get_mock_client(zone: str = MOCK_ZONE, records: list = MOCK_ZONE_RECORDS): +def _get_mock_client( + zone: pycfdns.ZoneModel | UndefinedType = UNDEFINED, + records: list[pycfdns.RecordModel] | UndefinedType = UNDEFINED, +): client: pycfdns.Client = AsyncMock() - client.list_zones = AsyncMock(return_value=[zone]) - client.list_dns_records = AsyncMock(return_value=records) + client.list_zones = AsyncMock( + return_value=[MOCK_ZONE if zone is UNDEFINED else zone] + ) + client.list_dns_records = AsyncMock( + return_value=MOCK_ZONE_RECORDS if records is UNDEFINED else records + ) client.update_dns_record = AsyncMock(return_value=None) return client diff --git a/tests/components/cloudflare/test_helpers.py b/tests/components/cloudflare/test_helpers.py index 2d0546882dd..0edb0bb58b8 100644 --- a/tests/components/cloudflare/test_helpers.py +++ b/tests/components/cloudflare/test_helpers.py @@ -3,7 +3,7 @@ from homeassistant.components.cloudflare.helpers import get_zone_id -def test_get_zone_id(): +def test_get_zone_id() -> None: """Test get_zone_id.""" zones = [ {"id": "1", "name": "example.com"}, diff --git a/tests/components/cloudflare/test_init.py b/tests/components/cloudflare/test_init.py index 2d66d3c8752..3b2a6803566 100644 --- a/tests/components/cloudflare/test_init.py +++ b/tests/components/cloudflare/test_init.py @@ -83,7 +83,9 @@ async def test_async_setup_raises_entry_auth_failed( assert flow["context"]["entry_id"] == entry.entry_id -async def test_integration_services(hass: HomeAssistant, cfupdate, caplog) -> None: +async def test_integration_services( + hass: HomeAssistant, cfupdate, caplog: pytest.LogCaptureFixture +) -> None: """Test integration services.""" instance = cfupdate.return_value @@ -138,13 +140,12 @@ async def test_integration_services_with_issue(hass: HomeAssistant, cfupdate) -> {}, blocking=True, ) - await hass.async_block_till_done() instance.update_dns_record.assert_not_called() async def test_integration_services_with_nonexisting_record( - hass: HomeAssistant, cfupdate, caplog + hass: HomeAssistant, cfupdate, caplog: pytest.LogCaptureFixture ) -> None: """Test integration services.""" instance = cfupdate.return_value @@ -185,7 +186,7 @@ async def test_integration_services_with_nonexisting_record( async def test_integration_update_interval( hass: HomeAssistant, cfupdate, - caplog, + caplog: pytest.LogCaptureFixture, ) -> None: """Test integration update interval.""" instance = cfupdate.return_value diff --git a/tests/components/co2signal/conftest.py b/tests/components/co2signal/conftest.py index 64972e6403f..04ab6db7464 100644 --- a/tests/components/co2signal/conftest.py +++ b/tests/components/co2signal/conftest.py @@ -1,21 +1,22 @@ """Fixtures for Electricity maps integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.co2signal import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import VALID_RESPONSE + from tests.common import MockConfigEntry -from tests.components.co2signal import VALID_RESPONSE @pytest.fixture(name="electricity_maps") -def mock_electricity_maps() -> Generator[None, MagicMock, None]: +def mock_electricity_maps() -> Generator[MagicMock]: """Mock the ElectricityMaps client.""" with ( diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index d3e02023142..e9f46e483d1 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -91,7 +91,7 @@ async def test_sensor_reauth_triggered( hass: HomeAssistant, freezer: FrozenDateTimeFactory, electricity_maps: AsyncMock, -): +) -> None: """Test if reauth flow is triggered.""" assert (state := hass.states.get("sensor.electricity_maps_co2_intensity")) assert state.state == "45.9862319009581" diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 6ad4830c2c4..7b603420bdf 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -111,7 +111,6 @@ async def test_missing_url_and_path(hass: HomeAssistant, setup_integration) -> N await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, service_data, blocking=True ) - await hass.async_block_till_done() # check light is still off, unchanged due to bad parameters on service call state = hass.states.get(LIGHT_ENTITY) @@ -244,7 +243,7 @@ def _get_file_mock(file_path): """Convert file to BytesIO for testing due to PIL UnidentifiedImageError.""" _file = None - with open(file_path) as file_handler: + with open(file_path, encoding="utf8") as file_handler: _file = io.BytesIO(file_handler.read()) _file.name = "color_extractor.jpg" diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index b839d2de7a0..c6a9547b451 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -7,11 +7,13 @@ from homeassistant.components.config import auth as auth_config from homeassistant.core import HomeAssistant from tests.common import CLIENT_ID, MockGroup, MockUser -from tests.typing import WebSocketGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) -async def setup_config(hass, aiohttp_client): +async def setup_config( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator +) -> None: """Fixture that sets up the auth provider homeassistant module.""" auth_config.async_setup(hass) diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py index d2631cd7a7c..5c5661376e2 100644 --- a/tests/components/config/test_auth_provider_homeassistant.py +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -13,19 +13,23 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -async def setup_config(hass, local_auth): +async def setup_config( + hass: HomeAssistant, local_auth: prov_ha.HassAuthProvider +) -> None: """Fixture that sets up the auth provider .""" auth_ha.async_setup(hass) @pytest.fixture -async def auth_provider(local_auth): +async def auth_provider( + local_auth: prov_ha.HassAuthProvider, +) -> prov_ha.HassAuthProvider: """Hass auth provider.""" return local_auth @pytest.fixture -async def owner_access_token(hass, hass_owner_user): +async def owner_access_token(hass: HomeAssistant, hass_owner_user: MockUser) -> str: """Access token for owner user.""" refresh_token = await hass.auth.async_create_refresh_token( hass_owner_user, CLIENT_ID diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index b17face10d9..9d9ee5d5649 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -25,10 +25,10 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture async def setup_automation( - hass, + hass: HomeAssistant, automation_config, - stub_blueprint_populate, -): + stub_blueprint_populate: None, +) -> None: """Set up automation integration.""" assert await async_setup_component( hass, "automation", {"automation": automation_config} diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 87c712b3716..95ff87c2beb 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -22,10 +22,11 @@ from tests.common import ( MockConfigEntry, MockModule, MockUser, + mock_config_flow, mock_integration, mock_platform, ) -from tests.typing import WebSocketGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture @@ -42,14 +43,34 @@ def mock_test_component(hass): @pytest.fixture -async def client(hass, hass_client) -> TestClient: +async def client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Fixture that can interact with the config manager API.""" await async_setup_component(hass, "http", {}) config_entries.async_setup(hass) return await hass_client() -async def test_get_entries(hass: HomeAssistant, client, clear_handlers) -> None: +@pytest.fixture +async def mock_flow(): + """Mock a config flow.""" + + class Comp1ConfigFlow(ConfigFlow): + """Config flow with options flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow.""" + + with mock_config_flow("comp1", Comp1ConfigFlow): + yield + + +async def test_get_entries( + hass: HomeAssistant, client, clear_handlers, mock_flow +) -> None: """Test get entries.""" mock_integration(hass, MockModule("comp1")) mock_integration( @@ -65,21 +86,6 @@ async def test_get_entries(hass: HomeAssistant, client, clear_handlers) -> None: hass, MockModule("comp5", partial_manifest={"integration_type": "service"}) ) - @HANDLERS.register("comp1") - class Comp1ConfigFlow: - """Config flow with options flow.""" - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get options flow.""" - - @classmethod - @callback - def async_supports_options_flow(cls, config_entry): - """Return options flow support for this handler.""" - return True - config_entry_flow.register_discovery_flow("comp2", "Comp 2", lambda: None) entry = MockConfigEntry( @@ -120,84 +126,84 @@ async def test_get_entries(hass: HomeAssistant, client, clear_handlers) -> None: entry.pop("entry_id") assert data == [ { + "disabled_by": None, "domain": "comp1", - "title": "Test 1", + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, "source": "bla", "state": core_ce.ConfigEntryState.NOT_LOADED.value, - "supports_reconfigure": False, "supports_options": True, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": True, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "disabled_by": None, - "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, + "title": "Test 1", }, { + "disabled_by": None, "domain": "comp2", - "title": "Test 2", + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", "source": "bla2", "state": core_ce.ConfigEntryState.SETUP_ERROR.value, - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "disabled_by": None, - "reason": "Unsupported API", - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, + "title": "Test 2", }, { + "disabled_by": core_ce.ConfigEntryDisabler.USER, "domain": "comp3", - "title": "Test 3", + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, "source": "bla3", "state": core_ce.ConfigEntryState.NOT_LOADED.value, - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "disabled_by": core_ce.ConfigEntryDisabler.USER, - "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, + "title": "Test 3", }, { + "disabled_by": None, "domain": "comp4", - "title": "Test 4", + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, "source": "bla4", "state": core_ce.ConfigEntryState.NOT_LOADED.value, - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "disabled_by": None, - "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, + "title": "Test 4", }, { - "domain": "comp5", - "title": "Test 5", - "source": "bla5", - "state": core_ce.ConfigEntryState.NOT_LOADED.value, - "supports_reconfigure": False, - "supports_options": False, - "supports_remove_device": False, - "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, "disabled_by": None, - "reason": None, + "domain": "comp5", "error_reason_translation_key": None, "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla5", + "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supports_options": False, + "supports_reconfigure": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 5", }, ] @@ -501,9 +507,8 @@ async def test_abort(hass: HomeAssistant, client) -> None: } -async def test_create_account( - hass: HomeAssistant, client, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_create_account(hass: HomeAssistant, client) -> None: """Test a flow that creates an account.""" mock_platform(hass, "test.config_flow", None) @@ -540,18 +545,18 @@ async def test_create_account( "disabled_by": None, "domain": "test", "entry_id": entries[0].entry_id, - "source": core_ce.SOURCE_USER, - "state": core_ce.ConfigEntryState.LOADED.value, - "supports_reconfigure": False, - "supports_options": False, - "supports_remove_device": False, - "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "title": "Test Entry", - "reason": None, "error_reason_translation_key": None, "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": core_ce.SOURCE_USER, + "state": core_ce.ConfigEntryState.LOADED.value, + "supports_options": False, + "supports_reconfigure": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test Entry", }, "description": None, "description_placeholders": None, @@ -560,9 +565,8 @@ async def test_create_account( } -async def test_two_step_flow( - hass: HomeAssistant, client, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_two_step_flow(hass: HomeAssistant, client) -> None: """Test we can finish a two step flow.""" mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) @@ -621,18 +625,18 @@ async def test_two_step_flow( "disabled_by": None, "domain": "test", "entry_id": entries[0].entry_id, - "source": core_ce.SOURCE_USER, - "state": core_ce.ConfigEntryState.LOADED.value, - "supports_reconfigure": False, - "supports_options": False, - "supports_remove_device": False, - "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "title": "user-title", - "reason": None, "error_reason_translation_key": None, "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": core_ce.SOURCE_USER, + "state": core_ce.ConfigEntryState.LOADED.value, + "supports_options": False, + "supports_reconfigure": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "user-title", }, "description": None, "description_placeholders": None, @@ -701,7 +705,7 @@ async def test_get_progress_index( class TestFlow(core_ce.ConfigFlow): VERSION = 5 - async def async_step_hassio(self, info): + async def async_step_hassio(self, discovery_info): return await self.async_step_account() async def async_step_account(self, user_input=None): @@ -1073,15 +1077,15 @@ async def test_get_single( "disabled_by": None, "domain": "test", "entry_id": entry.entry_id, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "user", "state": "loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Mock Title", @@ -1412,15 +1416,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 1", @@ -1429,15 +1433,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp2", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla2", "state": "setup_error", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 2", @@ -1446,15 +1450,15 @@ async def test_get_matching_entries_ws( "disabled_by": "user", "domain": "comp3", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla3", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 3", @@ -1463,15 +1467,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp4", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla4", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 4", @@ -1480,15 +1484,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp5", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla5", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 5", @@ -1508,15 +1512,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 1", @@ -1535,15 +1539,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp4", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla4", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 4", @@ -1552,15 +1556,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp5", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla5", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 5", @@ -1579,15 +1583,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 1", @@ -1596,15 +1600,15 @@ async def test_get_matching_entries_ws( "disabled_by": "user", "domain": "comp3", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla3", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 3", @@ -1629,15 +1633,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 1", @@ -1646,15 +1650,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp2", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla2", "state": "setup_error", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 2", @@ -1663,15 +1667,15 @@ async def test_get_matching_entries_ws( "disabled_by": "user", "domain": "comp3", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla3", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 3", @@ -1680,15 +1684,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp4", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla4", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 4", @@ -1697,15 +1701,15 @@ async def test_get_matching_entries_ws( "disabled_by": None, "domain": "comp5", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla5", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 5", @@ -1798,15 +1802,15 @@ async def test_subscribe_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 1", @@ -1818,15 +1822,15 @@ async def test_subscribe_entries_ws( "disabled_by": None, "domain": "comp2", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla2", "state": "setup_error", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 2", @@ -1838,15 +1842,15 @@ async def test_subscribe_entries_ws( "disabled_by": "user", "domain": "comp3", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla3", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 3", @@ -1862,15 +1866,15 @@ async def test_subscribe_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "changed", @@ -1887,15 +1891,15 @@ async def test_subscribe_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "changed", @@ -1912,15 +1916,15 @@ async def test_subscribe_entries_ws( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "changed", @@ -1996,15 +2000,15 @@ async def test_subscribe_entries_ws_filtered( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 1", @@ -2016,15 +2020,15 @@ async def test_subscribe_entries_ws_filtered( "disabled_by": "user", "domain": "comp3", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla3", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "Test 3", @@ -2042,15 +2046,15 @@ async def test_subscribe_entries_ws_filtered( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "changed", @@ -2066,15 +2070,15 @@ async def test_subscribe_entries_ws_filtered( "disabled_by": "user", "domain": "comp3", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla3", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "changed too", @@ -2092,15 +2096,15 @@ async def test_subscribe_entries_ws_filtered( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "changed", @@ -2117,15 +2121,15 @@ async def test_subscribe_entries_ws_filtered( "disabled_by": None, "domain": "comp1", "entry_id": ANY, + "error_reason_translation_key": None, + "error_reason_translation_placeholders": None, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, - "error_reason_translation_key": None, - "error_reason_translation_placeholders": None, "source": "bla", "state": "not_loaded", - "supports_reconfigure": False, "supports_options": False, + "supports_reconfigure": False, "supports_remove_device": False, "supports_unload": False, "title": "changed", @@ -2221,9 +2225,8 @@ async def test_flow_with_multiple_schema_errors_base( } -async def test_supports_reconfigure( - hass: HomeAssistant, client, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_supports_reconfigure(hass: HomeAssistant, client) -> None: """Test a flow that support reconfigure step.""" mock_platform(hass, "test.config_flow", None) @@ -2291,18 +2294,18 @@ async def test_supports_reconfigure( "disabled_by": None, "domain": "test", "entry_id": entries[0].entry_id, - "source": core_ce.SOURCE_RECONFIGURE, - "state": core_ce.ConfigEntryState.LOADED.value, - "supports_reconfigure": True, - "supports_options": False, - "supports_remove_device": False, - "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "title": "Test Entry", - "reason": None, "error_reason_translation_key": None, "error_reason_translation_placeholders": None, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": core_ce.SOURCE_RECONFIGURE, + "state": core_ce.ConfigEntryState.LOADED.value, + "supports_options": False, + "supports_reconfigure": True, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test Entry", }, "description": None, "description_placeholders": None, @@ -2311,8 +2314,9 @@ async def test_supports_reconfigure( } +@pytest.mark.usefixtures("enable_custom_integrations") async def test_does_not_support_reconfigure( - hass: HomeAssistant, client: TestClient, enable_custom_integrations: None + hass: HomeAssistant, client: TestClient ) -> None: """Test a flow that does not support reconfigure step.""" mock_platform(hass, "test.config_flow", None) diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index da8a60ca6fd..7d02063b2b9 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -8,22 +8,23 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.components.config import core -from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.const import ( - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, -) +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util, location from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import MockUser -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) @pytest.fixture -async def client(hass, hass_ws_client): +async def client( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> MockHAClientWebSocket: """Fixture that can interact with the config manager API.""" with patch.object(config, "SECTIONS", [core]): assert await async_setup_component(hass, "config", {}) @@ -119,6 +120,7 @@ async def test_websocket_core_update(hass: HomeAssistant, client) -> None: assert hass.config.currency == "EUR" assert hass.config.country != "SE" assert hass.config.language != "sv" + assert hass.config.radius != 150 with ( patch("homeassistant.util.dt.set_default_time_zone") as mock_set_tz, @@ -134,13 +136,14 @@ async def test_websocket_core_update(hass: HomeAssistant, client) -> None: "longitude": 50, "elevation": 25, "location_name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "America/New_York", "external_url": "https://www.example.com", "internal_url": "http://example.local", "currency": "USD", "country": "SE", "language": "sv", + "radius": 150, } ) @@ -159,6 +162,9 @@ async def test_websocket_core_update(hass: HomeAssistant, client) -> None: assert hass.config.external_url == "https://www.example.com" assert hass.config.internal_url == "http://example.local" assert hass.config.currency == "USD" + assert hass.config.country == "SE" + assert hass.config.language == "sv" + assert hass.config.radius == 150 assert len(mock_set_tz.mock_calls) == 1 assert mock_set_tz.mock_calls[0][1][0] == dt_util.get_time_zone("America/New_York") @@ -173,7 +179,7 @@ async def test_websocket_core_update(hass: HomeAssistant, client) -> None: { "id": 6, "type": "config/core/update", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, + "unit_system": "metric", "update_units": True, } ) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 1b7eff84472..804cf29979e 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -146,7 +146,7 @@ async def test_update_device( client: MockHAClientWebSocket, device_registry: dr.DeviceRegistry, payload_key: str, - payload_value: str | None | dr.DeviceEntryDisabler, + payload_value: str | dr.DeviceEntryDisabler | None, ) -> None: """Test update entry.""" entry = MockConfigEntry(title=None) @@ -274,7 +274,7 @@ async def test_remove_config_entry_from_device( config_entry_id=entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == {entry_1.entry_id, entry_2.entry_id} + assert device_entry.config_entries == [entry_1.entry_id, entry_2.entry_id] # Try removing a config entry from the device, it should fail because # async_remove_config_entry_device returns False @@ -293,9 +293,9 @@ async def test_remove_config_entry_from_device( assert response["result"]["config_entries"] == [entry_2.entry_id] # Check that the config entry was removed from the device - assert device_registry.async_get(device_entry.id).config_entries == { + assert device_registry.async_get(device_entry.id).config_entries == [ entry_2.entry_id - } + ] # Remove the 2nd config entry response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) @@ -365,11 +365,11 @@ async def test_remove_config_entry_from_device_fails( config_entry_id=entry_3.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { + assert device_entry.config_entries == [ entry_1.entry_id, entry_2.entry_id, entry_3.entry_id, - } + ] fake_entry_id = "abc123" assert entry_1.entry_id != fake_entry_id diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index d61d9d7f892..813ec654abb 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -6,13 +6,12 @@ from pytest_unordered import unordered from homeassistant.components.config import entity_registry from homeassistant.const import ATTR_ICON, EntityCategory 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.helpers.device_registry import DeviceEntryDisabler from homeassistant.helpers.entity_registry import ( RegistryEntry, RegistryEntryDisabler, RegistryEntryHider, - async_get as async_get_entity_registry, ) from tests.common import ( @@ -863,6 +862,7 @@ async def test_enable_entity_disabled_device( hass: HomeAssistant, client: MockHAClientWebSocket, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test enabling entity of disabled device.""" entity_id = "test_domain.test_platform_1234" @@ -889,8 +889,7 @@ async def test_enable_entity_disabled_device( state = hass.states.get(entity_id) assert state is None - entity_reg = async_get_entity_registry(hass) - entity_entry = entity_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry.config_entry_id == config_entry.entry_id assert entity_entry.device_id == device.id assert entity_entry.disabled_by == RegistryEntryDisabler.DEVICE diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 3c1970a9bca..3ee45aec26a 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -24,7 +24,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture(autouse=True) -async def setup_script(hass, script_config, stub_blueprint_populate): +async def setup_script(hass: HomeAssistant, script_config: dict[str, Any]) -> None: """Set up script integration.""" assert await async_setup_component(hass, "script", {"script": script_config}) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index bde8cad5ea4..42746525a0d 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,25 +1,28 @@ """Fixtures for component testing.""" -from collections.abc import Callable, Generator +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from tests.components.conversation import MockAgent - if TYPE_CHECKING: - from tests.components.device_tracker.common import MockScanner - from tests.components.light.common import MockLight - from tests.components.sensor.common import MockSensor - from tests.components.switch.common import MockSwitch + from .conversation import MockAgent + from .device_tracker.common import MockScanner + from .light.common import MockLight + from .sensor.common import MockSensor + from .switch.common import MockSwitch @pytest.fixture(scope="session", autouse=True) -def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: +def patch_zeroconf_multiple_catcher() -> Generator[None]: """Patch zeroconf wrapper that detects if multiple instances are used.""" with patch( "homeassistant.components.zeroconf.install_multiple_zeroconf_catcher", @@ -29,7 +32,7 @@ def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: @pytest.fixture(scope="session", autouse=True) -def prevent_io() -> Generator[None, None, None]: +def prevent_io() -> Generator[None]: """Fixture to prevent certain I/O from happening.""" with patch( "homeassistant.components.http.ban.load_yaml_config_file", @@ -38,7 +41,7 @@ def prevent_io() -> Generator[None, None, None]: @pytest.fixture -def entity_registry_enabled_by_default() -> Generator[None, None, None]: +def entity_registry_enabled_by_default() -> Generator[None]: """Test fixture that ensures all entities are enabled in the registry.""" with patch( "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", @@ -49,18 +52,20 @@ def entity_registry_enabled_by_default() -> Generator[None, None, None]: # Blueprint test fixtures @pytest.fixture(name="stub_blueprint_populate") -def stub_blueprint_populate_fixture() -> Generator[None, Any, None]: +def stub_blueprint_populate_fixture() -> Generator[None]: """Stub copying the blueprints to the config folder.""" - from tests.components.blueprint.common import stub_blueprint_populate_fixture_helper + # pylint: disable-next=import-outside-toplevel + from .blueprint.common import stub_blueprint_populate_fixture_helper yield from stub_blueprint_populate_fixture_helper() # TTS test fixtures @pytest.fixture(name="mock_tts_get_cache_files") -def mock_tts_get_cache_files_fixture(): +def mock_tts_get_cache_files_fixture() -> Generator[MagicMock]: """Mock the list TTS cache function.""" - from tests.components.tts.common import mock_tts_get_cache_files_fixture_helper + # pylint: disable-next=import-outside-toplevel + from .tts.common import mock_tts_get_cache_files_fixture_helper yield from mock_tts_get_cache_files_fixture_helper() @@ -68,9 +73,10 @@ def mock_tts_get_cache_files_fixture(): @pytest.fixture(name="mock_tts_init_cache_dir") def mock_tts_init_cache_dir_fixture( init_tts_cache_dir_side_effect: Any, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock the TTS cache dir in memory.""" - from tests.components.tts.common import mock_tts_init_cache_dir_fixture_helper + # pylint: disable-next=import-outside-toplevel + from .tts.common import mock_tts_init_cache_dir_fixture_helper yield from mock_tts_init_cache_dir_fixture_helper(init_tts_cache_dir_side_effect) @@ -78,19 +84,22 @@ def mock_tts_init_cache_dir_fixture( @pytest.fixture(name="init_tts_cache_dir_side_effect") def init_tts_cache_dir_side_effect_fixture() -> Any: """Return the cache dir.""" - from tests.components.tts.common import ( - init_tts_cache_dir_side_effect_fixture_helper, - ) + # pylint: disable-next=import-outside-toplevel + from .tts.common import init_tts_cache_dir_side_effect_fixture_helper return init_tts_cache_dir_side_effect_fixture_helper() @pytest.fixture(name="mock_tts_cache_dir") def mock_tts_cache_dir_fixture( - tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request -): + tmp_path: Path, + mock_tts_init_cache_dir: MagicMock, + mock_tts_get_cache_files: MagicMock, + request: pytest.FixtureRequest, +) -> Generator[Path]: """Mock the TTS cache dir with empty dir.""" - from tests.components.tts.common import mock_tts_cache_dir_fixture_helper + # pylint: disable-next=import-outside-toplevel + from .tts.common import mock_tts_cache_dir_fixture_helper yield from mock_tts_cache_dir_fixture_helper( tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request @@ -98,9 +107,10 @@ def mock_tts_cache_dir_fixture( @pytest.fixture(name="tts_mutagen_mock") -def tts_mutagen_mock_fixture(): +def tts_mutagen_mock_fixture() -> Generator[MagicMock]: """Mock writing tags.""" - from tests.components.tts.common import tts_mutagen_mock_fixture_helper + # pylint: disable-next=import-outside-toplevel + from .tts.common import tts_mutagen_mock_fixture_helper yield from tts_mutagen_mock_fixture_helper() @@ -108,15 +118,14 @@ def tts_mutagen_mock_fixture(): @pytest.fixture(name="mock_conversation_agent") def mock_conversation_agent_fixture(hass: HomeAssistant) -> MockAgent: """Mock a conversation agent.""" - from tests.components.conversation.common import ( - mock_conversation_agent_fixture_helper, - ) + # pylint: disable-next=import-outside-toplevel + from .conversation.common import mock_conversation_agent_fixture_helper return mock_conversation_agent_fixture_helper(hass) @pytest.fixture(scope="session", autouse=True) -def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: +def prevent_ffmpeg_subprocess() -> Generator[None]: """Prevent ffmpeg from creating a subprocess.""" with patch( "homeassistant.components.ffmpeg.FFVersion.get_version", return_value="6.0" @@ -125,9 +134,10 @@ def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: @pytest.fixture -def mock_light_entities() -> list["MockLight"]: +def mock_light_entities() -> list[MockLight]: """Return mocked light entities.""" - from tests.components.light.common import MockLight + # pylint: disable-next=import-outside-toplevel + from .light.common import MockLight return [ MockLight("Ceiling", STATE_ON), @@ -137,34 +147,36 @@ def mock_light_entities() -> list["MockLight"]: @pytest.fixture -def mock_sensor_entities() -> dict[str, "MockSensor"]: +def mock_sensor_entities() -> dict[str, MockSensor]: """Return mocked sensor entities.""" - from tests.components.sensor.common import get_mock_sensor_entities + # pylint: disable-next=import-outside-toplevel + from .sensor.common import get_mock_sensor_entities return get_mock_sensor_entities() @pytest.fixture -def mock_switch_entities() -> list["MockSwitch"]: +def mock_switch_entities() -> list[MockSwitch]: """Return mocked toggle entities.""" - from tests.components.switch.common import get_mock_switch_entities + # pylint: disable-next=import-outside-toplevel + from .switch.common import get_mock_switch_entities return get_mock_switch_entities() @pytest.fixture -def mock_legacy_device_scanner() -> "MockScanner": +def mock_legacy_device_scanner() -> MockScanner: """Return mocked legacy device scanner entity.""" - from tests.components.device_tracker.common import MockScanner + # pylint: disable-next=import-outside-toplevel + from .device_tracker.common import MockScanner return MockScanner() @pytest.fixture -def mock_legacy_device_tracker_setup() -> ( - Callable[[HomeAssistant, "MockScanner"], None] -): +def mock_legacy_device_tracker_setup() -> Callable[[HomeAssistant, MockScanner], None]: """Return setup callable for legacy device tracker setup.""" - from tests.components.device_tracker.common import mock_legacy_device_tracker_setup + # pylint: disable-next=import-outside-toplevel + from .device_tracker.common import mock_legacy_device_tracker_setup return mock_legacy_device_tracker_setup diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index d1faf2da6c6..9a1b392f61c 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -20,25 +20,23 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -def _get_mock_c4_account( - getAccountControllers={ +def _get_mock_c4_account(): + c4_account_mock = AsyncMock(C4Account) + + c4_account_mock.getAccountControllers.return_value = { "controllerCommonName": "control4_model_00AA00AA00AA", "href": "https://apis.control4.com/account/v3/rest/accounts/000000", "name": "Name", - }, - getDirectorBearerToken={"token": "token"}, -): - c4_account_mock = AsyncMock(C4Account) + } - c4_account_mock.getAccountControllers.return_value = getAccountControllers - c4_account_mock.getDirectorBearerToken.return_value = getDirectorBearerToken + c4_account_mock.getDirectorBearerToken.return_value = {"token": "token"} return c4_account_mock -def _get_mock_c4_director(getAllItemInfo={}): +def _get_mock_c4_director(): c4_director_mock = AsyncMock(C4Director) - c4_director_mock.getAllItemInfo.return_value = getAllItemInfo + c4_director_mock.getAllItemInfo.return_value = {} return c4_director_mock diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 4801e506460..6575ab2ac98 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_agent_support_all(hass: HomeAssistant): +def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: """Mock agent that supports all languages.""" entry = MockConfigEntry(entry_id="mock-entry-support-all") entry.add_to_hass(hass) diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index d514d145477..403c72aaa10 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -117,12 +117,6 @@ 'name': 'Home Assistant', }) # --- -# name: test_get_agent_info.3 - dict({ - 'id': 'mock-entry', - 'name': 'test', - }) -# --- # name: test_get_agent_list dict({ 'agents': list([ @@ -569,7 +563,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', + 'speech': 'Sorry, I am not aware of any device called kitchen', }), }), }), @@ -709,7 +703,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called late added light', + 'speech': 'Sorry, I am not aware of any device called late added', }), }), }), @@ -789,7 +783,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', + 'speech': 'Sorry, I am not aware of any device called kitchen', }), }), }), @@ -809,7 +803,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called my cool light', + 'speech': 'Sorry, I am not aware of any device called my cool', }), }), }), @@ -949,7 +943,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called kitchen light', + 'speech': 'Sorry, I am not aware of any device called kitchen', }), }), }), @@ -999,7 +993,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any device called renamed light', + 'speech': 'Sorry, I am not aware of any device called renamed', }), }), }), @@ -1515,30 +1509,6 @@ }), }) # --- -# name: test_ws_get_agent_info - dict({ - 'attribution': None, - }) -# --- -# name: test_ws_get_agent_info.1 - dict({ - 'attribution': None, - }) -# --- -# name: test_ws_get_agent_info.2 - dict({ - 'attribution': dict({ - 'name': 'Mock assistant', - 'url': 'https://assist.me', - }), - }) -# --- -# name: test_ws_get_agent_info.3 - dict({ - 'code': 'invalid_format', - 'message': "invalid agent ID for dictionary value @ data['agent_id']. Got 'not_exist'", - }) -# --- # name: test_ws_hass_agent_debug dict({ 'results': list([ @@ -1664,15 +1634,6 @@ ]), }) # --- -# name: test_ws_hass_agent_debug.1 - dict({ - 'name': dict({ - 'name': 'name', - 'text': 'my cool light', - 'value': 'my cool light', - }), - }) -# --- # name: test_ws_hass_agent_debug_custom_sentence dict({ 'results': list([ diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 9048a1259c5..511967e3a9c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -6,13 +6,18 @@ from unittest.mock import AsyncMock, patch from hassil.recognize import Intent, IntentData, MatchEntity, RecognizeResult import pytest -from homeassistant.components import conversation +from homeassistant.components import conversation, cover, media_player from homeassistant.components.conversation import default_agent from homeassistant.components.homeassistant.exposed_entities import ( async_get_assistant_settings, ) -from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant +from homeassistant.components.intent import ( + TimerEventType, + TimerInfo, + async_register_timer_handler, +) +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, STATE_CLOSED +from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -67,15 +72,23 @@ async def test_hidden_entities_skipped( async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: """Test that we can't interact with entities that aren't exposed.""" hass.states.async_set( - "media_player.test", "off", attributes={ATTR_FRIENDLY_NAME: "Test Media Player"} + "lock.front_door", "off", attributes={ATTR_FRIENDLY_NAME: "Front Door"} ) + hass.states.async_set( + "script.my_script", "off", attributes={ATTR_FRIENDLY_NAME: "My Script"} + ) + + # These are match failures instead of handle failures because the domains + # aren't exposed by default. + result = await conversation.async_converse( + hass, "unlock front door", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS result = await conversation.async_converse( - hass, "turn on test media player", None, Context(), None + hass, "run my script", None, Context(), None ) - - # This is a match failure instead of a handle failure because the media - # player domain is not exposed. assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS @@ -607,14 +620,23 @@ async def test_error_no_domain_in_floor( async def test_error_no_device_class(hass: HomeAssistant, init_components) -> None: """Test error message when no entities of a device class exist.""" + # Create a cover entity that is not a window. + # This ensures that the filtering below won't exit early because there are + # no entities in the cover domain. + hass.states.async_set( + "cover.garage_door", + STATE_CLOSED, + attributes={ATTR_DEVICE_CLASS: cover.CoverDeviceClass.GARAGE}, + ) # We don't have a sentence for opening all windows + cover_domain = MatchEntity(name="domain", value="cover", text="cover") window_class = MatchEntity(name="device_class", value="window", text="windows") recognize_result = RecognizeResult( intent=Intent("HassTurnOn"), intent_data=IntentData([]), - entities={"device_class": window_class}, - entities_list=[window_class], + entities={"domain": cover_domain, "device_class": window_class}, + entities_list=[cover_domain, window_class], ) with patch( @@ -783,6 +805,139 @@ async def test_error_duplicate_names_in_area( ) +async def test_error_wrong_state(hass: HomeAssistant, init_components) -> None: + """Test error message when no entities are in the correct state.""" + assert await async_setup_component(hass, media_player.DOMAIN, {}) + + hass.states.async_set( + "media_player.test_player", + media_player.STATE_IDLE, + {ATTR_FRIENDLY_NAME: "test player"}, + ) + + result = await conversation.async_converse( + hass, "pause test player", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert result.response.speech["plain"]["speech"] == "Sorry, no device is playing" + + +async def test_error_feature_not_supported( + hass: HomeAssistant, init_components +) -> None: + """Test error message when no devices support a required feature.""" + assert await async_setup_component(hass, media_player.DOMAIN, {}) + + hass.states.async_set( + "media_player.test_player", + media_player.STATE_PLAYING, + {ATTR_FRIENDLY_NAME: "test player"}, + # missing VOLUME_SET feature + ) + + result = await conversation.async_converse( + hass, "set test player volume to 100%", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, no device supports the required features" + ) + + +async def test_error_no_timer_support(hass: HomeAssistant, init_components) -> None: + """Test error message when a device does not support timers (no handler is registered).""" + device_id = "test_device" + + # No timer handler is registered for the device + result = await conversation.async_converse( + hass, "pause timer", None, Context(), None, device_id=device_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, timers are not supported on this device" + ) + + +async def test_error_timer_not_found(hass: HomeAssistant, init_components) -> None: + """Test error message when a timer cannot be matched.""" + device_id = "test_device" + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + # Register a handler so the device "supports" timers + async_register_timer_handler(hass, device_id, handle_timer) + + result = await conversation.async_converse( + hass, "pause timer", None, Context(), None, device_id=device_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert ( + result.response.speech["plain"]["speech"] == "Sorry, I couldn't find that timer" + ) + + +async def test_error_multiple_timers_matched( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test error message when an intent would target multiple timers.""" + area_kitchen = area_registry.async_create("kitchen") + + # Starting a timer requires a device in an area + entry = MockConfigEntry() + entry.add_to_hass(hass) + device_kitchen = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "device-kitchen")}, + ) + device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id) + device_id = device_kitchen.id + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + # Register a handler so the device "supports" timers + async_register_timer_handler(hass, device_id, handle_timer) + + # Create two identical timers from the same device + result = await conversation.async_converse( + hass, "set a timer for 5 minutes", None, Context(), None, device_id=device_id + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + result = await conversation.async_converse( + hass, "set a timer for 5 minutes", None, Context(), None, device_id=device_id + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + # Cannot target multiple timers + result = await conversation.async_converse( + hass, "cancel timer", None, Context(), None, device_id=device_id + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.FAILED_TO_HANDLE + assert ( + result.response.speech["plain"]["speech"] + == "Sorry, I am unable to target multiple timers" + ) + + async def test_no_states_matched_default_error( hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry ) -> None: @@ -792,7 +947,9 @@ async def test_no_states_matched_default_error( with patch( "homeassistant.components.conversation.default_agent.intent.async_handle", - side_effect=intent.NoStatesMatchedError(), + side_effect=intent.MatchFailedError( + intent.MatchTargetsResult(False), intent.MatchTargetsConstraints() + ), ): result = await conversation.async_converse( hass, "turn on lights in the kitchen", None, Context(), None @@ -863,17 +1020,14 @@ async def test_empty_aliases( assert slot_lists.keys() == {"area", "name", "floor"} areas = slot_lists["area"] assert len(areas.values) == 1 - assert areas.values[0].value_out == area_kitchen.id assert areas.values[0].text_in.text == area_kitchen.normalized_name names = slot_lists["name"] assert len(names.values) == 1 - assert names.values[0].value_out == kitchen_light.name assert names.values[0].text_in.text == kitchen_light.name floors = slot_lists["floor"] assert len(floors.values) == 1 - assert floors.values[0].value_out == floor_1.floor_id assert floors.values[0].text_in.text == floor_1.name @@ -1082,3 +1236,89 @@ async def test_same_aliased_entities_in_different_areas( hass, "how many lights are on?", None, Context(), None ) assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER + + +async def test_device_id_in_handler(hass: HomeAssistant, init_components) -> None: + """Test that the default agent passes device_id to intent handler.""" + device_id = "test_device" + + # Reuse custom sentences in test config to trigger default agent. + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.device_id: str | None = None + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.device_id = intent_obj.device_id + return intent_obj.create_response() + + handler = OrderBeerIntentHandler() + intent.async_register(hass, handler) + + result = await conversation.async_converse( + hass, + "I'd like to order a stout please", + None, + Context(), + device_id=device_id, + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert handler.device_id == device_id + + +async def test_name_wildcard_lower_priority( + hass: HomeAssistant, init_components +) -> None: + """Test that the default agent does not prioritize a {name} slot when it's a wildcard.""" + + class OrderBeerIntentHandler(intent.IntentHandler): + intent_type = "OrderBeer" + + def __init__(self) -> None: + super().__init__() + self.triggered = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.triggered = True + return intent_obj.create_response() + + class OrderFoodIntentHandler(intent.IntentHandler): + intent_type = "OrderFood" + + def __init__(self) -> None: + super().__init__() + self.triggered = False + + async def async_handle( + self, intent_obj: intent.Intent + ) -> intent.IntentResponse: + self.triggered = True + return intent_obj.create_response() + + beer_handler = OrderBeerIntentHandler() + food_handler = OrderFoodIntentHandler() + intent.async_register(hass, beer_handler) + intent.async_register(hass, food_handler) + + # Matches OrderBeer because more literal text is matched ("a") + result = await conversation.async_converse( + hass, "I'd like to order a stout please", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert beer_handler.triggered + assert not food_handler.triggered + + # Matches OrderFood because "cookie" is not in the beer styles list + beer_handler.triggered = False + result = await conversation.async_converse( + hass, "I'd like to order a cookie please", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert not beer_handler.triggered + assert food_handler.triggered diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 9636ac07f63..b1c4a6d51af 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -1,5 +1,7 @@ """Test intents for the default agent.""" +from unittest.mock import patch + import pytest from homeassistant.components import ( @@ -7,14 +9,23 @@ from homeassistant.components import ( cover, light, media_player, + todo, vacuum, valve, ) from homeassistant.components.cover import intent as cover_intent from homeassistant.components.homeassistant.exposed_entities import async_expose_entity -from homeassistant.components.media_player import intent as media_player_intent +from homeassistant.components.media_player import ( + MediaPlayerEntityFeature, + intent as media_player_intent, +) from homeassistant.components.vacuum import intent as vaccum_intent -from homeassistant.const import STATE_CLOSED +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + STATE_CLOSED, + STATE_PAUSED, + STATE_PLAYING, +) from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -27,6 +38,27 @@ from homeassistant.setup import async_setup_component from tests.common import async_mock_service +class MockTodoListEntity(todo.TodoListEntity): + """Test todo list entity.""" + + def __init__(self, items: list[todo.TodoItem] | None = None) -> None: + """Initialize entity.""" + self._attr_todo_items = items or [] + + @property + def items(self) -> list[todo.TodoItem]: + """Return the items in the To-do list.""" + return self._attr_todo_items + + async def async_create_todo_item(self, item: todo.TodoItem) -> None: + """Add an item to the To-do list.""" + self._attr_todo_items.append(item) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item in the To-do list.""" + self._attr_todo_items = [item for item in self.items if item.uid not in uids] + + @pytest.fixture async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" @@ -189,7 +221,13 @@ async def test_media_player_intents( await media_player_intent.async_setup_intents(hass) entity_id = f"{media_player.DOMAIN}.tv" - hass.states.async_set(entity_id, media_player.STATE_PLAYING) + attributes = { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + } + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) async_expose_entity(hass, conversation.DOMAIN, entity_id, True) # pause @@ -206,6 +244,9 @@ async def test_media_player_intents( call = calls[0] assert call.data == {"entity_id": entity_id} + # Unpause requires paused state + hass.states.async_set(entity_id, STATE_PAUSED, attributes=attributes) + # unpause calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PLAY @@ -217,11 +258,14 @@ async def test_media_player_intents( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Unpaused" + assert response.speech["plain"]["speech"] == "Resumed" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} + # Next track requires playing state + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + # next calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_NEXT_TRACK @@ -345,3 +389,27 @@ async def test_turn_floor_lights_on_off( assert {s.entity_id for s in result.response.matched_states} == { bedroom_light.entity_id } + + +async def test_todo_add_item_fr( + hass: HomeAssistant, + init_components, +) -> None: + """Test that wildcard matches prioritize results with more literal text matched.""" + assert await async_setup_component(hass, todo.DOMAIN, {}) + hass.states.async_set("todo.liste_des_courses", 0, {}) + + with ( + patch.object(hass.config, "language", "fr"), + patch( + "homeassistant.components.todo.intent.ListAddItemIntent.async_handle", + return_value=intent.IntentResponse(hass.config.language), + ) as mock_handle, + ): + await conversation.async_converse( + hass, "Ajoute de la farine a la liste des courses", None, Context(), None + ) + mock_handle.assert_called_once() + assert mock_handle.call_args.args + intent_obj = mock_handle.call_args.args[0] + assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine" diff --git a/tests/components/conversation/test_entity.py b/tests/components/conversation/test_entity.py index c84f94c4aa4..109c0ed361f 100644 --- a/tests/components/conversation/test_entity.py +++ b/tests/components/conversation/test_entity.py @@ -2,7 +2,9 @@ from unittest.mock import patch +from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -31,6 +33,11 @@ async def test_state_set_and_restore(hass: HomeAssistant) -> None: ) as mock_process, patch("homeassistant.util.dt.utcnow", return_value=now), ): + intent_response = intent.IntentResponse(language="en") + intent_response.async_set_speech("response text") + mock_process.return_value = conversation.ConversationResult( + response=intent_response, + ) await hass.services.async_call( "conversation", "process", diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 5b117c1ac70..48f227e9497 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.conversation import default_agent from homeassistant.components.conversation.models import ConversationInput from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_ON from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -24,7 +24,7 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from . import expose_entity, expose_new +from . import MockAgent, expose_entity, expose_new from tests.common import ( MockConfigEntry, @@ -94,7 +94,7 @@ async def test_http_processing_intent_target_ha_agent( init_components, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, - mock_conversation_agent, + mock_conversation_agent: MockAgent, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -269,7 +269,7 @@ async def test_http_processing_intent_entity_renamed( We want to ensure that renaming an entity later busts the cache so that the new name is used. """ - entity = MockLight("kitchen light", "on") + entity = MockLight("kitchen light", STATE_ON) entity._attr_unique_id = "1234" entity.entity_id = "light.kitchen" setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) @@ -357,7 +357,7 @@ async def test_http_processing_intent_entity_exposed( We want to ensure that manually exposing an entity later busts the cache so that the new setting is used. """ - entity = MockLight("kitchen light", "on") + entity = MockLight("kitchen light", STATE_ON) entity._attr_unique_id = "1234" entity.entity_id = "light.kitchen" setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) @@ -458,7 +458,7 @@ async def test_http_processing_intent_conversion_not_expose_new( # Disable exposing new entities to the default agent expose_new(hass, False) - entity = MockLight("kitchen light", "on") + entity = MockLight("kitchen light", STATE_ON) entity._attr_unique_id = "1234" entity.entity_id = "light.kitchen" setup_test_component_platform(hass, LIGHT_DOMAIN, [entity]) @@ -502,7 +502,12 @@ async def test_http_processing_intent_conversion_not_expose_new( @pytest.mark.parametrize("sentence", ["turn on kitchen", "turn kitchen on"]) @pytest.mark.parametrize("conversation_id", ["my_new_conversation", None]) async def test_turn_on_intent( - hass: HomeAssistant, init_components, conversation_id, sentence, agent_id, snapshot + hass: HomeAssistant, + init_components, + conversation_id, + sentence, + agent_id, + snapshot: SnapshotAssertion, ) -> None: """Test calling the turn on intent.""" hass.states.async_set("light.kitchen", "off") @@ -658,7 +663,7 @@ async def test_custom_agent( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, - mock_conversation_agent, + mock_conversation_agent: MockAgent, snapshot: SnapshotAssertion, ) -> None: """Test a custom conversation agent.""" @@ -927,6 +932,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non conversation_id=None, device_id=None, language=hass.config.language, + agent_id=None, ) ) assert len(calls) == 1 @@ -1075,8 +1081,8 @@ async def test_agent_id_validator_invalid_agent( async def test_get_agent_list( hass: HomeAssistant, init_components, - mock_conversation_agent, - mock_agent_support_all, + mock_conversation_agent: MockAgent, + mock_agent_support_all: MockAgent, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, ) -> None: @@ -1133,7 +1139,7 @@ async def test_get_agent_list( async def test_get_agent_info( hass: HomeAssistant, init_components, - mock_conversation_agent, + mock_conversation_agent: MockAgent, snapshot: SnapshotAssertion, ) -> None: """Test get agent info.""" diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py new file mode 100644 index 00000000000..c586eb8865d --- /dev/null +++ b/tests/components/conversation/test_trace.py @@ -0,0 +1,80 @@ +"""Test for the conversation traces.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.conversation import trace +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + + +@pytest.fixture +async def init_components(hass: HomeAssistant): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "conversation", {}) + assert await async_setup_component(hass, "intent", {}) + + +async def test_converation_trace( + hass: HomeAssistant, + init_components: None, + sl_setup: None, +) -> None: + """Test tracing a conversation.""" + await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + assert last_trace.get("events") + assert len(last_trace.get("events")) == 1 + trace_event = last_trace["events"][0] + assert ( + trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS + ) + assert trace_event.get("data") + assert trace_event["data"].get("text") == "add apples to my shopping list" + assert last_trace.get("result") + assert ( + last_trace["result"] + .get("response", {}) + .get("speech", {}) + .get("plain", {}) + .get("speech") + == "Added apples" + ) + + +async def test_converation_trace_error( + hass: HomeAssistant, + init_components: None, + sl_setup: None, +) -> None: + """Test tracing a conversation.""" + with ( + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_process", + side_effect=HomeAssistantError("Failed to talk to agent"), + ), + pytest.raises(HomeAssistantError), + ): + await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + assert last_trace.get("events") + assert len(last_trace.get("events")) == 1 + trace_event = last_trace["events"][0] + assert ( + trace_event.get("event_type") == trace.ConversationTraceEventType.ASYNC_PROCESS + ) + assert last_trace.get("error") == "Failed to talk to agent" diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 83f4e97c853..c5d4382e917 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.conversation import default_agent from homeassistant.components.conversation.models import ConversationInput -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import trigger from homeassistant.setup import async_setup_component @@ -16,19 +16,21 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @pytest.fixture(autouse=True) -async def setup_comp(hass): +async def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) -async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None: +async def test_if_fires_on_event( + hass: HomeAssistant, calls: list[ServiceCall], setup_comp: None +) -> None: """Test the firing of events.""" assert await async_setup_component( hass, @@ -134,7 +136,9 @@ async def test_empty_response(hass: HomeAssistant, setup_comp) -> None: assert service_response["response"]["speech"]["plain"]["speech"] == "" -async def test_response_same_sentence(hass: HomeAssistant, calls, setup_comp) -> None: +async def test_response_same_sentence( + hass: HomeAssistant, calls: list[ServiceCall], setup_comp: None +) -> None: """Test the conversation response action with multiple triggers using the same sentence.""" assert await async_setup_component( hass, @@ -196,7 +200,10 @@ async def test_response_same_sentence(hass: HomeAssistant, calls, setup_comp) -> async def test_response_same_sentence_with_error( - hass: HomeAssistant, calls, setup_comp, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + calls: list[ServiceCall], + setup_comp: None, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the conversation response action with multiple triggers using the same sentence and an error.""" caplog.set_level(logging.ERROR) @@ -303,7 +310,7 @@ async def test_subscribe_trigger_does_not_interfere_with_responses( async def test_same_trigger_multiple_sentences( - hass: HomeAssistant, calls, setup_comp + hass: HomeAssistant, calls: list[ServiceCall], setup_comp: None ) -> None: """Test matching of multiple sentences from the same trigger.""" assert await async_setup_component( @@ -348,7 +355,7 @@ async def test_same_trigger_multiple_sentences( async def test_same_sentence_multiple_triggers( - hass: HomeAssistant, calls, setup_comp + hass: HomeAssistant, calls: list[ServiceCall], setup_comp: None ) -> None: """Test use of the same sentence in multiple triggers.""" assert await async_setup_component( @@ -467,7 +474,9 @@ async def test_fails_on_no_sentences(hass: HomeAssistant) -> None: ) -async def test_wildcards(hass: HomeAssistant, calls, setup_comp) -> None: +async def test_wildcards( + hass: HomeAssistant, calls: list[ServiceCall], setup_comp: None +) -> None: """Test wildcards in trigger sentences.""" assert await async_setup_component( hass, @@ -555,6 +564,7 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None: conversation_id=None, device_id="my_device", language=hass.config.language, + agent_id=None, ) ) assert result.response.speech["plain"]["speech"] == "my_device" diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index c0bd6344adb..ef2caf2eab1 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -1,6 +1,7 @@ """The tests for the counter component.""" import logging +from typing import Any import pytest @@ -37,7 +38,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): @@ -532,7 +533,10 @@ async def test_ws_delete( async def test_update_min_max( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -549,7 +553,6 @@ async def test_update_min_max( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - entity_registry = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None @@ -620,7 +623,10 @@ async def test_update_min_max( async def test_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test creating counter using WS.""" @@ -630,7 +636,6 @@ async def test_create( counter_id = "new_counter" input_entity_id = f"{DOMAIN}.{counter_id}" - entity_registry = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index e70e8d3a70f..db9e75bcaef 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -12,6 +12,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component +from .common import MockCover + from tests.common import ( MockConfigEntry, async_get_device_automation_capabilities, @@ -19,7 +21,6 @@ from tests.common import ( async_mock_service, setup_test_component_platform, ) -from tests.components.cover.common import MockCover @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -135,7 +136,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["close"] + for action in ("close",) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index d1a542e6608..545bdd6587e 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -15,11 +15,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component +from .common import MockCover + from tests.common import ( MockConfigEntry, async_get_device_automation_capabilities, @@ -27,7 +29,6 @@ from tests.common import ( async_mock_service, setup_test_component_platform, ) -from tests.components.cover.common import MockCover @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -36,7 +37,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -164,7 +165,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_open", "is_closed", "is_opening", "is_closing"] + for condition in ("is_open", "is_closed", "is_opening", "is_closing") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -358,7 +359,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -501,7 +502,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -557,7 +558,7 @@ async def test_if_position( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], caplog: pytest.LogCaptureFixture, mock_cover_entities: list[MockCover], ) -> None: @@ -717,7 +718,7 @@ async def test_if_tilt_position( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], caplog: pytest.LogCaptureFixture, mock_cover_entities: list[MockCover], ) -> None: diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index 8e2f794f1e0..419eea05f9f 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -16,12 +16,14 @@ from homeassistant.const import ( STATE_OPENING, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .common import MockCover + from tests.common import ( MockConfigEntry, async_fire_time_changed, @@ -30,7 +32,6 @@ from tests.common import ( async_mock_service, setup_test_component_platform, ) -from tests.components.cover.common import MockCover @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -39,7 +40,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -165,7 +166,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["opened", "closed", "opening", "closing"] + for trigger in ("opened", "closed", "opening", "closing") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -380,7 +381,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for state triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -533,7 +534,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for state triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -593,7 +594,7 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -659,7 +660,7 @@ async def test_if_fires_on_position( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, mock_cover_entities: list[MockCover], - calls, + calls: list[ServiceCall], ) -> None: """Test for position triggers.""" setup_test_component_platform(hass, DOMAIN, mock_cover_entities) @@ -811,7 +812,7 @@ async def test_if_fires_on_tilt_position( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_cover_entities: list[MockCover], ) -> None: """Test for tilt position triggers.""" diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 5ccd948cc6b..7da6c6efe21 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -17,12 +17,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import MockCover + from tests.common import ( help_test_all, import_and_test_deprecated_constant_enum, setup_test_component_platform, ) -from tests.components.cover.common import MockCover async def test_services( diff --git a/tests/components/cover/test_intent.py b/tests/components/cover/test_intent.py index b1dbe786065..8ee621596db 100644 --- a/tests/components/cover/test_intent.py +++ b/tests/components/cover/test_intent.py @@ -28,7 +28,7 @@ async def test_open_cover_intent(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert response.speech["plain"]["speech"] == "Opened garage door" + assert response.speech["plain"]["speech"] == "Opening garage door" assert len(calls) == 1 call = calls[0] assert call.domain == DOMAIN @@ -51,7 +51,7 @@ async def test_close_cover_intent(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert response.speech["plain"]["speech"] == "Closed garage door" + assert response.speech["plain"]["speech"] == "Closing garage door" assert len(calls) == 1 call = calls[0] assert call.domain == DOMAIN diff --git a/tests/components/cpuspeed/conftest.py b/tests/components/cpuspeed/conftest.py index 82dfb5eac30..e3ea1432659 100644 --- a/tests/components/cpuspeed/conftest.py +++ b/tests/components/cpuspeed/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.cpuspeed.const import DOMAIN from homeassistant.core import HomeAssistant @@ -25,7 +25,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_cpuinfo_config_flow() -> Generator[MagicMock, None, None]: +def mock_cpuinfo_config_flow() -> Generator[MagicMock]: """Return a mocked get_cpu_info. It is only used to check truthy or falsy values, so it is mocked @@ -39,7 +39,7 @@ def mock_cpuinfo_config_flow() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.cpuspeed.async_setup_entry", return_value=True @@ -48,7 +48,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_cpuinfo() -> Generator[MagicMock, None, None]: +def mock_cpuinfo() -> Generator[MagicMock]: """Return a mocked get_cpu_info.""" info = { "hz_actual": (3200000001, 0), diff --git a/tests/components/crownstone/test_config_flow.py b/tests/components/crownstone/test_config_flow.py index 3525d8c3f53..be9086e02da 100644 --- a/tests/components/crownstone/test_config_flow.py +++ b/tests/components/crownstone/test_config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from crownstone_cloud.cloud_models.spheres import Spheres @@ -12,6 +11,7 @@ from crownstone_cloud.exceptions import ( ) import pytest from serial.tools.list_ports_common import ListPortInfo +from typing_extensions import Generator from homeassistant.components import usb from homeassistant.components.crownstone.const import ( @@ -30,7 +30,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -MockFixture = Generator[MagicMock | AsyncMock, None, None] +type MockFixture = Generator[MagicMock | AsyncMock] @pytest.fixture(name="crownstone_setup") diff --git a/tests/components/date/test_init.py b/tests/components/date/test_init.py index a6c517c7b9e..c7d2949d326 100644 --- a/tests/components/date/test_init.py +++ b/tests/components/date/test_init.py @@ -12,8 +12,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import MockDateEntity + from tests.common import setup_test_component_platform -from tests.components.date.common import MockDateEntity async def test_date(hass: HomeAssistant) -> None: diff --git a/tests/components/datetime/test_init.py b/tests/components/datetime/test_init.py index da65e1bce9e..6d90bbf746d 100644 --- a/tests/components/datetime/test_init.py +++ b/tests/components/datetime/test_init.py @@ -10,15 +10,16 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_PLATFOR from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import MockDateTimeEntity + from tests.common import setup_test_component_platform -from tests.components.datetime.common import MockDateTimeEntity DEFAULT_VALUE = datetime(2020, 1, 1, 12, 0, 0, tzinfo=UTC) async def test_datetime(hass: HomeAssistant) -> None: """Test date/time entity.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") setup_test_component_platform( hass, DOMAIN, diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index 9fd57926f44..6ab5f2f5477 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -21,7 +21,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import async_entries_for_config_entry from .test_gateway import ( DECONZ_WEB_REQUEST, @@ -687,7 +686,8 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_service_call( assert not hass.states.get("binary_sensor.presence_sensor") assert ( - len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 0 + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 0 ) aioclient_mock.clear_requests() @@ -738,7 +738,8 @@ async def test_add_new_binary_sensor_ignored_load_entities_on_options_change( assert not hass.states.get("binary_sensor.presence_sensor") assert ( - len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 0 + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 0 ) aioclient_mock.clear_requests() diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 5a55fb64090..b00a5cc1f05 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -1,6 +1,7 @@ """Test deCONZ gateway.""" from copy import deepcopy +from typing import Any from unittest.mock import patch import pydeconz @@ -44,6 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -105,12 +107,10 @@ def mock_deconz_put_request(aioclient_mock, config, path): async def setup_deconz_integration( - hass, - aioclient_mock=None, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker | None = None, *, - config=ENTRY_CONFIG, - options=ENTRY_OPTIONS, - get_state_response=DECONZ_WEB_REQUEST, + options: dict[str, Any] | UndefinedType = UNDEFINED, entry_id="1", unique_id=BRIDGEID, source=SOURCE_USER, @@ -119,15 +119,15 @@ async def setup_deconz_integration( config_entry = MockConfigEntry( domain=DECONZ_DOMAIN, source=source, - data=deepcopy(config), - options=deepcopy(options), + data=deepcopy(ENTRY_CONFIG), + options=deepcopy(ENTRY_OPTIONS if options is UNDEFINED else options), entry_id=entry_id, unique_id=unique_id, ) config_entry.add_to_hass(hass) if aioclient_mock: - mock_deconz_request(aioclient_mock, config, get_state_response) + mock_deconz_request(aioclient_mock, ENTRY_CONFIG, DECONZ_WEB_REQUEST) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -141,6 +141,8 @@ async def test_gateway_setup( device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" + # Patching async_forward_entry_setup* is not advisable, and should be refactored + # in the future. with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", return_value=True, @@ -190,8 +192,10 @@ async def test_gateway_device_configuration_url_when_addon( device_registry: dr.DeviceRegistry, ) -> None: """Successful setup.""" + # Patching async_forward_entry_setup* is not advisable, and should be refactored + # in the future. with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", return_value=True, ): config_entry = await setup_deconz_integration( diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 0555f70f5e6..d08bd039184 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -150,7 +150,8 @@ async def test_unload_entry_multiple_gateways_parallel( assert len(hass.data[DECONZ_DOMAIN]) == 2 await asyncio.gather( - config_entry.async_unload(hass), config_entry2.async_unload(hass) + hass.config_entries.async_unload(config_entry.entry_id), + hass.config_entries.async_unload(config_entry2.entry_id), ) assert len(hass.data[DECONZ_DOMAIN]) == 0 diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 5144f222484..d964361df57 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1522,4 +1522,4 @@ async def test_verify_group_color_mode_fallback( ) group_state = hass.states.get("light.opbergruimte") assert group_state.state == STATE_ON - assert group_state.attributes[ATTR_COLOR_MODE] is ColorMode.UNKNOWN + assert group_state.attributes[ATTR_COLOR_MODE] is ColorMode.BRIGHTNESS diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 4950928f2e6..1e1ca6efe7c 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -275,6 +275,49 @@ TEST_DATA = [ "next_state": "50", }, ), + ( # Carbon dioxide sensor + { + "capabilities": { + "measured_value": { + "unit": "PPB", + } + }, + "config": { + "on": True, + "reachable": True, + }, + "etag": "dc3a3788ddd2a2d175ead376ea4d814c", + "lastannounced": None, + "lastseen": "2024-02-02T21:13Z", + "manufacturername": "_TZE200_dwcarsat", + "modelid": "TS0601", + "name": "CarbonDioxide 35", + "state": { + "lastupdated": "2024-02-02T21:14:37.745", + "measured_value": 370, + }, + "type": "ZHACarbonDioxide", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-040d", + }, + { + "entity_count": 1, + "device_count": 3, + "entity_id": "sensor.carbondioxide_35", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-040d-carbon_dioxide", + "state": "370", + "entity_category": None, + "device_class": SensorDeviceClass.CO2, + "state_class": CONCENTRATION_PARTS_PER_BILLION, + "attributes": { + "device_class": "carbon_dioxide", + "friendly_name": "CarbonDioxide 35", + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, + }, + "websocket_event": {"state": {"measured_value": 500}}, + "next_state": "500", + }, + ), ( # Consumption sensor { "config": {"on": True, "reachable": True}, @@ -354,6 +397,49 @@ TEST_DATA = [ "next_state": "dusk", }, ), + ( # Formaldehyde + { + "capabilities": { + "measured_value": { + "unit": "PPM", + } + }, + "config": { + "on": True, + "reachable": True, + }, + "etag": "bb01ac0313b6724e8c540a6eef7cc3cb", + "lastannounced": None, + "lastseen": "2024-02-02T21:13Z", + "manufacturername": "_TZE200_dwcarsat", + "modelid": "TS0601", + "name": "Formaldehyde 34", + "state": { + "lastupdated": "2024-02-02T21:14:46.810", + "measured_value": 1, + }, + "type": "ZHAFormaldehyde", + "uniqueid": "xx:xx:xx:xx:xx:xx:xx:xx-01-042b", + }, + { + "entity_count": 1, + "device_count": 3, + "entity_id": "sensor.formaldehyde_34", + "unique_id": "xx:xx:xx:xx:xx:xx:xx:xx-01-042b-formaldehyde", + "state": "1", + "entity_category": None, + "device_class": SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + "state_class": SensorStateClass.MEASUREMENT, + "attributes": { + "device_class": "volatile_organic_compounds", + "friendly_name": "Formaldehyde 34", + "state_class": SensorStateClass.MEASUREMENT, + "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, + }, + "websocket_event": {"state": {"measured_value": 2}}, + "next_state": "2", + }, + ), ( # Generic status sensor { "config": { diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 7cf55ae75c3..de061fc4e8c 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -18,12 +18,10 @@ from homeassistant.components.deconz.services import ( SERVICE_ENTITY, SERVICE_FIELD, SERVICE_REMOVE_ORPHANED_ENTRIES, - SUPPORTED_SERVICES, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import async_entries_for_config_entry from .test_gateway import ( BRIDGEID, @@ -37,40 +35,6 @@ from tests.common import async_capture_events from tests.test_util.aiohttp import AiohttpClientMocker -async def test_service_setup_and_unload( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Verify service setup works.""" - config_entry = await setup_deconz_integration(hass, aioclient_mock) - for service in SUPPORTED_SERVICES: - assert hass.services.has_service(DECONZ_DOMAIN, service) - - assert await hass.config_entries.async_unload(config_entry.entry_id) - for service in SUPPORTED_SERVICES: - assert not hass.services.has_service(DECONZ_DOMAIN, service) - - -@patch("homeassistant.core.ServiceRegistry.async_remove") -@patch("homeassistant.core.ServiceRegistry.async_register") -async def test_service_setup_and_unload_not_called_if_multiple_integrations_detected( - register_service_mock, - remove_service_mock, - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Make sure that services are only setup and removed once.""" - config_entry = await setup_deconz_integration(hass, aioclient_mock) - register_service_mock.reset_mock() - config_entry_2 = await setup_deconz_integration(hass, aioclient_mock, entry_id=2) - register_service_mock.assert_not_called() - - register_service_mock.assert_not_called() - assert await hass.config_entries.async_unload(config_entry_2.entry_id) - remove_service_mock.assert_not_called() - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert remove_service_mock.call_count == 3 - - async def test_configure_service_with_field( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: @@ -185,7 +149,6 @@ async def test_configure_service_with_faulty_field( await hass.services.async_call( DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data ) - await hass.async_block_till_done() async def test_configure_service_with_faulty_entity( @@ -404,7 +367,7 @@ async def test_remove_orphaned_entries_service( ) assert ( - len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 3 # Light, switch battery and orphan ) @@ -427,6 +390,6 @@ async def test_remove_orphaned_entries_service( ) assert ( - len(async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) == 2 # Light and switch battery ) diff --git a/tests/components/default_config/conftest.py b/tests/components/default_config/conftest.py index 4714102eff9..ce1b3ad8de4 100644 --- a/tests/components/default_config/conftest.py +++ b/tests/components/default_config/conftest.py @@ -1,8 +1,10 @@ """default_config session fixtures.""" +from unittest.mock import MagicMock + import pytest @pytest.fixture(autouse=True) -def default_config_mock_async_zeroconf(mock_async_zeroconf): +def default_config_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index 222b2b14673..1a6665b2404 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -33,9 +33,8 @@ def recorder_url_mock(): yield -async def test_setup( - hass: HomeAssistant, mock_zeroconf: None, mock_get_source_ip, mock_bluetooth: None -) -> None: +@pytest.mark.usefixtures("mock_bluetooth", "mock_zeroconf") +async def test_setup(hass: HomeAssistant) -> None: """Test setup.""" recorder_helper.async_initialize_recorder(hass) # default_config needs the homeassistant integration, assert it will be diff --git a/tests/components/demo/conftest.py b/tests/components/demo/conftest.py index 731a33360d7..56aabac0280 100644 --- a/tests/components/demo/conftest.py +++ b/tests/components/demo/conftest.py @@ -22,10 +22,16 @@ async def setup_homeassistant(hass: HomeAssistant): @pytest.fixture -async def disable_platforms(hass: HomeAssistant) -> None: +def disable_platforms(hass: HomeAssistant) -> None: """Disable platforms to speed up tests.""" - with patch( - "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", - [], + with ( + patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [], + ), + patch( + "homeassistant.components.demo.COMPONENTS_WITH_DEMO_PLATFORM", + [], + ), ): yield diff --git a/tests/components/demo/test_camera.py b/tests/components/demo/test_camera.py index ea115e72f72..ecbd3fecee3 100644 --- a/tests/components/demo/test_camera.py +++ b/tests/components/demo/test_camera.py @@ -83,7 +83,7 @@ async def test_turn_off_image(hass: HomeAssistant) -> None: with pytest.raises(HomeAssistantError) as error: await async_get_image(hass, ENTITY_CAMERA) - assert error.args[0] == "Camera is off" + assert error.value.args[0] == "Camera is off" async def test_turn_off_invalid_camera(hass: HomeAssistant) -> None: diff --git a/tests/components/demo/test_config_flow.py b/tests/components/demo/test_config_flow.py new file mode 100644 index 00000000000..a0b687e422a --- /dev/null +++ b/tests/components/demo/test_config_flow.py @@ -0,0 +1,92 @@ +"""Test the Demo config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.demo import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("disable_platforms") +async def test_import(hass: HomeAssistant) -> None: + """Test that we can import a config entry.""" + with patch("homeassistant.components.demo.async_setup_entry", return_value=True): + assert await setup.async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.data == {} + + +@pytest.mark.usefixtures("disable_platforms") +async def test_import_once(hass: HomeAssistant) -> None: + """Test that we don't create multiple config entries.""" + with patch( + "homeassistant.components.demo.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={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Demo" + assert result["data"] == {} + assert result["options"] == {} + mock_setup_entry.assert_called_once() + + # Test importing again doesn't create a 2nd entry + with patch("homeassistant.components.demo.async_setup_entry") as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + mock_setup_entry.assert_not_called() + + +@pytest.mark.usefixtures("disable_platforms") +async def test_options_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "options_1" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"bool": True, "constant": "Constant Value", "int": 15}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "options_2" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + "bool": True, + "constant": "Constant Value", + "int": 15, + "multi": ["default"], + "select": "default", + "string": "Default", + } + + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/demo/test_datetime.py b/tests/components/demo/test_datetime.py index c1f88d7686b..bd4adafd695 100644 --- a/tests/components/demo/test_datetime.py +++ b/tests/components/demo/test_datetime.py @@ -37,7 +37,7 @@ def test_setup_params(hass: HomeAssistant) -> None: async def test_set_datetime(hass: HomeAssistant) -> None: """Test set datetime service.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index bd42ae3a953..bf6b8479a12 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -189,7 +189,6 @@ async def test_turn_on_with_preset_mode_only( {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, blocking=True, ) - await hass.async_block_till_done() assert exc.value.translation_domain == fan.DOMAIN assert exc.value.translation_key == "not_valid_preset_mode" assert exc.value.translation_placeholders == { @@ -263,7 +262,6 @@ async def test_turn_on_with_preset_mode_and_speed( {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, blocking=True, ) - await hass.async_block_till_done() assert exc.value.translation_domain == fan.DOMAIN assert exc.value.translation_key == "not_valid_preset_mode" assert exc.value.translation_placeholders == { @@ -362,7 +360,6 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, blocking=True, ) - await hass.async_block_till_done() assert exc.value.translation_domain == fan.DOMAIN assert exc.value.translation_key == "not_valid_preset_mode" @@ -373,7 +370,6 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, blocking=True, ) - await hass.async_block_till_done() assert exc.value.translation_domain == fan.DOMAIN assert exc.value.translation_key == "not_valid_preset_mode" diff --git a/tests/components/demo/test_init.py b/tests/components/demo/test_init.py index 05532d7503b..2d60f7caf94 100644 --- a/tests/components/demo/test_init.py +++ b/tests/components/demo/test_init.py @@ -34,7 +34,7 @@ async def test_setting_up_demo(mock_history, hass: HomeAssistant) -> None: # non-JSON-serializable data in the state machine. try: json.dumps(hass.states.async_all(), cls=JSONEncoder) - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 pytest.fail( "Unable to convert all demo entities to JSON. Wrong data in state machine!" ) diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index 634eee44385..853b9197ab7 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -16,7 +16,13 @@ from homeassistant.components.lock import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_STATE_CHANGED, + STATE_OPEN, + STATE_OPENING, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -87,6 +93,26 @@ async def test_unlocking(hass: HomeAssistant) -> None: assert state_changes[1].data["new_state"].state == STATE_UNLOCKED +@patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) +async def test_opening(hass: HomeAssistant) -> None: + """Test the opening of a lock.""" + state = hass.states.get(OPENABLE_LOCK) + assert state.state == STATE_LOCKED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == OPENABLE_LOCK + assert state_changes[0].data["new_state"].state == STATE_OPENING + + assert state_changes[1].data["entity_id"] == OPENABLE_LOCK + assert state_changes[1].data["new_state"].state == STATE_OPEN + + @patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) async def test_jammed_when_locking(hass: HomeAssistant) -> None: """Test the locking of a lock jams.""" @@ -114,12 +140,3 @@ async def test_opening_mocked(hass: HomeAssistant) -> None: LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True ) assert len(calls) == 1 - - -async def test_opening(hass: HomeAssistant) -> None: - """Test the opening of a lock.""" - await hass.services.async_call( - LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True - ) - state = hass.states.get(OPENABLE_LOCK) - assert state.state == STATE_UNLOCKED diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 8e7b32cc4b7..a6669fa705c 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -6,11 +6,46 @@ from unittest.mock import patch import pytest import voluptuous as vol -import homeassistant.components.media_player as mp +from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, + ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_EPISODE, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MP_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, + SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOURCE, + SERVICE_UNJOIN, + MediaPlayerEntityFeature, + RepeatMode, + is_on, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, STATE_OFF, STATE_PAUSED, STATE_PLAYING, @@ -50,30 +85,30 @@ async def test_source_select(hass: HomeAssistant) -> None: entity_id = "media_player.lounge_room" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes.get(mp.ATTR_INPUT_SOURCE) == "dvd" + assert state.attributes.get(ATTR_INPUT_SOURCE) == "dvd" with pytest.raises(vol.Invalid): await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: entity_id, mp.ATTR_INPUT_SOURCE: None}, + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: None}, blocking=True, ) state = hass.states.get(entity_id) - assert state.attributes.get(mp.ATTR_INPUT_SOURCE) == "dvd" + assert state.attributes.get(ATTR_INPUT_SOURCE) == "dvd" await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_SELECT_SOURCE, - {ATTR_ENTITY_ID: entity_id, mp.ATTR_INPUT_SOURCE: "xbox"}, + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: "xbox"}, blocking=True, ) state = hass.states.get(entity_id) - assert state.attributes.get(mp.ATTR_INPUT_SOURCE) == "xbox" + assert state.attributes.get(ATTR_INPUT_SOURCE) == "xbox" async def test_repeat_set(hass: HomeAssistant) -> None: @@ -81,26 +116,26 @@ async def test_repeat_set(hass: HomeAssistant) -> None: entity_id = "media_player.walkman" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes.get(mp.ATTR_MEDIA_REPEAT) == mp.const.REPEAT_MODE_OFF + assert state.attributes.get(ATTR_MEDIA_REPEAT) == RepeatMode.OFF await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_REPEAT_SET, - {ATTR_ENTITY_ID: entity_id, mp.ATTR_MEDIA_REPEAT: mp.const.REPEAT_MODE_ALL}, + MP_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_REPEAT: RepeatMode.ALL}, blocking=True, ) state = hass.states.get(entity_id) - assert state.attributes.get(mp.ATTR_MEDIA_REPEAT) == mp.const.REPEAT_MODE_ALL + assert state.attributes.get(ATTR_MEDIA_REPEAT) == RepeatMode.ALL async def test_clear_playlist(hass: HomeAssistant) -> None: """Test clear playlist.""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -108,8 +143,8 @@ async def test_clear_playlist(hass: HomeAssistant) -> None: assert state.state == STATE_PLAYING await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_CLEAR_PLAYLIST, + MP_DOMAIN, + SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) @@ -120,79 +155,79 @@ async def test_clear_playlist(hass: HomeAssistant) -> None: async def test_volume_services(hass: HomeAssistant) -> None: """Test the volume service.""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_LEVEL) == 1.0 + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 1.0 with pytest.raises(vol.Invalid): await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, mp.ATTR_MEDIA_VOLUME_LEVEL: None}, + MP_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: None}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_LEVEL) == 1.0 + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 1.0 await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, mp.ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + MP_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_LEVEL) == 0.5 + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.5 await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_VOLUME_DOWN, + MP_DOMAIN, + SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_LEVEL) == 0.4 + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.4 await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_VOLUME_UP, + MP_DOMAIN, + SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_LEVEL) == 0.5 + assert state.attributes.get(ATTR_MEDIA_VOLUME_LEVEL) == 0.5 - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_MUTED) is False + assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is False with pytest.raises(vol.Invalid): await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_VOLUME_MUTE, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, mp.ATTR_MEDIA_VOLUME_MUTED: None}, + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: None}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_MUTED) is False + assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is False await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_VOLUME_MUTE, - {ATTR_ENTITY_ID: TEST_ENTITY_ID, mp.ATTR_MEDIA_VOLUME_MUTED: True}, + MP_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: TEST_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_VOLUME_MUTED) is True + assert state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True async def test_turning_off_and_on(hass: HomeAssistant) -> None: """Test turn_on and turn_off.""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -200,40 +235,40 @@ async def test_turning_off_and_on(hass: HomeAssistant) -> None: assert state.state == STATE_PLAYING await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_TURN_OFF, + MP_DOMAIN, + SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF - assert not mp.is_on(hass, TEST_ENTITY_ID) + assert not is_on(hass, TEST_ENTITY_ID) await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_TURN_ON, + MP_DOMAIN, + SERVICE_TURN_ON, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_PLAYING - assert mp.is_on(hass, TEST_ENTITY_ID) + assert is_on(hass, TEST_ENTITY_ID) await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_TOGGLE, + MP_DOMAIN, + SERVICE_TOGGLE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF - assert not mp.is_on(hass, TEST_ENTITY_ID) + assert not is_on(hass, TEST_ENTITY_ID) async def test_playing_pausing(hass: HomeAssistant) -> None: """Test media_pause.""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -241,8 +276,8 @@ async def test_playing_pausing(hass: HomeAssistant) -> None: assert state.state == STATE_PLAYING await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_PAUSE, + MP_DOMAIN, + SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) @@ -250,8 +285,8 @@ async def test_playing_pausing(hass: HomeAssistant) -> None: assert state.state == STATE_PAUSED await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_PLAY_PAUSE, + MP_DOMAIN, + SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) @@ -259,8 +294,8 @@ async def test_playing_pausing(hass: HomeAssistant) -> None: assert state.state == STATE_PLAYING await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_PLAY_PAUSE, + MP_DOMAIN, + SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) @@ -268,8 +303,8 @@ async def test_playing_pausing(hass: HomeAssistant) -> None: assert state.state == STATE_PAUSED await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_PLAY, + MP_DOMAIN, + SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) @@ -280,148 +315,148 @@ async def test_playing_pausing(hass: HomeAssistant) -> None: async def test_prev_next_track(hass: HomeAssistant) -> None: """Test media_next_track and media_previous_track .""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_TRACK) == 1 + assert state.attributes.get(ATTR_MEDIA_TRACK) == 1 await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_NEXT_TRACK, + MP_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_TRACK) == 2 + assert state.attributes.get(ATTR_MEDIA_TRACK) == 2 await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_NEXT_TRACK, + MP_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_TRACK) == 3 + assert state.attributes.get(ATTR_MEDIA_TRACK) == 3 await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_PREVIOUS_TRACK, + MP_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get(mp.ATTR_MEDIA_TRACK) == 2 + assert state.attributes.get(ATTR_MEDIA_TRACK) == 2 assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() ent_id = "media_player.lounge_room" state = hass.states.get(ent_id) - assert state.attributes.get(mp.ATTR_MEDIA_EPISODE) == "1" + assert state.attributes.get(ATTR_MEDIA_EPISODE) == "1" await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_NEXT_TRACK, + MP_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ent_id}, blocking=True, ) state = hass.states.get(ent_id) - assert state.attributes.get(mp.ATTR_MEDIA_EPISODE) == "2" + assert state.attributes.get(ATTR_MEDIA_EPISODE) == "2" await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_PREVIOUS_TRACK, + MP_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ent_id}, blocking=True, ) state = hass.states.get(ent_id) - assert state.attributes.get(mp.ATTR_MEDIA_EPISODE) == "1" + assert state.attributes.get(ATTR_MEDIA_EPISODE) == "1" async def test_play_media(hass: HomeAssistant) -> None: """Test play_media .""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() ent_id = "media_player.living_room" state = hass.states.get(ent_id) assert ( - mp.MediaPlayerEntityFeature.PLAY_MEDIA + MediaPlayerEntityFeature.PLAY_MEDIA & state.attributes.get(ATTR_SUPPORTED_FEATURES) > 0 ) - assert state.attributes.get(mp.ATTR_MEDIA_CONTENT_ID) is not None + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) is not None with pytest.raises(vol.Invalid): await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_PLAY_MEDIA, - {ATTR_ENTITY_ID: ent_id, mp.ATTR_MEDIA_CONTENT_ID: "some_id"}, + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + {ATTR_ENTITY_ID: ent_id, ATTR_MEDIA_CONTENT_ID: "some_id"}, blocking=True, ) state = hass.states.get(ent_id) assert ( - mp.MediaPlayerEntityFeature.PLAY_MEDIA + MediaPlayerEntityFeature.PLAY_MEDIA & state.attributes.get(ATTR_SUPPORTED_FEATURES) > 0 ) - assert state.attributes.get(mp.ATTR_MEDIA_CONTENT_ID) != "some_id" + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) != "some_id" await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_PLAY_MEDIA, + MP_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: ent_id, - mp.ATTR_MEDIA_CONTENT_TYPE: "youtube", - mp.ATTR_MEDIA_CONTENT_ID: "some_id", + ATTR_MEDIA_CONTENT_TYPE: "youtube", + ATTR_MEDIA_CONTENT_ID: "some_id", }, blocking=True, ) state = hass.states.get(ent_id) assert ( - mp.MediaPlayerEntityFeature.PLAY_MEDIA + MediaPlayerEntityFeature.PLAY_MEDIA & state.attributes.get(ATTR_SUPPORTED_FEATURES) > 0 ) - assert state.attributes.get(mp.ATTR_MEDIA_CONTENT_ID) == "some_id" + assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == "some_id" async def test_seek(hass: HomeAssistant, mock_media_seek) -> None: """Test seek.""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() ent_id = "media_player.living_room" state = hass.states.get(ent_id) - assert state.attributes[ATTR_SUPPORTED_FEATURES] & mp.MediaPlayerEntityFeature.SEEK + assert state.attributes[ATTR_SUPPORTED_FEATURES] & MediaPlayerEntityFeature.SEEK assert not mock_media_seek.called with pytest.raises(vol.Invalid): await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_SEEK, + MP_DOMAIN, + SERVICE_MEDIA_SEEK, { ATTR_ENTITY_ID: ent_id, - mp.ATTR_MEDIA_SEEK_POSITION: None, + ATTR_MEDIA_SEEK_POSITION: None, }, blocking=True, ) assert not mock_media_seek.called await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_SEEK, + MP_DOMAIN, + SERVICE_MEDIA_SEEK, { ATTR_ENTITY_ID: ent_id, - mp.ATTR_MEDIA_SEEK_POSITION: 100, + ATTR_MEDIA_SEEK_POSITION: 100, }, blocking=True, ) @@ -431,7 +466,7 @@ async def test_seek(hass: HomeAssistant, mock_media_seek) -> None: async def test_stop(hass: HomeAssistant) -> None: """Test stop.""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -439,8 +474,8 @@ async def test_stop(hass: HomeAssistant) -> None: assert state.state == STATE_PLAYING await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_MEDIA_STOP, + MP_DOMAIN, + SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) @@ -453,7 +488,7 @@ async def test_media_image_proxy( ) -> None: """Test the media server image proxy server .""" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -500,31 +535,31 @@ async def test_grouping(hass: HomeAssistant) -> None: kitchen = "media_player.kitchen" assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "demo"}} + hass, MP_DOMAIN, {"media_player": {"platform": "demo"}} ) await hass.async_block_till_done() state = hass.states.get(walkman) - assert state.attributes.get(mp.ATTR_GROUP_MEMBERS) == [] + assert state.attributes.get(ATTR_GROUP_MEMBERS) == [] await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_JOIN, + MP_DOMAIN, + SERVICE_JOIN, { ATTR_ENTITY_ID: walkman, - mp.ATTR_GROUP_MEMBERS: [ + ATTR_GROUP_MEMBERS: [ kitchen, ], }, blocking=True, ) state = hass.states.get(walkman) - assert state.attributes.get(mp.ATTR_GROUP_MEMBERS) == [walkman, kitchen] + assert state.attributes.get(ATTR_GROUP_MEMBERS) == [walkman, kitchen] await hass.services.async_call( - mp.DOMAIN, - mp.SERVICE_UNJOIN, + MP_DOMAIN, + SERVICE_UNJOIN, {ATTR_ENTITY_ID: walkman}, blocking=True, ) state = hass.states.get(walkman) - assert state.attributes.get(mp.ATTR_GROUP_MEMBERS) == [] + assert state.attributes.get(ATTR_GROUP_MEMBERS) == [] diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 50730fb6c1e..4ebbfbdac04 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -1,22 +1,22 @@ """The tests for the notify demo platform.""" -from collections.abc import Generator from unittest.mock import patch import pytest +from typing_extensions import Generator from homeassistant.components import notify from homeassistant.components.demo import DOMAIN import homeassistant.components.demo.notify as demo from homeassistant.const import Platform -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_capture_events @pytest.fixture -def notify_only() -> Generator[None, None]: +def notify_only() -> Generator[None]: """Enable only the notify platform.""" with patch( "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", @@ -42,24 +42,6 @@ def events(hass: HomeAssistant) -> list[Event]: return async_capture_events(hass, demo.EVENT_NOTIFY) -@pytest.fixture -def calls(): - """Fixture to calls.""" - return [] - - -@pytest.fixture -def record_calls(calls): - """Fixture to record calls.""" - - @callback - def record_calls(*args): - """Record calls.""" - calls.append(args) - - return record_calls - - async def test_sending_message(hass: HomeAssistant, events: list[Event]) -> None: """Test sending a message.""" data = { diff --git a/tests/components/demo/test_update.py b/tests/components/demo/test_update.py index d8af9c21c75..0a8886a085d 100644 --- a/tests/components/demo/test_update.py +++ b/tests/components/demo/test_update.py @@ -134,6 +134,7 @@ async def test_update_with_progress(hass: HomeAssistant) -> None: async_track_state_change_event( hass, "update.demo_update_with_progress", + # pylint: disable-next=unnecessary-lambda callback(lambda event: events.append(event)), ) @@ -171,6 +172,7 @@ async def test_update_with_progress_raising(hass: HomeAssistant) -> None: async_track_state_change_event( hass, "update.demo_update_with_progress", + # pylint: disable-next=unnecessary-lambda callback(lambda event: events.append(event)), ) diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 3db0227c2a6..efdde93173c 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant import config_entries from homeassistant.components.derivative.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import selector from tests.common import MockConfigEntry @@ -71,7 +72,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") @pytest.mark.parametrize("platform", ["sensor"]) @@ -95,6 +96,10 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + hass.states.async_set("sensor.input", 10, {"unit_of_measurement": "dog"}) + hass.states.async_set("sensor.valid", 10, {"unit_of_measurement": "dog"}) + hass.states.async_set("sensor.invalid", 10, {"unit_of_measurement": "cat"}) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -104,9 +109,17 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert get_suggested(schema, "unit_prefix") == "k" assert get_suggested(schema, "unit_time") == "min" + source = schema["source"] + assert isinstance(source, selector.EntitySelector) + assert source.config["include_entities"] == [ + "sensor.input", + "sensor.valid", + ] + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ + "source": "sensor.valid", "round": 2.0, "time_window": {"seconds": 10.0}, "unit_time": "h", @@ -116,7 +129,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert result["data"] == { "name": "My derivative", "round": 2.0, - "source": "sensor.input", + "source": "sensor.valid", "time_window": {"seconds": 10.0}, "unit_time": "h", } @@ -124,7 +137,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert config_entry.options == { "name": "My derivative", "round": 2.0, - "source": "sensor.input", + "source": "sensor.valid", "time_window": {"seconds": 10.0}, "unit_time": "h", } @@ -134,11 +147,11 @@ async def test_options(hass: HomeAssistant, platform) -> None: await hass.async_block_till_done() # Check the entity was updated, no new entity was created - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 4 # Check the state of the entity has changed as expected - hass.states.async_set("sensor.input", 10, {"unit_of_measurement": "cat"}) - hass.states.async_set("sensor.input", 11, {"unit_of_measurement": "cat"}) + hass.states.async_set("sensor.valid", 10, {"unit_of_measurement": "cat"}) + hass.states.async_set("sensor.valid", 11, {"unit_of_measurement": "cat"}) await hass.async_block_till_done() state = hass.states.get(f"{platform}.my_derivative") assert state.attributes["unit_of_measurement"] == "cat/h" diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 34fe385032b..0081ab97580 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -4,7 +4,7 @@ import pytest from homeassistant.components.derivative.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -60,3 +60,90 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(derivative_entity_id) is None assert entity_registry.async_get(derivative_entity_id) is None + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for source entity device for Derivative.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + # Configure the configuration entry for Derivative + derivative_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "Derivative", + "round": 1.0, + "source": "sensor.test_source", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="Derivative", + ) + derivative_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the derivative sensor + derivative_entity = entity_registry.async_get("sensor.derivative") + assert derivative_entity is not None + assert derivative_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to Derivative config entry + device_registry.async_get_or_create( + config_entry_id=derivative_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=derivative_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, two devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + derivative_config_entry.entry_id + ) + assert len(devices_before_reload) == 3 + + # Config entry reload + await hass.config_entries.async_reload(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the derivative sensor after reload + derivative_entity = entity_registry.async_get("sensor.derivative") + assert derivative_entity is not None + assert derivative_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + derivative_config_entry.entry_id + ) + assert len(devices_after_reload) == 1 diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 3c3101d7a1f..7d68a944de1 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -13,10 +13,10 @@ from homeassistant.components.device_automation import ( InvalidDeviceAutomationConfig, toggle_entity, ) -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound @@ -1385,14 +1385,14 @@ async def test_automation_with_bad_condition( @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") async def test_automation_with_sub_condition( hass: HomeAssistant, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index a8850bf50b9..f15730d9525 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import automation from homeassistant.const import STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -20,7 +20,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -29,7 +29,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing. @@ -145,8 +145,8 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - trigger, + calls: list[ServiceCall], + trigger: str, ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 5f44593aabe..65afd5743f5 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -108,9 +108,8 @@ async def test_lights_on_when_sun_sets( ) -async def test_lights_turn_off_when_everyone_leaves( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_lights_turn_off_when_everyone_leaves(hass: HomeAssistant) -> None: """Test lights turn off when everyone leaves the house.""" assert await async_setup_component( hass, "light", {light.DOMAIN: {CONF_PLATFORM: "test"}} diff --git a/tests/components/device_tracker/common.py b/tests/components/device_tracker/common.py index a17556cfbaa..d30db984a66 100644 --- a/tests/components/device_tracker/common.py +++ b/tests/components/device_tracker/common.py @@ -20,7 +20,7 @@ from homeassistant.components.device_tracker import ( SourceType, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.typing import GPSType +from homeassistant.helpers.typing import ConfigType, GPSType from homeassistant.loader import bind_hass from tests.common import MockPlatform, mock_platform @@ -143,7 +143,9 @@ def mock_legacy_device_tracker_setup( ) -> None: """Mock legacy device tracker platform setup.""" - async def _async_get_scanner(hass, config) -> MockScanner: + async def _async_get_scanner( + hass: HomeAssistant, config: ConfigType + ) -> MockScanner: """Return the test scanner.""" return legacy_device_scanner diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index 077e964f0af..45b94012051 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -1,9 +1,9 @@ """Test Device Tracker config entry things.""" -from collections.abc import Generator from typing import Any import pytest +from typing_extensions import Generator from homeassistant.components.device_tracker import ( ATTR_HOST_NAME, @@ -55,7 +55,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 18f3d64ec0e..6ea4ed7a372 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -7,7 +7,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_tracker import DOMAIN from homeassistant.const import STATE_HOME, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -25,7 +25,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -54,7 +54,7 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in ["is_not_home", "is_home"] + for condition in ("is_not_home", "is_home") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -102,7 +102,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_not_home", "is_home"] + for condition in ("is_not_home", "is_home") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -114,7 +114,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -199,7 +199,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index 67c41b85752..8932eb15997 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -8,7 +8,7 @@ from homeassistant.components import automation, zone from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.device_tracker import DOMAIN, device_trigger from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -37,7 +37,7 @@ HOME_LONGITUDE = -117.237561 @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -85,7 +85,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["leaves", "enters"] + for trigger in ("leaves", "enters") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -133,7 +133,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["leaves", "enters"] + for trigger in ("leaves", "enters") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -145,7 +145,7 @@ async def test_if_fires_on_zone_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for enter and leave triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -252,7 +252,7 @@ async def test_if_fires_on_zone_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for enter and leave triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py index dba069c410b..c2df3a74770 100644 --- a/tests/components/device_tracker/test_legacy.py +++ b/tests/components/device_tracker/test_legacy.py @@ -9,7 +9,7 @@ from homeassistant.util.yaml import dump from tests.common import patch_yaml_files -def test_remove_device_from_config(hass: HomeAssistant): +def test_remove_device_from_config(hass: HomeAssistant) -> None: """Test the removal of a device from a config.""" yaml_devices = { "test": { diff --git a/tests/components/devolo_home_control/conftest.py b/tests/components/devolo_home_control/conftest.py index 6ce9b73ff83..04752da5925 100644 --- a/tests/components/devolo_home_control/conftest.py +++ b/tests/components/devolo_home_control/conftest.py @@ -1,9 +1,9 @@ """Fixtures for tests.""" -from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator @pytest.fixture @@ -19,9 +19,7 @@ def maintenance() -> bool: @pytest.fixture(autouse=True) -def patch_mydevolo( - credentials_valid: bool, maintenance: bool -) -> Generator[None, None, None]: +def patch_mydevolo(credentials_valid: bool, maintenance: bool) -> Generator[None]: """Fixture to patch mydevolo into a desired state.""" with ( patch( @@ -41,5 +39,5 @@ def patch_mydevolo( @pytest.fixture(autouse=True) -def devolo_home_control_mock_async_zeroconf(mock_async_zeroconf): +def devolo_home_control_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 422a24c3be0..02823871e0f 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -25,7 +25,7 @@ from devolo_home_control_api.publisher.publisher import Publisher class BinarySensorPropertyMock(BinarySensorProperty): """devolo Home Control binary sensor mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() self.element_uid = "Test" @@ -38,7 +38,7 @@ class BinarySensorPropertyMock(BinarySensorProperty): class BinarySwitchPropertyMock(BinarySwitchProperty): """devolo Home Control binary sensor mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() self.element_uid = "Test" @@ -48,7 +48,7 @@ class BinarySwitchPropertyMock(BinarySwitchProperty): class ConsumptionPropertyMock(ConsumptionProperty): """devolo Home Control binary sensor mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() self.element_uid = "devolo.Meter:Test" @@ -61,7 +61,7 @@ class ConsumptionPropertyMock(ConsumptionProperty): class MultiLevelSensorPropertyMock(MultiLevelSensorProperty): """devolo Home Control multi level sensor mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self.element_uid = "Test" self.sensor_type = "temperature" @@ -73,7 +73,7 @@ class MultiLevelSensorPropertyMock(MultiLevelSensorProperty): class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): """devolo Home Control multi level switch mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self.element_uid = "Test" self.min = 4 @@ -85,7 +85,7 @@ class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): class SirenPropertyMock(MultiLevelSwitchProperty): """devolo Home Control siren mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self.element_uid = "Test" self.max = 0 @@ -98,7 +98,7 @@ class SirenPropertyMock(MultiLevelSwitchProperty): class SettingsMock(SettingsProperty): """devolo Home Control settings mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self._logger = MagicMock() self.name = "Test" @@ -109,7 +109,7 @@ class SettingsMock(SettingsProperty): class DeviceMock(Zwave): """devolo Home Control device mock.""" - def __init__(self) -> None: + def __init__(self) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self.status = 0 self.brand = "devolo" @@ -250,7 +250,7 @@ class SwitchMock(DeviceMock): class HomeControlMock(HomeControl): """devolo Home Control gateway mock.""" - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called """Initialize the mock.""" self.devices = {} self.publisher = MagicMock() diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 9c3b1668991..da007303688 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -19,7 +19,8 @@ from .mocks import HomeControlMock, HomeControlMockBinarySensor from tests.typing import WebSocketGenerator -async def test_setup_entry(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry.""" entry = configure_integration(hass) with patch("homeassistant.components.devolo_home_control.HomeControl"): @@ -43,7 +44,8 @@ async def test_setup_entry_maintenance(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_gateway_offline(hass: HomeAssistant, mock_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_setup_gateway_offline(hass: HomeAssistant) -> None: """Test setup entry fails on gateway offline.""" entry = configure_integration(hass) with patch( diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index f6a6e233b6d..fd03063cd34 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -1,7 +1,7 @@ """Fixtures for tests.""" from itertools import cycle -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -50,5 +50,5 @@ def mock_validate_input(): @pytest.fixture(autouse=True) -def devolo_home_network_mock_async_zeroconf(mock_async_zeroconf): +def devolo_home_network_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py index 1097c0271cb..b2d410b03f9 100644 --- a/tests/components/devolo_home_network/test_button.py +++ b/tests/components/devolo_home_network/test_button.py @@ -106,7 +106,6 @@ async def test_button( {ATTR_ENTITY_ID: state_key}, blocking=True, ) - await hass.async_block_till_done() await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index c4a02f9e375..1b8903c568e 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -53,9 +53,11 @@ async def test_setup_without_password(hass: HomeAssistant) -> None: } entry = MockConfigEntry(domain=DOMAIN, data=config) entry.add_to_hass(hass) + # Patching async_forward_entry_setup* is not advisable, and should be refactored + # in the future. with ( patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", return_value=True, ), patch("homeassistant.core.EventBus.async_listen_once"), diff --git a/tests/components/dexcom/__init__.py b/tests/components/dexcom/__init__.py index e9ca303765b..adc9c56049a 100644 --- a/tests/components/dexcom/__init__.py +++ b/tests/components/dexcom/__init__.py @@ -7,6 +7,7 @@ from pydexcom import GlucoseReading from homeassistant.components.dexcom.const import CONF_SERVER, DOMAIN, SERVER_US from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -19,7 +20,7 @@ CONFIG = { GLUCOSE_READING = GlucoseReading(json.loads(load_fixture("data.json", "dexcom"))) -async def init_integration(hass) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Dexcom integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py index dff71d9edbf..eeb4f420225 100644 --- a/tests/components/diagnostics/test_init.py +++ b/tests/components/diagnostics/test_init.py @@ -1,14 +1,15 @@ """Test the Diagnostics integration.""" from http import HTTPStatus -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.system_info import async_get_system_info +from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component from . import _get_diagnostics_for_config_entry, _get_diagnostics_for_device @@ -79,10 +80,11 @@ async def test_websocket( } +@pytest.mark.usefixtures("enable_custom_integrations") async def test_download_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - enable_custom_integrations: None, + device_registry: dr.DeviceRegistry, ) -> None: """Test download diagnostics.""" config_entry = MockConfigEntry(domain="fake_integration") @@ -90,9 +92,16 @@ async def test_download_diagnostics( hass_sys_info = await async_get_system_info(hass) hass_sys_info["run_as_root"] = hass_sys_info["user"] == "root" del hass_sys_info["user"] - - assert await _get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { + integration = await async_get_integration(hass, "fake_integration") + original_manifest = integration.manifest.copy() + original_manifest["codeowners"] = ["@test"] + with patch.object(integration, "manifest", original_manifest): + response = await _get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert response == { "home_assistant": hass_sys_info, + "setup_times": {}, "custom_components": { "test": { "documentation": "http://example.com", @@ -161,7 +170,7 @@ async def test_download_diagnostics( }, }, "integration_manifest": { - "codeowners": [], + "codeowners": ["test"], "dependencies": [], "domain": "fake_integration", "is_built_in": True, @@ -171,8 +180,7 @@ async def test_download_diagnostics( "data": {"config_entry": "info"}, } - dev_reg = async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("test", "test")} ) @@ -256,6 +264,7 @@ async def test_download_diagnostics( "requirements": [], }, "data": {"device": "info"}, + "setup_times": {}, } diff --git a/tests/components/dialogflow/test_init.py b/tests/components/dialogflow/test_init.py index a977a414fe4..4c36a6887aa 100644 --- a/tests/components/dialogflow/test_init.py +++ b/tests/components/dialogflow/test_init.py @@ -9,10 +9,12 @@ import pytest from homeassistant import config_entries from homeassistant.components import dialogflow, intent_script from homeassistant.config import async_process_ha_core_config -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + SESSION_ID = "a9b84cec-46b6-484e-8f31-f65dba03ae6d" INTENT_ID = "c6a74079-a8f0-46cd-b372-5a934d23591c" INTENT_NAME = "tests" @@ -22,12 +24,12 @@ CONTEXT_NAME = "78a5db95-b7d6-4d50-9c9b-2fc73a5e34c3_id_dialog_context" @pytest.fixture -async def calls(hass, fixture): +async def calls(hass: HomeAssistant, fixture) -> list[ServiceCall]: """Return a list of Dialogflow calls triggered.""" - calls = [] + calls: list[ServiceCall] = [] @callback - def mock_service(call): + def mock_service(call: ServiceCall) -> None: """Mock action call.""" calls.append(call) @@ -37,7 +39,7 @@ async def calls(hass, fixture): @pytest.fixture -async def fixture(hass, hass_client_no_auth): +async def fixture(hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator): """Initialize a Home Assistant server for testing this module.""" await async_setup_component(hass, dialogflow.DOMAIN, {"dialogflow": {}}) await async_setup_component( @@ -343,7 +345,9 @@ async def test_intent_request_without_slots_v2(hass: HomeAssistant, fixture) -> assert text == "You are both home, you silly" -async def test_intent_request_calling_service_v1(fixture, calls) -> None: +async def test_intent_request_calling_service_v1( + fixture, calls: list[ServiceCall] +) -> None: """Test a request for calling a service. If this request is done async the test could finish before the action @@ -365,7 +369,9 @@ async def test_intent_request_calling_service_v1(fixture, calls) -> None: assert call.data.get("hello") == "virgo" -async def test_intent_request_calling_service_v2(fixture, calls) -> None: +async def test_intent_request_calling_service_v2( + fixture, calls: list[ServiceCall] +) -> None: """Test a request for calling a service. If this request is done async the test could finish before the action diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index 913e33f6367..056f763c3e2 100644 --- a/tests/components/discovergy/conftest.py +++ b/tests/components/discovergy/conftest.py @@ -1,18 +1,19 @@ """Fixtures for Discovergy integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from pydiscovergy.models import Reading import pytest +from typing_extensions import Generator from homeassistant.components.discovergy.const import DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .const import GET_METERS, LAST_READING, LAST_READING_GAS + from tests.common import MockConfigEntry -from tests.components.discovergy.const import GET_METERS, LAST_READING, LAST_READING_GAS def _meter_last_reading(meter_id: str) -> Reading: @@ -25,7 +26,7 @@ def _meter_last_reading(meter_id: str) -> Reading: @pytest.fixture(name="discovergy") -def mock_discovergy() -> Generator[AsyncMock, None, None]: +def mock_discovergy() -> Generator[AsyncMock]: """Mock the pydiscovergy client.""" with ( patch( diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index 98cf042c0a3..4bbf99000a9 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -1,10 +1,11 @@ """Configure pytest for D-Link tests.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from copy import deepcopy from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN @@ -41,7 +42,7 @@ CONF_DHCP_FLOW_NEW_IP = dhcp.DhcpServiceInfo( hostname="dsp-w215", ) -ComponentSetup = Callable[[], Awaitable[None]] +type ComponentSetup = Callable[[], Awaitable[None]] def create_entry(hass: HomeAssistant, unique_id: str | None = None) -> MockConfigEntry: @@ -130,7 +131,7 @@ async def setup_integration( hass: HomeAssistant, config_entry_with_uid: MockConfigEntry, mocked_plug: MagicMock, -) -> Generator[ComponentSetup, None, None]: +) -> Generator[ComponentSetup]: """Set up the D-Link integration in Home Assistant.""" async def func() -> None: @@ -144,7 +145,7 @@ async def setup_integration_legacy( hass: HomeAssistant, config_entry_with_uid: MockConfigEntry, mocked_plug_legacy: MagicMock, -) -> Generator[ComponentSetup, None, None]: +) -> Generator[ComponentSetup]: """Set up the D-Link integration in Home Assistant with different data.""" async def func() -> None: diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index d070158d9fb..0460a6a918f 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components.dlink import DOMAIN +from homeassistant.components.dlink.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 59b1af546f2..0d88009f58e 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -158,8 +158,3 @@ def async_get_local_ip_mock() -> Iterable[Mock]: ) as func: func.return_value = AddressFamily.AF_INET, LOCAL_IP yield func - - -@pytest.fixture(autouse=True) -def dlna_dmr_mock_get_source_ip(mock_get_source_ip): - """Mock network util's async_get_source_ip.""" diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index 55cf20859d3..765d65ff0b9 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -598,12 +598,12 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "alternative_integration" - for manufacturer, model in [ + for manufacturer, model in ( ("XBMC Foundation", "Kodi"), ("Samsung", "Smart TV"), ("LG Electronics.", "LG TV"), ("Royal Philips Electronics", "Philips TV DMR"), - ]: + ): discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 87c54c2956b..d202994f988 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -20,7 +20,7 @@ from didl_lite import didl_lite import pytest from homeassistant import const as ha_const -from homeassistant.components import ssdp +from homeassistant.components import media_player as mp, ssdp from homeassistant.components.dlna_dmr.const import ( CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, @@ -31,13 +31,10 @@ from homeassistant.components.dlna_dmr.const import ( from homeassistant.components.dlna_dmr.data import EventListenAddr from homeassistant.components.dlna_dmr.media_player import DlnaDmrEntity from homeassistant.components.media_player import ( - ATTR_TO_PROPERTY, - DOMAIN as MP_DOMAIN, MediaPlayerEntityFeature, MediaPlayerState, MediaType, RepeatMode, - const as mp_const, ) from homeassistant.components.media_source import DOMAIN as MS_DOMAIN, PlayMedia from homeassistant.const import ( @@ -48,16 +45,8 @@ from homeassistant.const import ( CONF_URL, ) from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - CONNECTION_UPNP, - async_get as async_get_dr, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get as async_get_er, -) from homeassistant.setup import async_setup_component from .conftest import ( @@ -83,7 +72,8 @@ async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) assert await hass.config_entries.async_setup(mock_entry.entry_id) is True await hass.async_block_till_done() - entries = async_entries_for_config_entry(async_get_er(hass), mock_entry.entry_id) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, mock_entry.entry_id) assert len(entries) == 1 return entries[0].entity_id @@ -348,6 +338,7 @@ async def test_setup_entry_with_options( async def test_setup_entry_mac_address( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, domain_data_mock: Mock, config_entry_mock: MockConfigEntry, ssdp_scanner_mock: Mock, @@ -359,17 +350,17 @@ async def test_setup_entry_mac_address( await async_update_entity(hass, mock_entity_id) await hass.async_block_till_done() # Check the device registry connections for MAC address - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None - assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections + assert (dr.CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections async def test_setup_entry_no_mac_address( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, domain_data_mock: Mock, config_entry_mock_no_mac: MockConfigEntry, ssdp_scanner_mock: Mock, @@ -381,13 +372,12 @@ async def test_setup_entry_no_mac_address( await async_update_entity(hass, mock_entity_id) await hass.async_block_till_done() # Check the device registry connections does not include the MAC address - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None - assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections + assert (dr.CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections async def test_event_subscribe_failure( @@ -448,15 +438,17 @@ async def test_event_subscribe_rejected( async def test_available_device( - hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + dmr_device_mock: Mock, + mock_entity_id: str, ) -> None: """Test a DlnaDmrEntity with a connected DmrDevice.""" # Check hass device information is filled in await async_update_entity(hass, mock_entity_id) await hass.async_block_till_done() - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None @@ -466,7 +458,7 @@ async def test_available_device( assert device.name == "device_name" # Check entity state gets updated when device changes state - for dev_state, ent_state in [ + for dev_state, ent_state in ( (None, MediaPlayerState.ON), (TransportState.STOPPED, MediaPlayerState.IDLE), (TransportState.PLAYING, MediaPlayerState.PLAYING), @@ -476,7 +468,7 @@ async def test_available_device( (TransportState.RECORDING, MediaPlayerState.IDLE), (TransportState.NO_MEDIA_PRESENT, MediaPlayerState.IDLE), (TransportState.VENDOR_DEFINED, ha_const.STATE_UNKNOWN), - ]: + ): dmr_device_mock.profile_device.available = True dmr_device_mock.transport_state = dev_state await async_update_entity(hass, mock_entity_id) @@ -551,59 +543,59 @@ async def test_attributes( """Test attributes of a connected DlnaDmrEntity.""" # Check attributes come directly from the device attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level - assert attrs[mp_const.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted - assert attrs[mp_const.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration - assert attrs[mp_const.ATTR_MEDIA_POSITION] is dmr_device_mock.media_position + assert attrs[mp.ATTR_MEDIA_VOLUME_LEVEL] is dmr_device_mock.volume_level + assert attrs[mp.ATTR_MEDIA_VOLUME_MUTED] is dmr_device_mock.is_volume_muted + assert attrs[mp.ATTR_MEDIA_DURATION] is dmr_device_mock.media_duration + assert attrs[mp.ATTR_MEDIA_POSITION] is dmr_device_mock.media_position assert ( - attrs[mp_const.ATTR_MEDIA_POSITION_UPDATED_AT] + attrs[mp.ATTR_MEDIA_POSITION_UPDATED_AT] is dmr_device_mock.media_position_updated_at ) - assert attrs[mp_const.ATTR_MEDIA_CONTENT_ID] is dmr_device_mock.current_track_uri - assert attrs[mp_const.ATTR_MEDIA_ARTIST] is dmr_device_mock.media_artist - assert attrs[mp_const.ATTR_MEDIA_ALBUM_NAME] is dmr_device_mock.media_album_name - assert attrs[mp_const.ATTR_MEDIA_ALBUM_ARTIST] is dmr_device_mock.media_album_artist - assert attrs[mp_const.ATTR_MEDIA_TRACK] is dmr_device_mock.media_track_number - assert attrs[mp_const.ATTR_MEDIA_SERIES_TITLE] is dmr_device_mock.media_series_title - assert attrs[mp_const.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number - assert attrs[mp_const.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number - assert attrs[mp_const.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name - assert attrs[mp_const.ATTR_SOUND_MODE_LIST] is dmr_device_mock.preset_names + assert attrs[mp.ATTR_MEDIA_CONTENT_ID] is dmr_device_mock.current_track_uri + assert attrs[mp.ATTR_MEDIA_ARTIST] is dmr_device_mock.media_artist + assert attrs[mp.ATTR_MEDIA_ALBUM_NAME] is dmr_device_mock.media_album_name + assert attrs[mp.ATTR_MEDIA_ALBUM_ARTIST] is dmr_device_mock.media_album_artist + assert attrs[mp.ATTR_MEDIA_TRACK] is dmr_device_mock.media_track_number + assert attrs[mp.ATTR_MEDIA_SERIES_TITLE] is dmr_device_mock.media_series_title + assert attrs[mp.ATTR_MEDIA_SEASON] is dmr_device_mock.media_season_number + assert attrs[mp.ATTR_MEDIA_EPISODE] is dmr_device_mock.media_episode_number + assert attrs[mp.ATTR_MEDIA_CHANNEL] is dmr_device_mock.media_channel_name + assert attrs[mp.ATTR_SOUND_MODE_LIST] is dmr_device_mock.preset_names # Entity picture is cached, won't correspond to remote image assert isinstance(attrs[ha_const.ATTR_ENTITY_PICTURE], str) # media_title depends on what is available - assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title + assert attrs[mp.ATTR_MEDIA_TITLE] is dmr_device_mock.media_program_title dmr_device_mock.media_program_title = None attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title + assert attrs[mp.ATTR_MEDIA_TITLE] is dmr_device_mock.media_title # media_content_type is mapped from UPnP class to MediaPlayer type dmr_device_mock.media_class = "object.item.audioItem.musicTrack" attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert attrs[mp.ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC dmr_device_mock.media_class = "object.item.videoItem.movie" attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == MediaType.MOVIE + assert attrs[mp.ATTR_MEDIA_CONTENT_TYPE] == MediaType.MOVIE dmr_device_mock.media_class = "object.item.videoItem.videoBroadcast" attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_CONTENT_TYPE] == MediaType.TVSHOW + assert attrs[mp.ATTR_MEDIA_CONTENT_TYPE] == MediaType.TVSHOW # media_season & media_episode have a special case dmr_device_mock.media_season_number = "0" dmr_device_mock.media_episode_number = "123" attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_SEASON] == "1" - assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "23" + assert attrs[mp.ATTR_MEDIA_SEASON] == "1" + assert attrs[mp.ATTR_MEDIA_EPISODE] == "23" dmr_device_mock.media_season_number = "0" dmr_device_mock.media_episode_number = "S1E23" # Unexpected and not parsed attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_SEASON] == "0" - assert attrs[mp_const.ATTR_MEDIA_EPISODE] == "S1E23" + assert attrs[mp.ATTR_MEDIA_SEASON] == "0" + assert attrs[mp.ATTR_MEDIA_EPISODE] == "S1E23" # shuffle and repeat is based on device's play mode - for play_mode, shuffle, repeat in [ + for play_mode, shuffle, repeat in ( (PlayMode.NORMAL, False, RepeatMode.OFF), (PlayMode.SHUFFLE, True, RepeatMode.OFF), (PlayMode.REPEAT_ONE, False, RepeatMode.ONE), @@ -611,16 +603,16 @@ async def test_attributes( (PlayMode.RANDOM, True, RepeatMode.ALL), (PlayMode.DIRECT_1, False, RepeatMode.OFF), (PlayMode.INTRO, False, RepeatMode.OFF), - ]: + ): dmr_device_mock.play_mode = play_mode attrs = await get_attrs(hass, mock_entity_id) - assert attrs[mp_const.ATTR_MEDIA_SHUFFLE] is shuffle - assert attrs[mp_const.ATTR_MEDIA_REPEAT] == repeat - for bad_play_mode in [None, PlayMode.VENDOR_DEFINED]: + assert attrs[mp.ATTR_MEDIA_SHUFFLE] is shuffle + assert attrs[mp.ATTR_MEDIA_REPEAT] == repeat + for bad_play_mode in (None, PlayMode.VENDOR_DEFINED): dmr_device_mock.play_mode = bad_play_mode attrs = await get_attrs(hass, mock_entity_id) - assert mp_const.ATTR_MEDIA_SHUFFLE not in attrs - assert mp_const.ATTR_MEDIA_REPEAT not in attrs + assert mp.ATTR_MEDIA_SHUFFLE not in attrs + assert mp.ATTR_MEDIA_REPEAT not in attrs async def test_services( @@ -629,65 +621,65 @@ async def test_services( """Test service calls of a connected DlnaDmrEntity.""" # Check interface methods interact directly with the device await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, blocking=True, ) dmr_device_mock.async_set_volume_level.assert_awaited_once_with(0.80) await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_VOLUME_MUTE, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_MUTED: True}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True, ) dmr_device_mock.async_mute_volume.assert_awaited_once_with(True) await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: mock_entity_id}, blocking=True, ) dmr_device_mock.async_pause.assert_awaited_once_with() await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: mock_entity_id}, blocking=True, ) dmr_device_mock.async_pause.assert_awaited_once_with() await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: mock_entity_id}, blocking=True, ) dmr_device_mock.async_stop.assert_awaited_once_with() await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: mock_entity_id}, blocking=True, ) dmr_device_mock.async_next.assert_awaited_once_with() await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: mock_entity_id}, blocking=True, ) dmr_device_mock.async_previous.assert_awaited_once_with() await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_MEDIA_SEEK, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SEEK_POSITION: 33}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_SEEK_POSITION: 33}, blocking=True, ) dmr_device_mock.async_seek_rel_time.assert_awaited_once_with(timedelta(seconds=33)) await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_SELECT_SOUND_MODE, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_SOUND_MODE: "Default"}, + mp.DOMAIN, + mp.SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_SOUND_MODE: "Default"}, blocking=True, ) dmr_device_mock.async_select_preset.assert_awaited_once_with("Default") @@ -701,15 +693,15 @@ async def test_play_media_stopped( dmr_device_mock.can_stop = True dmr_device_mock.transport_state = TransportState.STOPPED await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_PLAY_MEDIA, + mp.DOMAIN, + mp.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: ( + mp.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + mp.ATTR_MEDIA_CONTENT_ID: ( "http://198.51.100.20:8200/MediaItems/17621.mp3" ), - mp_const.ATTR_MEDIA_ENQUEUE: False, + mp.ATTR_MEDIA_ENQUEUE: False, }, blocking=True, ) @@ -735,15 +727,15 @@ async def test_play_media_playing( dmr_device_mock.can_stop = False dmr_device_mock.transport_state = TransportState.PLAYING await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_PLAY_MEDIA, + mp.DOMAIN, + mp.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: ( + mp.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + mp.ATTR_MEDIA_CONTENT_ID: ( "http://198.51.100.20:8200/MediaItems/17621.mp3" ), - mp_const.ATTR_MEDIA_ENQUEUE: False, + mp.ATTR_MEDIA_ENQUEUE: False, }, blocking=True, ) @@ -770,16 +762,16 @@ async def test_play_media_no_autoplay( dmr_device_mock.can_stop = True dmr_device_mock.transport_state = TransportState.STOPPED await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_PLAY_MEDIA, + mp.DOMAIN, + mp.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: ( + mp.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + mp.ATTR_MEDIA_CONTENT_ID: ( "http://198.51.100.20:8200/MediaItems/17621.mp3" ), - mp_const.ATTR_MEDIA_ENQUEUE: False, - mp_const.ATTR_MEDIA_EXTRA: {"autoplay": False}, + mp.ATTR_MEDIA_ENQUEUE: False, + mp.ATTR_MEDIA_EXTRA: {"autoplay": False}, }, blocking=True, ) @@ -803,16 +795,16 @@ async def test_play_media_metadata( ) -> None: """Test play_media constructs useful metadata from user params.""" await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_PLAY_MEDIA, + mp.DOMAIN, + mp.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: ( + mp.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + mp.ATTR_MEDIA_CONTENT_ID: ( "http://198.51.100.20:8200/MediaItems/17621.mp3" ), - mp_const.ATTR_MEDIA_ENQUEUE: False, - mp_const.ATTR_MEDIA_EXTRA: { + mp.ATTR_MEDIA_ENQUEUE: False, + mp.ATTR_MEDIA_EXTRA: { "title": "Mock song", "thumb": "http://198.51.100.20:8200/MediaItems/17621.jpg", "metadata": {"artist": "Mock artist", "album": "Mock album"}, @@ -835,16 +827,14 @@ async def test_play_media_metadata( # Check again for a different media type dmr_device_mock.construct_play_media_metadata.reset_mock() await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_PLAY_MEDIA, + mp.DOMAIN, + mp.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.TVSHOW, - mp_const.ATTR_MEDIA_CONTENT_ID: ( - "http://198.51.100.20:8200/MediaItems/123.mkv" - ), - mp_const.ATTR_MEDIA_ENQUEUE: False, - mp_const.ATTR_MEDIA_EXTRA: { + mp.ATTR_MEDIA_CONTENT_TYPE: MediaType.TVSHOW, + mp.ATTR_MEDIA_CONTENT_ID: ("http://198.51.100.20:8200/MediaItems/123.mkv"), + mp.ATTR_MEDIA_ENQUEUE: False, + mp.ATTR_MEDIA_EXTRA: { "title": "Mock show", "metadata": {"season": 1, "episode": 12}, }, @@ -870,12 +860,12 @@ async def test_play_media_local_source( await hass.async_block_till_done() await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_PLAY_MEDIA, + mp.DOMAIN, + mp.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4", - mp_const.ATTR_MEDIA_CONTENT_ID: ( + mp.ATTR_MEDIA_CONTENT_TYPE: "video/mp4", + mp.ATTR_MEDIA_CONTENT_ID: ( "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4" ), }, @@ -927,12 +917,12 @@ async def test_play_media_didl_metadata( return_value=play_media, ): await hass.services.async_call( - MP_DOMAIN, - mp_const.SERVICE_PLAY_MEDIA, + mp.DOMAIN, + mp.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4", - mp_const.ATTR_MEDIA_CONTENT_ID: ( + mp.ATTR_MEDIA_CONTENT_TYPE: "video/mp4", + mp.ATTR_MEDIA_CONTENT_ID: ( "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4" ), }, @@ -954,7 +944,7 @@ async def test_shuffle_repeat_modes( """Test setting repeat and shuffle modes.""" # Test shuffle with all variations of existing play mode dmr_device_mock.valid_play_modes = {mode.value for mode in PlayMode} - for init_mode, shuffle_set, expect_mode in [ + for init_mode, shuffle_set, expect_mode in ( (PlayMode.NORMAL, False, PlayMode.NORMAL), (PlayMode.SHUFFLE, False, PlayMode.NORMAL), (PlayMode.REPEAT_ONE, False, PlayMode.REPEAT_ONE), @@ -965,18 +955,18 @@ async def test_shuffle_repeat_modes( (PlayMode.REPEAT_ONE, True, PlayMode.RANDOM), (PlayMode.REPEAT_ALL, True, PlayMode.RANDOM), (PlayMode.RANDOM, True, PlayMode.RANDOM), - ]: + ): dmr_device_mock.play_mode = init_mode await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_SHUFFLE_SET, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SHUFFLE: shuffle_set}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_SHUFFLE: shuffle_set}, blocking=True, ) dmr_device_mock.async_set_play_mode.assert_awaited_with(expect_mode) # Test repeat with all variations of existing play mode - for init_mode, repeat_set, expect_mode in [ + for init_mode, repeat_set, expect_mode in ( (PlayMode.NORMAL, RepeatMode.OFF, PlayMode.NORMAL), (PlayMode.SHUFFLE, RepeatMode.OFF, PlayMode.SHUFFLE), (PlayMode.REPEAT_ONE, RepeatMode.OFF, PlayMode.NORMAL), @@ -992,12 +982,12 @@ async def test_shuffle_repeat_modes( (PlayMode.REPEAT_ONE, RepeatMode.ALL, PlayMode.REPEAT_ALL), (PlayMode.REPEAT_ALL, RepeatMode.ALL, PlayMode.REPEAT_ALL), (PlayMode.RANDOM, RepeatMode.ALL, PlayMode.RANDOM), - ]: + ): dmr_device_mock.play_mode = init_mode await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_REPEAT_SET, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_REPEAT: repeat_set}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_REPEAT: repeat_set}, blocking=True, ) dmr_device_mock.async_set_play_mode.assert_awaited_with(expect_mode) @@ -1009,9 +999,9 @@ async def test_shuffle_repeat_modes( dmr_device_mock.valid_play_modes = {PlayMode.SHUFFLE, PlayMode.RANDOM} await get_attrs(hass, mock_entity_id) await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_SHUFFLE_SET, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_SHUFFLE: False}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_SHUFFLE: False}, blocking=True, ) dmr_device_mock.async_set_play_mode.assert_not_awaited() @@ -1023,11 +1013,11 @@ async def test_shuffle_repeat_modes( dmr_device_mock.valid_play_modes = {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL} await get_attrs(hass, mock_entity_id) await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_REPEAT_SET, { ATTR_ENTITY_ID: mock_entity_id, - mp_const.ATTR_MEDIA_REPEAT: RepeatMode.OFF, + mp.ATTR_MEDIA_REPEAT: RepeatMode.OFF, }, blocking=True, ) @@ -1105,7 +1095,7 @@ async def test_browse_media( assert expected_child_audio in response["result"]["children"] # Device specifies extra parameters in MIME type, uses non-standard "x-" - # prefix, and capitilizes things, all of which should be ignored + # prefix, and capitalizes things, all of which should be ignored dmr_device_mock.sink_protocol_info = [ "http-get:*:audio/X-MPEG;codecs=mp3:*", ] @@ -1265,6 +1255,7 @@ async def test_playback_update_state( ) async def test_unavailable_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, domain_data_mock: Mock, ssdp_scanner_mock: Mock, config_entry_mock: MockConfigEntry, @@ -1322,49 +1313,48 @@ async def test_unavailable_device( # Check attributes are unavailable attrs = mock_state.attributes - for attr in ATTR_TO_PROPERTY: + for attr in mp.ATTR_TO_PROPERTY: assert attr not in attrs assert attrs[ha_const.ATTR_FRIENDLY_NAME] == MOCK_DEVICE_NAME assert attrs[ha_const.ATTR_SUPPORTED_FEATURES] == 0 - assert mp_const.ATTR_SOUND_MODE_LIST not in attrs + assert mp.ATTR_SOUND_MODE_LIST not in attrs # Check service calls do nothing SERVICES: list[tuple[str, dict]] = [ - (ha_const.SERVICE_VOLUME_SET, {mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}), - (ha_const.SERVICE_VOLUME_MUTE, {mp_const.ATTR_MEDIA_VOLUME_MUTED: True}), + (ha_const.SERVICE_VOLUME_SET, {mp.ATTR_MEDIA_VOLUME_LEVEL: 0.80}), + (ha_const.SERVICE_VOLUME_MUTE, {mp.ATTR_MEDIA_VOLUME_MUTED: True}), (ha_const.SERVICE_MEDIA_PAUSE, {}), (ha_const.SERVICE_MEDIA_PLAY, {}), (ha_const.SERVICE_MEDIA_STOP, {}), (ha_const.SERVICE_MEDIA_NEXT_TRACK, {}), (ha_const.SERVICE_MEDIA_PREVIOUS_TRACK, {}), - (ha_const.SERVICE_MEDIA_SEEK, {mp_const.ATTR_MEDIA_SEEK_POSITION: 33}), + (ha_const.SERVICE_MEDIA_SEEK, {mp.ATTR_MEDIA_SEEK_POSITION: 33}), ( - mp_const.SERVICE_PLAY_MEDIA, + mp.SERVICE_PLAY_MEDIA, { - mp_const.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - mp_const.ATTR_MEDIA_CONTENT_ID: ( + mp.ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + mp.ATTR_MEDIA_CONTENT_ID: ( "http://198.51.100.20:8200/MediaItems/17621.mp3" ), - mp_const.ATTR_MEDIA_ENQUEUE: False, + mp.ATTR_MEDIA_ENQUEUE: False, }, ), - (mp_const.SERVICE_SELECT_SOUND_MODE, {mp_const.ATTR_SOUND_MODE: "Default"}), - (ha_const.SERVICE_SHUFFLE_SET, {mp_const.ATTR_MEDIA_SHUFFLE: True}), - (ha_const.SERVICE_REPEAT_SET, {mp_const.ATTR_MEDIA_REPEAT: "all"}), + (mp.SERVICE_SELECT_SOUND_MODE, {mp.ATTR_SOUND_MODE: "Default"}), + (ha_const.SERVICE_SHUFFLE_SET, {mp.ATTR_MEDIA_SHUFFLE: True}), + (ha_const.SERVICE_REPEAT_SET, {mp.ATTR_MEDIA_REPEAT: "all"}), ] for service, data in SERVICES: await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, service, {ATTR_ENTITY_ID: mock_entity_id, **data}, blocking=True, ) # Check hass device information has not been filled in yet - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None @@ -1392,6 +1382,7 @@ async def test_unavailable_device( ) async def test_become_available( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, domain_data_mock: Mock, ssdp_scanner_mock: Mock, config_entry_mock: MockConfigEntry, @@ -1409,9 +1400,8 @@ async def test_become_available( assert mock_state.state == ha_const.STATE_UNAVAILABLE # Check hass device information has not been filled in yet - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None @@ -1451,9 +1441,8 @@ async def test_become_available( assert mock_state is not None assert mock_state.state == MediaPlayerState.IDLE # Check hass device information is now filled in - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None @@ -1980,9 +1969,9 @@ async def test_become_unavailable( # Interface service calls should flag that the device is unavailable, but # not disconnect it immediately await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, blocking=True, ) @@ -2003,9 +1992,9 @@ async def test_become_unavailable( dmr_device_mock.async_update.side_effect = UpnpConnectionError await hass.services.async_call( - MP_DOMAIN, + mp.DOMAIN, ha_const.SERVICE_VOLUME_SET, - {ATTR_ENTITY_ID: mock_entity_id, mp_const.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, + {ATTR_ENTITY_ID: mock_entity_id, mp.ATTR_MEDIA_VOLUME_LEVEL: 0.80}, blocking=True, ) await async_update_entity(hass, mock_entity_id) @@ -2083,10 +2072,10 @@ async def test_disappearing_device( directly to skip the availability check. """ # Retrieve entity directly. - entity: DlnaDmrEntity = hass.data[MP_DOMAIN].get_entity(mock_disconnected_entity_id) + entity: DlnaDmrEntity = hass.data[mp.DOMAIN].get_entity(mock_disconnected_entity_id) # Test attribute access - for attr in ATTR_TO_PROPERTY: + for attr in mp.ATTR_TO_PROPERTY: value = getattr(entity, attr) assert value is None @@ -2286,6 +2275,7 @@ async def test_config_update_poll_availability( async def test_config_update_mac_address( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, domain_data_mock: Mock, config_entry_mock_no_mac: MockConfigEntry, ssdp_scanner_mock: Mock, @@ -2298,13 +2288,12 @@ async def test_config_update_mac_address( domain_data_mock.upnp_factory.async_create_device.reset_mock() # Check the device registry connections does not include the MAC address - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None - assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections + assert (dr.CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections # MAC address discovered and set by config flow hass.config_entries.async_update_entry( @@ -2319,12 +2308,12 @@ async def test_config_update_mac_address( await hass.async_block_till_done() # Device registry connections should now include the MAC address - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None - assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections + assert (dr.CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections @pytest.mark.parametrize( @@ -2333,6 +2322,8 @@ async def test_config_update_mac_address( ) async def test_connections_restored( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, domain_data_mock: Mock, ssdp_scanner_mock: Mock, config_entry_mock: MockConfigEntry, @@ -2350,9 +2341,8 @@ async def test_connections_restored( assert mock_state.state == ha_const.STATE_UNAVAILABLE # Check hass device information has not been filled in yet - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None @@ -2392,9 +2382,8 @@ async def test_connections_restored( assert mock_state is not None assert mock_state.state == MediaPlayerState.IDLE # Check hass device information is now filled in - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None @@ -2415,17 +2404,15 @@ async def test_connections_restored( dmr_device_mock.async_unsubscribe_services.assert_awaited_once() # Check hass device information has not been filled in yet - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device( - connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + device = device_registry.async_get_device( + connections={(dr.CONNECTION_UPNP, MOCK_DEVICE_UDN)}, identifiers=set(), ) assert device is not None assert device.connections == previous_connections # Verify the entity remains linked to the device - ent_reg = async_get_er(hass) - entry = ent_reg.async_get(mock_entity_id) + entry = entity_registry.async_get(mock_entity_id) assert entry is not None assert entry.device_id == device.id @@ -2440,6 +2427,8 @@ async def test_connections_restored( async def test_udn_upnp_connection_added_if_missing( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, domain_data_mock: Mock, ssdp_scanner_mock: Mock, config_entry_mock: MockConfigEntry, @@ -2454,23 +2443,21 @@ async def test_udn_upnp_connection_added_if_missing( config_entry_mock.add_to_hass(hass) # Cause connection attempts to fail before adding entity - ent_reg = async_get_er(hass) - entry = ent_reg.async_get_or_create( - MP_DOMAIN, + entry = entity_registry.async_get_or_create( + mp.DOMAIN, DOMAIN, MOCK_DEVICE_UDN, config_entry=config_entry_mock, ) mock_entity_id = entry.entity_id - dev_reg = async_get_dr(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry_mock.entry_id, - connections={(CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS)}, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS)}, identifiers=set(), ) - ent_reg.async_update_entity(mock_entity_id, device_id=device.id) + entity_registry.async_update_entity(mock_entity_id, device_id=device.id) domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpConnectionError assert await hass.config_entries.async_setup(config_entry_mock.entry_id) is True @@ -2481,7 +2468,6 @@ async def test_udn_upnp_connection_added_if_missing( assert mock_state.state == ha_const.STATE_UNAVAILABLE # Check hass device information has not been filled in yet - dev_reg = async_get_dr(hass) - device = dev_reg.async_get(device.id) + device = device_registry.async_get(device.id) assert device is not None - assert (CONNECTION_UPNP, MOCK_DEVICE_UDN) in device.connections + assert (dr.CONNECTION_UPNP, MOCK_DEVICE_UDN) in device.connections diff --git a/tests/components/dlna_dms/conftest.py b/tests/components/dlna_dms/conftest.py index c1bee224c5a..1fa56f4bc24 100644 --- a/tests/components/dlna_dms/conftest.py +++ b/tests/components/dlna_dms/conftest.py @@ -38,7 +38,7 @@ NEW_DEVICE_LOCATION: Final = "http://192.88.99.7" + "/dmr_description.xml" @pytest.fixture -async def setup_media_source(hass) -> None: +async def setup_media_source(hass: HomeAssistant) -> None: """Set up media source.""" assert await async_setup_component(hass, "media_source", {}) diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index bb3c9230534..23d9e6927ae 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -38,7 +38,7 @@ pytestmark = [ ] -BrowseResultList = list[didl_lite.DidlObject | didl_lite.Descriptor] +type BrowseResultList = list[didl_lite.DidlObject | didl_lite.Descriptor] async def async_resolve_media( diff --git a/tests/components/dnsip/__init__.py b/tests/components/dnsip/__init__.py index d98de181892..a0e6b7c81b8 100644 --- a/tests/components/dnsip/__init__.py +++ b/tests/components/dnsip/__init__.py @@ -6,8 +6,10 @@ from __future__ import annotations class QueryResult: """Return Query results.""" - host = "1.2.3.4" - ttl = 60 + def __init__(self, ip="1.2.3.4", ttl=60) -> None: + """Initialize QueryResult class.""" + self.host = ip + self.ttl = ttl class RetrieveDNS: @@ -22,11 +24,20 @@ class RetrieveDNS: self._nameservers = ["1.2.3.4"] self.error = error - async def query(self, hostname, qtype) -> dict[str, str]: + async def query(self, hostname, qtype) -> list[QueryResult]: """Return information.""" if self.error: raise self.error - return [QueryResult] + if qtype == "AAAA": + results = [ + QueryResult("2001:db8:77::face:b00c"), + QueryResult("2001:db8:77::dead:beef"), + QueryResult("2001:db8::77:dead:beef"), + QueryResult("2001:db8:66::dead:beef"), + ] + else: + results = [QueryResult("1.2.3.4"), QueryResult("1.1.1.1")] + return results @property def nameservers(self) -> list[str]: diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index ff089be0e1e..99dc5781d16 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -13,12 +13,13 @@ from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, + CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -66,6 +67,8 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["options"] == { "resolver": "208.67.222.222", "resolver_ipv6": "2620:119:53::53", + "port": 53, + "port_ipv6": 53, } assert len(mock_setup_entry.mock_calls) == 1 @@ -96,6 +99,8 @@ async def test_form_adv(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_RESOLVER: "8.8.8.8", CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, ) await hass.async_block_till_done() @@ -111,6 +116,8 @@ async def test_form_adv(hass: HomeAssistant) -> None: assert result2["options"] == { "resolver": "8.8.8.8", "resolver_ipv6": "2620:119:53::53", + "port": 53, + "port_ipv6": 53, } assert len(mock_setup_entry.mock_calls) == 1 @@ -152,6 +159,8 @@ async def test_flow_already_exist(hass: HomeAssistant) -> None: options={ CONF_RESOLVER: "208.67.222.222", CONF_RESOLVER_IPV6: "2620:119:53::5", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, unique_id="home-assistant.io", ).add_to_hass(hass) @@ -197,6 +206,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: options={ CONF_RESOLVER: "208.67.222.222", CONF_RESOLVER_IPV6: "2620:119:53::5", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, ) entry.add_to_hass(hass) @@ -218,6 +229,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: user_input={ CONF_RESOLVER: "8.8.8.8", CONF_RESOLVER_IPV6: "2001:4860:4860::8888", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, ) await hass.async_block_till_done() @@ -226,6 +239,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == { "resolver": "8.8.8.8", "resolver_ipv6": "2001:4860:4860::8888", + "port": 53, + "port_ipv6": 53, } assert entry.state is ConfigEntryState.LOADED @@ -245,6 +260,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None: options={ CONF_RESOLVER: "8.8.8.8", CONF_RESOLVER_IPV6: "2620:119:53::1", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, ) entry.add_to_hass(hass) @@ -271,6 +288,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None: assert result["data"] == { "resolver": "208.67.222.222", "resolver_ipv6": "2620:119:53::53", + "port": 53, + "port_ipv6": 53, } entry = hass.config_entries.async_get_entry(entry.entry_id) @@ -283,6 +302,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None: assert entry.options == { "resolver": "208.67.222.222", "resolver_ipv6": "2620:119:53::53", + "port": 53, + "port_ipv6": 53, } @@ -294,6 +315,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None: CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", CONF_RESOLVER_IPV6: "2620:119:53::5", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, CONF_IPV4: True, CONF_IPV6: False, }, @@ -302,6 +325,8 @@ async def test_options_flow_empty_return(hass: HomeAssistant) -> None: CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", CONF_RESOLVER_IPV6: "2620:119:53::5", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, CONF_IPV4: False, CONF_IPV6: True, }, @@ -334,6 +359,8 @@ async def test_options_error(hass: HomeAssistant, p_input: dict[str, str]) -> No { CONF_RESOLVER: "192.168.200.34", CONF_RESOLVER_IPV6: "2001:4860:4860::8888", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, ) await hass.async_block_till_done() diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 3d816bebe60..ac5da227bde 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -8,12 +8,14 @@ from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, + CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, + DEFAULT_PORT, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from . import RetrieveDNS @@ -35,6 +37,8 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: options={ CONF_RESOLVER: "208.67.222.222", CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, entry_id="1", unique_id="home-assistant.io", @@ -52,3 +56,77 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: 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_port_migration( + hass: HomeAssistant, +) -> None: + """Test migration of the config entry from no ports to with ports.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: True, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + }, + entry_id="1", + unique_id="home-assistant.io", + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.options[CONF_PORT] == DEFAULT_PORT + assert entry.options[CONF_PORT_IPV6] == DEFAULT_PORT + assert entry.state is ConfigEntryState.LOADED + + +async def test_migrate_error_from_future(hass: HomeAssistant) -> None: + """Test a future version isn't migrated.""" + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: True, + "some_new_data": "new_value", + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + }, + entry_id="1", + unique_id="home-assistant.io", + version=2, + minor_version=1, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index e1353d83268..66cb5cc6ad9 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -12,13 +12,14 @@ from homeassistant.components.dnsip.const import ( CONF_HOSTNAME, CONF_IPV4, CONF_IPV6, + CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, DOMAIN, ) from homeassistant.components.dnsip.sensor import SCAN_INTERVAL from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE +from homeassistant.const import CONF_NAME, CONF_PORT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from . import RetrieveDNS @@ -40,6 +41,8 @@ async def test_sensor(hass: HomeAssistant) -> None: options={ CONF_RESOLVER: "208.67.222.222", CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, entry_id="1", unique_id="home-assistant.io", @@ -56,8 +59,58 @@ async def test_sensor(hass: HomeAssistant) -> None: state1 = hass.states.get("sensor.home_assistant_io") state2 = hass.states.get("sensor.home_assistant_io_ipv6") - assert state1.state == "1.2.3.4" - assert state2.state == "1.2.3.4" + assert state1.state == "1.1.1.1" + assert state1.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] + assert state2.state == "2001:db8::77:dead:beef" + assert state2.attributes["ip_addresses"] == [ + "2001:db8::77:dead:beef", + "2001:db8:66::dead:beef", + "2001:db8:77::dead:beef", + "2001:db8:77::face:b00c", + ] + + +async def test_legacy_sensor(hass: HomeAssistant) -> None: + """Test the DNS IP sensor configured before the addition of ports.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_HOSTNAME: "home-assistant.io", + CONF_NAME: "home-assistant.io", + CONF_IPV4: True, + CONF_IPV6: True, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::53", + }, + entry_id="1", + unique_id="home-assistant.io", + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.sensor.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state1 = hass.states.get("sensor.home_assistant_io") + state2 = hass.states.get("sensor.home_assistant_io_ipv6") + + assert state1.state == "1.1.1.1" + assert state1.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] + assert state2.state == "2001:db8::77:dead:beef" + assert state2.attributes["ip_addresses"] == [ + "2001:db8::77:dead:beef", + "2001:db8:66::dead:beef", + "2001:db8:77::dead:beef", + "2001:db8:77::face:b00c", + ] async def test_sensor_no_response( @@ -76,6 +129,8 @@ async def test_sensor_no_response( options={ CONF_RESOLVER: "208.67.222.222", CONF_RESOLVER_IPV6: "2620:119:53::53", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, }, entry_id="1", unique_id="home-assistant.io", @@ -92,7 +147,7 @@ async def test_sensor_no_response( state = hass.states.get("sensor.home_assistant_io") - assert state.state == "1.2.3.4" + assert state.state == "1.1.1.1" dns_mock.error = DNSError() with patch( @@ -107,7 +162,8 @@ async def test_sensor_no_response( # Allows 2 retries before going unavailable state = hass.states.get("sensor.home_assistant_io") - assert state.state == "1.2.3.4" + assert state.state == "1.1.1.1" + assert state.attributes["ip_addresses"] == ["1.1.1.1", "1.2.3.4"] freezer.tick(timedelta(seconds=SCAN_INTERVAL.seconds)) async_fire_time_changed(hass) diff --git a/tests/components/dormakaba_dkey/conftest.py b/tests/components/dormakaba_dkey/conftest.py index d911739943f..1530cb82e33 100644 --- a/tests/components/dormakaba_dkey/conftest.py +++ b/tests/components/dormakaba_dkey/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/dremel_3d_printer/conftest.py b/tests/components/dremel_3d_printer/conftest.py index 0284d8baebf..6490b844dc0 100644 --- a/tests/components/dremel_3d_printer/conftest.py +++ b/tests/components/dremel_3d_printer/conftest.py @@ -32,23 +32,23 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture def connection() -> None: """Mock Dremel 3D Printer connection.""" - mock = requests_mock.Mocker() - mock.post( - f"http://{HOST}:80/command", - response_list=[ - {"text": load_fixture("dremel_3d_printer/command_1.json")}, - {"text": load_fixture("dremel_3d_printer/command_2.json")}, - {"text": load_fixture("dremel_3d_printer/command_1.json")}, - {"text": load_fixture("dremel_3d_printer/command_2.json")}, - ], - ) + with requests_mock.Mocker() as mock: + mock.post( + f"http://{HOST}:80/command", + response_list=[ + {"text": load_fixture("dremel_3d_printer/command_1.json")}, + {"text": load_fixture("dremel_3d_printer/command_2.json")}, + {"text": load_fixture("dremel_3d_printer/command_1.json")}, + {"text": load_fixture("dremel_3d_printer/command_2.json")}, + ], + ) - mock.post( - f"https://{HOST}:11134/getHomeMessage", - text=load_fixture("dremel_3d_printer/get_home_message.json"), - status_code=HTTPStatus.OK, - ) - mock.start() + mock.post( + f"https://{HOST}:11134/getHomeMessage", + text=load_fixture("dremel_3d_printer/get_home_message.json"), + status_code=HTTPStatus.OK, + ) + yield def patch_async_setup_entry(): diff --git a/tests/components/dremel_3d_printer/test_binary_sensor.py b/tests/components/dremel_3d_printer/test_binary_sensor.py index 6581b6ff13d..e430d93b585 100644 --- a/tests/components/dremel_3d_printer/test_binary_sensor.py +++ b/tests/components/dremel_3d_printer/test_binary_sensor.py @@ -1,6 +1,6 @@ """Binary sensor tests for the Dremel 3D Printer integration.""" -from unittest.mock import AsyncMock +import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.dremel_3d_printer.const import DOMAIN @@ -11,11 +11,9 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +@pytest.mark.usefixtures("connection", "entity_registry_enabled_by_default") async def test_binary_sensors( - hass: HomeAssistant, - connection, - config_entry: MockConfigEntry, - entity_registry_enabled_by_default: AsyncMock, + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test we get binary sensor data.""" await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/dremel_3d_printer/test_button.py b/tests/components/dremel_3d_printer/test_button.py index 48b39b09cf1..d2d63bb6a25 100644 --- a/tests/components/dremel_3d_printer/test_button.py +++ b/tests/components/dremel_3d_printer/test_button.py @@ -1,6 +1,6 @@ """Button tests for the Dremel 3D Printer integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -22,11 +22,10 @@ from tests.common import MockConfigEntry ("resume", "resume"), ], ) +@pytest.mark.usefixtures("connection", "entity_registry_enabled_by_default") async def test_buttons( hass: HomeAssistant, - connection: None, config_entry: MockConfigEntry, - entity_registry_enabled_by_default: AsyncMock, button: str, function: str, ) -> None: diff --git a/tests/components/dremel_3d_printer/test_sensor.py b/tests/components/dremel_3d_printer/test_sensor.py index c1e3a9bc14b..74a4fc32f09 100644 --- a/tests/components/dremel_3d_printer/test_sensor.py +++ b/tests/components/dremel_3d_printer/test_sensor.py @@ -1,9 +1,9 @@ """Sensor tests for the Dremel 3D Printer integration.""" from datetime import datetime -from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.dremel_3d_printer.const import DOMAIN from homeassistant.components.sensor import ( @@ -26,11 +26,10 @@ from homeassistant.util.dt import UTC from tests.common import MockConfigEntry +@pytest.mark.usefixtures("connection", "entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, - connection, config_entry: MockConfigEntry, - entity_registry_enabled_by_default: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: """Test we get sensor data.""" diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 791797f7dcd..711b29f4ae0 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -519,16 +519,17 @@ def test_get_serial_by_id_no_dir() -> None: def test_get_serial_by_id() -> None: """Test serial by id conversion.""" - p1 = patch("os.path.isdir", MagicMock(return_value=True)) - p2 = patch("os.scandir") def _realpath(path): if path is sentinel.matched_link: return sentinel.path return sentinel.serial_link_path - p3 = patch("os.path.realpath", side_effect=_realpath) - with p1 as is_dir_mock, p2 as scan_mock, p3: + with ( + patch("os.path.isdir", MagicMock(return_value=True)) as is_dir_mock, + patch("os.scandir") as scan_mock, + patch("os.path.realpath", side_effect=_realpath), + ): res = config_flow.get_serial_by_id(sentinel.path) assert res is sentinel.path assert is_dir_mock.call_count == 1 diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 429128c48bb..284a0001b89 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -3,6 +3,13 @@ import datetime from decimal import Decimal +from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, +) +from dsmr_parser.objects import CosemObject, MBusObject + from homeassistant.components.dsmr.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -20,13 +27,6 @@ async def test_migrate_gas_to_mbus( """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_DEVICE_TYPE, - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS1_METER_READING2, - ) - from dsmr_parser.objects import CosemObject, MBusObject - mock_entry = MockConfigEntry( domain=DOMAIN, unique_id="/dev/ttyUSB0", @@ -118,13 +118,6 @@ async def test_migrate_gas_to_mbus_exists( """Test migration of unique_id.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_DEVICE_TYPE, - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS1_METER_READING2, - ) - from dsmr_parser.objects import CosemObject, MBusObject - mock_entry = MockConfigEntry( domain=DOMAIN, unique_id="/dev/ttyUSB0", diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 7a38e3010d8..e014fdb68f2 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -11,6 +11,33 @@ from decimal import Decimal from itertools import chain, repeat from unittest.mock import DEFAULT, MagicMock +from dsmr_parser.obis_references import ( + BELGIUM_CURRENT_AVERAGE_DEMAND, + BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS1_METER_READING2, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING1, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING1, + BELGIUM_MBUS4_METER_READING2, + CURRENT_ELECTRICITY_USAGE, + ELECTRICITY_ACTIVE_TARIFF, + ELECTRICITY_EXPORTED_TOTAL, + ELECTRICITY_IMPORTED_TOTAL, + GAS_METER_READING, + HOURLY_GAS_METER_READING, +) +from dsmr_parser.objects import CosemObject, MBusObject import pytest from homeassistant.components.sensor import ( @@ -41,13 +68,6 @@ async def test_default_setup( """Test the default setup.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - CURRENT_ELECTRICITY_USAGE, - ELECTRICITY_ACTIVE_TARIFF, - GAS_METER_READING, - ) - from dsmr_parser.objects import CosemObject, MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", @@ -176,12 +196,6 @@ async def test_setup_only_energy( """Test the default setup.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - CURRENT_ELECTRICITY_USAGE, - ELECTRICITY_ACTIVE_TARIFF, - ) - from dsmr_parser.objects import CosemObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", @@ -230,12 +244,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if v4 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - ELECTRICITY_ACTIVE_TARIFF, - HOURLY_GAS_METER_READING, - ) - from dsmr_parser.objects import CosemObject, MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "4", @@ -316,12 +324,6 @@ async def test_v5_meter( """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - ELECTRICITY_ACTIVE_TARIFF, - HOURLY_GAS_METER_READING, - ) - from dsmr_parser.objects import CosemObject, MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5", @@ -388,13 +390,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - ELECTRICITY_EXPORTED_TOTAL, - ELECTRICITY_IMPORTED_TOTAL, - HOURLY_GAS_METER_READING, - ) - from dsmr_parser.objects import CosemObject, MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5L", @@ -477,25 +472,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - BELGIUM_CURRENT_AVERAGE_DEMAND, - BELGIUM_MAXIMUM_DEMAND_MONTH, - BELGIUM_MBUS1_DEVICE_TYPE, - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_DEVICE_TYPE, - BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_DEVICE_TYPE, - BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_DEVICE_TYPE, - BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS4_METER_READING1, - ELECTRICITY_ACTIVE_TARIFF, - ) - from dsmr_parser.objects import CosemObject, MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", @@ -679,22 +655,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_DEVICE_TYPE, - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS2_DEVICE_TYPE, - BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_DEVICE_TYPE, - BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS3_METER_READING1, - BELGIUM_MBUS4_DEVICE_TYPE, - BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS4_METER_READING2, - ) - from dsmr_parser.objects import CosemObject, MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", @@ -842,20 +802,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_DEVICE_TYPE, - BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS2_DEVICE_TYPE, - BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS3_DEVICE_TYPE, - BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, - BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_DEVICE_TYPE, - BELGIUM_MBUS4_METER_READING1, - ELECTRICITY_ACTIVE_TARIFF, - ) - from dsmr_parser.objects import CosemObject, MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", @@ -963,9 +909,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ELECTRICITY_ACTIVE_TARIFF - from dsmr_parser.objects import CosemObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", @@ -1012,12 +955,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - ELECTRICITY_EXPORTED_TOTAL, - ELECTRICITY_IMPORTED_TOTAL, - ) - from dsmr_parser.objects import CosemObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5S", @@ -1084,12 +1021,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Q3D meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - ELECTRICITY_EXPORTED_TOTAL, - ELECTRICITY_IMPORTED_TOTAL, - ) - from dsmr_parser.objects import CosemObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", @@ -1248,11 +1179,6 @@ async def test_connection_errors_retry( @patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" - from dsmr_parser.obis_references import ( - CURRENT_ELECTRICITY_USAGE, - ELECTRICITY_ACTIVE_TARIFF, - ) - from dsmr_parser.objects import CosemObject (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -1334,9 +1260,6 @@ async def test_gas_meter_providing_energy_reading( """Test that gas providing energy readings use the correct device class.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import GAS_METER_READING - from dsmr_parser.objects import MBusObject - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py new file mode 100644 index 00000000000..2ddd8395e78 --- /dev/null +++ b/tests/components/dsmr_reader/test_definitions.py @@ -0,0 +1,109 @@ +"""Test the DSMR Reader definitions.""" + +import pytest + +from homeassistant.components.dsmr_reader.const import DOMAIN +from homeassistant.components.dsmr_reader.definitions import ( + DSMRReaderSensorEntityDescription, + dsmr_transform, + tariff_transform, +) +from homeassistant.components.dsmr_reader.sensor import DSMRSensor +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_mqtt_message + + +@pytest.mark.parametrize( + ("input", "expected"), + [ + ("20", 2.0), + ("version 5", "version 5"), + ], +) +async def test_dsmr_transform(input, expected) -> None: + """Test the dsmr_transform function.""" + assert dsmr_transform(input) == expected + + +@pytest.mark.parametrize( + ("input", "expected"), + [ + ("1", "low"), + ("0", "high"), + ], +) +async def test_tariff_transform(input, expected) -> None: + """Test the tariff_transform function.""" + assert tariff_transform(input) == expected + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_entity_tariff(hass: HomeAssistant) -> None: + """Test the state attribute of DSMRReaderSensorEntityDescription when a tariff transform is needed.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + options={}, + entry_id="TEST_ENTRY_ID", + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test if the payload is empty + async_fire_mqtt_message(hass, "dsmr/meter-stats/electricity_tariff", "") + await hass.async_block_till_done() + + electricity_tariff = "sensor.dsmr_meter_stats_electricity_tariff" + assert hass.states.get(electricity_tariff).state == STATE_UNKNOWN + + # Test high tariff + async_fire_mqtt_message(hass, "dsmr/meter-stats/electricity_tariff", "0") + await hass.async_block_till_done() + assert hass.states.get(electricity_tariff).state == "high" + + # Test low tariff + async_fire_mqtt_message(hass, "dsmr/meter-stats/electricity_tariff", "1") + await hass.async_block_till_done() + assert hass.states.get(electricity_tariff).state == "low" + + +@pytest.mark.usefixtures("mqtt_mock") +async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: + """Test the state attribute of DSMRReaderSensorEntityDescription when a dsmr transform is needed.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + options={}, + entry_id="TEST_ENTRY_ID", + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Create the entity, since it's not by default + description = DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/dsmr_version", + name="version_test", + state=dsmr_transform, + ) + sensor = DSMRSensor(description, config_entry) + sensor.hass = hass + await sensor.async_added_to_hass() + + # Test dsmr version, if it's a digit + async_fire_mqtt_message(hass, "dsmr/meter-stats/dsmr_version", "42") + await hass.async_block_till_done() + + dsmr_version = "sensor.dsmr_meter_stats_dsmr_version" + assert hass.states.get(dsmr_version).state == "4.2" + + # Test dsmr version, if it's not a digit + async_fire_mqtt_message(hass, "dsmr/meter-stats/dsmr_version", "version 5") + await hass.async_block_till_done() + + assert hass.states.get(dsmr_version).state == "version 5" diff --git a/tests/components/dsmr_reader/test_sensor.py b/tests/components/dsmr_reader/test_sensor.py new file mode 100644 index 00000000000..5e4ffcba5c6 --- /dev/null +++ b/tests/components/dsmr_reader/test_sensor.py @@ -0,0 +1,66 @@ +"""Tests for DSMR Reader sensor.""" + +from homeassistant.components.dsmr_reader.const import DOMAIN +from homeassistant.components.dsmr_reader.definitions import ( + DSMRReaderSensorEntityDescription, +) +from homeassistant.components.dsmr_reader.sensor import DSMRSensor +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_dsmr_sensor_mqtt( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, +) -> None: + """Test the DSMRSensor class, via an emluated MQTT message.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + options={}, + entry_id="TEST_ENTRY_ID", + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + electricity_delivered_1 = "sensor.dsmr_reading_electricity_delivered_1" + assert hass.states.get(electricity_delivered_1).state == STATE_UNKNOWN + + electricity_delivered_2 = "sensor.dsmr_reading_electricity_delivered_2" + assert hass.states.get(electricity_delivered_2).state == STATE_UNKNOWN + + # Test if the payload is empty + async_fire_mqtt_message(hass, "dsmr/reading/electricity_delivered_1", "") + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "dsmr/reading/electricity_delivered_2", "") + await hass.async_block_till_done() + + assert hass.states.get(electricity_delivered_1).state == STATE_UNKNOWN + assert hass.states.get(electricity_delivered_2).state == STATE_UNKNOWN + + # Test if the payload is not empty + async_fire_mqtt_message(hass, "dsmr/reading/electricity_delivered_1", "1050.39") + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "dsmr/reading/electricity_delivered_2", "2001.12") + await hass.async_block_till_done() + + assert hass.states.get(electricity_delivered_1).state == "1050.39" + assert hass.states.get(electricity_delivered_2).state == "2001.12" + + # Create a test entity to ensure the entity_description.state is not None + description = DSMRReaderSensorEntityDescription( + key="DSMR_TEST_KEY", + name="DSMR_TEST_NAME", + state=lambda x: x, + ) + sensor = DSMRSensor(description, config_entry) + sensor.hass = hass + await sensor.async_added_to_hass() + async_fire_mqtt_message(hass, "DSMR_TEST_KEY", "192.8") + await hass.async_block_till_done() + assert sensor.native_value == "192.8" diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index d019861af1b..c06add7156a 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -33,7 +33,7 @@ async def async_set_txt(hass, txt): @pytest.fixture -def setup_duckdns(hass, aioclient_mock): +def setup_duckdns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up DuckDNS.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" diff --git a/tests/components/duotecno/conftest.py b/tests/components/duotecno/conftest.py index c79210bdfe0..1b6ba8f65e5 100644 --- a/tests/components/duotecno/conftest.py +++ b/tests/components/duotecno/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the duotecno tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.duotecno.async_setup_entry", return_value=True diff --git a/tests/components/duotecno/test_config_flow.py b/tests/components/duotecno/test_config_flow.py index 77946babd8c..f1fb60d2f0f 100644 --- a/tests/components/duotecno/test_config_flow.py +++ b/tests/components/duotecno/test_config_flow.py @@ -55,7 +55,9 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: (Exception, "unknown"), ], ) -async def test_invalid(hass: HomeAssistant, test_side_effect, test_error): +async def test_invalid( + hass: HomeAssistant, test_side_effect: Exception, test_error: str +) -> None: """Test all side_effects on the controller.connect via parameters.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/dwd_weather_warnings/conftest.py b/tests/components/dwd_weather_warnings/conftest.py index a2932944cc2..40c8bf3cfa0 100644 --- a/tests/components/dwd_weather_warnings/conftest.py +++ b/tests/components/dwd_weather_warnings/conftest.py @@ -1,9 +1,9 @@ """Configuration for Deutscher Wetterdienst (DWD) Weather Warnings tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.dwd_weather_warnings.const import ( ADVANCE_WARNING_SENSOR, @@ -23,7 +23,7 @@ MOCK_CONDITIONS = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR] @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.dwd_weather_warnings.async_setup_entry", @@ -59,7 +59,7 @@ def mock_tracker_entry() -> MockConfigEntry: @pytest.fixture -def mock_dwdwfsapi() -> Generator[MagicMock, None, None]: +def mock_dwdwfsapi() -> Generator[MagicMock]: """Return a mocked dwdwfsapi API client.""" with ( patch( diff --git a/tests/components/dwd_weather_warnings/test_init.py b/tests/components/dwd_weather_warnings/test_init.py index e5b82d0c453..54f57ead77c 100644 --- a/tests/components/dwd_weather_warnings/test_init.py +++ b/tests/components/dwd_weather_warnings/test_init.py @@ -12,7 +12,8 @@ from homeassistant.components.dwd_weather_warnings.coordinator import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryType from . import init_integration @@ -36,6 +37,41 @@ async def test_load_unload_entry( assert entry.state is ConfigEntryState.NOT_LOADED +async def test_removing_old_device( + hass: HomeAssistant, + mock_identifier_entry: MockConfigEntry, + mock_dwdwfsapi: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing old device when reloading the integration.""" + + mock_identifier_entry.add_to_hass(hass) + + device_registry.async_get_or_create( + identifiers={(DOMAIN, mock_identifier_entry.entry_id)}, + config_entry_id=mock_identifier_entry.entry_id, + entry_type=DeviceEntryType.SERVICE, + name="test", + ) + + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, mock_identifier_entry.entry_id)} + ) + is not None + ) + + await hass.config_entries.async_setup(mock_identifier_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, mock_identifier_entry.entry_id)} + ) + is None + ) + + async def test_load_invalid_registry_entry( hass: HomeAssistant, mock_tracker_entry: MockConfigEntry ) -> None: diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index 2b56786e4e0..8bb47fd67e3 100644 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -10,10 +10,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PORT from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_get as async_get_issue_registry, -) +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -34,10 +31,10 @@ async def test_flow( exp_type, exp_result, exp_reason, + issue_registry: ir.IssueRegistry, ) -> None: """Run a flow with or without errors and return result.""" - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + issue = issue_registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") assert issue is None host = "1.2.3.4" with patch( @@ -55,12 +52,12 @@ async def test_flow( assert result["result"].state == exp_result if exp_reason: assert result["reason"] == exp_reason - issue = registry.async_get_issue( + issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{dynalite.DOMAIN}" ) assert issue is not None assert issue.issue_domain == dynalite.DOMAIN - assert issue.severity == IssueSeverity.WARNING + assert issue.severity == ir.IssueSeverity.WARNING async def test_deprecated( @@ -142,7 +139,7 @@ async def test_two_entries(hass: HomeAssistant) -> None: assert result["result"].state is ConfigEntryState.LOADED -async def test_setup_user(hass): +async def test_setup_user(hass: HomeAssistant) -> None: """Test configuration via the user flow.""" host = "3.4.5.6" port = 1234 @@ -172,7 +169,7 @@ async def test_setup_user(hass): } -async def test_setup_user_existing_host(hass): +async def test_setup_user_existing_host(hass: HomeAssistant) -> None: """Test that when we setup a host that is defined, we get an error.""" host = "3.4.5.6" MockConfigEntry( diff --git a/tests/components/dynalite/test_panel.py b/tests/components/dynalite/test_panel.py index a1cd9749eb5..97752142f0c 100644 --- a/tests/components/dynalite/test_panel.py +++ b/tests/components/dynalite/test_panel.py @@ -5,11 +5,15 @@ from unittest.mock import patch from homeassistant.components import dynalite from homeassistant.components.cover import DEVICE_CLASSES from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator -async def test_get_config(hass, hass_ws_client): +async def test_get_config( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Get the config via websocket.""" host = "1.2.3.4" port = 765 @@ -49,7 +53,9 @@ async def test_get_config(hass, hass_ws_client): } -async def test_save_config(hass, hass_ws_client): +async def test_save_config( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Save the config via websocket.""" host1 = "1.2.3.4" port1 = 765 @@ -103,7 +109,9 @@ async def test_save_config(hass, hass_ws_client): assert modified_entry.data[CONF_PORT] == port3 -async def test_save_config_invalid_entry(hass, hass_ws_client): +async def test_save_config_invalid_entry( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Try to update nonexistent entry.""" host1 = "1.2.3.4" port1 = 765 diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py index 082c4e08908..986e1153cac 100644 --- a/tests/components/eafm/test_sensor.py +++ b/tests/components/eafm/test_sensor.py @@ -447,7 +447,7 @@ async def test_unload_entry(hass: HomeAssistant, mock_get_station) -> None: state = hass.states.get("sensor.my_station_water_level_stage") assert state.state == "5" - assert await entry.async_unload(hass) + await hass.config_entries.async_unload(entry.entry_id) # And the entity should be unavailable assert ( diff --git a/tests/components/easyenergy/conftest.py b/tests/components/easyenergy/conftest.py index dd8abae4d4a..96d356b8906 100644 --- a/tests/components/easyenergy/conftest.py +++ b/tests/components/easyenergy/conftest.py @@ -1,11 +1,11 @@ """Fixtures for easyEnergy integration tests.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch from easyenergy import Electricity, Gas import pytest +from typing_extensions import Generator from homeassistant.components.easyenergy.const import DOMAIN from homeassistant.core import HomeAssistant @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.easyenergy.async_setup_entry", return_value=True @@ -34,7 +34,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_easyenergy() -> Generator[MagicMock, None, None]: +def mock_easyenergy() -> Generator[MagicMock]: """Return a mocked easyEnergy client.""" with patch( "homeassistant.components.easyenergy.coordinator.EasyEnergy", autospec=True diff --git a/tests/components/easyenergy/snapshots/test_services.ambr b/tests/components/easyenergy/snapshots/test_services.ambr index 96b1eca5498..3330e5cf03c 100644 --- a/tests/components/easyenergy/snapshots/test_services.ambr +++ b/tests/components/easyenergy/snapshots/test_services.ambr @@ -611,312 +611,6 @@ ]), }) # --- -# 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([ @@ -1529,933 +1223,6 @@ ]), }) # --- -# 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([ @@ -3068,15 +1835,6 @@ ]), }) # --- -# 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([ @@ -3689,1902 +2447,3 @@ ]), }) # --- -# 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/ecobee/conftest.py b/tests/components/ecobee/conftest.py index 27d5a949c58..d9583e15986 100644 --- a/tests/components/ecobee/conftest.py +++ b/tests/components/ecobee/conftest.py @@ -1,9 +1,10 @@ """Fixtures for tests.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from requests_mock import Mocker +from typing_extensions import Generator from homeassistant.components.ecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN @@ -11,7 +12,7 @@ from tests.common import load_fixture, load_json_object_fixture @pytest.fixture(autouse=True) -def requests_mock_fixture(requests_mock): +def requests_mock_fixture(requests_mock: Mocker) -> None: """Fixture to provide a requests mocker.""" requests_mock.get( "https://api.ecobee.com/1/thermostat", @@ -24,7 +25,7 @@ def requests_mock_fixture(requests_mock): @pytest.fixture -def mock_ecobee() -> Generator[None, MagicMock]: +def mock_ecobee() -> Generator[MagicMock]: """Mock an Ecobee object.""" ecobee = MagicMock() ecobee.request_pin.return_value = True diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json index c86782d9c0b..b2f336e064d 100644 --- a/tests/components/ecobee/fixtures/ecobee-data.json +++ b/tests/components/ecobee/fixtures/ecobee-data.json @@ -11,8 +11,14 @@ }, "program": { "climates": [ - { "name": "Climate1", "climateRef": "c1" }, - { "name": "Climate2", "climateRef": "c2" } + { + "name": "Climate1", + "climateRef": "c1" + }, + { + "name": "Climate2", + "climateRef": "c2" + } ], "currentClimateRef": "c1" }, @@ -39,6 +45,7 @@ "isVentilatorTimerOn": false, "hasHumidifier": true, "humidifierMode": "manual", + "hasHeatPump": false, "humidity": "30" }, "equipmentStatus": "fan", @@ -82,8 +89,14 @@ "modelNumber": "athenaSmart", "program": { "climates": [ - { "name": "Climate1", "climateRef": "c1" }, - { "name": "Climate2", "climateRef": "c2" } + { + "name": "Climate1", + "climateRef": "c1" + }, + { + "name": "Climate2", + "climateRef": "c2" + } ], "currentClimateRef": "c1" }, @@ -109,6 +122,7 @@ "isVentilatorTimerOn": false, "hasHumidifier": true, "humidifierMode": "manual", + "hasHeatPump": true, "humidity": "30" }, "equipmentStatus": "fan", @@ -184,6 +198,7 @@ "isVentilatorTimerOn": false, "hasHumidifier": true, "humidifierMode": "manual", + "hasHeatPump": false, "humidity": "30" }, "equipmentStatus": "fan", diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 46ca77025cc..ae53132fe46 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -18,8 +18,8 @@ from homeassistant.components.ecobee.climate import ( from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF from homeassistant.core import HomeAssistant -from tests.components.ecobee import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP -from tests.components.ecobee.common import setup_platform +from . import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP +from .common import setup_platform ENTITY_ID = "climate.ecobee" @@ -363,13 +363,10 @@ async def test_hold_preference(ecobee_fixture, thermostat) -> None: """Test hold preference.""" ecobee_fixture["settings"]["holdAction"] = "indefinite" assert thermostat.hold_preference() == "indefinite" - for action in ["useEndTime2hour", "useEndTime4hour"]: + for action in ("useEndTime2hour", "useEndTime4hour"): ecobee_fixture["settings"]["holdAction"] = action assert thermostat.hold_preference() == "holdHours" - for action in [ - "nextPeriod", - "askMe", - ]: + for action in ("nextPeriod", "askMe"): ecobee_fixture["settings"]["holdAction"] = action assert thermostat.hold_preference() == "nextTransition" @@ -380,11 +377,7 @@ def test_hold_hours(ecobee_fixture, thermostat) -> None: assert thermostat.hold_hours() == 2 ecobee_fixture["settings"]["holdAction"] = "useEndTime4hour" assert thermostat.hold_hours() == 4 - for action in [ - "nextPeriod", - "indefinite", - "askMe", - ]: + for action in ("nextPeriod", "indefinite", "askMe"): ecobee_fixture["settings"]["holdAction"] = action assert thermostat.hold_hours() is None diff --git a/tests/components/ecobee/test_repairs.py b/tests/components/ecobee/test_repairs.py index 19fdc6f7bba..1473f8eb3a1 100644 --- a/tests/components/ecobee/test_repairs.py +++ b/tests/components/ecobee/test_repairs.py @@ -3,6 +3,11 @@ from http import HTTPStatus from unittest.mock import MagicMock +from homeassistant.components.climate import ( + ATTR_AUX_HEAT, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, +) from homeassistant.components.ecobee import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.repairs.issue_handler import ( @@ -12,6 +17,7 @@ from homeassistant.components.repairs.websocket_api import ( RepairsFlowIndexView, RepairsFlowResourceView, ) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -22,7 +28,7 @@ from tests.typing import ClientSessionGenerator THERMOSTAT_ID = 0 -async def test_ecobee_repair_flow( +async def test_ecobee_notify_repair_flow( hass: HomeAssistant, mock_ecobee: MagicMock, hass_client: ClientSessionGenerator, @@ -48,14 +54,14 @@ async def test_ecobee_repair_flow( # Assert the issue is present assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id="migrate_notify", + domain="notify", + issue_id=f"migrate_notify_{DOMAIN}_{DOMAIN}", ) assert len(issue_registry.issues) == 1 url = RepairsFlowIndexView.url resp = await http_client.post( - url, json={"handler": DOMAIN, "issue_id": "migrate_notify"} + url, json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}_{DOMAIN}"} ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -73,7 +79,36 @@ async def test_ecobee_repair_flow( # Assert the issue is no longer present assert not issue_registry.async_get_issue( - domain=DOMAIN, - issue_id="migrate_notify", + domain="notify", + issue_id=f"migrate_notify_{DOMAIN}_{DOMAIN}", ) assert len(issue_registry.issues) == 0 + + +async def test_ecobee_aux_heat_repair_flow( + hass: HomeAssistant, + mock_ecobee: MagicMock, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the ecobee aux_heat service repair flow is triggered.""" + await setup_platform(hass, CLIMATE_DOMAIN) + await async_process_repairs_platforms(hass) + + ENTITY_ID = "climate.ecobee2" + + # Simulate legacy service being used + assert hass.services.has_service(CLIMATE_DOMAIN, SERVICE_SET_AUX_HEAT) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_AUX_HEAT: True}, + blocking=True, + ) + + # Assert the issue is present + assert issue_registry.async_get_issue( + domain="ecobee", + issue_id="migrate_aux_heat", + ) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/ecobee/test_switch.py b/tests/components/ecobee/test_switch.py index 383abf9644c..05cea5a5e9d 100644 --- a/tests/components/ecobee/test_switch.py +++ b/tests/components/ecobee/test_switch.py @@ -12,10 +12,9 @@ from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TU from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from . import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP from .common import setup_platform -from tests.components.ecobee import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP - VENTILATOR_20MIN_ID = "switch.ecobee_ventilator_20m_timer" THERMOSTAT_ID = 0 @@ -113,3 +112,34 @@ async def test_turn_off_20min_ventilator(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() mock_set_20min_ventilator.assert_called_once_with(THERMOSTAT_ID, False) + + +DEVICE_ID = "switch.ecobee2_aux_heat_only" + + +async def test_aux_heat_only_turn_on(hass: HomeAssistant) -> None: + """Test the switch can be turned on.""" + with patch("pyecobee.Ecobee.set_hvac_mode") as mock_turn_on: + await setup_platform(hass, DOMAIN) + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: DEVICE_ID}, + blocking=True, + ) + mock_turn_on.assert_called_once_with(1, "auxHeatOnly") + + +async def test_aux_heat_only_turn_off(hass: HomeAssistant) -> None: + """Test the switch can be turned off.""" + with patch("pyecobee.Ecobee.set_hvac_mode") as mock_turn_off: + await setup_platform(hass, DOMAIN) + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: DEVICE_ID}, + blocking=True, + ) + mock_turn_off.assert_called_once_with(1, "auto") diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py index 79d1ea7f77b..3eb13e58aee 100644 --- a/tests/components/ecoforest/conftest.py +++ b/tests/components/ecoforest/conftest.py @@ -1,10 +1,10 @@ """Common fixtures for the Ecoforest tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch from pyecoforest.models.device import Alarm, Device, OperationMode, State import pytest +from typing_extensions import Generator from homeassistant.components.ecoforest import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.ecoforest.async_setup_entry", return_value=True diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index d4333f65dc4..8d0033a6bc9 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -1,14 +1,15 @@ """Common fixtures for the Ecovacs tests.""" -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch from deebot_client import const +from deebot_client.command import DeviceCommandResult from deebot_client.device import Device from deebot_client.exceptions import ApiError from deebot_client.models import Credentials import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.ecovacs import PLATFORMS from homeassistant.components.ecovacs.const import DOMAIN @@ -22,7 +23,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.ecovacs.async_setup_entry", return_value=True @@ -53,7 +54,7 @@ def device_fixture() -> str: @pytest.fixture -def mock_authenticator(device_fixture: str) -> Generator[Mock, None, None]: +def mock_authenticator(device_fixture: str) -> Generator[Mock]: """Mock the authenticator.""" with ( patch( @@ -98,7 +99,7 @@ def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock: @pytest.fixture -def mock_mqtt_client(mock_authenticator: Mock) -> Mock: +def mock_mqtt_client(mock_authenticator: Mock) -> Generator[Mock]: """Mock the MQTT client.""" with ( patch( @@ -117,10 +118,12 @@ def mock_mqtt_client(mock_authenticator: Mock) -> Mock: @pytest.fixture -def mock_device_execute() -> AsyncMock: +def mock_device_execute() -> Generator[AsyncMock]: """Mock the device execute function.""" with patch.object( - Device, "_execute_command", return_value=True + Device, + "_execute_command", + return_value=DeviceCommandResult(device_reached=True), ) as mock_device_execute: yield mock_device_execute @@ -139,7 +142,7 @@ async def init_integration( mock_mqtt_client: Mock, mock_device_execute: AsyncMock, platforms: Platform | list[Platform], -) -> MockConfigEntry: +) -> AsyncGenerator[MockConfigEntry]: """Set up the Ecovacs integration for testing.""" if not isinstance(platforms, list): platforms = [platforms] diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py index 697e57c6def..b57f67e948e 100644 --- a/tests/components/ecovacs/test_binary_sensor.py +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -1,6 +1,5 @@ """Tests for Ecovacs binary sensors.""" -from deebot_client.capabilities import Capabilities from deebot_client.events import WaterAmount, WaterInfoEvent import pytest from syrupy import SnapshotAssertion @@ -38,7 +37,7 @@ async def test_mop_attached( assert entity_entry == snapshot(name=f"{entity_id}-entity_entry") assert entity_entry.device_id - device = next(controller.devices(Capabilities)) + device = controller.devices[0] assert (device_entry := device_registry.async_get(entity_entry.device_id)) assert device_entry.identifiers == {(DOMAIN, device.device_info["did"])} diff --git a/tests/components/ecovacs/test_button.py b/tests/components/ecovacs/test_button.py index 277983eb0c5..08d53f3e93d 100644 --- a/tests/components/ecovacs/test_button.py +++ b/tests/components/ecovacs/test_button.py @@ -1,13 +1,12 @@ """Tests for Ecovacs sensors.""" -from deebot_client.capabilities import Capabilities from deebot_client.command import Command from deebot_client.commands.json import ResetLifeSpan, SetRelocationState from deebot_client.events import LifeSpan import pytest from syrupy import SnapshotAssertion -from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform @@ -74,7 +73,7 @@ async def test_buttons( ) -> None: """Test that sensor entity snapshots match.""" assert hass.states.async_entity_ids() == [e[0] for e in entities] - device = next(controller.devices(Capabilities)) + device = controller.devices[0] for entity_id, command in entities: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN diff --git a/tests/components/ecovacs/test_event.py b/tests/components/ecovacs/test_event.py index 104a3bfc69e..03fb79e083f 100644 --- a/tests/components/ecovacs/test_event.py +++ b/tests/components/ecovacs/test_event.py @@ -2,7 +2,6 @@ from datetime import timedelta -from deebot_client.capabilities import Capabilities from deebot_client.events import CleanJobStatus, ReportStatsEvent from freezegun.api import FrozenDateTimeFactory import pytest @@ -10,7 +9,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.components.event.const import ATTR_EVENT_TYPE +from homeassistant.components.event import ATTR_EVENT_TYPE from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -44,7 +43,7 @@ async def test_last_job( assert entity_entry == snapshot(name=f"{entity_id}-entity_entry") assert entity_entry.device_id - device = next(controller.devices(Capabilities)) + device = controller.devices[0] assert (device_entry := device_registry.async_get(entity_entry.device_id)) assert device_entry.identifiers == {(DOMAIN, device.device_info["did"])} diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 752276015d3..27d00a2d023 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -3,7 +3,6 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch -from deebot_client.capabilities import Capabilities from deebot_client.exceptions import DeebotError, InvalidAuthenticationError import pytest from syrupy import SnapshotAssertion @@ -121,7 +120,7 @@ async def test_devices_in_dr( snapshot: SnapshotAssertion, ) -> None: """Test all devices are in the device registry.""" - for device in controller.devices(Capabilities): + for device in controller.devices: assert ( device_entry := device_registry.async_get_device( identifiers={(DOMAIN, device.device_info["did"])} diff --git a/tests/components/ecovacs/test_lawn_mower.py b/tests/components/ecovacs/test_lawn_mower.py index 563e6aecbb0..2c0abd0a49e 100644 --- a/tests/components/ecovacs/test_lawn_mower.py +++ b/tests/components/ecovacs/test_lawn_mower.py @@ -2,7 +2,6 @@ from dataclasses import dataclass -from deebot_client.capabilities import MowerCapabilities from deebot_client.command import Command from deebot_client.commands.json import Charge, CleanV2 from deebot_client.events import StateEvent @@ -14,12 +13,10 @@ from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.components.lawn_mower import ( DOMAIN as PLATFORM_DOMAIN, - LawnMowerActivity, -) -from homeassistant.components.lawn_mower.const import ( SERVICE_DOCK, SERVICE_PAUSE, SERVICE_START_MOWING, + LawnMowerActivity, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant @@ -58,7 +55,7 @@ async def test_lawn_mower( assert entity_entry == snapshot(name=f"{entity_id}-entity_entry") assert entity_entry.device_id - device = next(controller.devices(MowerCapabilities)) + device = controller.devices[0] assert (device_entry := device_registry.async_get(entity_entry.device_id)) assert device_entry.identifiers == {(DOMAIN, device.device_info["did"])} @@ -106,7 +103,7 @@ async def test_mover_services( tests: list[MowerTestCase], ) -> None: """Test mover services.""" - device = next(controller.devices(MowerCapabilities)) + device = controller.devices[0] for test in tests: device._execute_command.reset_mock() diff --git a/tests/components/ecovacs/test_number.py b/tests/components/ecovacs/test_number.py index 6d8941506b5..d444d6510a8 100644 --- a/tests/components/ecovacs/test_number.py +++ b/tests/components/ecovacs/test_number.py @@ -2,7 +2,6 @@ from dataclasses import dataclass -from deebot_client.capabilities import Capabilities from deebot_client.command import Command from deebot_client.commands.json import SetVolume from deebot_client.events import Event, VolumeEvent @@ -11,7 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.components.number.const import ( +from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as PLATFORM_DOMAIN, SERVICE_SET_VALUE, @@ -66,7 +65,7 @@ async def test_number_entities( tests: list[NumberTestCase], ) -> None: """Test that number entity snapshots match.""" - device = next(controller.devices(Capabilities)) + device = controller.devices[0] event_bus = device.events assert sorted(hass.states.async_entity_ids()) == sorted( @@ -131,7 +130,7 @@ async def test_volume_maximum( controller: EcovacsController, ) -> None: """Test volume maximum.""" - device = next(controller.devices(Capabilities)) + device = controller.devices[0] event_bus = device.events entity_id = "number.ozmo_950_volume" assert (state := hass.states.get(entity_id)) diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index b7e9435b416..02a6b5ebfa4 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -1,6 +1,5 @@ """Tests for Ecovacs select entities.""" -from deebot_client.capabilities import Capabilities from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus @@ -64,7 +63,7 @@ async def test_selects( assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN - device = next(controller.devices(Capabilities)) + device = controller.devices[0] await notify_events(hass, device.events) for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" @@ -100,7 +99,7 @@ async def test_selects_change( command: Command, ) -> None: """Test that changing select entities works.""" - device = next(controller.devices(Capabilities)) + device = controller.devices[0] await notify_events(hass, device.events) assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" diff --git a/tests/components/ecovacs/test_sensor.py b/tests/components/ecovacs/test_sensor.py index 5b8bf18e1d8..005d10bffbd 100644 --- a/tests/components/ecovacs/test_sensor.py +++ b/tests/components/ecovacs/test_sensor.py @@ -1,6 +1,5 @@ """Tests for Ecovacs sensors.""" -from deebot_client.capabilities import Capabilities from deebot_client.event_bus import EventBus from deebot_client.events import ( BatteryEvent, @@ -103,7 +102,7 @@ async def test_sensors( assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" assert state.state == STATE_UNKNOWN - device = next(controller.devices(Capabilities)) + device = controller.devices[0] await notify_events(hass, device.events) for entity_id in entity_ids: assert (state := hass.states.get(entity_id)), f"State of {entity_id} is missing" diff --git a/tests/components/ecovacs/test_services.py b/tests/components/ecovacs/test_services.py new file mode 100644 index 00000000000..973c63782ec --- /dev/null +++ b/tests/components/ecovacs/test_services.py @@ -0,0 +1,89 @@ +"""Tests for Ecovacs services.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + +from deebot_client.device import Device +import pytest + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.vacuum import SERVICE_RAW_GET_POSITIONS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def mock_device_execute_response( + data: dict[str, Any], +) -> Generator[dict[str, Any], None, None]: + """Mock the device execute function response.""" + + response = { + "ret": "ok", + "resp": { + "header": { + "pri": 1, + "tzm": 480, + "ts": "1717113600000", + "ver": "0.0.1", + "fwVer": "1.2.0", + "hwVer": "0.1.0", + }, + "body": { + "code": 0, + "msg": "ok", + "data": data, + }, + }, + "id": "xRV3", + "payloadType": "j", + } + + with patch.object( + Device, + "execute_command", + return_value=response, + ): + yield response + + +@pytest.mark.usefixtures("mock_device_execute_response") +@pytest.mark.parametrize( + "data", + [ + { + "deebotPos": {"x": 1, "y": 5, "a": 85}, + "chargePos": {"x": 5, "y": 9, "a": 85}, + }, + { + "deebotPos": {"x": 375, "y": 313, "a": 90}, + "chargePos": [{"x": 112, "y": 768, "a": 32}, {"x": 489, "y": 322, "a": 0}], + }, + ], +) +@pytest.mark.parametrize( + ("device_fixture", "entity_id"), + [ + ("yna5x1", "vacuum.ozmo_950"), + ], + ids=["yna5x1"], +) +async def test_get_positions_service( + hass: HomeAssistant, + mock_device_execute_response: dict[str], + entity_id: str, +) -> None: + """Test that get_positions service response snapshots match.""" + vacuum = hass.states.get(entity_id) + assert vacuum + + assert await hass.services.async_call( + DOMAIN, + SERVICE_RAW_GET_POSITIONS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + return_response=True, + ) == {entity_id: mock_device_execute_response} diff --git a/tests/components/ecovacs/test_switch.py b/tests/components/ecovacs/test_switch.py index fee348149ee..b14cafeaba4 100644 --- a/tests/components/ecovacs/test_switch.py +++ b/tests/components/ecovacs/test_switch.py @@ -2,7 +2,6 @@ from dataclasses import dataclass -from deebot_client.capabilities import Capabilities from deebot_client.command import Command from deebot_client.commands.json import ( SetAdvancedMode, @@ -32,7 +31,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.components.switch.const import DOMAIN as PLATFORM_DOMAIN +from homeassistant.components.switch import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -140,7 +139,7 @@ async def test_switch_entities( tests: list[SwitchTestCase], ) -> None: """Test switch entities.""" - device = next(controller.devices(Capabilities)) + device = controller.devices[0] event_bus = device.events assert hass.states.async_entity_ids() == [test.entity_id for test in tests] diff --git a/tests/components/edl21/conftest.py b/tests/components/edl21/conftest.py index dc64659d2b8..b6af4ea9cef 100644 --- a/tests/components/edl21/conftest.py +++ b/tests/components/edl21/conftest.py @@ -1,13 +1,13 @@ """Define test fixtures for EDL21.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.edl21.async_setup_entry", return_value=True diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index d7ab3101900..addaa1b9c48 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -28,7 +28,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(autouse=True) -def enable_all_entities(entity_registry_enabled_by_default): +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: """Make sure all entities are enabled.""" diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py index cf0d1b5ab15..6b943014cbc 100644 --- a/tests/components/electrasmart/test_config_flow.py +++ b/tests/components/electrasmart/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import load_fixture -async def test_form(hass: HomeAssistant): +async def test_form(hass: HomeAssistant) -> None: """Test user config.""" mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) @@ -44,7 +44,7 @@ async def test_form(hass: HomeAssistant): assert result["step_id"] == CONF_OTP -async def test_one_time_password(hass: HomeAssistant): +async def test_one_time_password(hass: HomeAssistant) -> None: """Test one time password.""" mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) @@ -76,7 +76,7 @@ async def test_one_time_password(hass: HomeAssistant): assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_one_time_password_api_error(hass: HomeAssistant): +async def test_one_time_password_api_error(hass: HomeAssistant) -> None: """Test one time password.""" mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) with ( @@ -102,7 +102,7 @@ async def test_one_time_password_api_error(hass: HomeAssistant): assert result["type"] is FlowResultType.FORM -async def test_cannot_connect(hass: HomeAssistant): +async def test_cannot_connect(hass: HomeAssistant) -> None: """Test cannot connect.""" with patch( @@ -120,7 +120,7 @@ async def test_cannot_connect(hass: HomeAssistant): assert result["errors"] == {"base": "cannot_connect"} -async def test_invalid_phone_number(hass: HomeAssistant): +async def test_invalid_phone_number(hass: HomeAssistant) -> None: """Test invalid phone number.""" mock_invalid_phone_number_response = loads( @@ -143,7 +143,7 @@ async def test_invalid_phone_number(hass: HomeAssistant): assert result["errors"] == {"phone_number": "invalid_phone_number"} -async def test_invalid_auth(hass: HomeAssistant): +async def test_invalid_auth(hass: HomeAssistant) -> None: """Test invalid auth.""" mock_generate_token_response = loads( diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 8052ae5e129..c9f9c7e04f0 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from time import time from unittest.mock import AsyncMock, patch from electrickiwi_api.model import AccountBalance, Hop, HopIntervals import pytest +from typing_extensions import Generator from homeassistant.components.application_credentials import ( ClientCredential, @@ -23,14 +24,13 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -YieldFixture = Generator[AsyncMock, None, None] -ComponentSetup = Callable[[], Awaitable[bool]] +type YieldFixture = Generator[AsyncMock] +type ComponentSetup = Callable[[], Awaitable[bool]] @pytest.fixture(autouse=True) -async def request_setup(current_request_with_host) -> None: +async def request_setup(current_request_with_host: None) -> None: """Request setup.""" - return @pytest.fixture @@ -79,7 +79,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.electric_kiwi.async_setup_entry", return_value=True diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index d74abab7692..bf248aafb13 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -53,11 +53,11 @@ async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: assert result.get("reason") == "missing_credentials" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: @@ -107,11 +107,11 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials: None, config_entry: MockConfigEntry, ) -> None: @@ -150,10 +150,10 @@ async def test_existing_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, aioclient_mock: AiohttpClientMocker, mock_setup_entry: MagicMock, config_entry: MockConfigEntry, diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index a247497b263..bb3304ec66c 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -24,7 +24,7 @@ from .conftest import ComponentSetup, YieldFixture from tests.common import MockConfigEntry -DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE +DEFAULT_TIME_ZONE = dt_util.get_default_time_zone() TEST_TZ_NAME = "Pacific/Auckland" TEST_TIMEZONE = zoneinfo.ZoneInfo(TEST_TZ_NAME) diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index 5a783c509c2..aaaed0dc8da 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -1,10 +1,10 @@ """Fixtures for Elgato integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from elgato import BatteryInfo, ElgatoNoBatteryError, Info, Settings, State import pytest +from typing_extensions import Generator from homeassistant.components.elgato.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT @@ -42,7 +42,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.elgato.async_setup_entry", return_value=True @@ -51,7 +51,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_onboarding() -> Generator[None, MagicMock, None]: +def mock_onboarding() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -61,9 +61,7 @@ def mock_onboarding() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_elgato( - device_fixtures: str, state_variant: str -) -> Generator[None, MagicMock, None]: +def mock_elgato(device_fixtures: str, state_variant: str) -> Generator[MagicMock]: """Return a mocked Elgato client.""" with ( patch( diff --git a/tests/components/elgato/fixtures/light-strip/info.json b/tests/components/elgato/fixtures/light-strip/info.json index e2a816df26e..a8c3200e4b9 100644 --- a/tests/components/elgato/fixtures/light-strip/info.json +++ b/tests/components/elgato/fixtures/light-strip/info.json @@ -1,5 +1,5 @@ { - "productName": "Elgato Key Light", + "productName": "Elgato Light Strip", "hardwareBoardType": 53, "firmwareBuildNumber": 192, "firmwareVersion": "1.0.3", diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 6ef773a7304..e2f663d294b 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -218,7 +218,7 @@ 'labels': set({ }), 'manufacturer': 'Elgato', - 'model': 'Elgato Key Light', + 'model': 'Elgato Light Strip', 'name': 'Frenck', 'name_by_user': None, 'serial_number': 'CN11A1A00001', @@ -333,7 +333,7 @@ 'labels': set({ }), 'manufacturer': 'Elgato', - 'model': 'Elgato Key Light', + 'model': 'Elgato Light Strip', 'name': 'Frenck', 'name_by_user': None, 'serial_number': 'CN11A1A00001', diff --git a/tests/components/elgato/test_init.py b/tests/components/elgato/test_init.py index a4ccb302461..a6ff923beed 100644 --- a/tests/components/elgato/test_init.py +++ b/tests/components/elgato/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock from elgato import ElgatoConnectionError -from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -27,7 +26,6 @@ async def test_load_unload_config_entry( 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 diff --git a/tests/components/elmax/__init__.py b/tests/components/elmax/__init__.py index 1434c831df3..e1a6728f1f5 100644 --- a/tests/components/elmax/__init__.py +++ b/tests/components/elmax/__init__.py @@ -1,6 +1,19 @@ """Tests for the Elmax component.""" -from tests.common import load_fixture +from homeassistant.components.elmax.const import ( + CONF_ELMAX_MODE, + CONF_ELMAX_MODE_DIRECT, + CONF_ELMAX_MODE_DIRECT_HOST, + CONF_ELMAX_MODE_DIRECT_PORT, + CONF_ELMAX_MODE_DIRECT_SSL, + CONF_ELMAX_MODE_DIRECT_SSL_CERT, + CONF_ELMAX_PANEL_ID, + CONF_ELMAX_PANEL_PIN, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture MOCK_USER_JWT = ( "JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" @@ -22,3 +35,23 @@ MOCK_DIRECT_PORT = 443 MOCK_DIRECT_SSL = True MOCK_DIRECT_CERT = load_fixture("direct/cert.pem", "elmax") MOCK_DIRECT_FOLLOW_MDNS = True + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock integration setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ELMAX_MODE: CONF_ELMAX_MODE_DIRECT, + CONF_ELMAX_MODE_DIRECT_HOST: MOCK_DIRECT_HOST, + CONF_ELMAX_MODE_DIRECT_PORT: MOCK_DIRECT_PORT, + CONF_ELMAX_MODE_DIRECT_SSL: MOCK_DIRECT_SSL, + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_PANEL_ID: None, + CONF_ELMAX_MODE_DIRECT_SSL_CERT: MOCK_DIRECT_CERT, + }, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index e69f52f4cad..552aa138f1b 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -1,7 +1,7 @@ """Configuration for Elmax tests.""" import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from elmax_api.constants import ( BASE_URL, @@ -12,6 +12,7 @@ from elmax_api.constants import ( from httpx import Response import pytest import respx +from typing_extensions import Generator from . import ( MOCK_DIRECT_HOST, @@ -29,7 +30,7 @@ MOCK_DIRECT_BASE_URI = ( @pytest.fixture(autouse=True) -def httpx_mock_cloud_fixture(requests_mock): +def httpx_mock_cloud_fixture() -> Generator[respx.MockRouter]: """Configure httpx fixture for cloud API communication.""" with respx.mock(base_url=BASE_URL, assert_all_called=False) as respx_mock: # Mock Login POST. @@ -56,7 +57,7 @@ def httpx_mock_cloud_fixture(requests_mock): @pytest.fixture(autouse=True) -def httpx_mock_direct_fixture(requests_mock): +def httpx_mock_direct_fixture() -> Generator[respx.MockRouter]: """Configure httpx fixture for direct Panel-API communication.""" with respx.mock( base_url=MOCK_DIRECT_BASE_URI, assert_all_called=False @@ -79,7 +80,7 @@ def httpx_mock_direct_fixture(requests_mock): @pytest.fixture(autouse=True) -def elmax_mock_direct_cert(requests_mock): +def elmax_mock_direct_cert() -> Generator[AsyncMock]: """Patch elmax library to return a specific PEM for SSL communication.""" with patch( "elmax_api.http.GenericElmax.retrieve_server_certificate", diff --git a/tests/components/elmax/snapshots/test_alarm_control_panel.ambr b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..f09ba6752c5 --- /dev/null +++ b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_alarm_control_panels[alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AREA 1', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '13762559c53cd093171-area-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panels[alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': , + 'friendly_name': 'Direct Panel https://1.1.1.1:443/api/v2 AREA 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_alarm_control_panels[alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AREA 2', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '13762559c53cd093171-area-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panels[alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': , + 'friendly_name': 'Direct Panel https://1.1.1.1:443/api/v2 AREA 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_alarm_control_panels[alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AREA 3', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '13762559c53cd093171-area-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panels[alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': , + 'friendly_name': 'Direct Panel https://1.1.1.1:443/api/v2 AREA 3', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.direct_panel_https_1_1_1_1_443_api_v2_area_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/elmax/snapshots/test_binary_sensor.ambr b/tests/components/elmax/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..3c3f63b44ca --- /dev/null +++ b/tests/components/elmax/snapshots/test_binary_sensor.ambr @@ -0,0 +1,377 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.zona_01-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_01', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 01', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_01-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 01', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_01', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_02e-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_02e', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 02e', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_02e-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 02e', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_02e', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_03a-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_03a', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 03a', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_03a-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 03a', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_03a', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_04-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_04', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 04', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_04-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 04', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_04', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_05-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_05', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 05', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_05-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 05', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_05', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_06-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_06', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 06', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_06-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 06', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_06', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_07-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_07', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 07', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_07-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 07', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_07', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_08-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zona_08', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ZONA 08', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-zona-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.zona_08-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'ZONA 08', + }), + 'context': , + 'entity_id': 'binary_sensor.zona_08', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/elmax/snapshots/test_cover.ambr b/tests/components/elmax/snapshots/test_cover.ambr new file mode 100644 index 00000000000..0dbea416934 --- /dev/null +++ b/tests/components/elmax/snapshots/test_cover.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_covers[cover.espan_dom_01-entry] + 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.espan_dom_01', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ESPAN.DOM.01', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '13762559c53cd093171-tapparella-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.espan_dom_01-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'friendly_name': 'ESPAN.DOM.01', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.espan_dom_01', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/elmax/snapshots/test_switch.ambr b/tests/components/elmax/snapshots/test_switch.ambr new file mode 100644 index 00000000000..0ae1942e7e0 --- /dev/null +++ b/tests/components/elmax/snapshots/test_switch.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_switches[switch.uscita_02-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.uscita_02', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'USCITA 02', + 'platform': 'elmax', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '13762559c53cd093171-uscita-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.uscita_02-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'USCITA 02', + }), + 'context': , + 'entity_id': 'switch.uscita_02', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/elmax/test_alarm_control_panel.py b/tests/components/elmax/test_alarm_control_panel.py new file mode 100644 index 00000000000..6e4f09710fc --- /dev/null +++ b/tests/components/elmax/test_alarm_control_panel.py @@ -0,0 +1,27 @@ +"""Tests for the Elmax alarm control panels.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_alarm_control_panels( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test alarm control panels.""" + with patch( + "homeassistant.components.elmax.ELMAX_PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/elmax/test_binary_sensor.py b/tests/components/elmax/test_binary_sensor.py new file mode 100644 index 00000000000..f6cead79ee7 --- /dev/null +++ b/tests/components/elmax/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Elmax binary sensors.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensors.""" + with patch( + "homeassistant.components.elmax.ELMAX_PLATFORMS", [Platform.BINARY_SENSOR] + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index c00de2003c2..85e14dd0a3f 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -172,7 +172,7 @@ async def test_cloud_setup(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_zeroconf_form_setup_api_not_supported(hass): +async def test_zeroconf_form_setup_api_not_supported(hass: HomeAssistant) -> None: """Test the zeroconf setup case.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -183,7 +183,7 @@ async def test_zeroconf_form_setup_api_not_supported(hass): assert result["reason"] == "not_supported" -async def test_zeroconf_discovery(hass): +async def test_zeroconf_discovery(hass: HomeAssistant) -> None: """Test discovery of Elmax local api panel.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -195,7 +195,7 @@ async def test_zeroconf_discovery(hass): assert result["errors"] is None -async def test_zeroconf_setup_show_form(hass): +async def test_zeroconf_setup_show_form(hass: HomeAssistant) -> None: """Test discovery shows a form when activated.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -211,7 +211,7 @@ async def test_zeroconf_setup_show_form(hass): assert result["step_id"] == "zeroconf_setup" -async def test_zeroconf_setup(hass): +async def test_zeroconf_setup(hass: HomeAssistant) -> None: """Test the successful creation of config entry via discovery flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -231,7 +231,7 @@ async def test_zeroconf_setup(hass): assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_zeroconf_already_configured(hass): +async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Ensure local discovery aborts when same panel is already added to ha.""" MockConfigEntry( domain=DOMAIN, @@ -257,7 +257,7 @@ async def test_zeroconf_already_configured(hass): assert result["reason"] == "already_configured" -async def test_zeroconf_panel_changed_ip(hass): +async def test_zeroconf_panel_changed_ip(hass: HomeAssistant) -> None: """Ensure local discovery updates the panel data when a the panel changes its IP.""" # Simulate an entry already exists for ip MOCK_DIRECT_HOST. config_entry = MockConfigEntry( diff --git a/tests/components/elmax/test_cover.py b/tests/components/elmax/test_cover.py new file mode 100644 index 00000000000..9fa72432072 --- /dev/null +++ b/tests/components/elmax/test_cover.py @@ -0,0 +1,25 @@ +"""Tests for the Elmax covers.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_covers( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test covers.""" + with patch("homeassistant.components.elmax.ELMAX_PLATFORMS", [Platform.COVER]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/elmax/test_switch.py b/tests/components/elmax/test_switch.py new file mode 100644 index 00000000000..ba6efee2184 --- /dev/null +++ b/tests/components/elmax/test_switch.py @@ -0,0 +1,25 @@ +"""Tests for the Elmax switches.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test switches.""" + with patch("homeassistant.components.elmax.ELMAX_PLATFORMS", [Platform.SWITCH]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/elvia/conftest.py b/tests/components/elvia/conftest.py index c8b98f18f3f..0708e5c698a 100644 --- a/tests/components/elvia/conftest.py +++ b/tests/components/elvia/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the Elvia tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.elvia.async_setup_entry", return_value=True diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 08974b36215..4edd52b812d 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -8,6 +8,7 @@ import json from unittest.mock import patch from aiohttp.hdrs import CONTENT_TYPE +from aiohttp.test_utils import TestClient import pytest from homeassistant import const, setup @@ -243,7 +244,9 @@ def _mock_hue_endpoints( @pytest.fixture -async def hue_client(hass_hue, hass_client_no_auth): +async def hue_client( + hass_hue, hass_client_no_auth: ClientSessionGenerator +) -> TestClient: """Create web client for emulated hue api.""" _mock_hue_endpoints( hass_hue, @@ -1648,6 +1651,7 @@ async def test_only_change_contrast(hass: HomeAssistant, hass_hue, hue_client) - ) # Check that only setting the contrast will also turn on the light. + # pylint: disable-next=fixme # TODO: It should be noted that a real Hue hub will not allow to change the brightness if the underlying entity is off. # giving the error: [{"error":{"type":201,"address":"/lights/20/state/bri","description":"parameter, bri, is not modifiable. Device is set to off."}}] # emulated_hue however will always turn on the light. @@ -1661,6 +1665,7 @@ async def test_only_change_hue_or_saturation( ) -> None: """Test setting either the hue or the saturation but not both.""" + # pylint: disable-next=fixme # TODO: The handling of this appears wrong, as setting only one will set the other to 0. # The return values also appear wrong. diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index f69bd1b0651..3522f7e8047 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -1,13 +1,16 @@ """The tests for the emulated Hue component.""" +from asyncio import AbstractEventLoop from http import HTTPStatus import json import unittest from unittest.mock import patch from aiohttp import web +from aiohttp.test_utils import TestClient import defusedxml.ElementTree as ET import pytest +from typing_extensions import Generator from homeassistant import setup from homeassistant.components import emulated_hue @@ -16,6 +19,7 @@ from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from tests.common import get_test_instance_port +from tests.typing import ClientSessionGenerator BRIDGE_SERVER_PORT = get_test_instance_port() @@ -23,7 +27,7 @@ BRIDGE_SERVER_PORT = get_test_instance_port() class MockTransport: """Mock asyncio transport.""" - def __init__(self): + def __init__(self) -> None: """Create a place to store the sends.""" self.sends = [] @@ -33,13 +37,19 @@ class MockTransport: @pytest.fixture -def aiohttp_client(event_loop, aiohttp_client, socket_enabled): +def aiohttp_client( + event_loop: AbstractEventLoop, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, +) -> ClientSessionGenerator: """Return aiohttp_client and allow opening sockets.""" return aiohttp_client @pytest.fixture -def hue_client(aiohttp_client): +def hue_client( + aiohttp_client: ClientSessionGenerator, +) -> Generator[TestClient]: """Return a hue API client.""" app = web.Application() with unittest.mock.patch( @@ -53,7 +63,7 @@ def hue_client(aiohttp_client): yield client -async def setup_hue(hass): +async def setup_hue(hass: HomeAssistant) -> None: """Set up the emulated_hue integration.""" with patch( "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" @@ -72,7 +82,7 @@ def test_upnp_discovery_basic() -> None: mock_transport = MockTransport() upnp_responder_protocol.transport = mock_transport - """Original request emitted by the Hue Bridge v1 app.""" + # Original request emitted by the Hue Bridge v1 app. request = """M-SEARCH * HTTP/1.1 HOST:239.255.255.250:1900 ST:ssdp:all @@ -104,7 +114,7 @@ def test_upnp_discovery_rootdevice() -> None: mock_transport = MockTransport() upnp_responder_protocol.transport = mock_transport - """Original request emitted by Busch-Jaeger free@home SysAP.""" + # Original request emitted by Busch-Jaeger free@home SysAP. request = """M-SEARCH * HTTP/1.1 HOST: 239.255.255.250:1900 MAN: "ssdp:discover" @@ -136,7 +146,7 @@ def test_upnp_no_response() -> None: mock_transport = MockTransport() upnp_responder_protocol.transport = mock_transport - """Original request emitted by the Hue Bridge v1 app.""" + # Original request emitted by the Hue Bridge v1 app. request = """INVALID * HTTP/1.1 HOST:239.255.255.250:1900 ST:ssdp:all @@ -148,7 +158,7 @@ MX:3 upnp_responder_protocol.datagram_received(encoded_request, 1234) - assert mock_transport.sends == [] + assert not mock_transport.sends async def test_description_xml(hass: HomeAssistant, hue_client) -> None: @@ -164,7 +174,7 @@ async def test_description_xml(hass: HomeAssistant, hue_client) -> None: root = ET.fromstring(await result.text()) ns = {"s": "urn:schemas-upnp-org:device-1-0"} assert root.find("./s:device/s:serialNumber", ns).text == "001788FFFE23BFC2" - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 pytest.fail("description.xml is not valid XML!") diff --git a/tests/components/emulated_roku/test_config_flow.py b/tests/components/emulated_roku/test_config_flow.py index 45cb83b4fea..0b0efb83967 100644 --- a/tests/components/emulated_roku/test_config_flow.py +++ b/tests/components/emulated_roku/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_flow_works(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_flow_works(hass: HomeAssistant) -> None: """Test that config flow works.""" result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, @@ -21,9 +21,7 @@ async def test_flow_works(hass: HomeAssistant, mock_get_source_ip) -> None: assert result["data"] == {"name": "Emulated Roku Test", "listen_port": 8060} -async def test_flow_already_registered_entry( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_flow_already_registered_entry(hass: HomeAssistant) -> None: """Test that config flow doesn't allow existing names.""" MockConfigEntry( domain="emulated_roku", data={"name": "Emulated Roku Test", "listen_port": 8062} diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index 00316c66425..cf2a415f19c 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -async def test_config_required_fields(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_config_required_fields(hass: HomeAssistant) -> None: """Test that configuration is successful with required fields.""" with ( patch.object(emulated_roku, "configured_servers", return_value=[]), @@ -35,9 +35,7 @@ async def test_config_required_fields(hass: HomeAssistant, mock_get_source_ip) - ) -async def test_config_already_registered_not_configured( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_config_already_registered_not_configured(hass: HomeAssistant) -> None: """Test that an already registered name causes the entry to be ignored.""" with ( patch( diff --git a/tests/components/energenie_power_sockets/conftest.py b/tests/components/energenie_power_sockets/conftest.py index f119c0008f7..64eb8bbd2a8 100644 --- a/tests/components/energenie_power_sockets/conftest.py +++ b/tests/components/energenie_power_sockets/conftest.py @@ -1,11 +1,11 @@ """Configure tests for Energenie-Power-Sockets.""" -from collections.abc import Generator from typing import Final from unittest.mock import MagicMock, patch from pyegps.fakes.powerstrip import FakePowerStrip import pytest +from typing_extensions import Generator from homeassistant.components.energenie_power_sockets.const import ( CONF_DEVICE_API_ID, @@ -58,7 +58,7 @@ def get_pyegps_device_mock() -> MagicMock: @pytest.fixture(name="mock_get_device") -def patch_get_device(pyegps_device_mock: MagicMock) -> Generator[MagicMock, None, None]: +def patch_get_device(pyegps_device_mock: MagicMock) -> Generator[MagicMock]: """Fixture to patch the `get_device` api method.""" with ( patch("homeassistant.components.energenie_power_sockets.get_device") as m1, @@ -74,7 +74,7 @@ def patch_get_device(pyegps_device_mock: MagicMock) -> Generator[MagicMock, None @pytest.fixture(name="mock_search_for_devices") def patch_search_devices( pyegps_device_mock: MagicMock, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Fixture to patch the `search_for_devices` api method.""" with patch( "homeassistant.components.energenie_power_sockets.config_flow.search_for_devices", diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 192cf6abea4..0439ac2c028 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -4,9 +4,11 @@ import copy from datetime import timedelta from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.energy import data +from homeassistant.components.recorder.core import Recorder from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import ( ATTR_LAST_RESET, @@ -35,7 +37,7 @@ TEST_TIME_ADVANCE_INTERVAL = timedelta(milliseconds=10) @pytest.fixture -async def setup_integration(recorder_mock): +async def setup_integration(recorder_mock: Recorder): """Set up the integration.""" async def setup_integration(hass): @@ -46,7 +48,7 @@ async def setup_integration(recorder_mock): @pytest.fixture(autouse=True) -def frozen_time(freezer): +def frozen_time(freezer: FrozenDateTimeFactory) -> FrozenDateTimeFactory: """Freeze clock for tests.""" freezer.move_to("2022-04-19 07:53:05") return freezer @@ -85,6 +87,7 @@ async def test_cost_sensor_no_states( "data": energy_data, } await setup_integration(hass) + # pylint: disable-next=fixme # TODO: No states, should the cost entity refuse to setup? diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 7a328e77d76..d7f0485139f 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -5,6 +5,8 @@ from unittest.mock import patch import pytest from homeassistant.components.energy import async_get_manager, validate +from homeassistant.components.energy.data import EnergyManager +from homeassistant.components.recorder import Recorder from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSON_DUMP @@ -46,7 +48,9 @@ def mock_get_metadata(): @pytest.fixture(autouse=True) -async def mock_energy_manager(recorder_mock, hass): +async def mock_energy_manager( + recorder_mock: Recorder, hass: HomeAssistant +) -> EnergyManager: """Set up energy.""" assert await async_setup_component(hass, "energy", {"energy": {}}) manager = await async_get_manager(hass) diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index afb23e4e88a..959ec7d1687 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -21,13 +21,13 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -async def setup_integration(recorder_mock, hass): +async def setup_integration(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Set up the integration.""" assert await async_setup_component(hass, "energy", {}) @pytest.fixture -def mock_energy_platform(hass): +def mock_energy_platform(hass: HomeAssistant) -> None: """Mock an energy platform.""" hass.config.components.add("some_domain") mock_platform( diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index 2198e8c0c79..49f6c18b09e 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -1,11 +1,11 @@ """Fixtures for EnergyZero integration tests.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch from energyzero import Electricity, Gas import pytest +from typing_extensions import Generator from homeassistant.components.energyzero.const import DOMAIN from homeassistant.core import HomeAssistant @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.energyzero.async_setup_entry", return_value=True @@ -34,7 +34,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_energyzero() -> Generator[MagicMock, None, None]: +def mock_energyzero() -> Generator[MagicMock]: """Return a mocked EnergyZero client.""" with patch( "homeassistant.components.energyzero.coordinator.EnergyZero", autospec=True diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 5ffa623fd87..23b232379df 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -1,461 +1,4 @@ # serializer version: 1 -# name: test_energy_today - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Current hour', - 'state_class': , - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', - 'last_changed': , - 'last_updated': , - 'state': '0.49', - }) -# --- -# name: test_energy_today.1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current hour', - 'platform': 'energyzero', - 'supported_features': 0, - 'translation_key': 'current_hour_price', - 'unit_of_measurement': '€/kWh', - }) -# --- -# name: test_energy_today.2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'manufacturer': 'EnergyZero', - 'model': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Average - today', - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'last_changed': , - 'last_updated': , - 'state': '0.37', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Average - today', - 'platform': 'energyzero', - 'supported_features': 0, - 'translation_key': 'average_price', - 'unit_of_measurement': '€/kWh', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'manufacturer': 'EnergyZero', - 'model': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Average - today', - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'last_changed': , - 'last_updated': , - 'state': '0.37', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_average_price', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Average - today', - 'platform': 'energyzero', - 'supported_features': 0, - 'translation_key': 'current_hour_price', - 'unit_of_measurement': '€/kWh', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'manufacturer': 'EnergyZero', - 'model': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Current hour', - 'state_class': , - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', - 'last_changed': , - 'last_updated': , - 'state': '0.49', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current hour', - 'platform': 'energyzero', - 'supported_features': 0, - 'translation_key': 'current_hour_price', - 'unit_of_measurement': '€/kWh', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'manufacturer': 'EnergyZero', - 'model': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Current hour', - 'state_class': , - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', - 'last_changed': , - 'last_updated': , - 'state': '0.49', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_current_hour_price', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Current hour', - 'platform': 'energyzero', - 'supported_features': 0, - 'translation_key': 'average_price', - 'unit_of_measurement': '€/kWh', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_current_hour_price-today_energy_current_hour_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'manufacturer': 'EnergyZero', - 'model': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'device_class': 'timestamp', - 'friendly_name': 'Energy market price Time of highest price - today', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', - 'last_changed': , - 'last_updated': , - 'state': '2022-12-07T16:00:00+00:00', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_highest_price_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Time of highest price - today', - 'platform': 'energyzero', - 'supported_features': 0, - 'translation_key': 'highest_price_time', - 'unit_of_measurement': None, - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_highest_price_time-today_energy_highest_price_time-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'manufacturer': 'EnergyZero', - 'model': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by EnergyZero', - 'friendly_name': 'Energy market price Highest price - today', - 'unit_of_measurement': '€/kWh', - }), - 'context': , - 'entity_id': 'sensor.energyzero_today_energy_max_price', - 'last_changed': , - 'last_updated': , - 'state': '0.55', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.energyzero_today_energy_max_price', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Highest price - today', - 'platform': 'energyzero', - 'supported_features': 0, - 'translation_key': 'max_price', - 'unit_of_measurement': '€/kWh', - }) -# --- -# name: test_energy_today[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'is_new': False, - 'manufacturer': 'EnergyZero', - 'model': None, - 'name': 'Energy market price', - 'name_by_user': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- # name: test_sensor[sensor.energyzero_today_energy_average_price-today_energy_average_price-today_energy] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py index 38929d7007a..03dad5a0abd 100644 --- a/tests/components/energyzero/test_services.py +++ b/tests/components/energyzero/test_services.py @@ -146,8 +146,7 @@ async def test_service_called_with_unloaded_entry( service: str, ) -> None: """Test service calls with unloaded config entry.""" - - await mock_config_entry.async_unload(hass) + await hass.config_entries.async_unload(mock_config_entry.entry_id) data = {"config_entry": mock_config_entry.entry_id, "incl_vat": True} diff --git a/tests/components/enigma2/conftest.py b/tests/components/enigma2/conftest.py index 9bbbda895bd..f879fb327d7 100644 --- a/tests/components/enigma2/conftest.py +++ b/tests/components/enigma2/conftest.py @@ -86,5 +86,16 @@ class MockDevice: """Get mock about endpoint.""" return await self._call_api("/api/about") + async def get_all_bouquets(self) -> dict: + """Get all bouquets.""" + return { + "bouquets": [ + [ + '1:7:1:0:0:0:0:0:0:0:FROM BOUQUET "userbouquet.favourites.tv" ORDER BY bouquet', + "Favourites (TV)", + ] + ] + } + async def close(self): """Mock close.""" diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py index dfca569276d..74721ce0993 100644 --- a/tests/components/enigma2/test_config_flow.py +++ b/tests/components/enigma2/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.components.enigma2.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.issue_registry import IssueRegistry +from homeassistant.helpers import issue_registry as ir from .conftest import ( EXPECTED_OPTIONS, @@ -23,6 +23,8 @@ from .conftest import ( MockDevice, ) +from tests.common import MockConfigEntry + @pytest.fixture async def user_flow(hass: HomeAssistant) -> str: @@ -41,7 +43,7 @@ async def user_flow(hass: HomeAssistant) -> str: ) async def test_form_user( hass: HomeAssistant, user_flow: str, test_config: dict[str, Any] -): +) -> None: """Test a successful user initiated flow.""" with ( patch( @@ -97,7 +99,7 @@ async def test_form_import( test_config: dict[str, Any], expected_data: dict[str, Any], expected_options: dict[str, Any], - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test we get the form with import source.""" with ( @@ -143,7 +145,7 @@ async def test_form_import_errors( hass: HomeAssistant, exception: Exception, error_type: str, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test we handle errors on import.""" with patch( @@ -164,3 +166,34 @@ async def test_form_import_errors( assert issue.issue_domain == DOMAIN assert result["type"] is FlowResultType.ABORT assert result["reason"] == error_type + + +async def test_options_flow(hass: HomeAssistant, user_flow: str) -> None: + """Test the form options.""" + + with patch( + "openwebif.api.OpenWebIfDevice.__new__", + return_value=MockDevice(), + ): + entry = MockConfigEntry(domain=DOMAIN, data=TEST_FULL, options={}, entry_id="1") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"source_bouquet": "Favourites (TV)"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert entry.options == {"source_bouquet": "Favourites (TV)"} + + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index cec9d5141cd..e403886b096 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -3767,9 +3767,6 @@ # name: test_sensor[sensor.envoy_1234_metering_status_net_consumption_ct_l3-state] None # --- -# name: test_sensor[sensor.envoy_1234_metering_status_priduction_ct-state] - None -# --- # name: test_sensor[sensor.envoy_1234_metering_status_production_ct-state] None # --- diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 2709087a543..7e1808ffa52 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -11,6 +11,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import DOMAIN, PLATFORMS +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -513,7 +514,6 @@ async def test_zero_conf_malformed_serial_property( type="mock_type", ), ) - await hass.async_block_till_done() assert "serialnum" in str(ex.value) result3 = await hass.config_entries.flow.async_configure( @@ -656,6 +656,304 @@ async def test_reauth(hass: HomeAssistant, config_entry, setup_enphase_envoy) -> assert result2["reason"] == "reauth_successful" +async def test_reconfigure( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test we can reconfiger the entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # original entry + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.2", + "username": "test-username2", + "password": "test-password2", + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # changed entry + assert config_entry.data["host"] == "1.1.1.2" + assert config_entry.data["username"] == "test-username2" + assert config_entry.data["password"] == "test-password2" + + +async def test_reconfigure_nochange( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test we get the reconfigure form and apply nochange.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # original entry + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # unchanged original entry + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + +async def test_reconfigure_otherenvoy( + hass: HomeAssistant, config_entry, setup_enphase_envoy, mock_envoy +) -> None: + """Test entering ip of other envoy and prevent changing it based on serial.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # let mock return different serial from first time, sim it's other one on changed ip + mock_envoy.serial_number = "45678" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.2", + "username": "test-username", + "password": "new-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unexpected_envoy"} + + # entry should still be original entry + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + # set serial back to original to finsich flow + mock_envoy.serial_number = "1234" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "new-password", + }, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # updated original entry + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "new-password" + + +@pytest.mark.parametrize( + "mock_authenticate", + [ + AsyncMock( + side_effect=[ + None, + EnvoyAuthenticationError("fail authentication"), + EnvoyError("cannot_connect"), + Exception("Unexpected exception"), + None, + ] + ), + ], +) +async def test_reconfigure_auth_failure( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test changing credentials for existing host with auth failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + # existing config + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + # mock failing authentication on first try + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.2", + "username": "test-username", + "password": "wrong-password", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # still original config after failure + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + # mock failing authentication on first try + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.2", + "username": "new-username", + "password": "wrong-password", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # still original config after failure + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + # mock failing authentication on first try + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.2", + "username": "other-username", + "password": "test-password", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # still original config after failure + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + # mock successful authentication and update of credentials + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.2", + "username": "test-username", + "password": "changed-password", + }, + ) + await hass.async_block_till_done() + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # updated config with new ip and changed pw + assert config_entry.data["host"] == "1.1.1.2" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "changed-password" + + +async def test_reconfigure_change_ip_to_existing( + hass: HomeAssistant, config_entry, setup_enphase_envoy +) -> None: + """Test reconfiguration to existing entry with same ip does not harm existing one.""" + other_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="65432155aaddb2007c5f6602e0c38e72", + title="Envoy 654321", + unique_id="654321", + data={ + CONF_HOST: "1.1.1.2", + CONF_NAME: "Envoy 654321", + CONF_USERNAME: "other-username", + CONF_PASSWORD: "other-password", + }, + ) + other_entry.add_to_hass(hass) + + # original other entry + assert other_entry.data["host"] == "1.1.1.2" + assert other_entry.data["username"] == "other-username" + assert other_entry.data["password"] == "other-password" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + + # original entry + assert config_entry.data["host"] == "1.1.1.1" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.2", + "username": "test-username", + "password": "test-password2", + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # updated entry + assert config_entry.data["host"] == "1.1.1.2" + assert config_entry.data["username"] == "test-username" + assert config_entry.data["password"] == "test-password2" + + # unchanged other entry + assert other_entry.data["host"] == "1.1.1.2" + assert other_entry.data["username"] == "other-username" + assert other_entry.data["password"] == "other-password" + + async def test_platforms(snapshot: SnapshotAssertion) -> None: """Test if platform list changed and requires more tests.""" assert snapshot == PLATFORMS diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 3d6a0ec5757..13727e29eac 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -38,14 +38,12 @@ async def setup_enphase_envoy_sensor_fixture(hass, config, mock_envoy): async def test_sensor( hass: HomeAssistant, + entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, setup_enphase_envoy_sensor, ) -> None: """Test enphase_envoy sensor entities.""" - entity_registry = er.async_get(hass) - assert entity_registry - # compare registered entities against snapshot of prior run entity_entries = er.async_entries_for_config_entry( entity_registry, config_entry.entry_id diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 3571c74cdcc..f2c35ab4295 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -23,26 +23,16 @@ FAKE_CONFIG = { FAKE_TITLE = "Universal title!" -def mocked_ec( - station_id=FAKE_CONFIG[CONF_STATION], - lat=FAKE_CONFIG[CONF_LATITUDE], - lon=FAKE_CONFIG[CONF_LONGITUDE], - lang=FAKE_CONFIG[CONF_LANGUAGE], - update=None, - metadata={"location": FAKE_TITLE}, -): +def mocked_ec(): """Mock the env_canada library.""" ec_mock = MagicMock() - ec_mock.station_id = station_id - ec_mock.lat = lat - ec_mock.lon = lon - ec_mock.language = lang - ec_mock.metadata = metadata + ec_mock.station_id = FAKE_CONFIG[CONF_STATION] + ec_mock.lat = FAKE_CONFIG[CONF_LATITUDE] + ec_mock.lon = FAKE_CONFIG[CONF_LONGITUDE] + ec_mock.language = FAKE_CONFIG[CONF_LANGUAGE] + ec_mock.metadata = {"location": FAKE_TITLE} - if update: - ec_mock.update = update - else: - ec_mock.update = AsyncMock() + ec_mock.update = AsyncMock() return patch( "homeassistant.components.environment_canada.config_flow.ECWeather", diff --git a/tests/components/epic_games_store/const.py b/tests/components/epic_games_store/const.py index dcd82c7e03e..f9c8b5dd581 100644 --- a/tests/components/epic_games_store/const.py +++ b/tests/components/epic_games_store/const.py @@ -23,3 +23,7 @@ DATA_FREE_GAMES_ONE = load_json_object_fixture("free_games_one.json", DOMAIN) DATA_FREE_GAMES_CHRISTMAS_SPECIAL = load_json_object_fixture( "free_games_christmas_special.json", DOMAIN ) + +DATA_FREE_GAMES_MYSTERY_SPECIAL = load_json_object_fixture( + "free_games_mystery_special.json", DOMAIN +) diff --git a/tests/components/epic_games_store/fixtures/free_games_mystery_special.json b/tests/components/epic_games_store/fixtures/free_games_mystery_special.json new file mode 100644 index 00000000000..5456e091a6b --- /dev/null +++ b/tests/components/epic_games_store/fixtures/free_games_mystery_special.json @@ -0,0 +1,541 @@ +{ + "data": { + "Catalog": { + "searchStore": { + "elements": [ + { + "title": "Lost Castle: The Old Ones Awaken", + "id": "4a88d0dc64114b20b67339c74543f859", + "namespace": "ab29925a0a9a49598adba45d108ceb3e", + "description": "Les Chasseurs de tr\u00e9sor ont creus\u00e9 trop profond\u00e9ment sous Castle Harwood, et les voil\u00e0 dans des lieux qui n\u2019auraient jamais d\u00fb sortir de l\u2019oubli.", + "effectiveDate": "2024-02-08T16:00:00.000Z", + "offerType": "ADD_ON", + "expiryDate": null, + "viewableDate": "2024-02-01T16:00:00.000Z", + "status": "ACTIVE", + "isCodeRedemptionOnly": false, + "keyImages": [ + { + "type": "OfferImageWide", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-r390n.png" + }, + { + "type": "OfferImageTall", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1qvy6.jpg" + }, + { + "type": "Thumbnail", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1qvy6.jpg" + }, + { + "type": "featuredMedia", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-5fr2h.jpg" + }, + { + "type": "featuredMedia", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-tl3jh.jpg" + }, + { + "type": "featuredMedia", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-ooqww.jpg" + }, + { + "type": "featuredMedia", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-y89ep.jpg" + }, + { + "type": "featuredMedia", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-sagu3.jpg" + }, + { + "type": "featuredMedia", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1309n.jpg" + }, + { + "type": "featuredMedia", + "url": "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1mwvz.jpg" + } + ], + "seller": { + "id": "o-ze7grkplqlrzc92lepkjv4xpaj7gn8", + "name": "Another Indie Studio Limited" + }, + "productSlug": null, + "urlSlug": "lost-castle-the-old-ones-awaken", + "url": null, + "items": [ + { + "id": "30f2fedfe5af4e9d96e151696f372a70", + "namespace": "ab29925a0a9a49598adba45d108ceb3e" + } + ], + "customAttributes": [ + { + "key": "isManuallySetRefundableType", + "value": "true" + }, + { + "key": "autoGeneratedPrice", + "value": "false" + }, + { + "key": "isManuallySetViewableDate", + "value": "true" + }, + { + "key": "isManuallySetPCReleaseDate", + "value": "false" + }, + { + "key": "isBlockchainUsed", + "value": "false" + } + ], + "categories": [ + { + "path": "addons" + }, + { + "path": "freegames" + }, + { + "path": "addons/durable" + } + ], + "tags": [ + { + "id": "1264" + }, + { + "id": "1265" + }, + { + "id": "1367" + }, + { + "id": "1370" + }, + { + "id": "1083" + }, + { + "id": "9547" + }, + { + "id": "35244" + }, + { + "id": "9549" + } + ], + "catalogNs": { + "mappings": [ + { + "pageSlug": "lost-castle-abb2e2", + "pageType": "productHome" + } + ] + }, + "offerMappings": [ + { + "pageSlug": "lost-castle-lost-castle-the-old-ones-awaken-db1545", + "pageType": "offer" + } + ], + "price": { + "totalPrice": { + "discountPrice": 359, + "originalPrice": 359, + "voucherDiscount": 0, + "discount": 0, + "currencyCode": "EUR", + "currencyInfo": { + "decimals": 2 + }, + "fmtPrice": { + "originalPrice": "3,59\u00a0\u20ac", + "discountPrice": "3,59\u00a0\u20ac", + "intermediatePrice": "3,59\u00a0\u20ac" + } + }, + "lineOffers": [ + { + "appliedRules": [] + } + ] + }, + "promotions": { + "promotionalOffers": [], + "upcomingPromotionalOffers": [ + { + "promotionalOffers": [ + { + "startDate": "2024-06-13T15:00:00.000Z", + "endDate": "2024-06-27T15:00:00.000Z", + "discountSetting": { + "discountType": "PERCENTAGE", + "discountPercentage": 50 + } + } + ] + } + ] + } + }, + { + "title": "LISA: Definitive Edition", + "id": "944b5b5d646d46bc92bc33edfe983d26", + "namespace": "ca3a9d16d131478c97fd56c138a6511a", + "description": "Explorez Olathe et d\u00e9couvrez ses terribles secrets avec LISA: Definitive Edition, qui contient le jeu de r\u00f4le narratif d'origine LISA: The Painful et sa suite, LISA: The Joyful.", + "effectiveDate": "2024-05-21T16:00:00.000Z", + "offerType": "BUNDLE", + "expiryDate": null, + "viewableDate": "2024-05-21T16:00:00.000Z", + "status": "ACTIVE", + "isCodeRedemptionOnly": false, + "keyImages": [ + { + "type": "OfferImageTall", + "url": "https://cdn1.epicgames.com/offer/ca3a9d16d131478c97fd56c138a6511a/EGS_LISATheDefinitiveEdition_DingalingProductions_Bundles_S2_1200x1600-4a9b4fc6e06e8aff136c1a3cf18292ae" + }, + { + "type": "OfferImageWide", + "url": "https://cdn1.epicgames.com/offer/ca3a9d16d131478c97fd56c138a6511a/EGS_LISATheDefinitiveEdition_DingalingProductions_Bundles_S1_2560x1440-55b66eb2046507e58eac435c21331bd5" + }, + { + "type": "Thumbnail", + "url": "https://cdn1.epicgames.com/offer/ca3a9d16d131478c97fd56c138a6511a/EGS_LISATheDefinitiveEdition_DingalingProductions_Bundles_S2_1200x1600-4a9b4fc6e06e8aff136c1a3cf18292ae" + } + ], + "seller": { + "id": "o-256f2bc2a35049a39ceae0f57d01bb", + "name": "Serenity Forge" + }, + "productSlug": "lisa-the-definitive-edition", + "urlSlug": "lisa-the-definitive-edition", + "url": null, + "items": [ + { + "id": "2cde880361534ed4bafd0a9bb502c543", + "namespace": "2052c58b9f64498386cbbbc85df90bbf" + }, + { + "id": "a7729179144d41ec9e0a7e1c09ad2f35", + "namespace": "87de7c0aad7944899fb6d2b05e13b108" + } + ], + "customAttributes": [ + { + "key": "com.epicgames.app.productSlug", + "value": "lisa-the-definitive-edition" + } + ], + "categories": [ + { + "path": "bundles" + }, + { + "path": "freegames" + }, + { + "path": "games" + }, + { + "path": "bundles/games" + }, + { + "path": "applications" + } + ], + "tags": [ + { + "id": "1367" + }, + { + "id": "1370" + }, + { + "id": "9547" + }, + { + "id": "1117" + }, + { + "id": "9549" + }, + { + "id": "1263" + } + ], + "catalogNs": { + "mappings": null + }, + "offerMappings": null, + "price": { + "totalPrice": { + "discountPrice": 2419, + "originalPrice": 2419, + "voucherDiscount": 0, + "discount": 0, + "currencyCode": "EUR", + "currencyInfo": { + "decimals": 2 + }, + "fmtPrice": { + "originalPrice": "24,19\u00a0\u20ac", + "discountPrice": "24,19\u00a0\u20ac", + "intermediatePrice": "24,19\u00a0\u20ac" + } + }, + "lineOffers": [ + { + "appliedRules": [] + } + ] + }, + "promotions": null + }, + { + "title": "Farming Simulator 22", + "id": "da9df253a7d04f6e8ba9ed175fe73d68", + "namespace": "d5241c76f178492ea1540fce45616757", + "description": "The new Farming Simulator is incoming!", + "effectiveDate": "2024-05-23T15:00:00.000Z", + "offerType": "OTHERS", + "expiryDate": null, + "viewableDate": "2024-05-16T14:25:00.000Z", + "status": "ACTIVE", + "isCodeRedemptionOnly": true, + "keyImages": [ + { + "type": "DieselStoreFrontWide", + "url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/c93edfff-e8d3-4c0d-855b-03f44f1d9cd3_2560x1440-79fcb25480b4c1faf67a97207b97b7e2_2560x1440-79fcb25480b4c1faf67a97207b97b7e2" + }, + { + "type": "OfferImageWide", + "url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/c93edfff-e8d3-4c0d-855b-03f44f1d9cd3_2560x1440-79fcb25480b4c1faf67a97207b97b7e2_2560x1440-79fcb25480b4c1faf67a97207b97b7e2" + }, + { + "type": "VaultClosed", + "url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/EN-mega-sale-vault-16x9-asset_1920x1080-a27cf3919dde320a72936374a1d47813" + } + ], + "seller": { + "id": "o-ufmrk5furrrxgsp5tdngefzt5rxdcn", + "name": "Epic Dev Test Account" + }, + "productSlug": "farming-simulator-22", + "urlSlug": "mystery-game-02", + "url": null, + "items": [ + { + "id": "8341d7c7e4534db7848cc428aa4cbe5a", + "namespace": "d5241c76f178492ea1540fce45616757" + } + ], + "customAttributes": [ + { + "key": "com.epicgames.app.freegames.vault.close", + "value": "[]" + }, + { + "key": "com.epicgames.app.blacklist", + "value": "[]" + }, + { + "key": "com.epicgames.app.freegames.vault.slug", + "value": "sales-and-specials/mega-sale-2024" + }, + { + "key": "com.epicgames.app.freegames.vault.open", + "value": "[]" + }, + { + "key": "com.epicgames.app.productSlug", + "value": "farming-simulator-22" + } + ], + "categories": [ + { + "path": "freegames/vaulted" + }, + { + "path": "freegames" + }, + { + "path": "games" + }, + { + "path": "applications" + } + ], + "tags": [], + "catalogNs": { + "mappings": [] + }, + "offerMappings": [], + "price": { + "totalPrice": { + "discountPrice": 0, + "originalPrice": 0, + "voucherDiscount": 0, + "discount": 0, + "currencyCode": "EUR", + "currencyInfo": { + "decimals": 2 + }, + "fmtPrice": { + "originalPrice": "0", + "discountPrice": "0", + "intermediatePrice": "0" + } + }, + "lineOffers": [ + { + "appliedRules": [] + } + ] + }, + "promotions": { + "promotionalOffers": [ + { + "promotionalOffers": [ + { + "startDate": "2024-05-23T15:00:00.000Z", + "endDate": "2024-05-30T15:00:00.000Z", + "discountSetting": { + "discountType": "PERCENTAGE", + "discountPercentage": 0 + } + } + ] + } + ], + "upcomingPromotionalOffers": [] + } + }, + { + "title": "Mystery Game 3", + "id": "7a872a4be7ce438082f331cfe6c26b79", + "namespace": "d5241c76f178492ea1540fce45616757", + "description": "Mystery Game 3", + "effectiveDate": "2024-05-30T15:00:00.000Z", + "offerType": "OTHERS", + "expiryDate": null, + "viewableDate": "2024-05-23T14:25:00.000Z", + "status": "ACTIVE", + "isCodeRedemptionOnly": true, + "keyImages": [ + { + "type": "DieselStoreFrontWide", + "url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/EN-mega-sale-vault-16x9-asset_1920x1080-a27cf3919dde320a72936374a1d47813_1920x1080-a27cf3919dde320a72936374a1d47813" + }, + { + "type": "VaultClosed", + "url": "https://cdn1.epicgames.com/offer/d5241c76f178492ea1540fce45616757/EN-mega-sale-vault-16x9-asset_1920x1080-a27cf3919dde320a72936374a1d47813" + } + ], + "seller": { + "id": "o-ufmrk5furrrxgsp5tdngefzt5rxdcn", + "name": "Epic Dev Test Account" + }, + "productSlug": "[]", + "urlSlug": "mystery-game-03", + "url": null, + "items": [ + { + "id": "8341d7c7e4534db7848cc428aa4cbe5a", + "namespace": "d5241c76f178492ea1540fce45616757" + } + ], + "customAttributes": [ + { + "key": "com.epicgames.app.freegames.vault.close", + "value": "[]" + }, + { + "key": "com.epicgames.app.blacklist", + "value": "[]" + }, + { + "key": "com.epicgames.app.freegames.vault.slug", + "value": "sales-and-specials/mega-sale" + }, + { + "key": "com.epicgames.app.freegames.vault.open", + "value": "[]" + }, + { + "key": "com.epicgames.app.productSlug", + "value": "[]" + } + ], + "categories": [ + { + "path": "freegames/vaulted" + }, + { + "path": "freegames" + }, + { + "path": "games" + }, + { + "path": "applications" + } + ], + "tags": [], + "catalogNs": { + "mappings": [] + }, + "offerMappings": [], + "price": { + "totalPrice": { + "discountPrice": 0, + "originalPrice": 0, + "voucherDiscount": 0, + "discount": 0, + "currencyCode": "EUR", + "currencyInfo": { + "decimals": 2 + }, + "fmtPrice": { + "originalPrice": "0", + "discountPrice": "0", + "intermediatePrice": "0" + } + }, + "lineOffers": [ + { + "appliedRules": [] + } + ] + }, + "promotions": { + "promotionalOffers": [], + "upcomingPromotionalOffers": [ + { + "promotionalOffers": [ + { + "startDate": "2024-05-30T15:00:00.000Z", + "endDate": "2024-06-06T15:00:00.000Z", + "discountSetting": { + "discountType": "PERCENTAGE", + "discountPercentage": 0 + } + } + ] + } + ] + } + } + ], + "paging": { + "count": 1000, + "total": 4 + } + } + } + }, + "extensions": {} +} diff --git a/tests/components/epic_games_store/test_helper.py b/tests/components/epic_games_store/test_helper.py index 155ccb7d211..1ca6884642e 100644 --- a/tests/components/epic_games_store/test_helper.py +++ b/tests/components/epic_games_store/test_helper.py @@ -10,16 +10,73 @@ from homeassistant.components.epic_games_store.helper import ( is_free_game, ) -from .const import DATA_ERROR_ATTRIBUTE_NOT_FOUND, DATA_FREE_GAMES_ONE +from .const import ( + DATA_ERROR_ATTRIBUTE_NOT_FOUND, + DATA_FREE_GAMES_MYSTERY_SPECIAL, + DATA_FREE_GAMES_ONE, +) -FREE_GAMES_API = DATA_FREE_GAMES_ONE["data"]["Catalog"]["searchStore"]["elements"] -FREE_GAME = FREE_GAMES_API[2] -NOT_FREE_GAME = FREE_GAMES_API[0] +GAMES_TO_TEST_FREE_OR_DISCOUNT = [ + { + "raw_game_data": DATA_FREE_GAMES_ONE["data"]["Catalog"]["searchStore"][ + "elements" + ][2], + "expected_result": True, + }, + { + "raw_game_data": DATA_FREE_GAMES_ONE["data"]["Catalog"]["searchStore"][ + "elements" + ][0], + "expected_result": False, + }, + { + "raw_game_data": DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"][ + "searchStore" + ]["elements"][1], + "expected_result": False, + }, + { + "raw_game_data": DATA_FREE_GAMES_MYSTERY_SPECIAL["data"]["Catalog"][ + "searchStore" + ]["elements"][2], + "expected_result": True, + }, +] + + +GAMES_TO_TEST_URL = [ + { + "raw_game_data": DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"][ + "searchStore" + ]["elements"][1], + "expected_result": "/p/destiny-2--bungie-30th-anniversary-pack", + }, + { + "raw_game_data": DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"][ + "searchStore" + ]["elements"][4], + "expected_result": "/bundles/qube-ultimate-bundle", + }, + { + "raw_game_data": DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"][ + "searchStore" + ]["elements"][5], + "expected_result": "/p/payday-2-c66369", + }, + { + "raw_game_data": DATA_FREE_GAMES_MYSTERY_SPECIAL["data"]["Catalog"][ + "searchStore" + ]["elements"][2], + "expected_result": "/p/farming-simulator-22", + }, +] def test_format_game_data() -> None: """Test game data format.""" - game_data = format_game_data(FREE_GAME, "fr") + game_data = format_game_data( + GAMES_TO_TEST_FREE_OR_DISCOUNT[0]["raw_game_data"], "fr" + ) assert game_data assert game_data["title"] assert game_data["description"] @@ -38,22 +95,20 @@ def test_format_game_data() -> None: ("raw_game_data", "expected_result"), [ ( - DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"]["searchStore"][ - "elements" - ][1], - "/p/destiny-2--bungie-30th-anniversary-pack", + GAMES_TO_TEST_URL[0]["raw_game_data"], + GAMES_TO_TEST_URL[0]["expected_result"], ), ( - DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"]["searchStore"][ - "elements" - ][4], - "/bundles/qube-ultimate-bundle", + GAMES_TO_TEST_URL[1]["raw_game_data"], + GAMES_TO_TEST_URL[1]["expected_result"], ), ( - DATA_ERROR_ATTRIBUTE_NOT_FOUND["data"]["Catalog"]["searchStore"][ - "elements" - ][5], - "/p/mystery-game-7", + GAMES_TO_TEST_URL[2]["raw_game_data"], + GAMES_TO_TEST_URL[2]["expected_result"], + ), + ( + GAMES_TO_TEST_URL[3]["raw_game_data"], + GAMES_TO_TEST_URL[3]["expected_result"], ), ], ) @@ -65,8 +120,22 @@ def test_get_game_url(raw_game_data: dict[str, Any], expected_result: bool) -> N @pytest.mark.parametrize( ("raw_game_data", "expected_result"), [ - (FREE_GAME, True), - (NOT_FREE_GAME, False), + ( + GAMES_TO_TEST_FREE_OR_DISCOUNT[0]["raw_game_data"], + GAMES_TO_TEST_FREE_OR_DISCOUNT[0]["expected_result"], + ), + ( + GAMES_TO_TEST_FREE_OR_DISCOUNT[1]["raw_game_data"], + GAMES_TO_TEST_FREE_OR_DISCOUNT[1]["expected_result"], + ), + ( + GAMES_TO_TEST_FREE_OR_DISCOUNT[2]["raw_game_data"], + GAMES_TO_TEST_FREE_OR_DISCOUNT[2]["expected_result"], + ), + ( + GAMES_TO_TEST_FREE_OR_DISCOUNT[3]["raw_game_data"], + GAMES_TO_TEST_FREE_OR_DISCOUNT[3]["expected_result"], + ), ], ) def test_is_free_game(raw_game_data: dict[str, Any], expected_result: bool) -> None: diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py index 000071054f1..e529746dcd0 100644 --- a/tests/components/epson/test_media_player.py +++ b/tests/components/epson/test_media_player.py @@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory from homeassistant.components.epson.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed @@ -16,9 +16,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_set_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, -): +) -> None: """Test the unique id is set on runtime.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/eq3btsmart/conftest.py b/tests/components/eq3btsmart/conftest.py index b16c5088044..92f1be29b70 100644 --- a/tests/components/eq3btsmart/conftest.py +++ b/tests/components/eq3btsmart/conftest.py @@ -11,7 +11,7 @@ from tests.components.bluetooth import generate_ble_device @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index f71b4196be6..43edca54158 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -5,8 +5,9 @@ from __future__ import annotations import asyncio from asyncio import Event from collections.abc import Awaitable, Callable +from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( APIClient, @@ -41,28 +42,28 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" @pytest.fixture(autouse=True) -def esphome_mock_async_zeroconf(mock_async_zeroconf): +def esphome_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" @pytest.fixture(autouse=True) -async def load_homeassistant(hass) -> None: +async def load_homeassistant(hass: HomeAssistant) -> None: """Load the homeassistant integration.""" assert await async_setup_component(hass, "homeassistant", {}) @pytest.fixture(autouse=True) -def mock_tts(mock_tts_cache_dir): +def mock_tts(mock_tts_cache_dir: Path) -> None: """Auto mock the tts cache.""" @pytest.fixture -def mock_config_entry(hass) -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" config_entry = MockConfigEntry( title="ESPHome Device", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 439092d9fb1..9c61a5d0615 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -30,6 +30,7 @@ from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from . import VALID_NOISE_PSK @@ -46,8 +47,9 @@ def mock_setup_entry(): yield +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_connection_works( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( @@ -88,8 +90,9 @@ async def test_user_connection_works( assert mock_client.noise_psk is None +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_connection_updates_host( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test setup up the same name updates the host.""" entry = MockConfigEntry( @@ -117,8 +120,9 @@ async def test_user_connection_updates_host( assert entry.data[CONF_HOST] == "127.0.0.1" +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_sets_unique_id( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -169,8 +173,9 @@ async def test_user_sets_unique_id( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_resolve_error( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test user step with IP resolve error.""" @@ -194,8 +199,9 @@ async def test_user_resolve_error( assert len(mock_client.disconnect.mock_calls) == 1 +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_causes_zeroconf_to_abort( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -241,8 +247,9 @@ async def test_user_causes_zeroconf_to_abort( assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_connection_error( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test user step with connection error.""" mock_client.device_info.side_effect = APIConnectionError @@ -262,8 +269,9 @@ async def test_user_connection_error( assert len(mock_client.disconnect.mock_calls) == 1 +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_with_password( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test user step with password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -292,9 +300,8 @@ async def test_user_with_password( assert mock_client.password == "password1" -async def test_user_invalid_password( - hass: HomeAssistant, mock_client, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None: """Test user step with invalid password.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -318,11 +325,11 @@ async def test_user_invalid_password( assert result["errors"] == {"base": "invalid_auth"} +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_dashboard_has_wrong_key( hass: HomeAssistant, mock_client, mock_dashboard, - mock_zeroconf: None, mock_setup_entry: None, ) -> None: """Test user step with key from dashboard that is incorrect.""" @@ -337,7 +344,7 @@ async def test_user_dashboard_has_wrong_key( ] with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=WRONG_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( @@ -365,11 +372,11 @@ async def test_user_dashboard_has_wrong_key( assert mock_client.noise_psk == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard( hass: HomeAssistant, mock_client, mock_dashboard, - mock_zeroconf: None, mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" @@ -392,7 +399,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ): result = await hass.config_entries.flow.async_init( @@ -417,12 +424,12 @@ async def test_user_discovers_name_and_gets_key_from_dashboard( "dashboard_exception", [aiohttp.ClientError(), json.JSONDecodeError("test", "test", 0)], ) +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_discovers_name_and_gets_key_from_dashboard_fails( hass: HomeAssistant, dashboard_exception: Exception, mock_client, mock_dashboard, - mock_zeroconf: None, mock_setup_entry: None, ) -> None: """Test user step can discover the name and get the key from the dashboard.""" @@ -445,7 +452,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", side_effect=dashboard_exception, ): result = await hass.config_entries.flow.async_init( @@ -473,11 +480,11 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( assert mock_client.noise_psk == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_discovers_name_and_dashboard_is_unavailable( hass: HomeAssistant, mock_client, mock_dashboard, - mock_zeroconf: None, mock_setup_entry: None, ) -> None: """Test user step can discover the name but the dashboard is unavailable.""" @@ -528,8 +535,9 @@ async def test_user_discovers_name_and_dashboard_is_unavailable( assert mock_client.noise_psk == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_login_connection_error( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test user step with connection error on login attempt.""" mock_client.device_info.return_value = DeviceInfo(uses_password=True, name="test") @@ -554,8 +562,9 @@ async def test_login_connection_error( assert result["errors"] == {"base": "connection_error"} +@pytest.mark.usefixtures("mock_zeroconf") async def test_discovery_initiation( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery importing works.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -586,8 +595,9 @@ async def test_discovery_initiation( assert result["result"].unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") async def test_discovery_no_mac( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = zeroconf.ZeroconfServiceInfo( @@ -693,8 +703,9 @@ async def test_discovery_updates_unique_id( assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_requires_psk( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test user step with requiring encryption key.""" mock_client.device_info.side_effect = RequiresEncryptionAPIError @@ -714,8 +725,9 @@ async def test_user_requires_psk( assert len(mock_client.disconnect.mock_calls) == 2 +@pytest.mark.usefixtures("mock_zeroconf") async def test_encryption_key_valid_psk( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test encryption key step with valid key.""" @@ -748,8 +760,9 @@ async def test_encryption_key_valid_psk( assert mock_client.noise_psk == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_encryption_key_invalid_psk( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test encryption key step with invalid key.""" @@ -775,9 +788,8 @@ async def test_encryption_key_invalid_psk( assert mock_client.noise_psk == INVALID_NOISE_PSK -async def test_reauth_initiation( - hass: HomeAssistant, mock_client, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None: """Test reauth initiation shows form.""" entry = MockConfigEntry( domain=DOMAIN, @@ -797,8 +809,9 @@ async def test_reauth_initiation( assert result["step_id"] == "reauth_confirm" +@pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_confirm_valid( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test reauth initiation with valid PSK.""" entry = MockConfigEntry( @@ -826,10 +839,10 @@ async def test_reauth_confirm_valid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, mock_client, - mock_zeroconf: None, mock_dashboard, mock_setup_entry: None, ) -> None: @@ -858,7 +871,7 @@ async def test_reauth_fixed_via_dashboard( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_init( @@ -877,10 +890,10 @@ async def test_reauth_fixed_via_dashboard( assert len(mock_get_encryption_key.mock_calls) == 1 +@pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( hass: HomeAssistant, mock_client, - mock_zeroconf: None, mock_dashboard, mock_config_entry, mock_setup_entry: None, @@ -901,7 +914,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_init( @@ -945,10 +958,10 @@ async def test_reauth_fixed_via_remove_password( assert mock_config_entry.data[CONF_PASSWORD] == "" +@pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_fixed_via_dashboard_at_confirm( hass: HomeAssistant, mock_client, - mock_zeroconf: None, mock_dashboard, mock_setup_entry: None, ) -> None: @@ -989,7 +1002,7 @@ async def test_reauth_fixed_via_dashboard_at_confirm( await dashboard.async_get_dashboard(hass).async_refresh() with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: # We just fetch the form @@ -1002,8 +1015,9 @@ async def test_reauth_fixed_via_dashboard_at_confirm( assert len(mock_get_encryption_key.mock_calls) == 1 +@pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_confirm_invalid( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( @@ -1043,8 +1057,9 @@ async def test_reauth_confirm_invalid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_confirm_invalid_with_unique_id( - hass: HomeAssistant, mock_client, mock_zeroconf: None, mock_setup_entry: None + hass: HomeAssistant, mock_client, mock_setup_entry: None ) -> None: """Test reauth initiation with invalid PSK.""" entry = MockConfigEntry( @@ -1165,10 +1180,10 @@ async def test_discovery_hassio(hass: HomeAssistant, mock_dashboard) -> None: assert dash.addon_slug == "mock-slug" +@pytest.mark.usefixtures("mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard( hass: HomeAssistant, mock_client, - mock_zeroconf: None, mock_dashboard, mock_setup_entry: None, ) -> None: @@ -1210,7 +1225,7 @@ async def test_zeroconf_encryption_key_via_dashboard( ] with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_configure( @@ -1231,10 +1246,10 @@ async def test_zeroconf_encryption_key_via_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( hass: HomeAssistant, mock_client, - mock_zeroconf: None, mock_dashboard, mock_setup_entry: None, ) -> None: @@ -1276,7 +1291,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( ] with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_configure( @@ -1297,10 +1312,10 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( assert mock_client.noise_psk == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf") async def test_zeroconf_no_encryption_key_via_dashboard( hass: HomeAssistant, mock_client, - mock_zeroconf: None, mock_dashboard, mock_setup_entry: None, ) -> None: @@ -1374,10 +1389,10 @@ async def test_option_flow( assert len(mock_reload.mock_calls) == int(option_value) +@pytest.mark.usefixtures("mock_zeroconf") async def test_user_discovers_name_no_dashboard( hass: HomeAssistant, mock_client, - mock_zeroconf: None, mock_setup_entry: None, ) -> None: """Test user step can discover the name and the there is not dashboard.""" @@ -1414,3 +1429,76 @@ async def test_user_discovers_name_no_dashboard( CONF_DEVICE_NAME: "test", } assert mock_client.noise_psk == VALID_NOISE_PSK + + +async def mqtt_discovery_test_abort(hass: HomeAssistant, payload: str, reason: str): + """Test discovery aborted.""" + service_info = MqttServiceInfo( + topic="esphome/discover/test", + payload=payload, + qos=0, + retain=False, + subscribed_topic="esphome/discover/#", + timestamp=None, + ) + flow = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_MQTT}, data=service_info + ) + assert flow["type"] is FlowResultType.ABORT + assert flow["reason"] == reason + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_discovery_mqtt_no_mac( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test discovery aborted if mac is missing in MQTT payload.""" + await mqtt_discovery_test_abort(hass, "{}", "mqtt_missing_mac") + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_discovery_mqtt_no_api( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test discovery aborted if api/port is missing in MQTT payload.""" + await mqtt_discovery_test_abort(hass, '{"mac":"abcdef123456"}', "mqtt_missing_api") + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_discovery_mqtt_no_ip( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test discovery aborted if ip is missing in MQTT payload.""" + await mqtt_discovery_test_abort( + hass, '{"mac":"abcdef123456","port":6053}', "mqtt_missing_ip" + ) + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_discovery_mqtt_initiation( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test discovery importing works.""" + service_info = MqttServiceInfo( + topic="esphome/discover/test", + payload='{"name":"mock_name","mac":"1122334455aa","port":6053,"ip":"192.168.43.183"}', + qos=0, + retain=False, + subscribed_topic="esphome/discover/#", + timestamp=None, + ) + flow = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_MQTT}, data=service_info + ) + + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test" + assert result["data"][CONF_HOST] == "192.168.43.183" + assert result["data"][CONF_PORT] == 6053 + + assert result["result"] + assert result["result"].unique_id == "11:22:33:44:55:aa" diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 01c1553cf42..1b0303a8a48 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -1,10 +1,11 @@ """Test ESPHome dashboard features.""" +from typing import Any from unittest.mock import patch from aioesphomeapi import DeviceInfo, InvalidAuthAPIError -from homeassistant.components.esphome import CONF_NOISE_PSK, dashboard +from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,7 +16,7 @@ from tests.common import MockConfigEntry async def test_dashboard_storage( - hass: HomeAssistant, init_integration, mock_dashboard, hass_storage + hass: HomeAssistant, init_integration, mock_dashboard, hass_storage: dict[str, Any] ) -> None: """Test dashboard storage.""" assert hass_storage[dashboard.STORAGE_KEY]["data"] == { @@ -28,8 +29,10 @@ async def test_dashboard_storage( async def test_restore_dashboard_storage( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage -) -> MockConfigEntry: + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +) -> None: """Restore dashboard url and slug from storage.""" hass_storage[dashboard.STORAGE_KEY] = { "version": dashboard.STORAGE_VERSION, @@ -46,8 +49,10 @@ async def test_restore_dashboard_storage( async def test_restore_dashboard_storage_end_to_end( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage -) -> MockConfigEntry: + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +) -> None: """Restore dashboard url and slug from storage.""" hass_storage[dashboard.STORAGE_KEY] = { "version": dashboard.STORAGE_VERSION, @@ -56,7 +61,7 @@ async def test_restore_dashboard_storage_end_to_end( "data": {"info": {"addon_slug": "test-slug", "host": "new-host", "port": 6052}}, } with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI" + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI" ) as mock_dashboard_api: await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -65,11 +70,13 @@ async def test_restore_dashboard_storage_end_to_end( async def test_setup_dashboard_fails( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage -) -> MockConfigEntry: + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +) -> None: """Test that nothing is stored on failed dashboard setup when there was no dashboard before.""" with patch.object( - dashboard.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError ) as mock_get_devices: await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -83,10 +90,14 @@ async def test_setup_dashboard_fails( async def test_setup_dashboard_fails_when_already_setup( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, hass_storage -) -> MockConfigEntry: + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +) -> None: """Test failed dashboard setup still reloads entries if one existed before.""" - with patch.object(dashboard.ESPHomeDashboardAPI, "get_devices") as mock_get_devices: + with patch.object( + coordinator.ESPHomeDashboardAPI, "get_devices" + ) as mock_get_devices: await dashboard.async_set_dashboard_info( hass, "test-slug", "working-host", 6052 ) @@ -100,7 +111,7 @@ async def test_setup_dashboard_fails_when_already_setup( with ( patch.object( - dashboard.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError + coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError ) as mock_get_devices, patch( "homeassistant.components.esphome.async_setup_entry", return_value=True @@ -145,7 +156,7 @@ async def test_new_dashboard_fix_reauth( ) with patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key: result = await hass.config_entries.flow.async_init( @@ -171,7 +182,7 @@ async def test_new_dashboard_fix_reauth( with ( patch( - "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + "homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI.get_encryption_key", return_value=VALID_NOISE_PSK, ) as mock_get_encryption_key, patch( diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 1cf4f77875f..4fb8f993aca 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import ANY +import pytest from syrupy import SnapshotAssertion from homeassistant.components import bluetooth @@ -14,11 +15,11 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("enable_bluetooth") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, - enable_bluetooth: None, mock_dashboard, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index bc633d87fae..296d61b664d 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -32,6 +32,7 @@ from .conftest import MockESPHomeDevice async def test_entities_removed( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: Callable[ @@ -40,7 +41,6 @@ async def test_entities_removed( ], ) -> None: """Test entities are removed when static info changes.""" - ent_reg = er.async_get(hass) entity_info = [ BinarySensorInfo( object_id="mybinary_sensor", @@ -86,7 +86,9 @@ async def test_entities_removed( assert state.attributes[ATTR_RESTORED] is True state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is not None - reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) assert reg_entry is not None assert state.attributes[ATTR_RESTORED] is True @@ -114,7 +116,9 @@ async def test_entities_removed( assert state.state == STATE_ON state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is None - reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) assert reg_entry is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -123,6 +127,7 @@ async def test_entities_removed( async def test_entities_removed_after_reload( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, hass_storage: dict[str, Any], mock_esphome_device: Callable[ @@ -131,7 +136,6 @@ async def test_entities_removed_after_reload( ], ) -> None: """Test entities and their registry entry are removed when static info changes after a reload.""" - ent_reg = er.async_get(hass) entity_info = [ BinarySensorInfo( object_id="mybinary_sensor", @@ -167,7 +171,9 @@ async def test_entities_removed_after_reload( assert state is not None assert state.state == STATE_ON - reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) assert reg_entry is not None assert await hass.config_entries.async_unload(entry.entry_id) @@ -182,7 +188,9 @@ async def test_entities_removed_after_reload( assert state is not None assert state.attributes[ATTR_RESTORED] is True - reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) assert reg_entry is not None assert await hass.config_entries.async_setup(entry.entry_id) @@ -196,7 +204,9 @@ async def test_entities_removed_after_reload( state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is not None assert ATTR_RESTORED not in state.attributes - reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) assert reg_entry is not None assert await hass.config_entries.async_unload(entry.entry_id) @@ -241,7 +251,9 @@ async def test_entities_removed_after_reload( await hass.async_block_till_done() - reg_entry = ent_reg.async_get("binary_sensor.test_mybinary_sensor_to_be_removed") + reg_entry = entity_registry.async_get( + "binary_sensor.test_mybinary_sensor_to_be_removed" + ) assert reg_entry is None assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index 7e008cde212..9e4c9709e7d 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -1,5 +1,7 @@ """ESPHome set up tests.""" +import pytest + from homeassistant.components.esphome import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant @@ -7,9 +9,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_delete_entry( - hass: HomeAssistant, mock_client, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_delete_entry(hass: HomeAssistant, mock_client) -> None: """Test we can delete an entry with error.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index e62c85b7f9a..c17ff9a7d8c 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -52,6 +52,7 @@ async def test_esphome_device_service_calls_not_allowed( Awaitable[MockESPHomeDevice], ], caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test a device with service calls not allowed.""" entity_info = [] @@ -74,7 +75,6 @@ async def test_esphome_device_service_calls_not_allowed( ) await hass.async_block_till_done() assert len(mock_esphome_test) == 0 - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue( "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" ) @@ -95,6 +95,7 @@ async def test_esphome_device_service_calls_allowed( Awaitable[MockESPHomeDevice], ], caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test a device with service calls are allowed.""" await async_setup_component(hass, "tag", {}) @@ -126,7 +127,6 @@ async def test_esphome_device_service_calls_allowed( ) ) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue( "esphome", "service_calls_not_enabled-11:22:33:44:55:aa" ) @@ -254,6 +254,7 @@ async def test_esphome_device_with_old_bluetooth( [APIClient, list[EntityInfo], list[UserService], list[EntityState]], Awaitable[MockESPHomeDevice], ], + issue_registry: ir.IssueRegistry, ) -> None: """Test a device with old bluetooth creates an issue.""" entity_info = [] @@ -267,7 +268,6 @@ async def test_esphome_device_with_old_bluetooth( device_info={"bluetooth_proxy_feature_flags": 1, "esphome_version": "2023.3.0"}, ) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue( "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) @@ -284,6 +284,7 @@ async def test_esphome_device_with_password( [APIClient, list[EntityInfo], list[UserService], list[EntityState]], Awaitable[MockESPHomeDevice], ], + issue_registry: ir.IssueRegistry, ) -> None: """Test a device with legacy password creates an issue.""" entity_info = [] @@ -308,7 +309,6 @@ async def test_esphome_device_with_password( entry=entry, ) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) assert ( issue_registry.async_get_issue( # This issue uses the ESPHome mac address which @@ -327,6 +327,7 @@ async def test_esphome_device_with_current_bluetooth( [APIClient, list[EntityInfo], list[UserService], list[EntityState]], Awaitable[MockESPHomeDevice], ], + issue_registry: ir.IssueRegistry, ) -> None: """Test a device with recent bluetooth does not create an issue.""" entity_info = [] @@ -343,7 +344,6 @@ 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 @@ -354,9 +354,8 @@ async def test_esphome_device_with_current_bluetooth( ) -async def test_unique_id_updated_to_mac( - hass: HomeAssistant, mock_client, mock_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_zeroconf") +async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> None: """Test we update config entry unique ID to MAC address.""" entry = MockConfigEntry( domain=DOMAIN, @@ -384,8 +383,9 @@ async def test_unique_id_updated_to_mac( assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( - hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we never update the entry unique ID event if the name is the same.""" entry = MockConfigEntry( @@ -418,8 +418,9 @@ async def test_unique_id_not_updated_if_name_same_and_already_mac( assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_updated_if_name_unset_and_already_mac( - hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we never update config entry unique ID even if the name is unset.""" entry = MockConfigEntry( @@ -447,8 +448,9 @@ async def test_unique_id_updated_if_name_unset_and_already_mac( assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_different_and_already_mac( - hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we do not update config entry unique ID if the name is different.""" entry = MockConfigEntry( @@ -483,8 +485,9 @@ async def test_unique_id_not_updated_if_name_different_and_already_mac( assert entry.data[CONF_DEVICE_NAME] == "test" +@pytest.mark.usefixtures("mock_zeroconf") async def test_name_updated_only_if_mac_matches( - hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we update config entry name only if the mac matches.""" entry = MockConfigEntry( @@ -517,8 +520,9 @@ async def test_name_updated_only_if_mac_matches( assert entry.data[CONF_DEVICE_NAME] == "new" +@pytest.mark.usefixtures("mock_zeroconf") async def test_name_updated_only_if_mac_was_unset( - hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None + hass: HomeAssistant, mock_client: APIClient ) -> None: """Test we update config entry name if the old unique id was not a mac.""" entry = MockConfigEntry( @@ -551,10 +555,10 @@ async def test_name_updated_only_if_mac_was_unset( assert entry.data[CONF_DEVICE_NAME] == "new" +@pytest.mark.usefixtures("mock_zeroconf") async def test_connection_aborted_wrong_device( hass: HomeAssistant, mock_client: APIClient, - mock_zeroconf: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test we abort the connection if the unique id is a mac and neither name or mac match.""" @@ -615,10 +619,10 @@ async def test_connection_aborted_wrong_device( assert "Unexpected device found at" not in caplog.text +@pytest.mark.usefixtures("mock_zeroconf") async def test_failure_during_connect( hass: HomeAssistant, mock_client: APIClient, - mock_zeroconf: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test we disconnect when there is a failure during connection setup.""" @@ -968,6 +972,7 @@ async def test_esphome_user_services_changes( async def test_esphome_device_with_suggested_area( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: Callable[ [APIClient, list[EntityInfo], list[UserService], list[EntityState]], @@ -983,9 +988,8 @@ async def test_esphome_device_with_suggested_area( states=[], ) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) entry = device.entry - dev = dev_reg.async_get_device( + dev = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) assert dev.suggested_area == "kitchen" @@ -993,6 +997,7 @@ async def test_esphome_device_with_suggested_area( async def test_esphome_device_with_project( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: Callable[ [APIClient, list[EntityInfo], list[UserService], list[EntityState]], @@ -1008,9 +1013,8 @@ async def test_esphome_device_with_project( states=[], ) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) entry = device.entry - dev = dev_reg.async_get_device( + dev = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) assert dev.manufacturer == "mfr" @@ -1020,6 +1024,7 @@ async def test_esphome_device_with_project( async def test_esphome_device_with_manufacturer( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: Callable[ [APIClient, list[EntityInfo], list[UserService], list[EntityState]], @@ -1035,9 +1040,8 @@ async def test_esphome_device_with_manufacturer( states=[], ) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) entry = device.entry - dev = dev_reg.async_get_device( + dev = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) assert dev.manufacturer == "acme" @@ -1045,6 +1049,7 @@ async def test_esphome_device_with_manufacturer( async def test_esphome_device_with_web_server( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: Callable[ [APIClient, list[EntityInfo], list[UserService], list[EntityState]], @@ -1060,9 +1065,8 @@ async def test_esphome_device_with_web_server( states=[], ) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) entry = device.entry - dev = dev_reg.async_get_device( + dev = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) assert dev.configuration_url == "http://test.local:80" @@ -1070,6 +1074,7 @@ async def test_esphome_device_with_web_server( async def test_esphome_device_with_compilation_time( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_client: APIClient, mock_esphome_device: Callable[ [APIClient, list[EntityInfo], list[UserService], list[EntityState]], @@ -1085,9 +1090,8 @@ async def test_esphome_device_with_compilation_time( states=[], ) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) entry = device.entry - dev = dev_reg.async_get_device( + dev = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} ) assert "comp_time" in dev.sw_version diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 8a3630b92a4..3879129ccb6 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -13,6 +13,7 @@ import pytest from homeassistant.components import media_source from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_VOLUME_LEVEL, @@ -247,7 +248,7 @@ async def test_media_player_entity_with_source( ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="http://www.example.com/xy.mp3")] + [call(1, media_url="http://www.example.com/xy.mp3", announcement=None)] ) client = await hass_ws_client() @@ -268,10 +269,11 @@ async def test_media_player_entity_with_source( ATTR_ENTITY_ID: "media_player.test_mymedia_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, ATTR_MEDIA_CONTENT_ID: "media-source://tts?message=hello", + ATTR_MEDIA_ANNOUNCE: True, }, blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="media-source://tts?message=hello")] + [call(1, media_url="media-source://tts?message=hello", announcement=True)] ) diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 9f8e45ed64d..bebfaaa69d4 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -97,6 +97,7 @@ async def test_generic_numeric_sensor( async def test_generic_numeric_sensor_with_entity_category_and_icon( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, mock_generic_device_entry, ) -> None: @@ -123,8 +124,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( assert state is not None assert state.state == "50" assert state.attributes[ATTR_ICON] == "mdi:leaf" - entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.test_mysensor") + entry = entity_registry.async_get("sensor.test_mysensor") 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) @@ -134,6 +134,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( async def test_generic_numeric_sensor_state_class_measurement( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_client: APIClient, mock_generic_device_entry, ) -> None: @@ -161,8 +162,7 @@ async def test_generic_numeric_sensor_state_class_measurement( assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.test_mysensor") + entry = entity_registry.async_get("sensor.test_mysensor") 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) diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index b3deb2f33ee..fc845299142 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -3,12 +3,24 @@ from collections.abc import Awaitable, Callable from unittest.mock import Mock, patch -from aioesphomeapi import APIClient, EntityInfo, EntityState, UserService +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UpdateInfo, + UpdateState, + UserService, +) import pytest from homeassistant.components.esphome.dashboard import async_get_dashboard -from homeassistant.components.update import UpdateEntityFeature +from homeassistant.components.update import ( + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, + UpdateEntityFeature, +) from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON, @@ -83,11 +95,10 @@ async def test_update_entity( with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info), + return_value=Mock(available=True, device_info=mock_device_info, info={}), ): - assert await hass.config_entries.async_forward_entry_setup( - mock_config_entry, "update" - ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("update.none_firmware") assert state is not None @@ -267,7 +278,7 @@ async def test_update_entity_dashboard_not_available_startup( with ( patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info), + return_value=Mock(available=True, device_info=mock_device_info, info={}), ), patch( "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", @@ -275,9 +286,8 @@ async def test_update_entity_dashboard_not_available_startup( ), ): await async_get_dashboard(hass).async_refresh() - assert await hass.config_entries.async_forward_entry_setup( - mock_config_entry, "update" - ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() # We have a dashboard but it is not available state = hass.states.get("update.none_firmware") @@ -360,11 +370,10 @@ async def test_update_entity_not_present_without_dashboard( """Test ESPHome update entity does not get created if there is no dashboard.""" with patch( "homeassistant.components.esphome.update.DomainData.get_entry_data", - return_value=Mock(available=True, device_info=mock_device_info), + return_value=Mock(available=True, device_info=mock_device_info, info={}), ): - assert await hass.config_entries.async_forward_entry_setup( - mock_config_entry, "update" - ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("update.none_firmware") assert state is None @@ -411,3 +420,104 @@ async def test_update_becomes_available_at_runtime( # We now know the version so install is enabled features = state.attributes[ATTR_SUPPORTED_FEATURES] assert features is UpdateEntityFeature.INSTALL + + +async def test_generic_device_update_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic device update entity.""" + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + unique_id="my_update", + ) + ] + states = [ + UpdateState( + key=1, + current_version="2024.6.0", + latest_version="2024.6.0", + title="ESPHome Project", + release_summary="This is a release summary", + release_url="https://esphome.io/changelog", + ) + ] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("update.test_myupdate") + assert state is not None + assert state.state == STATE_OFF + + +async def test_generic_device_update_entity_has_update( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic device update entity with an update.""" + entity_info = [ + UpdateInfo( + object_id="myupdate", + key=1, + name="my update", + unique_id="my_update", + ) + ] + states = [ + UpdateState( + key=1, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary="This is a release summary", + release_url="https://esphome.io/changelog", + ) + ] + user_service = [] + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("update.test_myupdate") + assert state is not None + assert state.state == STATE_ON + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.test_myupdate"}, + blocking=True, + ) + + mock_device.set_state( + UpdateState( + key=1, + in_progress=True, + has_progress=True, + progress=50, + current_version="2024.6.0", + latest_version="2024.6.1", + title="ESPHome Project", + release_summary="This is a release summary", + release_url="https://esphome.io/changelog", + ) + ) + + state = hass.states.get("update.test_myupdate") + assert state is not None + assert state.state == STATE_ON + assert state.attributes["in_progress"] == 50 diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index e67d833656e..bcd49f91c03 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -1,12 +1,21 @@ """Test ESPHome voice assistant server.""" import asyncio +from collections.abc import Awaitable, Callable import io import socket -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import wave -from aioesphomeapi import APIClient, VoiceAssistantEventType +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, + VoiceAssistantEventType, + VoiceAssistantFeature, + VoiceAssistantTimerEventType, +) import pytest from homeassistant.components.assist_pipeline import ( @@ -15,6 +24,7 @@ from homeassistant.components.assist_pipeline import ( PipelineStage, ) from homeassistant.components.assist_pipeline.error import ( + PipelineNotFound, WakeWordDetectionAborted, WakeWordDetectionError, ) @@ -24,6 +34,10 @@ from homeassistant.components.esphome.voice_assistant import ( VoiceAssistantUDPPipeline, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent as intent_helper +import homeassistant.helpers.device_registry as dr + +from .conftest import MockESPHomeDevice _TEST_INPUT_TEXT = "This is an input test" _TEST_OUTPUT_TEXT = "This is an output test" @@ -85,7 +99,7 @@ def voice_assistant_udp_pipeline_v2( @pytest.fixture -def test_wav() -> bytes: +def mock_wav() -> bytes: """Return one second of empty WAV audio.""" with io.BytesIO() as wav_io: with wave.open(wav_io, "wb") as wav_file: @@ -172,10 +186,9 @@ async def test_pipeline_events( ) +@pytest.mark.usefixtures("socket_enabled") async def test_udp_server( - hass: HomeAssistant, - socket_enabled, - unused_udp_port_factory, + unused_udp_port_factory: Callable[[], int], voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, ) -> None: """Test the UDP server runs and queues incoming data.""" @@ -299,10 +312,9 @@ async def test_error_calls_handle_finished( voice_assistant_udp_pipeline_v1.handle_finished.assert_called() +@pytest.mark.usefixtures("socket_enabled") async def test_udp_server_multiple( - hass: HomeAssistant, - socket_enabled, - unused_udp_port_factory, + unused_udp_port_factory: Callable[[], int], voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, ) -> None: """Test that the UDP server raises an error if started twice.""" @@ -322,10 +334,9 @@ async def test_udp_server_multiple( await voice_assistant_udp_pipeline_v1.start_server() +@pytest.mark.usefixtures("socket_enabled") async def test_udp_server_after_stopped( - hass: HomeAssistant, - socket_enabled, - unused_udp_port_factory, + unused_udp_port_factory: Callable[[], int], voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, ) -> None: """Test that the UDP server raises an error if started after stopped.""" @@ -340,6 +351,87 @@ async def test_udp_server_after_stopped( await voice_assistant_udp_pipeline_v1.start_server() +async def test_events_converted_correctly( + hass: HomeAssistant, + voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, +) -> None: + """Test the pipeline events produce the correct data to send to the device.""" + + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantPipeline._send_tts", + ): + voice_assistant_api_pipeline._event_callback( + PipelineEvent( + type=PipelineEventType.STT_START, + data={}, + ) + ) + + voice_assistant_api_pipeline.handle_event.assert_called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_STT_START, None + ) + + voice_assistant_api_pipeline._event_callback( + PipelineEvent( + type=PipelineEventType.STT_END, + data={"stt_output": {"text": "text"}}, + ) + ) + + voice_assistant_api_pipeline.handle_event.assert_called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_STT_END, {"text": "text"} + ) + + voice_assistant_api_pipeline._event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_START, + data={}, + ) + ) + + voice_assistant_api_pipeline.handle_event.assert_called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START, None + ) + + voice_assistant_api_pipeline._event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "conversation-id", + } + }, + ) + ) + + voice_assistant_api_pipeline.handle_event.assert_called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, + {"conversation_id": "conversation-id"}, + ) + + voice_assistant_api_pipeline._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_START, + data={"tts_input": "text"}, + ) + ) + + voice_assistant_api_pipeline.handle_event.assert_called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START, {"text": "text"} + ) + + voice_assistant_api_pipeline._event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={"tts_output": {"url": "url", "media_id": "media-id"}}, + ) + ) + + voice_assistant_api_pipeline.handle_event.assert_called_with( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END, {"url": "url"} + ) + + async def test_unknown_event_type( hass: HomeAssistant, voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, @@ -465,12 +557,12 @@ async def test_send_tts_not_called_when_empty( async def test_send_tts_udp( hass: HomeAssistant, voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, - test_wav, + mock_wav: bytes, ) -> None: """Test the UDP server calls sendto to transmit audio data to device.""" with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", test_wav), + return_value=("wav", mock_wav), ): voice_assistant_udp_pipeline_v2.started = True voice_assistant_udp_pipeline_v2.transport = Mock(spec=asyncio.DatagramTransport) @@ -498,12 +590,12 @@ async def test_send_tts_api( hass: HomeAssistant, mock_client: APIClient, voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, - test_wav, + mock_wav: bytes, ) -> None: """Test the API pipeline calls cli.send_voice_assistant_audio to transmit audio data to device.""" with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", test_wav), + return_value=("wav", mock_wav), ): voice_assistant_api_pipeline.started = True @@ -537,12 +629,9 @@ async def test_send_tts_wrong_sample_rate( wav_file.writeframes(bytes(_ONE_SECOND)) wav_bytes = wav_io.getvalue() - with ( - patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", wav_bytes), - ), - pytest.raises(ValueError), + with patch( + "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", + return_value=("wav", wav_bytes), ): voice_assistant_api_pipeline.started = True voice_assistant_api_pipeline.transport = Mock(spec=asyncio.DatagramTransport) @@ -557,7 +646,8 @@ async def test_send_tts_wrong_sample_rate( ) assert voice_assistant_api_pipeline._tts_task is not None - await voice_assistant_api_pipeline._tts_task # raises ValueError + with pytest.raises(ValueError): + await voice_assistant_api_pipeline._tts_task async def test_send_tts_wrong_format( @@ -570,7 +660,6 @@ async def test_send_tts_wrong_format( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", return_value=("raw", bytes(1024)), ), - pytest.raises(ValueError), ): voice_assistant_api_pipeline.started = True voice_assistant_api_pipeline.transport = Mock(spec=asyncio.DatagramTransport) @@ -585,18 +674,19 @@ async def test_send_tts_wrong_format( ) assert voice_assistant_api_pipeline._tts_task is not None - await voice_assistant_api_pipeline._tts_task # raises ValueError + with pytest.raises(ValueError): + await voice_assistant_api_pipeline._tts_task async def test_send_tts_not_started( hass: HomeAssistant, voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, - test_wav, + mock_wav: bytes, ) -> None: """Test the UDP server does not call sendto when not started.""" with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", test_wav), + return_value=("wav", mock_wav), ): voice_assistant_udp_pipeline_v2.started = False voice_assistant_udp_pipeline_v2.transport = Mock(spec=asyncio.DatagramTransport) @@ -618,13 +708,13 @@ async def test_send_tts_not_started( async def test_send_tts_transport_none( hass: HomeAssistant, voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, - test_wav, + mock_wav: bytes, caplog: pytest.LogCaptureFixture, ) -> None: """Test the UDP server does not call sendto when transport is None.""" with patch( "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", test_wav), + return_value=("wav", mock_wav), ): voice_assistant_udp_pipeline_v2.started = True voice_assistant_udp_pipeline_v2.transport = None @@ -719,3 +809,131 @@ async def test_wake_word_abort_exception( ) mock_handle_event.assert_not_called() + + +async def test_timer_events( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that injecting timer events results in the correct api client calls.""" + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.TIMERS + }, + ) + dev_reg = dr.async_get(hass) + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=dev.id, + ) + + mock_client.send_voice_assistant_timer_event.assert_called_with( + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED, + ANY, + "test timer", + 3723, + 3723, + True, + ) + + +async def test_unknown_timer_event( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that unknown (new) timer event types do not result in api calls.""" + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.TIMERS + }, + ) + dev_reg = dr.async_get(hass) + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + with patch( + "homeassistant.components.esphome.voice_assistant._TIMER_EVENT_TYPES.from_hass", + side_effect=KeyError, + ): + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=dev.id, + ) + + mock_client.send_voice_assistant_timer_event.assert_not_called() + + +async def test_invalid_pipeline_id( + hass: HomeAssistant, + voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, +) -> None: + """Test that the pipeline is set to start with Wake word.""" + + invalid_pipeline_id = "invalid-pipeline-id" + + async def async_pipeline_from_audio_stream(*args, **kwargs): + raise PipelineNotFound( + "pipeline_not_found", f"Pipeline {invalid_pipeline_id} not found" + ) + + with patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ): + + def handle_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert data is not None + assert data["code"] == "pipeline_not_found" + assert data["message"] == f"Pipeline {invalid_pipeline_id} not found" + + voice_assistant_api_pipeline.handle_event = handle_event + + await voice_assistant_api_pipeline.run_pipeline( + device_id="mock-device-id", + conversation_id=None, + flags=2, + ) diff --git a/tests/components/eufylife_ble/conftest.py b/tests/components/eufylife_ble/conftest.py index 18f5a0ec3a1..210f3dbed69 100644 --- a/tests/components/eufylife_ble/conftest.py +++ b/tests/components/eufylife_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index fd3cf0eaf9b..981a7744beb 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -1,10 +1,10 @@ """The tests for the event integration.""" -from collections.abc import Generator from typing import Any from freezegun import freeze_time import pytest +from typing_extensions import Generator from homeassistant.components.event import ( ATTR_EVENT_TYPE, @@ -238,7 +238,7 @@ class MockFlow(ConfigFlow): @pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -254,7 +254,7 @@ async def test_name(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True mock_platform(hass, f"{TEST_DOMAIN}.config_flow") diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 7872cf37b68..9fc297be099 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -90,19 +90,12 @@ def _patch_async_setup_entry(return_value=True): ) -async def init_integration( - hass: HomeAssistant, - *, - data: dict = ENTRY_CONFIG, - options: dict = ENTRY_OPTIONS, - skip_entry_setup: bool = False, -) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the EZVIZ integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=data, options=options) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS) entry.add_to_hass(hass) - if not skip_entry_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index bbaa1f12516..77ae544646d 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -10,13 +10,15 @@ from homeassistant.core import HomeAssistant @pytest.fixture -def facebook(): +def facebook() -> fb.FacebookNotificationService: """Fixture for facebook.""" access_token = "page-access-token" return fb.FacebookNotificationService(access_token) -async def test_send_simple_message(hass: HomeAssistant, facebook) -> None: +async def test_send_simple_message( + hass: HomeAssistant, facebook: fb.FacebookNotificationService +) -> None: """Test sending a simple message with success.""" with requests_mock.Mocker() as mock: mock.register_uri(requests_mock.POST, fb.BASE_URL, status_code=HTTPStatus.OK) @@ -40,7 +42,9 @@ async def test_send_simple_message(hass: HomeAssistant, facebook) -> None: assert mock.last_request.qs == expected_params -async def test_send_multiple_message(hass: HomeAssistant, facebook) -> None: +async def test_send_multiple_message( + hass: HomeAssistant, facebook: fb.FacebookNotificationService +) -> None: """Test sending a message to multiple targets.""" with requests_mock.Mocker() as mock: mock.register_uri(requests_mock.POST, fb.BASE_URL, status_code=HTTPStatus.OK) @@ -66,7 +70,9 @@ async def test_send_multiple_message(hass: HomeAssistant, facebook) -> None: assert request.qs == expected_params -async def test_send_message_attachment(hass: HomeAssistant, facebook) -> None: +async def test_send_message_attachment( + hass: HomeAssistant, facebook: fb.FacebookNotificationService +) -> None: """Test sending a message with a remote attachment.""" with requests_mock.Mocker() as mock: mock.register_uri(requests_mock.POST, fb.BASE_URL, status_code=HTTPStatus.OK) @@ -95,32 +101,36 @@ async def test_send_message_attachment(hass: HomeAssistant, facebook) -> None: expected_params = {"access_token": ["page-access-token"]} assert mock.last_request.qs == expected_params - async def test_send_targetless_message(hass, facebook): - """Test sending a message without a target.""" - with requests_mock.Mocker() as mock: - mock.register_uri( - requests_mock.POST, fb.BASE_URL, status_code=HTTPStatus.OK - ) - facebook.send_message(message="going nowhere") - assert not mock.called +async def test_send_targetless_message( + hass: HomeAssistant, facebook: fb.FacebookNotificationService +) -> None: + """Test sending a message without a target.""" + with requests_mock.Mocker() as mock: + mock.register_uri(requests_mock.POST, fb.BASE_URL, status_code=HTTPStatus.OK) - async def test_send_message_with_400(hass, facebook): - """Test sending a message with a 400 from Facebook.""" - with requests_mock.Mocker() as mock: - mock.register_uri( - requests_mock.POST, - fb.BASE_URL, - status_code=HTTPStatus.BAD_REQUEST, - json={ - "error": { - "message": "Invalid OAuth access token.", - "type": "OAuthException", - "code": 190, - "fbtrace_id": "G4Da2pFp2Dp", - } - }, - ) - facebook.send_message(message="nope!", target=["+15555551234"]) - assert mock.called - assert mock.call_count == 1 + facebook.send_message(message="going nowhere") + assert not mock.called + + +async def test_send_message_with_400( + hass: HomeAssistant, facebook: fb.FacebookNotificationService +) -> None: + """Test sending a message with a 400 from Facebook.""" + with requests_mock.Mocker() as mock: + mock.register_uri( + requests_mock.POST, + fb.BASE_URL, + status_code=HTTPStatus.BAD_REQUEST, + json={ + "error": { + "message": "Invalid OAuth access token.", + "type": "OAuthException", + "code": 190, + "fbtrace_id": "G4Da2pFp2Dp", + } + }, + ) + facebook.send_message(message="nope!", target=["+15555551234"]) + assert mock.called + assert mock.call_count == 1 diff --git a/tests/components/fan/common.py b/tests/components/fan/common.py index fbc7c7bb1bb..0b4243e4144 100644 --- a/tests/components/fan/common.py +++ b/tests/components/fan/common.py @@ -25,12 +25,13 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) +from homeassistant.core import HomeAssistant from tests.common import MockEntity async def async_turn_on( - hass, + hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL, percentage: int | None = None, preset_mode: str | None = None, @@ -38,11 +39,11 @@ async def async_turn_on( """Turn all or specified fan on.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage), (ATTR_PRESET_MODE, preset_mode), - ] + ) if value is not None } @@ -50,7 +51,7 @@ async def async_turn_on( await hass.async_block_till_done() -async def async_turn_off(hass, entity_id=ENTITY_MATCH_ALL) -> None: +async def async_turn_off(hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL) -> None: """Turn all or specified fan off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -59,15 +60,15 @@ async def async_turn_off(hass, entity_id=ENTITY_MATCH_ALL) -> None: async def async_oscillate( - hass, entity_id=ENTITY_MATCH_ALL, should_oscillate: bool = True + hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL, should_oscillate: bool = True ) -> None: """Set oscillation on all or specified fan.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_ENTITY_ID, entity_id), (ATTR_OSCILLATING, should_oscillate), - ] + ) if value is not None } @@ -76,12 +77,12 @@ async def async_oscillate( async def async_set_preset_mode( - hass, entity_id=ENTITY_MATCH_ALL, preset_mode: str | None = None + hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL, preset_mode: str | None = None ) -> None: """Set preset mode for all or specified fan.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)) if value is not None } @@ -90,12 +91,12 @@ async def async_set_preset_mode( async def async_set_percentage( - hass, entity_id=ENTITY_MATCH_ALL, percentage: int | None = None + hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL, percentage: int | None = None ) -> None: """Set percentage for all or specified fan.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)) if value is not None } @@ -104,15 +105,15 @@ async def async_set_percentage( async def async_increase_speed( - hass, entity_id=ENTITY_MATCH_ALL, percentage_step: int | None = None + hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL, percentage_step: int | None = None ) -> None: """Increase speed for all or specified fan.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE_STEP, percentage_step), - ] + ) if value is not None } @@ -121,15 +122,15 @@ async def async_increase_speed( async def async_decrease_speed( - hass, entity_id=ENTITY_MATCH_ALL, percentage_step: int | None = None + hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL, percentage_step: int | None = None ) -> None: """Decrease speed for all or specified fan.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE_STEP, percentage_step), - ] + ) if value is not None } @@ -138,12 +139,12 @@ async def async_decrease_speed( async def async_set_direction( - hass, entity_id=ENTITY_MATCH_ALL, direction: str | None = None + hass: HomeAssistant, entity_id=ENTITY_MATCH_ALL, direction: str | None = None ) -> None: """Set direction for all or specified fan.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_DIRECTION, direction)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_DIRECTION, direction)) if value is not None } diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 96e02ab5592..647e45374ac 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -48,7 +48,7 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in ["turn_on", "turn_off", "toggle"] + for action in ("turn_on", "turn_off", "toggle") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -96,7 +96,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["turn_on", "turn_off", "toggle"] + for action in ("turn_on", "turn_off", "toggle") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 72e1dfb4ca2..9f9bde1a680 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -7,7 +7,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.fan import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -25,7 +25,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -54,7 +54,7 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -102,7 +102,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -114,7 +114,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -199,7 +199,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index a217a5d89ec..38f39376592 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -9,7 +9,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.fan import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -59,7 +59,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["turned_off", "turned_on", "changed_states"] + for trigger in ("turned_off", "turned_on", "changed_states") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -107,7 +107,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["turned_off", "turned_on", "changed_states"] + for trigger in ("turned_off", "turned_on", "changed_states") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -180,7 +180,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -293,7 +293,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -353,7 +353,7 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index e6bcc5542bd..04f594b959c 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -16,12 +16,13 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component +from .common import MockFan + from tests.common import ( help_test_all, import_and_test_deprecated_constant_enum, setup_test_component_platform, ) -from tests.components.fan.common import MockFan class BaseFan(FanEntity): @@ -148,7 +149,7 @@ async def test_preset_mode_validation( }, blocking=True, ) - assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_key == "not_valid_preset_mode" with pytest.raises(NotValidPresetModeError) as exc: await test_fan._valid_preset_mode_or_raise("invalid") diff --git a/tests/components/fastdotcom/test_config_flow.py b/tests/components/fastdotcom/test_config_flow.py index db28aaec703..88dda3a4aae 100644 --- a/tests/components/fastdotcom/test_config_flow.py +++ b/tests/components/fastdotcom/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import patch import pytest -from homeassistant import config_entries from homeassistant.components.fastdotcom.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.core import HomeAssistant @@ -54,19 +53,3 @@ async def test_single_instance_allowed( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" - - -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test import flow.""" - with patch("homeassistant.components.fastdotcom.coordinator.fast_com"): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Fast.com" - assert result["data"] == {} - assert result["options"] == {} diff --git a/tests/components/fastdotcom/test_init.py b/tests/components/fastdotcom/test_init.py index c17b455057b..ac7708a3c36 100644 --- a/tests/components/fastdotcom/test_init.py +++ b/tests/components/fastdotcom/test_init.py @@ -10,8 +10,6 @@ from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState 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 @@ -37,23 +35,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert config_entry.state is 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: @@ -88,32 +69,3 @@ async def test_delayed_speedtest_during_startup( assert state.state == "5.0" assert config_entry.state is 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_service.py b/tests/components/fastdotcom/test_service.py deleted file mode 100644 index 8747beb6245..00000000000 --- a/tests/components/fastdotcom/test_service.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Test Fastdotcom service.""" - -from unittest.mock import patch - -import pytest - -from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN, SERVICE_NAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError - -from tests.common import MockConfigEntry - - -async def test_service(hass: HomeAssistant) -> None: - """Test the Fastdotcom 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=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 == "0" - - with patch( - "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 - ): - await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) - - state = hass.states.get("sensor.fast_com_download") - assert state is not None - assert state.state == "5.0" - - -async def test_service_unloaded_entry(hass: HomeAssistant) -> None: - """Test service called when config entry unloaded.""" - 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=0 - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry - await config_entry.async_unload(hass) - - with pytest.raises(HomeAssistantError) as exc: - await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) - - assert "Fast.com is not loaded" in str(exc) - - -async def test_service_removed_entry(hass: HomeAssistant) -> None: - """Test service called when config entry was removed and HA was not restarted yet.""" - 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=0 - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry - await hass.config_entries.async_remove(config_entry.entry_id) - - with pytest.raises(HomeAssistantError) as exc: - await hass.services.async_call(DOMAIN, SERVICE_NAME, blocking=True) - - assert "No Fast.com config entries found" in str(exc) diff --git a/tests/components/feedreader/__init__.py b/tests/components/feedreader/__init__.py index 3667f7c75ea..cb017ed944d 100644 --- a/tests/components/feedreader/__init__.py +++ b/tests/components/feedreader/__init__.py @@ -1 +1,48 @@ """Tests for the feedreader component.""" + +from typing import Any +from unittest.mock import patch + +from homeassistant.components.feedreader.const import CONF_MAX_ENTRIES, DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +def load_fixture_bytes(src: str) -> bytes: + """Return byte stream of fixture.""" + feed_data = load_fixture(src, DOMAIN) + return bytes(feed_data, "utf-8") + + +def create_mock_entry( + data: dict[str, Any], +) -> MockConfigEntry: + """Create config entry mock from data.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: data[CONF_URL]}, + options={CONF_MAX_ENTRIES: data[CONF_MAX_ENTRIES]}, + ) + + +async def async_setup_config_entry( + hass: HomeAssistant, + data: dict[str, Any], + return_value: bytes | None = None, + side_effect: bytes | None = None, +) -> bool: + """Do setup of a MockConfigEntry.""" + entry = create_mock_entry(data) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + ) as feedparser: + if return_value: + feedparser.return_value = return_value + if side_effect: + feedparser.side_effect = side_effect + result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return result diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py new file mode 100644 index 00000000000..0a5342615a9 --- /dev/null +++ b/tests/components/feedreader/conftest.py @@ -0,0 +1,58 @@ +"""Fixtures for the tests for the feedreader component.""" + +import pytest + +from homeassistant.components.feedreader.coordinator import EVENT_FEEDREADER +from homeassistant.core import Event, HomeAssistant + +from . import load_fixture_bytes + +from tests.common import async_capture_events + + +@pytest.fixture(name="feed_one_event") +def fixture_feed_one_event(hass: HomeAssistant) -> bytes: + """Load test feed data for one event.""" + return load_fixture_bytes("feedreader.xml") + + +@pytest.fixture(name="feed_two_event") +def fixture_feed_two_events(hass: HomeAssistant) -> bytes: + """Load test feed data for two event.""" + return load_fixture_bytes("feedreader1.xml") + + +@pytest.fixture(name="feed_21_events") +def fixture_feed_21_events(hass: HomeAssistant) -> bytes: + """Load test feed data for twenty one events.""" + return load_fixture_bytes("feedreader2.xml") + + +@pytest.fixture(name="feed_three_events") +def fixture_feed_three_events(hass: HomeAssistant) -> bytes: + """Load test feed data for three events.""" + return load_fixture_bytes("feedreader3.xml") + + +@pytest.fixture(name="feed_four_events") +def fixture_feed_four_events(hass: HomeAssistant) -> bytes: + """Load test feed data for three events.""" + return load_fixture_bytes("feedreader4.xml") + + +@pytest.fixture(name="feed_atom_event") +def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: + """Load test feed data for atom event.""" + 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.""" + return async_capture_events(hass, EVENT_FEEDREADER) diff --git a/tests/components/feedreader/const.py b/tests/components/feedreader/const.py new file mode 100644 index 00000000000..bbd0f82bcfa --- /dev/null +++ b/tests/components/feedreader/const.py @@ -0,0 +1,14 @@ +"""Constants for the tests for the feedreader component.""" + +from homeassistant.components.feedreader.const import ( + CONF_MAX_ENTRIES, + DEFAULT_MAX_ENTRIES, +) +from homeassistant.const import CONF_URL + +URL = "http://some.rss.local/rss_feed.xml" +FEED_TITLE = "RSS Sample" +VALID_CONFIG_DEFAULT = {CONF_URL: URL, CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES} +VALID_CONFIG_100 = {CONF_URL: URL, CONF_MAX_ENTRIES: 100} +VALID_CONFIG_5 = {CONF_URL: URL, CONF_MAX_ENTRIES: 5} +VALID_CONFIG_1 = {CONF_URL: URL, CONF_MAX_ENTRIES: 1} diff --git a/tests/components/feedreader/test_config_flow.py b/tests/components/feedreader/test_config_flow.py new file mode 100644 index 00000000000..48c341492e0 --- /dev/null +++ b/tests/components/feedreader/test_config_flow.py @@ -0,0 +1,298 @@ +"""The tests for the feedreader config flow.""" + +from unittest.mock import Mock, patch +import urllib + +import pytest + +from homeassistant.components.feedreader import CONF_URLS +from homeassistant.components.feedreader.const import ( + CONF_MAX_ENTRIES, + DEFAULT_MAX_ENTRIES, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import create_mock_entry +from .const import FEED_TITLE, URL, VALID_CONFIG_DEFAULT + + +@pytest.fixture(name="feedparser") +def feedparser_fixture(feed_one_event: bytes) -> Mock: + """Patch libraries.""" + with ( + patch( + "homeassistant.components.feedreader.config_flow.feedparser.http.get", + return_value=feed_one_event, + ) as feedparser, + ): + yield feedparser + + +@pytest.fixture(name="setup_entry") +def setup_entry_fixture(feed_one_event: bytes) -> Mock: + """Patch libraries.""" + with ( + patch("homeassistant.components.feedreader.async_setup_entry") as setup_entry, + ): + yield setup_entry + + +async def test_user(hass: HomeAssistant, feedparser, setup_entry) -> None: + """Test starting a flow by user.""" + # init user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: URL} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FEED_TITLE + assert result["data"][CONF_URL] == URL + assert result["options"][CONF_MAX_ENTRIES] == DEFAULT_MAX_ENTRIES + + +async def test_user_errors( + hass: HomeAssistant, feedparser, setup_entry, feed_one_event +) -> None: + """Test starting a flow by user which results in an URL error.""" + # init user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # raise URLError + feedparser.side_effect = urllib.error.URLError("Test") + feedparser.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: URL} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "url_error"} + + # no feed entries returned + feedparser.side_effect = None + feedparser.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: URL} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_feed_entries"} + + # success + feedparser.side_effect = None + feedparser.return_value = feed_one_event + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_URL: URL} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == FEED_TITLE + assert result["data"][CONF_URL] == URL + assert result["options"][CONF_MAX_ENTRIES] == DEFAULT_MAX_ENTRIES + + +@pytest.mark.parametrize( + ("data", "expected_data", "expected_options"), + [ + ({CONF_URLS: [URL]}, {CONF_URL: URL}, {CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES}), + ( + {CONF_URLS: [URL], CONF_MAX_ENTRIES: 5}, + {CONF_URL: URL}, + {CONF_MAX_ENTRIES: 5}, + ), + ], +) +async def test_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + data, + expected_data, + expected_options, + feedparser, + setup_entry, +) -> None: + """Test starting an import flow.""" + config_entries = hass.config_entries.async_entries(DOMAIN) + assert not config_entries + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: data}) + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert config_entries + assert len(config_entries) == 1 + assert config_entries[0].title == FEED_TITLE + assert config_entries[0].data == expected_data + assert config_entries[0].options == expected_options + + assert issue_registry.async_get_issue(HA_DOMAIN, "deprecated_yaml_feedreader") + + +@pytest.mark.parametrize( + ("side_effect", "return_value", "expected_issue_id"), + [ + ( + urllib.error.URLError("Test"), + None, + "import_yaml_error_feedreader_url_error_http_some_rss_local_rss_feed_xml", + ), + ( + None, + None, + "import_yaml_error_feedreader_no_feed_entries_http_some_rss_local_rss_feed_xml", + ), + ], +) +async def test_import_errors( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + feedparser, + setup_entry, + feed_one_event, + side_effect, + return_value, + expected_issue_id, +) -> None: + """Test starting an import flow which results in an URL error.""" + config_entries = hass.config_entries.async_entries(DOMAIN) + assert not config_entries + + # raise URLError + feedparser.side_effect = side_effect + feedparser.return_value = return_value + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_URLS: [URL]}}) + assert issue_registry.async_get_issue(DOMAIN, expected_issue_id) + + +async def test_reconfigure(hass: HomeAssistant, feedparser) -> None: + """Test starting a reconfigure flow.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # init user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + # success + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as mock_async_reload: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: "http://other.rss.local/rss_feed.xml", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_URL: "http://other.rss.local/rss_feed.xml", + } + + await hass.async_block_till_done() + assert mock_async_reload.call_count == 1 + + +async def test_reconfigure_errors( + hass: HomeAssistant, feedparser, setup_entry, feed_one_event +) -> None: + """Test starting a reconfigure flow by user which results in an URL error.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + + # init user flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + # raise URLError + feedparser.side_effect = urllib.error.URLError("Test") + feedparser.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: "http://other.rss.local/rss_feed.xml", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {"base": "url_error"} + + # no feed entries returned + feedparser.side_effect = None + feedparser.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: "http://other.rss.local/rss_feed.xml", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {"base": "no_feed_entries"} + + # success + feedparser.side_effect = None + feedparser.return_value = feed_one_event + + # success + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: "http://other.rss.local/rss_feed.xml", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_URL: "http://other.rss.local/rss_feed.xml", + } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MAX_ENTRIES: 10, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MAX_ENTRIES: 10, + } diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 67ce95811a0..1dcbf5ba45d 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -1,188 +1,51 @@ """The tests for the feedreader component.""" -from collections.abc import Generator from datetime import datetime, timedelta -import pickle from time import gmtime from typing import Any -from unittest import mock -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import patch +import urllib +import urllib.error +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components import feedreader -from homeassistant.components.feedreader import ( - CONF_MAX_ENTRIES, - CONF_URLS, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - EVENT_FEEDREADER, -) -from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START +from homeassistant.components.feedreader.const import DOMAIN from homeassistant.core import Event, HomeAssistant -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_capture_events, async_fire_time_changed, load_fixture +from . import async_setup_config_entry, create_mock_entry +from .const import ( + URL, + VALID_CONFIG_1, + VALID_CONFIG_5, + VALID_CONFIG_100, + VALID_CONFIG_DEFAULT, +) -URL = "http://some.rss.local/rss_feed.xml" -VALID_CONFIG_1 = {feedreader.DOMAIN: {CONF_URLS: [URL]}} -VALID_CONFIG_2 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_SCAN_INTERVAL: 60}} -VALID_CONFIG_3 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 100}} -VALID_CONFIG_4 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 5}} -VALID_CONFIG_5 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 1}} - - -def load_fixture_bytes(src: str) -> bytes: - """Return byte stream of fixture.""" - feed_data = load_fixture(src, DOMAIN) - return bytes(feed_data, "utf-8") - - -@pytest.fixture(name="feed_one_event") -def fixture_feed_one_event(hass: HomeAssistant) -> bytes: - """Load test feed data for one event.""" - return load_fixture_bytes("feedreader.xml") - - -@pytest.fixture(name="feed_two_event") -def fixture_feed_two_events(hass: HomeAssistant) -> bytes: - """Load test feed data for two event.""" - return load_fixture_bytes("feedreader1.xml") - - -@pytest.fixture(name="feed_21_events") -def fixture_feed_21_events(hass: HomeAssistant) -> bytes: - """Load test feed data for twenty one events.""" - return load_fixture_bytes("feedreader2.xml") - - -@pytest.fixture(name="feed_three_events") -def fixture_feed_three_events(hass: HomeAssistant) -> bytes: - """Load test feed data for three events.""" - return load_fixture_bytes("feedreader3.xml") - - -@pytest.fixture(name="feed_atom_event") -def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: - """Load test feed data for atom event.""" - 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.""" - return async_capture_events(hass, EVENT_FEEDREADER) - - -@pytest.fixture(name="storage") -def fixture_storage(request: pytest.FixtureRequest) -> Generator[None, None, None]: - """Set up the test storage environment.""" - if request.param == "legacy_storage": - with patch("os.path.exists", return_value=False): - yield - elif request.param == "json_storage": - with patch("os.path.exists", return_value=True): - yield - else: - raise RuntimeError("Invalid storage fixture") - - -@pytest.fixture(name="legacy_storage_open") -def fixture_legacy_storage_open() -> Generator[MagicMock, None, None]: - """Mock builtins.open for feedreader storage.""" - with patch( - "homeassistant.components.feedreader.open", - mock_open(), - create=True, - ) as open_mock: - yield open_mock - - -@pytest.fixture(name="legacy_storage_load", autouse=True) -def fixture_legacy_storage_load( - legacy_storage_open, -) -> Generator[MagicMock, None, None]: - """Mock builtins.open for feedreader storage.""" - with patch( - "homeassistant.components.feedreader.pickle.load", return_value={} - ) as pickle_load: - yield pickle_load - - -async def test_setup_no_feeds(hass: HomeAssistant) -> None: - """Test config with no urls.""" - assert not await async_setup_component( - hass, feedreader.DOMAIN, {feedreader.DOMAIN: {CONF_URLS: []}} - ) +from tests.common import async_fire_time_changed @pytest.mark.parametrize( - ("open_error", "load_error"), - [ - (FileNotFoundError("No file"), None), - (OSError("Boom"), None), - (None, pickle.PickleError("Bad data")), - ], + "config", + [VALID_CONFIG_DEFAULT, VALID_CONFIG_1, VALID_CONFIG_100, VALID_CONFIG_5], ) -async def test_legacy_storage_error( - hass: HomeAssistant, - legacy_storage_open: MagicMock, - legacy_storage_load: MagicMock, - open_error: Exception | None, - load_error: Exception | None, -) -> None: - """Test legacy storage error.""" - legacy_storage_open.side_effect = open_error - legacy_storage_load.side_effect = load_error - - with patch( - "homeassistant.components.feedreader.async_track_time_interval" - ) as track_method: - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_1) - await hass.async_block_till_done() - - track_method.assert_called_once_with( - hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True - ) - - -@pytest.mark.parametrize("storage", ["legacy_storage", "json_storage"], indirect=True) -async def test_storage_data_loading( +async def test_setup( hass: HomeAssistant, events: list[Event], feed_one_event: bytes, - legacy_storage_load: MagicMock, hass_storage: dict[str, Any], - storage: None, + config: dict[str, Any], ) -> None: """Test loading existing storage data.""" storage_data: dict[str, str] = {URL: "2018-04-30T05:10:00+00:00"} - hass_storage[feedreader.DOMAIN] = { + hass_storage[DOMAIN] = { "version": 1, "minor_version": 1, - "key": feedreader.DOMAIN, + "key": DOMAIN, "data": storage_data, } - legacy_storage_data = { - URL: gmtime(datetime.fromisoformat(storage_data[URL]).timestamp()) - } - legacy_storage_load.return_value = legacy_storage_data - - with patch( - "feedparser.http.get", - return_value=feed_one_event, - ): - 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 await async_setup_config_entry(hass, config, return_value=feed_one_event) # no new events assert not events @@ -198,67 +61,24 @@ async def test_storage_data_writing( storage_data: dict[str, str] = {URL: "2018-04-30T05:10:00+00:00"} with ( - patch( - "feedparser.http.get", - return_value=feed_one_event, - ), - patch("homeassistant.components.feedreader.DELAY_SAVE", new=0), + patch("homeassistant.components.feedreader.coordinator.DELAY_SAVE", new=0), ): - 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 await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_one_event + ) # one new event assert len(events) == 1 # storage data updated - assert hass_storage[feedreader.DOMAIN]["data"] == storage_data - - -@pytest.mark.parametrize("storage", ["legacy_storage", "json_storage"], indirect=True) -async def test_setup_one_feed(hass: HomeAssistant, storage: None) -> None: - """Test the general setup of this component.""" - with patch( - "homeassistant.components.feedreader.async_track_time_interval" - ) as track_method: - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_1) - await hass.async_block_till_done() - - track_method.assert_called_once_with( - hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True - ) - - -async def test_setup_scan_interval(hass: HomeAssistant) -> None: - """Test the setup of this component with scan interval.""" - with patch( - "homeassistant.components.feedreader.async_track_time_interval" - ) as track_method: - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) - await hass.async_block_till_done() - - track_method.assert_called_once_with( - hass, mock.ANY, timedelta(seconds=60), cancel_on_shutdown=True - ) - - -async def test_setup_max_entries(hass: HomeAssistant) -> None: - """Test the setup of this component with max entries.""" - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_3) - await hass.async_block_till_done() + assert hass_storage[DOMAIN]["data"] == storage_data async def test_feed(hass: HomeAssistant, events, feed_one_event) -> None: """Test simple rss feed with valid data.""" - with patch( - "feedparser.http.get", - return_value=feed_one_event, - ): - 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 await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_one_event + ) assert len(events) == 1 assert events[0].data.title == "Title 1" @@ -274,14 +94,9 @@ async def test_feed(hass: HomeAssistant, events, feed_one_event) -> None: async def test_atom_feed(hass: HomeAssistant, events, feed_atom_event) -> None: """Test simple atom feed with valid data.""" - with patch( - "feedparser.http.get", - return_value=feed_atom_event, - ): - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_5) - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() + assert await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_atom_event + ) assert len(events) == 1 assert events[0].data.title == "Atom-Powered Robots Run Amok" @@ -301,20 +116,15 @@ async def test_feed_identical_timestamps( """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", + "homeassistant.components.feedreader.coordinator.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 await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_identically_timed_events + ) assert len(events) == 2 assert events[0].data.title == "Title 1" @@ -365,10 +175,13 @@ async def test_feed_updates( feed_two_event, ] - with patch("feedparser.http.get", side_effect=side_effect): - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get", + side_effect=side_effect, + ): + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert len(events) == 1 @@ -392,22 +205,20 @@ async def test_feed_default_max_length( hass: HomeAssistant, events, feed_21_events ) -> None: """Test long feed beyond the default 20 entry limit.""" - with patch("feedparser.http.get", return_value=feed_21_events): - 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 await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_21_events + ) + await hass.async_block_till_done() assert len(events) == 20 async def test_feed_max_length(hass: HomeAssistant, events, feed_21_events) -> None: """Test long feed beyond a configured 5 entry limit.""" - with patch("feedparser.http.get", return_value=feed_21_events): - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_4) - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() + assert await async_setup_config_entry( + hass, VALID_CONFIG_5, return_value=feed_21_events + ) + await hass.async_block_till_done() assert len(events) == 5 @@ -416,53 +227,104 @@ async def test_feed_without_publication_date_and_title( hass: HomeAssistant, events, feed_three_events ) -> None: """Test simple feed with entry without publication date and title.""" - with patch("feedparser.http.get", return_value=feed_three_events): - 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 await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_three_events + ) + await hass.async_block_till_done() assert len(events) == 3 async def test_feed_with_unrecognized_publication_date( - hass: HomeAssistant, events + hass: HomeAssistant, events, feed_four_events ) -> None: """Test simple feed with entry with unrecognized publication date.""" - with patch( - "feedparser.http.get", return_value=load_fixture_bytes("feedreader4.xml") - ): - 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 await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_four_events + ) + await hass.async_block_till_done() assert len(events) == 1 async def test_feed_invalid_data(hass: HomeAssistant, events) -> None: """Test feed with invalid data.""" - invalid_data = bytes("INVALID DATA", "utf-8") - with patch("feedparser.http.get", return_value=invalid_data): - 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 await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=bytes("INVALID DATA", "utf-8") + ) + await hass.async_block_till_done() assert len(events) == 0 async def test_feed_parsing_failed( - hass: HomeAssistant, events, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, events, feed_one_event, caplog: pytest.LogCaptureFixture ) -> None: """Test feed where parsing fails.""" assert "Error fetching feed data" not in caplog.text with patch("feedparser.parse", return_value=None): - assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) - - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + assert not await async_setup_config_entry( + hass, VALID_CONFIG_DEFAULT, return_value=feed_one_event + ) await hass.async_block_till_done() assert "Error fetching feed data" in caplog.text assert not events + + +async def test_feed_errors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + feed_one_event, +) -> None: + """Test feed errors.""" + entry = create_mock_entry(VALID_CONFIG_DEFAULT) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.http.get" + ) as feedreader: + # success setup + feedreader.return_value = feed_one_event + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # raise URL error + feedreader.side_effect = urllib.error.URLError("Test") + freezer.tick(timedelta(hours=1, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert ( + "Error fetching feed data from http://some.rss.local/rss_feed.xml: " + in caplog.text + ) + + # success + feedreader.side_effect = None + feedreader.return_value = feed_one_event + freezer.tick(timedelta(hours=1, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + caplog.clear() + + # no feed returned + freezer.tick(timedelta(hours=1, seconds=1)) + with patch( + "homeassistant.components.feedreader.coordinator.feedparser.parse", + return_value=None, + ): + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert ( + "Error fetching feed data from http://some.rss.local/rss_feed.xml" + in caplog.text + ) + caplog.clear() + + # success + feedreader.side_effect = None + feedreader.return_value = feed_one_event + freezer.tick(timedelta(hours=1, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/ffmpeg/test_binary_sensor.py b/tests/components/ffmpeg/test_binary_sensor.py index 8b1a5115f86..535ac863361 100644 --- a/tests/components/ffmpeg/test_binary_sensor.py +++ b/tests/components/ffmpeg/test_binary_sensor.py @@ -30,7 +30,7 @@ async def test_noise_setup_component(hass: HomeAssistant) -> None: @patch("haffmpeg.sensor.SensorNoise.open_sensor", side_effect=AsyncMock()) -async def test_noise_setup_component_start(mock_start, hass: HomeAssistant): +async def test_noise_setup_component_start(mock_start, hass: HomeAssistant) -> None: """Set up ffmpeg component.""" with assert_setup_component(1, "binary_sensor"): await async_setup_component(hass, "binary_sensor", CONFIG_NOISE) @@ -48,7 +48,9 @@ async def test_noise_setup_component_start(mock_start, hass: HomeAssistant): @patch("haffmpeg.sensor.SensorNoise") -async def test_noise_setup_component_start_callback(mock_ffmpeg, hass: HomeAssistant): +async def test_noise_setup_component_start_callback( + mock_ffmpeg, hass: HomeAssistant +) -> None: """Set up ffmpeg component.""" mock_ffmpeg().open_sensor.side_effect = AsyncMock() mock_ffmpeg().close = AsyncMock() @@ -86,7 +88,7 @@ async def test_motion_setup_component(hass: HomeAssistant) -> None: @patch("haffmpeg.sensor.SensorMotion.open_sensor", side_effect=AsyncMock()) -async def test_motion_setup_component_start(mock_start, hass: HomeAssistant): +async def test_motion_setup_component_start(mock_start, hass: HomeAssistant) -> None: """Set up ffmpeg component.""" with assert_setup_component(1, "binary_sensor"): await async_setup_component(hass, "binary_sensor", CONFIG_MOTION) @@ -104,7 +106,9 @@ async def test_motion_setup_component_start(mock_start, hass: HomeAssistant): @patch("haffmpeg.sensor.SensorMotion") -async def test_motion_setup_component_start_callback(mock_ffmpeg, hass: HomeAssistant): +async def test_motion_setup_component_start_callback( + mock_ffmpeg, hass: HomeAssistant +) -> None: """Set up ffmpeg component.""" mock_ffmpeg().open_sensor.side_effect = AsyncMock() mock_ffmpeg().close = AsyncMock() diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py index 60d24baa302..353b8fdfcc0 100644 --- a/tests/components/ffmpeg/test_init.py +++ b/tests/components/ffmpeg/test_init.py @@ -77,7 +77,7 @@ class MockFFmpegDev(ffmpeg.FFmpegBase): self.called_entities = entity_ids -def test_setup_component(): +def test_setup_component() -> None: """Set up ffmpeg component.""" with get_test_home_assistant() as hass: with assert_setup_component(1): @@ -87,7 +87,7 @@ def test_setup_component(): hass.stop() -def test_setup_component_test_service(): +def test_setup_component_test_service() -> None: """Set up ffmpeg component test services.""" with get_test_home_assistant() as hass: with assert_setup_component(1): diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 345668c23bd..d2f004a160c 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -1,9 +1,9 @@ """Test helpers.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -21,7 +21,7 @@ TEST_MODEL = "HC3" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.fibaro.async_setup_entry", return_value=True @@ -66,7 +66,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -def mock_fibaro_client() -> Generator[Mock, None, None]: +def mock_fibaro_client() -> Generator[Mock]: """Return a mocked FibaroClient.""" info_mock = Mock() info_mock.serial_number = TEST_SERIALNUMBER diff --git a/tests/components/file/conftest.py b/tests/components/file/conftest.py new file mode 100644 index 00000000000..265acde36ca --- /dev/null +++ b/tests/components/file/conftest.py @@ -0,0 +1,32 @@ +"""Test fixtures for file platform.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.file.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def is_allowed() -> bool: + """Parameterize mock_is_allowed_path, default True.""" + return True + + +@pytest.fixture +def mock_is_allowed_path(hass: HomeAssistant, is_allowed: bool) -> Generator[MagicMock]: + """Mock is_allowed_path method.""" + with patch.object( + hass.config, "is_allowed_path", return_value=is_allowed + ) as allowed_path_mock: + yield allowed_path_mock diff --git a/tests/components/file/test_config_flow.py b/tests/components/file/test_config_flow.py new file mode 100644 index 00000000000..86ada1fec61 --- /dev/null +++ b/tests/components/file/test_config_flow.py @@ -0,0 +1,142 @@ +"""Tests for the file config flow.""" + +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.file import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_CONFIG_NOTIFY = { + "platform": "notify", + "file_path": "some_file", + "timestamp": True, +} +MOCK_CONFIG_SENSOR = { + "platform": "sensor", + "file_path": "some/path", + "value_template": "{{ value | round(1) }}", +} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.parametrize( + ("platform", "data"), + [("sensor", MOCK_CONFIG_SENSOR), ("notify", MOCK_CONFIG_NOTIFY)], +) +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_is_allowed_path: bool, + platform: str, + data: dict[str, Any], +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": platform}, + ) + await hass.async_block_till_done() + + user_input = dict(data) + user_input.pop("platform") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == data + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("platform", "data"), + [("sensor", MOCK_CONFIG_SENSOR), ("notify", MOCK_CONFIG_NOTIFY)], +) +async def test_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_is_allowed_path: bool, + platform: str, + data: dict[str, Any], +) -> None: + """Test aborting if the entry is already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=data) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": platform}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == platform + + user_input = dict(data) + user_input.pop("platform") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +@pytest.mark.parametrize("is_allowed", [False], ids=["not_allowed"]) +@pytest.mark.parametrize( + ("platform", "data"), + [("sensor", MOCK_CONFIG_SENSOR), ("notify", MOCK_CONFIG_NOTIFY)], +) +async def test_not_allowed( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_is_allowed_path: bool, + platform: str, + data: dict[str, Any], +) -> None: + """Test aborting if the file path is not allowed.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": platform}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == platform + + user_input = dict(data) + user_input.pop("platform") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"file_path": "not_allowed"} diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 3077d71bdde..faa9027aa21 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -1,57 +1,94 @@ """The tests for the notify file platform.""" import os -from unittest.mock import call, mock_open, patch +from typing import Any +from unittest.mock import MagicMock, call, mock_open, patch from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import notify +from homeassistant.components.file import DOMAIN from homeassistant.components.notify import ATTR_TITLE_DEFAULT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import assert_setup_component +from tests.common import MockConfigEntry, assert_setup_component async def test_bad_config(hass: HomeAssistant) -> None: """Test set up the platform with bad/missing config.""" config = {notify.DOMAIN: {"name": "test", "platform": "file"}} - with assert_setup_component(0) as handle_config: + with assert_setup_component(0, domain="notify") as handle_config: assert await async_setup_component(hass, notify.DOMAIN, config) await hass.async_block_till_done() assert not handle_config[notify.DOMAIN] @pytest.mark.parametrize( - "timestamp", + ("domain", "service", "params"), [ - False, - True, + (notify.DOMAIN, "test", {"message": "one, two, testing, testing"}), + ( + notify.DOMAIN, + "send_message", + {"entity_id": "notify.test", "message": "one, two, testing, testing"}, + ), ], + ids=["legacy", "entity"], +) +@pytest.mark.parametrize( + ("timestamp", "config"), + [ + ( + False, + { + "notify": [ + { + "name": "test", + "platform": "file", + "filename": "mock_file", + "timestamp": False, + } + ] + }, + ), + ( + True, + { + "notify": [ + { + "name": "test", + "platform": "file", + "filename": "mock_file", + "timestamp": True, + } + ] + }, + ), + ], + ids=["no_timestamp", "timestamp"], ) async def test_notify_file( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, timestamp: bool + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + timestamp: bool, + mock_is_allowed_path: MagicMock, + config: ConfigType, + domain: str, + service: str, + params: dict[str, str], ) -> None: """Test the notify file output.""" filename = "mock_file" - message = "one, two, testing, testing" - with assert_setup_component(1) as handle_config: - assert await async_setup_component( - hass, - notify.DOMAIN, - { - "notify": { - "name": "test", - "platform": "file", - "filename": filename, - "timestamp": timestamp, - } - }, - ) - await hass.async_block_till_done() - assert handle_config[notify.DOMAIN] + message = params["message"] + assert await async_setup_component(hass, notify.DOMAIN, config) + await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) freezer.move_to(dt_util.utcnow()) @@ -66,9 +103,7 @@ async def test_notify_file( f"(Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" ) - await hass.services.async_call( - "notify", "test", {"message": message}, blocking=True - ) + await hass.services.async_call(domain, service, params, blocking=True) full_filename = os.path.join(hass.config.path(), filename) assert m_open.call_count == 1 @@ -85,3 +120,220 @@ async def test_notify_file( call(title), call(f"{dt_util.utcnow().isoformat()} {message}\n"), ] + + +@pytest.mark.parametrize( + ("domain", "service", "params"), + [(notify.DOMAIN, "test", {"message": "one, two, testing, testing"})], + ids=["legacy"], +) +@pytest.mark.parametrize( + ("is_allowed", "config"), + [ + ( + True, + { + "notify": [ + { + "name": "test", + "platform": "file", + "filename": "mock_file", + } + ] + }, + ), + ], + ids=["allowed_but_access_failed"], +) +async def test_legacy_notify_file_exception( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_is_allowed_path: MagicMock, + config: ConfigType, + domain: str, + service: str, + params: dict[str, str], +) -> None: + """Test legacy notify file output has exception.""" + assert await async_setup_component(hass, notify.DOMAIN, config) + await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + + 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, + ): + mock_st.side_effect = OSError("Access Failed") + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call(domain, service, params, blocking=True) + assert f"{exc.value!r}" == "ServiceValidationError('write_access_failed')" + + +@pytest.mark.parametrize( + ("timestamp", "data"), + [ + ( + False, + { + "name": "test", + "platform": "notify", + "file_path": "mock_file", + "timestamp": False, + }, + ), + ( + True, + { + "name": "test", + "platform": "notify", + "file_path": "mock_file", + "timestamp": True, + }, + ), + ], + ids=["no_timestamp", "timestamp"], +) +async def test_legacy_notify_file_entry_only_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + timestamp: bool, + mock_is_allowed_path: MagicMock, + data: dict[str, Any], +) -> None: + """Test the legacy notify file output in entry only setup.""" + filename = "mock_file" + + domain = notify.DOMAIN + service = "test" + params = {"message": "one, two, testing, testing"} + message = params["message"] + + entry = MockConfigEntry( + domain=DOMAIN, data=data, title=f"test [{data['file_path']}]" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + 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, + ): + mock_st.return_value.st_size = 0 + title = ( + f"{ATTR_TITLE_DEFAULT} notifications " + f"(Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" + ) + + await hass.services.async_call(domain, service, params, blocking=True) + + assert m_open.call_count == 1 + assert m_open.call_args == call(filename, "a", encoding="utf8") + + assert m_open.return_value.write.call_count == 2 + if not timestamp: + assert m_open.return_value.write.call_args_list == [ + call(title), + call(f"{message}\n"), + ] + else: + assert m_open.return_value.write.call_args_list == [ + call(title), + call(f"{dt_util.utcnow().isoformat()} {message}\n"), + ] + + +@pytest.mark.parametrize( + ("is_allowed", "config"), + [ + ( + False, + { + "name": "test", + "platform": "notify", + "file_path": "mock_file", + "timestamp": False, + }, + ), + ], + ids=["not_allowed"], +) +async def test_legacy_notify_file_not_allowed( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_is_allowed_path: MagicMock, + config: dict[str, Any], +) -> None: + """Test legacy notify file output not allowed.""" + entry = MockConfigEntry( + domain=DOMAIN, data=config, title=f"test [{config['file_path']}]" + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + assert "is not allowed" in caplog.text + + +@pytest.mark.parametrize( + ("service", "params"), + [ + ("test", {"message": "one, two, testing, testing"}), + ( + "send_message", + {"entity_id": "notify.test", "message": "one, two, testing, testing"}, + ), + ], +) +@pytest.mark.parametrize( + ("data", "is_allowed"), + [ + ( + { + "name": "test", + "platform": "notify", + "file_path": "mock_file", + "timestamp": False, + }, + True, + ), + ], + ids=["not_allowed"], +) +async def test_notify_file_write_access_failed( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_is_allowed_path: MagicMock, + service: str, + params: dict[str, Any], + data: dict[str, Any], +) -> None: + """Test the notify file fails.""" + domain = notify.DOMAIN + + entry = MockConfigEntry( + domain=DOMAIN, data=data, title=f"test [{data['file_path']}]" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + 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, + ): + mock_st.side_effect = OSError("Access Failed") + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call(domain, service, params, blocking=True) + assert f"{exc.value!r}" == "ServiceValidationError('write_access_failed')" diff --git a/tests/components/file/test_sensor.py b/tests/components/file/test_sensor.py index 8acdc324209..60a81df2b1e 100644 --- a/tests/components/file/test_sensor.py +++ b/tests/components/file/test_sensor.py @@ -1,29 +1,34 @@ """The tests for local file sensor platform.""" -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch +import pytest + +from homeassistant.components.file import DOMAIN from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path +from tests.common import MockConfigEntry, get_fixture_path @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) -async def test_file_value(hass: HomeAssistant) -> None: - """Test the File sensor.""" +async def test_file_value_yaml_setup( + hass: HomeAssistant, mock_is_allowed_path: MagicMock +) -> None: + """Test the File sensor from YAML setup.""" config = { "sensor": { "platform": "file", + "scan_interval": 30, "name": "file1", "file_path": get_fixture_path("file_value.txt", "file"), } } - with patch.object(hass.config, "is_allowed_path", return_value=True): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() state = hass.states.get("sensor.file1") assert state.state == "21" @@ -31,20 +36,44 @@ async def test_file_value(hass: HomeAssistant) -> None: @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) -async def test_file_value_template(hass: HomeAssistant) -> None: - """Test the File sensor with JSON entries.""" - config = { - "sensor": { - "platform": "file", - "name": "file2", - "file_path": get_fixture_path("file_value_template.txt", "file"), - "value_template": "{{ value_json.temperature }}", - } +async def test_file_value_entry_setup( + hass: HomeAssistant, mock_is_allowed_path: MagicMock +) -> None: + """Test the File sensor from an entry setup.""" + data = { + "platform": "sensor", + "name": "file1", + "file_path": get_fixture_path("file_value.txt", "file"), } - with patch.object(hass.config, "is_allowed_path", return_value=True): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + entry = MockConfigEntry( + domain=DOMAIN, data=data, title=f"test [{data['file_path']}]" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + state = hass.states.get("sensor.file1") + assert state.state == "21" + + +@patch("os.path.isfile", Mock(return_value=True)) +@patch("os.access", Mock(return_value=True)) +async def test_file_value_template( + hass: HomeAssistant, mock_is_allowed_path: MagicMock +) -> None: + """Test the File sensor with JSON entries.""" + data = { + "platform": "sensor", + "name": "file2", + "file_path": get_fixture_path("file_value_template.txt", "file"), + "value_template": "{{ value_json.temperature }}", + } + + entry = MockConfigEntry( + domain=DOMAIN, data=data, title=f"test [{data['file_path']}]" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) state = hass.states.get("sensor.file2") assert state.state == "26" @@ -52,19 +81,19 @@ async def test_file_value_template(hass: HomeAssistant) -> None: @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) -async def test_file_empty(hass: HomeAssistant) -> None: +async def test_file_empty(hass: HomeAssistant, mock_is_allowed_path: MagicMock) -> None: """Test the File sensor with an empty file.""" - config = { - "sensor": { - "platform": "file", - "name": "file3", - "file_path": get_fixture_path("file_empty.txt", "file"), - } + data = { + "platform": "sensor", + "name": "file3", + "file_path": get_fixture_path("file_empty.txt", "file"), } - with patch.object(hass.config, "is_allowed_path", return_value=True): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + entry = MockConfigEntry( + domain=DOMAIN, data=data, title=f"test [{data['file_path']}]" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) state = hass.states.get("sensor.file3") assert state.state == STATE_UNKNOWN @@ -72,18 +101,21 @@ async def test_file_empty(hass: HomeAssistant) -> None: @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) -async def test_file_path_invalid(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("is_allowed", [False]) +async def test_file_path_invalid( + hass: HomeAssistant, mock_is_allowed_path: MagicMock +) -> None: """Test the File sensor with invalid path.""" - config = { - "sensor": { - "platform": "file", - "name": "file4", - "file_path": get_fixture_path("file_value.txt", "file"), - } + data = { + "platform": "sensor", + "name": "file4", + "file_path": get_fixture_path("file_value.txt", "file"), } - with patch.object(hass.config, "is_allowed_path", return_value=False): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + entry = MockConfigEntry( + domain=DOMAIN, data=data, title=f"test [{data['file_path']}]" + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) assert len(hass.states.async_entity_ids("sensor")) == 0 diff --git a/tests/components/file_upload/test_init.py b/tests/components/file_upload/test_init.py index fa77f6e55f5..149bbb7ee2f 100644 --- a/tests/components/file_upload/test_init.py +++ b/tests/components/file_upload/test_init.py @@ -16,7 +16,9 @@ from tests.typing import ClientSessionGenerator @pytest.fixture -async def uploaded_file_dir(hass: HomeAssistant, hass_client) -> Path: +async def uploaded_file_dir( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> Path: """Test uploading and using a file.""" assert await async_setup_component(hass, "file_upload", {}) client = await hass_client() diff --git a/tests/components/filesize/conftest.py b/tests/components/filesize/conftest.py index 81aea2aee54..859886a3058 100644 --- a/tests/components/filesize/conftest.py +++ b/tests/components/filesize/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from pathlib import Path from unittest.mock import patch import pytest +from typing_extensions import Generator from homeassistant.components.filesize.const import DOMAIN from homeassistant.const import CONF_FILE_PATH @@ -29,7 +29,7 @@ def mock_config_entry(tmp_path: Path) -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.filesize.async_setup_entry", return_value=True diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index 67370bbcedc..0ece61708f2 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -390,7 +390,7 @@ def test_initial_outlier(values: list[State]) -> None: """Test issue #13363.""" filt = OutlierFilter(window_size=3, precision=2, entity=None, radius=4.0) out = State("sensor.test_monitored", "4000") - for state in [out, *values]: + for state in (out, *values): filtered = filt.filter_state(state) assert filtered.state == 21 @@ -399,7 +399,7 @@ def test_unknown_state_outlier(values: list[State]) -> None: """Test issue #32395.""" filt = OutlierFilter(window_size=3, precision=2, entity=None, radius=4.0) out = State("sensor.test_monitored", "unknown") - for state in [out, *values, out]: + for state in (out, *values, out): try: filtered = filt.filter_state(state) except ValueError: @@ -419,7 +419,7 @@ def test_lowpass(values: list[State]) -> None: """Test if lowpass filter works.""" filt = LowPassFilter(window_size=10, precision=2, entity=None, time_constant=10) out = State("sensor.test_monitored", "unknown") - for state in [out, *values, out]: + for state in (out, *values, out): try: filtered = filt.filter_state(state) except ValueError: diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index a4bfed43cba..b1ff8a94e12 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for fitbit.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus import time @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest from requests_mock.mocker import Mocker +from typing_extensions import Generator from homeassistant.components.application_credentials import ( ClientCredential, @@ -122,7 +123,7 @@ def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any] | No @pytest.fixture(name="fitbit_config_setup") def mock_fitbit_config_setup( fitbit_config_yaml: dict[str, Any] | None, -) -> Generator[None, None, None]: +) -> Generator[None]: """Fixture to mock out fitbit.conf file data loading and persistence.""" has_config = fitbit_config_yaml is not None with ( diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 843a85dec68..d5f3d09abdd 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -32,11 +32,11 @@ from tests.typing import ClientSessionGenerator REDIRECT_URL = "https://example.com/auth/external/callback" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, profile: None, setup_credentials: None, ) -> None: @@ -97,11 +97,11 @@ async def test_full_flow( (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_token_error( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, profile: None, setup_credentials: None, status_code: HTTPStatus, @@ -155,11 +155,11 @@ async def test_token_error( ), ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_api_failure( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, requests_mock: Mocker, setup_credentials: None, http_status: HTTPStatus, @@ -207,12 +207,11 @@ async def test_api_failure( assert result.get("reason") == error_reason +@pytest.mark.usefixtures("current_request_with_host") async def test_config_entry_already_exists( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, - requests_mock: Mocker, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, @@ -457,12 +456,12 @@ async def test_platform_setup_without_import( assert issue.translation_key == "deprecated_yaml_no_import" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_flow( hass: HomeAssistant, config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, profile: None, setup_credentials: None, ) -> None: @@ -532,12 +531,12 @@ async def test_reauth_flow( @pytest.mark.parametrize("profile_id", ["other-user-id"]) +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_wrong_user_id( hass: HomeAssistant, config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, profile: None, setup_credentials: None, ) -> None: @@ -610,11 +609,11 @@ async def test_reauth_wrong_user_id( ], ids=("full_profile_data", "display_name_only"), ) +@pytest.mark.usefixtures("current_request_with_host") async def test_partial_profile_data( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, profile: None, setup_credentials: None, expected_title: str, diff --git a/tests/components/fjaraskupan/conftest.py b/tests/components/fjaraskupan/conftest.py index 85493157a3c..1f29b086955 100644 --- a/tests/components/fjaraskupan/conftest.py +++ b/tests/components/fjaraskupan/conftest.py @@ -6,5 +6,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index d7e7962003b..e1b98070d25 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -1,10 +1,10 @@ """Configuration for Flexit Nordic (BACnet) tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from flexit_bacnet import FlexitBACnet import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.flexit_bacnet.const import DOMAIN @@ -29,7 +29,7 @@ async def flow_id(hass: HomeAssistant) -> str: @pytest.fixture -def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: +def mock_flexit_bacnet() -> Generator[AsyncMock]: """Mock data from the device.""" flexit_bacnet = AsyncMock(spec=FlexitBACnet) with ( @@ -83,7 +83,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True diff --git a/tests/components/flexit_bacnet/snapshots/test_climate.ambr b/tests/components/flexit_bacnet/snapshots/test_climate.ambr index 551c5363e98..790c377b1f2 100644 --- a/tests/components/flexit_bacnet/snapshots/test_climate.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_climate.ambr @@ -1,35 +1,5 @@ # serializer version: 1 -# name: test_climate_entity - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': 19.0, - 'friendly_name': 'Device Name', - 'hvac_action': , - 'hvac_modes': list([ - , - , - ]), - 'max_temp': 30, - 'min_temp': 10, - 'preset_mode': 'boost', - 'preset_modes': list([ - 'away', - 'home', - 'boost', - ]), - 'supported_features': , - 'target_temp_step': 0.5, - 'temperature': 22.0, - }), - 'context': , - 'entity_id': 'climate.device_name', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'fan_only', - }) -# --- -# name: test_climate_entity.1 +# name: test_climate_entity[climate.device_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -75,3 +45,33 @@ 'unit_of_measurement': None, }) # --- +# name: test_climate_entity[climate.device_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Device Name', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30, + 'min_temp': 10, + 'preset_mode': 'boost', + 'preset_modes': list([ + 'away', + 'home', + 'boost', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.device_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fan_only', + }) +# --- diff --git a/tests/components/flexit_bacnet/snapshots/test_number.ambr b/tests/components/flexit_bacnet/snapshots/test_number.ambr index 008046bf512..c4fb1e7c434 100644 --- a/tests/components/flexit_bacnet/snapshots/test_number.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_number.ambr @@ -569,563 +569,3 @@ 'state': '60', }) # --- -# name: test_numbers[number.device_name_power_factor-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'away_extract_fan_setpoint', - 'unique_id': '0000-0001-away_extract_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor', - 'last_changed': , - 'last_updated': , - 'state': '30', - }) -# --- -# name: test_numbers[number.device_name_power_factor_10-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_10', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'home_supply_fan_setpoint', - 'unique_id': '0000-0001-home_supply_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_10-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_10', - 'last_changed': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_numbers[number.device_name_power_factor_2-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'away_supply_fan_setpoint', - 'unique_id': '0000-0001-away_supply_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_2', - 'last_changed': , - 'last_updated': , - 'state': '40', - }) -# --- -# name: test_numbers[number.device_name_power_factor_3-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_3', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'cooker_hood_extract_fan_setpoint', - 'unique_id': '0000-0001-cooker_hood_extract_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_3-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_3', - 'last_changed': , - 'last_updated': , - 'state': '90', - }) -# --- -# name: test_numbers[number.device_name_power_factor_4-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_4', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'cooker_hood_supply_fan_setpoint', - 'unique_id': '0000-0001-cooker_hood_supply_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_4-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_4', - 'last_changed': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_numbers[number.device_name_power_factor_5-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_5', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fireplace_extract_fan_setpoint', - 'unique_id': '0000-0001-fireplace_extract_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_5-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_5', - 'last_changed': , - 'last_updated': , - 'state': '10', - }) -# --- -# name: test_numbers[number.device_name_power_factor_6-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_6', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fireplace_supply_fan_setpoint', - 'unique_id': '0000-0001-fireplace_supply_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_6-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_6', - 'last_changed': , - 'last_updated': , - 'state': '20', - }) -# --- -# name: test_numbers[number.device_name_power_factor_7-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_7', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'high_extract_fan_setpoint', - 'unique_id': '0000-0001-high_extract_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_7-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_7', - 'last_changed': , - 'last_updated': , - 'state': '70', - }) -# --- -# name: test_numbers[number.device_name_power_factor_8-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_8', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'high_supply_fan_setpoint', - 'unique_id': '0000-0001-high_supply_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_8-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_8', - 'last_changed': , - 'last_updated': , - 'state': '80', - }) -# --- -# name: test_numbers[number.device_name_power_factor_9-entry] - 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': None, - 'entity_id': 'number.device_name_power_factor_9', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Power factor', - 'platform': 'flexit_bacnet', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'home_extract_fan_setpoint', - 'unique_id': '0000-0001-home_extract_fan_setpoint', - 'unit_of_measurement': '%', - }) -# --- -# name: test_numbers[number.device_name_power_factor_9-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power_factor', - 'friendly_name': 'Device Name Power factor', - 'max': 100, - 'min': 0, - 'mode': , - 'step': 1, - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'number.device_name_power_factor_9', - 'last_changed': , - 'last_updated': , - 'state': '50', - }) -# --- diff --git a/tests/components/flexit_bacnet/test_binary_sensor.py b/tests/components/flexit_bacnet/test_binary_sensor.py index 649eebaec2c..ceb9853acac 100644 --- a/tests/components/flexit_bacnet/test_binary_sensor.py +++ b/tests/components/flexit_bacnet/test_binary_sensor.py @@ -8,8 +8,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry -from tests.components.flexit_bacnet import setup_with_selected_platforms +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform async def test_binary_sensors( @@ -24,13 +25,4 @@ async def test_binary_sensors( await setup_with_selected_platforms( hass, mock_config_entry, [Platform.BINARY_SENSOR] ) - entity_entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - - assert entity_entries - for entity_entry in entity_entries: - assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") - assert hass.states.get(entity_entry.entity_id) == snapshot( - name=f"{entity_entry.entity_id}-state" - ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 6c88e6e69d2..7b0546f60ea 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -8,10 +8,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from tests.common import MockConfigEntry -from tests.components.flexit_bacnet import setup_with_selected_platforms +from . import setup_with_selected_platforms -ENTITY_CLIMATE = "climate.device_name" +from tests.common import MockConfigEntry, snapshot_platform async def test_climate_entity( @@ -24,5 +23,4 @@ async def test_climate_entity( """Test the initial parameters.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) - assert hass.states.get(ENTITY_CLIMATE) == snapshot - assert entity_registry.async_get(ENTITY_CLIMATE) == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/flexit_bacnet/test_init.py b/tests/components/flexit_bacnet/test_init.py index 4ff52a3bcfc..4cae562c1be 100644 --- a/tests/components/flexit_bacnet/test_init.py +++ b/tests/components/flexit_bacnet/test_init.py @@ -7,8 +7,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from . import setup_with_selected_platforms + from tests.common import MockConfigEntry -from tests.components.flexit_bacnet import setup_with_selected_platforms async def test_loading_and_unloading_config_entry( diff --git a/tests/components/flexit_bacnet/test_number.py b/tests/components/flexit_bacnet/test_number.py index 2aa3c9abcff..ad49908fa96 100644 --- a/tests/components/flexit_bacnet/test_number.py +++ b/tests/components/flexit_bacnet/test_number.py @@ -6,15 +6,19 @@ from flexit_bacnet import DecodingError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.number.const import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry -from tests.components.flexit_bacnet import setup_with_selected_platforms +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform ENTITY_ID = "number.device_name_fireplace_supply_fan_setpoint" @@ -29,15 +33,8 @@ async def test_numbers( """Test number states are correctly collected from library.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) - entity_entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - assert entity_entries - for entity_entry in entity_entries: - assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") - assert (state := hass.states.get(entity_entry.entity_id)) - assert state == snapshot(name=f"{entity_entry.entity_id}-state") + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_numbers_implementation( diff --git a/tests/components/flexit_bacnet/test_sensor.py b/tests/components/flexit_bacnet/test_sensor.py index 460f2cf5728..ef1269ee7b2 100644 --- a/tests/components/flexit_bacnet/test_sensor.py +++ b/tests/components/flexit_bacnet/test_sensor.py @@ -8,8 +8,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry -from tests.components.flexit_bacnet import setup_with_selected_platforms +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform async def test_sensors( @@ -22,13 +23,5 @@ async def test_sensors( """Test sensor states are correctly collected from library.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) - entity_entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - assert entity_entries - for entity_entry in entity_entries: - assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") - assert hass.states.get(entity_entry.entity_id) == snapshot( - name=f"{entity_entry.entity_id}-state" - ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/flexit_bacnet/test_switch.py b/tests/components/flexit_bacnet/test_switch.py index 19c7dfc804e..8ce0bf11977 100644 --- a/tests/components/flexit_bacnet/test_switch.py +++ b/tests/components/flexit_bacnet/test_switch.py @@ -16,8 +16,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry -from tests.components.flexit_bacnet import setup_with_selected_platforms +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform ENTITY_ID = "switch.device_name_electric_heater" @@ -32,15 +33,8 @@ async def test_switches( """Test switch states are correctly collected from library.""" await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SWITCH]) - entity_entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - assert entity_entries - for entity_entry in entity_entries: - assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") - assert (state := hass.states.get(entity_entry.entity_id)) - assert state == snapshot(name=f"{entity_entry.entity_id}-state") + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_switches_implementation( diff --git a/tests/components/flic/test_binary_sensor.py b/tests/components/flic/test_binary_sensor.py index d2584e4f5a2..44db1d6ea1b 100644 --- a/tests/components/flic/test_binary_sensor.py +++ b/tests/components/flic/test_binary_sensor.py @@ -12,6 +12,7 @@ class _MockFlicClient: self.addresses = button_addresses self.get_info_callback = None self.scan_wizard = None + self.channel = None def close(self): pass diff --git a/tests/components/flo/conftest.py b/tests/components/flo/conftest.py index 3cd666b7462..33d467a2abf 100644 --- a/tests/components/flo/conftest.py +++ b/tests/components/flo/conftest.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONTENT_TYPE_JSON from .common import TEST_EMAIL_ADDRESS, TEST_PASSWORD, TEST_TOKEN, TEST_USER_ID from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture @@ -25,7 +26,7 @@ def config_entry(hass): @pytest.fixture -def aioclient_mock_fixture(aioclient_mock): +def aioclient_mock_fixture(aioclient_mock: AiohttpClientMocker) -> None: """Fixture to provide a aioclient mocker.""" now = round(time.time()) # Mocks the login response for flo. diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index c1c9222c723..6248bdcd8f9 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -7,7 +7,7 @@ from aioflo.errors import RequestError from freezegun.api import FrozenDateTimeFactory from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.components.flo.device import FloDeviceDataUpdateCoordinator +from homeassistant.components.flo.coordinator import FloDeviceDataUpdateCoordinator from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/flo/test_services.py b/tests/components/flo/test_services.py index c65aa7937ee..d8837d9c6b6 100644 --- a/tests/components/flo/test_services.py +++ b/tests/components/flo/test_services.py @@ -106,5 +106,4 @@ async def test_services( }, blocking=True, ) - await hass.async_block_till_done() - assert aioclient_mock.call_count == 13 + assert aioclient_mock.call_count == 13 diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index 018d1c43b70..ab85303584f 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -14,6 +14,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, ) from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -28,9 +29,9 @@ from tests.components.light.common import MockLight @pytest.fixture(autouse=True) -def set_utc(hass): +async def set_utc(hass): """Set timezone to UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") async def test_valid_config(hass: HomeAssistant) -> None: @@ -52,6 +53,31 @@ async def test_valid_config(hass: HomeAssistant) -> None: assert state.state == "off" +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test configuration with unique ID.""" + assert await async_setup_component( + hass, + "switch", + { + "switch": { + "platform": "flux", + "name": "flux", + "lights": ["light.desk", "light.lamp"], + "unique_id": "zaphotbeeblebrox", + } + }, + ) + await hass.async_block_till_done() + state = hass.states.get("switch.flux") + assert state + assert state.state == "off" + + assert len(entity_registry.entities) == 1 + assert entity_registry.async_get_entity_id("switch", "flux", "zaphotbeeblebrox") + + async def test_restore_state_last_on(hass: HomeAssistant) -> None: """Test restoring state when the last state is on.""" mock_restore_cache(hass, [State("switch.flux", "on")]) diff --git a/tests/components/folder/test_sensor.py b/tests/components/folder/test_sensor.py index ad0969c6a0f..e71f1b3addc 100644 --- a/tests/components/folder/test_sensor.py +++ b/tests/components/folder/test_sensor.py @@ -15,7 +15,7 @@ TEST_FILE = os.path.join(TEST_DIR, TEST_TXT) def create_file(path): """Create a test file.""" - with open(path, "w") as test_file: + with open(path, "w", encoding="utf8") as test_file: test_file.write("test") diff --git a/tests/components/folder_watcher/conftest.py b/tests/components/folder_watcher/conftest.py index 06c0a41d49c..6de9c69d574 100644 --- a/tests/components/folder_watcher/conftest.py +++ b/tests/components/folder_watcher/conftest.py @@ -2,16 +2,49 @@ from __future__ import annotations -from collections.abc import Generator +from pathlib import Path from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from typing_extensions import Generator + +from homeassistant.components.folder_watcher.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.folder_watcher.async_setup_entry", return_value=True ): yield + + +@pytest.fixture +async def load_int( + hass: HomeAssistant, tmp_path: Path, freezer: FrozenDateTimeFactory +) -> MockConfigEntry: + """Set up the Folder watcher integration in Home Assistant.""" + freezer.move_to("2022-04-19 10:31:02+00:00") + path = tmp_path.as_posix() + hass.config.allowlist_external_dirs = {path} + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + title=f"Folder Watcher {path!s}", + data={}, + options={"folder": str(path), "patterns": ["*"]}, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/folder_watcher/snapshots/test_event.ambr b/tests/components/folder_watcher/snapshots/test_event.ambr new file mode 100644 index 00000000000..04405e0694b --- /dev/null +++ b/tests/components/folder_watcher/snapshots/test_event.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_event_entity[1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'closed', + 'created', + 'deleted', + 'modified', + 'moved', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'folder_watcher', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'folder_watcher', + 'unique_id': '1', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_entity[1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'dest_file': 'hello2.txt', + 'event_type': 'moved', + 'event_types': list([ + 'closed', + 'created', + 'deleted', + 'modified', + 'moved', + ]), + 'file': 'hello.txt', + }), + 'context': , + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-04-19T10:31:02.000+00:00', + }) +# --- diff --git a/tests/components/folder_watcher/test_event.py b/tests/components/folder_watcher/test_event.py new file mode 100644 index 00000000000..71f9094f59f --- /dev/null +++ b/tests/components/folder_watcher/test_event.py @@ -0,0 +1,53 @@ +"""The event entity tests for Folder Watcher.""" + +from pathlib import Path +from time import sleep + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_event_entity( + hass: HomeAssistant, + load_int: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + tmp_path: Path, +) -> None: + """Test the event entity.""" + entry = load_int + await hass.async_block_till_done() + + file = tmp_path.joinpath("hello.txt") + file.write_text("Hello, world!") + new_file = tmp_path.joinpath("hello2.txt") + file.rename(new_file) + + await hass.async_add_executor_job(sleep, 0.1) + + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert entity_entries + + def limit_attrs(prop, path): + exclude_attrs = { + "entity_id", + "friendly_name", + "folder", + "path", + "dest_folder", + "dest_path", + } + return prop in exclude_attrs + + for entity_entry in entity_entries: + assert entity_entry == snapshot( + name=f"{entity_entry.unique_id}-entry", exclude=limit_attrs + ) + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot( + name=f"{entity_entry.unique_id}-state", exclude=limit_attrs + ) diff --git a/tests/components/folder_watcher/test_init.py b/tests/components/folder_watcher/test_init.py index 2e9eb99f678..8309988931a 100644 --- a/tests/components/folder_watcher/test_init.py +++ b/tests/components/folder_watcher/test_init.py @@ -44,7 +44,7 @@ def test_event() -> None: MockPatternMatchingEventHandler, ): hass = Mock() - handler = folder_watcher.create_event_handler(["*"], hass) + handler = folder_watcher.create_event_handler(["*"], hass, "1") handler.on_created( SimpleNamespace( is_directory=False, src_path="/hello/world.txt", event_type="created" @@ -74,7 +74,7 @@ def test_move_event() -> None: MockPatternMatchingEventHandler, ): hass = Mock() - handler = folder_watcher.create_event_handler(["*"], hass) + handler = folder_watcher.create_event_handler(["*"], hass, "1") handler.on_moved( SimpleNamespace( is_directory=False, diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 06cf39b4875..d1eacad8dbe 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -1,11 +1,11 @@ """Fixtures for Forecast.Solar integration tests.""" -from collections.abc import Generator from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch from forecast_solar import models import pytest +from typing_extensions import Generator from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, @@ -24,7 +24,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.forecast_solar.async_setup_entry", return_value=True @@ -57,7 +57,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]: +def mock_forecast_solar(hass: HomeAssistant) -> Generator[MagicMock]: """Return a mocked Forecast.Solar client. hass fixture included because it sets the time zone. @@ -67,7 +67,7 @@ def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]: autospec=True, ) as forecast_solar_mock: forecast_solar = forecast_solar_mock.return_value - now = datetime(2021, 6, 27, 6, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE) + now = datetime(2021, 6, 27, 6, 0, tzinfo=dt_util.get_default_time_zone()) estimate = MagicMock(spec=models.Estimate) estimate.now.return_value = now @@ -79,10 +79,10 @@ def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]: estimate.energy_production_tomorrow = 200000 estimate.power_production_now = 300000 estimate.power_highest_peak_time_today = datetime( - 2021, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE + 2021, 6, 27, 13, 0, tzinfo=dt_util.get_default_time_zone() ) estimate.power_highest_peak_time_tomorrow = datetime( - 2021, 6, 27, 14, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE + 2021, 6, 27, 14, 0, tzinfo=dt_util.get_default_time_zone() ) estimate.energy_current_hour = 800000 @@ -96,16 +96,16 @@ def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]: 1: 900000, }.get estimate.watts = { - datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 10, - datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 100, + datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.get_default_time_zone()): 10, + datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.get_default_time_zone()): 100, } estimate.wh_days = { - datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 20, - datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 200, + datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.get_default_time_zone()): 20, + datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.get_default_time_zone()): 200, } estimate.wh_period = { - datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 30, - datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE): 300, + datetime(2021, 6, 27, 13, 0, tzinfo=dt_util.get_default_time_zone()): 30, + datetime(2022, 6, 27, 13, 0, tzinfo=dt_util.get_default_time_zone()): 300, } forecast_solar.estimate.return_value = estimate diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index 29923c9f9e9..805bcac3976 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -12,10 +12,10 @@ from homeassistant.components.forked_daapd.browse_media import ( is_owntone_media_content_id, ) from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType -from homeassistant.components.spotify.const import ( +from homeassistant.components.spotify.const import ( # pylint: disable=hass-component-root-import MEDIA_PLAYER_PREFIX as SPOTIFY_MEDIA_PLAYER_PREFIX, ) -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 19488666be7..dd2e03f435f 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -347,7 +347,7 @@ async def test_unload_config_entry( """Test the player is set unavailable when the config entry is unloaded.""" assert hass.states.get(TEST_MASTER_ENTITY_NAME) assert hass.states.get(TEST_ZONE_ENTITY_NAMES[0]) - await config_entry.async_unload(hass) + await hass.config_entries.async_unload(config_entry.entry_id) assert hass.states.get(TEST_MASTER_ENTITY_NAME).state == STATE_UNAVAILABLE assert hass.states.get(TEST_ZONE_ENTITY_NAMES[0]).state == STATE_UNAVAILABLE diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py index bdb60933a19..d142fd767e1 100644 --- a/tests/components/freedns/test_init.py +++ b/tests/components/freedns/test_init.py @@ -16,7 +16,7 @@ UPDATE_URL = freedns.UPDATE_URL @pytest.fixture -def setup_freedns(hass, aioclient_mock): +def setup_freedns(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up FreeDNS.""" params = {} params[ACCESS_TOKEN] = "" diff --git a/tests/components/freedompro/conftest.py b/tests/components/freedompro/conftest.py index 27e6c767223..91eecc24f27 100644 --- a/tests/components/freedompro/conftest.py +++ b/tests/components/freedompro/conftest.py @@ -2,14 +2,15 @@ from __future__ import annotations -from collections.abc import Generator from copy import deepcopy from typing import Any from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.freedompro.const import DOMAIN +from homeassistant.core import HomeAssistant from .const import DEVICES, DEVICES_STATE @@ -17,7 +18,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.freedompro.async_setup_entry", return_value=True @@ -45,7 +46,7 @@ def mock_freedompro(): @pytest.fixture -async def init_integration(hass) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Freedompro integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, @@ -64,7 +65,7 @@ async def init_integration(hass) -> MockConfigEntry: @pytest.fixture -async def init_integration_no_state(hass) -> MockConfigEntry: +async def init_integration_no_state(hass: HomeAssistant) -> MockConfigEntry: """Set up the Freedompro integration in Home Assistant without state.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index acf6b0e98cd..bb049f067b4 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -84,7 +84,7 @@ def fc_data_mock(): def fc_class_mock(fc_data): """Fixture that sets up a mocked FritzConnection class.""" with patch( - "homeassistant.components.fritz.common.FritzConnection", autospec=True + "homeassistant.components.fritz.coordinator.FritzConnection", autospec=True ) as result: result.return_value = FritzConnectionMock(fc_data) yield result @@ -94,7 +94,7 @@ def fc_class_mock(fc_data): def fh_class_mock(): """Fixture that sets up a mocked FritzHosts class.""" with patch( - "homeassistant.components.fritz.common.FritzHosts", + "homeassistant.components.fritz.coordinator.FritzHosts", new=FritzHosts, ) as result: result.get_mesh_topology = MagicMock(return_value=MOCK_MESH_DATA) diff --git a/tests/components/fritz/snapshots/test_image.ambr b/tests/components/fritz/snapshots/test_image.ambr index 452aab2a887..a51ab015a89 100644 --- a/tests/components/fritz/snapshots/test_image.ambr +++ b/tests/components/fritz/snapshots/test_image.ambr @@ -1,10 +1,4 @@ # serializer version: 1 -# name: test_image[fc_data0] - b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x94\x00\x00\x00\x94\x01\x00\x00\x00\x00]G=y\x00\x00\x00\xf5IDATx\xda\xedVQ\x0eC!\x0c"\xbb@\xef\x7fKn\xe0\x00\xfd\xdb\xcf6\xf9|\xc6\xc4\xc6\x0f\xd2\x02\xadb},\xe2\xb9\xfb\xe5\x0e\xc0(\x18\xf2\x84/|\xaeo\xef\x847\xda\x14\x1af\x1c\xde\xe3\x19(X\tKxN\xb2\x87\x17j9\x1d None: @@ -97,7 +96,7 @@ async def test_wol_button( assert button assert button.state == STATE_UNKNOWN with patch( - "homeassistant.components.fritz.common.AvmWrapper.async_wake_on_lan" + "homeassistant.components.fritz.coordinator.AvmWrapper.async_wake_on_lan" ) as mock_press_action: await hass.services.async_call( BUTTON_DOMAIN, @@ -105,16 +104,15 @@ async def test_wol_button( {ATTR_ENTITY_ID: "button.printer_wake_on_lan"}, blocking=True, ) - await hass.async_block_till_done() mock_press_action.assert_called_once_with("AA:BB:CC:00:11:22") button = hass.states.get("button.printer_wake_on_lan") assert button.state != STATE_UNKNOWN +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_wol_button_new_device( hass: HomeAssistant, - entity_registry_enabled_by_default: None, fc_class_mock, fh_class_mock, ) -> None: @@ -140,9 +138,9 @@ async def test_wol_button_new_device( assert hass.states.get("button.server_wake_on_lan") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_wol_button_absent_for_mesh_slave( hass: HomeAssistant, - entity_registry_enabled_by_default: None, fc_class_mock, fh_class_mock, ) -> None: @@ -162,9 +160,9 @@ async def test_wol_button_absent_for_mesh_slave( assert button is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_wol_button_absent_for_non_lan_device( hass: HomeAssistant, - entity_registry_enabled_by_default: None, fc_class_mock, fh_class_mock, ) -> None: diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index f87fbe722cd..a54acbb0ac0 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -93,7 +93,6 @@ from tests.common import MockConfigEntry async def test_user( hass: HomeAssistant, fc_class_mock, - mock_get_source_ip, show_advanced_options: bool, user_input: dict, expected_config: dict, @@ -105,7 +104,7 @@ async def test_user( side_effect=fc_class_mock, ), patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + "homeassistant.components.fritz.coordinator.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch("homeassistant.components.fritz.async_setup_entry") as mock_setup_entry, @@ -145,7 +144,6 @@ async def test_user( == DEFAULT_CONSIDER_HOME.total_seconds() ) assert not result["result"].unique_id - await hass.async_block_till_done() assert mock_setup_entry.called @@ -157,7 +155,6 @@ async def test_user( async def test_user_already_configured( hass: HomeAssistant, fc_class_mock, - mock_get_source_ip, show_advanced_options: bool, user_input, ) -> None: @@ -172,7 +169,7 @@ async def test_user_already_configured( side_effect=fc_class_mock, ), patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + "homeassistant.components.fritz.coordinator.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( @@ -219,7 +216,6 @@ async def test_user_already_configured( ) async def test_exception_security( hass: HomeAssistant, - mock_get_source_ip, error, show_advanced_options: bool, user_input, @@ -252,7 +248,6 @@ async def test_exception_security( ) async def test_exception_connection( hass: HomeAssistant, - mock_get_source_ip, show_advanced_options: bool, user_input, ) -> None: @@ -283,7 +278,7 @@ async def test_exception_connection( [(True, MOCK_USER_INPUT_ADVANCED), (False, MOCK_USER_INPUT_SIMPLE)], ) async def test_exception_unknown( - hass: HomeAssistant, mock_get_source_ip, show_advanced_options: bool, user_input + hass: HomeAssistant, show_advanced_options: bool, user_input ) -> None: """Test starting a flow by user with an unknown exception.""" @@ -310,7 +305,6 @@ async def test_exception_unknown( async def test_reauth_successful( hass: HomeAssistant, fc_class_mock, - mock_get_source_ip, ) -> None: """Test starting a reauthentication flow.""" @@ -323,7 +317,7 @@ async def test_reauth_successful( side_effect=fc_class_mock, ), patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + "homeassistant.components.fritz.coordinator.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( @@ -375,7 +369,6 @@ async def test_reauth_successful( async def test_reauth_not_successful( hass: HomeAssistant, fc_class_mock, - mock_get_source_ip, side_effect, error, ) -> None: @@ -443,7 +436,6 @@ async def test_reauth_not_successful( async def test_reconfigure_successful( hass: HomeAssistant, fc_class_mock, - mock_get_source_ip, show_advanced_options: bool, user_input: dict, expected_config: dict, @@ -459,7 +451,7 @@ async def test_reconfigure_successful( side_effect=fc_class_mock, ), patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + "homeassistant.components.fritz.coordinator.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( @@ -509,7 +501,6 @@ async def test_reconfigure_successful( async def test_reconfigure_not_successful( hass: HomeAssistant, fc_class_mock, - mock_get_source_ip, ) -> None: """Test starting a reconfigure flow but no connection found.""" @@ -522,7 +513,7 @@ async def test_reconfigure_not_successful( side_effect=[FritzConnectionException, fc_class_mock], ), patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + "homeassistant.components.fritz.coordinator.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch( @@ -580,9 +571,7 @@ async def test_reconfigure_not_successful( } -async def test_ssdp_already_configured( - hass: HomeAssistant, fc_class_mock, mock_get_source_ip -) -> None: +async def test_ssdp_already_configured(hass: HomeAssistant, fc_class_mock) -> None: """Test starting a flow from discovery with an already configured device.""" mock_config = MockConfigEntry( @@ -609,9 +598,7 @@ async def test_ssdp_already_configured( assert result["reason"] == "already_configured" -async def test_ssdp_already_configured_host( - hass: HomeAssistant, fc_class_mock, mock_get_source_ip -) -> None: +async def test_ssdp_already_configured_host(hass: HomeAssistant, fc_class_mock) -> None: """Test starting a flow from discovery with an already configured host.""" mock_config = MockConfigEntry( @@ -639,7 +626,7 @@ async def test_ssdp_already_configured_host( async def test_ssdp_already_configured_host_uuid( - hass: HomeAssistant, fc_class_mock, mock_get_source_ip + hass: HomeAssistant, fc_class_mock ) -> None: """Test starting a flow from discovery with an already configured uuid.""" @@ -668,7 +655,7 @@ async def test_ssdp_already_configured_host_uuid( async def test_ssdp_already_in_progress_host( - hass: HomeAssistant, fc_class_mock, mock_get_source_ip + hass: HomeAssistant, fc_class_mock ) -> None: """Test starting a flow from discovery twice.""" with patch( @@ -691,7 +678,7 @@ async def test_ssdp_already_in_progress_host( assert result["reason"] == "already_in_progress" -async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip) -> None: +async def test_ssdp(hass: HomeAssistant, fc_class_mock) -> None: """Test starting a flow from discovery.""" with ( patch( @@ -699,7 +686,7 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip) -> N side_effect=fc_class_mock, ), patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", + "homeassistant.components.fritz.coordinator.FritzBoxTools._update_device_info", return_value=MOCK_FIRMWARE_INFO, ), patch("homeassistant.components.fritz.async_setup_entry") as mock_setup_entry, @@ -733,7 +720,7 @@ async def test_ssdp(hass: HomeAssistant, fc_class_mock, mock_get_source_ip) -> N assert mock_setup_entry.called -async def test_ssdp_exception(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_ssdp_exception(hass: HomeAssistant) -> None: """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.fritz.config_flow.FritzConnection", @@ -764,14 +751,12 @@ async def test_options_flow(hass: HomeAssistant) -> None: mock_config.add_to_hass(hass) result = await hass.config_entries.options.async_init(mock_config.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CONSIDER_HOME: 37, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index 35d50ff4572..55196eb6988 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -3,8 +3,8 @@ from __future__ import annotations from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.fritz.common import AvmWrapper from homeassistant.components.fritz.const import DOMAIN +from homeassistant.components.fritz.coordinator import AvmWrapper from homeassistant.components.fritz.diagnostics import TO_REDACT from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/fritz/test_image.py b/tests/components/fritz/test_image.py index a22ab76fdb6..9097aab1762 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -13,7 +13,7 @@ from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from .const import MOCK_FB_SERVICES, MOCK_USER_DATA @@ -89,6 +89,7 @@ GUEST_WIFI_DISABLED: dict[str, dict] = { async def test_image_entity( hass: HomeAssistant, hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, fc_class_mock, fh_class_mock, @@ -122,7 +123,6 @@ async def test_image_entity( "friendly_name": "Mock Title GuestWifi", } - entity_registry = async_get_entity_registry(hass) entity_entry = entity_registry.async_get("image.mock_title_guestwifi") assert entity_entry.unique_id == "1c_ed_6f_12_34_11_guestwifi_qr_code" diff --git a/tests/components/fritz/test_init.py b/tests/components/fritz/test_init.py index be45698e160..de69e0b5914 100644 --- a/tests/components/fritz/test_init.py +++ b/tests/components/fritz/test_init.py @@ -56,7 +56,6 @@ async def test_options_reload( assert entry.state is ConfigEntryState.LOADED result = await hass.config_entries.options.async_init(entry.entry_id) - await hass.async_block_till_done() await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_CONSIDER_HOME: 60}, @@ -76,7 +75,7 @@ async def test_setup_auth_fail(hass: HomeAssistant, error) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.fritz.common.FritzConnection", + "homeassistant.components.fritz.coordinator.FritzConnection", side_effect=error, ): await hass.config_entries.async_setup(entry.entry_id) @@ -96,7 +95,7 @@ async def test_setup_fail(hass: HomeAssistant, error) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.fritz.common.FritzConnection", + "homeassistant.components.fritz.coordinator.FritzConnection", side_effect=error, ): await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index c39dd24de02..5d7ef852d4c 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -104,7 +104,7 @@ async def test_available_update_can_be_installed( fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) with patch( - "homeassistant.components.fritz.common.FritzBoxTools.async_trigger_firmware_update", + "homeassistant.components.fritz.coordinator.FritzBoxTools.async_trigger_firmware_update", return_value=True, ) as mocked_update_call: entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 5fb9c853bf5..2bd8f26d73b 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -103,10 +103,10 @@ class FritzDeviceClimateMock(FritzEntityBaseMock): has_temperature_sensor = True has_thermostat = True has_blind = False - holiday_active = "fake_holiday" + holiday_active = False lock = "fake_locked" present = True - summer_active = "fake_summer" + summer_active = False target_temperature = 19.5 window_open = "fake_window" nextchange_temperature = 22.0 diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 54d222c6899..8d1da9d09d5 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -3,6 +3,8 @@ from datetime import timedelta from unittest.mock import Mock, call +from freezegun.api import FrozenDateTimeFactory +import pytest from requests.exceptions import HTTPError from homeassistant.components.climate import ( @@ -21,6 +23,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) +from homeassistant.components.fritzbox.climate import PRESET_HOLIDAY, PRESET_SUMMER from homeassistant.components.fritzbox.const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, @@ -40,6 +43,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util from . import FritzDeviceClimateMock, set_devices, setup_config_entry @@ -68,8 +72,8 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_PRESET_MODE] is None assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] assert state.attributes[ATTR_STATE_BATTERY_LOW] is True - assert state.attributes[ATTR_STATE_HOLIDAY_MODE] == "fake_holiday" - assert state.attributes[ATTR_STATE_SUMMER_MODE] == "fake_summer" + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" assert state.attributes[ATTR_TEMPERATURE] == 19.5 assert ATTR_STATE_CLASS not in state.attributes @@ -444,3 +448,109 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: state = hass.states.get(f"{DOMAIN}.new_climate") assert state + + +async def test_holidy_summer_mode( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, fritz: Mock +) -> None: + """Test holiday and summer mode.""" + device = FritzDeviceClimateMock() + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + # initial state + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] + assert state.attributes[ATTR_PRESET_MODE] is None + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] + + # test holiday mode + device.holiday_active = True + device.summer_active = False + freezer.tick(timedelta(seconds=200)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOLIDAY + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_HOLIDAY] + + with pytest.raises( + HomeAssistantError, + match="Can't change hvac mode while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {"entity_id": ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + with pytest.raises( + HomeAssistantError, + match="Can't change preset while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_PRESET_MODE, + {"entity_id": ENTITY_ID, ATTR_PRESET_MODE: PRESET_HOLIDAY}, + blocking=True, + ) + + # test summer mode + device.holiday_active = False + device.summer_active = True + freezer.tick(timedelta(seconds=200)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF] + assert state.attributes[ATTR_PRESET_MODE] == PRESET_SUMMER + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_SUMMER] + + with pytest.raises( + HomeAssistantError, + match="Can't change hvac mode while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_HVAC_MODE, + {"entity_id": ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + with pytest.raises( + HomeAssistantError, + match="Can't change preset while holiday or summer mode is active on the device", + ): + await hass.services.async_call( + "climate", + SERVICE_SET_PRESET_MODE, + {"entity_id": ENTITY_ID, ATTR_PRESET_MODE: PRESET_SUMMER}, + blocking=True, + ) + + # back to normal state + device.holiday_active = False + device.summer_active = False + freezer.tick(timedelta(seconds=200)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.attributes[ATTR_STATE_HOLIDAY_MODE] is False + assert state.attributes[ATTR_STATE_SUMMER_MODE] is False + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT, HVACMode.OFF] + assert state.attributes[ATTR_PRESET_MODE] is None + assert state.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_COMFORT] diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index f1630d6cd7e..2109d4a6692 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -25,6 +26,7 @@ async def setup_fronius_integration( """Create the Fronius integration.""" entry = MockConfigEntry( domain=DOMAIN, + entry_id="f1e2b9837e8adaed6fa682acaa216fd8", unique_id=unique_id, # has to match mocked logger unique_id data={ CONF_HOST: MOCK_HOST, @@ -63,7 +65,7 @@ def mock_responses( aioclient_mock: AiohttpClientMocker, host: str = MOCK_HOST, fixture_set: str = "symo", - inverter_ids: list[str | int] = [1], + inverter_ids: list[str | int] | UndefinedType = UNDEFINED, night: bool = False, override_data: dict[str, list[tuple[list[str], Any]]] | None = None, # {filename: [([list of nested keys], patch_value)]} @@ -77,7 +79,7 @@ def mock_responses( f"{host}/solar_api/GetAPIVersion.cgi", text=_load(f"{fixture_set}/GetAPIVersion.json", "fronius"), ) - for inverter_id in inverter_ids: + for inverter_id in [1] if inverter_ids is UNDEFINED else inverter_ids: aioclient_mock.get( f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&" f"DeviceId={inverter_id}&DataCollection=CommonInverterData", diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f23d63a58e3 --- /dev/null +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -0,0 +1,370 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'http://fronius', + 'is_logger': True, + }), + 'disabled_by': None, + 'domain': 'fronius', + 'entry_id': 'f1e2b9837e8adaed6fa682acaa216fd8', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'coordinators': dict({ + 'inverters': dict({ + '1': dict({ + 'current_ac': dict({ + 'unit': 'A', + 'value': 5.19, + }), + 'current_dc': dict({ + 'unit': 'A', + 'value': 2.19, + }), + 'energy_day': dict({ + 'unit': 'Wh', + 'value': 1113, + }), + 'energy_total': dict({ + 'unit': 'Wh', + 'value': 44188000, + }), + 'energy_year': dict({ + 'unit': 'Wh', + 'value': 25508798, + }), + 'error_code': dict({ + 'value': 0, + }), + 'frequency_ac': dict({ + 'unit': 'Hz', + 'value': 49.94, + }), + 'led_color': dict({ + 'value': 2, + }), + 'led_state': dict({ + 'value': 0, + }), + 'power_ac': dict({ + 'unit': 'W', + 'value': 1190, + }), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'status_code': dict({ + 'value': 7, + }), + 'timestamp': dict({ + 'value': '2021-10-07T10:01:17+02:00', + }), + 'voltage_ac': dict({ + 'unit': 'V', + 'value': 227.9, + }), + 'voltage_dc': dict({ + 'unit': 'V', + 'value': 518, + }), + }), + }), + 'logger': dict({ + 'system': dict({ + 'cash_factor': dict({ + 'unit': 'EUR/kWh', + 'value': 0.07800000160932541, + }), + 'co2_factor': dict({ + 'unit': 'kg/kWh', + 'value': 0.5299999713897705, + }), + 'delivery_factor': dict({ + 'unit': 'EUR/kWh', + 'value': 0.15000000596046448, + }), + 'hardware_platform': dict({ + 'value': 'wilma', + }), + 'hardware_version': dict({ + 'value': '2.4E', + }), + 'product_type': dict({ + 'value': 'fronius-datamanager-card', + }), + 'software_version': dict({ + 'value': '3.18.7-1', + }), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'time_zone': dict({ + 'value': 'CEST', + }), + 'time_zone_location': dict({ + 'value': 'Vienna', + }), + 'timestamp': dict({ + 'value': '2021-10-06T23:56:32+02:00', + }), + 'unique_identifier': '**REDACTED**', + 'utc_offset': dict({ + 'value': 7200, + }), + }), + }), + 'meter': dict({ + '0': dict({ + 'current_ac_phase_1': dict({ + 'unit': 'A', + 'value': 7.755, + }), + 'current_ac_phase_2': dict({ + 'unit': 'A', + 'value': 6.68, + }), + 'current_ac_phase_3': dict({ + 'unit': 'A', + 'value': 10.102, + }), + 'enable': dict({ + 'value': 1, + }), + 'energy_reactive_ac_consumed': dict({ + 'unit': 'VArh', + 'value': 59960790, + }), + 'energy_reactive_ac_produced': dict({ + 'unit': 'VArh', + 'value': 723160, + }), + 'energy_real_ac_minus': dict({ + 'unit': 'Wh', + 'value': 35623065, + }), + 'energy_real_ac_plus': dict({ + 'unit': 'Wh', + 'value': 15303334, + }), + 'energy_real_consumed': dict({ + 'unit': 'Wh', + 'value': 15303334, + }), + 'energy_real_produced': dict({ + 'unit': 'Wh', + 'value': 35623065, + }), + 'frequency_phase_average': dict({ + 'unit': 'Hz', + 'value': 50, + }), + 'manufacturer': dict({ + 'value': 'Fronius', + }), + 'meter_location': dict({ + 'value': 0, + }), + 'model': dict({ + 'value': 'Smart Meter 63A', + }), + 'power_apparent': dict({ + 'unit': 'VA', + 'value': 5592.57, + }), + 'power_apparent_phase_1': dict({ + 'unit': 'VA', + 'value': 1772.793, + }), + 'power_apparent_phase_2': dict({ + 'unit': 'VA', + 'value': 1527.048, + }), + 'power_apparent_phase_3': dict({ + 'unit': 'VA', + 'value': 2333.562, + }), + 'power_factor': dict({ + 'value': 1, + }), + 'power_factor_phase_1': dict({ + 'value': -0.99, + }), + 'power_factor_phase_2': dict({ + 'value': -0.99, + }), + 'power_factor_phase_3': dict({ + 'value': 0.99, + }), + 'power_reactive': dict({ + 'unit': 'VAr', + 'value': 2.87, + }), + 'power_reactive_phase_1': dict({ + 'unit': 'VAr', + 'value': 51.48, + }), + 'power_reactive_phase_2': dict({ + 'unit': 'VAr', + 'value': 115.63, + }), + 'power_reactive_phase_3': dict({ + 'unit': 'VAr', + 'value': -164.24, + }), + 'power_real': dict({ + 'unit': 'W', + 'value': 5592.57, + }), + 'power_real_phase_1': dict({ + 'unit': 'W', + 'value': 1765.55, + }), + 'power_real_phase_2': dict({ + 'unit': 'W', + 'value': 1515.8, + }), + 'power_real_phase_3': dict({ + 'unit': 'W', + 'value': 2311.22, + }), + 'serial': '**REDACTED**', + 'visible': dict({ + 'value': 1, + }), + 'voltage_ac_phase_1': dict({ + 'unit': 'V', + 'value': 228.6, + }), + 'voltage_ac_phase_2': dict({ + 'unit': 'V', + 'value': 228.6, + }), + 'voltage_ac_phase_3': dict({ + 'unit': 'V', + 'value': 231, + }), + 'voltage_ac_phase_to_phase_12': dict({ + 'unit': 'V', + 'value': 395.9, + }), + 'voltage_ac_phase_to_phase_23': dict({ + 'unit': 'V', + 'value': 398, + }), + 'voltage_ac_phase_to_phase_31': dict({ + 'unit': 'V', + 'value': 398, + }), + }), + }), + 'ohmpilot': None, + 'power_flow': dict({ + 'power_flow': dict({ + 'energy_day': dict({ + 'unit': 'Wh', + 'value': 1101.7000732421875, + }), + 'energy_total': dict({ + 'unit': 'Wh', + 'value': 44188000, + }), + 'energy_year': dict({ + 'unit': 'Wh', + 'value': 25508788, + }), + 'meter_location': dict({ + 'value': 'grid', + }), + 'meter_mode': dict({ + 'value': 'meter', + }), + 'power_battery': dict({ + 'unit': 'W', + 'value': None, + }), + 'power_grid': dict({ + 'unit': 'W', + 'value': 1703.74, + }), + 'power_load': dict({ + 'unit': 'W', + 'value': -2814.74, + }), + 'power_photovoltaics': dict({ + 'unit': 'W', + 'value': 1111, + }), + 'relative_autonomy': dict({ + 'unit': '%', + 'value': 39.4707859340472, + }), + 'relative_self_consumption': dict({ + 'unit': '%', + 'value': 100, + }), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'timestamp': dict({ + 'value': '2021-10-07T10:00:43+02:00', + }), + }), + }), + 'storage': None, + }), + 'inverter_info': dict({ + 'inverters': list([ + dict({ + 'custom_name': dict({ + 'value': 'Symo 20', + }), + 'device_id': dict({ + 'value': '1', + }), + 'device_type': dict({ + 'manufacturer': 'Fronius', + 'model': 'Symo 20.0-3-M', + 'value': 121, + }), + 'error_code': dict({ + 'value': 0, + }), + 'pv_power': dict({ + 'unit': 'W', + 'value': 23100, + }), + 'show': dict({ + 'value': 1, + }), + 'status_code': dict({ + 'value': 7, + }), + 'unique_id': '**REDACTED**', + }), + ]), + 'status': dict({ + 'Code': 0, + 'Reason': '', + 'UserMessage': '', + }), + 'timestamp': dict({ + 'value': '2021-10-07T13:41:00+02:00', + }), + }), + }) +# --- diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py index bf5ef360752..41593a0ad2e 100644 --- a/tests/components/fronius/test_config_flow.py +++ b/tests/components/fronius/test_config_flow.py @@ -50,7 +50,7 @@ async def test_form_with_logger(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] with ( patch( @@ -85,7 +85,7 @@ async def test_form_with_inverter(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] with ( patch( @@ -338,3 +338,232 @@ async def test_dhcp_invalid( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_host" + + +async def test_reconfigure(hass: HomeAssistant) -> None: + """Test reconfiguring an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with ( + patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), + patch( + "pyfronius.Fronius.inverter_info", + return_value=INVERTER_INFO_RETURN_VALUE, + ), + patch( + "homeassistant.components.fronius.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "host": "10.9.1.1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "host": "10.9.1.1", + "is_logger": False, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with ( + patch( + "pyfronius.Fronius.current_logger_info", + side_effect=FroniusError, + ), + patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reconfigure_unexpected(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "pyfronius.Fronius.current_logger_info", + side_effect=KeyError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_reconfigure_already_configured(hass: HomeAssistant) -> None: + """Test reconfiguring an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with ( + patch( + "pyfronius.Fronius.current_logger_info", + return_value=LOGGER_INFO_RETURN_VALUE, + ), + patch( + "pyfronius.Fronius.inverter_info", + return_value=INVERTER_INFO_RETURN_VALUE, + ), + patch( + "homeassistant.components.fronius.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "host": "10.1.2.3", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reconfigure_already_existing(hass: HomeAssistant) -> None: + """Test reconfiguring entry to already existing device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123.4567890", + data={ + CONF_HOST: "10.1.2.3", + "is_logger": True, + }, + ) + entry.add_to_hass(hass) + + entry_2_uid = "222.2222222" + entry_2 = MockConfigEntry( + domain=DOMAIN, + unique_id=entry_2_uid, + data={ + CONF_HOST: "10.2.2.2", + "is_logger": True, + }, + ) + entry_2.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + with patch( + "pyfronius.Fronius.current_logger_info", + return_value={"unique_identifier": {"value": entry_2_uid}}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "10.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/fronius/test_diagnostics.py b/tests/components/fronius/test_diagnostics.py new file mode 100644 index 00000000000..7b1f384e405 --- /dev/null +++ b/tests/components/fronius/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Tests for the diagnostics data provided by the Fronius integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import mock_responses, setup_fronius_integration + +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_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_responses(aioclient_mock) + entry = await setup_fronius_integration(hass) + + assert ( + await get_diagnostics_for_config_entry( + hass, + hass_client, + entry, + ) + == snapshot + ) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index f5e77660271..04c25ce26f2 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -34,14 +34,14 @@ async def test_symo_inverter( mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 assert_state("sensor.symo_20_dc_current", 0) assert_state("sensor.symo_20_energy_day", 10828) assert_state("sensor.symo_20_total_energy", 44186900) @@ -54,14 +54,14 @@ async def test_symo_inverter( freezer.tick(FroniusInverterUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 62 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 # 4 additional AC entities assert_state("sensor.symo_20_dc_current", 2.19) assert_state("sensor.symo_20_energy_day", 1113) @@ -97,7 +97,7 @@ async def test_symo_logger( mock_responses(aioclient_mock) await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 26 # states are rounded to 4 decimals assert_state("sensor.solarnet_grid_export_tariff", 0.078) assert_state("sensor.solarnet_co2_factor", 0.53) @@ -119,14 +119,14 @@ async def test_symo_meter( mock_responses(aioclient_mock) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 25 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 26 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 # states are rounded to 4 decimals assert_state("sensor.smart_meter_63a_current_phase_1", 7.755) assert_state("sensor.smart_meter_63a_current_phase_2", 6.68) @@ -222,20 +222,23 @@ async def test_symo_power_flow( mock_responses(aioclient_mock, night=True) config_entry = await setup_fronius_integration(hass) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 21 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # states are rounded to 4 decimals assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) assert_state("sensor.solarnet_energy_year", 25507686) assert_state("sensor.solarnet_power_grid", 975.31) + assert_state("sensor.solarnet_power_grid_import", 975.31) + assert_state("sensor.solarnet_power_grid_export", 0) assert_state("sensor.solarnet_power_load", -975.31) + assert_state("sensor.solarnet_power_load_consumed", 975.31) assert_state("sensor.solarnet_relative_autonomy", 0) # Second test at daytime when inverter is producing @@ -244,12 +247,16 @@ async def test_symo_power_flow( async_fire_time_changed(hass) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 assert_state("sensor.solarnet_energy_day", 1101.7001) assert_state("sensor.solarnet_total_energy", 44188000) assert_state("sensor.solarnet_energy_year", 25508788) assert_state("sensor.solarnet_power_grid", 1703.74) + assert_state("sensor.solarnet_power_grid_import", 1703.74) + assert_state("sensor.solarnet_power_grid_export", 0) assert_state("sensor.solarnet_power_load", -2814.74) + assert_state("sensor.solarnet_power_load_generated", 0) + assert_state("sensor.solarnet_power_load_consumed", 2814.74) assert_state("sensor.solarnet_power_photovoltaics", 1111) assert_state("sensor.solarnet_relative_autonomy", 39.4708) assert_state("sensor.solarnet_relative_self_consumption", 100) @@ -259,7 +266,7 @@ async def test_symo_power_flow( freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 60 assert_state("sensor.solarnet_energy_day", 10828) assert_state("sensor.solarnet_total_energy", 44186900) assert_state("sensor.solarnet_energy_year", 25507686) @@ -285,14 +292,14 @@ async def test_gen24( mock_responses(aioclient_mock, fixture_set="gen24") config_entry = await setup_fronius_integration(hass, is_logger=False) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 23 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # inverter 1 assert_state("sensor.inverter_name_ac_current", 0.1589) assert_state("sensor.inverter_name_dc_current_2", 0.0754) @@ -386,14 +393,14 @@ async def test_gen24_storage( hass, is_logger=False, unique_id="12345678" ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 35 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 37 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 66 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 72 # inverter 1 assert_state("sensor.gen24_storage_dc_current", 0.3952) assert_state("sensor.gen24_storage_dc_voltage_2", 318.8103) @@ -452,6 +459,8 @@ async def test_gen24_storage( # power_flow assert_state("sensor.solarnet_power_grid", 2274.9) assert_state("sensor.solarnet_power_battery", 0.1591) + assert_state("sensor.solarnet_power_battery_charge", 0) + assert_state("sensor.solarnet_power_battery_discharge", 0.1591) assert_state("sensor.solarnet_power_load", -2459.3092) assert_state("sensor.solarnet_relative_self_consumption", 100.0) assert_state("sensor.solarnet_power_photovoltaics", 216.4328) @@ -514,14 +523,14 @@ async def test_primo_s0( mock_responses(aioclient_mock, fixture_set="primo_s0", inverter_ids=[1, 2]) config_entry = await setup_fronius_integration(hass, is_logger=True) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 30 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 31 await enable_all_entities( hass, freezer, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval, ) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 43 + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 47 # logger assert_state("sensor.solarnet_grid_export_tariff", 1) assert_state("sensor.solarnet_co2_factor", 0.53) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index d715eb8859d..83c82abea35 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -1,10 +1,13 @@ """The tests for Home Assistant frontend.""" +from asyncio import AbstractEventLoop from http import HTTPStatus +from pathlib import Path import re from typing import Any from unittest.mock import patch +from aiohttp.test_utils import TestClient from freezegun.api import FrozenDateTimeFactory import pytest @@ -16,16 +19,22 @@ from homeassistant.components.frontend import ( DOMAIN, EVENT_PANELS_UPDATED, THEMES_STORAGE_KEY, + add_extra_js_url, async_register_built_in_panel, async_remove_panel, + remove_extra_js_url, ) -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component from tests.common import MockUser, async_capture_events, async_fire_time_changed -from tests.typing import MockHAClientWebSocket, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) MOCK_THEMES = { "happy": {"primary-color": "red", "app-header-background-color": "blue"}, @@ -84,31 +93,43 @@ async def frontend_themes(hass): @pytest.fixture -def aiohttp_client(event_loop, aiohttp_client, socket_enabled): +def aiohttp_client( + event_loop: AbstractEventLoop, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, +) -> ClientSessionGenerator: """Return aiohttp_client and allow opening sockets.""" return aiohttp_client @pytest.fixture -async def mock_http_client(hass, aiohttp_client, frontend): +async def mock_http_client( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, frontend +) -> TestClient: """Start the Home Assistant HTTP component.""" return await aiohttp_client(hass.http.app) @pytest.fixture -async def themes_ws_client(hass, hass_ws_client, frontend_themes): +async def themes_ws_client( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, frontend_themes +) -> MockHAClientWebSocket: """Start the Home Assistant HTTP component.""" return await hass_ws_client(hass) @pytest.fixture -async def ws_client(hass, hass_ws_client, frontend): +async def ws_client( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, frontend +) -> MockHAClientWebSocket: """Start the Home Assistant HTTP component.""" return await hass_ws_client(hass) @pytest.fixture -async def mock_http_client_with_extra_js(hass, aiohttp_client, ignore_frontend_deps): +async def mock_http_client_with_extra_js( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, ignore_frontend_deps +) -> TestClient: """Start the Home Assistant HTTP component.""" assert await async_setup_component( hass, @@ -387,31 +408,97 @@ async def test_missing_themes(hass: HomeAssistant, ws_client) -> None: assert msg["result"]["themes"] == {} +@pytest.mark.usefixtures("mock_onboarded") async def test_extra_js( - hass: HomeAssistant, mock_http_client_with_extra_js, mock_onboarded -): + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_http_client_with_extra_js, +) -> None: """Test that extra javascript is loaded.""" - resp = await mock_http_client_with_extra_js.get("") - assert resp.status == 200 - assert "cache-control" not in resp.headers - text = await resp.text() + async def get_response(): + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + return await resp.text() + + text = await get_response() assert '"/local/my_module.js"' in text assert '"/local/my_es5.js"' in text + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "frontend/subscribe_extra_js"}) + msg = await client.receive_json() + + assert msg["success"] is True + subscription_id = msg["id"] + + # Test dynamically adding and removing extra javascript + add_extra_js_url(hass, "/local/my_module_2.js", False) + add_extra_js_url(hass, "/local/my_es5_2.js", True) + text = await get_response() + assert '"/local/my_module_2.js"' in text + assert '"/local/my_es5_2.js"' in text + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["event"] == { + "change_type": "added", + "item": {"type": "module", "url": "/local/my_module_2.js"}, + } + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["event"] == { + "change_type": "added", + "item": {"type": "es5", "url": "/local/my_es5_2.js"}, + } + + remove_extra_js_url(hass, "/local/my_module_2.js", False) + remove_extra_js_url(hass, "/local/my_es5_2.js", True) + text = await get_response() + assert '"/local/my_module_2.js"' not in text + assert '"/local/my_es5_2.js"' not in text + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["event"] == { + "change_type": "removed", + "item": {"type": "module", "url": "/local/my_module_2.js"}, + } + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["event"] == { + "change_type": "removed", + "item": {"type": "es5", "url": "/local/my_es5_2.js"}, + } + + # Remove again should not raise + remove_extra_js_url(hass, "/local/my_module_2.js", False) + remove_extra_js_url(hass, "/local/my_es5_2.js", True) + text = await get_response() + assert '"/local/my_module_2.js"' not in text + assert '"/local/my_es5_2.js"' not in text + # safe mode hass.config.safe_mode = True - resp = await mock_http_client_with_extra_js.get("") - assert resp.status == 200 - assert "cache-control" not in resp.headers - - text = await resp.text() + text = await get_response() assert '"/local/my_module.js"' not in text assert '"/local/my_es5.js"' not in text + # Test dynamically adding extra javascript + add_extra_js_url(hass, "/local/my_module_2.js", False) + add_extra_js_url(hass, "/local/my_es5_2.js", True) + text = await get_response() + assert '"/local/my_module_2.js"' not in text + assert '"/local/my_es5_2.js"' not in text + async def test_get_panels( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_http_client + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_http_client, + caplog: pytest.LogCaptureFixture, ) -> None: """Test get_panels command.""" events = async_capture_events(hass, EVENT_PANELS_UPDATED) @@ -449,6 +536,15 @@ async def test_get_panels( assert len(events) == 2 + # Remove again, will warn but not trigger event + async_remove_panel(hass, "map") + assert "Removing unknown panel map" in caplog.text + caplog.clear() + + # Remove again, without warning + async_remove_panel(hass, "map", warn_if_unknown=False) + assert "Removing unknown panel map" not in caplog.text + async def test_get_panels_non_admin( hass: HomeAssistant, ws_client, hass_admin_user: MockUser @@ -741,3 +837,23 @@ async def test_get_icons_for_single_integration( assert msg["type"] == TYPE_RESULT assert msg["success"] assert msg["result"] == {"resources": {"http": {}}} + + +async def test_www_local_dir( + hass: HomeAssistant, tmp_path: Path, hass_client: ClientSessionGenerator +) -> None: + """Test local www folder.""" + hass.config.config_dir = str(tmp_path) + tmp_path_www = tmp_path / "www" + x_txt_file = tmp_path_www / "x.txt" + + def _create_www_and_x_txt(): + tmp_path_www.mkdir() + x_txt_file.write_text("any") + + await hass.async_add_executor_job(_create_www_and_x_txt) + + assert await async_setup_component(hass, "frontend", {}) + client = await hass_client() + resp = await client.get("/local/x.txt") + assert resp.status == HTTPStatus.OK diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py index 65a5ede5b26..2322740c69a 100644 --- a/tests/components/frontier_silicon/conftest.py +++ b/tests/components/frontier_silicon/conftest.py @@ -1,9 +1,9 @@ """Configuration for frontier_silicon tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN from homeassistant.const import CONF_PIN @@ -22,7 +22,7 @@ def config_entry() -> MockConfigEntry: @pytest.fixture(autouse=True) -def mock_valid_device_url() -> Generator[None, None, None]: +def mock_valid_device_url() -> Generator[None]: """Return a valid webfsapi endpoint.""" with patch( "afsapi.AFSAPI.get_webfsapi_endpoint", @@ -32,7 +32,7 @@ def mock_valid_device_url() -> Generator[None, None, None]: @pytest.fixture(autouse=True) -def mock_valid_pin() -> Generator[None, None, None]: +def mock_valid_pin() -> Generator[None]: """Make get_friendly_name return a value, indicating a valid pin.""" with patch( "afsapi.AFSAPI.get_friendly_name", @@ -42,14 +42,14 @@ def mock_valid_pin() -> Generator[None, None, None]: @pytest.fixture(autouse=True) -def mock_radio_id() -> Generator[None, None, None]: +def mock_radio_id() -> Generator[None]: """Return a valid radio_id.""" with patch("afsapi.AFSAPI.get_radio_id", return_value="mock_radio_id"): yield @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.frontier_silicon.async_setup_entry", return_value=True diff --git a/tests/components/fully_kiosk/conftest.py b/tests/components/fully_kiosk/conftest.py index ff732d0e223..3f7c2985daf 100644 --- a/tests/components/fully_kiosk/conftest.py +++ b/tests/components/fully_kiosk/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.fully_kiosk.const import DOMAIN from homeassistant.const import ( @@ -39,7 +39,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.fully_kiosk.async_setup_entry", return_value=True @@ -48,7 +48,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_fully_kiosk_config_flow() -> Generator[MagicMock, None, None]: +def mock_fully_kiosk_config_flow() -> Generator[MagicMock]: """Return a mocked Fully Kiosk client for the config flow.""" with patch( "homeassistant.components.fully_kiosk.config_flow.FullyKiosk", @@ -64,7 +64,7 @@ def mock_fully_kiosk_config_flow() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_fully_kiosk() -> Generator[MagicMock, None, None]: +def mock_fully_kiosk() -> Generator[MagicMock]: """Return a mocked Fully Kiosk client.""" with patch( "homeassistant.components.fully_kiosk.coordinator.FullyKiosk", diff --git a/tests/components/fully_kiosk/test_camera.py b/tests/components/fully_kiosk/test_camera.py new file mode 100644 index 00000000000..4e48749eebb --- /dev/null +++ b/tests/components/fully_kiosk/test_camera.py @@ -0,0 +1,55 @@ +"""Test the Fully Kiosk Browser camera platform.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.camera import async_get_image +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_camera( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test the camera entity.""" + entity_camera = "camera.amazon_fire" + entity = hass.states.get(entity_camera) + assert entity + assert entity.state == "idle" + entry = entity_registry.async_get(entity_camera) + assert entry + assert entry.unique_id == "abcdef-123456-camera" + + mock_fully_kiosk.getSettings.return_value = {"motionDetection": True} + await hass.services.async_call( + "camera", + "turn_on", + {"entity_id": entity_camera}, + blocking=True, + ) + assert len(mock_fully_kiosk.enableMotionDetection.mock_calls) == 1 + + mock_fully_kiosk.getCamshot.return_value = b"image_bytes" + image = await async_get_image(hass, entity_camera) + assert mock_fully_kiosk.getCamshot.call_count == 1 + assert image.content == b"image_bytes" + + mock_fully_kiosk.getSettings.return_value = {"motionDetection": False} + await hass.services.async_call( + "camera", + "turn_off", + {"entity_id": entity_camera}, + blocking=True, + ) + assert len(mock_fully_kiosk.disableMotionDetection.mock_calls) == 1 + + with pytest.raises(HomeAssistantError) as error: + await async_get_image(hass, entity_camera) + assert error.value.args[0] == "Camera is off" diff --git a/tests/components/fully_kiosk/test_image.py b/tests/components/fully_kiosk/test_image.py new file mode 100644 index 00000000000..0dda707037f --- /dev/null +++ b/tests/components/fully_kiosk/test_image.py @@ -0,0 +1,42 @@ +"""Test the Fully Kiosk Browser image platform.""" + +from http import HTTPStatus +from unittest.mock import MagicMock + +from fullykiosk import FullyKioskError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + + +async def test_image( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test the image entity.""" + entity_image = "image.amazon_fire_screenshot" + entity = hass.states.get(entity_image) + assert entity + assert entity.state == "unknown" + entry = entity_registry.async_get(entity_image) + assert entry + assert entry.unique_id == "abcdef-123456-screenshot" + + mock_fully_kiosk.getScreenshot.return_value = b"image_bytes" + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{entity_image}") + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Type"] == "image/png" + assert await resp.read() == b"image_bytes" + assert mock_fully_kiosk.getScreenshot.call_count == 1 + + mock_fully_kiosk.getScreenshot.side_effect = FullyKioskError("error", "status") + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{entity_image}") + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/tests/components/fully_kiosk/test_media_player.py b/tests/components/fully_kiosk/test_media_player.py index b6eff4cfa2c..aa53421616f 100644 --- a/tests/components/fully_kiosk/test_media_player.py +++ b/tests/components/fully_kiosk/test_media_player.py @@ -2,11 +2,14 @@ from unittest.mock import MagicMock, Mock, patch +import pytest + from homeassistant.components import media_player from homeassistant.components.fully_kiosk.const import DOMAIN, MEDIA_SUPPORT_FULLYKIOSK from homeassistant.components.media_source import DOMAIN as MS_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 from homeassistant.setup import async_setup_component @@ -97,6 +100,60 @@ async def test_media_player( assert device_entry.sw_version == "1.42.5" +@pytest.mark.parametrize("media_content_type", ["video", "video/mp4"]) +async def test_media_player_video( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, + media_content_type: str, +) -> None: + """Test Fully Kiosk media player for videos.""" + await hass.services.async_call( + media_player.DOMAIN, + "play_media", + { + ATTR_ENTITY_ID: "media_player.amazon_fire", + "media_content_type": media_content_type, + "media_content_id": "test.mp4", + }, + blocking=True, + ) + assert len(mock_fully_kiosk.sendCommand.mock_calls) == 1 + mock_fully_kiosk.sendCommand.assert_called_with( + "playVideo", url="test.mp4", stream=3, showControls=1, exitOnCompletion=1 + ) + + await hass.services.async_call( + media_player.DOMAIN, + "media_stop", + { + ATTR_ENTITY_ID: "media_player.amazon_fire", + }, + blocking=True, + ) + mock_fully_kiosk.sendCommand.assert_called_with("stopVideo") + + +async def test_media_player_unsupported( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test Fully Kiosk media player for unsupported media.""" + with pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + media_player.DOMAIN, + "play_media", + { + ATTR_ENTITY_ID: "media_player.amazon_fire", + "media_content_type": "playlist", + "media_content_id": "test.m4u", + }, + blocking=True, + ) + assert error.value.args[0] == "Unsupported media type playlist" + + async def test_browse_media( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -113,6 +170,8 @@ async def test_browse_media( { "id": 1, "type": "media_player/browse_media", + "media_content_id": "media-source://media_source", + "media_content_type": "library", "entity_id": "media_player.amazon_fire", } ) diff --git a/tests/components/fully_kiosk/test_notify.py b/tests/components/fully_kiosk/test_notify.py new file mode 100644 index 00000000000..727457f1b84 --- /dev/null +++ b/tests/components/fully_kiosk/test_notify.py @@ -0,0 +1,70 @@ +"""Test the Fully Kiosk Browser notify platform.""" + +from unittest.mock import MagicMock + +from fullykiosk import FullyKioskError +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +async def test_notify_text_to_speech( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test notify text to speech entity.""" + message = "one, two, testing, testing" + await hass.services.async_call( + "notify", + "send_message", + { + "entity_id": "notify.amazon_fire_text_to_speech", + "message": message, + }, + blocking=True, + ) + mock_fully_kiosk.sendCommand.assert_called_with("textToSpeech", text=message) + + +async def test_notify_text_to_speech_raises( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test notify text to speech entity raises.""" + mock_fully_kiosk.sendCommand.side_effect = FullyKioskError("error", "status") + message = "one, two, testing, testing" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "notify", + "send_message", + { + "entity_id": "notify.amazon_fire_text_to_speech", + "message": message, + }, + blocking=True, + ) + mock_fully_kiosk.sendCommand.assert_called_with("textToSpeech", text=message) + + +async def test_notify_overlay_message( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test notify overlay message entity.""" + message = "one, two, testing, testing" + await hass.services.async_call( + "notify", + "send_message", + { + "entity_id": "notify.amazon_fire_overlay_message", + "message": message, + }, + blocking=True, + ) + mock_fully_kiosk.sendCommand.assert_called_with("setOverlayMessage", text=message) diff --git a/tests/components/fully_kiosk/test_services.py b/tests/components/fully_kiosk/test_services.py index eaf00d74a91..6bce012aad3 100644 --- a/tests/components/fully_kiosk/test_services.py +++ b/tests/components/fully_kiosk/test_services.py @@ -71,6 +71,22 @@ async def test_services( mock_fully_kiosk.setConfigurationString.assert_called_once_with(key, value) + key = "test_key" + value = 1234 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG, + { + ATTR_DEVICE_ID: [device_entry.id], + ATTR_KEY: key, + ATTR_VALUE: value, + }, + blocking=True, + ) + + mock_fully_kiosk.setConfigurationString.assert_called_with(key, str(value)) + key = "test_key" value = "true" await hass.services.async_call( @@ -109,7 +125,7 @@ async def test_service_unloaded_entry( init_integration: MockConfigEntry, ) -> None: """Test service not called when config entry unloaded.""" - await init_integration.async_unload(hass) + await hass.config_entries.async_unload(init_integration.entry_id) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "abcdef-123456")} diff --git a/tests/components/fyta/__init__.py b/tests/components/fyta/__init__.py index cdc2cf63b0d..b2b1c762208 100644 --- a/tests/components/fyta/__init__.py +++ b/tests/components/fyta/__init__.py @@ -1 +1,19 @@ """Tests for the Fyta integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_platform( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> MockConfigEntry: + """Set up the Fyta platform.""" + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.fyta.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index 63af6340ade..de5dece776c 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -1,54 +1,75 @@ -"""Test helpers.""" +"""Test helpers for FYTA.""" -from collections.abc import Generator -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator -from homeassistant.components.fyta.const import CONF_EXPIRATION -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN as FYTA_DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME -from .test_config_flow import ACCESS_TOKEN, EXPIRATION +from .const import ACCESS_TOKEN, EXPIRATION, PASSWORD, USERNAME + +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_fyta(): - """Build a fixture for the Fyta API that connects successfully and returns one device.""" - - mock_fyta_api = AsyncMock() - with patch( - "homeassistant.components.fyta.config_flow.FytaConnector", - return_value=mock_fyta_api, - ) as mock_fyta_api: - mock_fyta_api.return_value.login.return_value = { +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=FYTA_DOMAIN, + title="fyta_user", + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, CONF_ACCESS_TOKEN: ACCESS_TOKEN, CONF_EXPIRATION: EXPIRATION, - } - yield mock_fyta_api + }, + minor_version=2, + entry_id="ce5f5431554d101905d31797e1232da8", + ) @pytest.fixture -def mock_fyta_init(): +def mock_fyta_connector(): """Build a fixture for the Fyta API that connects successfully and returns one device.""" - mock_fyta_api = AsyncMock() - mock_fyta_api.expiration = datetime.now(tz=UTC) + timedelta(days=1) - mock_fyta_api.login = AsyncMock( + mock_fyta_connector = AsyncMock() + mock_fyta_connector.expiration = datetime.fromisoformat(EXPIRATION).replace( + tzinfo=UTC + ) + mock_fyta_connector.client = AsyncMock(autospec=True) + mock_fyta_connector.update_all_plants.return_value = load_json_object_fixture( + "plant_status.json", FYTA_DOMAIN + ) + mock_fyta_connector.plant_list = load_json_object_fixture( + "plant_list.json", FYTA_DOMAIN + ) + + mock_fyta_connector.login = AsyncMock( return_value={ CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_EXPIRATION: EXPIRATION, + CONF_EXPIRATION: datetime.fromisoformat(EXPIRATION).replace(tzinfo=UTC), } ) - with patch( - "homeassistant.components.fyta.FytaConnector.__new__", - return_value=mock_fyta_api, + with ( + patch( + "homeassistant.components.fyta.FytaConnector", + autospec=True, + return_value=mock_fyta_connector, + ), + patch( + "homeassistant.components.fyta.config_flow.FytaConnector", + autospec=True, + return_value=mock_fyta_connector, + ), ): - yield mock_fyta_api + yield mock_fyta_connector @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.fyta.async_setup_entry", return_value=True diff --git a/tests/components/fyta/const.py b/tests/components/fyta/const.py new file mode 100644 index 00000000000..97143af9f79 --- /dev/null +++ b/tests/components/fyta/const.py @@ -0,0 +1,7 @@ +"""Common methods and const used across tests for FYTA.""" + +USERNAME = "fyta_user" +PASSWORD = "fyta_pass" +ACCESS_TOKEN = "123xyz" +EXPIRATION = "2030-12-31T10:00:00+00:00" +EXPIRATION_OLD = "2020-01-01T00:00:00+00:00" diff --git a/tests/components/fyta/fixtures/plant_list.json b/tests/components/fyta/fixtures/plant_list.json new file mode 100644 index 00000000000..9527c7d9d96 --- /dev/null +++ b/tests/components/fyta/fixtures/plant_list.json @@ -0,0 +1,4 @@ +{ + "0": "Gummibaum", + "1": "Kakaobaum" +} diff --git a/tests/components/fyta/fixtures/plant_status.json b/tests/components/fyta/fixtures/plant_status.json new file mode 100644 index 00000000000..5d9cb2d31d9 --- /dev/null +++ b/tests/components/fyta/fixtures/plant_status.json @@ -0,0 +1,14 @@ +{ + "0": { + "name": "Gummibaum", + "scientific_name": "Ficus elastica", + "status": 1, + "sw_version": "1.0" + }, + "1": { + "name": "Kakaobaum", + "scientific_name": "Theobroma cacao", + "status": 2, + "sw_version": "1.0" + } +} diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..7491310129b --- /dev/null +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'access_token': '**REDACTED**', + 'expiration': '2030-12-31T10:00:00+00:00', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'fyta', + 'entry_id': 'ce5f5431554d101905d31797e1232da8', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'fyta_user', + 'unique_id': None, + 'version': 1, + }), + 'plant_data': dict({ + '0': dict({ + 'name': 'Gummibaum', + 'scientific_name': 'Ficus elastica', + 'status': 1, + 'sw_version': '1.0', + }), + '1': dict({ + 'name': 'Kakaobaum', + 'scientific_name': 'Theobroma cacao', + 'status': 2, + 'sw_version': '1.0', + }), + }), + }) +# --- diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..1041fff501e --- /dev/null +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -0,0 +1,213 @@ +# serializer version: 1 +# name: test_all_entities[sensor.gummibaum_plant_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'deleted', + 'doing_great', + 'need_attention', + 'no_sensor', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_plant_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plant state', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plant_status', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_plant_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Gummibaum Plant state', + 'options': list([ + 'deleted', + 'doing_great', + 'need_attention', + 'no_sensor', + ]), + }), + 'context': , + 'entity_id': 'sensor.gummibaum_plant_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'doing_great', + }) +# --- +# name: test_all_entities[sensor.gummibaum_scientific_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gummibaum_scientific_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scientific name', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scientific_name', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-0-scientific_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.gummibaum_scientific_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gummibaum Scientific name', + }), + 'context': , + 'entity_id': 'sensor.gummibaum_scientific_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ficus elastica', + }) +# --- +# name: test_all_entities[sensor.kakaobaum_plant_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'deleted', + 'doing_great', + 'need_attention', + 'no_sensor', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_plant_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plant state', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plant_status', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_plant_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Kakaobaum Plant state', + 'options': list([ + 'deleted', + 'doing_great', + 'need_attention', + 'no_sensor', + ]), + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_plant_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'need_attention', + }) +# --- +# name: test_all_entities[sensor.kakaobaum_scientific_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kakaobaum_scientific_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scientific name', + 'platform': 'fyta', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'scientific_name', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-1-scientific_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.kakaobaum_scientific_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kakaobaum Scientific name', + }), + 'context': , + 'entity_id': 'sensor.kakaobaum_scientific_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Theobroma cacao', + }) +# --- diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index dedb468a617..df0626d0af0 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -1,6 +1,5 @@ """Test the fyta config flow.""" -from datetime import UTC, datetime from unittest.mock import AsyncMock from fyta_cli.fyta_exceptions import ( @@ -16,16 +15,13 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from .const import ACCESS_TOKEN, EXPIRATION, PASSWORD, USERNAME -USERNAME = "fyta_user" -PASSWORD = "fyta_pass" -ACCESS_TOKEN = "123xyz" -EXPIRATION = datetime.fromisoformat("2024-12-31T10:00:00").replace(tzinfo=UTC) +from tests.common import MockConfigEntry async def test_user_flow( - hass: HomeAssistant, mock_fyta: AsyncMock, mock_setup_entry: AsyncMock + hass: HomeAssistant, mock_fyta_connector: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test we get the form.""" @@ -46,7 +42,7 @@ async def test_user_flow( CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_EXPIRATION: "2024-12-31T10:00:00+00:00", + CONF_EXPIRATION: EXPIRATION, } assert len(mock_setup_entry.mock_calls) == 1 @@ -64,7 +60,7 @@ async def test_form_exceptions( hass: HomeAssistant, exception: Exception, error: dict[str, str], - mock_fyta: AsyncMock, + mock_fyta_connector: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: """Test we can handle Form exceptions.""" @@ -73,7 +69,7 @@ async def test_form_exceptions( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_fyta.return_value.login.side_effect = exception + mock_fyta_connector.login.side_effect = exception # tests with connection error result = await hass.config_entries.flow.async_configure( @@ -85,7 +81,7 @@ async def test_form_exceptions( assert result["step_id"] == "user" assert result["errors"] == error - mock_fyta.return_value.login.side_effect = None + mock_fyta_connector.login.side_effect = None # tests with all information provided result = await hass.config_entries.flow.async_configure( @@ -98,12 +94,14 @@ async def test_form_exceptions( assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN - assert result["data"][CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" + assert result["data"][CONF_EXPIRATION] == EXPIRATION assert len(mock_setup_entry.mock_calls) == 1 -async def test_duplicate_entry(hass: HomeAssistant, mock_fyta: AsyncMock) -> None: +async def test_duplicate_entry( + hass: HomeAssistant, mock_fyta_connector: AsyncMock +) -> None: """Test duplicate setup handling.""" entry = MockConfigEntry( domain=DOMAIN, @@ -143,7 +141,7 @@ async def test_reauth( hass: HomeAssistant, exception: Exception, error: dict[str, str], - mock_fyta: AsyncMock, + mock_fyta_connector: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: """Test reauth-flow works.""" @@ -155,7 +153,7 @@ async def test_reauth( CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_ACCESS_TOKEN: ACCESS_TOKEN, - CONF_EXPIRATION: "2024-06-30T10:00:00+00:00", + CONF_EXPIRATION: EXPIRATION, }, ) entry.add_to_hass(hass) @@ -168,7 +166,7 @@ async def test_reauth( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - mock_fyta.return_value.login.side_effect = exception + mock_fyta_connector.login.side_effect = exception # tests with connection error result = await hass.config_entries.flow.async_configure( @@ -181,7 +179,7 @@ async def test_reauth( assert result["step_id"] == "reauth_confirm" assert result["errors"] == error - mock_fyta.return_value.login.side_effect = None + mock_fyta_connector.login.side_effect = None # tests with all information provided result = await hass.config_entries.flow.async_configure( @@ -195,4 +193,4 @@ async def test_reauth( assert entry.data[CONF_USERNAME] == "other_username" assert entry.data[CONF_PASSWORD] == "other_password" assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN - assert entry.data[CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" + assert entry.data[CONF_EXPIRATION] == EXPIRATION diff --git a/tests/components/fyta/test_diagnostics.py b/tests/components/fyta/test_diagnostics.py new file mode 100644 index 00000000000..3a95b533489 --- /dev/null +++ b/tests/components/fyta/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Test Fyta diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_platform + +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, + mock_config_entry: MockConfigEntry, + mock_fyta_connector: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot diff --git a/tests/components/fyta/test_init.py b/tests/components/fyta/test_init.py index 844a818df85..88cb125ecee 100644 --- a/tests/components/fyta/test_init.py +++ b/tests/components/fyta/test_init.py @@ -1,23 +1,133 @@ """Test the initialization.""" +from datetime import UTC, datetime from unittest.mock import AsyncMock -from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from fyta_cli.fyta_exceptions import ( + FytaAuthentificationError, + FytaConnectionError, + FytaPasswordError, +) +import pytest + +from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN as FYTA_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant -from .test_config_flow import ACCESS_TOKEN, PASSWORD, USERNAME +from . import setup_platform +from .const import ACCESS_TOKEN, EXPIRATION, EXPIRATION_OLD, PASSWORD, USERNAME from tests.common import MockConfigEntry +async def test_load_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fyta_connector: AsyncMock, +) -> None: + """Test load and unload.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_refresh_expired_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fyta_connector: AsyncMock, +) -> None: + """Test we refresh an expired token.""" + + mock_fyta_connector.expiration = datetime.fromisoformat(EXPIRATION_OLD).replace( + tzinfo=UTC + ) + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert len(mock_fyta_connector.login.mock_calls) == 1 + assert mock_config_entry.data[CONF_EXPIRATION] == EXPIRATION + + +@pytest.mark.parametrize( + "exception", + [ + FytaAuthentificationError, + FytaPasswordError, + ], +) +async def test_invalid_credentials( + hass: HomeAssistant, + exception: Exception, + mock_config_entry: MockConfigEntry, + mock_fyta_connector: AsyncMock, +) -> None: + """Test FYTA credentials changing.""" + + mock_fyta_connector.expiration = datetime.fromisoformat(EXPIRATION_OLD).replace( + tzinfo=UTC + ) + mock_fyta_connector.login.side_effect = exception + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_raise_config_entry_not_ready_when_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fyta_connector: AsyncMock, +) -> None: + """Config entry state is SETUP_RETRY when FYTA is offline.""" + + mock_fyta_connector.update_all_plants.side_effect = FytaConnectionError + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_raise_config_entry_not_ready_when_offline_and_expired( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_fyta_connector: AsyncMock, +) -> None: + """Config entry state is SETUP_RETRY when FYTA is offline and access_token is expired.""" + + mock_fyta_connector.login.side_effect = FytaConnectionError + mock_fyta_connector.expiration = datetime.fromisoformat(EXPIRATION_OLD).replace( + tzinfo=UTC + ) + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + assert len(hass.config_entries.flow.async_progress()) == 0 + + async def test_migrate_config_entry( hass: HomeAssistant, - mock_fyta_init: AsyncMock, + mock_fyta_connector: AsyncMock, ) -> None: """Test successful migration of entry data.""" entry = MockConfigEntry( - domain=DOMAIN, + domain=FYTA_DOMAIN, title=USERNAME, data={ CONF_USERNAME: USERNAME, @@ -39,4 +149,4 @@ async def test_migrate_config_entry( assert entry.data[CONF_USERNAME] == USERNAME assert entry.data[CONF_PASSWORD] == PASSWORD assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN - assert entry.data[CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" + assert entry.data[CONF_EXPIRATION] == EXPIRATION diff --git a/tests/components/fyta/test_sensor.py b/tests/components/fyta/test_sensor.py new file mode 100644 index 00000000000..e33c54695e5 --- /dev/null +++ b/tests/components/fyta/test_sensor.py @@ -0,0 +1,56 @@ +"""Test the Home Assistant fyta sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "exception", + [ + FytaConnectionError, + FytaPlantError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_fyta_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + + mock_fyta_connector.update_all_plants.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.gummibaum_plant_state").state == STATE_UNAVAILABLE diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index 052de4bf311..08f698b4b67 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Gardena Bluetooth tests.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Callable, Coroutine from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -10,6 +10,7 @@ from gardena_bluetooth.const import DeviceInformation from gardena_bluetooth.exceptions import CharacteristicNotFound from gardena_bluetooth.parse import Characteristic import pytest +from typing_extensions import Generator from homeassistant.components.gardena_bluetooth.const import DOMAIN from homeassistant.components.gardena_bluetooth.coordinator import SCAN_INTERVAL @@ -30,7 +31,7 @@ def mock_entry(): @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.gardena_bluetooth.async_setup_entry", @@ -51,12 +52,12 @@ def mock_read_char_raw(): @pytest.fixture async def scan_step( hass: HomeAssistant, freezer: FrozenDateTimeFactory -) -> Generator[None, None, Callable[[], Awaitable[None]]]: +) -> Callable[[], Coroutine[Any, Any, None]]: """Step system time forward.""" freezer.move_to("2023-01-01T01:00:00Z") - async def delay(): + async def delay() -> None: """Trigger delay in system.""" freezer.tick(delta=SCAN_INTERVAL) async_fire_time_changed(hass) @@ -68,7 +69,7 @@ async def scan_step( @pytest.fixture(autouse=True) def mock_client( enable_bluetooth: None, scan_step, mock_read_char_raw: dict[str, Any] -) -> None: +) -> Generator[Mock]: """Auto mock bluetooth.""" client = Mock(spec_set=Client) diff --git a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr new file mode 100644 index 00000000000..c030332e75b --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_setup.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/gardena_bluetooth/test_valve.py b/tests/components/gardena_bluetooth/test_valve.py new file mode 100644 index 00000000000..411778658f4 --- /dev/null +++ b/tests/components/gardena_bluetooth/test_valve.py @@ -0,0 +1,85 @@ +"""Test Gardena Bluetooth valve.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, call + +from gardena_bluetooth.const import Valve +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + Platform, +) +from homeassistant.core import HomeAssistant + +from . import setup_entry + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_switch_chars(mock_read_char_raw): + """Mock data on device.""" + mock_read_char_raw[Valve.state.uuid] = b"\x00" + mock_read_char_raw[Valve.remaining_open_time.uuid] = ( + Valve.remaining_open_time.encode(0) + ) + mock_read_char_raw[Valve.manual_watering_time.uuid] = ( + Valve.manual_watering_time.encode(1000) + ) + return mock_read_char_raw + + +async def test_setup( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_switch_chars: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Test setup creates expected entities.""" + + entity_id = "valve.mock_title" + await setup_entry(hass, mock_entry, [Platform.VALVE]) + assert hass.states.get(entity_id) == snapshot + + mock_switch_chars[Valve.state.uuid] = b"\x01" + await scan_step() + assert hass.states.get(entity_id) == snapshot + + +async def test_switching( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_switch_chars: dict[str, bytes], +) -> None: + """Test switching makes correct calls.""" + + entity_id = "valve.mock_title" + await setup_entry(hass, mock_entry, [Platform.VALVE]) + assert hass.states.get(entity_id) + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_client.write_char.mock_calls == [ + call(Valve.remaining_open_time, 1000), + call(Valve.remaining_open_time, 0), + ] diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index e359ddaca9d..72a7c32ba25 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -25,8 +25,8 @@ from homeassistant.components.generic.const import ( CONF_STREAM_SOURCE, DOMAIN, ) -from homeassistant.components.stream.const import CONF_RTSP_TRANSPORT -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.stream import CONF_RTSP_TRANSPORT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -74,7 +74,7 @@ async def test_fetching_url( hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png, - caplog: pytest.CaptureFixture, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that it fetches the given url.""" hass.states.async_set("sensor.temp", "http://example.com/0a") diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 841fb710717..7e76d8f3891 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -409,16 +409,9 @@ async def test_form_only_stream( user_flow["flow_id"], data, ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" - result3 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - user_input={CONF_CONFIRMED_OK: True}, - ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "127_0_0_1" - assert result3["options"] == { + assert result1["type"] is FlowResultType.CREATE_ENTRY + assert result1["title"] == "127_0_0_1" + assert result1["options"] == { CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2", CONF_USERNAME: "fred_flintstone", diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index ef7a2c90aa9..eadc1b22527 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -471,7 +471,7 @@ async def test_sensor_bad_value(hass: HomeAssistant, setup_comp_2) -> None: async def test_sensor_bad_value_twice( - hass: HomeAssistant, setup_comp_2, caplog + hass: HomeAssistant, setup_comp_2, caplog: pytest.LogCaptureFixture ) -> None: """Test sensor that the second bad value is not logged as warning.""" assert hass.states.get(ENTITY).state == STATE_ON diff --git a/tests/components/generic_thermostat/snapshots/test_config_flow.ambr b/tests/components/generic_thermostat/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..d515d52a81b --- /dev/null +++ b/tests/components/generic_thermostat/snapshots/test_config_flow.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_config_flow[create_entry] + FlowResultSnapshot({ + 'result': ConfigEntrySnapshot({ + 'title': 'My thermostat', + }), + 'title': 'My thermostat', + 'type': , + }) +# --- +# name: test_config_flow[init] + FlowResultSnapshot({ + 'type': , + }) +# --- +# name: test_config_flow[presets] + FlowResultSnapshot({ + 'type': , + }) +# --- +# name: test_options[create_entry] + FlowResultSnapshot({ + 'result': True, + 'type': , + }) +# --- +# name: test_options[init] + FlowResultSnapshot({ + 'type': , + }) +# --- +# name: test_options[presets] + FlowResultSnapshot({ + 'type': , + }) +# --- +# name: test_options[with_away] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.0, + 'friendly_name': 'My thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'away', + ]), + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 7, + }), + 'context': , + 'entity_id': 'climate.my_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_options[without_away] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.0, + 'friendly_name': 'My thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 7.0, + }), + 'context': , + 'entity_id': 'climate.my_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index ff409511221..1ecde733f48 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -910,120 +910,6 @@ async def test_hvac_mode_change_toggles_heating_cooling_switch_even_when_within_ assert call.data["entity_id"] == ENT_SWITCH -@pytest.fixture -async def setup_comp_5(hass): - """Initialize components.""" - hass.config.temperature_unit = UnitOfTemperature.CELSIUS - assert await async_setup_component( - hass, - DOMAIN, - { - "climate": { - "platform": "generic_thermostat", - "name": "test", - "cold_tolerance": 0.3, - "hot_tolerance": 0.3, - "heater": ENT_SWITCH, - "target_sensor": ENT_SENSOR, - "ac_mode": True, - "min_cycle_duration": datetime.timedelta(minutes=10), - "initial_hvac_mode": HVACMode.COOL, - } - }, - ) - await hass.async_block_till_done() - - -async def test_temp_change_ac_trigger_on_not_long_enough_2( - hass: HomeAssistant, setup_comp_5 -) -> None: - """Test if temperature change turn ac on.""" - calls = _setup_switch(hass, False) - await common.async_set_temperature(hass, 25) - _setup_sensor(hass, 30) - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_temp_change_ac_trigger_on_long_enough_2( - hass: HomeAssistant, setup_comp_5 -) -> None: - """Test if temperature change turn ac on.""" - fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with freeze_time(fake_changed): - calls = _setup_switch(hass, False) - await common.async_set_temperature(hass, 25) - _setup_sensor(hass, 30) - await hass.async_block_till_done() - assert len(calls) == 1 - call = calls[0] - assert call.domain == HASS_DOMAIN - assert call.service == SERVICE_TURN_ON - assert call.data["entity_id"] == ENT_SWITCH - - -async def test_temp_change_ac_trigger_off_not_long_enough_2( - hass: HomeAssistant, setup_comp_5 -) -> None: - """Test if temperature change turn ac on.""" - calls = _setup_switch(hass, True) - await common.async_set_temperature(hass, 30) - _setup_sensor(hass, 25) - await hass.async_block_till_done() - assert len(calls) == 0 - - -async def test_temp_change_ac_trigger_off_long_enough_2( - hass: HomeAssistant, setup_comp_5 -) -> None: - """Test if temperature change turn ac on.""" - fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with freeze_time(fake_changed): - calls = _setup_switch(hass, True) - await common.async_set_temperature(hass, 30) - _setup_sensor(hass, 25) - await hass.async_block_till_done() - assert len(calls) == 1 - call = calls[0] - assert call.domain == HASS_DOMAIN - assert call.service == SERVICE_TURN_OFF - assert call.data["entity_id"] == ENT_SWITCH - - -async def test_mode_change_ac_trigger_off_not_long_enough_2( - hass: HomeAssistant, setup_comp_5 -) -> None: - """Test if mode change turns ac off despite minimum cycle.""" - calls = _setup_switch(hass, True) - await common.async_set_temperature(hass, 30) - _setup_sensor(hass, 25) - await hass.async_block_till_done() - assert len(calls) == 0 - await common.async_set_hvac_mode(hass, HVACMode.OFF) - assert len(calls) == 1 - call = calls[0] - assert call.domain == "homeassistant" - assert call.service == SERVICE_TURN_OFF - assert call.data["entity_id"] == ENT_SWITCH - - -async def test_mode_change_ac_trigger_on_not_long_enough_2( - hass: HomeAssistant, setup_comp_5 -) -> None: - """Test if mode change turns ac on despite minimum cycle.""" - calls = _setup_switch(hass, False) - await common.async_set_temperature(hass, 25) - _setup_sensor(hass, 30) - await hass.async_block_till_done() - assert len(calls) == 0 - await common.async_set_hvac_mode(hass, HVACMode.HEAT) - assert len(calls) == 1 - call = calls[0] - assert call.domain == "homeassistant" - assert call.service == SERVICE_TURN_ON - assert call.data["entity_id"] == ENT_SWITCH - - @pytest.fixture async def setup_comp_7(hass): """Initialize components.""" diff --git a/tests/components/generic_thermostat/test_config_flow.py b/tests/components/generic_thermostat/test_config_flow.py new file mode 100644 index 00000000000..81e06146a14 --- /dev/null +++ b/tests/components/generic_thermostat/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the generic hygrostat config flow.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.climate import PRESET_AWAY +from homeassistant.components.generic_thermostat.climate import ( + CONF_AC_MODE, + CONF_COLD_TOLERANCE, + CONF_HEATER, + CONF_HOT_TOLERANCE, + CONF_NAME, + CONF_PRESETS, + CONF_SENSOR, + DOMAIN, +) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SNAPSHOT_FLOW_PROPS = props("type", "title", "result", "error") + + +async def test_config_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test the config flow.""" + with patch( + "homeassistant.components.generic_thermostat.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result == snapshot(name="init", include=SNAPSHOT_FLOW_PROPS) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "My thermostat", + CONF_HEATER: "switch.run", + CONF_SENSOR: "sensor.temperature", + CONF_AC_MODE: False, + CONF_COLD_TOLERANCE: 0.3, + CONF_HOT_TOLERANCE: 0.3, + }, + ) + assert result == snapshot(name="presets", include=SNAPSHOT_FLOW_PROPS) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PRESETS[PRESET_AWAY]: 20, + }, + ) + assert result == snapshot(name="create_entry", include=SNAPSHOT_FLOW_PROPS) + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.title == "My thermostat" + + +async def test_options(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test reconfiguring.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My thermostat", + CONF_HEATER: "switch.run", + CONF_SENSOR: "sensor.temperature", + CONF_AC_MODE: False, + CONF_COLD_TOLERANCE: 0.3, + CONF_HOT_TOLERANCE: 0.3, + CONF_PRESETS[PRESET_AWAY]: 20, + }, + title="My dehumidifier", + ) + config_entry.add_to_hass(hass) + + hass.states.async_set( + "sensor.temperature", + "15", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + hass.states.async_set("switch.run", STATE_OFF) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # check that it is setup + await hass.async_block_till_done() + assert hass.states.get("climate.my_thermostat") == snapshot(name="with_away") + + # remove away preset + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result == snapshot(name="init", include=SNAPSHOT_FLOW_PROPS) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_HEATER: "switch.run", + CONF_SENSOR: "sensor.temperature", + CONF_AC_MODE: False, + CONF_COLD_TOLERANCE: 0.3, + CONF_HOT_TOLERANCE: 0.3, + }, + ) + assert result == snapshot(name="presets", include=SNAPSHOT_FLOW_PROPS) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result == snapshot(name="create_entry", include=SNAPSHOT_FLOW_PROPS) + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + assert hass.states.get("climate.my_thermostat") == snapshot(name="without_away") diff --git a/tests/components/geo_json_events/conftest.py b/tests/components/geo_json_events/conftest.py index 80e06f4880c..beab7bf1403 100644 --- a/tests/components/geo_json_events/conftest.py +++ b/tests/components/geo_json_events/conftest.py @@ -1,9 +1,9 @@ """Configuration for GeoJSON Events tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.geo_json_events import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL @@ -30,7 +30,7 @@ def config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock geo_json_events entry setup.""" with patch( "homeassistant.components.geo_json_events.async_setup_entry", return_value=True diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index 365c4ca27bc..173ba201888 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -28,9 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util +from . import _generate_mock_feed_entry +from .conftest import URL + from tests.common import MockConfigEntry, async_fire_time_changed -from tests.components.geo_json_events import _generate_mock_feed_entry -from tests.components.geo_json_events.conftest import URL CONFIG_LEGACY = { GEO_LOCATION_DOMAIN: [ diff --git a/tests/components/geo_json_events/test_init.py b/tests/components/geo_json_events/test_init.py index 278586ba2e3..e90e663d8b6 100644 --- a/tests/components/geo_json_events/test_init.py +++ b/tests/components/geo_json_events/test_init.py @@ -7,8 +7,9 @@ from homeassistant.components.geo_location import DOMAIN as GEO_LOCATION_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import _generate_mock_feed_entry + from tests.common import MockConfigEntry -from tests.components.geo_json_events import _generate_mock_feed_entry async def test_component_unload_config_entry( diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index b8045ad495c..e5fb93dcf8f 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -11,7 +11,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component @@ -23,7 +23,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -48,7 +48,9 @@ def setup_comp(hass): ) -async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_enter( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on zone enter.""" context = Context() hass.states.async_set( @@ -126,7 +128,9 @@ async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_not_fires_for_enter_on_zone_leave(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_for_enter_on_zone_leave( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for not firing on zone leave.""" hass.states.async_set( "geo_location.entity", @@ -161,7 +165,9 @@ async def test_if_not_fires_for_enter_on_zone_leave(hass: HomeAssistant, calls) assert len(calls) == 0 -async def test_if_fires_on_zone_leave(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_leave( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on zone leave.""" hass.states.async_set( "geo_location.entity", @@ -196,7 +202,9 @@ async def test_if_fires_on_zone_leave(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_on_zone_leave_2(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_leave_2( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on zone leave for unavailable entity.""" hass.states.async_set( "geo_location.entity", @@ -231,7 +239,9 @@ async def test_if_fires_on_zone_leave_2(hass: HomeAssistant, calls) -> None: assert len(calls) == 0 -async def test_if_not_fires_for_leave_on_zone_enter(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_for_leave_on_zone_enter( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for not firing on zone enter.""" hass.states.async_set( "geo_location.entity", @@ -266,7 +276,9 @@ async def test_if_not_fires_for_leave_on_zone_enter(hass: HomeAssistant, calls) assert len(calls) == 0 -async def test_if_fires_on_zone_appear(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_appear( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing if entity appears in zone.""" assert await async_setup_component( hass, @@ -312,7 +324,9 @@ async def test_if_fires_on_zone_appear(hass: HomeAssistant, calls) -> None: ) -async def test_if_fires_on_zone_appear_2(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_appear_2( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing if entity appears in zone.""" assert await async_setup_component( hass, @@ -367,7 +381,9 @@ async def test_if_fires_on_zone_appear_2(hass: HomeAssistant, calls) -> None: ) -async def test_if_fires_on_zone_disappear(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_disappear( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing if entity disappears from zone.""" hass.states.async_set( "geo_location.entity", @@ -414,7 +430,7 @@ async def test_if_fires_on_zone_disappear(hass: HomeAssistant, calls) -> None: async def test_zone_undefined( - hass: HomeAssistant, calls, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, calls: list[ServiceCall], caplog: pytest.LogCaptureFixture ) -> None: """Test for undefined zone.""" hass.states.async_set( diff --git a/tests/components/geocaching/conftest.py b/tests/components/geocaching/conftest.py index 68041672efb..155cd2c5a7e 100644 --- a/tests/components/geocaching/conftest.py +++ b/tests/components/geocaching/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from geocachingapi import GeocachingStatus import pytest +from typing_extensions import Generator from homeassistant.components.geocaching.const import DOMAIN @@ -28,7 +28,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.geocaching.async_setup_entry", return_value=True @@ -37,7 +37,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_geocaching_config_flow() -> Generator[None, MagicMock, None]: +def mock_geocaching_config_flow() -> Generator[MagicMock]: """Return a mocked Geocaching API client.""" mock_status = GeocachingStatus() diff --git a/tests/components/geocaching/test_config_flow.py b/tests/components/geocaching/test_config_flow.py index f4e8f0c8a96..0c2ce66b513 100644 --- a/tests/components/geocaching/test_config_flow.py +++ b/tests/components/geocaching/test_config_flow.py @@ -40,11 +40,11 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_geocaching_config_flow: MagicMock, mock_setup_entry: MagicMock, ) -> None: @@ -90,11 +90,11 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_geocaching_config_flow: MagicMock, mock_setup_entry: MagicMock, mock_config_entry: MockConfigEntry, @@ -136,11 +136,11 @@ async def test_existing_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_oauth_error( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_geocaching_config_flow: MagicMock, mock_setup_entry: MagicMock, ) -> None: @@ -183,11 +183,11 @@ async def test_oauth_error( assert len(mock_setup_entry.mock_calls) == 0 +@pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_geocaching_config_flow: MagicMock, mock_setup_entry: MagicMock, mock_config_entry: MockConfigEntry, diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 389a4647e2e..2228cea80ee 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -3,10 +3,12 @@ from http import HTTPStatus from unittest.mock import patch +from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries from homeassistant.components import zone +from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.geofency import CONF_MOBILE_BEACONS, DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( @@ -21,6 +23,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import slugify +from tests.typing import ClientSessionGenerator + HOME_LATITUDE = 37.239622 HOME_LONGITUDE = -115.815811 @@ -113,12 +117,14 @@ BEACON_EXIT_CAR = { @pytest.fixture(autouse=True) -def mock_dev_track(mock_device_tracker_conf): +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: """Mock device tracker config loading.""" @pytest.fixture -async def geofency_client(hass, hass_client_no_auth): +async def geofency_client( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> TestClient: """Geofency mock client (unauthenticated).""" assert await async_setup_component( diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index d5c43c8acc0..07dbd6502b4 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -4,6 +4,7 @@ import json from unittest.mock import patch from homeassistant.components.gios.const import DOMAIN +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -14,7 +15,7 @@ STATIONS = [ async def init_integration( - hass, incomplete_data=False, invalid_indexes=False + hass: HomeAssistant, incomplete_data=False, invalid_indexes=False ) -> MockConfigEntry: """Set up the GIOS integration in Home Assistant.""" entry = MockConfigEntry( @@ -37,18 +38,19 @@ async def init_integration( with ( patch( - "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, ), patch( - "homeassistant.components.gios.Gios._get_station", + "homeassistant.components.gios.coordinator.Gios._get_station", return_value=station, ), patch( - "homeassistant.components.gios.Gios._get_all_sensors", + "homeassistant.components.gios.coordinator.Gios._get_all_sensors", return_value=sensors, ), patch( - "homeassistant.components.gios.Gios._get_indexes", + "homeassistant.components.gios.coordinator.Gios._get_indexes", return_value=indexes, ), ): diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index a96b065574a..d81758b0de0 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -35,7 +35,8 @@ async def test_show_form(hass: HomeAssistant) -> None: async def test_invalid_station_id(hass: HomeAssistant) -> None: """Test that errors are shown when measuring station ID is invalid.""" with patch( - "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, ): flow = config_flow.GiosFlowHandler() flow.hass = hass @@ -52,14 +53,15 @@ async def test_invalid_sensor_data(hass: HomeAssistant) -> None: """Test that errors are shown when sensor data is invalid.""" with ( patch( - "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, ), patch( - "homeassistant.components.gios.Gios._get_station", + "homeassistant.components.gios.coordinator.Gios._get_station", return_value=json.loads(load_fixture("gios/station.json")), ), patch( - "homeassistant.components.gios.Gios._get_sensor", + "homeassistant.components.gios.coordinator.Gios._get_sensor", return_value={}, ), ): @@ -75,7 +77,8 @@ async def test_invalid_sensor_data(hass: HomeAssistant) -> None: async def test_cannot_connect(hass: HomeAssistant) -> None: """Test that errors are shown when cannot connect to GIOS server.""" with patch( - "homeassistant.components.gios.Gios._async_get", side_effect=ApiError("error") + "homeassistant.components.gios.coordinator.Gios._async_get", + side_effect=ApiError("error"), ): flow = config_flow.GiosFlowHandler() flow.hass = hass @@ -90,19 +93,19 @@ async def test_create_entry(hass: HomeAssistant) -> None: """Test that the user step works.""" with ( patch( - "homeassistant.components.gios.Gios._get_stations", + "homeassistant.components.gios.coordinator.Gios._get_stations", return_value=STATIONS, ), patch( - "homeassistant.components.gios.Gios._get_station", + "homeassistant.components.gios.coordinator.Gios._get_station", return_value=json.loads(load_fixture("gios/station.json")), ), patch( - "homeassistant.components.gios.Gios._get_all_sensors", + "homeassistant.components.gios.coordinator.Gios._get_all_sensors", return_value=json.loads(load_fixture("gios/sensors.json")), ), patch( - "homeassistant.components.gios.Gios._get_indexes", + "homeassistant.components.gios.coordinator.Gios._get_indexes", return_value=json.loads(load_fixture("gios/indexes.json")), ), ): diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index e5f3454bcd9..bf954d48548 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -35,7 +35,7 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.gios.Gios._get_stations", + "homeassistant.components.gios.coordinator.Gios._get_stations", side_effect=ConnectionError(), ): entry.add_to_hass(hass) @@ -77,17 +77,21 @@ async def test_migrate_device_and_config_entry( with ( patch( - "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, ), patch( - "homeassistant.components.gios.Gios._get_station", + "homeassistant.components.gios.coordinator.Gios._get_station", return_value=station, ), patch( - "homeassistant.components.gios.Gios._get_all_sensors", + "homeassistant.components.gios.coordinator.Gios._get_all_sensors", return_value=sensors, ), - patch("homeassistant.components.gios.Gios._get_indexes", return_value=indexes), + patch( + "homeassistant.components.gios.coordinator.Gios._get_indexes", + return_value=indexes, + ), ): config_entry.add_to_hass(hass) diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index b24d88ccb8d..d9096916106 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -51,7 +51,7 @@ async def test_availability(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=60) with patch( - "homeassistant.components.gios.Gios._get_all_sensors", + "homeassistant.components.gios.coordinator.Gios._get_all_sensors", side_effect=ApiError("Unexpected error"), ): async_fire_time_changed(hass, future) @@ -74,11 +74,11 @@ async def test_availability(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=120) with ( patch( - "homeassistant.components.gios.Gios._get_all_sensors", + "homeassistant.components.gios.coordinator.Gios._get_all_sensors", return_value=incomplete_sensors, ), patch( - "homeassistant.components.gios.Gios._get_indexes", + "homeassistant.components.gios.coordinator.Gios._get_indexes", return_value={}, ), ): @@ -103,10 +103,11 @@ async def test_availability(hass: HomeAssistant) -> None: future = utcnow() + timedelta(minutes=180) with ( patch( - "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors + "homeassistant.components.gios.coordinator.Gios._get_all_sensors", + return_value=sensors, ), patch( - "homeassistant.components.gios.Gios._get_indexes", + "homeassistant.components.gios.coordinator.Gios._get_indexes", return_value=indexes, ), ): diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py index 2951a58702a..df7de604c2c 100644 --- a/tests/components/github/conftest.py +++ b/tests/components/github/conftest.py @@ -1,9 +1,9 @@ """conftest for the GitHub integration.""" -from collections.abc import Generator from unittest.mock import patch import pytest +from typing_extensions import Generator from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN @@ -27,7 +27,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.github.async_setup_entry", return_value=True): yield diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py index a721298c129..9a1bb37c7cc 100644 --- a/tests/components/github/test_config_flow.py +++ b/tests/components/github/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiogithubapi import GitHubException +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config_entries @@ -26,6 +27,7 @@ async def test_full_user_flow_implementation( hass: HomeAssistant, mock_setup_entry: None, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test the full manual user flow from start to finish.""" aioclient_mock.post( @@ -39,18 +41,10 @@ async def test_full_user_flow_implementation( }, headers={"Content-Type": "application/json"}, ) + # User has not yet entered the code aioclient_mock.post( "https://github.com/login/oauth/access_token", - json={ - CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN, - "token_type": "bearer", - "scope": "", - }, - headers={"Content-Type": "application/json"}, - ) - aioclient_mock.get( - "https://api.github.com/user/starred", - json=[{"full_name": "home-assistant/core"}, {"full_name": "esphome/esphome"}], + json={"error": "authorization_pending"}, headers={"Content-Type": "application/json"}, ) @@ -62,8 +56,20 @@ async def test_full_user_flow_implementation( assert result["step_id"] == "device" assert result["type"] is FlowResultType.SHOW_PROGRESS - # Wait for the task to start before configuring + # User enters the code + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://github.com/login/oauth/access_token", + json={ + CONF_ACCESS_TOKEN: MOCK_ACCESS_TOKEN, + "token_type": "bearer", + "scope": "", + }, + headers={"Content-Type": "application/json"}, + ) + freezer.tick(10) await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure( @@ -101,6 +107,7 @@ async def test_flow_with_registration_failure( async def test_flow_with_activation_failure( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test flow with activation failure of the device.""" aioclient_mock.post( @@ -114,9 +121,11 @@ async def test_flow_with_activation_failure( }, headers={"Content-Type": "application/json"}, ) + # User has not yet entered the code aioclient_mock.post( "https://github.com/login/oauth/access_token", - exc=GitHubException("Activation failed"), + json={"error": "authorization_pending"}, + headers={"Content-Type": "application/json"}, ) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -124,6 +133,14 @@ async def test_flow_with_activation_failure( ) assert result["step_id"] == "device" assert result["type"] is FlowResultType.SHOW_PROGRESS + + # Activation fails + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://github.com/login/oauth/access_token", + exc=GitHubException("Activation failed"), + ) + freezer.tick(10) await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) diff --git a/tests/components/glances/test_init.py b/tests/components/glances/test_init.py index 02fa6960c2f..553bd6f2089 100644 --- a/tests/components/glances/test_init.py +++ b/tests/components/glances/test_init.py @@ -38,8 +38,9 @@ async def test_entry_deprecated_version( entry.add_to_hass(hass) mock_api.return_value.get_ha_sensor_data.side_effect = [ - GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), - HA_SENSOR_DATA, + GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), # fail v4 + GlancesApiNoDataAvailable("endpoint: 'all' is not valid"), # fail v3 + HA_SENSOR_DATA, # success v2 HA_SENSOR_DATA, ] diff --git a/tests/components/goalzero/__init__.py b/tests/components/goalzero/__init__.py index d2e990ca122..30a7c92510e 100644 --- a/tests/components/goalzero/__init__.py +++ b/tests/components/goalzero/__init__.py @@ -3,8 +3,7 @@ from unittest.mock import AsyncMock, patch from homeassistant.components import dhcp -from homeassistant.components.goalzero import DOMAIN -from homeassistant.components.goalzero.const import DEFAULT_NAME +from homeassistant.components.goalzero.const import DEFAULT_NAME, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index d36d692422e..6421f0c526c 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -1,5 +1,7 @@ """Sensor tests for the Goalzero integration.""" +import pytest + from homeassistant.components.goalzero.const import DEFAULT_NAME from homeassistant.components.sensor import ( ATTR_STATE_CLASS, @@ -25,10 +27,9 @@ from . import async_init_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: None, + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we get sensor data.""" await async_init_integration(hass, aioclient_mock) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index bd64a1d8a49..26a32a64b21 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -2,17 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import datetime import http import time -from typing import Any, TypeVar +from typing import Any from unittest.mock import Mock, mock_open, patch from aiohttp.client_exceptions import ClientError from gcal_sync.auth import API_BASE_URL from oauth2client.client import OAuth2Credentials import pytest +from typing_extensions import AsyncGenerator, Generator import yaml from homeassistant.components.application_credentials import ( @@ -27,10 +28,9 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -ApiResult = Callable[[dict[str, Any]], None] -ComponentSetup = Callable[[], Awaitable[bool]] -_T = TypeVar("_T") -YieldFixture = Generator[_T, None, None] +type ApiResult = Callable[[dict[str, Any]], None] +type ComponentSetup = Callable[[], Awaitable[bool]] +type AsyncYieldFixture[_T] = AsyncGenerator[_T] CALENDAR_ID = "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com" @@ -93,14 +93,14 @@ CLIENT_ID = "client-id" CLIENT_SECRET = "client-secret" -@pytest.fixture(name="calendar_access_role") -def test_calendar_access_role() -> str: - """Default access role to use for test_api_calendar in tests.""" +@pytest.fixture +def calendar_access_role() -> str: + """Set default access role to use for test_api_calendar in tests.""" return "owner" -@pytest.fixture -def test_api_calendar(calendar_access_role: str) -> None: +@pytest.fixture(name="test_api_calendar") +def api_calendar(calendar_access_role: str) -> dict[str, Any]: """Return a test calendar object used in API responses.""" return { **TEST_API_CALENDAR, @@ -151,7 +151,7 @@ def calendars_config(calendars_config_entity: dict[str, Any]) -> list[dict[str, def mock_calendars_yaml( hass: HomeAssistant, calendars_config: list[dict[str, Any]], -) -> Generator[Mock, None, None]: +) -> Generator[Mock]: """Fixture that prepares the google_calendars.yaml mocks.""" mocked_open_function = mock_open( read_data=yaml.dump(calendars_config) if calendars_config else None @@ -331,11 +331,11 @@ def mock_insert_event( @pytest.fixture(autouse=True) -def set_time_zone(hass): +async def set_time_zone(hass): """Set the time zone for the tests.""" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round - hass.config.set_time_zone("America/Regina") + await hass.config.async_set_time_zone("America/Regina") @pytest.fixture diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index cf138567ba9..8e934925f46 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -42,9 +42,9 @@ TEST_ENTITY_NAME = TEST_API_ENTITY_NAME @pytest.fixture(autouse=True) def mock_test_setup( - test_api_calendar, - mock_calendars_list, -): + test_api_calendar: dict[str, Any], + mock_calendars_list: ApiResult, +) -> None: """Fixture that sets up the default API responses during integration setup.""" mock_calendars_list({"items": [test_api_calendar]}) @@ -103,7 +103,7 @@ class Client: return resp.get("result") -ClientFixture = Callable[[], Awaitable[Client]] +type ClientFixture = Callable[[], Awaitable[Client]] @pytest.fixture @@ -447,9 +447,7 @@ async def test_http_event_api_failure( hass: HomeAssistant, hass_client: ClientSessionGenerator, component_setup, - mock_calendars_list, mock_events_list, - aioclient_mock: AiohttpClientMocker, ) -> None: """Test the Rest API response during a calendar failure.""" mock_events_list({}, exc=ClientError()) @@ -474,7 +472,7 @@ async def test_http_api_event( component_setup, ) -> None: """Test querying the API and fetching events from the server.""" - hass.config.set_time_zone("Asia/Baghdad") + await hass.config.async_set_time_zone("Asia/Baghdad") event = { **TEST_EVENT, **upcoming(), @@ -487,7 +485,7 @@ async def test_http_api_event( assert response.status == HTTPStatus.OK events = await response.json() assert len(events) == 1 - assert {k: events[0].get(k) for k in ["summary", "start", "end"]} == { + assert {k: events[0].get(k) for k in ("summary", "start", "end")} == { "summary": TEST_EVENT["summary"], "start": {"dateTime": "2022-03-27T15:05:00+03:00"}, "end": {"dateTime": "2022-03-27T15:10:00+03:00"}, @@ -515,7 +513,7 @@ async def test_http_api_all_day_event( assert response.status == HTTPStatus.OK events = await response.json() assert len(events) == 1 - assert {k: events[0].get(k) for k in ["summary", "start", "end"]} == { + assert {k: events[0].get(k) for k in ("summary", "start", "end")} == { "summary": TEST_EVENT["summary"], "start": {"date": "2022-03-27"}, "end": {"date": "2022-03-28"}, @@ -570,7 +568,7 @@ async def test_opaque_event( async def test_scan_calendar_error( hass: HomeAssistant, component_setup, - mock_calendars_list, + mock_calendars_list: ApiResult, config_entry, ) -> None: """Test that the calendar update handles a server error.""" @@ -788,7 +786,7 @@ async def test_all_day_iter_order( event_order, ) -> None: """Test the sort order of an all day events depending on the time zone.""" - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) mock_events_list_items( [ { diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index 12af97c8604..12281f6d348 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -37,7 +37,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import CLIENT_ID, CLIENT_SECRET, EMAIL_ADDRESS, YieldFixture +from .conftest import CLIENT_ID, CLIENT_SECRET, EMAIL_ADDRESS, AsyncYieldFixture from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -50,9 +50,8 @@ OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" @pytest.fixture(autouse=True) -async def request_setup(current_request_with_host) -> None: +async def request_setup(current_request_with_host: None) -> None: """Request setup.""" - return @pytest.fixture(autouse=True) @@ -70,7 +69,7 @@ async def code_expiration_delta() -> datetime.timedelta: @pytest.fixture async def mock_code_flow( code_expiration_delta: datetime.timedelta, -) -> YieldFixture[Mock]: +) -> AsyncYieldFixture[Mock]: """Fixture for initiating OAuth flow.""" with patch( "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", @@ -88,7 +87,7 @@ async def mock_code_flow( @pytest.fixture -async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: +async def mock_exchange(creds: OAuth2Credentials) -> AsyncYieldFixture[Mock]: """Fixture for mocking out the exchange for credentials.""" with patch( "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", @@ -656,9 +655,9 @@ async def test_options_flow_no_changes( assert config_entry.options == {"calendar_access": "read_write"} +@pytest.mark.usefixtures("current_request_with_host") async def test_web_auth_compatibility( hass: HomeAssistant, - current_request_with_host: None, mock_code_flow: Mock, aioclient_mock: AiohttpClientMocker, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/google/test_diagnostics.py b/tests/components/google/test_diagnostics.py index 32ed2ab3224..5d6259309b8 100644 --- a/tests/components/google/test_diagnostics.py +++ b/tests/components/google/test_diagnostics.py @@ -13,7 +13,7 @@ from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import TEST_EVENT, ComponentSetup +from .conftest import TEST_EVENT, ApiResult, ComponentSetup from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -23,9 +23,9 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) def mock_test_setup( - test_api_calendar, - mock_calendars_list, -): + test_api_calendar: dict[str, Any], + mock_calendars_list: ApiResult, +) -> None: """Fixture that sets up the default API responses during integration setup.""" mock_calendars_list({"items": [test_api_calendar]}) @@ -62,6 +62,7 @@ async def setup_diag(hass): @freeze_time("2023-03-13 12:05:00-07:00") +@pytest.mark.usefixtures("socket_enabled") async def test_diagnostics( hass: HomeAssistant, component_setup: ComponentSetup, @@ -70,7 +71,6 @@ async def test_diagnostics( hass_admin_credential: Credentials, config_entry: MockConfigEntry, aiohttp_client: ClientSessionGenerator, - socket_enabled: None, snapshot: SnapshotAssertion, aioclient_mock: AiohttpClientMocker, ) -> None: diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 2a26776b031..de5e2ea9145 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -39,7 +39,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() # Typing helpers -HassApi = Callable[[], Awaitable[dict[str, Any]]] +type HassApi = Callable[[], Awaitable[dict[str, Any]]] TEST_EVENT_SUMMARY = "Test Summary" TEST_EVENT_DESCRIPTION = "Test Description" @@ -81,7 +81,7 @@ def assert_state(actual: State | None, expected: State | None) -> None: ) def add_event_call_service( hass: HomeAssistant, - request: Any, + request: pytest.FixtureRequest, ) -> Callable[dict[str, Any], Awaitable[None]]: """Fixture for calling the add or create event service.""" (domain, service_call, data, target) = request.param diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 73dc109f7e6..6be58f50469 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -2,7 +2,8 @@ from unittest.mock import MagicMock -from homeassistant.components.google_assistant import helpers, http +from homeassistant.components.google_assistant import http +from homeassistant.core import HomeAssistant def mock_google_config_store(agent_user_ids=None): @@ -24,14 +25,14 @@ class MockConfig(http.GoogleConfig): agent_user_ids=None, enabled=True, entity_config=None, - hass=None, + hass: HomeAssistant | None = None, secure_devices_pin=None, should_2fa=None, should_expose=None, should_report_state=False, - ): + ) -> None: """Initialize config.""" - helpers.AbstractConfig.__init__(self, hass) + super().__init__(hass, None) self._enabled = enabled self._entity_config = entity_config or {} self._secure_devices_pin = secure_devices_pin diff --git a/tests/components/google_assistant/test_button.py b/tests/components/google_assistant/test_button.py index 11ca77bf733..6fdb94a5610 100644 --- a/tests/components/google_assistant/test_button.py +++ b/tests/components/google_assistant/test_button.py @@ -43,9 +43,8 @@ async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser) -> No ) mock_sync_entities.assert_called_once_with(hass_owner_user.id) + mock_sync_entities.return_value = 400 with pytest.raises(HomeAssistantError): - mock_sync_entities.return_value = 400 - await hass.services.async_call( "button", "press", diff --git a/tests/components/google_assistant/test_data_redaction.py b/tests/components/google_assistant/test_data_redaction.py index d650a223e15..9ec8393ad25 100644 --- a/tests/components/google_assistant/test_data_redaction.py +++ b/tests/components/google_assistant/test_data_redaction.py @@ -7,7 +7,7 @@ from homeassistant.components.google_assistant.data_redaction import async_redac from tests.common import load_fixture -def test_redact_msg(): +def test_redact_msg() -> None: """Test async_redact_msg.""" messages = json.loads(load_fixture("data_redaction.json", "google_assistant")) agent_user_id = "333dee20-1234-1234-1234-2225a0d70d4c" diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 648feb1cc8e..ea30f89e0ef 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -1,10 +1,12 @@ """The tests for the Google Assistant component.""" +from asyncio import AbstractEventLoop from http import HTTPStatus import json from unittest.mock import patch from aiohttp.hdrs import AUTHORIZATION +from aiohttp.test_utils import TestClient import pytest from homeassistant import const, core, setup @@ -24,6 +26,8 @@ from homeassistant.helpers import entity_registry as er from . import DEMO_DEVICES +from tests.typing import ClientSessionGenerator + API_PASSWORD = "test1234" PROJECT_ID = "hasstest-1234" @@ -32,13 +36,17 @@ ACCESS_TOKEN = "superdoublesecret" @pytest.fixture -def auth_header(hass_access_token): +def auth_header(hass_access_token: str) -> dict[str, str]: """Generate an HTTP header with bearer token authorization.""" return {AUTHORIZATION: f"Bearer {hass_access_token}"} @pytest.fixture -def assistant_client(event_loop, hass, hass_client_no_auth): +def assistant_client( + event_loop: AbstractEventLoop, + hass: core.HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> TestClient: """Create web client for the Google Assistant API.""" loop = event_loop loop.run_until_complete( @@ -83,7 +91,9 @@ async def wanted_platforms_only() -> None: @pytest.fixture -def hass_fixture(event_loop, hass): +def hass_fixture( + event_loop: AbstractEventLoop, hass: core.HomeAssistant +) -> core.HomeAssistant: """Set up a Home Assistant instance for these tests.""" loop = event_loop diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 1dac75875a6..b041f69828f 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -577,6 +577,8 @@ async def test_async_get_users_from_store(tmpdir: py.path.local) -> None: assert await async_get_users(hass) == ["agent_1"] + await hass.async_stop() + VALID_STORE_DATA = json.dumps( { @@ -653,7 +655,7 @@ async def test_async_get_users( ) path = hass.config.config_dir / ".storage" / GoogleConfigStore._STORAGE_KEY os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w") as f: + with open(path, "w", encoding="utf8") as f: f.write(store_data) assert await async_get_users(hass) == expected_users diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 04ceafb004a..2eeb3d16b81 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1076,7 +1076,7 @@ async def test_device_class_binary_sensor( ("non_existing_class", "action.devices.types.BLINDS"), ("door", "action.devices.types.DOOR"), ("garage", "action.devices.types.GARAGE"), - ("gate", "action.devices.types.GARAGE"), + ("gate", "action.devices.types.GATE"), ("awning", "action.devices.types.AWNING"), ("shutter", "action.devices.types.SHUTTER"), ("curtain", "action.devices.types.CURTAIN"), @@ -1281,7 +1281,7 @@ async def test_identify(hass: HomeAssistant) -> None: "payload": { "device": { "mdnsScanData": { - "additionals": [ + "additionals": [ # codespell:ignore additionals { "type": "TXT", "class": "IN", diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 0ed4d960edc..63a34c01dac 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1763,7 +1763,7 @@ async def test_arm_disarm_arm_away(hass: HomeAssistant) -> None: ], }, ], - "ordered": False, + "ordered": True, } } @@ -1782,16 +1782,16 @@ async def test_arm_disarm_arm_away(hass: HomeAssistant) -> None: # Test with no secure_pin configured + trt = trait.ArmDisArmTrait( + hass, + State( + "alarm_control_panel.alarm", + STATE_ALARM_DISARMED, + {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, + ), + BASIC_CONFIG, + ) with pytest.raises(error.SmartHomeError) as err: - trt = trait.ArmDisArmTrait( - hass, - State( - "alarm_control_panel.alarm", - STATE_ALARM_DISARMED, - {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, - ), - BASIC_CONFIG, - ) await trt.execute( trait.COMMAND_ARMDISARM, BASIC_DATA, @@ -1845,16 +1845,16 @@ async def test_arm_disarm_arm_away(hass: HomeAssistant) -> None: assert len(calls) == 1 # Test already armed + trt = trait.ArmDisArmTrait( + hass, + State( + "alarm_control_panel.alarm", + STATE_ALARM_ARMED_AWAY, + {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, + ), + PIN_CONFIG, + ) with pytest.raises(error.SmartHomeError) as err: - trt = trait.ArmDisArmTrait( - hass, - State( - "alarm_control_panel.alarm", - STATE_ALARM_ARMED_AWAY, - {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, - ), - PIN_CONFIG, - ) await trt.execute( trait.COMMAND_ARMDISARM, PIN_DATA, @@ -1905,7 +1905,8 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: { alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True, ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.TRIGGER - | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + | AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY, }, ), PIN_CONFIG, @@ -1914,10 +1915,19 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: "availableArmLevels": { "levels": [ { - "level_name": "armed_custom_bypass", + "level_name": "armed_home", "level_values": [ { - "level_synonym": ["armed custom bypass", "custom"], + "level_synonym": ["armed home", "home"], + "lang": "en", + } + ], + }, + { + "level_name": "armed_away", + "level_values": [ + { + "level_synonym": ["armed away", "away"], "lang": "en", } ], @@ -1927,11 +1937,14 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: "level_values": [{"level_synonym": ["triggered"], "lang": "en"}], }, ], - "ordered": False, + "ordered": True, } } - assert trt.query_attributes() == {"isArmed": False} + assert trt.query_attributes() == { + "currentArmLevel": "armed_home", + "isArmed": False, + } assert trt.can_execute(trait.COMMAND_ARMDISARM, {"arm": False}) @@ -1940,16 +1953,16 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: ) # Test without secure_pin configured + trt = trait.ArmDisArmTrait( + hass, + State( + "alarm_control_panel.alarm", + STATE_ALARM_ARMED_AWAY, + {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, + ), + BASIC_CONFIG, + ) with pytest.raises(error.SmartHomeError) as err: - trt = trait.ArmDisArmTrait( - hass, - State( - "alarm_control_panel.alarm", - STATE_ALARM_ARMED_AWAY, - {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, - ), - BASIC_CONFIG, - ) await trt.execute(trait.COMMAND_ARMDISARM, BASIC_DATA, {"arm": False}, {}) assert len(calls) == 0 @@ -1989,31 +2002,32 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: assert len(calls) == 1 # Test already disarmed + trt = trait.ArmDisArmTrait( + hass, + State( + "alarm_control_panel.alarm", + STATE_ALARM_DISARMED, + {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, + ), + PIN_CONFIG, + ) with pytest.raises(error.SmartHomeError) as err: - trt = trait.ArmDisArmTrait( - hass, - State( - "alarm_control_panel.alarm", - STATE_ALARM_DISARMED, - {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True}, - ), - PIN_CONFIG, - ) await trt.execute(trait.COMMAND_ARMDISARM, PIN_DATA, {"arm": False}, {}) assert len(calls) == 1 assert err.value.code == const.ERR_ALREADY_DISARMED + trt = trait.ArmDisArmTrait( + hass, + State( + "alarm_control_panel.alarm", + STATE_ALARM_ARMED_AWAY, + {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: False}, + ), + PIN_CONFIG, + ) + # Cancel arming after already armed will require pin with pytest.raises(error.SmartHomeError) as err: - trt = trait.ArmDisArmTrait( - hass, - State( - "alarm_control_panel.alarm", - STATE_ALARM_ARMED_AWAY, - {alarm_control_panel.ATTR_CODE_ARM_REQUIRED: False}, - ), - PIN_CONFIG, - ) await trt.execute( trait.COMMAND_ARMDISARM, PIN_DATA, {"arm": True, "cancel": True}, {} ) @@ -2160,13 +2174,13 @@ async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None: ], ) async def test_fan_speed_ordered( - hass, + hass: HomeAssistant, percentage: int, percentage_step: float, speed: str, speeds: list[list[str]], percentage_result: int, -): +) -> None: """Test FanSpeed trait speed control support for fan domain.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None assert trait.FanSpeedTrait.supported( @@ -3982,7 +3996,7 @@ async def test_sensorstate( ), } - for sensor_type in sensor_types: + for sensor_type, item in sensor_types.items(): assert helpers.get_google_type(sensor.DOMAIN, None) is not None assert trait.SensorStateTrait.supported(sensor.DOMAIN, None, sensor_type, None) @@ -3998,8 +4012,8 @@ async def test_sensorstate( BASIC_CONFIG, ) - name = sensor_types[sensor_type][0] - unit = sensor_types[sensor_type][1] + name = item[0] + unit = item[1] if sensor_type == sensor.SensorDeviceClass.AQI: assert trt.sync_attributes() == { diff --git a/tests/components/google_assistant_sdk/conftest.py b/tests/components/google_assistant_sdk/conftest.py index 6922b078574..742e89cab08 100644 --- a/tests/components/google_assistant_sdk/conftest.py +++ b/tests/components/google_assistant_sdk/conftest.py @@ -17,7 +17,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -ComponentSetup = Callable[[], Awaitable[None]] +type ComponentSetup = Callable[[], Awaitable[None]] CLIENT_ID = "1234" CLIENT_SECRET = "5678" diff --git a/tests/components/google_assistant_sdk/snapshots/test_diagnostics.ambr b/tests/components/google_assistant_sdk/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..134bf6e5ad4 --- /dev/null +++ b/tests/components/google_assistant_sdk/snapshots/test_diagnostics.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'auth_implementation': 'google_assistant_sdk', + 'token': dict({ + 'access_token': '**REDACTED**', + 'expires_at': 1717074000.0, + 'refresh_token': '**REDACTED**', + 'scope': 'https://www.googleapis.com/auth/assistant-sdk-prototype', + }), + }), + 'options': dict({ + 'language_code': 'en-US', + }), + }) +# --- diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py index 4a4931d7bae..d66d12509e8 100644 --- a/tests/components/google_assistant_sdk/test_config_flow.py +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.google_assistant_sdk.const import DOMAIN from homeassistant.core import HomeAssistant @@ -19,11 +21,11 @@ GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" TITLE = "Google Assistant SDK" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, ) -> None: """Check full flow.""" @@ -80,11 +82,11 @@ async def test_full_flow( ) +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, ) -> None: """Test the reauthentication case updates the existing config entry.""" @@ -155,11 +157,11 @@ async def test_reauth( assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") async def test_single_instance_allowed( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, ) -> None: """Test case where config flow allows a single test.""" diff --git a/tests/components/google_assistant_sdk/test_diagnostics.py b/tests/components/google_assistant_sdk/test_diagnostics.py new file mode 100644 index 00000000000..cf815c96943 --- /dev/null +++ b/tests/components/google_assistant_sdk/test_diagnostics.py @@ -0,0 +1,38 @@ +"""Tests for the diagnostics data provided by the Google Assistant SDK integration.""" + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_assistant_sdk.const import CONF_LANGUAGE_CODE +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 + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-30 12:00:00", tz_offset=0): + yield + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + config_entry, + options={CONF_LANGUAGE_CODE: "en-US"}, + ) + await hass.config_entries.async_setup(config_entry.entry_id) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/google_assistant_sdk/test_helpers.py b/tests/components/google_assistant_sdk/test_helpers.py index 1090eb9da45..4632a86f40f 100644 --- a/tests/components/google_assistant_sdk/test_helpers.py +++ b/tests/components/google_assistant_sdk/test_helpers.py @@ -3,6 +3,7 @@ from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES from homeassistant.components.google_assistant_sdk.helpers import ( DEFAULT_LANGUAGE_CODES, + best_matching_language_code, default_language_code, ) from homeassistant.core import HomeAssistant @@ -46,3 +47,42 @@ def test_default_language_code(hass: HomeAssistant) -> None: hass.config.language = "el" hass.config.country = "GR" assert default_language_code(hass) == "en-US" + + +def test_best_matching_language_code(hass: HomeAssistant) -> None: + """Test best_matching_language_code.""" + hass.config.language = "es" + hass.config.country = "MX" + + # Assist Language is supported + assert best_matching_language_code(hass, "de-DE", "en-AU") == "de-DE" + assert best_matching_language_code(hass, "de-DE") == "de-DE" + + # Assist Language is not supported, but agent language has the same "lang" part, and is supported + assert best_matching_language_code(hass, "en", "en-AU") == "en-AU" + assert best_matching_language_code(hass, "en-XYZ", "en-AU") == "en-AU" + # Assist Language is not supported, but agent language has the same "lang" part, but is not supported + assert best_matching_language_code(hass, "en", "en-XYZ") == "en-US" + assert best_matching_language_code(hass, "en-XYZ", "en-ABC") == "en-US" + + # Assist Language is not supported, agent is not matching or available, falling back to the default of assist lang + assert best_matching_language_code(hass, "de", "en-AU") == "de-DE" + assert best_matching_language_code(hass, "de-XYZ", "en-AU") == "de-DE" + assert best_matching_language_code(hass, "de") == "de-DE" + assert best_matching_language_code(hass, "de-XYZ") == "de-DE" + + # Assist language is not existing at all, agent is supported + assert best_matching_language_code(hass, "abc-XYZ", "en-AU") == "en-AU" + + # Assist language is not existing at all, agent is not supported, falling back to the agent default + assert best_matching_language_code(hass, "abc-XYZ", "de-XYZ") == "de-DE" + + # Assist language is not existing at all, agent is not existing or available, falling back to system default + assert best_matching_language_code(hass, "abc-XYZ", "def-XYZ") == "es-MX" + assert best_matching_language_code(hass, "abc-XYZ") == "es-MX" + + # Assist language is not existing at all, agent is not existing or available, system default is not supported + hass.config.language = "el" + hass.config.country = "GR" + assert best_matching_language_code(hass, "abc-XYZ", "def-XYZ") == "en-US" + assert best_matching_language_code(hass, "abc-XYZ") == "en-US" diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 11b3fbaa03f..f986497ed29 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -149,6 +149,7 @@ async def test_send_text_command( mock_text_assistant.assert_called_once_with( ExpectedCredentials(), expected_language_code, audio_out=False ) + # pylint:disable-next=unnecessary-dunder-call mock_text_assistant.assert_has_calls([call().__enter__().assist(command)]) diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py index 0ffdc3c5660..266846b17e1 100644 --- a/tests/components/google_assistant_sdk/test_notify.py +++ b/tests/components/google_assistant_sdk/test_notify.py @@ -50,6 +50,7 @@ async def test_broadcast_no_targets( mock_text_assistant.assert_called_once_with( ExpectedCredentials(), language_code, audio_out=False ) + # pylint:disable-next=unnecessary-dunder-call mock_text_assistant.assert_has_calls([call().__enter__().assist(expected_command)]) diff --git a/tests/components/google_domains/test_init.py b/tests/components/google_domains/test_init.py index a682d4ad090..bb27cf7b483 100644 --- a/tests/components/google_domains/test_init.py +++ b/tests/components/google_domains/test_init.py @@ -20,7 +20,9 @@ UPDATE_URL = f"https://{USERNAME}:{PASSWORD}@domains.google.com/nic/update" @pytest.fixture -def setup_google_domains(hass, aioclient_mock): +def setup_google_domains( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up NamecheapDNS.""" aioclient_mock.get(UPDATE_URL, params={"hostname": DOMAIN}, text="ok 0.0.0.0") diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index c377a469df0..1761516e4f5 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -5,17 +5,27 @@ from unittest.mock import patch import pytest from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry(hass): +def mock_genai(): + """Mock the genai call in async_setup_entry.""" + with patch("google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.get_model"): + yield + + +@pytest.fixture +def mock_config_entry(hass, mock_genai): """Mock a config entry.""" entry = MockConfigEntry( domain="google_generative_ai_conversation", + title="Google Generative AI Conversation", data={ "api_key": "bla", }, @@ -24,14 +34,20 @@ def mock_config_entry(hass): return entry +@pytest.fixture +def mock_config_entry_with_assist(hass, mock_config_entry): + """Mock a config entry with assist.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component(hass: HomeAssistant, mock_config_entry: ConfigEntry): """Initialize integration.""" - with patch("google.generativeai.get_model"): - assert await async_setup_component( - hass, "google_generative_ai_conversation", {} - ) - await hass.async_block_till_done() + assert await async_setup_component(hass, "google_generative_ai_conversation", {}) + await hass.async_block_till_done() @pytest.fixture(autouse=True) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr new file mode 100644 index 00000000000..aec8d088b20 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -0,0 +1,417 @@ +# serializer version: 1 +# name: test_chat_history[models/gemini-1.0-pro-False] + list([ + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.0-pro', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': None, + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + dict({ + 'parts': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + ''', + 'role': 'user', + }), + dict({ + 'parts': 'Ok', + 'role': 'model', + }), + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + '1st user request', + ), + dict({ + }), + ), + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.0-pro', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': None, + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + dict({ + 'parts': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + ''', + 'role': 'user', + }), + dict({ + 'parts': 'Ok', + 'role': 'model', + }), + dict({ + 'parts': '1st user request', + 'role': 'user', + }), + dict({ + 'parts': '1st model response', + 'role': 'model', + }), + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + '2nd user request', + ), + dict({ + }), + ), + ]) +# --- +# name: test_chat_history[models/gemini-1.5-pro-True] + list([ + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-pro', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + ''', + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + '1st user request', + ), + dict({ + }), + ), + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-pro', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. + ''', + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + dict({ + 'parts': '1st user request', + 'role': 'user', + }), + dict({ + 'parts': '1st model response', + 'role': 'model', + }), + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + '2nd user request', + ), + dict({ + }), + ), + ]) +# --- +# name: test_default_prompt[config_entry_options0-None] + list([ + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-flash-latest', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + + ''', + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + 'hello', + ), + dict({ + }), + ), + ]) +# --- +# name: test_default_prompt[config_entry_options0-conversation.google_generative_ai_conversation] + list([ + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-flash-latest', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + + ''', + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + 'hello', + ), + dict({ + }), + ), + ]) +# --- +# name: test_default_prompt[config_entry_options1-None] + list([ + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-flash-latest', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + + ''', + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + 'hello', + ), + dict({ + }), + ), + ]) +# --- +# name: test_default_prompt[config_entry_options1-conversation.google_generative_ai_conversation] + list([ + tuple( + '', + tuple( + ), + dict({ + 'generation_config': dict({ + 'max_output_tokens': 150, + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'model_name': 'models/gemini-1.5-flash-latest', + 'safety_settings': dict({ + 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', + 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', + 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', + 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', + }), + 'system_instruction': ''' + Current time is 05:00:00. Today's date is 2024-05-24. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + + ''', + 'tools': None, + }), + ), + tuple( + '().start_chat', + tuple( + ), + dict({ + 'history': list([ + ]), + }), + ), + tuple( + '().start_chat().send_message_async', + tuple( + 'hello', + ), + dict({ + }), + ), + ]) +# --- diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..316bf74b72a --- /dev/null +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + }), + 'options': dict({ + 'chat_model': 'models/gemini-1.5-flash-latest', + 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'max_tokens': 150, + 'prompt': 'Speak like a pirate', + 'recommended': False, + 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'title': 'Google Generative AI Conversation', + }) +# --- diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 5347c010f28..f68f4c6bf14 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,64 +1,4 @@ # serializer version: 1 -# name: test_default_prompt - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 0.9, - 'top_k': 1, - 'top_p': 1.0, - }), - 'model_name': 'models/gemini-pro', - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': ''' - This smart home is controlled by Home Assistant. - - An overview of the areas and the devices in this smart home: - - Test Area: - - Test Device (Test Model) - - Test Area 2: - - Test Device 2 - - Test Device 3 (Test Model 3A) - - Test Device 4 - - 1 (3) - - Answer the user's questions about the world truthfully. - - If the user wants to control a device, reject the request and suggest using the Home Assistant app. - ''', - 'role': 'user', - }), - dict({ - 'parts': 'Ok', - 'role': 'model', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- # name: test_generate_content_service_with_image list([ tuple( @@ -66,7 +6,7 @@ tuple( ), dict({ - 'model_name': 'gemini-pro-vision', + 'model_name': 'models/gemini-1.5-flash-latest', }), ), tuple( @@ -92,7 +32,7 @@ tuple( ), dict({ - 'model_name': 'gemini-pro', + 'model_name': 'models/gemini-1.5-flash-latest', }), ), tuple( diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 3bac01db42d..24ed06a408f 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -1,29 +1,68 @@ """Test the Google Generative AI Conversation config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import ClientError, DeadlineExceeded from google.rpc.error_details_pb2 import ErrorInfo import pytest from homeassistant import config_entries +from homeassistant.components.google_generative_ai_conversation.config_flow import ( + RECOMMENDED_OPTIONS, +) from homeassistant.components.google_generative_ai_conversation.const import ( CONF_CHAT_MODEL, + CONF_DANGEROUS_BLOCK_THRESHOLD, + CONF_HARASSMENT_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD, CONF_MAX_TOKENS, + CONF_PROMPT, + CONF_RECOMMENDED, + CONF_SEXUAL_BLOCK_THRESHOLD, + CONF_TEMPERATURE, CONF_TOP_K, CONF_TOP_P, - DEFAULT_CHAT_MODEL, - DEFAULT_MAX_TOKENS, - DEFAULT_TOP_K, - DEFAULT_TOP_P, DOMAIN, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, ) +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +@pytest.fixture +def mock_models(): + """Mock the model list API.""" + model_15_flash = Mock( + display_name="Gemini 1.5 Flash", + supported_generation_methods=["generateContent"], + ) + model_15_flash.name = "models/gemini-1.5-flash-latest" + + model_15_pro = Mock( + display_name="Gemini 1.5 Pro", + supported_generation_methods=["generateContent"], + ) + model_15_pro.name = "models/gemini-1.5-pro-latest" + + model_10_pro = Mock( + display_name="Gemini 1.0 Pro", + supported_generation_methods=["generateContent"], + ) + model_10_pro.name = "models/gemini-pro" + with patch( + "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", + return_value=iter([model_15_flash, model_15_pro, model_10_pro]), + ): + yield + + async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" # Pretend we already set up a config entry. @@ -37,11 +76,11 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] with ( patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", + "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.list_models", ), patch( "homeassistant.components.google_generative_ai_conversation.async_setup_entry", @@ -60,44 +99,106 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } + assert result2["options"] == RECOMMENDED_OPTIONS assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( - hass: HomeAssistant, mock_config_entry, mock_init_component +@pytest.mark.parametrize( + ("current_options", "new_options", "expected_options"), + [ + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "none", + CONF_PROMPT: "bla", + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + }, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "assist", + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "assist", + CONF_PROMPT: "", + }, + ), + ], +) +async def test_options_switching( + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + mock_models, + current_options, + new_options, + expected_options, ) -> None: """Test the options form.""" + hass.config_entries.async_update_entry(mock_config_entry, options=current_options) options_flow = await hass.config_entries.options.async_init( mock_config_entry.entry_id ) + if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + **current_options, + CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], + }, + ) options = await hass.config_entries.options.async_configure( options_flow["flow_id"], - { - "prompt": "Speak like a pirate", - "temperature": 0.3, - }, + new_options, ) await hass.async_block_till_done() assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"]["prompt"] == "Speak like a pirate" - assert options["data"]["temperature"] == 0.3 - assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL - assert options["data"][CONF_TOP_P] == DEFAULT_TOP_P - assert options["data"][CONF_TOP_K] == DEFAULT_TOP_K - assert options["data"][CONF_MAX_TOKENS] == DEFAULT_MAX_TOKENS + assert options["data"] == expected_options @pytest.mark.parametrize( ("side_effect", "error"), [ ( - ClientError(message="some error"), + ClientError("some error"), + "cannot_connect", + ), + ( + DeadlineExceeded("deadline exceeded"), "cannot_connect", ), ( ClientError( - message="invalid api key", - error_info=ErrorInfo(reason="API_KEY_INVALID"), + "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") ), "invalid_auth", ), @@ -110,9 +211,11 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) + mock_client = AsyncMock() + mock_client.list_models.side_effect = side_effect with patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", - side_effect=side_effect, + "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient", + return_value=mock_client, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -123,3 +226,51 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + hass.config.components.add("google_generative_ai_conversation") + mock_config_entry = MockConfigEntry( + domain=DOMAIN, state=config_entries.ConfigEntryState.LOADED, title="Gemini" + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert result["context"]["source"] == "reauth" + assert result["context"]["title_placeholders"] == {"name": "Gemini"} + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "api" + assert "api_key" in result["data_schema"].schema + assert not result["errors"] + + with ( + patch( + "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.list_models", + ), + patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.google_generative_ai_conversation.async_unload_entry", + return_value=True, + ) as mock_unload_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_key": "1234"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert hass.config_entries.async_entries(DOMAIN)[0].data == {"api_key": "1234"} + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py new file mode 100644 index 00000000000..7f4fe886e90 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -0,0 +1,541 @@ +"""Tests for the Google Generative AI Conversation integration conversation platform.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun import freeze_time +from google.ai.generativelanguage_v1beta.types.content import FunctionCall +from google.api_core.exceptions import GoogleAPICallError +import google.generativeai.types as genai_types +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components import conversation +from homeassistant.components.conversation import trace +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, +) +from homeassistant.components.google_generative_ai_conversation.conversation import ( + _escape_decode, +) +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent, llm + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +@pytest.mark.parametrize( + "agent_id", [None, "conversation.google_generative_ai_conversation"] +) +@pytest.mark.parametrize( + "config_entry_options", + [ + {}, + {CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, + ], +) +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, + agent_id: str | None, + config_entry_options: {}, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the default prompt works.""" + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + + if agent_id is None: + agent_id = mock_config_entry.entry_id + + hass.config_entries.async_update_entry( + mock_config_entry, + options={**mock_config_entry.options, **config_entry_options}, + ) + + with ( + patch("google.generativeai.GenerativeModel") as mock_model, + patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools", + return_value=[], + ) as mock_get_tools, + patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_api_prompt", + return_value="", + ), + patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.async_render_no_api_prompt", + return_value="", + ), + ): + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + mock_part = MagicMock() + mock_part.function_call = None + mock_part.text = "Hi there!\n" + chat_response.parts = [mock_part] + result = await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id=agent_id, + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert mock_get_tools.called == (CONF_LLM_HASS_API in config_entry_options) + + +@pytest.mark.parametrize( + ("model_name", "supports_system_instruction"), + [("models/gemini-1.5-pro", True), ("models/gemini-1.0-pro", False)], +) +async def test_chat_history( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + model_name: str, + supports_system_instruction: bool, + snapshot: SnapshotAssertion, +) -> None: + """Test that the agent keeps track of the chat history.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_CHAT_MODEL: model_name} + ) + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + mock_part = MagicMock() + mock_part.function_call = None + mock_part.text = "1st model response" + chat_response.parts = [mock_part] + if supports_system_instruction: + mock_chat.history = [] + else: + mock_chat.history = [ + {"role": "user", "parts": "prompt"}, + {"role": "model", "parts": "Ok"}, + ] + mock_chat.history += [ + {"role": "user", "parts": "1st user request"}, + {"role": "model", "parts": "1st model response"}, + ] + result = await conversation.async_converse( + hass, + "1st user request", + None, + Context(), + agent_id=mock_config_entry.entry_id, + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.as_dict()["speech"]["plain"]["speech"] + == "1st model response" + ) + mock_part.text = "2nd model response" + chat_response.parts = [mock_part] + result = await conversation.async_converse( + hass, + "2nd user request", + result.conversation_id, + Context(), + agent_id=mock_config_entry.entry_id, + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.as_dict()["speech"]["plain"]["speech"] + == "2nd model response" + ) + + assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + + +@patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" +) +async def test_function_call( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test function calling.""" + agent_id = mock_config_entry_with_assist.entry_id + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + { + vol.Optional("param1", description="Test parameters"): [ + vol.All(str, vol.Lower) + ] + } + ) + + mock_get_tools.return_value = [mock_tool] + + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + mock_part = MagicMock() + mock_part.function_call = FunctionCall( + name="test_tool", + args={ + "param1": ["test_value", "param1\\'s value"], + "param2": "param2\\'s value", + }, + ) + + def tool_call(hass, tool_input, tool_context): + mock_part.function_call = None + mock_part.text = "Hi there!" + return {"result": "Test response"} + + mock_tool.async_call.side_effect = tool_call + chat_response.parts = [mock_part] + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" + mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] + mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) + assert mock_tool_call == { + "parts": [ + { + "function_response": { + "name": "test_tool", + "response": { + "result": "Test response", + }, + }, + }, + ], + "role": "", + } + + mock_tool.async_call.assert_awaited_once_with( + hass, + llm.ToolInput( + tool_name="test_tool", + tool_args={ + "param1": ["test_value", "param1's value"], + "param2": "param2's value", + }, + ), + llm.LLMContext( + platform="google_generative_ai_conversation", + context=context, + user_prompt="Please call the test function", + language="en", + assistant="conversation", + device_id="test_device", + ), + ) + + # Test conversating tracing + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + trace_events = last_trace.get("events", []) + assert [event["event_type"] for event in trace_events] == [ + trace.ConversationTraceEventType.ASYNC_PROCESS, + trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.LLM_TOOL_CALL, + ] + # AGENT_DETAIL event contains the raw prompt passed to the model + detail_event = trace_events[1] + assert "Answer in plain text" in detail_event["data"]["prompt"] + + +@patch( + "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" +) +async def test_function_exception( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test exception in function calling.""" + agent_id = mock_config_entry_with_assist.entry_id + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + { + vol.Optional("param1", description="Test parameters"): vol.All( + vol.Coerce(int), vol.Range(0, 100) + ) + } + ) + + mock_get_tools.return_value = [mock_tool] + + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + mock_part = MagicMock() + mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) + + def tool_call(hass, tool_input, tool_context): + mock_part.function_call = None + mock_part.text = "Hi there!" + raise HomeAssistantError("Test tool exception") + + mock_tool.async_call.side_effect = tool_call + chat_response.parts = [mock_part] + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + device_id="test_device", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" + mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] + mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) + assert mock_tool_call == { + "parts": [ + { + "function_response": { + "name": "test_tool", + "response": { + "error": "HomeAssistantError", + "error_text": "Test tool exception", + }, + }, + }, + ], + "role": "", + } + mock_tool.async_call.assert_awaited_once_with( + hass, + llm.ToolInput( + tool_name="test_tool", + tool_args={"param1": 1}, + ), + llm.LLMContext( + platform="google_generative_ai_conversation", + context=context, + user_prompt="Please call the test function", + language="en", + assistant="conversation", + device_id="test_device", + ), + ) + + +async def test_error_handling( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test that client errors are caught.""" + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + mock_chat.send_message_async.side_effect = GoogleAPICallError("some error") + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "Sorry, I had a problem talking to Google Generative AI: None some error" + ) + + +async def test_blocked_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test blocked response.""" + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + mock_chat.send_message_async.side_effect = genai_types.StopCandidateException( + "finish_reason: SAFETY\n" + ) + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "The message got blocked by your safety settings" + ) + + +async def test_empty_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test empty response.""" + with patch("google.generativeai.GenerativeModel") as mock_model: + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + chat_response.parts = [] + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "Sorry, I had a problem getting a response from Google Generative AI." + ) + + +async def test_invalid_llm_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test handling of invalid llm api.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, + ) + + result = await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + "Error preparing LLM API: API invalid_llm_api not found" + ) + + +async def test_template_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template error handling works.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", + }, + ) + with patch("google.generativeai.GenerativeModel"): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + + +async def test_template_variables( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template variables work.""" + context = Context(user_id="12345") + mock_user = MagicMock() + mock_user.id = "12345" + mock_user.name = "Test User" + + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": ( + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + ), + }, + ) + with ( + patch("google.generativeai.GenerativeModel") as mock_model, + patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + mock_part = MagicMock() + mock_part.text = "Model response" + chat_response.parts = [mock_part] + result = await conversation.async_converse( + hass, "hello", None, context, agent_id=mock_config_entry.entry_id + ) + + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + assert ( + "The user name is Test User." + in mock_model.mock_calls[0][2]["system_instruction"] + ) + assert "The user id is 12345." in mock_model.mock_calls[0][2]["system_instruction"] + + +async def test_conversation_agent( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test GoogleGenerativeAIAgent.""" + agent = conversation.get_agent_manager(hass).async_get_agent( + mock_config_entry.entry_id + ) + assert agent.supported_languages == "*" + + +async def test_escape_decode() -> None: + """Test _escape_decode.""" + assert _escape_decode( + { + "param1": ["test_value", "param1\\'s value"], + "param2": "param2\\'s value", + "param3": {"param31": "Cheminée", "param32": "Chemin\\303\\251e"}, + } + ) == { + "param1": ["test_value", "param1's value"], + "param2": "param2's value", + "param3": {"param31": "Cheminée", "param32": "Cheminée"}, + } diff --git a/tests/components/google_generative_ai_conversation/test_diagnostics.py b/tests/components/google_generative_ai_conversation/test_diagnostics.py new file mode 100644 index 00000000000..ebc1b5e52a5 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_diagnostics.py @@ -0,0 +1,59 @@ +"""Tests for the diagnostics data provided by the Google Generative AI Conversation integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, + CONF_DANGEROUS_BLOCK_THRESHOLD, + CONF_HARASSMENT_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD, + CONF_MAX_TOKENS, + CONF_PROMPT, + CONF_RECOMMENDED, + CONF_SEXUAL_BLOCK_THRESHOLD, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, +) +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, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: RECOMMENDED_TEMPERATURE, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_TOP_K: RECOMMENDED_TOP_K, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_HARASSMENT_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_SEXUAL_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + CONF_DANGEROUS_BLOCK_THRESHOLD: RECOMMENDED_HARM_BLOCK_THRESHOLD, + }, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 07254be9e3f..7afa9b4a31e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -2,162 +2,18 @@ from unittest.mock import AsyncMock, MagicMock, patch -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import ClientError, DeadlineExceeded +from google.rpc.error_details_pb2 import ErrorInfo import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import conversation -from homeassistant.core import Context, HomeAssistant +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import area_registry as ar, device_registry as dr, intent from tests.common import MockConfigEntry -async def test_default_prompt( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test that the default prompt works.""" - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - for i in range(3): - area_registry.async_create(f"{i}Empty Area") - - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "1234")}, - name="Test Device", - manufacturer="Test Manufacturer", - model="Test Model", - suggested_area="Test Area", - ) - for i in range(3): - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", f"{i}abcd")}, - name="Test Service", - manufacturer="Test Manufacturer", - model="Test Model", - suggested_area="Test Area", - entry_type=dr.DeviceEntryType.SERVICE, - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "5678")}, - name="Test Device 2", - manufacturer="Test Manufacturer 2", - model="Device 2", - suggested_area="Test Area 2", - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876")}, - name="Test Device 3", - manufacturer="Test Manufacturer 3", - model="Test Model 3A", - suggested_area="Test Area 2", - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "qwer")}, - name="Test Device 4", - suggested_area="Test Area 2", - ) - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-disabled")}, - name="Test Device 3", - manufacturer="Test Manufacturer 3", - model="Test Model 3A", - suggested_area="Test Area 2", - ) - device_registry.async_update_device( - device.id, disabled_by=dr.DeviceEntryDisabler.USER - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-no-name")}, - manufacturer="Test Manufacturer NoName", - model="Test Model NoName", - suggested_area="Test Area 2", - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-integer-values")}, - name=1, - manufacturer=2, - model=3, - suggested_area="Test Area 2", - ) - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_model.return_value.start_chat.return_value = AsyncMock() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot - - -async def test_error_handling( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test that the default prompt works.""" - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - mock_chat.send_message_async.side_effect = ClientError("") - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with ( - patch( - "google.generativeai.get_model", - ), - patch("google.generativeai.GenerativeModel"), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_conversation_agent( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, -) -> None: - """Test GoogleGenerativeAIAgent.""" - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id - ) - assert agent.supported_languages == "*" - - async def test_generate_content_service_without_images( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -238,22 +94,44 @@ async def test_generate_content_service_error( mock_config_entry: MockConfigEntry, ) -> None: """Test generate content service handles errors.""" - with ( - patch("google.generativeai.GenerativeModel") as mock_model, - pytest.raises( - HomeAssistantError, match="Error generating content: None reason" - ), - ): + with patch("google.generativeai.GenerativeModel") as mock_model: mock_model.return_value.generate_content_async = AsyncMock( side_effect=ClientError("reason") ) - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "write a story about an epic fail"}, - blocking=True, - return_response=True, + with pytest.raises( + HomeAssistantError, match="Error generating content: None reason" + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_response_has_empty_parts( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service handles response with empty parts.""" + with ( + patch("google.generativeai.GenerativeModel") as mock_model, + ): + mock_response = MagicMock() + mock_response.parts = [] + mock_model.return_value.generate_content_async = AsyncMock( + return_value=mock_response ) + with pytest.raises(HomeAssistantError, match="Error generating content"): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, + ) async def test_generate_content_service_with_image_not_allowed_path( @@ -339,3 +217,42 @@ async def test_generate_content_service_with_non_image( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + ("side_effect", "state", "reauth"), + [ + ( + ClientError("some error"), + ConfigEntryState.SETUP_ERROR, + False, + ), + ( + DeadlineExceeded("deadline exceeded"), + ConfigEntryState.SETUP_RETRY, + False, + ), + ( + ClientError( + "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") + ), + ConfigEntryState.SETUP_ERROR, + True, + ), + ], +) +async def test_config_entry_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, side_effect, state, reauth +) -> None: + """Test different configuration entry errors.""" + mock_client = AsyncMock() + mock_client.get_model.side_effect = side_effect + with patch( + "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient", + return_value=mock_client, + ): + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state == state + mock_config_entry.async_get_active_flows(hass, {"reauth"}) + assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) == reauth diff --git a/tests/components/google_mail/conftest.py b/tests/components/google_mail/conftest.py index 947d5fe2fb1..7e63282d181 100644 --- a/tests/components/google_mail/conftest.py +++ b/tests/components/google_mail/conftest.py @@ -19,7 +19,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -ComponentSetup = Callable[[], Awaitable[None]] +type ComponentSetup = Callable[[], Awaitable[None]] BUILD = "homeassistant.components.google_mail.api.build" CLIENT_ID = "1234" diff --git a/tests/components/google_mail/test_config_flow.py b/tests/components/google_mail/test_config_flow.py index 06479504f9d..1e933c8932a 100644 --- a/tests/components/google_mail/test_config_flow.py +++ b/tests/components/google_mail/test_config_flow.py @@ -18,10 +18,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -76,7 +75,7 @@ async def test_full_flow( @pytest.mark.parametrize( - ("fixture", "abort_reason", "placeholders", "calls", "access_token"), + ("fixture", "abort_reason", "placeholders", "call_count", "access_token"), [ ("get_profile", "reauth_successful", None, 1, "updated-access-token"), ( @@ -88,16 +87,16 @@ async def test_full_flow( ), ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host, config_entry: MockConfigEntry, fixture: str, abort_reason: str, placeholders: dict[str, str], - calls: int, + call_count: int, access_token: str, ) -> None: """Test the re-authentication case updates the correct config entry. @@ -164,7 +163,7 @@ async def test_reauth( assert result.get("type") is FlowResultType.ABORT assert result["reason"] == abort_reason assert result["description_placeholders"] == placeholders - assert len(mock_setup.mock_calls) == calls + assert len(mock_setup.mock_calls) == call_count assert config_entry.unique_id == TITLE assert "token" in config_entry.data @@ -173,10 +172,10 @@ async def test_reauth( assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") async def test_already_configured( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, config_entry: MockConfigEntry, ) -> None: """Test case where config flow discovers unique id was already configured.""" diff --git a/tests/components/google_sheets/test_config_flow.py b/tests/components/google_sheets/test_config_flow.py index 5d8a19d1b61..0da046645d2 100644 --- a/tests/components/google_sheets/test_config_flow.py +++ b/tests/components/google_sheets/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Google Sheets config flow.""" -from collections.abc import Generator from unittest.mock import Mock, patch from gspread import GSpreadException import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.application_credentials import ( @@ -41,7 +41,7 @@ async def setup_credentials(hass: HomeAssistant) -> None: @pytest.fixture(autouse=True) -async def mock_client() -> Generator[Mock, None, None]: +async def mock_client() -> Generator[Mock]: """Fixture to setup a fake spreadsheet client library.""" with patch( "homeassistant.components.google_sheets.config_flow.Client" @@ -49,11 +49,11 @@ async def mock_client() -> Generator[Mock, None, None]: yield mock_client +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, mock_client, ) -> None: @@ -116,11 +116,11 @@ async def test_full_flow( ) +@pytest.mark.usefixtures("current_request_with_host") async def test_create_sheet_error( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, mock_client, ) -> None: @@ -168,11 +168,11 @@ async def test_create_sheet_error( assert result.get("reason") == "create_spreadsheet_failure" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, mock_client, ) -> None: @@ -249,11 +249,11 @@ async def test_reauth( assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_abort( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, mock_client, ) -> None: @@ -318,11 +318,11 @@ async def test_reauth_abort( assert result.get("reason") == "open_spreadsheet_failure" +@pytest.mark.usefixtures("current_request_with_host") async def test_already_configured( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials, mock_client, ) -> None: diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py index f474e44e925..014e89349e2 100644 --- a/tests/components/google_sheets/test_init.py +++ b/tests/components/google_sheets/test_init.py @@ -25,7 +25,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker TEST_SHEET_ID = "google-sheet-it" -ComponentSetup = Callable[[], Awaitable[None]] +type ComponentSetup = Callable[[], Awaitable[None]] @pytest.fixture(name="scopes") @@ -294,7 +294,7 @@ async def test_append_sheet_invalid_config_entry( await hass.async_block_till_done() assert config_entry2.state is ConfigEntryState.NOT_LOADED - with pytest.raises(ValueError, match="Config entry not loaded"): + with pytest.raises(ValueError, match="Invalid config entry"): await hass.services.async_call( DOMAIN, "append_sheet", diff --git a/tests/components/google_tasks/test_config_flow.py b/tests/components/google_tasks/test_config_flow.py index 5b2d4f11fee..f2655afd602 100644 --- a/tests/components/google_tasks/test_config_flow.py +++ b/tests/components/google_tasks/test_config_flow.py @@ -1,11 +1,11 @@ """Test the Google Tasks config flow.""" -from collections.abc import Generator from unittest.mock import Mock, patch from googleapiclient.errors import HttpError from httplib2 import Response import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.google_tasks.const import ( @@ -19,6 +19,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -31,7 +32,7 @@ def user_identifier() -> str: @pytest.fixture -def setup_userinfo(user_identifier: str) -> Generator[Mock, None, None]: +def setup_userinfo(user_identifier: str) -> Generator[Mock]: """Set up userinfo.""" with patch("homeassistant.components.google_tasks.config_flow.build") as mock: mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { @@ -41,11 +42,11 @@ def setup_userinfo(user_identifier: str) -> Generator[Mock, None, None]: yield mock +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host, setup_credentials, setup_userinfo, ) -> None: @@ -96,11 +97,11 @@ async def test_full_flow( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_api_not_enabled( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host, setup_credentials, setup_userinfo, ) -> None: @@ -157,11 +158,11 @@ async def test_api_not_enabled( ) +@pytest.mark.usefixtures("current_request_with_host") async def test_general_exception( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host, setup_credentials, setup_userinfo, ) -> None: @@ -234,11 +235,11 @@ async def test_general_exception( ), ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host, setup_credentials, setup_userinfo, user_identifier: str, diff --git a/tests/components/google_translate/conftest.py b/tests/components/google_translate/conftest.py index 3600fae3841..82f8d50b83c 100644 --- a/tests/components/google_translate/conftest.py +++ b/tests/components/google_translate/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the Google Translate text-to-speech tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.google_translate.async_setup_entry", return_value=True diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 1cff6e97781..d19b1269438 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -2,13 +2,14 @@ from __future__ import annotations -from collections.abc import Generator from http import HTTPStatus +from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch from gtts import gTTSError import pytest +from typing_extensions import Generator from homeassistant.components import tts from homeassistant.components.google_translate.const import CONF_TLD, DOMAIN @@ -28,18 +29,18 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir @pytest.fixture -async def calls(hass: HomeAssistant) -> list[ServiceCall]: +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Mock media player calls.""" return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -53,7 +54,7 @@ async def setup_internal_url(hass: HomeAssistant) -> None: @pytest.fixture -def mock_gtts() -> Generator[MagicMock, None, None]: +def mock_gtts() -> Generator[MagicMock]: """Mock gtts.""" with patch("homeassistant.components.google_translate.tts.gTTS") as mock_gtts: yield mock_gtts diff --git a/tests/components/google_travel_time/const.py b/tests/components/google_travel_time/const.py index 77e99ffbf68..29cf32b8e29 100644 --- a/tests/components/google_travel_time/const.py +++ b/tests/components/google_travel_time/const.py @@ -11,3 +11,9 @@ MOCK_CONFIG = { CONF_ORIGIN: "location1", CONF_DESTINATION: "location2", } + +RECONFIGURE_CONFIG = { + CONF_API_KEY: "api_key2", + CONF_ORIGIN: "location3", + CONF_DESTINATION: "location4", +} diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 6e73bfd8d23..270b82272d8 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -1,5 +1,7 @@ """Test the Google Maps Travel Time config flow.""" +from unittest.mock import patch + import pytest from homeassistant import config_entries @@ -25,7 +27,58 @@ from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAM from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_CONFIG +from .const import MOCK_CONFIG, RECONFIGURE_CONFIG + + +async def assert_common_reconfigure_steps( + hass: HomeAssistant, reconfigure_result: config_entries.ConfigFlowResult +) -> None: + """Step through and assert the happy case reconfigure flow.""" + with ( + patch("homeassistant.components.google_travel_time.helpers.Client"), + patch( + "homeassistant.components.google_travel_time.helpers.distance_matrix", + return_value=None, + ), + ): + reconfigure_successful_result = await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], + RECONFIGURE_CONFIG, + ) + assert reconfigure_successful_result["type"] is FlowResultType.ABORT + assert reconfigure_successful_result["reason"] == "reconfigure_successful" + await hass.async_block_till_done() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.data == RECONFIGURE_CONFIG + + +async def assert_common_create_steps( + hass: HomeAssistant, user_step_result: config_entries.ConfigFlowResult +) -> None: + """Step through and assert the happy case create flow.""" + with ( + patch("homeassistant.components.google_travel_time.helpers.Client"), + patch( + "homeassistant.components.google_travel_time.helpers.distance_matrix", + return_value=None, + ), + ): + create_result = await hass.config_entries.flow.async_configure( + user_step_result["flow_id"], + MOCK_CONFIG, + ) + assert create_result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.title == DEFAULT_NAME + assert entry.data == { + CONF_NAME: DEFAULT_NAME, + CONF_API_KEY: "api_key", + CONF_ORIGIN: "location1", + CONF_DESTINATION: "location2", + } @pytest.mark.usefixtures("validate_config_entry", "bypass_setup") @@ -35,21 +88,9 @@ async def test_minimum_fields(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG, - ) - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == DEFAULT_NAME - assert result2["data"] == { - CONF_NAME: DEFAULT_NAME, - CONF_API_KEY: "api_key", - CONF_ORIGIN: "location1", - CONF_DESTINATION: "location2", - } + await assert_common_create_steps(hass, result) @pytest.mark.usefixtures("invalidate_config_entry") @@ -59,7 +100,7 @@ async def test_invalid_config_entry(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -67,6 +108,7 @@ async def test_invalid_config_entry(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + await assert_common_create_steps(hass, result2) @pytest.mark.usefixtures("invalid_api_key") @@ -76,7 +118,7 @@ async def test_invalid_api_key(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -84,6 +126,7 @@ async def test_invalid_api_key(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} + await assert_common_create_steps(hass, result2) @pytest.mark.usefixtures("transport_error") @@ -93,7 +136,7 @@ async def test_transport_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -101,6 +144,7 @@ async def test_transport_error(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + await assert_common_create_steps(hass, result2) @pytest.mark.usefixtures("timeout") @@ -110,7 +154,7 @@ async def test_timeout(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -118,6 +162,7 @@ async def test_timeout(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "timeout_connect"} + await assert_common_create_steps(hass, result2) async def test_malformed_api_key(hass: HomeAssistant) -> None: @@ -126,7 +171,7 @@ async def test_malformed_api_key(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -136,6 +181,173 @@ async def test_malformed_api_key(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "invalid_auth"} +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + }, + ) + ], +) +@pytest.mark.usefixtures("validate_config_entry", "bypass_setup") +async def test_reconfigure(hass: HomeAssistant, mock_config) -> None: + """Test reconfigure flow.""" + reconfigure_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config.entry_id, + }, + ) + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + await assert_common_reconfigure_steps(hass, reconfigure_result) + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + }, + ) + ], +) +@pytest.mark.usefixtures("invalidate_config_entry") +async def test_reconfigure_invalid_config_entry( + hass: HomeAssistant, mock_config +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_CONFIG, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + await assert_common_reconfigure_steps(hass, result2) + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + }, + ) + ], +) +@pytest.mark.usefixtures("invalid_api_key") +async def test_reconfigure_invalid_api_key(hass: HomeAssistant, mock_config) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_CONFIG, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + await assert_common_reconfigure_steps(hass, result2) + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + }, + ) + ], +) +@pytest.mark.usefixtures("transport_error") +async def test_reconfigure_transport_error(hass: HomeAssistant, mock_config) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_CONFIG, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + await assert_common_reconfigure_steps(hass, result2) + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + }, + ) + ], +) +@pytest.mark.usefixtures("timeout") +async def test_reconfigure_timeout(hass: HomeAssistant, mock_config) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + RECONFIGURE_CONFIG, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "timeout_connect"} + await assert_common_reconfigure_steps(hass, result2) + + @pytest.mark.parametrize( ("data", "options"), [ @@ -403,7 +615,7 @@ async def test_dupe(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -421,7 +633,7 @@ async def test_dupe(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} + assert result["errors"] is None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index a7fb263d4c9..57f3d7a0b98 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -9,8 +9,9 @@ from homeassistant.components.google_travel_time.const import ( CONF_ARRIVAL_TIME, CONF_DEPARTURE_TIME, DOMAIN, + UNITS_IMPERIAL, + UNITS_METRIC, ) -from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import ( METRIC_SYSTEM, @@ -208,8 +209,8 @@ async def test_sensor_arrival_time_custom_timestamp(hass: HomeAssistant) -> None @pytest.mark.parametrize( ("unit_system", "expected_unit_option"), [ - (METRIC_SYSTEM, CONF_UNIT_SYSTEM_METRIC), - (US_CUSTOMARY_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL), + (METRIC_SYSTEM, UNITS_METRIC), + (US_CUSTOMARY_SYSTEM, UNITS_IMPERIAL), ], ) async def test_sensor_unit_system( diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index fcc5603fdc5..c7df2b4e822 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -94,8 +94,8 @@ def setup_api(hass, data, requests_mock): "units": desc.native_unit_of_measurement, "icon": desc.icon, } - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] + for value in sensor_dict.values(): + sensor = value["sensor"] sensor.hass = hass return api, sensor_dict @@ -111,9 +111,9 @@ def fake_delay(hass, ha_delay): def test_name(requests_mock: requests_mock.Mocker) -> None: """Test the name.""" api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] - test_name = sensor_dict[name]["name"] + for value in sensor_dict.values(): + sensor = value["sensor"] + test_name = value["name"] assert test_name == sensor.name @@ -122,17 +122,17 @@ def test_unit_of_measurement( ) -> None: """Test the unit of measurement.""" api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] - assert sensor_dict[name]["units"] == sensor.unit_of_measurement + for value in sensor_dict.values(): + sensor = value["sensor"] + assert value["units"] == sensor.unit_of_measurement def test_icon(requests_mock: requests_mock.Mocker) -> None: """Test the icon.""" api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] - assert sensor_dict[name]["icon"] == sensor.icon + for value in sensor_dict.values(): + sensor = value["sensor"] + assert value["icon"] == sensor.icon def test_state(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: @@ -140,8 +140,8 @@ def test_state(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] + for name, value in sensor_dict.items(): + sensor = value["sensor"] fake_delay(hass, 2) sensor.update() if name == google_wifi.ATTR_LAST_RESTART: @@ -159,8 +159,8 @@ def test_update_when_value_is_none( ) -> None: """Test state gets updated to unknown when sensor returns no data.""" api, sensor_dict = setup_api(hass, None, requests_mock) - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] + for value in sensor_dict.values(): + sensor = value["sensor"] fake_delay(hass, 2) sensor.update() assert sensor.state is None @@ -173,8 +173,8 @@ def test_update_when_value_changed( api, sensor_dict = setup_api(hass, MOCK_DATA_NEXT, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] + for name, value in sensor_dict.items(): + sensor = value["sensor"] fake_delay(hass, 2) sensor.update() if name == google_wifi.ATTR_LAST_RESTART: @@ -198,8 +198,8 @@ def test_when_api_data_missing( api, sensor_dict = setup_api(hass, MOCK_DATA_MISSING, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] + for value in sensor_dict.values(): + sensor = value["sensor"] fake_delay(hass, 2) sensor.update() assert sensor.state is None @@ -214,8 +214,8 @@ def test_update_when_unavailable( "google_wifi.GoogleWifiAPI.update", side_effect=update_side_effect(hass, requests_mock), ) - for name in sensor_dict: - sensor = sensor_dict[name]["sensor"] + for value in sensor_dict.values(): + sensor = value["sensor"] sensor.update() assert sensor.state is None diff --git a/tests/components/govee_ble/conftest.py b/tests/components/govee_ble/conftest.py index 382854a5a28..0185cd9557f 100644 --- a/tests/components/govee_ble/conftest.py +++ b/tests/components/govee_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 5976d3c1b74..90a9f8e6827 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -1,10 +1,11 @@ """Tests configuration for Govee Local API.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from asyncio import Event +from unittest.mock import AsyncMock, MagicMock, patch from govee_local_api import GoveeLightCapability import pytest +from typing_extensions import Generator from homeassistant.components.govee_light_local.coordinator import GoveeController @@ -14,6 +15,8 @@ def fixture_mock_govee_api(): """Set up Govee Local API fixture.""" mock_api = AsyncMock(spec=GoveeController) mock_api.start = AsyncMock() + mock_api.cleanup = MagicMock(return_value=Event()) + mock_api.cleanup.return_value.set() mock_api.turn_on_off = AsyncMock() mock_api.set_brightness = AsyncMock() mock_api.set_color = AsyncMock() @@ -22,7 +25,7 @@ def fixture_mock_govee_api(): @pytest.fixture(name="mock_setup_entry") -def fixture_mock_setup_entry() -> Generator[AsyncMock, None, None]: +def fixture_mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.govee_light_local.async_setup_entry", diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 1f935f18530..2e7144fae3a 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -1,5 +1,6 @@ """Test Govee light local config flow.""" +from errno import EADDRINUSE from unittest.mock import AsyncMock, patch from govee_local_api import GoveeDevice @@ -12,6 +13,18 @@ from homeassistant.data_entry_flow import FlowResultType from .conftest import DEFAULT_CAPABILITEIS +def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: + return [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd1", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + async def test_creating_entry_has_no_devices( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_govee_api: AsyncMock ) -> None: @@ -52,15 +65,7 @@ async def test_creating_entry_has_with_devices( ) -> None: """Test setting up Govee with devices.""" - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd1", - sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, - ) - ] + mock_govee_api.devices = _get_devices(mock_govee_api) with patch( "homeassistant.components.govee_light_local.config_flow.GoveeController", @@ -80,3 +85,35 @@ async def test_creating_entry_has_with_devices( mock_govee_api.start.assert_awaited_once() mock_setup_entry.assert_awaited_once() + + +async def test_creating_entry_errno( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_govee_api: AsyncMock, +) -> None: + """Test setting up Govee with devices.""" + + e = OSError() + e.errno = EADDRINUSE + mock_govee_api.start.side_effect = e + mock_govee_api.devices = _get_devices(mock_govee_api) + + with patch( + "homeassistant.components.govee_light_local.config_flow.GoveeController", + return_value=mock_govee_api, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.ABORT + + await hass.async_block_till_done() + + assert mock_govee_api.start.call_count == 1 + mock_setup_entry.assert_not_awaited() diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 3bc9da77fe5..4a1125643fa 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -1,5 +1,6 @@ """Test Govee light local.""" +from errno import EADDRINUSE, ENETDOWN from unittest.mock import AsyncMock, MagicMock, patch from govee_local_api import GoveeDevice @@ -138,6 +139,62 @@ async def test_light_setup_retry( assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_light_setup_retry_eaddrinuse( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + mock_govee_api.start.side_effect = OSError() + mock_govee_api.start.side_effect.errno = EADDRINUSE + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_light_setup_error( + hass: HomeAssistant, mock_govee_api: AsyncMock +) -> None: + """Test adding an unknown device.""" + + mock_govee_api.start.side_effect = OSError() + mock_govee_api.start.side_effect.errno = ENETDOWN + mock_govee_api.devices = [ + GoveeDevice( + controller=mock_govee_api, + ip="192.168.1.100", + fingerprint="asdawdqwdqwd", + sku="H615A", + capabilities=DEFAULT_CAPABILITEIS, + ) + ] + + with patch( + "homeassistant.components.govee_light_local.coordinator.GoveeController", + return_value=mock_govee_api, + ): + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_ERROR + + async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> None: """Test adding a known device.""" diff --git a/tests/components/gpsd/conftest.py b/tests/components/gpsd/conftest.py index 71bb3aa61bf..c323365e8fd 100644 --- a/tests/components/gpsd/conftest.py +++ b/tests/components/gpsd/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the GPSD tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.gpsd.async_setup_entry", return_value=True diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 988581c804a..68b95df1702 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -3,11 +3,13 @@ from http import HTTPStatus from unittest.mock import patch +from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries from homeassistant.components import gpslogger, zone from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.gpslogger import DOMAIN, TRACKER_UPDATE from homeassistant.config import async_process_ha_core_config from homeassistant.const import STATE_HOME, STATE_NOT_HOME @@ -17,17 +19,21 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + HOME_LATITUDE = 37.239622 HOME_LONGITUDE = -115.815811 @pytest.fixture(autouse=True) -def mock_dev_track(mock_device_tracker_conf): +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: """Mock device tracker config loading.""" @pytest.fixture -async def gpslogger_client(hass, hass_client_no_auth): +async def gpslogger_client( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> TestClient: """Mock client for GPSLogger (unauthenticated).""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/gree/conftest.py b/tests/components/gree/conftest.py index 18113e6530c..88bcaea33c2 100644 --- a/tests/components/gree/conftest.py +++ b/tests/components/gree/conftest.py @@ -1,15 +1,15 @@ """Pytest module configuration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from .common import FakeDiscovery, build_device_mock @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.gree.async_setup_entry", return_value=True @@ -20,7 +20,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(autouse=True, name="discovery") def discovery_fixture(): """Patch the discovery object.""" - with patch("homeassistant.components.gree.bridge.Discovery") as mock: + with patch("homeassistant.components.gree.coordinator.Discovery") as mock: mock.return_value = FakeDiscovery() yield mock @@ -29,7 +29,7 @@ def discovery_fixture(): def device_fixture(): """Patch the device search and bind.""" with patch( - "homeassistant.components.gree.bridge.Device", + "homeassistant.components.gree.coordinator.Device", return_value=build_device_mock(), ) as mock: yield mock diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 9c465a9f297..c5684abbf6f 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -61,9 +61,8 @@ async def test_registry_settings( ENTITY_ID_XFAN, ], ) -async def test_send_switch_on( - hass: HomeAssistant, entity, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_send_switch_on(hass: HomeAssistant, entity: str) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -89,8 +88,9 @@ async def test_send_switch_on( ENTITY_ID_XFAN, ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_send_switch_on_device_timeout( - hass: HomeAssistant, device, entity, entity_registry_enabled_by_default: None + hass: HomeAssistant, device, entity: str ) -> None: """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -119,9 +119,8 @@ async def test_send_switch_on_device_timeout( ENTITY_ID_XFAN, ], ) -async def test_send_switch_off( - hass: HomeAssistant, entity, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_send_switch_off(hass: HomeAssistant, entity: str) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -147,9 +146,8 @@ async def test_send_switch_off( ENTITY_ID_XFAN, ], ) -async def test_send_switch_toggle( - hass: HomeAssistant, entity, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_send_switch_toggle(hass: HomeAssistant, entity: str) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) diff --git a/tests/components/greeneye_monitor/conftest.py b/tests/components/greeneye_monitor/conftest.py index 8d25a671806..ad8a98ce3fe 100644 --- a/tests/components/greeneye_monitor/conftest.py +++ b/tests/components/greeneye_monitor/conftest.py @@ -1,19 +1,16 @@ """Common fixtures for testing greeneye_monitor.""" -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.greeneye_monitor import DOMAIN from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import UnitOfElectricPotential, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import ( - RegistryEntry, - async_get as get_entity_registry, -) +from homeassistant.helpers import entity_registry as er from .common import add_listeners @@ -22,13 +19,15 @@ def assert_sensor_state( hass: HomeAssistant, entity_id: str, expected_state: str, - attributes: dict[str, Any] = {}, + attributes: dict[str, Any] | None = None, ) -> None: """Assert that the given entity has the expected state and at least the provided attributes.""" state = hass.states.get(entity_id) assert state actual_state = state.state assert actual_state == expected_state + if not attributes: + return for key, value in attributes.items(): assert key in state.attributes assert state.attributes[key] == value @@ -82,15 +81,15 @@ def assert_sensor_registered( sensor_type: str, number: int, name: str, -) -> RegistryEntry: +) -> er.RegistryEntry: """Assert that a sensor entity of a given type was registered properly.""" - registry = get_entity_registry(hass) + entity_registry = er.async_get(hass) unique_id = f"{serial_number}-{sensor_type}-{number}" - entity_id = registry.async_get_entity_id("sensor", DOMAIN, unique_id) + entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id) assert entity_id is not None - sensor = registry.async_get(entity_id) + sensor = entity_registry.async_get(entity_id) assert sensor assert sensor.unique_id == unique_id assert sensor.original_name == name @@ -99,7 +98,7 @@ def assert_sensor_registered( @pytest.fixture -def monitors() -> Generator[AsyncMock, None, None]: +def monitors() -> Generator[AsyncMock]: """Provide a mock greeneye.Monitors object that has listeners and can add new monitors.""" with patch("greeneye.Monitors", autospec=True) as mock_monitors: mock = mock_monitors.return_value diff --git a/tests/components/greeneye_monitor/test_sensor.py b/tests/components/greeneye_monitor/test_sensor.py index 35d515a4877..cd4243f4f6d 100644 --- a/tests/components/greeneye_monitor/test_sensor.py +++ b/tests/components/greeneye_monitor/test_sensor.py @@ -8,10 +8,7 @@ from homeassistant.components.greeneye_monitor.sensor import ( ) from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import ( - RegistryEntryDisabler, - async_get as get_entity_registry, -) +from homeassistant.helpers import entity_registry as er from .common import ( MULTI_MONITOR_CONFIG, @@ -27,7 +24,7 @@ from .conftest import assert_sensor_state async def test_sensor_does_not_exist_before_monitor_connected( - hass: HomeAssistant, monitors: AsyncMock + hass: HomeAssistant, entity_registry: er.EntityRegistry, monitors: AsyncMock ) -> None: """Test that a sensor does not exist before its monitor is connected.""" # The sensor base class handles connecting the monitor, so we test this with a single voltage sensor for ease @@ -35,7 +32,6 @@ async def test_sensor_does_not_exist_before_monitor_connected( hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS ) - entity_registry = get_entity_registry(hass) assert entity_registry.async_get("sensor.voltage_1") is None @@ -204,8 +200,8 @@ async def test_multi_monitor_sensors(hass: HomeAssistant, monitors: AsyncMock) - async def disable_entity(hass: HomeAssistant, entity_id: str) -> None: """Disable the given entity.""" - entity_registry = get_entity_registry(hass) + entity_registry = er.async_get(hass) entity_registry.async_update_entity( - entity_id, disabled_by=RegistryEntryDisabler.USER + entity_id, disabled_by=er.RegistryEntryDisabler.USER ) await hass.async_block_till_done() diff --git a/tests/components/group/common.py b/tests/components/group/common.py index 395fc990930..86fe537a776 100644 --- a/tests/components/group/common.py +++ b/tests/components/group/common.py @@ -64,13 +64,13 @@ def async_set_group( """Create/Update a group.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_OBJECT_ID, object_id), (ATTR_NAME, name), (ATTR_ENTITIES, entity_ids), (ATTR_ICON, icon), (ATTR_ADD_ENTITIES, add), - ] + ) if value is not None } diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 3aea9d21f0c..c6ee4ae5a87 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -205,7 +205,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") @pytest.mark.parametrize( diff --git a/tests/components/group/test_event.py b/tests/components/group/test_event.py index f82cc8f314b..1428fbeb8ad 100644 --- a/tests/components/group/test_event.py +++ b/tests/components/group/test_event.py @@ -2,8 +2,11 @@ from pytest_unordered import unordered -from homeassistant.components.event import DOMAIN as EVENT_DOMAIN -from homeassistant.components.event.const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN as EVENT_DOMAIN, +) from homeassistant.components.group import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 9dbd1fe1f6e..7434de74f63 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -19,17 +19,20 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_CLOSED, STATE_HOME, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, + STATE_OPENING, STATE_UNKNOWN, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.event import TRACK_STATE_CHANGE_CALLBACKS from homeassistant.setup import async_setup_component from . import common @@ -769,6 +772,48 @@ async def test_is_on(hass: HomeAssistant) -> None: (STATE_ON, True), (STATE_OFF, False), ), + ( + ("lock", "lock"), + (STATE_OPEN, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_OPENING, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_UNLOCKING, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_LOCKING, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_UNLOCKED, True), + (STATE_LOCKED, False), + ), + ( + ("lock", "lock"), + (STATE_JAMMED, STATE_LOCKED), + (STATE_LOCKED, STATE_LOCKED), + (STATE_LOCKED, False), + (STATE_LOCKED, False), + ), + ( + ("cover", "lock"), + (STATE_OPEN, STATE_OPEN), + (STATE_CLOSED, STATE_LOCKED), + (STATE_ON, True), + (STATE_OFF, False), + ), ], ) async def test_is_on_and_state_mixed_domains( @@ -855,10 +900,6 @@ async def test_reloading_groups(hass: HomeAssistant) -> None: "group.test_group", ] assert hass.bus.async_listeners()["state_changed"] == 1 - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS]["hello.world"]) == 1 - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS]["light.bowl"]) == 1 - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS]["test.one"]) == 1 - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS]["test.two"]) == 1 with patch( "homeassistant.config.load_yaml_config_file", @@ -874,9 +915,6 @@ async def test_reloading_groups(hass: HomeAssistant) -> None: "group.hello", ] assert hass.bus.async_listeners()["state_changed"] == 1 - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS]["light.bowl"]) == 1 - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS]["test.one"]) == 1 - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS]["test.two"]) == 1 async def test_modify_group(hass: HomeAssistant) -> None: @@ -1198,7 +1236,7 @@ async def test_group_mixed_domains_on(hass: HomeAssistant) -> None: hass.states.async_set("binary_sensor.alexander_garage_side_door_open", "on") hass.states.async_set("cover.small_garage_door", "open") - for domain in ["lock", "binary_sensor", "cover"]: + for domain in ("lock", "binary_sensor", "cover"): assert await async_setup_component(hass, domain, {}) assert await async_setup_component( hass, @@ -1223,7 +1261,7 @@ async def test_group_mixed_domains_off(hass: HomeAssistant) -> None: hass.states.async_set("binary_sensor.alexander_garage_side_door_open", "off") hass.states.async_set("cover.small_garage_door", "closed") - for domain in ["lock", "binary_sensor", "cover"]: + for domain in ("lock", "binary_sensor", "cover"): assert await async_setup_component(hass, domain, {}) assert await async_setup_component( hass, @@ -1247,6 +1285,8 @@ async def test_group_mixed_domains_off(hass: HomeAssistant) -> None: [ (("locked", "locked", "unlocked"), "unlocked"), (("locked", "locked", "locked"), "locked"), + (("locked", "locked", "open"), "unlocked"), + (("locked", "unlocked", "open"), "unlocked"), ], ) async def test_group_locks(hass: HomeAssistant, states, group_state) -> None: @@ -1447,28 +1487,67 @@ async def test_group_vacuum_on(hass: HomeAssistant) -> None: assert hass.states.get("group.group_zero").state == STATE_ON -async def test_device_tracker_not_home(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("entity_state_list", "group_state"), + [ + ( + { + "device_tracker.one": "not_home", + "device_tracker.two": "not_home", + "device_tracker.three": "not_home", + }, + "not_home", + ), + ( + { + "device_tracker.one": "home", + "device_tracker.two": "not_home", + "device_tracker.three": "not_home", + }, + "home", + ), + ( + { + "device_tracker.one": "home", + "device_tracker.two": "elsewhere", + "device_tracker.three": "not_home", + }, + "home", + ), + ( + { + "device_tracker.one": "not_home", + "device_tracker.two": "elsewhere", + "device_tracker.three": "not_home", + }, + "not_home", + ), + ], +) +async def test_device_tracker_or_person_not_home( + hass: HomeAssistant, + entity_state_list: dict[str, str], + group_state: str, +) -> None: """Test group of device_tracker not_home.""" await async_setup_component(hass, "device_tracker", {}) + await async_setup_component(hass, "person", {}) await hass.async_block_till_done() - hass.states.async_set("device_tracker.one", "not_home") - hass.states.async_set("device_tracker.two", "not_home") - hass.states.async_set("device_tracker.three", "not_home") + for entity_id, state in entity_state_list.items(): + hass.states.async_set(entity_id, state) assert await async_setup_component( hass, "group", { "group": { - "group_zero": { - "entities": "device_tracker.one, device_tracker.two, device_tracker.three" - }, + "group_zero": {"entities": ", ".join(entity_state_list)}, } }, ) await hass.async_block_till_done() - assert hass.states.get("group.group_zero").state == "not_home" + assert hass.states.get("group.group_zero").state == group_state async def test_light_removed(hass: HomeAssistant) -> None: diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index c8102b79ff9..0c62913ae3e 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -18,6 +18,7 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, @@ -204,8 +205,8 @@ async def test_service_calls_openable(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.lock_group"}, blocking=True, ) - assert hass.states.get("lock.openable_lock").state == STATE_UNLOCKED - assert hass.states.get("lock.another_openable_lock").state == STATE_UNLOCKED + assert hass.states.get("lock.openable_lock").state == STATE_OPEN + assert hass.states.get("lock.another_openable_lock").state == STATE_OPEN await hass.services.async_call( LOCK_DOMAIN, diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 4a8c434c742..db642506361 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -48,6 +48,7 @@ MAX_VALUE = max(VALUES) MEAN = statistics.mean(VALUES) MEDIAN = statistics.median(VALUES) RANGE = max(VALUES) - min(VALUES) +STDEV = statistics.stdev(VALUES) SUM_VALUE = sum(VALUES) PRODUCT_VALUE = prod(VALUES) @@ -61,6 +62,7 @@ PRODUCT_VALUE = prod(VALUES) ("median", MEDIAN, {}), ("last", VALUES[2], {ATTR_LAST_ENTITY_ID: "sensor.test_3"}), ("range", RANGE, {}), + ("stdev", STDEV, {}), ("sum", SUM_VALUE, {}), ("product", PRODUCT_VALUE, {}), ], @@ -761,3 +763,52 @@ async def test_last_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.test_last") assert str(float(value)) == state.state assert entity_id == state.attributes.get("last_entity_id") + + +async def test_sensors_attributes_added_when_entity_info_available( + hass: HomeAssistant, +) -> None: + """Test the sensor calculate attributes once all entities attributes are available.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": DEFAULT_NAME, + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id", + } + } + + entity_ids = config["sensor"]["entities"] + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.sensor_group_sum") + + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get(ATTR_ENTITY_ID) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + + for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items(): + hass.states.async_set( + entity_id, + value, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + ATTR_UNIT_OF_MEASUREMENT: "L", + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.sensor_group_sum") + + assert float(state.state) == pytest.approx(float(SUM_VALUE)) + assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLUME + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L" diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 32b21fcb0d7..4230a6ee86f 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -3,6 +3,8 @@ import asyncio from unittest.mock import patch +import pytest + from homeassistant import config as hass_config from homeassistant.components.group import DOMAIN, SERVICE_RELOAD from homeassistant.components.switch import ( @@ -232,9 +234,8 @@ async def test_state_reporting_all(hass: HomeAssistant) -> None: assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE -async def test_service_calls( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_service_calls(hass: HomeAssistant) -> None: """Test service calls.""" await async_setup_component( hass, diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index df517aba603..87ff96aff45 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -1,10 +1,10 @@ """Define fixtures for Elexa Guardian tests.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.guardian import CONF_UID, DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.guardian.async_setup_entry", return_value=True diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 50c7e664cd4..24c55c473b9 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -13,11 +13,11 @@ from homeassistant.components.habitica.const import ( EVENT_API_CALL_SUCCESS, SERVICE_API_CALL, ) -from homeassistant.components.habitica.sensor import TASKS_TYPES from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_capture_events +from tests.test_util.aiohttp import AiohttpClientMocker TEST_API_CALL_ARGS = {"text": "Use API from Home Assistant", "type": "todo"} TEST_USER_NAME = "test_user" @@ -46,7 +46,7 @@ def habitica_entry(hass): @pytest.fixture -def common_requests(aioclient_mock): +def common_requests(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: """Register requests for the tests.""" aioclient_mock.get( "https://habitica.com/api/v3/user", @@ -73,20 +73,21 @@ def common_requests(aioclient_mock): } }, ) - for n_tasks, task_type in enumerate(TASKS_TYPES.keys(), start=1): - aioclient_mock.get( - f"https://habitica.com/api/v3/tasks/user?type={task_type}", - json={ - "data": [ - { - "text": f"this is a mock {task_type} #{task}", - "id": f"{task}", - "type": TASKS_TYPES[task_type].path[0], - } - for task in range(n_tasks) - ] - }, - ) + + aioclient_mock.get( + "https://habitica.com/api/v3/tasks/user", + json={ + "data": [ + { + "text": f"this is a mock {task} #{i}", + "id": f"{i}", + "type": task, + "completed": False, + } + for i, task in enumerate(("habit", "daily", "todo", "reward"), start=1) + ] + }, + ) aioclient_mock.post( "https://habitica.com/api/v3/tasks/user", diff --git a/tests/components/harmony/conftest.py b/tests/components/harmony/conftest.py index 97449749667..fb4be73aa72 100644 --- a/tests/components/harmony/conftest.py +++ b/tests/components/harmony/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from aioharmony.const import ClientCallbackType import pytest +from typing_extensions import Generator from homeassistant.components.harmony.const import ACTIVITY_POWER_OFF, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME @@ -46,20 +47,17 @@ IDS_TO_DEVICES = { class FakeHarmonyClient: """FakeHarmonyClient to mock away network calls.""" - def initialize( - self, ip_address: str = "", callbacks: ClientCallbackType = MagicMock() - ): + _callbacks: ClientCallbackType + + def __init__(self) -> None: """Initialize FakeHarmonyClient class to capture callbacks.""" self._activity_name = "Watch TV" self.close = AsyncMock() self.send_commands = AsyncMock() self.change_channel = AsyncMock() self.sync = AsyncMock() - self._callbacks = callbacks self.fw_version = "123.456" - return self - async def connect(self): """Connect and call the appropriate callbacks.""" self._callbacks.connect(None) @@ -151,20 +149,27 @@ class FakeHarmonyClient: @pytest.fixture -def harmony_client(): +def harmony_client() -> FakeHarmonyClient: """Create the FakeHarmonyClient instance.""" return FakeHarmonyClient() @pytest.fixture -def mock_hc(harmony_client): +def mock_hc(harmony_client: FakeHarmonyClient) -> Generator[None]: """Patch the real HarmonyClient with initialization side effect.""" + def _on_create_instance( + ip_address: str, callbacks: ClientCallbackType + ) -> FakeHarmonyClient: + """Set client callbacks on instance creation.""" + harmony_client._callbacks = callbacks + return harmony_client + with patch( "homeassistant.components.harmony.data.HarmonyClient", - side_effect=harmony_client.initialize, - ) as fake: - yield fake + side_effect=_on_create_instance, + ): + yield @pytest.fixture diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py deleted file mode 100644 index 01f9287ae57..00000000000 --- a/tests/components/harmony/test_switch.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Test the Logitech Harmony Hub activity switches.""" - -from datetime import timedelta - -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.harmony.const import DOMAIN -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, -) -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_HOST, - CONF_NAME, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component -from homeassistant.util import utcnow - -from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME - -from tests.common import MockConfigEntry, async_fire_time_changed - - -async def test_connection_state_changes( - harmony_client, - mock_hc, - hass: HomeAssistant, - mock_write_config, - entity_registry: er.EntityRegistry, -) -> None: - """Ensure connection changes are reflected in the switch states.""" - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} - ) - - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # check if switch entities are disabled by default - assert not hass.states.get(ENTITY_WATCH_TV) - assert not hass.states.get(ENTITY_PLAY_MUSIC) - - # enable switch entities - entity_registry.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) - entity_registry.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) - await hass.config_entries.async_reload(entry.entry_id) - await hass.async_block_till_done() - - # mocks start with current activity == Watch TV - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) - - harmony_client.mock_disconnection() - await hass.async_block_till_done() - - # Entities do not immediately show as unavailable - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) - - future_time = utcnow() + timedelta(seconds=10) - async_fire_time_changed(hass, future_time) - await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_UNAVAILABLE) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_UNAVAILABLE) - - harmony_client.mock_reconnection() - await hass.async_block_till_done() - - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) - - harmony_client.mock_disconnection() - harmony_client.mock_reconnection() - future_time = utcnow() + timedelta(seconds=10) - async_fire_time_changed(hass, future_time) - - await hass.async_block_till_done() - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) - - -async def test_switch_toggles( - mock_hc, - hass: HomeAssistant, - mock_write_config, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, -) -> None: - """Ensure calls to the switch modify the harmony state.""" - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # enable switch entities - entity_registry.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) - entity_registry.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) - await hass.config_entries.async_reload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # mocks start with current activity == Watch TV - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) - - # turn off watch tv switch - await _toggle_switch_and_wait(hass, SERVICE_TURN_OFF, ENTITY_WATCH_TV) - assert hass.states.is_state(ENTITY_REMOTE, STATE_OFF) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) - - # turn on play music switch - await _toggle_switch_and_wait(hass, SERVICE_TURN_ON, ENTITY_PLAY_MUSIC) - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_OFF) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_ON) - - # turn on watch tv switch - await _toggle_switch_and_wait(hass, SERVICE_TURN_ON, ENTITY_WATCH_TV) - assert hass.states.is_state(ENTITY_REMOTE, STATE_ON) - assert hass.states.is_state(ENTITY_WATCH_TV, STATE_ON) - assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) - - -async def _toggle_switch_and_wait(hass, service_name, entity): - await hass.services.async_call( - SWITCH_DOMAIN, - service_name, - {ATTR_ENTITY_ID: entity}, - blocking=True, - ) - await hass.async_block_till_done() - - -async def test_create_issue( - harmony_client, - mock_hc, - 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( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": ENTITY_WATCH_TV}, - "action": {"service": "switch.turn_on", "entity_id": ENTITY_WATCH_TV}, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "service": "switch.turn_on", - "data": {"entity_id": ENTITY_WATCH_TV}, - }, - ], - } - } - }, - ) - - entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} - ) - - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert automations_with_entity(hass, ENTITY_WATCH_TV)[0] == "automation.test" - assert scripts_with_entity(hass, ENTITY_WATCH_TV)[0] == "script.test" - - assert issue_registry.async_get_issue(DOMAIN, "deprecated_switches") - assert issue_registry.async_get_issue( - DOMAIN, "deprecated_switches_switch.guest_room_watch_tv_automation.test" - ) - assert issue_registry.async_get_issue( - DOMAIN, "deprecated_switches_switch.guest_room_watch_tv_script.test" - ) - - assert len(issue_registry.issues) == 3 diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 21eeedb89ad..7b79dfe6179 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -4,16 +4,18 @@ import os import re from unittest.mock import Mock, patch +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.hassio.handler import HassIO, HassioAPIError -from homeassistant.core import CoreState +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component from . import SUPERVISOR_TOKEN from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) @@ -45,7 +47,12 @@ def hassio_env(): @pytest.fixture -def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): +def hassio_stubs( + hassio_env, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +): """Create mock hassio http client.""" with ( patch( @@ -78,19 +85,25 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): @pytest.fixture -def hassio_client(hassio_stubs, hass, hass_client): +def hassio_client( + hassio_stubs, hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Return a Hass.io HTTP client.""" return hass.loop.run_until_complete(hass_client()) @pytest.fixture -def hassio_noauth_client(hassio_stubs, hass, aiohttp_client): +def hassio_noauth_client( + hassio_stubs, hass: HomeAssistant, aiohttp_client: ClientSessionGenerator +) -> TestClient: """Return a Hass.io HTTP client without auth.""" return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @pytest.fixture -async def hassio_client_supervisor(hass, aiohttp_client, hassio_stubs): +async def hassio_client_supervisor( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, hassio_stubs +) -> TestClient: """Return an authenticated HTTP client.""" access_token = hass.auth.async_create_access_token(hassio_stubs) return await aiohttp_client( @@ -100,7 +113,7 @@ async def hassio_client_supervisor(hass, aiohttp_client, hassio_stubs): @pytest.fixture -async def hassio_handler(hass, aioclient_mock): +async def hassio_handler(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): """Create mock hassio handler.""" with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): yield HassIO(hass.loop, async_get_clientsession(hass), "127.0.0.1") @@ -109,7 +122,7 @@ async def hassio_handler(hass, aioclient_mock): @pytest.fixture def all_setup_requests( aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest -): +) -> None: """Mock all setup requests.""" include_addons = hasattr(request, "param") and request.param.get( "include_addons", False @@ -308,3 +321,13 @@ def all_setup_requests( }, }, ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) diff --git a/tests/components/hassio/test_addon_manager.py b/tests/components/hassio/test_addon_manager.py index f846de007ef..55c663d66cc 100644 --- a/tests/components/hassio/test_addon_manager.py +++ b/tests/components/hassio/test_addon_manager.py @@ -3,12 +3,12 @@ from __future__ import annotations import asyncio -from collections.abc import Generator import logging from typing import Any from unittest.mock import AsyncMock, call, patch import pytest +from typing_extensions import Generator from homeassistant.components.hassio.addon_manager import ( AddonError, @@ -56,7 +56,7 @@ def mock_addon_installed( @pytest.fixture(name="get_addon_discovery_info") -def get_addon_discovery_info_fixture() -> Generator[AsyncMock, None, None]: +def get_addon_discovery_info_fixture() -> Generator[AsyncMock]: """Mock get add-on discovery info.""" with patch( "homeassistant.components.hassio.addon_manager.async_get_addon_discovery_info" @@ -65,7 +65,7 @@ def get_addon_discovery_info_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="addon_store_info") -def addon_store_info_fixture() -> Generator[AsyncMock, None, None]: +def addon_store_info_fixture() -> Generator[AsyncMock]: """Mock Supervisor add-on store info.""" with patch( "homeassistant.components.hassio.addon_manager.async_get_addon_store_info" @@ -80,7 +80,7 @@ def addon_store_info_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="addon_info") -def addon_info_fixture() -> Generator[AsyncMock, None, None]: +def addon_info_fixture() -> Generator[AsyncMock]: """Mock Supervisor add-on info.""" with patch( "homeassistant.components.hassio.addon_manager.async_get_addon_info", @@ -97,7 +97,7 @@ def addon_info_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="set_addon_options") -def set_addon_options_fixture() -> Generator[AsyncMock, None, None]: +def set_addon_options_fixture() -> Generator[AsyncMock]: """Mock set add-on options.""" with patch( "homeassistant.components.hassio.addon_manager.async_set_addon_options" @@ -106,7 +106,7 @@ def set_addon_options_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="install_addon") -def install_addon_fixture() -> Generator[AsyncMock, None, None]: +def install_addon_fixture() -> Generator[AsyncMock]: """Mock install add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_install_addon" @@ -115,7 +115,7 @@ def install_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="uninstall_addon") -def uninstall_addon_fixture() -> Generator[AsyncMock, None, None]: +def uninstall_addon_fixture() -> Generator[AsyncMock]: """Mock uninstall add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_uninstall_addon" @@ -124,7 +124,7 @@ def uninstall_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="start_addon") -def start_addon_fixture() -> Generator[AsyncMock, None, None]: +def start_addon_fixture() -> Generator[AsyncMock]: """Mock start add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_start_addon" @@ -133,7 +133,7 @@ def start_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="restart_addon") -def restart_addon_fixture() -> Generator[AsyncMock, None, None]: +def restart_addon_fixture() -> Generator[AsyncMock]: """Mock restart add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_restart_addon" @@ -142,7 +142,7 @@ def restart_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="stop_addon") -def stop_addon_fixture() -> Generator[AsyncMock, None, None]: +def stop_addon_fixture() -> Generator[AsyncMock]: """Mock stop add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_stop_addon" @@ -151,7 +151,7 @@ def stop_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="create_backup") -def create_backup_fixture() -> Generator[AsyncMock, None, None]: +def create_backup_fixture() -> Generator[AsyncMock]: """Mock create backup.""" with patch( "homeassistant.components.hassio.addon_manager.async_create_backup" @@ -160,7 +160,7 @@ def create_backup_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="update_addon") -def mock_update_addon() -> Generator[AsyncMock, None, None]: +def mock_update_addon() -> Generator[AsyncMock]: """Mock update add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_update_addon" @@ -198,12 +198,12 @@ async def test_not_available_raises_exception( with pytest.raises(AddonError) as err: await addon_manager.async_install_addon() - assert str(err.value) == "Test add-on is not available anymore" + assert str(err.value) == "Test add-on is not available" with pytest.raises(AddonError) as err: await addon_manager.async_update_addon() - assert str(err.value) == "Test add-on is not available anymore" + assert str(err.value) == "Test add-on is not available" async def test_get_addon_discovery_info( diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py index 9b1735287c6..8436b3393b9 100644 --- a/tests/components/hassio/test_addon_panel.py +++ b/tests/components/hassio/test_addon_panel.py @@ -13,7 +13,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def mock_all(aioclient_mock): +def mock_all(aioclient_mock: AiohttpClientMocker) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index d502d6ea730..af72ea9d702 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -17,7 +17,7 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock, request): +def mock_all(aioclient_mock: AiohttpClientMocker) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) @@ -180,6 +180,16 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 6b0dae170c6..0d648ba9bdb 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -11,13 +11,14 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock, request): +def mock_all(aioclient_mock: AiohttpClientMocker) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) @@ -184,6 +185,16 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) async def test_diagnostics( diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 337a0dd864f..c418576a802 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -320,10 +320,10 @@ async def test_api_ingress_panels( ("update_diagnostics", "POST", True), ], ) +@pytest.mark.usefixtures("socket_enabled") async def test_api_headers( aiohttp_raw_server, # 'aiohttp_raw_server' must be before 'hass'! - hass, - socket_enabled, + hass: HomeAssistant, api_call: str, method: Literal["GET", "POST"], payload: Any, diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 55d4d8b0365..a5ffb4f0d83 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -6,6 +6,7 @@ from unittest.mock import patch from aiohttp import StreamReader import pytest +from tests.common import MockUser from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,7 +20,7 @@ def mock_not_onboarded(): @pytest.fixture -def hassio_user_client(hassio_client, hass_admin_user): +def hassio_user_client(hassio_client, hass_admin_user: MockUser): """Return a Hass.io HTTP client tied to a non-admin user.""" hass_admin_user.groups = [] return hassio_client diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ff038b620eb..0246b557ee4 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -24,7 +24,7 @@ from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -52,7 +52,7 @@ def os_info(extra_os_info): @pytest.fixture(autouse=True) -def mock_all(aioclient_mock, request, os_info): +def mock_all(aioclient_mock: AiohttpClientMocker, os_info) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) @@ -237,6 +237,16 @@ def mock_all(aioclient_mock, request, os_info): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) async def test_setup_api_ping( @@ -248,7 +258,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert get_core_info(hass)["version_latest"] == "1.0.0" assert is_hassio(hass) @@ -293,7 +303,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert "watchdog" not in aioclient_mock.mock_calls[1][2] @@ -312,7 +322,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -329,7 +339,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -409,7 +419,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -426,7 +436,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -447,7 +457,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -535,14 +545,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 23 + assert aioclient_mock.call_count == 24 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 25 + assert aioclient_mock.call_count == 26 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -557,7 +567,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 27 + assert aioclient_mock.call_count == 28 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -582,7 +592,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 29 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -601,7 +611,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -617,7 +627,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 31 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -636,7 +646,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 33 + assert aioclient_mock.call_count == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -763,9 +773,10 @@ async def test_migration_off_hassio(hass: HomeAssistant) -> None: assert hass.config_entries.async_entries(DOMAIN) == [] -async def test_device_registry_calls(hass: HomeAssistant) -> None: +async def test_device_registry_calls( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test device registry entries for hassio.""" - dev_reg = async_get(hass) supervisor_mock_data = { "version": "1.0.0", "version_latest": "1.0.0", @@ -819,7 +830,7 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) - assert len(dev_reg.devices) == 6 + assert len(device_registry.devices) == 6 supervisor_mock_data = { "version": "1.0.0", @@ -853,11 +864,11 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=1)) await hass.async_block_till_done(wait_background_tasks=True) - assert len(dev_reg.devices) == 5 + assert len(device_registry.devices) == 5 async_fire_time_changed(hass, dt_util.now() + timedelta(hours=2)) await hass.async_block_till_done(wait_background_tasks=True) - assert len(dev_reg.devices) == 5 + assert len(device_registry.devices) == 5 supervisor_mock_data = { "version": "1.0.0", @@ -911,7 +922,7 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=3)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 5 + assert len(device_registry.devices) == 5 async def test_coordinator_updates( @@ -989,10 +1000,10 @@ async def test_coordinator_updates( assert "Error on Supervisor API: Unknown" in caplog.text +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates_stats_entities_enabled( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - entity_registry_enabled_by_default: None, ) -> None: """Test coordinator updates with stats entities enabled.""" await async_setup_component(hass, "homeassistant", {}) @@ -1101,7 +1112,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done(wait_background_tasks=True) assert result - assert aioclient_mock.call_count == 19 + assert aioclient_mock.call_count == 20 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 2da9d30549d..ff0e4a8dd92 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -27,11 +27,6 @@ async def setup_repairs(hass): assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) -@pytest.fixture(autouse=True) -async def mock_all(all_setup_requests): - """Mock all setup requests.""" - - @pytest.fixture(autouse=True) async def fixture_supervisor_environ(): """Mock os environ for supervisor.""" @@ -110,9 +105,13 @@ def assert_issue_repair_in_list( context: str, type_: str, fixable: bool, - reference: str | None, + *, + reference: str | None = None, + placeholders: dict[str, str] | None = None, ): """Assert repair for unhealthy/unsupported in list.""" + if reference: + placeholders = (placeholders or {}) | {"reference": reference} assert { "breaks_in_ha_version": None, "created": ANY, @@ -125,7 +124,7 @@ def assert_issue_repair_in_list( "learn_more_url": None, "severity": "warning", "translation_key": f"issue_{context}_{type_}", - "translation_placeholders": {"reference": reference} if reference else None, + "translation_placeholders": placeholders, } in issues @@ -133,6 +132,7 @@ async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test issues added for unhealthy systems.""" mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) @@ -154,6 +154,7 @@ async def test_unsupported_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test issues added for unsupported systems.""" mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) @@ -177,6 +178,7 @@ async def test_unhealthy_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) @@ -233,6 +235,7 @@ async def test_unsupported_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) @@ -289,6 +292,7 @@ async def test_reset_issues_supervisor_restart( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """All issues reset on supervisor restart.""" mock_resolution_info( @@ -352,6 +356,7 @@ async def test_reasons_added_and_removed( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test an unsupported/unhealthy reasons being added and removed at same time.""" mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) @@ -401,6 +406,7 @@ async def test_ignored_unsupported_skipped( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Unsupported reasons which have an identical unhealthy reason are ignored.""" mock_resolution_info( @@ -423,6 +429,7 @@ async def test_new_unsupported_unhealthy_reason( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """New unsupported/unhealthy reasons result in a generic repair until next core update.""" mock_resolution_info( @@ -472,6 +479,7 @@ async def test_supervisor_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test repairs added for supervisor issue.""" mock_resolution_info( @@ -538,6 +546,7 @@ async def test_supervisor_issues_initial_failure( aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, + all_setup_requests, ) -> None: """Test issues manager retries after initial update failure.""" responses = [ @@ -614,6 +623,7 @@ async def test_supervisor_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test supervisor issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) @@ -724,6 +734,7 @@ async def test_supervisor_issues_suggestions_fail( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test failing to get suggestions for issue skips it.""" aioclient_mock.get( @@ -769,6 +780,7 @@ async def test_supervisor_remove_missing_issue_without_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test HA skips message to remove issue that it didn't know about (sync issue).""" mock_resolution_info(aioclient_mock) @@ -802,6 +814,7 @@ async def test_system_is_not_ready( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, + all_setup_requests, ) -> None: """Ensure hassio starts despite error.""" aioclient_mock.get( @@ -814,3 +827,57 @@ async def test_system_is_not_ready( assert await async_setup_component(hass, "hassio", {}) assert "Failed to update supervisor issues" in caplog.text + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +async def test_supervisor_issues_detached_addon_missing( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, + all_setup_requests, +) -> None: + """Test supervisor issue for detached addon due to missing repository.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": "1234", + "type": "detached_addon_missing", + "context": "addon", + "reference": "test", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="addon", + type_="detached_addon_missing", + fixable=False, + placeholders={ + "reference": "test", + "addon": "test", + "addon_url": "/hassio/addon/test", + }, + ) diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 33d266eb24b..8d0bbfac87c 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -780,3 +780,90 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1236" ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +async def test_supervisor_issue_detached_addon_removed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, +) -> None: + """Test fix flow for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "detached_addon_removed", + "context": "addon", + "reference": "test", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_remove", + "context": "addon", + "reference": "test", + } + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "addon_execute_remove", + "data_schema": [], + "errors": None, + "description_placeholders": { + "reference": "test", + "addon": "test", + "help_url": "https://www.home-assistant.io/help/", + "community_url": "https://community.home-assistant.io/", + }, + "last_step": True, + "preview": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 55cec90ec58..71b867d849d 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -28,7 +28,7 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock: AiohttpClientMocker, request): +def mock_all(aioclient_mock: AiohttpClientMocker) -> None: """Mock all setup requests.""" _install_default_mocks(aioclient_mock) _install_test_addon_stats_mock(aioclient_mock) @@ -202,6 +202,16 @@ def _install_default_mocks(aioclient_mock: AiohttpClientMocker): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) @pytest.mark.parametrize( diff --git a/tests/components/hassio/test_system_health.py b/tests/components/hassio/test_system_health.py index 873365aa3a0..c4c2b861e6e 100644 --- a/tests/components/hassio/test_system_health.py +++ b/tests/components/hassio/test_system_health.py @@ -43,6 +43,8 @@ async def test_hassio_system_health( "agent_version": "1337", "disk_total": "32.0", "disk_used": "30.0", + "dt_synchronized": True, + "virtualization": "qemu", } hass.data["hassio_os_info"] = {"board": "odroid-n2"} hass.data["hassio_supervisor_info"] = { @@ -50,6 +52,10 @@ async def test_hassio_system_health( "supported": True, "addons": [{"name": "Awesome Addon", "version": "1.0.0"}], } + hass.data["hassio_network_info"] = { + "host_internet": True, + "supervisor_internet": True, + } with patch.dict(os.environ, MOCK_ENVIRON): info = await get_system_health_info(hass, "hassio") @@ -65,13 +71,17 @@ async def test_hassio_system_health( "disk_used": "30.0 GB", "docker_version": "19.0.3", "healthy": True, + "host_connectivity": True, + "supervisor_connectivity": True, "host_os": "Home Assistant OS 5.9", "installed_addons": "Awesome Addon (1.0.0)", + "ntp_synchronized": True, "supervisor_api": "ok", "supervisor_version": "supervisor-2020.11.1", "supported": True, "update_channel": "stable", "version_api": "ok", + "virtualization": "qemu", } @@ -99,6 +109,7 @@ async def test_hassio_system_health_with_issues( "healthy": False, "supported": False, } + hass.data["hassio_network_info"] = {} with patch.dict(os.environ, MOCK_ENVIRON): info = await get_system_health_info(hass, "hassio") diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index f6b61aeedab..9a047010cc3 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -21,7 +21,7 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock, request): +def mock_all(aioclient_mock: AiohttpClientMocker) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) @@ -189,6 +189,16 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) @pytest.mark.parametrize( @@ -473,7 +483,7 @@ async def test_release_notes_between_versions( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.data.get_addons_changelogs", + "homeassistant.components.hassio.coordinator.get_addons_changelogs", return_value={"test": "# 2.0.1\nNew updates\n# 2.0.0\nOld updates"}, ), ): @@ -512,7 +522,7 @@ async def test_release_notes_full( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.data.get_addons_changelogs", + "homeassistant.components.hassio.coordinator.get_addons_changelogs", return_value={"test": "# 2.0.0\nNew updates\n# 2.0.0\nOld updates"}, ), ): @@ -551,7 +561,7 @@ async def test_not_release_notes( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.data.get_addons_changelogs", + "homeassistant.components.hassio.coordinator.get_addons_changelogs", return_value={"test": None}, ), ): diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 67252a0bc83..f3be391d9b7 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -23,7 +23,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_all(aioclient_mock): +def mock_all(aioclient_mock: AiohttpClientMocker) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index eac6d4c4053..2bd0519c12c 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -85,7 +85,7 @@ class TelnetMock: @pytest.fixture def telnetmock(): """Mock telnet.""" - with patch("telnetlib.Telnet", new=TelnetMock): + with patch("homeassistant.components.hddtemp.sensor.Telnet", new=TelnetMock): yield @@ -158,11 +158,11 @@ async def test_hddtemp_multiple_disks(hass: HomeAssistant, telnetmock) -> None: assert await async_setup_component(hass, "sensor", VALID_CONFIG_MULTIPLE_DISKS) await hass.async_block_till_done() - for sensor in [ + for sensor in ( "sensor.hd_temperature_dev_sda1", "sensor.hd_temperature_dev_sdb1", "sensor.hd_temperature_dev_sdc1", - ]: + ): state = hass.states.get(sensor) reference = REFERENCE[state.attributes.get("device")] diff --git a/tests/components/hdmi_cec/__init__.py b/tests/components/hdmi_cec/__init__.py index 31e09489d4a..5cf8ed18b6a 100644 --- a/tests/components/hdmi_cec/__init__.py +++ b/tests/components/hdmi_cec/__init__.py @@ -21,6 +21,7 @@ class MockHDMIDevice: self.turn_off = Mock() self.send_command = Mock() self.async_send_command = AsyncMock() + self._update = None def __getattr__(self, name): """Get attribute from `_values` if not explicitly set.""" diff --git a/tests/components/hdmi_cec/test_init.py b/tests/components/hdmi_cec/test_init.py index b8cbf1ea8cd..1263078c196 100644 --- a/tests/components/hdmi_cec/test_init.py +++ b/tests/components/hdmi_cec/test_init.py @@ -277,7 +277,7 @@ async def test_service_update_devices(hass: HomeAssistant, create_hdmi_network) @pytest.mark.parametrize( - ("count", "calls"), + ("count", "call_count"), [ (3, 3), (1, 1), @@ -294,7 +294,12 @@ async def test_service_update_devices(hass: HomeAssistant, create_hdmi_network) ) @pytest.mark.parametrize(("direction", "key"), [("up", 65), ("down", 66)]) async def test_service_volume_x_times( - hass: HomeAssistant, create_hdmi_network, count, calls, direction, key + hass: HomeAssistant, + create_hdmi_network, + count: int, + call_count: int, + direction, + key, ) -> None: """Test the volume service call with steps.""" mock_hdmi_network_instance = await create_hdmi_network() @@ -306,8 +311,8 @@ async def test_service_volume_x_times( blocking=True, ) - assert mock_hdmi_network_instance.send_command.call_count == calls * 2 - for i in range(calls): + assert mock_hdmi_network_instance.send_command.call_count == call_count * 2 + for i in range(call_count): assert_key_press_release( mock_hdmi_network_instance.send_command, i, dst=5, key=key ) diff --git a/tests/components/hdmi_cec/test_media_player.py b/tests/components/hdmi_cec/test_media_player.py index 4c2c5f42e6e..988279a235f 100644 --- a/tests/components/hdmi_cec/test_media_player.py +++ b/tests/components/hdmi_cec/test_media_player.py @@ -1,5 +1,7 @@ """Tests for the HDMI-CEC media player platform.""" +from collections.abc import Callable + from pycec.const import ( DEVICE_TYPE_NAMES, KEY_BACKWARD, @@ -54,6 +56,8 @@ from homeassistant.core import HomeAssistant from . import MockHDMIDevice, assert_key_press_release +type AssertState = Callable[[str, str], None] + @pytest.fixture( name="assert_state", @@ -70,20 +74,20 @@ from . import MockHDMIDevice, assert_key_press_release ], ids=["skip_assert_state", "run_assert_state"], ) -def assert_state_fixture(hass, request): +def assert_state_fixture(request: pytest.FixtureRequest) -> AssertState: """Allow for skipping the assert state changes. This is broken in this entity, but we still want to test that the rest of the code works as expected. """ - def test_state(state, expected): + def _test_state(state: str, expected: str) -> None: if request.param: assert state == expected else: assert True - return test_state + return _test_state async def test_load_platform( @@ -128,7 +132,10 @@ async def test_load_types( async def test_service_on( - hass: HomeAssistant, create_hdmi_network, create_cec_entity, assert_state + hass: HomeAssistant, + create_hdmi_network, + create_cec_entity, + assert_state: AssertState, ) -> None: """Test that media_player triggers on `on` service.""" hdmi_network = await create_hdmi_network({"platform": "media_player"}) @@ -152,7 +159,10 @@ async def test_service_on( async def test_service_off( - hass: HomeAssistant, create_hdmi_network, create_cec_entity, assert_state + hass: HomeAssistant, + create_hdmi_network, + create_cec_entity, + assert_state: AssertState, ) -> None: """Test that media_player triggers on `off` service.""" hdmi_network = await create_hdmi_network({"platform": "media_player"}) @@ -352,10 +362,10 @@ async def test_playback_services( hass: HomeAssistant, create_hdmi_network, create_cec_entity, - assert_state, - service, - key, - expected_state, + assert_state: AssertState, + service: str, + key: int, + expected_state: str, ) -> None: """Test playback related commands.""" hdmi_network = await create_hdmi_network({"platform": "media_player"}) @@ -382,7 +392,7 @@ async def test_play_pause_service( hass: HomeAssistant, create_hdmi_network, create_cec_entity, - assert_state, + assert_state: AssertState, ) -> None: """Test play pause service.""" hdmi_network = await create_hdmi_network({"platform": "media_player"}) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index fd453c70ebf..9341c8fbace 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -140,7 +140,6 @@ async def test_async_setup_entry_connect_failure( controller.connect.side_effect = HeosError() with pytest.raises(ConfigEntryNotReady): await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 controller.connect.reset_mock() @@ -155,7 +154,6 @@ async def test_async_setup_entry_player_failure( controller.get_players.side_effect = HeosError() with pytest.raises(ConfigEntryNotReady): await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() assert controller.connect.call_count == 1 assert controller.disconnect.call_count == 1 controller.connect.reset_mock() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 99d09cfb7b1..19f7ec74daf 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -688,7 +688,7 @@ async def test_unload_config_entry( ) -> None: """Test the player is set unavailable when the config entry is unloaded.""" await setup_platform(hass, config_entry, config) - await config_entry.async_unload(hass) + await hass.config_entries.async_unload(config_entry.entry_id) assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 1b867cea584..bec074362ca 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -781,7 +781,7 @@ async def test_history_during_period_significant_domain( time_zone, ) -> None: """Test history_during_period with climate domain.""" - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) now = dt_util.utcnow() await async_setup_component(hass, "history", {}) diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 8ff3c91a3fc..e5c33d0e7af 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -241,7 +241,7 @@ async def test_history_during_period_significant_domain( time_zone, ) -> None: """Test history_during_period with climate domain.""" - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) now = dt_util.utcnow() await async_setup_component(hass, "history", {}) @@ -466,16 +466,24 @@ async def test_history_stream_historical_only( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.three", "off", attributes={"any": "changed"}) - sensor_three_last_updated = hass.states.get("sensor.three").last_updated + sensor_three_last_updated_timestamp = hass.states.get( + "sensor.three" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.four", "off", attributes={"any": "again"}) - sensor_four_last_updated = hass.states.get("sensor.four").last_updated + sensor_four_last_updated_timestamp = hass.states.get( + "sensor.four" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -506,17 +514,27 @@ async def test_history_stream_historical_only( assert response == { "event": { - "end_time": sensor_four_last_updated.timestamp(), - "start_time": now.timestamp(), + "end_time": pytest.approx(sensor_four_last_updated_timestamp), + "start_time": pytest.approx(now.timestamp()), "states": { "sensor.four": [ - {"lu": sensor_four_last_updated.timestamp(), "s": "off"} + { + "lu": pytest.approx(sensor_four_last_updated_timestamp), + "s": "off", + } + ], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on"} ], - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], "sensor.three": [ - {"lu": sensor_three_last_updated.timestamp(), "s": "off"} + { + "lu": pytest.approx(sensor_three_last_updated_timestamp), + "s": "off", + } + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off"} ], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -817,10 +835,14 @@ async def test_history_stream_live_no_attributes_minimal_response( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -846,15 +868,19 @@ async def test_history_stream_live_no_attributes_minimal_response( assert response["type"] == "result" response = await client.receive_json() - first_end_time = sensor_two_last_updated.timestamp() + first_end_time = sensor_two_last_updated_timestamp assert response == { "event": { - "end_time": first_end_time, - "start_time": now.timestamp(), + "end_time": pytest.approx(first_end_time), + "start_time": pytest.approx(now.timestamp()), "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off"} + ], }, }, "id": 1, @@ -866,14 +892,22 @@ async def test_history_stream_live_no_attributes_minimal_response( hass.states.async_set("sensor.two", "two", attributes={"any": "attr"}) await async_recorder_block_till_done(hass) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp response = await client.receive_json() assert response == { "event": { "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "one"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "two"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "one"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "two"} + ], }, }, "id": 1, @@ -894,10 +928,14 @@ async def test_history_stream_live( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -923,24 +961,24 @@ async def test_history_stream_live( assert response["type"] == "result" response = await client.receive_json() - first_end_time = sensor_two_last_updated.timestamp() + first_end_time = sensor_two_last_updated_timestamp assert response == { "event": { - "end_time": first_end_time, - "start_time": now.timestamp(), + "end_time": pytest.approx(first_end_time), + "start_time": pytest.approx(now.timestamp()), "states": { "sensor.one": [ { "a": {"any": "attr"}, - "lu": sensor_one_last_updated.timestamp(), + "lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on", } ], "sensor.two": [ { "a": {"any": "attr"}, - "lu": sensor_two_last_updated.timestamp(), + "lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off", } ], @@ -955,24 +993,30 @@ async def test_history_stream_live( hass.states.async_set("sensor.two", "two", attributes={"any": "attr"}) await async_recorder_block_till_done(hass) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated - sensor_one_last_changed = hass.states.get("sensor.one").last_changed - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp + sensor_one_last_changed_timestamp = hass.states.get( + "sensor.one" + ).last_changed_timestamp + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp response = await client.receive_json() assert response == { "event": { "states": { "sensor.one": [ { - "lc": sensor_one_last_changed.timestamp(), - "lu": sensor_one_last_updated.timestamp(), + "lc": pytest.approx(sensor_one_last_changed_timestamp), + "lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on", "a": {"diff": "attr"}, } ], "sensor.two": [ { - "lu": sensor_two_last_updated.timestamp(), + "lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "two", "a": {"any": "attr"}, } @@ -997,10 +1041,14 @@ async def test_history_stream_live_minimal_response( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -1026,24 +1074,24 @@ async def test_history_stream_live_minimal_response( assert response["type"] == "result" response = await client.receive_json() - first_end_time = sensor_two_last_updated.timestamp() + first_end_time = sensor_two_last_updated_timestamp assert response == { "event": { - "end_time": first_end_time, + "end_time": pytest.approx(first_end_time), "start_time": now.timestamp(), "states": { "sensor.one": [ { "a": {"any": "attr"}, - "lu": sensor_one_last_updated.timestamp(), + "lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on", } ], "sensor.two": [ { "a": {"any": "attr"}, - "lu": sensor_two_last_updated.timestamp(), + "lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off", } ], @@ -1057,8 +1105,12 @@ async def test_history_stream_live_minimal_response( hass.states.async_set("sensor.one", "on", attributes={"diff": "attr"}) hass.states.async_set("sensor.two", "two", attributes={"any": "attr"}) # Only sensor.two has changed - sensor_one_last_updated = hass.states.get("sensor.one").last_updated - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp hass.states.async_remove("sensor.one") hass.states.async_remove("sensor.two") await async_recorder_block_till_done(hass) @@ -1069,7 +1121,7 @@ async def test_history_stream_live_minimal_response( "states": { "sensor.two": [ { - "lu": sensor_two_last_updated.timestamp(), + "lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "two", "a": {"any": "attr"}, } @@ -1094,10 +1146,14 @@ async def test_history_stream_live_no_attributes( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -1123,18 +1179,26 @@ async def test_history_stream_live_no_attributes( assert response["type"] == "result" response = await client.receive_json() - first_end_time = sensor_two_last_updated.timestamp() + first_end_time = sensor_two_last_updated_timestamp assert response == { "event": { - "end_time": first_end_time, - "start_time": now.timestamp(), + "end_time": pytest.approx(first_end_time), + "start_time": pytest.approx(now.timestamp()), "states": { "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + { + "a": {}, + "lu": pytest.approx(sensor_one_last_updated_timestamp), + "s": "on", + } ], "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} + { + "a": {}, + "lu": pytest.approx(sensor_two_last_updated_timestamp), + "s": "off", + } ], }, }, @@ -1147,14 +1211,22 @@ async def test_history_stream_live_no_attributes( hass.states.async_set("sensor.two", "two", attributes={"diff": "attr"}) await async_recorder_block_till_done(hass) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp response = await client.receive_json() assert response == { "event": { "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "one"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "two"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "one"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "two"} + ], }, }, "id": 1, @@ -1176,10 +1248,14 @@ async def test_history_stream_live_no_attributes_minimal_response_specific_entit await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -1205,15 +1281,19 @@ async def test_history_stream_live_no_attributes_minimal_response_specific_entit assert response["type"] == "result" response = await client.receive_json() - first_end_time = sensor_two_last_updated.timestamp() + first_end_time = sensor_two_last_updated_timestamp assert response == { "event": { - "end_time": first_end_time, - "start_time": now.timestamp(), + "end_time": pytest.approx(first_end_time), + "start_time": pytest.approx(now.timestamp()), "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off"} + ], }, }, "id": 1, @@ -1225,14 +1305,22 @@ async def test_history_stream_live_no_attributes_minimal_response_specific_entit hass.states.async_set("sensor.two", "two", attributes={"any": "attr"}) await async_recorder_block_till_done(hass) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp response = await client.receive_json() assert response == { "event": { "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "one"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "two"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "one"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "two"} + ], }, }, "id": 1, @@ -1254,10 +1342,14 @@ async def test_history_stream_live_with_future_end_time( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -1287,15 +1379,19 @@ async def test_history_stream_live_with_future_end_time( assert response["type"] == "result" response = await client.receive_json() - first_end_time = sensor_two_last_updated.timestamp() + first_end_time = sensor_two_last_updated_timestamp assert response == { "event": { - "end_time": first_end_time, - "start_time": now.timestamp(), + "end_time": pytest.approx(first_end_time), + "start_time": pytest.approx(now.timestamp()), "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off"} + ], }, }, "id": 1, @@ -1307,14 +1403,22 @@ async def test_history_stream_live_with_future_end_time( hass.states.async_set("sensor.two", "two", attributes={"any": "attr"}) await async_recorder_block_till_done(hass) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp response = await client.receive_json() assert response == { "event": { "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "one"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "two"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "one"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "two"} + ], }, }, "id": 1, @@ -1450,10 +1554,14 @@ async def test_overflow_queue( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -1481,18 +1589,24 @@ async def test_overflow_queue( assert response["type"] == "result" response = await client.receive_json() - first_end_time = sensor_two_last_updated.timestamp() + first_end_time = sensor_two_last_updated_timestamp assert response == { "event": { - "end_time": first_end_time, - "start_time": now.timestamp(), + "end_time": pytest.approx(first_end_time), + "start_time": pytest.approx(now.timestamp()), "states": { "sensor.one": [ - {"lu": sensor_one_last_updated.timestamp(), "s": "on"} + { + "lu": pytest.approx(sensor_one_last_updated_timestamp), + "s": "on", + } ], "sensor.two": [ - {"lu": sensor_two_last_updated.timestamp(), "s": "off"} + { + "lu": pytest.approx(sensor_two_last_updated_timestamp), + "s": "off", + } ], }, }, @@ -1522,10 +1636,14 @@ async def test_history_during_period_for_invalid_entity_ids( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.three", "off", attributes={"any": "again"}) await async_recorder_block_till_done(hass) @@ -1550,7 +1668,11 @@ async def test_history_during_period_for_invalid_entity_ids( assert response == { "result": { "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + { + "a": {}, + "lu": pytest.approx(sensor_one_last_updated_timestamp), + "s": "on", + } ], }, "id": 1, @@ -1574,10 +1696,18 @@ async def test_history_during_period_for_invalid_entity_ids( assert response == { "result": { "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + { + "a": {}, + "lu": pytest.approx(sensor_one_last_updated_timestamp), + "s": "on", + } ], "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} + { + "a": {}, + "lu": pytest.approx(sensor_two_last_updated_timestamp), + "s": "off", + } ], }, "id": 2, @@ -1670,10 +1800,14 @@ async def test_history_stream_for_invalid_entity_ids( await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) - sensor_one_last_updated = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.three", "off", attributes={"any": "again"}) await async_recorder_block_till_done(hass) @@ -1703,10 +1837,12 @@ async def test_history_stream_for_invalid_entity_ids( response = await client.receive_json() assert response == { "event": { - "end_time": sensor_one_last_updated.timestamp(), - "start_time": now.timestamp(), + "end_time": pytest.approx(sensor_one_last_updated_timestamp), + "start_time": pytest.approx(now.timestamp()), "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on"} + ], }, }, "id": 1, @@ -1733,11 +1869,15 @@ async def test_history_stream_for_invalid_entity_ids( response = await client.receive_json() assert response == { "event": { - "end_time": sensor_two_last_updated.timestamp(), - "start_time": now.timestamp(), + "end_time": pytest.approx(sensor_two_last_updated_timestamp), + "start_time": pytest.approx(now.timestamp()), "states": { - "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], + "sensor.one": [ + {"lu": pytest.approx(sensor_one_last_updated_timestamp), "s": "on"} + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off"} + ], }, }, "id": 2, @@ -1841,21 +1981,31 @@ async def test_history_stream_historical_only_with_start_time_state_past( now = dt_util.utcnow() await async_recorder_block_till_done(hass) hass.states.async_set("sensor.one", "second", attributes={"any": "attr"}) - sensor_one_last_updated_second = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_second_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await asyncio.sleep(0.00001) hass.states.async_set("sensor.one", "third", attributes={"any": "attr"}) - sensor_one_last_updated_third = hass.states.get("sensor.one").last_updated + sensor_one_last_updated_third_timestamp = hass.states.get( + "sensor.one" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) - sensor_two_last_updated = hass.states.get("sensor.two").last_updated + sensor_two_last_updated_timestamp = hass.states.get( + "sensor.two" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.three", "off", attributes={"any": "changed"}) - sensor_three_last_updated = hass.states.get("sensor.three").last_updated + sensor_three_last_updated_timestamp = hass.states.get( + "sensor.three" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("sensor.four", "off", attributes={"any": "again"}) - sensor_four_last_updated = hass.states.get("sensor.four").last_updated + sensor_four_last_updated_timestamp = hass.states.get( + "sensor.four" + ).last_updated_timestamp await async_recorder_block_till_done(hass) hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) await async_wait_recording_done(hass) @@ -1885,24 +2035,38 @@ async def test_history_stream_historical_only_with_start_time_state_past( assert response == { "event": { - "end_time": sensor_four_last_updated.timestamp(), - "start_time": now.timestamp(), + "end_time": pytest.approx(sensor_four_last_updated_timestamp), + "start_time": pytest.approx(now.timestamp()), "states": { "sensor.four": [ - {"lu": sensor_four_last_updated.timestamp(), "s": "off"} + { + "lu": pytest.approx(sensor_four_last_updated_timestamp), + "s": "off", + } ], "sensor.one": [ { - "lu": now.timestamp(), + "lu": pytest.approx(now.timestamp()), "s": "first", }, # should use start time state - {"lu": sensor_one_last_updated_second.timestamp(), "s": "second"}, - {"lu": sensor_one_last_updated_third.timestamp(), "s": "third"}, + { + "lu": pytest.approx(sensor_one_last_updated_second_timestamp), + "s": "second", + }, + { + "lu": pytest.approx(sensor_one_last_updated_third_timestamp), + "s": "third", + }, ], "sensor.three": [ - {"lu": sensor_three_last_updated.timestamp(), "s": "off"} + { + "lu": pytest.approx(sensor_three_last_updated_timestamp), + "s": "off", + } + ], + "sensor.two": [ + {"lu": pytest.approx(sensor_two_last_updated_timestamp), "s": "off"} ], - "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 4b4592c2104..c18fb2ff784 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -591,7 +591,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin hass: HomeAssistant, ) -> None: """Test we startup from history and switch to watching state changes.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") utcnow = dt_util.utcnow() start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) @@ -692,7 +692,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin hass: HomeAssistant, ) -> None: """Test we startup from history and switch to watching state changes with an expanding end time.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") utcnow = dt_util.utcnow() start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) @@ -809,7 +809,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul hass: HomeAssistant, ) -> None: """Test we startup from history and switch to watching state changes.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") utcnow = dt_util.utcnow() start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) @@ -950,7 +950,7 @@ async def test_does_not_work_into_the_future( Verifies we do not regress https://github.com/home-assistant/core/pull/20589 """ - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") utcnow = dt_util.utcnow() start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) @@ -1357,7 +1357,7 @@ async def test_measure_from_end_going_backwards( async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the history statistics sensor measure with a non-UTC timezone.""" - hass.config.set_time_zone("Europe/Berlin") + await hass.config.async_set_time_zone("Europe/Berlin") start_time = dt_util.utcnow() - timedelta(minutes=60) t0 = start_time + timedelta(minutes=20) t1 = t0 + timedelta(minutes=10) @@ -1446,7 +1446,7 @@ async def test_end_time_with_microseconds_zeroed( hass: HomeAssistant, ) -> None: """Test the history statistics sensor that has the end time microseconds zeroed out.""" - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) start_of_today = dt_util.now().replace( day=9, month=7, year=1986, hour=0, minute=0, second=0, microsecond=0 ) @@ -1650,7 +1650,7 @@ async def test_history_stats_handles_floored_timestamps( hass: HomeAssistant, ) -> None: """Test we account for microseconds when doing the data calculation.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") utcnow = dt_util.utcnow() start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) last_times = None diff --git a/tests/components/holiday/conftest.py b/tests/components/holiday/conftest.py index 92f46c8b238..1ac595aa1f9 100644 --- a/tests/components/holiday/conftest.py +++ b/tests/components/holiday/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the Holiday tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.holiday.async_setup_entry", return_value=True diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 5107fb44d69..f4c19320826 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -98,7 +98,7 @@ def mock_bypass_throttle(): """Fixture to bypass the throttle decorator in __init__.""" with patch( "homeassistant.components.home_connect.update_all_devices", - side_effect=lambda x, y: bypass_throttle(x, y), + side_effect=bypass_throttle, ): yield @@ -131,7 +131,7 @@ def mock_get_appliances() -> Generator[None, Any, None]: @pytest.fixture(name="appliance") -def mock_appliance(request) -> Mock: +def mock_appliance(request: pytest.FixtureRequest) -> MagicMock: """Fixture to mock Appliance.""" app = "Washer" if hasattr(request, "param") and request.param: diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py new file mode 100644 index 00000000000..d21aec35045 --- /dev/null +++ b/tests/components/home_connect/test_binary_sensor.py @@ -0,0 +1,74 @@ +"""Tests for home_connect binary_sensor entities.""" + +from collections.abc import Awaitable, Callable, Generator +from typing import Any +from unittest.mock import MagicMock, Mock + +import pytest + +from homeassistant.components.home_connect.const import ( + BSH_DOOR_STATE, + BSH_DOOR_STATE_CLOSED, + BSH_DOOR_STATE_LOCKED, + BSH_DOOR_STATE_OPEN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BINARY_SENSOR] + + +async def test_binary_sensors( + bypass_throttle: Generator[None, Any, None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + appliance: Mock, +) -> None: + """Test binary sensor entities.""" + get_appliances.return_value = [appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("state", "expected"), + [ + (BSH_DOOR_STATE_CLOSED, "off"), + (BSH_DOOR_STATE_LOCKED, "off"), + (BSH_DOOR_STATE_OPEN, "on"), + ("", "unavailable"), + ], +) +async def test_binary_sensors_door_states( + expected: str, + state: str, + bypass_throttle: Generator[None, Any, None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, + appliance: Mock, +) -> None: + """Tests for Appliance door states.""" + entity_id = "binary_sensor.washer_door" + get_appliances.return_value = [appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + appliance.status.update({BSH_DOOR_STATE: {"value": state}}) + await async_update_entity(hass, entity_id) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected) diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 2c094c74246..80f53e20b39 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -3,6 +3,8 @@ from http import HTTPStatus from unittest.mock import patch +import pytest + from homeassistant import config_entries, setup from homeassistant.components.application_credentials import ( ClientCredential, @@ -24,11 +26,11 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" assert await setup.async_setup_component(hass, "home_connect", {}) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index e304e2947d5..616a82edebc 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -118,6 +118,7 @@ SERVICE_APPLIANCE_METHOD_MAPPING = { async def test_api_setup( + bypass_throttle: Generator[None, Any, None], hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[], Awaitable[bool]], @@ -250,6 +251,7 @@ async def test_services( service_call: list[dict[str, Any]], bypass_throttle: Generator[None, Any, None], hass: HomeAssistant, + device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, integration_setup: Callable[[], Awaitable[bool]], setup_credentials: None, @@ -262,7 +264,6 @@ async def test_services( assert await integration_setup() assert config_entry.state == ConfigEntryState.LOADED - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, appliance.haId)}, @@ -295,7 +296,7 @@ async def test_services_exception( service_call = SERVICE_KV_CALL_PARAMS[0] + service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" + with pytest.raises(ValueError): - service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS" await hass.services.async_call(**service_call) - await hass.async_block_till diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 9a14198b1ef..b3ff6594509 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -57,9 +57,12 @@ def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]: entry_sensor_temperature = entity_registry.async_get_or_create( "sensor", "test", - "unique2", + "unique3", original_device_class="temperature", ) + entry_media_player = entity_registry.async_get_or_create( + "media_player", "test", "unique4", original_device_class="media_player" + ) return { "blocked": entry_blocked.entity_id, "lock": entry_lock.entity_id, @@ -67,6 +70,7 @@ def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]: "door_sensor": entry_binary_sensor_door.entity_id, "sensor": entry_sensor.entity_id, "temperature_sensor": entry_sensor_temperature.entity_id, + "media_player": entry_media_player.entity_id, } @@ -78,10 +82,12 @@ def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]: door_sensor = "binary_sensor.door" sensor = "sensor.test" sensor_temperature = "sensor.temperature" + media_player = "media_player.test" hass.states.async_set(binary_sensor, "on", {}) hass.states.async_set(door_sensor, "on", {"device_class": "door"}) hass.states.async_set(sensor, "on", {}) hass.states.async_set(sensor_temperature, "on", {"device_class": "temperature"}) + hass.states.async_set(media_player, "idle", {}) return { "blocked": blocked, "lock": lock, @@ -89,6 +95,7 @@ def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]: "door_sensor": door_sensor, "sensor": sensor, "temperature_sensor": sensor_temperature, + "media_player": media_player, } @@ -409,8 +416,8 @@ async def test_should_expose( # Blocked entity is not exposed assert async_should_expose(hass, "cloud.alexa", entities["blocked"]) is False - # Lock is exposed - assert async_should_expose(hass, "cloud.alexa", entities["lock"]) is True + # Lock is not exposed + assert async_should_expose(hass, "cloud.alexa", entities["lock"]) is False # Binary sensor without device class is not exposed assert async_should_expose(hass, "cloud.alexa", entities["binary_sensor"]) is False @@ -426,6 +433,9 @@ async def test_should_expose( async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True ) + # Media player is exposed + assert async_should_expose(hass, "cloud.alexa", entities["media_player"]) is True + # The second time we check, it should load it from storage assert ( async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 84319df2888..d090da280a0 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -355,7 +355,6 @@ async def test_require_admin( context=ha.Context(user_id=hass_read_only_user.id), blocking=True, ) - pytest.fail(f"Should have raises for {service}") with pytest.raises(Unauthorized): await hass.services.async_call( @@ -485,8 +484,8 @@ async def test_raises_when_db_upgrade_in_progress( service, blocking=True, ) - assert "The system cannot" in caplog.text - assert "while a database upgrade in progress" in caplog.text + assert "The system cannot" in caplog.text + assert "while a database upgrade is in progress" in caplog.text assert mock_async_migration_in_progress.called caplog.clear() @@ -530,9 +529,9 @@ async def test_raises_when_config_is_invalid( SERVICE_HOMEASSISTANT_RESTART, blocking=True, ) - assert "The system cannot" in caplog.text - assert "because the configuration is not valid" in caplog.text - assert "Error 1" in caplog.text + assert "The system cannot" in caplog.text + assert "because the configuration is not valid" in caplog.text + assert "Error 1" in caplog.text assert mock_async_check_ha_config_file.called caplog.clear() diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py index a1a532db162..3055f6b21b1 100644 --- a/tests/components/homeassistant/test_scene.py +++ b/tests/components/homeassistant/test_scene.py @@ -198,7 +198,6 @@ async def test_delete_service( }, blocking=True, ) - await hass.async_block_till_done() with pytest.raises(ServiceValidationError): await hass.services.async_call( @@ -209,7 +208,6 @@ async def test_delete_service( }, blocking=True, ) - await hass.async_block_till_done() assert hass.states.get("scene.hallo_2") is not None assert hass.states.get("scene.hallo") is not None @@ -303,7 +301,6 @@ async def test_ensure_no_intersection(hass: HomeAssistant) -> None: }, blocking=True, ) - await hass.async_block_till_done() assert "entities and snapshot_entities must not overlap" in str(ex.value) assert hass.states.get("scene.hallo") is None diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 451f35f66fe..b7bf8e5e7f3 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -4,14 +4,14 @@ import pytest from homeassistant.components import automation from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -28,7 +28,7 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_on_event(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_event(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the firing of events.""" context = Context() @@ -64,7 +64,9 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls) -> None: assert calls[0].data["id"] == 0 -async def test_if_fires_on_templated_event(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_templated_event( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the firing of events.""" context = Context() @@ -97,7 +99,9 @@ async def test_if_fires_on_templated_event(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_on_multiple_events(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_multiple_events( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the firing of events.""" context = Context() @@ -125,7 +129,7 @@ async def test_if_fires_on_multiple_events(hass: HomeAssistant, calls) -> None: async def test_if_fires_on_event_extra_data( - hass: HomeAssistant, calls, context_with_user + hass: HomeAssistant, calls: list[ServiceCall], context_with_user: Context ) -> None: """Test the firing of events still matches with event data and context.""" assert await async_setup_component( @@ -157,7 +161,7 @@ async def test_if_fires_on_event_extra_data( async def test_if_fires_on_event_with_data_and_context( - hass: HomeAssistant, calls, context_with_user + hass: HomeAssistant, calls: list[ServiceCall], context_with_user: Context ) -> None: """Test the firing of events with data and context.""" assert await async_setup_component( @@ -204,7 +208,7 @@ async def test_if_fires_on_event_with_data_and_context( async def test_if_fires_on_event_with_templated_data_and_context( - hass: HomeAssistant, calls, context_with_user + hass: HomeAssistant, calls: list[ServiceCall], context_with_user: Context ) -> None: """Test the firing of events with templated data and context.""" assert await async_setup_component( @@ -256,7 +260,7 @@ async def test_if_fires_on_event_with_templated_data_and_context( async def test_if_fires_on_event_with_empty_data_and_context_config( - hass: HomeAssistant, calls, context_with_user + hass: HomeAssistant, calls: list[ServiceCall], context_with_user: Context ) -> None: """Test the firing of events with empty data and context config. @@ -288,7 +292,9 @@ async def test_if_fires_on_event_with_empty_data_and_context_config( assert len(calls) == 1 -async def test_if_fires_on_event_with_nested_data(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_event_with_nested_data( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the firing of events with nested data. This test exercises the slow path of using vol.Schema to validate @@ -316,7 +322,9 @@ async def test_if_fires_on_event_with_nested_data(hass: HomeAssistant, calls) -> assert len(calls) == 1 -async def test_if_fires_on_event_with_empty_data(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_event_with_empty_data( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the firing of events with empty data. This test exercises the fast path to validate matching event data. @@ -340,7 +348,9 @@ async def test_if_fires_on_event_with_empty_data(hass: HomeAssistant, calls) -> assert len(calls) == 1 -async def test_if_fires_on_sample_zha_event(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_sample_zha_event( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the firing of events with a sample zha event. This test exercises the fast path to validate matching event data. @@ -398,7 +408,7 @@ async def test_if_fires_on_sample_zha_event(hass: HomeAssistant, calls) -> None: async def test_if_not_fires_if_event_data_not_matches( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test firing of event if no data match.""" assert await async_setup_component( @@ -422,7 +432,7 @@ async def test_if_not_fires_if_event_data_not_matches( async def test_if_not_fires_if_event_context_not_matches( - hass: HomeAssistant, calls, context_with_user + hass: HomeAssistant, calls: list[ServiceCall], context_with_user: Context ) -> None: """Test firing of event if no context match.""" assert await async_setup_component( @@ -446,7 +456,7 @@ async def test_if_not_fires_if_event_context_not_matches( async def test_if_fires_on_multiple_user_ids( - hass: HomeAssistant, calls, context_with_user + hass: HomeAssistant, calls: list[ServiceCall], context_with_user: Context ) -> None: """Test the firing of event when the trigger has multiple user ids. @@ -474,7 +484,9 @@ async def test_if_fires_on_multiple_user_ids( assert len(calls) == 1 -async def test_event_data_with_list(hass: HomeAssistant, calls) -> None: +async def test_event_data_with_list( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the (non)firing of event when the data schema has lists.""" assert await async_setup_component( hass, @@ -511,7 +523,10 @@ async def test_event_data_with_list(hass: HomeAssistant, calls) -> None: "event_type", ["state_reported", ["test_event", "state_reported"]] ) async def test_state_reported_event( - hass: HomeAssistant, calls, caplog, event_type: list[str] + hass: HomeAssistant, + calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, + event_type: str | list[str], ) -> None: """Test triggering on state reported event.""" context = Context() @@ -541,7 +556,7 @@ async def test_state_reported_event( async def test_templated_state_reported_event( - hass: HomeAssistant, calls, caplog + hass: HomeAssistant, calls: list[ServiceCall], caplog: pytest.LogCaptureFixture ) -> None: """Test triggering on state reported event.""" context = Context() diff --git a/tests/components/homeassistant/triggers/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py index 2afb533cdc0..9c552a0324b 100644 --- a/tests/components/homeassistant/triggers/test_homeassistant.py +++ b/tests/components/homeassistant/triggers/test_homeassistant.py @@ -27,8 +27,9 @@ from tests.common import async_mock_service } ], ) +@pytest.mark.usefixtures("mock_hass_config") async def test_if_fires_on_hass_start( - hass: HomeAssistant, mock_hass_config: None, hass_config: ConfigType + hass: HomeAssistant, hass_config: ConfigType ) -> None: """Test the firing when Home Assistant starts.""" calls = async_mock_service(hass, "test", "automation") diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 2e2dca5b57a..59cd7e2a2a7 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -18,7 +18,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -32,7 +32,7 @@ from tests.common import ( @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -63,7 +63,7 @@ async def setup_comp(hass): "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_not_fires_on_entity_removal( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test the firing with removed entity.""" hass.states.async_set("test.entity", 11) @@ -93,7 +93,7 @@ async def test_if_not_fires_on_entity_removal( "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_fires_on_entity_change_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) @@ -142,7 +142,10 @@ async def test_if_fires_on_entity_change_below( "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_fires_on_entity_change_below_uuid( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls, below + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], + below: int | str, ) -> None: """Test the firing with changed entity specified by registry entry id.""" entry = entity_registry.async_get_or_create( @@ -196,7 +199,7 @@ async def test_if_fires_on_entity_change_below_uuid( "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_fires_on_entity_change_over_to_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) @@ -227,7 +230,7 @@ async def test_if_fires_on_entity_change_over_to_below( "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_fires_on_entities_change_over_to_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test the firing with changed entities.""" hass.states.async_set("test.entity_1", 11) @@ -262,7 +265,7 @@ async def test_if_fires_on_entities_change_over_to_below( "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_not_fires_on_entity_change_below_to_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test the firing with changed entity.""" context = Context() @@ -305,7 +308,7 @@ async def test_if_not_fires_on_entity_change_below_to_below( "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_not_below_fires_on_entity_change_to_equal( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) @@ -336,7 +339,7 @@ async def test_if_not_below_fires_on_entity_change_to_equal( "below", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_not_fires_on_initial_entity_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test the firing when starting with a match.""" hass.states.async_set("test.entity", 9) @@ -367,7 +370,7 @@ async def test_if_not_fires_on_initial_entity_below( "above", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_not_fires_on_initial_entity_above( - hass: HomeAssistant, calls, above + hass: HomeAssistant, calls: list[ServiceCall], above: int | str ) -> None: """Test the firing when starting with a match.""" hass.states.async_set("test.entity", 11) @@ -398,7 +401,7 @@ async def test_if_not_fires_on_initial_entity_above( "above", [10, "input_number.value_10", "number.value_10", "sensor.value_10"] ) async def test_if_fires_on_entity_change_above( - hass: HomeAssistant, calls, above + hass: HomeAssistant, calls: list[ServiceCall], above: int | str ) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 9) @@ -425,7 +428,7 @@ async def test_if_fires_on_entity_change_above( async def test_if_fires_on_entity_unavailable_at_startup( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test the firing with changed entity at startup.""" assert await async_setup_component( @@ -450,7 +453,7 @@ async def test_if_fires_on_entity_unavailable_at_startup( @pytest.mark.parametrize("above", [10, "input_number.value_10"]) async def test_if_fires_on_entity_change_below_to_above( - hass: HomeAssistant, calls, above + hass: HomeAssistant, calls: list[ServiceCall], above: int | str ) -> None: """Test the firing with changed entity.""" # set initial state @@ -480,7 +483,7 @@ async def test_if_fires_on_entity_change_below_to_above( @pytest.mark.parametrize("above", [10, "input_number.value_10"]) async def test_if_not_fires_on_entity_change_above_to_above( - hass: HomeAssistant, calls, above + hass: HomeAssistant, calls: list[ServiceCall], above: int | str ) -> None: """Test the firing with changed entity.""" # set initial state @@ -515,7 +518,7 @@ async def test_if_not_fires_on_entity_change_above_to_above( @pytest.mark.parametrize("above", [10, "input_number.value_10"]) async def test_if_not_above_fires_on_entity_change_to_equal( - hass: HomeAssistant, calls, above + hass: HomeAssistant, calls: list[ServiceCall], above: int | str ) -> None: """Test the firing with changed entity.""" # set initial state @@ -553,7 +556,7 @@ async def test_if_not_above_fires_on_entity_change_to_equal( ], ) async def test_if_fires_on_entity_change_below_range( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) @@ -590,7 +593,7 @@ async def test_if_fires_on_entity_change_below_range( ], ) async def test_if_fires_on_entity_change_below_above_range( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test the firing with changed entity.""" assert await async_setup_component( @@ -624,7 +627,7 @@ async def test_if_fires_on_entity_change_below_above_range( ], ) async def test_if_fires_on_entity_change_over_to_below_range( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) @@ -662,7 +665,7 @@ async def test_if_fires_on_entity_change_over_to_below_range( ], ) async def test_if_fires_on_entity_change_over_to_below_above_range( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test the firing with changed entity.""" hass.states.async_set("test.entity", 11) @@ -692,7 +695,7 @@ async def test_if_fires_on_entity_change_over_to_below_above_range( @pytest.mark.parametrize("below", [100, "input_number.value_100"]) async def test_if_not_fires_if_entity_not_match( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test if not fired with non matching entity.""" assert await async_setup_component( @@ -716,7 +719,7 @@ async def test_if_not_fires_if_entity_not_match( async def test_if_not_fires_and_warns_if_below_entity_unknown( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, calls + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, calls: list[ServiceCall] ) -> None: """Test if warns with unknown below entity.""" assert await async_setup_component( @@ -747,7 +750,7 @@ async def test_if_not_fires_and_warns_if_below_entity_unknown( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) async def test_if_fires_on_entity_change_below_with_attribute( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test attributes change.""" hass.states.async_set("test.entity", 11, {"test_attribute": 11}) @@ -775,7 +778,7 @@ async def test_if_fires_on_entity_change_below_with_attribute( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) async def test_if_not_fires_on_entity_change_not_below_with_attribute( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test attributes.""" assert await async_setup_component( @@ -800,7 +803,7 @@ async def test_if_not_fires_on_entity_change_not_below_with_attribute( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) async def test_if_fires_on_attribute_change_with_attribute_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test attributes change.""" hass.states.async_set("test.entity", "entity", {"test_attribute": 11}) @@ -829,7 +832,7 @@ async def test_if_fires_on_attribute_change_with_attribute_below( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) async def test_if_not_fires_on_attribute_change_with_attribute_not_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test attributes change.""" assert await async_setup_component( @@ -855,7 +858,7 @@ async def test_if_not_fires_on_attribute_change_with_attribute_not_below( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) async def test_if_not_fires_on_entity_change_with_attribute_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test attributes change.""" assert await async_setup_component( @@ -881,7 +884,7 @@ async def test_if_not_fires_on_entity_change_with_attribute_below( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) async def test_if_not_fires_on_entity_change_with_not_attribute_below( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test attributes change.""" assert await async_setup_component( @@ -907,7 +910,7 @@ async def test_if_not_fires_on_entity_change_with_not_attribute_below( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) async def test_fires_on_attr_change_with_attribute_below_and_multiple_attr( - hass: HomeAssistant, calls, below + hass: HomeAssistant, calls: list[ServiceCall], below: int | str ) -> None: """Test attributes change.""" hass.states.async_set( @@ -938,7 +941,9 @@ async def test_fires_on_attr_change_with_attribute_below_and_multiple_attr( @pytest.mark.parametrize("below", [10, "input_number.value_10"]) -async def test_template_list(hass: HomeAssistant, calls, below) -> None: +async def test_template_list( + hass: HomeAssistant, calls: list[ServiceCall], below: int | str +) -> None: """Test template list.""" hass.states.async_set("test.entity", "entity", {"test_attribute": [11, 15, 11]}) await hass.async_block_till_done() @@ -964,7 +969,9 @@ async def test_template_list(hass: HomeAssistant, calls, below) -> None: @pytest.mark.parametrize("below", [10.0, "input_number.value_10"]) -async def test_template_string(hass: HomeAssistant, calls, below) -> None: +async def test_template_string( + hass: HomeAssistant, calls: list[ServiceCall], below: float | str +) -> None: """Test template string.""" assert await async_setup_component( hass, @@ -1005,7 +1012,7 @@ async def test_template_string(hass: HomeAssistant, calls, below) -> None: async def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test if not fired changed attributes.""" assert await async_setup_component( @@ -1040,7 +1047,9 @@ async def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr( ("input_number.value_8", "input_number.value_12"), ], ) -async def test_if_action(hass: HomeAssistant, calls, above, below) -> None: +async def test_if_action( + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str +) -> None: """Test if action.""" entity_id = "domain.test_entity" assert await async_setup_component( @@ -1088,7 +1097,9 @@ async def test_if_action(hass: HomeAssistant, calls, above, below) -> None: ("input_number.value_8", "input_number.value_12"), ], ) -async def test_if_fails_setup_bad_for(hass: HomeAssistant, calls, above, below) -> None: +async def test_if_fails_setup_bad_for( + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str +) -> None: """Test for setup failure for bad for.""" hass.states.async_set("test.entity", 5) await hass.async_block_till_done() @@ -1114,7 +1125,7 @@ async def test_if_fails_setup_bad_for(hass: HomeAssistant, calls, above, below) async def test_if_fails_setup_for_without_above_below( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for setup failures for missing above or below.""" with assert_setup_component(1, automation.DOMAIN): @@ -1145,7 +1156,11 @@ async def test_if_fails_setup_for_without_above_below( ], ) async def test_if_not_fires_on_entity_change_with_for( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], + above: int | str, + below: int | str, ) -> None: """Test for not firing on entity change with for.""" assert await async_setup_component( @@ -1185,7 +1200,7 @@ async def test_if_not_fires_on_entity_change_with_for( ], ) async def test_if_not_fires_on_entities_change_with_for_after_stop( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test for not firing on entities change with for after stop.""" hass.states.async_set("test.entity_1", 0) @@ -1246,7 +1261,11 @@ async def test_if_not_fires_on_entities_change_with_for_after_stop( ], ) async def test_if_fires_on_entity_change_with_for_attribute_change( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], + above: int | str, + below: int | str, ) -> None: """Test for firing on entity change with for and attribute change.""" hass.states.async_set("test.entity", 0) @@ -1292,7 +1311,7 @@ async def test_if_fires_on_entity_change_with_for_attribute_change( ], ) async def test_if_fires_on_entity_change_with_for( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test for firing on entity change with for.""" hass.states.async_set("test.entity", 0) @@ -1323,7 +1342,9 @@ async def test_if_fires_on_entity_change_with_for( @pytest.mark.parametrize("above", [10, "input_number.value_10"]) -async def test_wait_template_with_trigger(hass: HomeAssistant, calls, above) -> None: +async def test_wait_template_with_trigger( + hass: HomeAssistant, calls: list[ServiceCall], above: int | str +) -> None: """Test using wait template with 'trigger.entity_id'.""" hass.states.async_set("test.entity", "0") await hass.async_block_till_done() @@ -1374,7 +1395,11 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls, above) -> ], ) async def test_if_fires_on_entities_change_no_overlap( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], + above: int | str, + below: int | str, ) -> None: """Test for firing on entities change with no overlap.""" hass.states.async_set("test.entity_1", 0) @@ -1429,7 +1454,11 @@ async def test_if_fires_on_entities_change_no_overlap( ], ) async def test_if_fires_on_entities_change_overlap( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], + above: int | str, + below: int | str, ) -> None: """Test for firing on entities change with overlap.""" hass.states.async_set("test.entity_1", 0) @@ -1495,7 +1524,7 @@ async def test_if_fires_on_entities_change_overlap( ], ) async def test_if_fires_on_change_with_for_template_1( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", 0) @@ -1536,7 +1565,7 @@ async def test_if_fires_on_change_with_for_template_1( ], ) async def test_if_fires_on_change_with_for_template_2( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", 0) @@ -1577,7 +1606,7 @@ async def test_if_fires_on_change_with_for_template_2( ], ) async def test_if_fires_on_change_with_for_template_3( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str ) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", 0) @@ -1609,7 +1638,7 @@ async def test_if_fires_on_change_with_for_template_3( async def test_if_not_fires_on_error_with_for_template( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for not firing on error with for template.""" hass.states.async_set("test.entity", 0) @@ -1655,7 +1684,9 @@ async def test_if_not_fires_on_error_with_for_template( ("input_number.value_8", "input_number.value_12"), ], ) -async def test_invalid_for_template(hass: HomeAssistant, calls, above, below) -> None: +async def test_invalid_for_template( + hass: HomeAssistant, calls: list[ServiceCall], above: int | str, below: int | str +) -> None: """Test for invalid for template.""" hass.states.async_set("test.entity", 0) await hass.async_block_till_done() @@ -1693,7 +1724,11 @@ async def test_invalid_for_template(hass: HomeAssistant, calls, above, below) -> ], ) async def test_if_fires_on_entities_change_overlap_for_template( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], + above: int | str, + below: int | str, ) -> None: """Test for firing on entities change with overlap and for template.""" hass.states.async_set("test.entity_1", 0) @@ -1788,7 +1823,7 @@ async def test_schema_unacceptable_entities(hass: HomeAssistant) -> None: @pytest.mark.parametrize("above", [3, "input_number.value_3"]) async def test_attribute_if_fires_on_entity_change_with_both_filters( - hass: HomeAssistant, calls, above + hass: HomeAssistant, calls: list[ServiceCall], above: int | str ) -> None: """Test for firing if both filters are match attribute.""" hass.states.async_set("test.entity", "bla", {"test-measurement": 1}) @@ -1817,7 +1852,7 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters( @pytest.mark.parametrize("above", [3, "input_number.value_3"]) async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( - hass: HomeAssistant, calls, above + hass: HomeAssistant, calls: list[ServiceCall], above: int | str ) -> None: """Test for not firing on entity change with for after stop trigger.""" hass.states.async_set("test.entity", "bla", {"test-measurement": 1}) @@ -1856,7 +1891,11 @@ async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( [(8, 12)], ) async def test_variables_priority( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], + above: int, + below: int, ) -> None: """Test an externally defined trigger variable is overridden.""" hass.states.async_set("test.entity_1", 0) @@ -1911,7 +1950,9 @@ async def test_variables_priority( @pytest.mark.parametrize("multiplier", [1, 5]) -async def test_template_variable(hass: HomeAssistant, calls, multiplier) -> None: +async def test_template_variable( + hass: HomeAssistant, calls: list[ServiceCall], multiplier: int +) -> None: """Test template variable.""" hass.states.async_set("test.entity", "entity", {"test_attribute": [11, 15, 11]}) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index 597ef0ab1a5..a40ecae7579 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -40,7 +40,9 @@ def setup_comp(hass): hass.states.async_set("test.entity", "hello") -async def test_if_fires_on_entity_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_entity_change( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on entity change.""" context = Context() hass.states.async_set("test.entity", "hello") @@ -88,7 +90,7 @@ async def test_if_fires_on_entity_change(hass: HomeAssistant, calls) -> None: async def test_if_fires_on_entity_change_uuid( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] ) -> None: """Test for firing on entity change.""" context = Context() @@ -144,7 +146,7 @@ async def test_if_fires_on_entity_change_uuid( async def test_if_fires_on_entity_change_with_from_filter( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with filter.""" assert await async_setup_component( @@ -199,7 +201,7 @@ async def test_if_fires_on_entity_change_with_not_from_filter( async def test_if_fires_on_entity_change_with_to_filter( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with to filter.""" assert await async_setup_component( @@ -254,7 +256,7 @@ async def test_if_fires_on_entity_change_with_not_to_filter( async def test_if_fires_on_entity_change_with_from_filter_all( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with filter.""" assert await async_setup_component( @@ -280,7 +282,7 @@ async def test_if_fires_on_entity_change_with_from_filter_all( async def test_if_fires_on_entity_change_with_to_filter_all( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with to filter.""" assert await async_setup_component( @@ -306,7 +308,7 @@ async def test_if_fires_on_entity_change_with_to_filter_all( async def test_if_fires_on_attribute_change_with_to_filter( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for not firing on attribute change.""" assert await async_setup_component( @@ -332,7 +334,7 @@ async def test_if_fires_on_attribute_change_with_to_filter( async def test_if_fires_on_entity_change_with_both_filters( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing if both filters are a non match.""" assert await async_setup_component( @@ -451,7 +453,9 @@ async def test_if_fires_on_entity_change_with_from_not_to( assert len(calls) == 2 -async def test_if_not_fires_if_to_filter_not_match(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_if_to_filter_not_match( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for not firing if to filter is not a match.""" assert await async_setup_component( hass, @@ -476,7 +480,7 @@ async def test_if_not_fires_if_to_filter_not_match(hass: HomeAssistant, calls) - async def test_if_not_fires_if_from_filter_not_match( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for not firing if from filter is not a match.""" hass.states.async_set("test.entity", "bye") @@ -503,7 +507,9 @@ async def test_if_not_fires_if_from_filter_not_match( assert len(calls) == 0 -async def test_if_not_fires_if_entity_not_match(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_if_entity_not_match( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for not firing if entity is not matching.""" assert await async_setup_component( hass, @@ -522,7 +528,7 @@ async def test_if_not_fires_if_entity_not_match(hass: HomeAssistant, calls) -> N assert len(calls) == 0 -async def test_if_action(hass: HomeAssistant, calls) -> None: +async def test_if_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test for to action.""" entity_id = "domain.test_entity" test_state = "new_state" @@ -554,7 +560,9 @@ async def test_if_action(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fails_setup_if_to_boolean_value(hass: HomeAssistant, calls) -> None: +async def test_if_fails_setup_if_to_boolean_value( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for setup failure for boolean to.""" with assert_setup_component(1, automation.DOMAIN): assert await async_setup_component( @@ -574,7 +582,9 @@ async def test_if_fails_setup_if_to_boolean_value(hass: HomeAssistant, calls) -> assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE -async def test_if_fails_setup_if_from_boolean_value(hass: HomeAssistant, calls) -> None: +async def test_if_fails_setup_if_from_boolean_value( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for setup failure for boolean from.""" with assert_setup_component(1, automation.DOMAIN): assert await async_setup_component( @@ -594,7 +604,9 @@ async def test_if_fails_setup_if_from_boolean_value(hass: HomeAssistant, calls) assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE -async def test_if_fails_setup_bad_for(hass: HomeAssistant, calls) -> None: +async def test_if_fails_setup_bad_for( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for setup failure for bad for.""" with assert_setup_component(1, automation.DOMAIN): assert await async_setup_component( @@ -616,7 +628,7 @@ async def test_if_fails_setup_bad_for(hass: HomeAssistant, calls) -> None: async def test_if_not_fires_on_entity_change_with_for( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for not firing on entity change with for.""" assert await async_setup_component( @@ -646,7 +658,7 @@ async def test_if_not_fires_on_entity_change_with_for( async def test_if_not_fires_on_entities_change_with_for_after_stop( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for not firing on entity change with for after stop trigger.""" assert await async_setup_component( @@ -695,7 +707,7 @@ async def test_if_not_fires_on_entities_change_with_for_after_stop( async def test_if_fires_on_entity_change_with_for_attribute_change( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with for and attribute change.""" assert await async_setup_component( @@ -731,7 +743,7 @@ async def test_if_fires_on_entity_change_with_for_attribute_change( async def test_if_fires_on_entity_change_with_for_multiple_force_update( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with for and force update.""" assert await async_setup_component( @@ -765,7 +777,9 @@ async def test_if_fires_on_entity_change_with_for_multiple_force_update( assert len(calls) == 1 -async def test_if_fires_on_entity_change_with_for(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_entity_change_with_for( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on entity change with for.""" assert await async_setup_component( hass, @@ -792,7 +806,7 @@ async def test_if_fires_on_entity_change_with_for(hass: HomeAssistant, calls) -> async def test_if_fires_on_entity_change_with_for_without_to( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with for.""" assert await async_setup_component( @@ -831,7 +845,7 @@ async def test_if_fires_on_entity_change_with_for_without_to( async def test_if_does_not_fires_on_entity_change_with_for_without_to_2( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing on entity change with for.""" assert await async_setup_component( @@ -861,7 +875,7 @@ async def test_if_does_not_fires_on_entity_change_with_for_without_to_2( async def test_if_fires_on_entity_creation_and_removal( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on entity creation and removal, with to/from constraints.""" # set automations for multiple combinations to/from @@ -927,7 +941,9 @@ async def test_if_fires_on_entity_creation_and_removal( assert calls[3].context.parent_id == context_0.id -async def test_if_fires_on_for_condition(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_for_condition( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing if condition is on.""" point1 = dt_util.utcnow() point2 = point1 + timedelta(seconds=10) @@ -965,7 +981,7 @@ async def test_if_fires_on_for_condition(hass: HomeAssistant, calls) -> None: async def test_if_fires_on_for_condition_attribute_change( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing if condition is on with attribute change.""" point1 = dt_util.utcnow() @@ -1013,7 +1029,9 @@ async def test_if_fires_on_for_condition_attribute_change( assert len(calls) == 1 -async def test_if_fails_setup_for_without_time(hass: HomeAssistant, calls) -> None: +async def test_if_fails_setup_for_without_time( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for setup failure if no time is provided.""" with assert_setup_component(1, automation.DOMAIN): assert await async_setup_component( @@ -1035,7 +1053,9 @@ async def test_if_fails_setup_for_without_time(hass: HomeAssistant, calls) -> No assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE -async def test_if_fails_setup_for_without_entity(hass: HomeAssistant, calls) -> None: +async def test_if_fails_setup_for_without_entity( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for setup failure if no entity is provided.""" with assert_setup_component(1, automation.DOMAIN): assert await async_setup_component( @@ -1056,7 +1076,9 @@ async def test_if_fails_setup_for_without_entity(hass: HomeAssistant, calls) -> assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE -async def test_wait_template_with_trigger(hass: HomeAssistant, calls) -> None: +async def test_wait_template_with_trigger( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test using wait template with 'trigger.entity_id'.""" assert await async_setup_component( hass, @@ -1096,7 +1118,7 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls) -> None: async def test_if_fires_on_entities_change_no_overlap( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing on entities change with no overlap.""" assert await async_setup_component( @@ -1137,7 +1159,7 @@ async def test_if_fires_on_entities_change_no_overlap( async def test_if_fires_on_entities_change_overlap( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing on entities change with overlap.""" assert await async_setup_component( @@ -1189,7 +1211,7 @@ async def test_if_fires_on_entities_change_overlap( async def test_if_fires_on_change_with_for_template_1( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on change with for template.""" assert await async_setup_component( @@ -1217,7 +1239,7 @@ async def test_if_fires_on_change_with_for_template_1( async def test_if_fires_on_change_with_for_template_2( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on change with for template.""" assert await async_setup_component( @@ -1245,7 +1267,7 @@ async def test_if_fires_on_change_with_for_template_2( async def test_if_fires_on_change_with_for_template_3( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on change with for template.""" assert await async_setup_component( @@ -1273,7 +1295,7 @@ async def test_if_fires_on_change_with_for_template_3( async def test_if_fires_on_change_with_for_template_4( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing on change with for template.""" assert await async_setup_component( @@ -1301,7 +1323,9 @@ async def test_if_fires_on_change_with_for_template_4( assert len(calls) == 1 -async def test_if_fires_on_change_from_with_for(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_change_from_with_for( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on change with from/for.""" assert await async_setup_component( hass, @@ -1330,7 +1354,9 @@ async def test_if_fires_on_change_from_with_for(hass: HomeAssistant, calls) -> N assert len(calls) == 1 -async def test_if_not_fires_on_change_from_with_for(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_on_change_from_with_for( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on change with from/for.""" assert await async_setup_component( hass, @@ -1359,7 +1385,9 @@ async def test_if_not_fires_on_change_from_with_for(hass: HomeAssistant, calls) assert len(calls) == 0 -async def test_invalid_for_template_1(hass: HomeAssistant, calls) -> None: +async def test_invalid_for_template_1( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for invalid for template.""" assert await async_setup_component( hass, @@ -1384,7 +1412,7 @@ async def test_invalid_for_template_1(hass: HomeAssistant, calls) -> None: async def test_if_fires_on_entities_change_overlap_for_template( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing on entities change with overlap and for template.""" assert await async_setup_component( @@ -1443,7 +1471,7 @@ async def test_if_fires_on_entities_change_overlap_for_template( async def test_attribute_if_fires_on_entity_change_with_both_filters( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing if both filters are match attribute.""" hass.states.async_set("test.entity", "bla", {"name": "hello"}) @@ -1472,7 +1500,7 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters( async def test_attribute_if_fires_on_entity_where_attr_stays_constant( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing if attribute stays the same.""" hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"}) @@ -1510,7 +1538,7 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant( async def test_attribute_if_fires_on_entity_where_attr_stays_constant_filter( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing if attribute stays the same.""" hass.states.async_set("test.entity", "bla", {"name": "other_name"}) @@ -1555,7 +1583,7 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant_filter( async def test_attribute_if_fires_on_entity_where_attr_stays_constant_all( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing if attribute stays the same.""" hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"}) @@ -1600,7 +1628,7 @@ async def test_attribute_if_fires_on_entity_where_attr_stays_constant_all( async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for not firing on entity change with for after stop trigger.""" hass.states.async_set("test.entity", "bla", {"name": "hello"}) @@ -1656,7 +1684,7 @@ async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( async def test_attribute_if_fires_on_entity_change_with_both_filters_boolean( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for firing if both filters are match attribute.""" hass.states.async_set("test.entity", "bla", {"happening": False}) @@ -1685,7 +1713,7 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters_boolean( async def test_variables_priority( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test an externally defined trigger variable is overridden.""" assert await async_setup_component( diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 340b2839ab1..961bac6c367 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -16,7 +16,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, STATE_UNAVAILABLE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -29,7 +29,7 @@ from tests.common import ( @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -41,7 +41,7 @@ def setup_comp(hass): async def test_if_fires_using_at( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing at.""" now = dt_util.now() @@ -80,7 +80,11 @@ async def test_if_fires_using_at( ("has_date", "has_time"), [(True, True), (True, False), (False, True)] ) async def test_if_fires_using_at_input_datetime( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, has_date, has_time + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], + has_date, + has_time, ) -> None: """Test for firing at input_datetime.""" await async_setup_component( @@ -161,7 +165,7 @@ async def test_if_fires_using_at_input_datetime( async def test_if_fires_using_multiple_at( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing at.""" @@ -202,7 +206,7 @@ async def test_if_fires_using_multiple_at( async def test_if_not_fires_using_wrong_at( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """YAML translates time values to total seconds. @@ -241,7 +245,7 @@ async def test_if_not_fires_using_wrong_at( assert len(calls) == 0 -async def test_if_action_before(hass: HomeAssistant, calls) -> None: +async def test_if_action_before(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test for if action before.""" assert await async_setup_component( hass, @@ -272,7 +276,7 @@ async def test_if_action_before(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_action_after(hass: HomeAssistant, calls) -> None: +async def test_if_action_after(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test for if action after.""" assert await async_setup_component( hass, @@ -303,7 +307,9 @@ async def test_if_action_after(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_action_one_weekday(hass: HomeAssistant, calls) -> None: +async def test_if_action_one_weekday( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for if action with one weekday.""" assert await async_setup_component( hass, @@ -335,7 +341,9 @@ async def test_if_action_one_weekday(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_action_list_weekday(hass: HomeAssistant, calls) -> None: +async def test_if_action_list_weekday( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for action with a list of weekdays.""" assert await async_setup_component( hass, @@ -408,7 +416,7 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: async def test_if_fires_using_at_sensor( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -535,7 +543,9 @@ def test_schema_invalid(conf) -> None: time.TRIGGER_SCHEMA(conf) -async def test_datetime_in_past_on_load(hass: HomeAssistant, calls) -> None: +async def test_datetime_in_past_on_load( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test time trigger works if input_datetime is in past.""" await async_setup_component( hass, diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index 2324599c3c6..327623d373b 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import automation from homeassistant.components.homeassistant.triggers import time_pattern from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -17,7 +17,7 @@ from tests.common import async_fire_time_changed, async_mock_service, mock_compo @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -29,7 +29,7 @@ def setup_comp(hass): async def test_if_fires_when_hour_matches( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing if hour is matching.""" now = dt_util.utcnow() @@ -74,7 +74,7 @@ async def test_if_fires_when_hour_matches( async def test_if_fires_when_minute_matches( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing if minutes are matching.""" now = dt_util.utcnow() @@ -105,7 +105,7 @@ async def test_if_fires_when_minute_matches( async def test_if_fires_when_second_matches( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() @@ -136,7 +136,7 @@ async def test_if_fires_when_second_matches( async def test_if_fires_when_second_as_string_matches( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() @@ -169,7 +169,7 @@ async def test_if_fires_when_second_as_string_matches( async def test_if_fires_when_all_matches( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing if everything matches.""" now = dt_util.utcnow() @@ -202,7 +202,7 @@ async def test_if_fires_when_all_matches( async def test_if_fires_periodic_seconds( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing periodically every second.""" now = dt_util.utcnow() @@ -235,7 +235,7 @@ async def test_if_fires_periodic_seconds( async def test_if_fires_periodic_minutes( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing periodically every minute.""" @@ -269,7 +269,7 @@ async def test_if_fires_periodic_minutes( async def test_if_fires_periodic_hours( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing periodically every hour.""" now = dt_util.utcnow() @@ -302,7 +302,7 @@ async def test_if_fires_periodic_hours( async def test_default_values( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() @@ -343,7 +343,7 @@ async def test_default_values( assert len(calls) == 2 -async def test_invalid_schemas(hass: HomeAssistant, calls) -> None: +async def test_invalid_schemas(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test invalid schemas.""" schemas = ( None, diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index 761eb5dec13..444db019c7c 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -10,7 +10,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from pytest_unordered import unordered -from homeassistant.components.homeassistant_alerts import ( +from homeassistant.components.homeassistant_alerts.const import ( COMPONENT_LOADED_COOLDOWN, DOMAIN, UPDATE_INTERVAL, @@ -134,15 +134,15 @@ async def test_alerts( with ( patch( - "homeassistant.components.homeassistant_alerts.__version__", + "homeassistant.components.homeassistant_alerts.coordinator.__version__", ha_version, ), patch( - "homeassistant.components.homeassistant_alerts.is_hassio", + "homeassistant.components.homeassistant_alerts.coordinator.is_hassio", return_value=supervisor_info is not None, ), patch( - "homeassistant.components.homeassistant_alerts.get_supervisor_info", + "homeassistant.components.homeassistant_alerts.coordinator.get_supervisor_info", return_value=supervisor_info, ), ): @@ -317,15 +317,15 @@ async def test_alerts_refreshed_on_component_load( with ( patch( - "homeassistant.components.homeassistant_alerts.__version__", + "homeassistant.components.homeassistant_alerts.coordinator.__version__", ha_version, ), patch( - "homeassistant.components.homeassistant_alerts.is_hassio", + "homeassistant.components.homeassistant_alerts.coordinator.is_hassio", return_value=supervisor_info is not None, ), patch( - "homeassistant.components.homeassistant_alerts.get_supervisor_info", + "homeassistant.components.homeassistant_alerts.coordinator.get_supervisor_info", return_value=supervisor_info, ), ): @@ -361,15 +361,15 @@ async def test_alerts_refreshed_on_component_load( with ( patch( - "homeassistant.components.homeassistant_alerts.__version__", + "homeassistant.components.homeassistant_alerts.coordinator.__version__", ha_version, ), patch( - "homeassistant.components.homeassistant_alerts.is_hassio", + "homeassistant.components.homeassistant_alerts.coordinator.is_hassio", return_value=supervisor_info is not None, ), patch( - "homeassistant.components.homeassistant_alerts.get_supervisor_info", + "homeassistant.components.homeassistant_alerts.coordinator.get_supervisor_info", return_value=supervisor_info, ), ): @@ -456,7 +456,7 @@ async def test_bad_alerts( hass.config.components.add(domain) with patch( - "homeassistant.components.homeassistant_alerts.__version__", + "homeassistant.components.homeassistant_alerts.coordinator.__version__", ha_version, ): assert await async_setup_component(hass, DOMAIN, {}) @@ -580,13 +580,13 @@ async def test_no_alerts( ) async def test_alerts_change( hass: HomeAssistant, - hass_ws_client, + hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, ha_version: str, fixture_1: str, - expected_alerts_1: list[tuple(str, str)], + expected_alerts_1: list[tuple[str, str]], fixture_2: str, - expected_alerts_2: list[tuple(str, str)], + expected_alerts_2: list[tuple[str, str]], ) -> None: """Test creating issues based on alerts.""" fixture_1_content = load_fixture(fixture_1, "homeassistant_alerts") @@ -615,7 +615,7 @@ async def test_alerts_change( hass.config.components.add(domain) with patch( - "homeassistant.components.homeassistant_alerts.__version__", + "homeassistant.components.homeassistant_alerts.coordinator.__version__", ha_version, ): assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index ae9ee6e1d2e..72e937396ea 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -1,14 +1,14 @@ """Test fixtures for the Home Assistant Hardware integration.""" -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator @pytest.fixture(autouse=True) -def mock_zha_config_flow_setup() -> Generator[None, None, None]: +def mock_zha_config_flow_setup() -> Generator[None]: """Mock the radio connection and probing of the ZHA config flow.""" def mock_probe(config: dict[str, Any]) -> None: @@ -39,7 +39,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: @pytest.fixture(autouse=True) -def mock_zha_get_last_network_settings() -> Generator[None, None, None]: +def mock_zha_get_last_network_settings() -> Generator[None]: """Mock zha.api.async_get_last_network_settings.""" with patch( diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index d04f725baf6..1df8fa86cf9 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -2,16 +2,16 @@ from __future__ import annotations -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.hassio import AddonError, AddonInfo, AddonState, HassIO from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon -from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN +from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant, callback @@ -95,8 +95,8 @@ class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): @pytest.fixture(autouse=True) def config_flow_handler( - hass: HomeAssistant, current_request_with_host: Any -) -> Generator[FakeConfigFlow, None, None]: + hass: HomeAssistant, current_request_with_host: None +) -> Generator[None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): @@ -104,7 +104,7 @@ def config_flow_handler( @pytest.fixture -def options_flow_poll_addon_state() -> Generator[None, None, None]: +def options_flow_poll_addon_state() -> Generator[None]: """Fixture for patching options flow addon state polling.""" with patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.WaitingAddonManager.async_wait_until_addon_state" @@ -113,7 +113,7 @@ def options_flow_poll_addon_state() -> Generator[None, None, None]: @pytest.fixture(autouse=True) -def hassio_integration(hass: HomeAssistant) -> Generator[None, None, None]: +def hassio_integration(hass: HomeAssistant) -> Generator[None]: """Fixture to mock the `hassio` integration.""" mock_component(hass, "hassio") hass.data["hassio"] = Mock(spec_set=HassIO) @@ -148,7 +148,7 @@ class MockMultiprotocolPlatform(MockPlatform): @pytest.fixture def mock_multiprotocol_platform( hass: HomeAssistant, -) -> Generator[FakeConfigFlow, None, None]: +) -> Generator[FakeConfigFlow]: """Fixture for a test silabs multiprotocol platform.""" hass.config.components.add(TEST_DOMAIN) platform = MockMultiprotocolPlatform() @@ -164,20 +164,17 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") @patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.ADDON_STATE_POLL_INTERVAL", 0, ) -async def test_uninstall_addon_waiting( - hass: HomeAssistant, - addon_store_info, - addon_info, - install_addon, - uninstall_addon, -): +@pytest.mark.usefixtures( + "addon_store_info", "addon_info", "install_addon", "uninstall_addon" +) +async def test_uninstall_addon_waiting(hass: HomeAssistant) -> None: """Test the synchronous addon uninstall helper.""" multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( @@ -587,7 +584,7 @@ async def test_option_flow_addon_installed_same_device_reconfigure_expected_user config_entry.add_to_hass(hass) mock_multiprotocol_platforms = {} - for domain in ["otbr", "zha"]: + for domain in ("otbr", "zha"): mock_multiprotocol_platform = MockMultiprotocolPlatform() mock_multiprotocol_platforms[domain] = mock_multiprotocol_platform mock_multiprotocol_platform.channel = configured_channel @@ -622,7 +619,7 @@ async def test_option_flow_addon_installed_same_device_reconfigure_expected_user result = await hass.config_entries.options.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.CREATE_ENTRY - for domain in ["otbr", "zha"]: + for domain in ("otbr", "zha"): assert mock_multiprotocol_platforms[domain].change_channel_calls == [(14, 300)] assert multipan_manager._channel == 14 diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index de8576e2a0a..099582999d5 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -1,13 +1,13 @@ """Test fixtures for the Home Assistant SkyConnect integration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator @pytest.fixture(name="mock_usb_serial_by_id", autouse=True) -def mock_usb_serial_by_id_fixture() -> Generator[MagicMock, None, None]: +def mock_usb_serial_by_id_fixture() -> Generator[MagicMock]: """Mock usb serial by id.""" with patch( "homeassistant.components.zha.config_flow.usb.get_serial_by_id" @@ -39,7 +39,7 @@ def mock_zha(): @pytest.fixture(autouse=True) -def mock_zha_get_last_network_settings() -> Generator[None, None, None]: +def mock_zha_get_last_network_settings() -> Generator[None]: """Mock zha.api.async_get_last_network_settings.""" with patch( diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 611dda4a917..a4b7b4fb81d 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Awaitable, Callable +import contextlib from typing import Any from unittest.mock import AsyncMock, Mock, call, patch @@ -57,6 +58,77 @@ def delayed_side_effect() -> Callable[..., Awaitable[None]]: return side_effect +@contextlib.contextmanager +def mock_addon_info( + hass: HomeAssistant, + *, + is_hassio: bool = True, + app_type: ApplicationType = ApplicationType.EZSP, + otbr_addon_info: AddonInfo = AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.NOT_INSTALLED, + update_available=False, + version=None, + ), + flasher_addon_info: AddonInfo = AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.NOT_INSTALLED, + update_available=False, + version=None, + ), +): + """Mock the main addon states for the config flow.""" + mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) + mock_flasher_manager.addon_name = "Silicon Labs Flasher" + mock_flasher_manager.async_start_addon_waiting = AsyncMock( + side_effect=delayed_side_effect() + ) + mock_flasher_manager.async_install_addon_waiting = AsyncMock( + side_effect=delayed_side_effect() + ) + mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( + side_effect=delayed_side_effect() + ) + mock_flasher_manager.async_get_addon_info.return_value = flasher_addon_info + + mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) + mock_otbr_manager.addon_name = "OpenThread Border Router" + mock_otbr_manager.async_install_addon_waiting = AsyncMock( + side_effect=delayed_side_effect() + ) + mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock( + side_effect=delayed_side_effect() + ) + mock_otbr_manager.async_start_addon_waiting = AsyncMock( + side_effect=delayed_side_effect() + ) + mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info + + with ( + patch( + "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", + return_value=mock_otbr_manager, + ), + patch( + "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", + return_value=mock_flasher_manager, + ), + patch( + "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", + return_value=is_hassio, + ), + patch( + "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", + return_value=app_type, + ), + ): + yield mock_otbr_manager, mock_flasher_manager + + @pytest.mark.parametrize( ("usb_data", "model"), [ @@ -72,57 +144,13 @@ async def test_config_flow_zigbee( DOMAIN, context={"source": "usb"}, data=usb_data ) - # First step is confirmation, we haven't probed the firmware yet - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["description_placeholders"]["firmware_type"] == "unknown" - assert result["description_placeholders"]["model"] == model - - # Next, we probe the firmware - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, # Ensure we re-install it - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "spinel" - - # Set up Zigbee firmware - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, mock_flasher_manager): # Pick the menu option: we are now installing the addon result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -131,6 +159,7 @@ async def test_config_flow_zigbee( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "install_addon" assert result["step_id"] == "install_zigbee_flasher_addon" + assert result["description_placeholders"]["firmware_type"] == "spinel" await hass.async_block_till_done(wait_background_tasks=True) @@ -208,46 +237,13 @@ async def test_config_flow_zigbee_skip_step_if_installed( DOMAIN, context={"source": "usb"}, data=usb_data ) - # First step is confirmation, we haven't probed the firmware yet - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["description_placeholders"]["firmware_type"] == "unknown" - assert result["description_placeholders"]["model"] == model - - # Next, we probe the firmware - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, # Ensure we re-install it - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "spinel" - # Set up Zigbee firmware - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + flasher_addon_info=AddonInfo( available=True, hostname=None, options={ @@ -259,16 +255,18 @@ async def test_config_flow_zigbee_skip_step_if_installed( state=AddonState.NOT_RUNNING, update_available=False, version="1.2.3", - ) - + ), + ) as (mock_otbr_manager, mock_flasher_manager): # Pick the menu option: we skip installation, instead we directly run it result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "run_zigbee_flasher_addon" assert result["progress_action"] == "run_zigbee_flasher_addon" + assert result["description_placeholders"]["firmware_type"] == "spinel" assert mock_flasher_manager.async_set_addon_options.mock_calls == [ call( { @@ -306,54 +304,13 @@ async def test_config_flow_thread( DOMAIN, context={"source": "usb"}, data=usb_data ) - # First step is confirmation, we haven't probed the firmware yet - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["description_placeholders"]["firmware_type"] == "unknown" - assert result["description_placeholders"]["model"] == model - - # Next, we probe the firmware - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "ezsp" - - # Set up Thread firmware - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): # Pick the menu option result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -363,6 +320,8 @@ async def test_config_flow_thread( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "install_addon" assert result["step_id"] == "install_otbr_addon" + assert result["description_placeholders"]["firmware_type"] == "ezsp" + assert result["description_placeholders"]["model"] == model await hass.async_block_till_done(wait_background_tasks=True) @@ -438,41 +397,18 @@ async def test_config_flow_thread_addon_already_installed( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_RUNNING, - update_available=False, - version=None, - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + otbr_addon_info=AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version=None, ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): + ) as (mock_otbr_manager, mock_flasher_manager): # Pick the menu option result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -520,20 +456,11 @@ async def test_config_flow_zigbee_not_hassio( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=False, - ), - ): + with mock_addon_info( + hass, + is_hassio=False, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -604,35 +531,10 @@ async def test_options_flow_zigbee_to_thread( assert result["description_placeholders"]["firmware_type"] == "ezsp" assert result["description_placeholders"]["model"] == model - # Pick Thread - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -730,53 +632,10 @@ async def test_options_flow_thread_to_zigbee( assert result["description_placeholders"]["firmware_type"] == "spinel" assert result["description_placeholders"]["model"] == model - # Set up Zigbee firmware - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - - # OTBR is not installed - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, mock_flasher_manager): # Pick the menu option: we are now installing the addon result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/homeassistant_sky_connect/test_config_flow_failures.py b/tests/components/homeassistant_sky_connect/test_config_flow_failures.py index 128c812272f..b29f8d808ae 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow_failures.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow_failures.py @@ -1,6 +1,6 @@ """Test the Home Assistant SkyConnect config flow failure cases.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock import pytest from universal_silabs_flasher.const import ApplicationType @@ -16,41 +16,38 @@ from homeassistant.components.homeassistant_sky_connect.config_flow import ( STEP_PICK_FIRMWARE_ZIGBEE, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN -from homeassistant.components.homeassistant_sky_connect.util import ( - get_otbr_addon_manager, - get_zigbee_flasher_addon_manager, -) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .test_config_flow import USB_DATA_ZBT1, delayed_side_effect +from .test_config_flow import USB_DATA_ZBT1, delayed_side_effect, mock_addon_info from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("usb_data", "model"), + ("usb_data", "model", "next_step"), [ - (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1"), + (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1", STEP_PICK_FIRMWARE_ZIGBEE), + (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1", STEP_PICK_FIRMWARE_THREAD), ], ) async def test_config_flow_cannot_probe_firmware( - usb_data: usb.UsbServiceInfo, model: str, hass: HomeAssistant + usb_data: usb.UsbServiceInfo, model: str, next_step: str, hass: HomeAssistant ) -> None: """Test failure case when firmware cannot be probed.""" - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=None, - ): + with mock_addon_info( + hass, + app_type=None, + ) as (mock_otbr_manager, mock_flasher_manager): # Start the flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "usb"}, data=usb_data ) - # Probing fails result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + result["flow_id"], + user_input={"next_step_id": next_step}, ) assert result["type"] == FlowResultType.ABORT @@ -71,20 +68,15 @@ async def test_config_flow_zigbee_not_hassio_wrong_firmware( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + is_hassio=False, + ) as (mock_otbr_manager, mock_flasher_manager): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=False, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -107,35 +99,22 @@ async def test_config_flow_zigbee_flasher_addon_already_running( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + flasher_addon_info=AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ), + ) as (mock_otbr_manager, mock_flasher_manager): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -160,28 +139,23 @@ async def test_config_flow_zigbee_flasher_addon_info_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + flasher_addon_info=AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ), + ) as (mock_otbr_manager, mock_flasher_manager): + mock_flasher_manager.async_get_addon_info.side_effect = AddonError() + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.side_effect = AddonError() - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -206,38 +180,18 @@ async def test_config_flow_zigbee_flasher_addon_install_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_flasher_manager.async_install_addon_waiting = AsyncMock( + side_effect=AddonError() + ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -262,39 +216,20 @@ async def test_config_flow_zigbee_flasher_addon_set_config_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_flasher_manager.async_install_addon_waiting = AsyncMock( + side_effect=delayed_side_effect() + ) + mock_flasher_manager.async_set_addon_options = AsyncMock( + side_effect=AddonError() + ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -321,39 +256,17 @@ async def test_config_flow_zigbee_flasher_run_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_flasher_manager.async_start_addon_waiting = AsyncMock( + side_effect=AddonError() + ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_start_addon_waiting = AsyncMock(side_effect=AddonError()) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -380,44 +293,16 @@ async def test_config_flow_zigbee_flasher_uninstall_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.SPINEL, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( + side_effect=AddonError() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, @@ -448,20 +333,15 @@ async def test_config_flow_thread_not_hassio( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): + with mock_addon_info( + hass, + is_hassio=False, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=False, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -484,28 +364,14 @@ async def test_config_flow_thread_addon_info_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_otbr_manager.async_get_addon_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.side_effect = AddonError() - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -530,36 +396,25 @@ async def test_config_flow_thread_addon_already_running( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + otbr_addon_info=AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ), + ) as (mock_otbr_manager, mock_flasher_manager): + mock_otbr_manager.async_install_addon_waiting = AsyncMock( + side_effect=AddonError() + ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - mock_otbr_manager.async_install_addon_waiting = AsyncMock(side_effect=AddonError()) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -584,36 +439,17 @@ async def test_config_flow_thread_addon_install_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_otbr_manager.async_install_addon_waiting = AsyncMock( + side_effect=AddonError() + ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_otbr_manager.async_install_addon_waiting = AsyncMock(side_effect=AddonError()) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -638,39 +474,15 @@ async def test_config_flow_thread_addon_set_config_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -697,39 +509,16 @@ async def test_config_flow_thread_flasher_run_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_otbr_manager.async_start_addon_waiting = AsyncMock( + side_effect=AddonError() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_start_addon_waiting = AsyncMock(side_effect=AddonError()) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -756,44 +545,17 @@ async def test_config_flow_thread_flasher_uninstall_fails( DOMAIN, context={"source": "usb"}, data=usb_data ) - with patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, - ): + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock( + side_effect=AddonError() + ) + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ) - mock_otbr_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, - ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -890,28 +652,18 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( # Confirm options flow result = await hass.config_entries.options.async_init(config_entry.entry_id) - # Pick Zigbee - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) - mock_otbr_manager.addon_name = "OpenThread Border Router" - mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": usb_data.device}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - with ( - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.get_otbr_addon_manager", - return_value=mock_otbr_manager, + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + otbr_addon_info=AddonInfo( + available=True, + hostname=None, + options={"device": usb_data.device}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", ), - patch( - "homeassistant.components.homeassistant_sky_connect.config_flow.is_hassio", - return_value=True, - ), - ): + ) as (mock_otbr_manager, mock_flasher_manager): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, diff --git a/tests/components/homeassistant_sky_connect/test_const.py b/tests/components/homeassistant_sky_connect/test_const.py index 24a39270061..b439d8a8830 100644 --- a/tests/components/homeassistant_sky_connect/test_const.py +++ b/tests/components/homeassistant_sky_connect/test_const.py @@ -19,7 +19,7 @@ def test_hardware_variant( assert HardwareVariant.from_usb_product_name(usb_product_name) == expected_variant -def test_hardware_variant_invalid(): +def test_hardware_variant_invalid() -> None: """Test hardware variant parsing with an invalid product.""" with pytest.raises( ValueError, match=r"^Unknown SkyConnect product name: Some other product$" diff --git a/tests/components/homeassistant_sky_connect/test_util.py b/tests/components/homeassistant_sky_connect/test_util.py index 12ba352eb16..b560acc65b7 100644 --- a/tests/components/homeassistant_sky_connect/test_util.py +++ b/tests/components/homeassistant_sky_connect/test_util.py @@ -94,6 +94,18 @@ def test_get_zha_device_path() -> None: ) +def test_get_zha_device_path_ignored_discovery() -> None: + """Test extracting the ZHA device path from an ignored ZHA discovery.""" + config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data={}, + version=4, + ) + + assert get_zha_device_path(config_entry) is None + + async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index 070047648fc..38398eb719f 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -1,14 +1,14 @@ """Test fixtures for the Home Assistant Yellow integration.""" -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator @pytest.fixture(autouse=True) -def mock_zha_config_flow_setup() -> Generator[None, None, None]: +def mock_zha_config_flow_setup() -> Generator[None]: """Mock the radio connection and probing of the ZHA config flow.""" def mock_probe(config: dict[str, Any]) -> None: @@ -39,7 +39,7 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: @pytest.fixture(autouse=True) -def mock_zha_get_last_network_settings() -> Generator[None, None, None]: +def mock_zha_get_last_network_settings() -> Generator[None]: """Mock zha.api.async_get_last_network_settings.""" with patch( diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 206ad4dce15..4ae04180a64 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -1,13 +1,13 @@ """Test the Home Assistant Yellow config flow.""" -from collections.abc import Generator from unittest.mock import Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN from homeassistant.components.homeassistant_yellow.const import DOMAIN -from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN +from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry, MockModule, mock_integration @pytest.fixture(autouse=True) -def config_flow_handler(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_handler(hass: HomeAssistant) -> Generator[None]: """Fixture for a test config flow.""" with patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.WaitingAddonManager.async_wait_until_addon_state" diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index fcbeafa3b60..26333b0b807 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -1,8 +1,11 @@ """HomeKit session fixtures.""" +from asyncio import AbstractEventLoop +from collections.abc import Generator from contextlib import suppress import os -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch import pytest @@ -10,6 +13,7 @@ from homeassistant.components.device_tracker.legacy import YAML_DEVICES from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import BRIDGE_NAME, EVENT_HOMEKIT_CHANGED from homeassistant.components.homekit.iidmanager import AccessoryIIDStorage +from homeassistant.core import HomeAssistant from tests.common import async_capture_events @@ -22,7 +26,9 @@ def iid_storage(hass): @pytest.fixture -def run_driver(hass, event_loop, iid_storage): +def run_driver( + hass: HomeAssistant, event_loop: AbstractEventLoop, iid_storage: AccessoryIIDStorage +) -> Generator[HomeDriver, Any, None]: """Return a custom AccessoryDriver instance for HomeKit accessory init. This mock does not mock async_stop, so the driver will not be stopped @@ -49,7 +55,9 @@ def run_driver(hass, event_loop, iid_storage): @pytest.fixture -def hk_driver(hass, event_loop, iid_storage): +def hk_driver( + hass: HomeAssistant, event_loop: AbstractEventLoop, iid_storage: AccessoryIIDStorage +) -> Generator[HomeDriver, Any, None]: """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with ( patch("pyhap.accessory_driver.AsyncZeroconf"), @@ -76,7 +84,12 @@ def hk_driver(hass, event_loop, iid_storage): @pytest.fixture -def mock_hap(hass, event_loop, iid_storage, mock_zeroconf): +def mock_hap( + hass: HomeAssistant, + event_loop: AbstractEventLoop, + iid_storage: AccessoryIIDStorage, + mock_zeroconf: MagicMock, +) -> Generator[HomeDriver, Any, None]: """Return a custom AccessoryDriver instance for HomeKit accessory init.""" with ( patch("pyhap.accessory_driver.AsyncZeroconf"), diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 11a2675382a..32cd6622492 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -48,7 +48,6 @@ from homeassistant.const import ( __version__ as hass_version, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.event import TRACK_STATE_CHANGE_CALLBACKS from tests.common import async_mock_service @@ -66,9 +65,7 @@ async def test_accessory_cancels_track_state_change_on_stop( "homeassistant.components.homekit.accessories.HomeAccessory.async_update_state" ): acc.run() - assert len(hass.data[TRACK_STATE_CHANGE_CALLBACKS][entity_id]) == 1 await acc.stop() - assert entity_id not in hass.data[TRACK_STATE_CHANGE_CALLBACKS] async def test_home_accessory(hass: HomeAssistant, hk_driver) -> None: diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index ff47abab833..d6d0c7118db 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS from homeassistant.setup import async_setup_component @@ -45,7 +45,7 @@ def _mock_config_entry_with_options_populated(): ) -async def test_setup_in_bridge_mode(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_setup_in_bridge_mode(hass: HomeAssistant) -> None: """Test we can setup a new instance in bridge mode.""" result = await hass.config_entries.flow.async_init( @@ -99,9 +99,7 @@ async def test_setup_in_bridge_mode(hass: HomeAssistant, mock_get_source_ip) -> assert len(mock_setup_entry.mock_calls) == 1 -async def test_setup_in_bridge_mode_name_taken( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_setup_in_bridge_mode_name_taken(hass: HomeAssistant) -> None: """Test we can setup a new instance in bridge mode when the name is taken.""" entry = MockConfigEntry( @@ -163,7 +161,7 @@ async def test_setup_in_bridge_mode_name_taken( async def test_setup_creates_entries_for_accessory_mode_devices( - hass: HomeAssistant, mock_get_source_ip + hass: HomeAssistant, ) -> None: """Test we can setup a new instance and we create entries for accessory mode devices.""" hass.states.async_set("camera.one", "on") @@ -257,7 +255,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices( assert len(mock_setup_entry.mock_calls) == 7 -async def test_import(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_import(hass: HomeAssistant) -> None: """Test we can import instance.""" ignored_entry = MockConfigEntry(domain=DOMAIN, data={}, source=SOURCE_IGNORE) @@ -302,9 +300,7 @@ async def test_import(hass: HomeAssistant, mock_get_source_ip) -> None: assert len(mock_setup_entry.mock_calls) == 2 -async def test_options_flow_exclude_mode_advanced( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_options_flow_exclude_mode_advanced(hass: HomeAssistant) -> None: """Test config flow options in exclude mode with advanced options.""" config_entry = _mock_config_entry_with_options_populated() @@ -357,9 +353,7 @@ async def test_options_flow_exclude_mode_advanced( } -async def test_options_flow_exclude_mode_basic( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_options_flow_exclude_mode_basic(hass: HomeAssistant) -> None: """Test config flow options in exclude mode.""" config_entry = _mock_config_entry_with_options_populated() @@ -411,14 +405,12 @@ async def test_options_flow_exclude_mode_basic( @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_options_flow_devices( port_mock, hass: HomeAssistant, demo_cleanup, - device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mock_get_source_ip, - mock_async_zeroconf: None, ) -> None: """Test devices can be bridged.""" config_entry = MockConfigEntry( @@ -509,8 +501,9 @@ async def test_options_flow_devices( @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_options_flow_devices_preserved_when_advanced_off( - port_mock, hass: HomeAssistant, mock_get_source_ip, mock_async_zeroconf: None + port_mock, hass: HomeAssistant ) -> None: """Test devices are preserved if they were added in advanced mode but it was turned off.""" config_entry = MockConfigEntry( @@ -586,7 +579,7 @@ async def test_options_flow_devices_preserved_when_advanced_off( async def test_options_flow_include_mode_with_non_existant_entity( - hass: HomeAssistant, mock_get_source_ip + hass: HomeAssistant, ) -> None: """Test config flow options in include mode with a non-existent entity.""" config_entry = MockConfigEntry( @@ -646,7 +639,7 @@ async def test_options_flow_include_mode_with_non_existant_entity( async def test_options_flow_exclude_mode_with_non_existant_entity( - hass: HomeAssistant, mock_get_source_ip + hass: HomeAssistant, ) -> None: """Test config flow options in exclude mode with a non-existent entity.""" config_entry = MockConfigEntry( @@ -706,9 +699,7 @@ async def test_options_flow_exclude_mode_with_non_existant_entity( await hass.config_entries.async_unload(config_entry.entry_id) -async def test_options_flow_include_mode_basic( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_options_flow_include_mode_basic(hass: HomeAssistant) -> None: """Test config flow options in include mode.""" config_entry = _mock_config_entry_with_options_populated() @@ -754,9 +745,7 @@ async def test_options_flow_include_mode_basic( await hass.config_entries.async_unload(config_entry.entry_id) -async def test_options_flow_exclude_mode_with_cameras( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_options_flow_exclude_mode_with_cameras(hass: HomeAssistant) -> None: """Test config flow options in exclude mode with cameras.""" config_entry = _mock_config_entry_with_options_populated() @@ -863,9 +852,7 @@ async def test_options_flow_exclude_mode_with_cameras( await hass.config_entries.async_unload(config_entry.entry_id) -async def test_options_flow_include_mode_with_cameras( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_options_flow_include_mode_with_cameras(hass: HomeAssistant) -> None: """Test config flow options in include mode with cameras.""" config_entry = _mock_config_entry_with_options_populated() @@ -999,9 +986,7 @@ async def test_options_flow_include_mode_with_cameras( await hass.config_entries.async_unload(config_entry.entry_id) -async def test_options_flow_with_camera_audio( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_options_flow_with_camera_audio(hass: HomeAssistant) -> None: """Test config flow options with cameras that support audio.""" config_entry = _mock_config_entry_with_options_populated() @@ -1135,9 +1120,7 @@ async def test_options_flow_with_camera_audio( await hass.config_entries.async_unload(config_entry.entry_id) -async def test_options_flow_blocked_when_from_yaml( - hass: HomeAssistant, mock_get_source_ip -) -> None: +async def test_options_flow_blocked_when_from_yaml(hass: HomeAssistant) -> None: """Test config flow options.""" config_entry = MockConfigEntry( @@ -1178,12 +1161,11 @@ async def test_options_flow_blocked_when_from_yaml( @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_options_flow_include_mode_basic_accessory( port_mock, hass: HomeAssistant, - mock_get_source_ip, hk_driver, - mock_async_zeroconf: None, ) -> None: """Test config flow options in include mode with a single accessory.""" config_entry = _mock_config_entry_with_options_populated() @@ -1283,7 +1265,7 @@ async def test_options_flow_include_mode_basic_accessory( async def test_converting_bridge_to_accessory_mode( - hass: HomeAssistant, hk_driver, mock_get_source_ip + hass: HomeAssistant, hk_driver ) -> None: """Test we can convert a bridge to accessory mode.""" @@ -1405,12 +1387,11 @@ def _get_schema_default(schema, key_name): @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_options_flow_exclude_mode_skips_category_entities( port_mock, hass: HomeAssistant, - mock_get_source_ip, hk_driver, - mock_async_zeroconf: None, entity_registry: er.EntityRegistry, ) -> None: """Ensure exclude mode does not offer category entities.""" @@ -1510,12 +1491,11 @@ async def test_options_flow_exclude_mode_skips_category_entities( @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_options_flow_exclude_mode_skips_hidden_entities( port_mock, hass: HomeAssistant, - mock_get_source_ip, hk_driver, - mock_async_zeroconf: None, entity_registry: er.EntityRegistry, ) -> None: """Ensure exclude mode does not offer hidden entities.""" @@ -1595,12 +1575,11 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_options_flow_include_mode_allows_hidden_entities( port_mock, hass: HomeAssistant, - mock_get_source_ip, hk_driver, - mock_async_zeroconf: None, entity_registry: er.EntityRegistry, ) -> None: """Ensure include mode does not offer hidden entities.""" diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index 9fe4fc6fcc7..728624da0d0 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -2,6 +2,8 @@ from unittest.mock import ANY, MagicMock, patch +import pytest + from homeassistant.components.homekit.const import ( CONF_DEVICES, CONF_HOMEKIT_MODE, @@ -20,11 +22,11 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_config_entry_not_running( hass: HomeAssistant, hass_client: ClientSessionGenerator, hk_driver, - mock_async_zeroconf: None, ) -> None: """Test generating diagnostics for a config entry.""" entry = await async_init_integration(hass) @@ -40,11 +42,11 @@ async def test_config_entry_not_running( } +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_config_entry_running( hass: HomeAssistant, hass_client: ClientSessionGenerator, hk_driver, - mock_async_zeroconf: None, ) -> None: """Test generating diagnostics for a bridge config entry.""" entry = MockConfigEntry( @@ -152,11 +154,11 @@ async def test_config_entry_running( await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_config_entry_accessory( hass: HomeAssistant, hass_client: ClientSessionGenerator, hk_driver, - mock_async_zeroconf: None, ) -> None: """Test generating diagnostics for an accessory config entry.""" hass.states.async_set("light.demo", "on") @@ -314,11 +316,11 @@ async def test_config_entry_accessory( await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_config_entry_with_trigger_accessory( hass: HomeAssistant, hass_client: ClientSessionGenerator, hk_driver, - mock_async_zeroconf: None, events, demo_cleanup, device_registry: dr.DeviceRegistry, diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index e0f0786f15d..33bfc6e66d3 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -111,7 +111,7 @@ def always_patch_driver(hk_driver): @pytest.fixture(autouse=True) -def patch_source_ip(mock_get_source_ip): +def patch_source_ip(): """Patch homeassistant and pyhap functions for getting local address.""" with patch("pyhap.util.get_local_address", return_value="10.10.10.10"): yield @@ -154,7 +154,8 @@ def _mock_pyhap_bridge(): ) -async def test_setup_min(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_setup_min(hass: HomeAssistant) -> None: """Test async_setup with min config options.""" entry = MockConfigEntry( @@ -198,9 +199,8 @@ async def test_setup_min(hass: HomeAssistant, mock_async_zeroconf: None) -> None @patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) -async def test_removing_entry( - port_mock, hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_removing_entry(port_mock, hass: HomeAssistant) -> None: """Test removing a config entry.""" entry = MockConfigEntry( @@ -246,9 +246,8 @@ async def test_removing_entry( await hass.async_block_till_done() -async def test_homekit_setup( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_setup(hass: HomeAssistant, hk_driver) -> None: """Test setup of bridge and driver.""" entry = MockConfigEntry( domain=DOMAIN, @@ -297,7 +296,7 @@ async def test_homekit_setup( async def test_homekit_setup_ip_address( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None + hass: HomeAssistant, hk_driver, mock_async_zeroconf: MagicMock ) -> None: """Test setup with given IP address.""" entry = MockConfigEntry( @@ -344,7 +343,7 @@ async def test_homekit_setup_ip_address( async def test_homekit_with_single_advertise_ips( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, + mock_async_zeroconf: MagicMock, hass_storage: dict[str, Any], ) -> None: """Test setup with a single advertise ips.""" @@ -379,7 +378,7 @@ async def test_homekit_with_single_advertise_ips( async def test_homekit_with_many_advertise_ips( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, + mock_async_zeroconf: MagicMock, hass_storage: dict[str, Any], ) -> None: """Test setup with many advertise ips.""" @@ -415,9 +414,8 @@ async def test_homekit_with_many_advertise_ips( ) -async def test_homekit_setup_advertise_ips( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_setup_advertise_ips(hass: HomeAssistant, hk_driver) -> None: """Test setup with given IP address to advertise.""" entry = MockConfigEntry( domain=DOMAIN, @@ -461,9 +459,8 @@ async def test_homekit_setup_advertise_ips( ) -async def test_homekit_add_accessory( - hass: HomeAssistant, mock_async_zeroconf: None, mock_hap -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_add_accessory(hass: HomeAssistant, mock_hap) -> None: """Add accessory if config exists and get_acc returns an accessory.""" entry = MockConfigEntry( @@ -501,10 +498,10 @@ async def test_homekit_add_accessory( @pytest.mark.parametrize("acc_category", [CATEGORY_TELEVISION, CATEGORY_CAMERA]) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_warn_add_accessory_bridge( hass: HomeAssistant, acc_category, - mock_async_zeroconf: None, mock_hap, caplog: pytest.LogCaptureFixture, ) -> None: @@ -535,9 +532,8 @@ async def test_homekit_warn_add_accessory_bridge( assert "accessory mode" in caplog.text -async def test_homekit_remove_accessory( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_remove_accessory(hass: HomeAssistant) -> None: """Remove accessory from bridge.""" entry = await async_init_integration(hass) @@ -554,9 +550,8 @@ async def test_homekit_remove_accessory( assert len(homekit.bridge.accessories) == 0 -async def test_homekit_entity_filter( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_entity_filter(hass: HomeAssistant) -> None: """Test the entity filter.""" entry = await async_init_integration(hass) @@ -575,9 +570,8 @@ async def test_homekit_entity_filter( assert hass.states.get("light.demo") not in filtered_states -async def test_homekit_entity_glob_filter( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_entity_glob_filter(hass: HomeAssistant) -> None: """Test the entity filter.""" entry = await async_init_integration(hass) @@ -601,8 +595,9 @@ async def test_homekit_entity_glob_filter( assert hass.states.get("light.included_test") in filtered_states +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_entity_glob_filter_with_config_entities( - hass: HomeAssistant, mock_async_zeroconf: None, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the entity filter with configuration entities.""" entry = await async_init_integration(hass) @@ -654,8 +649,9 @@ async def test_homekit_entity_glob_filter_with_config_entities( assert hass.states.get("select.keep") in filtered_states +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_entity_glob_filter_with_hidden_entities( - hass: HomeAssistant, mock_async_zeroconf: None, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test the entity filter with hidden entities.""" entry = await async_init_integration(hass) @@ -707,10 +703,10 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( assert hass.states.get("select.keep") in filtered_states +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_start( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, device_registry: dr.DeviceRegistry, ) -> None: """Test HomeKit start method.""" @@ -794,8 +790,9 @@ async def test_homekit_start( assert homekit.driver.state.config_version == 1 +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_start_with_a_broken_accessory( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None + hass: HomeAssistant, hk_driver ) -> None: """Test HomeKit start method.""" entry = MockConfigEntry( @@ -835,10 +832,10 @@ async def test_homekit_start_with_a_broken_accessory( assert not hk_driver_start.called +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_start_with_a_device( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, demo_cleanup, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -908,9 +905,8 @@ async def test_homekit_stop(hass: HomeAssistant) -> None: assert homekit.driver.async_stop.called is True -async def test_homekit_reset_accessories( - hass: HomeAssistant, mock_async_zeroconf: None, mock_hap -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_reset_accessories(hass: HomeAssistant, mock_hap) -> None: """Test resetting HomeKit accessories.""" entry = MockConfigEntry( @@ -946,8 +942,9 @@ async def test_homekit_reset_accessories( await homekit.async_stop() +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_reload_accessory_can_change_class( - hass: HomeAssistant, mock_async_zeroconf: None, mock_hap + hass: HomeAssistant, mock_hap ) -> None: """Test reloading a HomeKit Accessory in brdige mode. @@ -981,8 +978,9 @@ async def test_homekit_reload_accessory_can_change_class( await homekit.async_stop() +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_reload_accessory_in_accessory_mode( - hass: HomeAssistant, mock_async_zeroconf: None, mock_hap + hass: HomeAssistant, mock_hap ) -> None: """Test reloading a HomeKit Accessory in accessory mode. @@ -1016,8 +1014,9 @@ async def test_homekit_reload_accessory_in_accessory_mode( await homekit.async_stop() +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_reload_accessory_same_class( - hass: HomeAssistant, mock_async_zeroconf: None, mock_hap + hass: HomeAssistant, mock_hap ) -> None: """Test reloading a HomeKit Accessory in bridge mode. @@ -1060,8 +1059,9 @@ async def test_homekit_reload_accessory_same_class( await homekit.async_stop() +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_unpair( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test unpairing HomeKit accessories.""" @@ -1110,9 +1110,8 @@ async def test_homekit_unpair( homekit.status = STATUS_STOPPED -async def test_homekit_unpair_missing_device_id( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_unpair_missing_device_id(hass: HomeAssistant) -> None: """Test unpairing HomeKit accessories with invalid device id.""" entry = MockConfigEntry( @@ -1152,8 +1151,9 @@ async def test_homekit_unpair_missing_device_id( homekit.status = STATUS_STOPPED +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_unpair_not_homekit_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test unpairing HomeKit accessories with a non-homekit device id.""" @@ -1205,9 +1205,8 @@ async def test_homekit_unpair_not_homekit_device( homekit.status = STATUS_STOPPED -async def test_homekit_reset_accessories_not_supported( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_reset_accessories_not_supported(hass: HomeAssistant) -> None: """Test resetting HomeKit accessories with an unsupported entity.""" entry = MockConfigEntry( @@ -1251,9 +1250,8 @@ async def test_homekit_reset_accessories_not_supported( homekit.status = STATUS_STOPPED -async def test_homekit_reset_accessories_state_missing( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_reset_accessories_state_missing(hass: HomeAssistant) -> None: """Test resetting HomeKit accessories when the state goes missing.""" entry = MockConfigEntry( @@ -1295,9 +1293,8 @@ async def test_homekit_reset_accessories_state_missing( homekit.status = STATUS_STOPPED -async def test_homekit_reset_accessories_not_bridged( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_reset_accessories_not_bridged(hass: HomeAssistant) -> None: """Test resetting HomeKit accessories when the state is not bridged.""" entry = MockConfigEntry( @@ -1342,9 +1339,8 @@ async def test_homekit_reset_accessories_not_bridged( homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory( - hass: HomeAssistant, mock_hap, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_reset_single_accessory(hass: HomeAssistant, mock_hap) -> None: """Test resetting HomeKit single accessory.""" entry = MockConfigEntry( @@ -1381,9 +1377,8 @@ async def test_homekit_reset_single_accessory( await homekit.async_stop() -async def test_homekit_reset_single_accessory_unsupported( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_reset_single_accessory_unsupported(hass: HomeAssistant) -> None: """Test resetting HomeKit single accessory with an unsupported entity.""" entry = MockConfigEntry( @@ -1422,8 +1417,9 @@ async def test_homekit_reset_single_accessory_unsupported( homekit.status = STATUS_STOPPED +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_reset_single_accessory_state_missing( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test resetting HomeKit single accessory when the state goes missing.""" @@ -1462,9 +1458,8 @@ async def test_homekit_reset_single_accessory_state_missing( homekit.status = STATUS_STOPPED -async def test_homekit_reset_single_accessory_no_match( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_reset_single_accessory_no_match(hass: HomeAssistant) -> None: """Test resetting HomeKit single accessory when the entity id does not match.""" entry = MockConfigEntry( @@ -1502,11 +1497,11 @@ async def test_homekit_reset_single_accessory_no_match( homekit.status = STATUS_STOPPED +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_too_many_accessories( hass: HomeAssistant, hk_driver, caplog: pytest.LogCaptureFixture, - mock_async_zeroconf: None, ) -> None: """Test adding too many accessories to HomeKit.""" entry = await async_init_integration(hass) @@ -1538,12 +1533,12 @@ async def test_homekit_too_many_accessories( assert "would exceed" in caplog.text +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_finds_linked_batteries( hass: HomeAssistant, hk_driver, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1617,12 +1612,12 @@ async def test_homekit_finds_linked_batteries( ) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_async_get_integration_fails( hass: HomeAssistant, hk_driver, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mock_async_zeroconf: None, ) -> None: """Test that we continue if async_get_integration fails.""" entry = await async_init_integration(hass) @@ -1692,9 +1687,8 @@ async def test_homekit_async_get_integration_fails( ) -async def test_yaml_updates_update_config_entry_for_name( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_yaml_updates_update_config_entry_for_name(hass: HomeAssistant) -> None: """Test async_setup with imported config.""" entry = MockConfigEntry( @@ -1742,9 +1736,8 @@ async def test_yaml_updates_update_config_entry_for_name( mock_homekit().async_start.assert_called() -async def test_yaml_can_link_with_default_name( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_yaml_can_link_with_default_name(hass: HomeAssistant) -> None: """Test async_setup with imported config linked by default name.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1776,9 +1769,8 @@ async def test_yaml_can_link_with_default_name( assert entry.options["entity_config"]["camera.back_camera"]["stream_count"] == 3 -async def test_yaml_can_link_with_port( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_yaml_can_link_with_port(hass: HomeAssistant) -> None: """Test async_setup with imported config linked by port.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1830,9 +1822,8 @@ async def test_yaml_can_link_with_port( assert entry3.options == {} -async def test_homekit_uses_system_zeroconf( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_uses_system_zeroconf(hass: HomeAssistant, hk_driver) -> None: """Test HomeKit uses system zeroconf.""" entry = MockConfigEntry( domain=DOMAIN, @@ -1856,12 +1847,12 @@ async def test_homekit_uses_system_zeroconf( await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_ignored_missing_devices( hass: HomeAssistant, hk_driver, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mock_async_zeroconf: None, ) -> None: """Test HomeKit handles a device in the entity registry but missing from the device registry.""" @@ -1947,12 +1938,12 @@ async def test_homekit_ignored_missing_devices( ) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_finds_linked_motion_sensors( hass: HomeAssistant, hk_driver, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -2014,12 +2005,12 @@ async def test_homekit_finds_linked_motion_sensors( ) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_finds_linked_humidity_sensors( hass: HomeAssistant, hk_driver, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -2084,7 +2075,8 @@ async def test_homekit_finds_linked_humidity_sensors( ) -async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_reload(hass: HomeAssistant) -> None: """Test we can reload from yaml.""" entry = MockConfigEntry( @@ -2166,10 +2158,10 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: ) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_start_in_accessory_mode( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, device_registry: dr.DeviceRegistry, ) -> None: """Test HomeKit start method in accessory mode.""" @@ -2210,11 +2202,10 @@ async def test_homekit_start_in_accessory_mode( assert len(device_registry.devices) == 1 +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_start_in_accessory_mode_unsupported_entity( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, - device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test HomeKit start method in accessory mode with an unsupported entity.""" @@ -2244,11 +2235,10 @@ async def test_homekit_start_in_accessory_mode_unsupported_entity( assert "entity not supported" in caplog.text +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_start_in_accessory_mode_missing_entity( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, - device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test HomeKit start method in accessory mode when entity is not available.""" @@ -2275,10 +2265,10 @@ async def test_homekit_start_in_accessory_mode_missing_entity( assert "entity not available" in caplog.text +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_wait_for_port_to_free( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test we wait for the port to free before declaring unload success.""" diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 7e924be1637..fdf599f41ea 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -26,9 +26,7 @@ from tests.common import MockConfigEntry from tests.components.logbook.common import MockRow, mock_humanify -async def test_humanify_homekit_changed_event( - hass: HomeAssistant, hk_driver, mock_get_source_ip -) -> None: +async def test_humanify_homekit_changed_event(hass: HomeAssistant, hk_driver) -> None: """Test humanifying HomeKit changed event.""" hass.config.components.add("recorder") with patch("homeassistant.components.homekit.HomeKit") as mock_homekit: @@ -72,10 +70,10 @@ async def test_humanify_homekit_changed_event( assert event2["entity_id"] == "cover.window" +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_bridge_with_triggers( hass: HomeAssistant, hk_driver, - mock_async_zeroconf: None, entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index b17f16231af..fb7233e5262 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -357,7 +357,6 @@ async def test_media_player_television( with pytest.raises(ValueError): acc.char_remote_key.client_update_value(20) - await hass.async_block_till_done() acc.char_remote_key.client_update_value(7) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index 988950c64a8..bd4ead58a7b 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -133,7 +133,6 @@ async def test_activity_remote( with pytest.raises(ValueError): acc.char_remote_key.client_update_value(20) - await hass.async_block_till_done() acc.char_remote_key.client_update_value(7) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index ac086b8100e..fc68b7c8ecf 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -5,6 +5,8 @@ from unittest.mock import patch from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homekit import get_accessory from homeassistant.components.homekit.const import ( + CONF_THRESHOLD_CO, + CONF_THRESHOLD_CO2, PROP_CELSIUS, THRESHOLD_CO, THRESHOLD_CO2, @@ -375,6 +377,34 @@ async def test_co(hass: HomeAssistant, hk_driver) -> None: assert acc.char_detected.value == 0 +async def test_co_with_configured_threshold(hass: HomeAssistant, hk_driver) -> None: + """Test if co threshold of accessory can be configured .""" + entity_id = "sensor.co" + + co_threshold = 10 + assert co_threshold < THRESHOLD_CO + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = CarbonMonoxideSensor( + hass, hk_driver, "CO", entity_id, 2, {CONF_THRESHOLD_CO: co_threshold} + ) + acc.run() + await hass.async_block_till_done() + + value = 15 + assert value > co_threshold + hass.states.async_set(entity_id, str(value)) + await hass.async_block_till_done() + assert acc.char_detected.value == 1 + + value = 5 + assert value < co_threshold + hass.states.async_set(entity_id, str(value)) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 + + async def test_co2(hass: HomeAssistant, hk_driver) -> None: """Test if accessory is updated after state change.""" entity_id = "sensor.co2" @@ -415,6 +445,34 @@ async def test_co2(hass: HomeAssistant, hk_driver) -> None: assert acc.char_detected.value == 0 +async def test_co2_with_configured_threshold(hass: HomeAssistant, hk_driver) -> None: + """Test if co2 threshold of accessory can be configured .""" + entity_id = "sensor.co2" + + co2_threshold = 500 + assert co2_threshold < THRESHOLD_CO2 + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = CarbonDioxideSensor( + hass, hk_driver, "CO2", entity_id, 2, {CONF_THRESHOLD_CO2: co2_threshold} + ) + acc.run() + await hass.async_block_till_done() + + value = 800 + assert value > co2_threshold + hass.states.async_set(entity_id, str(value)) + await hass.async_block_till_done() + assert acc.char_detected.value == 1 + + value = 400 + assert value < co2_threshold + hass.states.async_set(entity_id, str(value)) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 + + async def test_light(hass: HomeAssistant, hk_driver) -> None: """Test if accessory is updated after state change.""" entity_id = "sensor.light" diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 17e38a0a145..24999242dc1 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -11,6 +11,8 @@ from homeassistant.components.homekit.const import ( CONF_FEATURE_LIST, CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, + CONF_THRESHOLD_CO, + CONF_THRESHOLD_CO2, DEFAULT_CONFIG_FLOW_PORT, DOMAIN, FEATURE_ON_OFF, @@ -170,6 +172,12 @@ def test_validate_entity_config() -> None: assert vec({"switch.demo": {CONF_TYPE: TYPE_VALVE}}) == { "switch.demo": {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD: 20} } + assert vec({"sensor.co": {CONF_THRESHOLD_CO: 500}}) == { + "sensor.co": {CONF_THRESHOLD_CO: 500, CONF_LOW_BATTERY_THRESHOLD: 20} + } + assert vec({"sensor.co2": {CONF_THRESHOLD_CO2: 500}}) == { + "sensor.co2": {CONF_THRESHOLD_CO2: 500, CONF_LOW_BATTERY_THRESHOLD: 20} + } def test_validate_media_player_features() -> None: @@ -233,9 +241,7 @@ def test_density_to_air_quality() -> None: assert density_to_air_quality(200) == 5 -async def test_async_show_setup_msg( - hass: HomeAssistant, hk_driver, mock_get_source_ip -) -> None: +async def test_async_show_setup_msg(hass: HomeAssistant, hk_driver) -> None: """Test show setup message as persistence notification.""" pincode = b"123-45-678" diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index ae2ca721cfa..427c5285436 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -1,11 +1,13 @@ """HomeKit controller session fixtures.""" import datetime -import unittest.mock +from unittest.mock import MagicMock, patch from aiohomekit.testing import FakeController from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest +from typing_extensions import Generator import homeassistant.util.dt as dt_util @@ -15,7 +17,7 @@ pytest.register_assert_rewrite("tests.components.homekit_controller.common") @pytest.fixture(autouse=True) -def freeze_time_in_future(request): +def freeze_time_in_future() -> Generator[FrozenDateTimeFactory]: """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) @@ -24,10 +26,10 @@ def freeze_time_in_future(request): @pytest.fixture -def controller(hass): +def controller() -> Generator[FakeController]: """Replace aiohomekit.Controller with an instance of aiohomekit.testing.FakeController.""" instance = FakeController() - with unittest.mock.patch( + with patch( "homeassistant.components.homekit_controller.utils.Controller", return_value=instance, ): @@ -35,10 +37,10 @@ def controller(hass): @pytest.fixture(autouse=True) -def hk_mock_async_zeroconf(mock_async_zeroconf): +def hk_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" @pytest.fixture(autouse=True) -def auto_mock_bluetooth(mock_bluetooth): +def auto_mock_bluetooth(mock_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/homekit_controller/fixtures/haa_fan.json b/tests/components/homekit_controller/fixtures/haa_fan1.json similarity index 61% rename from tests/components/homekit_controller/fixtures/haa_fan.json rename to tests/components/homekit_controller/fixtures/haa_fan1.json index a144a9501ba..7389870e195 100644 --- a/tests/components/homekit_controller/fixtures/haa_fan.json +++ b/tests/components/homekit_controller/fixtures/haa_fan1.json @@ -9,7 +9,7 @@ "hidden": false, "characteristics": [ { - "aid": 2, + "aid": 1, "iid": 2, "type": "00000023-0000-1000-8000-0026BB765291", "perms": ["pr"], @@ -17,7 +17,7 @@ "value": "HAA-C718B3" }, { - "aid": 2, + "aid": 1, "iid": 3, "type": "00000020-0000-1000-8000-0026BB765291", "perms": ["pr"], @@ -33,7 +33,7 @@ "value": "C718B3-1" }, { - "aid": 2, + "aid": 1, "iid": 5, "type": "00000021-0000-1000-8000-0026BB765291", "perms": ["pr"], @@ -41,7 +41,7 @@ "value": "RavenSystem HAA" }, { - "aid": 2, + "aid": 1, "iid": 6, "type": "00000052-0000-1000-8000-0026BB765291", "perms": ["pr"], @@ -49,7 +49,7 @@ "value": "5.0.18" }, { - "aid": 2, + "aid": 1, "iid": 7, "type": "00000014-0000-1000-8000-0026BB765291", "perms": ["pw"], @@ -130,82 +130,5 @@ ] } ] - }, - { - "aid": 2, - "services": [ - { - "iid": 1, - "type": "0000003E-0000-1000-8000-0026BB765291", - "primary": false, - "hidden": false, - "characteristics": [ - { - "aid": 2, - "iid": 2, - "type": "00000023-0000-1000-8000-0026BB765291", - "perms": ["pr"], - "format": "string", - "value": "HAA-C718B3" - }, - { - "aid": 2, - "iid": 3, - "type": "00000020-0000-1000-8000-0026BB765291", - "perms": ["pr"], - "format": "string", - "value": "Jos\u00e9 A. Jim\u00e9nez Campos" - }, - { - "aid": 2, - "iid": 4, - "type": "00000030-0000-1000-8000-0026BB765291", - "perms": ["pr"], - "format": "string", - "value": "C718B3-2" - }, - { - "aid": 2, - "iid": 5, - "type": "00000021-0000-1000-8000-0026BB765291", - "perms": ["pr"], - "format": "string", - "value": "RavenSystem HAA" - }, - { - "aid": 2, - "iid": 6, - "type": "00000052-0000-1000-8000-0026BB765291", - "perms": ["pr"], - "format": "string", - "value": "5.0.18" - }, - { - "aid": 2, - "iid": 7, - "type": "00000014-0000-1000-8000-0026BB765291", - "perms": ["pw"], - "format": "bool" - } - ] - }, - { - "iid": 8, - "type": "00000049-0000-1000-8000-0026BB765291", - "primary": true, - "hidden": false, - "characteristics": [ - { - "aid": 2, - "iid": 9, - "type": "00000025-0000-1000-8000-0026BB765291", - "perms": ["pr", "pw", "ev"], - "ev": true, - "format": "bool", - "value": false - } - ] - } - ] } ] diff --git a/tests/components/homekit_controller/fixtures/haa_fan2.json b/tests/components/homekit_controller/fixtures/haa_fan2.json new file mode 100644 index 00000000000..3cf70c2a85f --- /dev/null +++ b/tests/components/homekit_controller/fixtures/haa_fan2.json @@ -0,0 +1,79 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "primary": false, + "hidden": false, + "characteristics": [ + { + "aid": 1, + "iid": 2, + "type": "00000023-0000-1000-8000-0026BB765291", + "perms": ["pr"], + "format": "string", + "value": "HAA-C718B3" + }, + { + "aid": 1, + "iid": 3, + "type": "00000020-0000-1000-8000-0026BB765291", + "perms": ["pr"], + "format": "string", + "value": "Jos\u00e9 A. Jim\u00e9nez Campos" + }, + { + "aid": 1, + "iid": 4, + "type": "00000030-0000-1000-8000-0026BB765291", + "perms": ["pr"], + "format": "string", + "value": "C718B3-2" + }, + { + "aid": 1, + "iid": 5, + "type": "00000021-0000-1000-8000-0026BB765291", + "perms": ["pr"], + "format": "string", + "value": "RavenSystem HAA" + }, + { + "aid": 1, + "iid": 6, + "type": "00000052-0000-1000-8000-0026BB765291", + "perms": ["pr"], + "format": "string", + "value": "5.0.18" + }, + { + "aid": 1, + "iid": 7, + "type": "00000014-0000-1000-8000-0026BB765291", + "perms": ["pw"], + "format": "bool" + } + ] + }, + { + "iid": 8, + "type": "00000049-0000-1000-8000-0026BB765291", + "primary": true, + "hidden": false, + "characteristics": [ + { + "aid": 1, + "iid": 9, + "type": "00000025-0000-1000-8000-0026BB765291", + "perms": ["pr", "pw", "ev"], + "ev": true, + "format": "bool", + "value": false + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 0507976cd20..c52bf2c3b27 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -6636,6 +6636,328 @@ }), ]) # --- +# name: test_snapshots[haa_fan1] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'serial_number': 'C718B3-1', + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_setup', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3 Setup', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'setup', + 'unique_id': '00:00:00:00:00:00_1_1010_1012', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3 Setup', + }), + 'entity_id': 'button.haa_c718b3_setup', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_update', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HAA-C718B3 Update', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1010_1011', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'update', + 'friendly_name': 'HAA-C718B3 Update', + }), + 'entity_id': 'button.haa_c718b3_update', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + 'percentage': 66, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.haa_c718b3', + 'state': 'on', + }), + }), + ]), + }), + ]) +# --- +# name: test_snapshots[haa_fan2] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'labels': list([ + ]), + 'manufacturer': 'José A. Jiménez Campos', + 'model': 'RavenSystem HAA', + 'name': 'HAA-C718B3', + 'name_by_user': None, + 'serial_number': 'C718B3-2', + 'suggested_area': None, + 'sw_version': '5.0.18', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.haa_c718b3_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HAA-C718B3 Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_7', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'identify', + 'friendly_name': 'HAA-C718B3 Identify', + }), + 'entity_id': 'button.haa_c718b3_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'categories': dict({ + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.haa_c718b3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HAA-C718B3', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HAA-C718B3', + }), + 'entity_id': 'switch.haa_c718b3', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[haa_fan] list([ dict({ diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index a660e29ca17..a8852aac4f7 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -34,7 +34,7 @@ async def test_switch_change_alarm_state(hass: HomeAssistant) -> None: await hass.services.async_call( "alarm_control_panel", "alarm_arm_home", - {"entity_id": "alarm_control_panel.testdevice"}, + {"entity_id": "alarm_control_panel.testdevice", "code": "1234"}, blocking=True, ) helper.async_assert_service_values( @@ -47,7 +47,7 @@ async def test_switch_change_alarm_state(hass: HomeAssistant) -> None: await hass.services.async_call( "alarm_control_panel", "alarm_arm_away", - {"entity_id": "alarm_control_panel.testdevice"}, + {"entity_id": "alarm_control_panel.testdevice", "code": "1234"}, blocking=True, ) helper.async_assert_service_values( @@ -60,7 +60,7 @@ async def test_switch_change_alarm_state(hass: HomeAssistant) -> None: await hass.services.async_call( "alarm_control_panel", "alarm_arm_night", - {"entity_id": "alarm_control_panel.testdevice"}, + {"entity_id": "alarm_control_panel.testdevice", "code": "1234"}, blocking=True, ) helper.async_assert_service_values( @@ -73,7 +73,7 @@ async def test_switch_change_alarm_state(hass: HomeAssistant) -> None: await hass.services.async_call( "alarm_control_panel", "alarm_disarm", - {"entity_id": "alarm_control_panel.testdevice"}, + {"entity_id": "alarm_control_panel.testdevice", "code": "1234"}, blocking=True, ) helper.async_assert_service_values( diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index 0d76ac98fbe..9f935569333 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -61,7 +61,7 @@ async def test_press_button(hass: HomeAssistant) -> None: button.async_assert_service_values( ServicesTypes.OUTLET, { - CharacteristicsTypes.VENDOR_HAA_SETUP: "#HAA@trcmd", + CharacteristicsTypes.VENDOR_HAA_SETUP: "#HAA@trcmd", # codespell:ignore haa }, ) diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 0a77509d675..0f2cdb7c9db 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -118,7 +118,7 @@ async def test_migrate_device_id_no_serial_skip_if_other_owner( bridge = device_registry.async_get(bridge.id) assert bridge.identifiers == variant.before - assert bridge.config_entries == {entry.entry_id} + assert bridge.config_entries == [entry.entry_id] @pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 671e9779d30..2157eb51212 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -3,6 +3,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -94,6 +95,24 @@ def create_window_covering_service_with_v_tilt_2(accessory): tilt_target.maxValue = 0 +def create_window_covering_service_with_none_tilt(accessory): + """Define a window-covering characteristics as per page 219 of HAP spec. + + This accessory uses None for the tilt value unexpectedly. + """ + service = create_window_covering_service(accessory) + + tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT) + tilt_current.value = None + tilt_current.minValue = -90 + tilt_current.maxValue = 0 + + tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET) + tilt_target.value = None + tilt_target.minValue = -90 + tilt_target.maxValue = 0 + + 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) @@ -212,6 +231,21 @@ async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: assert state.attributes["current_tilt_position"] == 83 +async def test_read_window_cover_tilt_missing_tilt(hass: HomeAssistant) -> None: + """Test that missing tilt is handled.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_none_tilt + ) + + await helper.async_update( + ServicesTypes.WINDOW_COVERING, + {CharacteristicsTypes.OBSTRUCTION_DETECTED: True}, + ) + state = await helper.poll_and_get_state() + assert "current_tilt_position" not in state.attributes + assert state.state != STATE_UNAVAILABLE + + async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index b5a9aee72b1..43572f56d50 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -9,7 +9,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.homekit_controller.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -24,7 +24,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -239,7 +239,7 @@ async def test_handle_events( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test that events are handled.""" helper = await setup_test_component(hass, create_remote) @@ -359,7 +359,7 @@ async def test_handle_events_late_setup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test that events are handled when setup happens after startup.""" helper = await setup_test_component(hass, create_remote) diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index db7fead9139..542d87d0b0e 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -70,7 +70,7 @@ async def test_async_remove_entry(hass: HomeAssistant) -> None: assert hkid in hass.data[ENTITY_MAP].storage_data # Remove it via config entry and number of pairings should go down - await helper.config_entry.async_remove(hass) + await hass.config_entries.async_remove(helper.config_entry.entry_id) assert len(controller.pairings) == 0 assert hkid not in hass.data[ENTITY_MAP].storage_data diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 606a9e75eb1..c2644735ecb 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -364,7 +364,7 @@ async def test_light_unloaded_removed(hass: HomeAssistant) -> None: state = await helper.poll_and_get_state() assert state.state == "off" - unload_result = await helper.config_entry.async_unload(hass) + unload_result = await hass.config_entries.async_unload(helper.config_entry.entry_id) assert unload_result is True # Make sure entity is set to unavailable state @@ -374,11 +374,11 @@ async def test_light_unloaded_removed(hass: HomeAssistant) -> None: conn = hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] assert not conn.pollable_characteristics - await helper.config_entry.async_remove(hass) + await hass.config_entries.async_remove(helper.config_entry.entry_id) await hass.async_block_till_done() # Make sure entity is removed - assert hass.states.get(helper.entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(helper.entity_id) is None async def test_migrate_unique_id( diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 8634b33fe3b..461d62742a5 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -8,6 +8,7 @@ from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, Threa from aiohomekit.model.services import ServicesTypes from aiohomekit.protocol.statuscodes import HapStatusCode from aiohomekit.testing import FakePairing +import pytest from homeassistant.components.homekit_controller.sensor import ( thread_node_capability_to_str, @@ -381,11 +382,8 @@ def test_thread_status_to_str() -> None: assert thread_status_to_str(ThreadStatus.DISABLED) == "disabled" -async def test_rssi_sensor( - hass: HomeAssistant, - entity_registry_enabled_by_default: None, - enable_bluetooth: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +async def test_rssi_sensor(hass: HomeAssistant) -> None: """Test an rssi sensor.""" inject_bluetooth_service_info(hass, TEST_DEVICE_SERVICE_INFO) @@ -405,11 +403,9 @@ async def test_rssi_sensor( assert hass.states.get("sensor.testdevice_signal_strength").state == "-56" +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") async def test_migrate_rssi_sensor_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - entity_registry_enabled_by_default: None, - enable_bluetooth: None, + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test an rssi sensor unique id migration.""" rssi_sensor = entity_registry.async_get_or_create( diff --git a/tests/components/homekit_controller/test_utils.py b/tests/components/homekit_controller/test_utils.py index 703cf288f63..92c7e4c5a4d 100644 --- a/tests/components/homekit_controller/test_utils.py +++ b/tests/components/homekit_controller/test_utils.py @@ -3,7 +3,7 @@ from homeassistant.components.homekit_controller.utils import unique_id_to_iids -def test_unique_id_to_iids(): +def test_unique_id_to_iids() -> None: """Check that unique_id_to_iids is safe against different invalid ids.""" assert unique_id_to_iids("pairingid_1_2_3") == (1, 2, 3) assert unique_id_to_iids("pairingid_1_2") == (1, 2, None) diff --git a/tests/components/homematic/test_notify.py b/tests/components/homematic/test_notify.py index 33c9b0f359e..a07bece9850 100644 --- a/tests/components/homematic/test_notify.py +++ b/tests/components/homematic/test_notify.py @@ -1,6 +1,6 @@ """The tests for the Homematic notification platform.""" -import homeassistant.components.notify as notify_comp +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -14,7 +14,7 @@ async def test_setup_full(hass: HomeAssistant) -> None: "homematic", {"homematic": {"hosts": {"ccu2": {"host": "127.0.0.1"}}}}, ) - with assert_setup_component(1) as handle_config: + with assert_setup_component(1, domain="notify") as handle_config: assert await async_setup_component( hass, "notify", @@ -30,7 +30,7 @@ async def test_setup_full(hass: HomeAssistant) -> None: } }, ) - assert handle_config[notify_comp.DOMAIN] + assert handle_config[NOTIFY_DOMAIN] async def test_setup_without_optional(hass: HomeAssistant) -> None: @@ -40,7 +40,7 @@ async def test_setup_without_optional(hass: HomeAssistant) -> None: "homematic", {"homematic": {"hosts": {"ccu2": {"host": "127.0.0.1"}}}}, ) - with assert_setup_component(1) as handle_config: + with assert_setup_component(1, domain="notify") as handle_config: assert await async_setup_component( hass, "notify", @@ -55,12 +55,12 @@ async def test_setup_without_optional(hass: HomeAssistant) -> None: } }, ) - assert handle_config[notify_comp.DOMAIN] + assert handle_config[NOTIFY_DOMAIN] async def test_bad_config(hass: HomeAssistant) -> None: """Test invalid configuration.""" - config = {notify_comp.DOMAIN: {"name": "test", "platform": "homematic"}} - with assert_setup_component(0) as handle_config: - assert await async_setup_component(hass, notify_comp.DOMAIN, config) - assert not handle_config[notify_comp.DOMAIN] + config = {NOTIFY_DOMAIN: {"name": "test", "platform": "homematic"}} + with assert_setup_component(0, domain="notify") as handle_config: + assert await async_setup_component(hass, NOTIFY_DOMAIN, config) + assert not handle_config[NOTIFY_DOMAIN] diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 3f87f12d9fc..a43a342478b 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -38,7 +38,7 @@ def mock_connection_fixture() -> AsyncConnection: def _rest_call_side_effect(path, body=None): return path, body - connection._restCall.side_effect = _rest_call_side_effect + connection._rest_call.side_effect = _rest_call_side_effect connection.api_call = AsyncMock(return_value=True) connection.init = AsyncMock(side_effect=True) diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index 4632b9107af..e7d7350f98e 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -77,14 +77,14 @@ class HomeFactory: hass: HomeAssistant, mock_connection, hmip_config_entry: config_entries.ConfigEntry, - ): + ) -> None: """Initialize the Factory.""" self.hass = hass self.mock_connection = mock_connection self.hmip_config_entry = hmip_config_entry async def async_get_mock_hap( - self, test_devices=[], test_groups=[] + self, test_devices=None, test_groups=None ) -> HomematicipHAP: """Create a mocked homematic access point.""" home_name = self.hmip_config_entry.data["name"] @@ -130,7 +130,9 @@ class HomeTemplate(Home): _typeGroupMap = TYPE_GROUP_MAP _typeSecurityEventMap = TYPE_SECURITY_EVENT_MAP - def __init__(self, connection=None, home_name="", test_devices=[], test_groups=[]): + def __init__( + self, connection=None, home_name="", test_devices=None, test_groups=None + ): """Init template with connection.""" super().__init__(connection=connection) self.name = home_name diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index 5135c0ec48a..0b5e81dd703 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -2,8 +2,7 @@ from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.button.const import SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 3cb8b7d61e9..2da32b2844d 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -88,7 +88,8 @@ async def test_hap_setup_works(hass: HomeAssistant) -> None: home = Mock() hap = HomematicipHAP(hass, entry) with patch.object(hap, "get_hap", return_value=home): - assert await hap.async_setup() + async with entry.setup_lock: + assert await hap.async_setup() assert hap.home is home @@ -96,14 +97,17 @@ async def test_hap_setup_works(hass: HomeAssistant) -> None: async def test_hap_setup_connection_error() -> None: """Test a failed accesspoint setup.""" hass = Mock() - entry = Mock() - entry.data = {HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"} + entry = MockConfigEntry( + domain=HMIPC_DOMAIN, + data={HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"}, + ) hap = HomematicipHAP(hass, entry) with ( patch.object(hap, "get_hap", side_effect=HmipcConnectionError), pytest.raises(ConfigEntryNotReady), ): - assert not await hap.async_setup() + async with entry.setup_lock: + assert not await hap.async_setup() assert not hass.async_run_hass_job.mock_calls assert not hass.config_entries.flow.async_init.mock_calls @@ -132,7 +136,8 @@ async def test_hap_create( hap = HomematicipHAP(hass, hmip_config_entry) assert hap with patch.object(hap, "async_connect"): - assert await hap.async_setup() + async with hmip_config_entry.setup_lock: + assert await hap.async_setup() async def test_hap_create_exception( diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index bc661da390d..eb638492941 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,11 +1,11 @@ """Fixtures for HomeWizard integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.errors import NotFoundError from homewizard_energy.models import Data, Device, State, System import pytest +from typing_extensions import Generator from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS @@ -62,7 +62,7 @@ def mock_homewizardenergy( @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.homewizard.async_setup_entry", return_value=True @@ -102,7 +102,7 @@ async def init_integration( @pytest.fixture -def mock_onboarding() -> Generator[MagicMock, None, None]: +def mock_onboarding() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", diff --git a/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/data.json b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/data.json new file mode 100644 index 00000000000..830a74ea0ee --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/data.json @@ -0,0 +1,82 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 100, + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "unique_id": "00112233445566778899AABBCCDDEEFF", + "active_tariff": 2, + "total_power_import_kwh": 13779.338, + "total_power_import_t1_kwh": 10830.511, + "total_power_import_t2_kwh": 2948.827, + "total_power_import_t3_kwh": 2948.827, + "total_power_import_t4_kwh": 2948.827, + "total_power_export_kwh": 13086.777, + "total_power_export_t1_kwh": 4321.333, + "total_power_export_t2_kwh": 8765.444, + "total_power_export_t3_kwh": 8765.444, + "total_power_export_t4_kwh": 8765.444, + "active_power_w": -123, + "active_power_l1_w": -123, + "active_power_l2_w": 456, + "active_power_l3_w": 123.456, + "active_voltage_l1_v": 230.111, + "active_voltage_l2_v": 230.222, + "active_voltage_l3_v": 230.333, + "active_current_l1_a": -4, + "active_current_l2_a": 2, + "active_current_l3_a": 0, + "active_frequency_hz": 50, + "voltage_sag_l1_count": 1, + "voltage_sag_l2_count": 2, + "voltage_sag_l3_count": 3, + "voltage_swell_l1_count": 4, + "voltage_swell_l2_count": 5, + "voltage_swell_l3_count": 6, + "any_power_fail_count": 4, + "long_power_fail_count": 5, + "total_gas_m3": 1122.333, + "gas_timestamp": 210314112233, + "gas_unique_id": "00000000000000000000000000000000", + "active_power_average_w": 123.0, + "montly_power_peak_w": 1111.0, + "montly_power_peak_timestamp": 230101080010, + "active_liter_lpm": 12.345, + "total_liter_m3": 1234.567, + "external": [ + { + "unique_id": "00000000000000000000000000000000", + "type": "gas_meter", + "timestamp": 230125220957, + "value": 111.111, + "unit": "m3" + }, + { + "unique_id": "00000000000000000000000000000000", + "type": "water_meter", + "timestamp": 230125220957, + "value": 222.222, + "unit": "m3" + }, + { + "unique_id": "00000000000000000000000000000000", + "type": "warm_water_meter", + "timestamp": 230125220957, + "value": 333.333, + "unit": "m3" + }, + { + "unique_id": "00000000000000000000000000000000", + "type": "heat_meter", + "timestamp": 230125220957, + "value": 444.444, + "unit": "GJ" + }, + { + "unique_id": "00000000000000000000000000000000", + "type": "inlet_heat_meter", + "timestamp": 230125220957, + "value": 555.555, + "unit": "m3" + } + ] +} diff --git a/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/device.json b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/device.json new file mode 100644 index 00000000000..4972c491859 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 meter", + "serial": "3c39e7aabbcc", + "firmware_version": "4.19", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/system.json b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/system.json new file mode 100644 index 00000000000..362491b3519 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1-invalid-EAN/system.json @@ -0,0 +1,3 @@ +{ + "cloud_enabled": true +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index ed744083373..7b82056aacb 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -199,7 +199,7 @@ 'active_voltage_v': None, 'any_power_fail_count': 4, 'external_devices': dict({ - 'G001': dict({ + 'gas_meter_G001': dict({ 'meter_type': dict({ '__type': "", 'repr': '', @@ -209,7 +209,7 @@ 'unit': 'm3', 'value': 111.111, }), - 'H001': dict({ + 'heat_meter_H001': dict({ 'meter_type': dict({ '__type': "", 'repr': '', @@ -219,7 +219,7 @@ 'unit': 'GJ', 'value': 444.444, }), - 'IH001': dict({ + 'inlet_heat_meter_IH001': dict({ 'meter_type': dict({ '__type': "", 'repr': '', @@ -229,17 +229,7 @@ 'unit': 'm3', 'value': 555.555, }), - 'W001': dict({ - 'meter_type': dict({ - '__type': "", - 'repr': '', - }), - 'timestamp': '2023-01-25T22:09:57', - 'unique_id': '**REDACTED**', - 'unit': 'm3', - 'value': 222.222, - }), - 'WW001': dict({ + 'warm_water_meter_WW001': dict({ 'meter_type': dict({ '__type': "", 'repr': '', @@ -249,6 +239,16 @@ 'unit': 'm3', 'value': 333.333, }), + 'water_meter_W001': dict({ + 'meter_type': dict({ + '__type': "", + 'repr': '', + }), + 'timestamp': '2023-01-25T22:09:57', + 'unique_id': '**REDACTED**', + 'unit': 'm3', + 'value': 222.222, + }), }), 'gas_timestamp': '2021-03-14T11:22:33', 'gas_unique_id': '**REDACTED**', diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 0503085b7e6..5e8ddc0d6be 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': 'aabbccddeeff_total_gas_m3', 'supported_features': 0, 'translation_key': None, - 'unique_id': 'homewizard_01FFEEDDCCBBAA99887766554433221100', + 'unique_id': 'homewizard_gas_meter_01FFEEDDCCBBAA99887766554433221100', 'unit_of_measurement': None, }) # --- @@ -6547,7 +6547,7 @@ 'identifiers': set({ tuple( 'homewizard', - 'G001', + 'gas_meter_G001', ), }), 'is_new': False, @@ -6557,7 +6557,7 @@ 'model': 'HWE-P1', 'name': 'Gas meter', 'name_by_user': None, - 'serial_number': 'G001', + 'serial_number': 'gas_meter_G001', 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -6594,7 +6594,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'homewizard_G001', + 'unique_id': 'homewizard_gas_meter_G001', 'unit_of_measurement': , }) # --- @@ -6628,7 +6628,7 @@ 'identifiers': set({ tuple( 'homewizard', - 'H001', + 'heat_meter_H001', ), }), 'is_new': False, @@ -6638,7 +6638,7 @@ 'model': 'HWE-P1', 'name': 'Heat meter', 'name_by_user': None, - 'serial_number': 'H001', + 'serial_number': 'heat_meter_H001', 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -6675,7 +6675,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'homewizard_H001', + 'unique_id': 'homewizard_heat_meter_H001', 'unit_of_measurement': 'GJ', }) # --- @@ -6709,7 +6709,7 @@ 'identifiers': set({ tuple( 'homewizard', - 'IH001', + 'inlet_heat_meter_IH001', ), }), 'is_new': False, @@ -6719,7 +6719,7 @@ 'model': 'HWE-P1', 'name': 'Inlet heat meter', 'name_by_user': None, - 'serial_number': 'IH001', + 'serial_number': 'inlet_heat_meter_IH001', 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -6756,7 +6756,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'homewizard_IH001', + 'unique_id': 'homewizard_inlet_heat_meter_IH001', 'unit_of_measurement': , }) # --- @@ -6789,7 +6789,7 @@ 'identifiers': set({ tuple( 'homewizard', - 'WW001', + 'warm_water_meter_WW001', ), }), 'is_new': False, @@ -6799,7 +6799,7 @@ 'model': 'HWE-P1', 'name': 'Warm water meter', 'name_by_user': None, - 'serial_number': 'WW001', + 'serial_number': 'warm_water_meter_WW001', 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -6836,7 +6836,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'homewizard_WW001', + 'unique_id': 'homewizard_warm_water_meter_WW001', 'unit_of_measurement': , }) # --- @@ -6870,7 +6870,7 @@ 'identifiers': set({ tuple( 'homewizard', - 'W001', + 'water_meter_W001', ), }), 'is_new': False, @@ -6880,7 +6880,7 @@ 'model': 'HWE-P1', 'name': 'Water meter', 'name_by_user': None, - 'serial_number': 'W001', + 'serial_number': 'water_meter_W001', 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -6917,7 +6917,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'homewizard_W001', + 'unique_id': 'homewizard_water_meter_W001', 'unit_of_measurement': , }) # --- @@ -6937,6 +6937,3678 @@ 'state': '222.222', }) # --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_average_demand:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_average_demand:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_average_demand', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Average demand', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_average_w', + 'unique_id': 'aabbccddeeff_active_power_average_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_average_demand:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Average demand', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_average_demand', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.0', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l1_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-4', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l2_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_current_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_current_phase_a', + 'unique_id': 'aabbccddeeff_active_current_l3_a', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_current_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Device Current phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_current_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_dsmr_version:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_dsmr_version:entity-registry] + 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.device_dsmr_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DSMR version', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dsmr_version', + 'unique_id': 'aabbccddeeff_smr_version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_dsmr_version:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device DSMR version', + }), + 'context': , + 'entity_id': 'sensor.device_dsmr_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13086.777', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4321.333', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy export tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_export_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_export_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_export_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy export tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_export_tariff_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8765.444', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13779.338', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t1_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10830.511', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t2_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t3_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_4:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_4:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy import tariff 4', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy_import_tariff_kwh', + 'unique_id': 'aabbccddeeff_total_power_import_t4_kwh', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_energy_import_tariff_4:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Device Energy import tariff 4', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_energy_import_tariff_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2948.827', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_frequency:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_frequency:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_frequency_hz', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_frequency:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Device Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_long_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_long_power_failures_detected:entity-registry] + 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.device_long_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Long power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'long_power_fail_count', + 'unique_id': 'aabbccddeeff_long_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_long_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Long power failures detected', + }), + 'context': , + 'entity_id': 'sensor.device_long_power_failures_detected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_peak_demand_current_month:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_peak_demand_current_month:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_peak_demand_current_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak demand current month', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_power_peak_w', + 'unique_id': 'aabbccddeeff_monthly_power_peak_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_peak_demand_current_month:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Peak demand current month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_peak_demand_current_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1111.0', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_active_power_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_failures_detected:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_failures_detected:entity-registry] + 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.device_power_failures_detected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power failures detected', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'any_power_fail_count', + 'unique_id': 'aabbccddeeff_any_power_fail_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_failures_detected:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Power failures detected', + }), + 'context': , + 'entity_id': 'sensor.device_power_failures_detected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l1_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-123', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l2_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '456', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_power_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_power_phase_w', + 'unique_id': 'aabbccddeeff_active_power_l3_w', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_power_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Device Power phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_power_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_smart_meter_identifier:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_smart_meter_identifier:entity-registry] + 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.device_smart_meter_identifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart meter identifier', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'unique_meter_id', + 'unique_id': 'aabbccddeeff_unique_meter_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_smart_meter_identifier:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Smart meter identifier', + }), + 'context': , + 'entity_id': 'sensor.device_smart_meter_identifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '00112233445566778899AABBCCDDEEFF', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_smart_meter_model:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_smart_meter_model:entity-registry] + 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.device_smart_meter_model', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart meter model', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_model', + 'unique_id': 'aabbccddeeff_meter_model', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_smart_meter_model:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Smart meter model', + }), + 'context': , + 'entity_id': 'sensor.device_smart_meter_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ISKRA 2M550T-101', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_tariff:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_tariff:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_tariff', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tariff', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_tariff', + 'unique_id': 'aabbccddeeff_active_tariff', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_tariff:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Device Tariff', + 'options': list([ + '1', + '2', + '3', + '4', + ]), + }), + 'context': , + 'entity_id': 'sensor.device_tariff', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_total_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_total_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_total_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_liter_m3', + 'unique_id': 'aabbccddeeff_total_liter_m3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_total_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Device Total water usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_total_water_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.567', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_1:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l1_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.111', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_2:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l2_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.222', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_3:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_voltage_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_voltage_phase_v', + 'unique_id': 'aabbccddeeff_active_voltage_l3_v', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Device Voltage phase 3', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.device_voltage_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.333', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_1:entity-registry] + 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.device_voltage_sags_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voltage sags detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_phase_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 1', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_2:entity-registry] + 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.device_voltage_sags_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voltage sags detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_phase_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 2', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_3:entity-registry] + 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.device_voltage_sags_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voltage sags detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_sag_phase_count', + 'unique_id': 'aabbccddeeff_voltage_sag_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_sags_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage sags detected phase 3', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_sags_detected_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_1:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_1:entity-registry] + 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.device_voltage_swells_detected_phase_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voltage swells detected phase 1', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_phase_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l1_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_1:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 1', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_2:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_2:entity-registry] + 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.device_voltage_swells_detected_phase_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voltage swells detected phase 2', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_phase_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l2_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_2:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 2', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_3:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_3:entity-registry] + 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.device_voltage_swells_detected_phase_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voltage swells detected phase 3', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_swell_phase_count', + 'unique_id': 'aabbccddeeff_voltage_swell_l3_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_voltage_swells_detected_phase_3:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Voltage swells detected phase 3', + }), + 'context': , + 'entity_id': 'sensor.device_voltage_swells_detected_phase_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_water_usage:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_water_usage:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.device_water_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water usage', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'active_liter_lpm', + 'unique_id': 'aabbccddeeff_active_liter_lpm', + 'unit_of_measurement': 'l/min', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_water_usage:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Water usage', + 'state_class': , + 'unit_of_measurement': 'l/min', + }), + 'context': , + 'entity_id': 'sensor.device_water_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.345', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_wi_fi_ssid:entity-registry] + 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.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'aabbccddeeff_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:39:e7:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '3c39e7aabbcc', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'aabbccddeeff_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.gas_meter_gas:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Gas meter', + 'name_by_user': None, + 'serial_number': 'gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.gas_meter_gas:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gas_meter_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_gas_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.gas_meter_gas:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas meter Gas', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gas_meter_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111.111', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.heat_meter_energy:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Heat meter', + 'name_by_user': None, + 'serial_number': 'heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.heat_meter_energy:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.heat_meter_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'unit_of_measurement': 'GJ', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.heat_meter_energy:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat meter Energy', + 'state_class': , + 'unit_of_measurement': 'GJ', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '444.444', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.inlet_heat_meter_none:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Inlet heat meter', + 'name_by_user': None, + 'serial_number': 'inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.inlet_heat_meter_none:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inlet_heat_meter_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_inlet_heat_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.inlet_heat_meter_none:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inlet heat meter None', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inlet_heat_meter_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '555.555', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.warm_water_meter_water:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Warm water meter', + 'name_by_user': None, + 'serial_number': 'warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.warm_water_meter_water:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.warm_water_meter_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_warm_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.warm_water_meter_water:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Warm water meter Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.warm_water_meter_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '333.333', + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.water_meter_water:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'HWE-P1', + 'name': 'Water meter', + 'name_by_user': None, + 'serial_number': 'water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.water_meter_water:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.water_meter_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'homewizard_water_meter_\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[HWE-P1-invalid-EAN-entity_ids9][sensor.water_meter_water:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Water meter Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.water_meter_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '222.222', + }) +# --- # name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_average_demand:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 438df8ab869..969be7a604c 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -241,3 +241,62 @@ async def test_sensor_migration_does_not_trigger( assert entity assert entity.unique_id == new_unique_id assert entity.previous_unique_id is None + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-P1", + "homewizard_G001", + "homewizard_gas_meter_G001", + ), + ( + "HWE-P1", + "homewizard_W001", + "homewizard_water_meter_W001", + ), + ( + "HWE-P1", + "homewizard_WW001", + "homewizard_warm_water_meter_WW001", + ), + ( + "HWE-P1", + "homewizard_H001", + "homewizard_heat_meter_H001", + ), + ( + "HWE-P1", + "homewizard_IH001", + "homewizard_inlet_heat_meter_IH001", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_external_sensor_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test unique ID or External sensors are migrated.""" + mock_config_entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + assert entity_migrated.previous_unique_id == old_unique_id diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 5a1b25c69bb..abcd6a879c5 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -244,6 +244,55 @@ pytestmark = [ "sensor.device_wi_fi_strength", ], ), + ( + "HWE-P1-invalid-EAN", + [ + "sensor.device_average_demand", + "sensor.device_current_phase_1", + "sensor.device_current_phase_2", + "sensor.device_current_phase_3", + "sensor.device_dsmr_version", + "sensor.device_energy_export_tariff_1", + "sensor.device_energy_export_tariff_2", + "sensor.device_energy_export_tariff_3", + "sensor.device_energy_export_tariff_4", + "sensor.device_energy_export", + "sensor.device_energy_import_tariff_1", + "sensor.device_energy_import_tariff_2", + "sensor.device_energy_import_tariff_3", + "sensor.device_energy_import_tariff_4", + "sensor.device_energy_import", + "sensor.device_frequency", + "sensor.device_long_power_failures_detected", + "sensor.device_peak_demand_current_month", + "sensor.device_power_failures_detected", + "sensor.device_power_phase_1", + "sensor.device_power_phase_2", + "sensor.device_power_phase_3", + "sensor.device_power", + "sensor.device_smart_meter_identifier", + "sensor.device_smart_meter_model", + "sensor.device_tariff", + "sensor.device_total_water_usage", + "sensor.device_voltage_phase_1", + "sensor.device_voltage_phase_2", + "sensor.device_voltage_phase_3", + "sensor.device_voltage_sags_detected_phase_1", + "sensor.device_voltage_sags_detected_phase_2", + "sensor.device_voltage_sags_detected_phase_3", + "sensor.device_voltage_swells_detected_phase_1", + "sensor.device_voltage_swells_detected_phase_2", + "sensor.device_voltage_swells_detected_phase_3", + "sensor.device_water_usage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", + "sensor.gas_meter_gas", + "sensor.heat_meter_energy", + "sensor.inlet_heat_meter_none", + "sensor.warm_water_meter_water", + "sensor.water_meter_water", + ], + ), ], ) async def test_sensors( diff --git a/tests/components/homeworks/conftest.py b/tests/components/homeworks/conftest.py index ccff56ae3d1..ca0e08e9215 100644 --- a/tests/components/homeworks/conftest.py +++ b/tests/components/homeworks/conftest.py @@ -1,9 +1,9 @@ """Common fixtures for the Lutron Homeworks Series 4 and 8 tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.homeworks.const import ( CONF_ADDR, @@ -88,7 +88,7 @@ def mock_empty_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_homeworks() -> Generator[None, MagicMock, None]: +def mock_homeworks() -> Generator[MagicMock]: """Return a mocked Homeworks client.""" with ( patch( @@ -103,7 +103,7 @@ def mock_homeworks() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.homeworks.async_setup_entry", return_value=True diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index d00b5a13150..8f5334b21f9 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -9,21 +9,17 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.homeworks.const import ( CONF_ADDR, - CONF_DIMMERS, CONF_INDEX, - CONF_KEYPADS, CONF_LED, CONF_NUMBER, CONF_RATE, CONF_RELEASE_DELAY, DOMAIN, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_RECONFIGURE, SOURCE_USER +from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT 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 tests.common import MockConfigEntry @@ -129,114 +125,6 @@ async def test_user_flow_cannot_connect( assert result["step_id"] == "user" -async def test_import_flow( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - mock_homeworks: MagicMock, - mock_setup_entry, -) -> None: - """Test importing yaml config.""" - entry = entity_registry.async_get_or_create( - LIGHT_DOMAIN, DOMAIN, "homeworks.[02:08:01:01]" - ) - - mock_controller = MagicMock() - mock_homeworks.return_value = mock_controller - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_HOST: "192.168.0.1", - CONF_PORT: 1234, - CONF_DIMMERS: [ - { - CONF_ADDR: "[02:08:01:01]", - CONF_NAME: "Foyer Sconces", - CONF_RATE: 1.0, - } - ], - CONF_KEYPADS: [ - { - CONF_ADDR: "[02:08:02:01]", - CONF_NAME: "Foyer Keypad", - } - ], - }, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "import_controller_name" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_NAME: "Main controller"} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "import_finish" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Main controller" - assert result["data"] == {} - assert result["options"] == { - "controller_id": "main_controller", - "dimmers": [{"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 1.0}], - "host": "192.168.0.1", - "keypads": [ - { - "addr": "[02:08:02:01]", - "buttons": [], - "name": "Foyer Keypad", - } - ], - "port": 1234, - } - assert len(issue_registry.issues) == 0 - - # Check unique ID is updated in entity registry - entry = entity_registry.async_get(entry.id) - assert entry.unique_id == "homeworks.main_controller.[02:08:01:01].0" - - -async def test_import_flow_already_exists( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_empty_config_entry: MockConfigEntry, -) -> None: - """Test importing yaml config where entry already exists.""" - mock_empty_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "192.168.0.1", "port": 1234, CONF_DIMMERS: [], CONF_KEYPADS: []}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - assert len(issue_registry.issues) == 1 - - -async def test_import_flow_controller_id_exists( - hass: HomeAssistant, mock_empty_config_entry: MockConfigEntry -) -> None: - """Test importing yaml config where entry already exists.""" - mock_empty_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "192.168.0.2", "port": 1234, CONF_DIMMERS: [], CONF_KEYPADS: []}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "import_controller_name" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_NAME: "Main controller"} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "import_controller_name" - assert result["errors"] == {"base": "duplicated_controller_id"} - - async def test_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock ) -> None: diff --git a/tests/components/homeworks/test_init.py b/tests/components/homeworks/test_init.py index 1969bb448ec..87aabb6258f 100644 --- a/tests/components/homeworks/test_init.py +++ b/tests/components/homeworks/test_init.py @@ -6,39 +6,14 @@ from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED import pytest from homeassistant.components.homeworks import EVENT_BUTTON_PRESS, EVENT_BUTTON_RELEASE -from homeassistant.components.homeworks.const import CONF_DIMMERS, CONF_KEYPADS, DOMAIN +from homeassistant.components.homeworks.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_capture_events -async def test_import( - hass: HomeAssistant, - mock_homeworks: MagicMock, -) -> None: - """Test the Homeworks YAML import.""" - await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_HOST: "192.168.0.1", - CONF_PORT: 1234, - CONF_DIMMERS: [], - CONF_KEYPADS: [], - } - }, - ) - await hass.async_block_till_done() - - assert len(hass.config_entries.flow.async_progress()) == 1 - assert hass.config_entries.flow.async_progress()[0]["context"]["source"] == "import" - - async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index a77c0aaed7e..cdd767f019d 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, create_autospec, patch +from aiohttp.client_exceptions import ClientConnectionError import aiosomecomfort import pytest @@ -120,11 +121,23 @@ async def test_login_error( assert config_entry.state is ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize( + "the_error", + [ + aiosomecomfort.ConnectionError, + aiosomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.SomeComfortError, + ClientConnectionError, + ], +) async def test_connection_error( - hass: HomeAssistant, client: MagicMock, config_entry: MagicMock + hass: HomeAssistant, + client: MagicMock, + config_entry: MagicMock, + the_error: Exception, ) -> None: """Test Connection errors from API.""" - client.login.side_effect = aiosomecomfort.ConnectionError + client.login.side_effect = the_error await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index ec14b38cd69..f54ec9fa8f7 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -83,7 +83,7 @@ async def mock_client(hass, hass_client, registrations=None): return await hass_client() -async def test_get_service_with_no_json(hass: HomeAssistant): +async def test_get_service_with_no_json(hass: HomeAssistant) -> None: """Test empty json file.""" await async_setup_component(hass, "http", {}) m = mock_open() @@ -94,7 +94,7 @@ async def test_get_service_with_no_json(hass: HomeAssistant): @patch("homeassistant.components.html5.notify.WebPusher") -async def test_dismissing_message(mock_wp, hass: HomeAssistant): +async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: """Test dismissing message.""" await async_setup_component(hass, "http", {}) mock_wp().send().status_code = 201 @@ -123,7 +123,7 @@ async def test_dismissing_message(mock_wp, hass: HomeAssistant): @patch("homeassistant.components.html5.notify.WebPusher") -async def test_sending_message(mock_wp, hass: HomeAssistant): +async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: """Test sending message.""" await async_setup_component(hass, "http", {}) mock_wp().send().status_code = 201 @@ -154,7 +154,7 @@ async def test_sending_message(mock_wp, hass: HomeAssistant): @patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_key_include(mock_wp, hass: HomeAssistant): +async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: """Test if the FCM header is included.""" await async_setup_component(hass, "http", {}) mock_wp().send().status_code = 201 @@ -179,7 +179,7 @@ async def test_fcm_key_include(mock_wp, hass: HomeAssistant): @patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant): +async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) mock_wp().send().status_code = 201 @@ -204,7 +204,7 @@ async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant): @patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_no_targets(mock_wp, hass: HomeAssistant): +async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) mock_wp().send().status_code = 201 @@ -229,7 +229,7 @@ async def test_fcm_no_targets(mock_wp, hass: HomeAssistant): @patch("homeassistant.components.html5.notify.WebPusher") -async def test_fcm_additional_data(mock_wp, hass: HomeAssistant): +async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: """Test if the gcm_key is only included for GCM endpoints.""" await async_setup_component(hass, "http", {}) mock_wp().send().status_code = 201 diff --git a/tests/components/http/conftest.py b/tests/components/http/conftest.py index 60b1b73ff83..5c10278040c 100644 --- a/tests/components/http/conftest.py +++ b/tests/components/http/conftest.py @@ -1,9 +1,17 @@ """Test configuration for http.""" +from asyncio import AbstractEventLoop + import pytest +from tests.typing import ClientSessionGenerator + @pytest.fixture -def aiohttp_client(event_loop, aiohttp_client, socket_enabled): +def aiohttp_client( + event_loop: AbstractEventLoop, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, +) -> ClientSessionGenerator: """Return aiohttp_client and allow opening sockets.""" return aiohttp_client diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index afff8294f0c..20dfe0a3710 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,28 +1,21 @@ """The tests for the Home Assistant HTTP component.""" -from collections.abc import Awaitable, Callable from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging from unittest.mock import Mock, patch -from aiohttp import BasicAuth, ServerDisconnectedError, web -from aiohttp.test_utils import TestClient +from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from aiohttp_session import get_session import jwt import pytest import yarl -from yarl import URL from homeassistant.auth.const import GROUP_ID_READ_ONLY -from homeassistant.auth.models import RefreshToken, User +from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks -from homeassistant.auth.providers.legacy_api_password import ( - LegacyApiPasswordAuthProvider, -) -from homeassistant.auth.session import SESSION_ID, TEMP_TIMEOUT +from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import websocket_api from homeassistant.components.http import KEY_HASS from homeassistant.components.http.auth import ( @@ -30,12 +23,11 @@ from homeassistant.components.http.auth import ( DATA_SIGN_SECRET, SIGN_QUERY_PARAM, STORAGE_KEY, - STRICT_CONNECTION_GUARD_PAGE, async_setup_auth, async_sign_path, async_user_not_allowed_do_auth, ) -from homeassistant.components.http.const import KEY_AUTHENTICATED, StrictConnectionMode +from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, @@ -43,11 +35,10 @@ from homeassistant.components.http.request_context import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from . import HTTP_HEADER_HA_AUTH -from tests.common import MockUser, async_fire_time_changed +from tests.common import MockUser from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -83,14 +74,6 @@ async def mock_handler(request): return web.json_response(data={"user_id": user_id}) -async def get_legacy_user(auth): - """Get the user in legacy_api_password auth provider.""" - provider = auth.get_auth_provider("legacy_api_password", None) - return await auth.async_get_or_create_user( - await provider.async_get_or_create_credentials({}) - ) - - @pytest.fixture def app(hass): """Fixture to set up a web.Application.""" @@ -133,11 +116,11 @@ async def test_auth_middleware_loaded_by_default(hass: HomeAssistant) -> None: async def test_cant_access_with_password_in_header( app, aiohttp_client: ClientSessionGenerator, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, hass: HomeAssistant, ) -> None: """Test access with password in header.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -150,11 +133,11 @@ async def test_cant_access_with_password_in_header( async def test_cant_access_with_password_in_query( app, aiohttp_client: ClientSessionGenerator, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, hass: HomeAssistant, ) -> None: """Test access with password in URL.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) resp = await client.get("/", params={"api_password": API_PASSWORD}) @@ -171,10 +154,10 @@ async def test_basic_auth_does_not_work( app, aiohttp_client: ClientSessionGenerator, hass: HomeAssistant, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, ) -> None: """Test access with basic authentication.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", auth=BasicAuth("homeassistant", API_PASSWORD)) @@ -198,7 +181,7 @@ async def test_cannot_access_with_trusted_ip( hass_owner_user: MockUser, ) -> None: """Test access with an untrusted ip address.""" - await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -226,7 +209,7 @@ async def test_auth_active_access_with_access_token_in_header( ) -> None: """Test access with access token in header.""" token = hass_access_token - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -262,7 +245,7 @@ async def test_auth_active_access_with_trusted_ip( hass_owner_user: MockUser, ) -> None: """Test access with an untrusted ip address.""" - await async_setup_auth(hass, app2, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -285,11 +268,11 @@ async def test_auth_active_access_with_trusted_ip( async def test_auth_legacy_support_api_password_cannot_access( app, aiohttp_client: ClientSessionGenerator, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, hass: HomeAssistant, ) -> None: """Test access using api_password if auth.support_legacy.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) req = await client.get("/", headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) @@ -311,7 +294,7 @@ async def test_auth_access_signed_path_with_refresh_token( """Test access with signed url.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -356,7 +339,7 @@ async def test_auth_access_signed_path_with_query_param( """Test access with signed url and query params.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -386,7 +369,7 @@ async def test_auth_access_signed_path_with_query_param_order( """Test access with signed url and query params different order.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -427,7 +410,7 @@ async def test_auth_access_signed_path_with_query_param_safe_param( """Test access with signed url and changing a safe param.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -466,7 +449,7 @@ async def test_auth_access_signed_path_with_query_param_tamper( """Test access with signed url and query params that have been tampered with.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -535,7 +518,7 @@ async def test_auth_access_signed_path_with_http( ) app.router.add_get("/hello", mock_handler) - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -559,7 +542,7 @@ async def test_auth_access_signed_path_with_content_user( hass: HomeAssistant, app, aiohttp_client: ClientSessionGenerator ) -> None: """Test access signed url uses content user.""" - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) signed_path = async_sign_path(hass, "/", timedelta(seconds=5)) signature = yarl.URL(signed_path).query["authSig"] claims = jwt.decode( @@ -579,7 +562,7 @@ async def test_local_only_user_rejected( ) -> None: """Test access with access token in header.""" token = hass_access_token - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) set_mock_ip = mock_real_ip(app) client = await aiohttp_client(app) refresh_token = hass.auth.async_validate_access_token(hass_access_token) @@ -645,7 +628,7 @@ async def test_create_user_once(hass: HomeAssistant) -> None: """Test that we reuse the user.""" cur_users = len(await hass.auth.async_get_users()) app = web.Application() - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) users = await hass.auth.async_get_users() assert len(users) == cur_users + 1 @@ -657,287 +640,7 @@ async def test_create_user_once(hass: HomeAssistant) -> None: assert len(user.refresh_tokens) == 1 assert user.system_generated - await async_setup_auth(hass, app, StrictConnectionMode.DISABLED) + await async_setup_auth(hass, app) # test it did not create a user assert len(await hass.auth.async_get_users()) == cur_users + 1 - - -@pytest.fixture -def app_strict_connection(hass): - """Fixture to set up a web.Application.""" - - async def handler(request): - """Return if request was authenticated.""" - return web.json_response(data={"authenticated": request[KEY_AUTHENTICATED]}) - - app = web.Application() - app[KEY_HASS] = hass - app.router.add_get("/", handler) - async_setup_forwarded(app, True, []) - return app - - -@pytest.mark.parametrize( - "strict_connection_mode", [e.value for e in StrictConnectionMode] -) -async def test_strict_connection_non_cloud_authenticated_requests( - hass: HomeAssistant, - app_strict_connection: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test authenticated requests with strict connection.""" - token = hass_access_token - await async_setup_auth(hass, app_strict_connection, strict_connection_mode) - set_mock_ip = mock_real_ip(app_strict_connection) - client = await aiohttp_client(app_strict_connection) - refresh_token = hass.auth.async_validate_access_token(hass_access_token) - assert refresh_token - assert hass.auth.session._strict_connection_sessions == {} - - signed_path = async_sign_path( - hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id - ) - - for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES, *EXTERNAL_ADDRESSES): - set_mock_ip(remote_addr) - - # authorized requests should work normally - req = await client.get("/", headers={"Authorization": f"Bearer {token}"}) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": True} - - -@pytest.mark.parametrize( - "strict_connection_mode", [e.value for e in StrictConnectionMode] -) -async def test_strict_connection_non_cloud_local_unauthenticated_requests( - hass: HomeAssistant, - app_strict_connection: web.Application, - aiohttp_client: ClientSessionGenerator, - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test local unauthenticated requests with strict connection.""" - await async_setup_auth(hass, app_strict_connection, strict_connection_mode) - set_mock_ip = mock_real_ip(app_strict_connection) - client = await aiohttp_client(app_strict_connection) - assert hass.auth.session._strict_connection_sessions == {} - - for remote_addr in (*LOCALHOST_ADDRESSES, *PRIVATE_ADDRESSES): - set_mock_ip(remote_addr) - # local requests should work normally - req = await client.get("/") - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": False} - - -def _add_set_cookie_endpoint(app: web.Application, refresh_token: RefreshToken) -> None: - """Add an endpoint to set a cookie.""" - - async def set_cookie(request: web.Request) -> web.Response: - hass = request.app[KEY_HASS] - # Clear all sessions - hass.auth.session._temp_sessions.clear() - hass.auth.session._strict_connection_sessions.clear() - - if request.query["token"] == "refresh": - await hass.auth.session.async_create_session(request, refresh_token) - else: - await hass.auth.session.async_create_temp_unauthorized_session(request) - session = await get_session(request) - return web.Response(text=session[SESSION_ID]) - - app.router.add_get("/test/cookie", set_cookie) - - -async def _test_strict_connection_non_cloud_enabled_setup( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - strict_connection_mode: StrictConnectionMode, -) -> tuple[TestClient, Callable[[str], None], RefreshToken]: - """Test external unauthenticated requests with strict connection non cloud enabled.""" - refresh_token = hass.auth.async_validate_access_token(hass_access_token) - assert refresh_token - session = hass.auth.session - assert session._strict_connection_sessions == {} - assert session._temp_sessions == {} - - _add_set_cookie_endpoint(app, refresh_token) - await async_setup_auth(hass, app, strict_connection_mode) - set_mock_ip = mock_real_ip(app) - client = await aiohttp_client(app) - return (client, set_mock_ip, refresh_token) - - -async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test external unauthenticated requests with strict connection non cloud enabled.""" - client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup( - hass, app, aiohttp_client, hass_access_token, strict_connection_mode - ) - - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test external unauthenticated requests with strict connection non cloud enabled and refresh token cookie.""" - ( - client, - set_mock_ip, - refresh_token, - ) = await _test_strict_connection_non_cloud_enabled_setup( - hass, app, aiohttp_client, hass_access_token, strict_connection_mode - ) - session = hass.auth.session - - # set strict connection cookie with refresh token - set_mock_ip(LOCALHOST_ADDRESSES[0]) - session_id = await (await client.get("/test/cookie?token=refresh")).text() - assert session._strict_connection_sessions == {session_id: refresh_token.id} - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - req = await client.get("/") - assert req.status == HTTPStatus.OK - assert await req.json() == {"authenticated": False} - - # Invalidate refresh token, which should also invalidate session - hass.auth.async_remove_refresh_token(refresh_token) - assert session._strict_connection_sessions == {} - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - await perform_unauthenticated_request(hass, client) - - -async def _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session( - hass: HomeAssistant, - app: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - perform_unauthenticated_request: Callable[ - [HomeAssistant, TestClient], Awaitable[None] - ], - strict_connection_mode: StrictConnectionMode, -) -> None: - """Test external unauthenticated requests with strict connection non cloud enabled and temp cookie.""" - client, set_mock_ip, _ = await _test_strict_connection_non_cloud_enabled_setup( - hass, app, aiohttp_client, hass_access_token, strict_connection_mode - ) - session = hass.auth.session - - # set strict connection cookie with temp session - assert session._temp_sessions == {} - set_mock_ip(LOCALHOST_ADDRESSES[0]) - session_id = await (await client.get("/test/cookie?token=temp")).text() - assert client.session.cookie_jar.filter_cookies(URL("http://127.0.0.1")) - assert session_id in session._temp_sessions - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - resp = await client.get("/") - assert resp.status == HTTPStatus.OK - assert await resp.json() == {"authenticated": False} - - async_fire_time_changed(hass, utcnow() + TEMP_TIMEOUT + timedelta(minutes=1)) - await hass.async_block_till_done(wait_background_tasks=True) - - assert session._temp_sessions == {} - for remote_addr in EXTERNAL_ADDRESSES: - set_mock_ip(remote_addr) - await perform_unauthenticated_request(hass, client) - - -async def _drop_connection_unauthorized_request( - _: HomeAssistant, client: TestClient -) -> None: - with pytest.raises(ServerDisconnectedError): - # unauthorized requests should raise ServerDisconnectedError - await client.get("/") - - -async def _guard_page_unauthorized_request( - hass: HomeAssistant, client: TestClient -) -> None: - req = await client.get("/") - assert req.status == HTTPStatus.IM_A_TEAPOT - - def read_guard_page() -> str: - with open(STRICT_CONNECTION_GUARD_PAGE, encoding="utf-8") as file: - return file.read() - - assert await req.text() == await hass.async_add_executor_job(read_guard_page) - - -@pytest.mark.parametrize( - "test_func", - [ - _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests, - _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_refresh_token, - _test_strict_connection_non_cloud_enabled_external_unauthenticated_requests_temp_session, - ], - ids=[ - "no cookie", - "refresh token cookie", - "temp session cookie", - ], -) -@pytest.mark.parametrize( - ("strict_connection_mode", "request_func"), - [ - (StrictConnectionMode.DROP_CONNECTION, _drop_connection_unauthorized_request), - (StrictConnectionMode.GUARD_PAGE, _guard_page_unauthorized_request), - ], - ids=["drop connection", "static page"], -) -async def test_strict_connection_non_cloud_external_unauthenticated_requests( - hass: HomeAssistant, - app_strict_connection: web.Application, - aiohttp_client: ClientSessionGenerator, - hass_access_token: str, - test_func: Callable[ - [ - HomeAssistant, - web.Application, - ClientSessionGenerator, - str, - Callable[[HomeAssistant, TestClient], Awaitable[None]], - StrictConnectionMode, - ], - Awaitable[None], - ], - strict_connection_mode: StrictConnectionMode, - request_func: Callable[[HomeAssistant, TestClient], Awaitable[None]], -) -> None: - """Test external unauthenticated requests with strict connection non cloud.""" - await test_func( - hass, - app_strict_connection, - aiohttp_client, - hass_access_token, - request_func, - strict_connection_mode, - ) diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index c4fd101f733..1188131cc0f 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,5 +1,6 @@ """Test cors for the HTTP component.""" +from asyncio import AbstractEventLoop from http import HTTPStatus from pathlib import Path from unittest.mock import patch @@ -13,12 +14,13 @@ from aiohttp.hdrs import ( AUTHORIZATION, ORIGIN, ) +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.http.cors import setup_cors from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant -from homeassistant.helpers.http import KEY_ALLOW_CONFIGRED_CORS +from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS from homeassistant.setup import async_setup_component from . import HTTP_HEADER_HA_AUTH @@ -54,11 +56,13 @@ async def mock_handler(request): @pytest.fixture -def client(event_loop, aiohttp_client): +def client( + event_loop: AbstractEventLoop, aiohttp_client: ClientSessionGenerator +) -> TestClient: """Fixture to set up a web.Application.""" app = web.Application() setup_cors(app, [TRUSTED_ORIGIN]) - app[KEY_ALLOW_CONFIGRED_CORS](app.router.add_get("/", mock_handler)) + app[KEY_ALLOW_CONFIGURED_CORS](app.router.add_get("/", mock_handler)) return event_loop.run_until_complete(aiohttp_client(app)) diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 9a4e80052f6..b415e54af04 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.helpers.http import KEY_ALLOW_CONFIGRED_CORS +from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS from tests.typing import ClientSessionGenerator @@ -17,7 +17,7 @@ async def get_client(aiohttp_client, validator): """Generate a client that hits a view decorated with validator.""" app = web.Application() app[KEY_HASS] = Mock(is_stopping=False) - app[KEY_ALLOW_CONFIGRED_CORS] = lambda _: None + app[KEY_ALLOW_CONFIGURED_CORS] = lambda _: None class TestView(HomeAssistantView): url = "/" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index b554737e7b3..7a9fb329fcd 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,24 +1,19 @@ """The tests for the Home Assistant HTTP component.""" import asyncio +from collections.abc import Callable from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging from pathlib import Path from unittest.mock import Mock, patch -from urllib.parse import quote_plus import pytest -from homeassistant.auth.providers.legacy_api_password import ( - LegacyApiPasswordAuthProvider, -) +from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import http -from homeassistant.components.http.const import StrictConnectionMode -from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component @@ -89,7 +84,9 @@ class TestView(http.HomeAssistantView): async def test_registering_view_while_running( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, unused_tcp_port_factory + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + unused_tcp_port_factory: Callable[[], int], ) -> None: """Test that we can register a view while the server is running.""" await async_setup_component( @@ -116,7 +113,7 @@ async def test_not_log_password( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, ) -> None: """Test access with password doesn't get logged.""" assert await async_setup_component(hass, "api", {"http": {}}) @@ -469,7 +466,9 @@ async def test_cors_defaults(hass: HomeAssistant) -> None: async def test_storing_config( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, unused_tcp_port_factory + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + unused_tcp_port_factory: Callable[[], int], ) -> None: """Test that we store last working config.""" config = { @@ -527,76 +526,22 @@ async def test_logging( assert "GET /api/states/logging.entity" not in caplog.text -async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( +async def test_register_static_paths( hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test service create_temporary_strict_connection_url with strict_connection not enabled.""" - assert await async_setup_component(hass, http.DOMAIN, {"http": {}}) - with pytest.raises( - ServiceValidationError, - match="Strict connection is not enabled for non-cloud requests", - ): - await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) + """Test registering a static path with old api.""" + assert await async_setup_component(hass, "frontend", {}) + path = str(Path(__file__).parent) + hass.http.register_static_path("/something", path) + client = await hass_client() + resp = await client.get("/something/__init__.py") + assert resp.status == HTTPStatus.OK - -@pytest.mark.parametrize( - ("mode"), - [ - StrictConnectionMode.DROP_CONNECTION, - StrictConnectionMode.GUARD_PAGE, - ], -) -async def test_service_create_temporary_strict_connection( - hass: HomeAssistant, mode: StrictConnectionMode -) -> None: - """Test service create_temporary_strict_connection_url.""" - assert await async_setup_component( - hass, http.DOMAIN, {"http": {"strict_connection": mode}} - ) - - # No external url set - assert hass.config.external_url is None - assert hass.config.internal_url is None - with pytest.raises(ServiceValidationError, match="No external URL available"): - await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - # Raise if only internal url is available - hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123") - with pytest.raises(ServiceValidationError, match="No external URL available"): - await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - - # Set external url too - external_url = "https://example.com" - await async_process_ha_core_config( - hass, - {"external_url": external_url}, - ) - assert hass.config.external_url == external_url - response = await hass.services.async_call( - http.DOMAIN, - "create_temporary_strict_connection_url", - blocking=True, - return_response=True, - ) - assert isinstance(response, dict) - direct_url_prefix = f"{external_url}/auth/strict_connection/temp_token?authSig=" - assert response.pop("direct_url").startswith(direct_url_prefix) - assert response.pop("url").startswith( - f"https://login.home-assistant.io?u={quote_plus(direct_url_prefix)}" - ) - assert response == {} # No more keys in response + assert ( + "Detected code that calls hass.http.register_static_path " + "which is deprecated because it does blocking I/O in the " + "event loop, instead call " + "`await hass.http.async_register_static_path" + ) in caplog.text diff --git a/tests/components/http/test_session.py b/tests/components/http/test_session.py deleted file mode 100644 index ae62365749a..00000000000 --- a/tests/components/http/test_session.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Tests for HTTP session.""" - -from collections.abc import Callable -import logging -from typing import Any -from unittest.mock import patch - -from aiohttp import web -from aiohttp.test_utils import make_mocked_request -import pytest - -from homeassistant.auth.session import SESSION_ID -from homeassistant.components.http.session import ( - COOKIE_NAME, - HomeAssistantCookieStorage, -) -from homeassistant.core import HomeAssistant - - -def fake_request_with_strict_connection_cookie(cookie_value: str) -> web.Request: - """Return a fake request with a strict connection cookie.""" - request = make_mocked_request( - "GET", "/", headers={"Cookie": f"{COOKIE_NAME}={cookie_value}"} - ) - assert COOKIE_NAME in request.cookies - return request - - -@pytest.fixture -def cookie_storage(hass: HomeAssistant) -> HomeAssistantCookieStorage: - """Fixture for the cookie storage.""" - return HomeAssistantCookieStorage(hass) - - -def _encrypt_cookie_data(cookie_storage: HomeAssistantCookieStorage, data: Any) -> str: - """Encrypt cookie data.""" - cookie_data = cookie_storage._encoder(data).encode("utf-8") - return cookie_storage._fernet.encrypt(cookie_data).decode("utf-8") - - -@pytest.mark.parametrize( - "func", - [ - lambda _: "invalid", - lambda storage: _encrypt_cookie_data(storage, "bla"), - lambda storage: _encrypt_cookie_data(storage, None), - ], -) -async def test_load_session_modified_cookies( - cookie_storage: HomeAssistantCookieStorage, - caplog: pytest.LogCaptureFixture, - func: Callable[[HomeAssistantCookieStorage], str], -) -> None: - """Test that on modified cookies the session is empty and the request will be logged for ban.""" - request = fake_request_with_strict_connection_cookie(func(cookie_storage)) - with patch( - "homeassistant.components.http.session.process_wrong_login", - ) as mock_process_wrong_login: - session = await cookie_storage.load_session(request) - assert session.empty - assert ( - "homeassistant.components.http.session", - logging.WARNING, - "Cannot decrypt/parse cookie value", - ) in caplog.record_tuples - mock_process_wrong_login.assert_called() - - -async def test_load_session_validate_session( - hass: HomeAssistant, - cookie_storage: HomeAssistantCookieStorage, -) -> None: - """Test load session validates the session.""" - session = await cookie_storage.new_session() - session[SESSION_ID] = "bla" - request = fake_request_with_strict_connection_cookie( - _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session)) - ) - - with patch.object( - hass.auth.session, "async_validate_strict_connection_session", return_value=True - ) as mock_validate: - session = await cookie_storage.load_session(request) - assert not session.empty - assert session[SESSION_ID] == "bla" - mock_validate.assert_called_with(session) - - # verify lru_cache is working - mock_validate.reset_mock() - await cookie_storage.load_session(request) - mock_validate.assert_not_called() - - session = await cookie_storage.new_session() - session[SESSION_ID] = "something" - request = fake_request_with_strict_connection_cookie( - _encrypt_cookie_data(cookie_storage, cookie_storage._get_session_data(session)) - ) - - with patch.object( - hass.auth.session, - "async_validate_strict_connection_session", - return_value=False, - ): - session = await cookie_storage.load_session(request) - assert session.empty - assert SESSION_ID not in session - assert session._changed diff --git a/tests/components/http/test_static.py b/tests/components/http/test_static.py index e3cf2f50c15..3e3f21d5002 100644 --- a/tests/components/http/test_static.py +++ b/tests/components/http/test_static.py @@ -1,14 +1,16 @@ """The tests for http static files.""" +from http import HTTPStatus from pathlib import Path from aiohttp.test_utils import TestClient from aiohttp.web_exceptions import HTTPForbidden import pytest +from homeassistant.components.http import StaticPathConfig from homeassistant.components.http.static import CachingStaticResource, _get_file_path from homeassistant.core import EVENT_HOMEASSISTANT_START, HomeAssistant -from homeassistant.helpers.http import KEY_ALLOW_CONFIGRED_CORS +from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator @@ -49,7 +51,7 @@ async def test_static_path_blocks_anchors( resource = CachingStaticResource(url, str(tmp_path)) assert resource.canonical == canonical_url app.router.register_resource(resource) - app[KEY_ALLOW_CONFIGRED_CORS](resource) + app[KEY_ALLOW_CONFIGURED_CORS](resource) resp = await mock_http_client.get(canonical_url, allow_redirects=False) assert resp.status == 403 @@ -59,3 +61,23 @@ async def test_static_path_blocks_anchors( # changes we still block it. with pytest.raises(HTTPForbidden): _get_file_path(canonical_url, tmp_path) + + +async def test_async_register_static_paths( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test registering multiple static paths.""" + assert await async_setup_component(hass, "frontend", {}) + path = str(Path(__file__).parent) + await hass.http.async_register_static_paths( + [ + StaticPathConfig("/something", path), + StaticPathConfig("/something_else", path), + ] + ) + + client = await hass_client() + resp = await client.get("/something/__init__.py") + assert resp.status == HTTPStatus.OK + resp = await client.get("/something_else/__init__.py") + assert resp.status == HTTPStatus.OK diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 200796c87e7..862af02963c 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -119,7 +119,7 @@ async def test_connection_errors( exception: Exception, errors: dict[str, str], data_patch: dict[str, Any], -): +) -> None: """Test we show user form on various errors.""" requests_mock.request(ANY, ANY, exc=exception) result = await hass.config_entries.flow.async_init( @@ -134,7 +134,7 @@ async def test_connection_errors( @pytest.fixture -def login_requests_mock(requests_mock): +def login_requests_mock(requests_mock: requests_mock.Mocker) -> requests_mock.Mocker: """Set up a requests_mock with base mocks for login tests.""" https_url = urlunparse( urlparse(FIXTURE_USER_INPUT[CONF_URL])._replace(scheme="https") diff --git a/tests/components/huawei_lte/test_select.py b/tests/components/huawei_lte/test_select.py index f6c8d34c4a0..85a0fcfdf0c 100644 --- a/tests/components/huawei_lte/test_select.py +++ b/tests/components/huawei_lte/test_select.py @@ -5,8 +5,10 @@ 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.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_URL from homeassistant.core import HomeAssistant diff --git a/tests/components/huawei_lte/test_sensor.py b/tests/components/huawei_lte/test_sensor.py index 4d5acaf2d31..75cdc7be1c2 100644 --- a/tests/components/huawei_lte/test_sensor.py +++ b/tests/components/huawei_lte/test_sensor.py @@ -15,6 +15,8 @@ from homeassistant.const import ( ("-71 dBm", (-71, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)), ("15dB", (15, SIGNAL_STRENGTH_DECIBELS)), (">=-51dBm", (-51, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)), + ("<-20dB", (-20, SIGNAL_STRENGTH_DECIBELS)), + (">=30dB", (30, SIGNAL_STRENGTH_DECIBELS)), ], ) def test_format_default(value, expected) -> None: diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index ac827d42d95..fca950d6b7a 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -15,15 +15,18 @@ import pytest from homeassistant.components import hue from homeassistant.components.hue.v1 import sensor_base as hue_sensor_base from homeassistant.components.hue.v2.device import async_setup_devices +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component +from .const import FAKE_BRIDGE, FAKE_BRIDGE_DEVICE + from tests.common import ( MockConfigEntry, async_mock_service, load_fixture, mock_device_registry, ) -from tests.components.hue.const import FAKE_BRIDGE, FAKE_BRIDGE_DEVICE @pytest.fixture(autouse=True) @@ -274,8 +277,8 @@ async def setup_platform( assert await async_setup_component(hass, hue.DOMAIN, {}) is True await hass.async_block_till_done() - for platform in platforms: - await hass.config_entries.async_forward_entry_setup(config_entry, platform) + config_entry.mock_state(hass, ConfigEntryState.LOADED) + await hass.config_entries.async_forward_entry_setups(config_entry, platforms) # and make sure it completes before going further await hass.async_block_till_done() @@ -288,6 +291,6 @@ def get_device_reg(hass): @pytest.fixture(name="calls") -def track_calls(hass): +def track_calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py index 252c9da9a9d..57a590ab1af 100644 --- a/tests/components/hue/const.py +++ b/tests/components/hue/const.py @@ -126,13 +126,14 @@ FAKE_ROTARY = { "id_v1": "/sensors/1", "owner": {"rid": "fake_device_id_1", "rtype": "device"}, "relative_rotary": { - "last_event": { + "rotary_report": { "action": "start", "rotation": { "direction": "clock_wise", "steps": 0, "duration": 0, }, + "updated": "2023-09-27T10:06:41.822Z", } }, "type": "relative_rotary", diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 5d103e47870..42631215035 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -34,7 +34,8 @@ async def test_bridge_setup_v1(hass: HomeAssistant, mock_api_v1) -> None: patch.object(hass.config_entries, "async_forward_entry_setups") as mock_forward, ): hue_bridge = bridge.HueBridge(hass, config_entry) - assert await hue_bridge.async_initialize_bridge() is True + async with config_entry.setup_lock: + assert await hue_bridge.async_initialize_bridge() is True assert hue_bridge.api is mock_api_v1 assert isinstance(hue_bridge.api, HueBridgeV1) @@ -125,7 +126,8 @@ async def test_reset_unloads_entry_if_setup(hass: HomeAssistant, mock_api_v1) -> patch.object(hass.config_entries, "async_forward_entry_setups") as mock_forward, ): hue_bridge = bridge.HueBridge(hass, config_entry) - assert await hue_bridge.async_initialize_bridge() is True + async with config_entry.setup_lock: + assert await hue_bridge.async_initialize_bridge() is True await asyncio.sleep(0) @@ -151,7 +153,8 @@ async def test_handle_unauthorized(hass: HomeAssistant, mock_api_v1) -> None: with patch.object(bridge, "HueBridgeV1", return_value=mock_api_v1): hue_bridge = bridge.HueBridge(hass, config_entry) - assert await hue_bridge.async_initialize_bridge() is True + async with config_entry.setup_lock: + assert await hue_bridge.async_initialize_bridge() is True with patch.object(bridge, "create_config_flow") as mock_create: await hue_bridge.handle_unauthorized_error() diff --git a/tests/components/hue/test_device_trigger_v1.py b/tests/components/hue/test_device_trigger_v1.py index b12c3cce584..3d8fa64baf4 100644 --- a/tests/components/hue/test_device_trigger_v1.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -5,8 +5,8 @@ from pytest_unordered import unordered from homeassistant.components import automation, hue from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.hue.v1 import device_trigger -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .conftest import setup_platform @@ -18,7 +18,10 @@ REMOTES_RESPONSE = {"7": HUE_TAP_REMOTE_1, "8": HUE_DIMMER_REMOTE_1} async def test_get_triggers( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge_v1, device_reg + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_bridge_v1, + device_reg: dr.DeviceRegistry, ) -> None: """Test we get the expected triggers from a hue remote.""" mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) @@ -86,7 +89,10 @@ async def test_get_triggers( async def test_if_fires_on_state_change( - hass: HomeAssistant, mock_bridge_v1, device_reg, calls + hass: HomeAssistant, + mock_bridge_v1, + device_reg: dr.DeviceRegistry, + calls: list[ServiceCall], ) -> None: """Test for button press trigger firing.""" mock_bridge_v1.mock_sensor_responses.append(REMOTES_RESPONSE) diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index b33509543e9..aedf11a6e82 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -31,7 +31,12 @@ async def test_event( ] # trigger firing 'initial_press' event from the device btn_event = { - "button": {"last_event": "initial_press"}, + "button": { + "button_report": { + "event": "initial_press", + "updated": "2023-09-27T10:06:41.822Z", + } + }, "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", "metadata": {"control_id": 1}, "type": "button", @@ -42,7 +47,12 @@ async def test_event( assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" # trigger firing 'long_release' event from the device btn_event = { - "button": {"last_event": "long_release"}, + "button": { + "button_report": { + "event": "long_release", + "updated": "2023-09-27T10:06:41.822Z", + } + }, "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", "metadata": {"control_id": 1}, "type": "button", @@ -79,13 +89,14 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: btn_event = { "id": "fake_relative_rotary", "relative_rotary": { - "last_event": { + "rotary_report": { "action": "repeat", "rotation": { "direction": "counter_clock_wise", "steps": 60, "duration": 400, }, + "updated": "2023-09-27T10:06:41.822Z", } }, "type": "relative_rotary", diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index 9a74d9cd994..21b35e6d5e8 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -186,7 +186,7 @@ async def setup_bridge(hass: HomeAssistant, mock_bridge_v1): config_entry.mock_state(hass, ConfigEntryState.LOADED) mock_bridge_v1.config_entry = config_entry hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge_v1} - await hass.config_entries.async_forward_entry_setup(config_entry, "light") + await hass.config_entries.async_forward_entry_setups(config_entry, ["light"]) # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index d8d0f4b6e66..fca907eabb0 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -412,6 +412,11 @@ async def test_grouped_lights( "Hue light with color and color temperature gradient", "Hue light with color and color temperature 2", } + assert test_entity.attributes["entity_id"] == { + "light.hue_light_with_color_and_color_temperature_gradient", + "light.hue_light_with_color_and_color_temperature_2", + "light.hue_light_with_color_and_color_temperature_1", + } # test light created for hue room test_entity = hass.states.get("light.test_room") @@ -431,6 +436,10 @@ async def test_grouped_lights( "Hue on/off light", "Hue light with color temperature only", } + assert test_entity.attributes["entity_id"] == { + "light.hue_light_with_color_temperature_only", + "light.hue_on_off_light", + } # Test calling the turn on service on a grouped light test_light_id = "light.test_zone" @@ -455,11 +464,11 @@ async def test_grouped_lights( assert mock_bridge_v2.mock_requests[0]["json"]["dynamics"]["duration"] == 200 # Now generate update events by emitting the json we've sent as incoming events - for light_id in [ + for light_id in ( "02cba059-9c2c-4d45-97e4-4f79b1bfbaa1", "b3fe71ef-d0ef-48de-9355-d9e604377df0", "8015b17f-8336-415b-966a-b364bd082397", - ]: + ): event = { "id": light_id, "type": "light", diff --git a/tests/components/hue/test_sensor_v1.py b/tests/components/hue/test_sensor_v1.py index 6e620ded365..b1ef94f8ed0 100644 --- a/tests/components/hue/test_sensor_v1.py +++ b/tests/components/hue/test_sensor_v1.py @@ -10,7 +10,7 @@ from homeassistant.components.hue.const import ATTR_HUE_EVENT from homeassistant.components.hue.v1 import sensor_base from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er from .conftest import create_mock_bridge, setup_platform @@ -314,7 +314,9 @@ async def test_sensors_with_multiple_bridges( assert len(hass.states.async_all()) == 10 -async def test_sensors(hass: HomeAssistant, mock_bridge_v1) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge_v1 +) -> None: """Test the update_items function with some sensors.""" mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) await setup_platform(hass, mock_bridge_v1, ["binary_sensor", "sensor"]) @@ -351,9 +353,10 @@ async def test_sensors(hass: HomeAssistant, mock_bridge_v1) -> None: assert battery_remote_1.state == "100" assert battery_remote_1.name == "Hue dimmer switch 1 battery level" - ent_reg = async_get(hass) assert ( - ent_reg.async_get("sensor.hue_dimmer_switch_1_battery_level").entity_category + entity_registry.async_get( + "sensor.hue_dimmer_switch_1_battery_level" + ).entity_category == EntityCategory.DIAGNOSTIC ) diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 4c1f8defc95..beb86de505b 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -75,7 +75,9 @@ async def test_enable_sensor( assert await async_setup_component(hass, hue.DOMAIN, {}) is True await hass.async_block_till_done() - await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") + await hass.config_entries.async_forward_entry_setups( + mock_config_entry_v2, ["sensor"] + ) entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity" entity_entry = entity_registry.async_get(entity_id) @@ -93,7 +95,9 @@ async def test_enable_sensor( # reload platform and check if entity is correctly there await hass.config_entries.async_forward_entry_unload(mock_config_entry_v2, "sensor") - await hass.config_entries.async_forward_entry_setup(mock_config_entry_v2, "sensor") + await hass.config_entries.async_forward_entry_setups( + mock_config_entry_v2, ["sensor"] + ) await hass.async_block_till_done() state = hass.states.get(entity_id) diff --git a/tests/components/hue/test_services.py b/tests/components/hue/test_services.py index 8139bfa034c..6ce3cf2cc82 100644 --- a/tests/components/hue/test_services.py +++ b/tests/components/hue/test_services.py @@ -2,7 +2,6 @@ from unittest.mock import patch -from homeassistant import config_entries from homeassistant.components import hue from homeassistant.components.hue import bridge from homeassistant.components.hue.const import ( @@ -13,6 +12,8 @@ from homeassistant.core import HomeAssistant from .conftest import setup_bridge, setup_component +from tests.common import MockConfigEntry + GROUP_RESPONSE = { "group_1": { "name": "Group 1", @@ -49,11 +50,8 @@ SCENE_RESPONSE = { async def test_hue_activate_scene(hass: HomeAssistant, mock_api_v1) -> None: """Test successful hue_activate_scene.""" - config_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + config_entry = MockConfigEntry( 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}, @@ -87,11 +85,8 @@ 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( - version=1, - minor_version=1, + config_entry = MockConfigEntry( 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}, @@ -127,11 +122,8 @@ async def test_hue_activate_scene_group_not_found( hass: HomeAssistant, mock_api_v1 ) -> None: """Test failed hue_activate_scene due to missing group.""" - config_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + config_entry = MockConfigEntry( 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}, @@ -162,11 +154,8 @@ async def test_hue_activate_scene_scene_not_found( hass: HomeAssistant, mock_api_v1 ) -> None: """Test failed hue_activate_scene due to missing scene.""" - config_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + config_entry = MockConfigEntry( 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/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 02a05c78763..5f5707bdd5d 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -59,7 +59,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: # Assert data is loaded current_power = hass.states.get("sensor.current_power") - assert current_power.state == "1012.0" + assert current_power.state == "1011.66666666667" assert ( current_power.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER ) @@ -72,7 +72,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) current_power_in = hass.states.get("sensor.current_power_in_peak") - assert current_power_in.state == "1012.0" + assert current_power_in.state == "1011.66666666667" assert ( current_power_in.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER @@ -134,7 +134,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_consumption_peak_today = hass.states.get( "sensor.energy_consumption_peak_today" ) - assert energy_consumption_peak_today.state == "2.67" + assert energy_consumption_peak_today.state == "2.669999453" assert ( energy_consumption_peak_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -151,7 +151,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_consumption_off_peak_today = hass.states.get( "sensor.energy_consumption_off_peak_today" ) - assert energy_consumption_off_peak_today.state == "0.627" + assert energy_consumption_off_peak_today.state == "0.626666416" assert ( energy_consumption_off_peak_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -168,7 +168,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_production_peak_today = hass.states.get( "sensor.energy_production_peak_today" ) - assert energy_production_peak_today.state == "1.512" + assert energy_production_peak_today.state == "1.51234" assert ( energy_production_peak_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -185,7 +185,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_production_off_peak_today = hass.states.get( "sensor.energy_production_off_peak_today" ) - assert energy_production_off_peak_today.state == "1.093" + assert energy_production_off_peak_today.state == "1.09281" assert ( energy_production_off_peak_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -200,7 +200,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_today = hass.states.get("sensor.energy_today") - assert energy_today.state == "3.3" + assert energy_today.state == "3.296665869" assert ( energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY ) @@ -211,7 +211,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_this_week = hass.states.get("sensor.energy_this_week") - assert energy_this_week.state == "17.5" + assert energy_this_week.state == "17.509996085" assert ( energy_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -225,7 +225,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_this_month = hass.states.get("sensor.energy_this_month") - assert energy_this_month.state == "103.3" + assert energy_this_month.state == "103.28830788" assert ( energy_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -239,7 +239,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) energy_this_year = hass.states.get("sensor.energy_this_year") - assert energy_this_year.state == "673.0" + assert energy_this_year.state == "672.97811773" assert ( energy_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -264,7 +264,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) gas_today = hass.states.get("sensor.gas_today") - assert gas_today.state == "1.1" + assert gas_today.state == "1.07" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_today.attributes.get(ATTR_STATE_CLASS) @@ -276,7 +276,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) gas_this_week = hass.states.get("sensor.gas_this_week") - assert gas_this_week.state == "5.6" + assert gas_this_week.state == "5.634224386" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_this_week.attributes.get(ATTR_STATE_CLASS) @@ -288,7 +288,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) gas_this_month = hass.states.get("sensor.gas_this_month") - assert gas_this_month.state == "39.1" + assert gas_this_month.state == "39.14" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_this_month.attributes.get(ATTR_STATE_CLASS) @@ -300,7 +300,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) gas_this_year = hass.states.get("sensor.gas_this_year") - assert gas_this_year.state == "116.7" + assert gas_this_year.state == "116.73" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_this_year.attributes.get(ATTR_STATE_CLASS) @@ -349,13 +349,13 @@ async def test_setup_entry_absent_measurement(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Assert data is loaded - assert hass.states.get("sensor.current_power").state == "1012.0" + assert hass.states.get("sensor.current_power").state == "1011.66666666667" assert hass.states.get("sensor.current_power_in_peak").state == "unknown" assert hass.states.get("sensor.current_power_in_off_peak").state == "unknown" assert hass.states.get("sensor.current_power_out_peak").state == "unknown" assert hass.states.get("sensor.current_power_out_off_peak").state == "unknown" assert hass.states.get("sensor.current_gas").state == "unknown" - assert hass.states.get("sensor.energy_today").state == "3.3" + assert hass.states.get("sensor.energy_today").state == "3.296665869" assert ( hass.states.get("sensor.energy_consumption_peak_today").state == "unknown" ) diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index 14ed9fae5e0..4f4d21adcba 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -8,7 +8,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.humidifier import DOMAIN, const, device_condition from homeassistant.const import ATTR_MODE, STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -141,7 +141,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -153,7 +153,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -273,7 +273,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index fd6441588c4..83202e16675 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -40,7 +40,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -166,7 +166,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -429,7 +429,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -484,7 +484,7 @@ async def test_if_fires_on_state_change_legacy( async def test_invalid_config( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] ) -> None: """Test for turn_on and turn_off triggers firing.""" entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") diff --git a/tests/components/humidifier/test_intent.py b/tests/components/humidifier/test_intent.py index 936369f8aa7..6318c5f136d 100644 --- a/tests/components/humidifier/test_intent.py +++ b/tests/components/humidifier/test_intent.py @@ -2,6 +2,8 @@ import pytest +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.humidifier import ( ATTR_AVAILABLE_MODES, ATTR_HUMIDITY, @@ -19,13 +21,22 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.intent import IntentHandleError, async_handle +from homeassistant.helpers.intent import ( + IntentHandleError, + IntentResponseType, + InvalidSlotInfo, + MatchFailedError, + MatchFailedReason, + async_handle, +) +from homeassistant.setup import async_setup_component from tests.common import async_mock_service async def test_intent_set_humidity(hass: HomeAssistant) -> None: """Test the set humidity intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} ) @@ -38,6 +49,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None: "test", intent.INTENT_HUMIDITY, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -54,6 +66,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None: async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: """Test the set humidity intent for turned off humidifier.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_OFF, {ATTR_HUMIDITY: 40} ) @@ -66,6 +79,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: "test", intent.INTENT_HUMIDITY, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -89,6 +103,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_mode(hass: HomeAssistant) -> None: """Test the set mode intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, @@ -108,6 +123,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -127,6 +143,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None: async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: """Test the set mode intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_OFF, @@ -146,6 +163,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -169,6 +187,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: """Test the set mode intent where modes are not supported.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} ) @@ -181,6 +200,7 @@ async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) assert str(excinfo.value) == "Entity bedroom humidifier does not support modes" assert len(mode_calls) == 0 @@ -191,6 +211,7 @@ async def test_intent_set_unknown_mode( hass: HomeAssistant, available_modes: list[str] | None ) -> None: """Test the set mode intent for unsupported mode.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, @@ -210,6 +231,111 @@ async def test_intent_set_unknown_mode( "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "eco"}}, + assistant=conversation.DOMAIN, ) assert str(excinfo.value) == "Entity bedroom humidifier does not support eco mode" assert len(mode_calls) == 0 + + +async def test_intent_errors(hass: HomeAssistant) -> None: + """Test the error conditions for set humidity and set mode intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + entity_id = "humidifier.bedroom_humidifier" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: None, + }, + ) + async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + await intent.async_setup_intents(hass) + + # Humidifiers are exposed by default + result = await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert result.response_type == IntentResponseType.ACTION_DONE + + result = await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert result.response_type == IntentResponseType.ACTION_DONE + + # Unexposing it should fail + async_expose_entity(hass, conversation.DOMAIN, entity_id, False) + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT + + # Expose again to test other errors + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # Empty name should fail + with pytest.raises(InvalidSlotInfo): + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": ""}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(InvalidSlotInfo): + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": ""}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + + # Wrong name should fail + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "does not exist"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.NAME + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "does not exist"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.NAME diff --git a/tests/components/hunterdouglas_powerview/conftest.py b/tests/components/hunterdouglas_powerview/conftest.py index e55e252f670..da339914aac 100644 --- a/tests/components/hunterdouglas_powerview/conftest.py +++ b/tests/components/hunterdouglas_powerview/conftest.py @@ -1,10 +1,10 @@ """Common fixtures for Hunter Douglas Powerview tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from aiopvapi.resources.shade import ShadePosition import pytest +from typing_extensions import Generator from homeassistant.components.hunterdouglas_powerview.const import DOMAIN @@ -12,7 +12,7 @@ from tests.common import load_json_object_fixture, load_json_value_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.hunterdouglas_powerview.async_setup_entry", @@ -29,7 +29,7 @@ def mock_hunterdouglas_hub( rooms_json: str, scenes_json: str, shades_json: str, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Return a mocked Powerview Hub with all data populated.""" with ( patch( diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 5d7cb43698b..7ace3b76808 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -1,12 +1,13 @@ """Test helpers for Husqvarna Automower.""" -from collections.abc import Generator import time from unittest.mock import AsyncMock, patch +from aioautomower.session import AutomowerSession, _MowerCommands from aioautomower.utils import mower_list_to_dictionary_dataclass from aiohttp import ClientWebSocketResponse import pytest +from typing_extensions import Generator from homeassistant.components.application_credentials import ( ClientCredential, @@ -22,7 +23,7 @@ from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture @pytest.fixture(name="jwt") -def load_jwt_fixture(): +def load_jwt_fixture() -> str: """Load Fixture data.""" return load_fixture("jwt", DOMAIN) @@ -33,8 +34,14 @@ def mock_expires_at() -> float: return time.time() + 3600 +@pytest.fixture(name="scope") +def mock_scope() -> str: + """Fixture to set correct scope for the token.""" + return "iam:read amc:api" + + @pytest.fixture -def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry: +def mock_config_entry(jwt: str, expires_at: int, scope: str) -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( version=1, @@ -44,7 +51,7 @@ def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry: "auth_implementation": DOMAIN, "token": { "access_token": jwt, - "scope": "iam:read amc:api", + "scope": scope, "expires_in": 86399, "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", "provider": "husqvarna", @@ -74,21 +81,20 @@ async def setup_credentials(hass: HomeAssistant) -> None: @pytest.fixture -def mock_automower_client() -> Generator[AsyncMock, None, None]: +def mock_automower_client() -> Generator[AsyncMock]: """Mock a Husqvarna Automower client.""" + + mower_dict = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + + mock = AsyncMock(spec=AutomowerSession) + mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) + mock.commands = AsyncMock(spec_set=_MowerCommands) + mock.get_status.return_value = mower_dict + with patch( "homeassistant.components.husqvarna_automower.AutomowerSession", - autospec=True, - ) as mock_client: - client = mock_client.return_value - client.get_status.return_value = mower_list_to_dictionary_dataclass( - load_json_value_fixture("mower.json", DOMAIN) - ) - - async def websocket_connect() -> ClientWebSocketResponse: - """Mock listen.""" - return ClientWebSocketResponse - - client.auth = AsyncMock(side_effect=websocket_connect) - - yield client + return_value=mock, + ): + yield mock diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 7d125c6356c..a5cae68f47c 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -21,9 +21,12 @@ "mower": { "mode": "MAIN_AREA", "activity": "PARKED_IN_CS", + "inactiveReason": "NONE", "state": "RESTRICTED", + "workAreaId": 123456, "errorCode": 0, - "errorCodeTimestamp": 0 + "errorCodeTimestamp": 0, + "isErrorConfirmable": false }, "calendar": { "tasks": [ @@ -154,12 +157,19 @@ "id": "81C6EEA2-D139-4FEA-B134-F22A6B3EA403", "name": "Springflowers", "enabled": true + }, + { + "id": "AAAAAAAA-BBBB-CCCC-DDDD-123456789101", + "name": "Danger Zone", + "enabled": false } ] }, - "cuttingHeight": 4, - "headlight": { - "mode": "EVENING_ONLY" + "settings": { + "cuttingHeight": 4, + "headlight": { + "mode": "EVENING_ONLY" + } } } } diff --git a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr index d677f504390..aaa9c59679f 100644 --- a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[binary_sensor.test_mower_1_charging-entry] +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +32,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[binary_sensor.test_mower_1_charging-state] +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_charging-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery_charging', @@ -41,11 +41,12 @@ 'context': , 'entity_id': 'binary_sensor.test_mower_1_charging', 'last_changed': , + 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_sensor[binary_sensor.test_mower_1_leaving_dock-entry] +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_leaving_dock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -78,7 +79,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[binary_sensor.test_mower_1_leaving_dock-state] +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_leaving_dock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Leaving dock', @@ -86,11 +87,12 @@ 'context': , 'entity_id': 'binary_sensor.test_mower_1_leaving_dock', 'last_changed': , + 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_sensor[binary_sensor.test_mower_1_returning_to_dock-entry] +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_returning_to_dock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -123,145 +125,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[binary_sensor.test_mower_1_returning_to_dock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Mower 1 Returning to dock', - }), - 'context': , - 'entity_id': 'binary_sensor.test_mower_1_returning_to_dock', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_snapshot_binary_sensor[binary_sensor.test_mower_1_charging-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_mower_1_charging', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charging', - 'platform': 'husqvarna_automower', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_battery_charging', - 'unit_of_measurement': None, - }) -# --- -# name: test_snapshot_binary_sensor[binary_sensor.test_mower_1_charging-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery_charging', - 'friendly_name': 'Test Mower 1 Charging', - }), - 'context': , - 'entity_id': 'binary_sensor.test_mower_1_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_snapshot_binary_sensor[binary_sensor.test_mower_1_leaving_dock-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_mower_1_leaving_dock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Leaving dock', - 'platform': 'husqvarna_automower', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'leaving_dock', - 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_leaving_dock', - 'unit_of_measurement': None, - }) -# --- -# name: test_snapshot_binary_sensor[binary_sensor.test_mower_1_leaving_dock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Mower 1 Leaving dock', - }), - 'context': , - 'entity_id': 'binary_sensor.test_mower_1_leaving_dock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_snapshot_binary_sensor[binary_sensor.test_mower_1_returning_to_dock-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_mower_1_returning_to_dock', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Returning to dock', - 'platform': 'husqvarna_automower', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'returning_to_dock', - 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_returning_to_dock', - 'unit_of_measurement': None, - }) -# --- -# name: test_snapshot_binary_sensor[binary_sensor.test_mower_1_returning_to_dock-state] +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_1_returning_to_dock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Returning to dock', diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr new file mode 100644 index 00000000000..ab2cb427f1a --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_button_snapshot[button.test_mower_1_confirm_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_mower_1_confirm_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Confirm error', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'confirm_error', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_confirm_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_mower_1_confirm_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Confirm error', + }), + 'context': , + 'entity_id': 'button.test_mower_1_confirm_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index c604923f67f..d8cd748c793 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -5,6 +5,22 @@ 'battery_percent': 100, }), 'calendar': dict({ + 'events': list([ + dict({ + 'end': '2024-03-02T00:00:00+00:00', + 'rrule': 'FREQ=WEEKLY;BYDAY=MO,WE,FR', + 'start': '2024-03-01T19:00:00+00:00', + 'uid': '1140_300_MO,WE,FR', + 'work_area_id': None, + }), + dict({ + 'end': '2024-03-02T08:00:00+00:00', + 'rrule': 'FREQ=WEEKLY;BYDAY=TU,TH,SA', + 'start': '2024-03-02T00:00:00+00:00', + 'uid': '0_480_TU,TH,SA', + 'work_area_id': None, + }), + ]), 'tasks': list([ dict({ 'duration': 300, @@ -38,10 +54,6 @@ 'stay_out_zones': True, 'work_areas': True, }), - 'cutting_height': 4, - 'headlight': dict({ - 'mode': 'EVENING_ONLY', - }), 'metadata': dict({ 'connected': True, 'status_dateteime': '2023-10-18T22:58:52.683000+00:00', @@ -52,8 +64,11 @@ 'error_datetime': None, 'error_datetime_naive': None, 'error_key': None, + 'inactive_reason': 'NONE', + 'is_error_confirmable': False, 'mode': 'MAIN_AREA', 'state': 'RESTRICTED', + 'work_area_id': 123456, }), 'planner': dict({ 'next_start_datetime': '2023-06-05T19:00:00+00:00', @@ -64,6 +79,12 @@ 'restricted_reason': 'WEEK_SCHEDULE', }), 'positions': '**REDACTED**', + 'settings': dict({ + 'cutting_height': 4, + 'headlight': dict({ + 'mode': 'EVENING_ONLY', + }), + }), 'statistics': dict({ 'cutting_blade_usage_time': 123, 'number_of_charging_cycles': 1380, @@ -81,6 +102,10 @@ 'enabled': True, 'name': 'Springflowers', }), + 'AAAAAAAA-BBBB-CCCC-DDDD-123456789101': dict({ + 'enabled': False, + 'name': 'Danger Zone', + }), }), }), 'system': dict({ diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr index 4ce5476a555..de8b397f01c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_number.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_snapshot_number[number.test_mower_1_back_lawn_cutting_height-entry] +# name: test_number_snapshot[number.test_mower_1_back_lawn_cutting_height-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -37,7 +37,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_snapshot_number[number.test_mower_1_back_lawn_cutting_height-state] +# name: test_number_snapshot[number.test_mower_1_back_lawn_cutting_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Back lawn cutting height', @@ -55,7 +55,7 @@ 'state': '25', }) # --- -# name: test_snapshot_number[number.test_mower_1_cutting_height-entry] +# name: test_number_snapshot[number.test_mower_1_cutting_height-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -93,7 +93,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_snapshot_number[number.test_mower_1_cutting_height-state] +# name: test_number_snapshot[number.test_mower_1_cutting_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Cutting height', @@ -110,7 +110,7 @@ 'state': '4', }) # --- -# name: test_snapshot_number[number.test_mower_1_front_lawn_cutting_height-entry] +# name: test_number_snapshot[number.test_mower_1_front_lawn_cutting_height-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -148,7 +148,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_snapshot_number[number.test_mower_1_front_lawn_cutting_height-state] +# name: test_number_snapshot[number.test_mower_1_front_lawn_cutting_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Front lawn cutting height', @@ -166,7 +166,7 @@ 'state': '50', }) # --- -# name: test_snapshot_number[number.test_mower_1_my_lawn_cutting_height-entry] +# name: test_number_snapshot[number.test_mower_1_my_lawn_cutting_height-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -204,7 +204,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_snapshot_number[number.test_mower_1_my_lawn_cutting_height-state] +# name: test_number_snapshot[number.test_mower_1_my_lawn_cutting_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 My lawn cutting height ', diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 7d4533afe72..0b0d76620d3 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[sensor.test_mower_1_battery-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor[sensor.test_mower_1_battery-state] +# name: test_sensor_snapshot[sensor.test_mower_1_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -50,7 +50,7 @@ 'state': '100', }) # --- -# name: test_sensor[sensor.test_mower_1_cutting_blade_usage_time-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_cutting_blade_usage_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -88,7 +88,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.test_mower_1_cutting_blade_usage_time-state] +# name: test_sensor_snapshot[sensor.test_mower_1_cutting_blade_usage_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -104,7 +104,7 @@ 'state': '0.034', }) # --- -# name: test_sensor[sensor.test_mower_1_error-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_error-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -283,7 +283,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.test_mower_1_error-state] +# name: test_sensor_snapshot[sensor.test_mower_1_error-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -442,7 +442,7 @@ 'state': 'no_error', }) # --- -# name: test_sensor[sensor.test_mower_1_mode-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -483,7 +483,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.test_mower_1_mode-state] +# name: test_sensor_snapshot[sensor.test_mower_1_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -504,7 +504,7 @@ 'state': 'main_area', }) # --- -# name: test_sensor[sensor.test_mower_1_next_start-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_next_start-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -537,7 +537,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.test_mower_1_next_start-state] +# name: test_sensor_snapshot[sensor.test_mower_1_next_start-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -551,7 +551,65 @@ 'state': '2023-06-05T19:00:00+00:00', }) # --- -# name: test_sensor[sensor.test_mower_1_number_of_charging_cycles-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Front lawn', + 'Back lawn', + 'my_lawn', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'work_area', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_work_area', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 None', + 'options': list([ + 'Front lawn', + 'Back lawn', + 'my_lawn', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Front lawn', + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_number_of_charging_cycles-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -586,7 +644,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.test_mower_1_number_of_charging_cycles-state] +# name: test_sensor_snapshot[sensor.test_mower_1_number_of_charging_cycles-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Number of charging cycles', @@ -600,7 +658,7 @@ 'state': '1380', }) # --- -# name: test_sensor[sensor.test_mower_1_number_of_collisions-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_number_of_collisions-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -635,7 +693,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.test_mower_1_number_of_collisions-state] +# name: test_sensor_snapshot[sensor.test_mower_1_number_of_collisions-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Number of collisions', @@ -649,7 +707,7 @@ 'state': '11396', }) # --- -# name: test_sensor[sensor.test_mower_1_restricted_reason-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_restricted_reason-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -695,7 +753,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.test_mower_1_restricted_reason-state] +# name: test_sensor_snapshot[sensor.test_mower_1_restricted_reason-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -721,7 +779,7 @@ 'state': 'week_schedule', }) # --- -# name: test_sensor[sensor.test_mower_1_total_charging_time-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_total_charging_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -759,7 +817,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.test_mower_1_total_charging_time-state] +# name: test_sensor_snapshot[sensor.test_mower_1_total_charging_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -775,7 +833,7 @@ 'state': '1204.000', }) # --- -# name: test_sensor[sensor.test_mower_1_total_cutting_time-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_total_cutting_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -813,7 +871,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.test_mower_1_total_cutting_time-state] +# name: test_sensor_snapshot[sensor.test_mower_1_total_cutting_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -829,7 +887,7 @@ 'state': '1165.000', }) # --- -# name: test_sensor[sensor.test_mower_1_total_drive_distance-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_total_drive_distance-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -867,7 +925,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.test_mower_1_total_drive_distance-state] +# name: test_sensor_snapshot[sensor.test_mower_1_total_drive_distance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -883,7 +941,7 @@ 'state': '1780.272', }) # --- -# name: test_sensor[sensor.test_mower_1_total_running_time-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_total_running_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -921,7 +979,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.test_mower_1_total_running_time-state] +# name: test_sensor_snapshot[sensor.test_mower_1_total_running_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -937,7 +995,7 @@ 'state': '1268.000', }) # --- -# name: test_sensor[sensor.test_mower_1_total_searching_time-entry] +# name: test_sensor_snapshot[sensor.test_mower_1_total_searching_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -975,7 +1033,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[sensor.test_mower_1_total_searching_time-state] +# name: test_sensor_snapshot[sensor.test_mower_1_total_searching_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -991,3 +1049,61 @@ 'state': '103.000', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_work_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Front lawn', + 'Back lawn', + 'my_lawn', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_work_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Work area', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'work_area', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_work_area', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_work_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 Work area', + 'options': list([ + 'Front lawn', + 'Back lawn', + 'my_lawn', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_work_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Front lawn', + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr index c54997fcf06..f52462496ff 100644 --- a/tests/components/husqvarna_automower/snapshots/test_switch.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -1,5 +1,97 @@ # serializer version: 1 -# name: test_switch[switch.test_mower_1_enable_schedule-entry] +# name: test_switch_snapshot[switch.test_mower_1_avoid_danger_zone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_mower_1_avoid_danger_zone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Avoid Danger Zone', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stay_out_zones', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_AAAAAAAA-BBBB-CCCC-DDDD-123456789101_stay_out_zones', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_avoid_danger_zone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Avoid Danger Zone', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_avoid_danger_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_avoid_springflowers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_mower_1_avoid_springflowers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Avoid Springflowers', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stay_out_zones', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_81C6EEA2-D139-4FEA-B134-F22A6B3EA403_stay_out_zones', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_avoid_springflowers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Avoid Springflowers', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_avoid_springflowers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_enable_schedule-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +124,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switch[switch.test_mower_1_enable_schedule-state] +# name: test_switch_snapshot[switch.test_mower_1_enable_schedule-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Test Mower 1 Enable schedule', diff --git a/tests/components/husqvarna_automower/test_binary_sensor.py b/tests/components/husqvarna_automower/test_binary_sensor.py index 5500b547853..fceaeee2321 100644 --- a/tests/components/husqvarna_automower/test_binary_sensor.py +++ b/tests/components/husqvarna_automower/test_binary_sensor.py @@ -45,11 +45,11 @@ async def test_binary_sensor_states( assert state is not None assert state.state == "off" - for activity, entity in [ + for activity, entity in ( (MowerActivities.CHARGING, "test_mower_1_charging"), (MowerActivities.LEAVING, "test_mower_1_leaving_dock"), (MowerActivities.GOING_HOME, "test_mower_1_returning_to_dock"), - ]: + ): values[TEST_MOWER_ID].mower.activity = activity mock_automower_client.get_status.return_value = values freezer.tick(SCAN_INTERVAL) @@ -59,14 +59,14 @@ async def test_binary_sensor_states( assert state.state == "on" -async def test_snapshot_binary_sensor( +async def test_binary_sensor_snapshot( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test states of the binary sensors.""" + """Snapshot test states of the binary sensors.""" with patch( "homeassistant.components.husqvarna_automower.PLATFORMS", [Platform.BINARY_SENSOR], diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py new file mode 100644 index 00000000000..6cc465df74b --- /dev/null +++ b/tests/components/husqvarna_automower/test_button.py @@ -0,0 +1,112 @@ +"""Tests for button platform.""" + +import datetime +from unittest.mock import AsyncMock, patch + +from aioautomower.exceptions import ApiException +from aioautomower.utils import mower_list_to_dictionary_dataclass +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .const import TEST_MOWER_ID + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_value_fixture, + snapshot_platform, +) + + +@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_states_and_commands( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test button commands.""" + entity_id = "button.test_mower_1_confirm_error" + await setup_integration(hass, mock_config_entry) + state = hass.states.get(entity_id) + assert state.name == "Test Mower 1 Confirm error" + assert state.state == STATE_UNAVAILABLE + + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + values[TEST_MOWER_ID].mower.is_error_confirmable = None + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + values[TEST_MOWER_ID].mower.is_error_confirmable = True + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + domain="button", + service=SERVICE_PRESS, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mocked_method = getattr(mock_automower_client.commands, "error_confirm") + mocked_method.assert_called_once_with(TEST_MOWER_ID) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "2024-02-29T11:16:00+00:00" + getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiException( + "Test error" + ) + with pytest.raises( + HomeAssistantError, + match="Failed to send command: Test error", + ): + await hass.services.async_call( + domain="button", + service=SERVICE_PRESS, + target={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot tests of the button entities.""" + with patch( + "homeassistant.components.husqvarna_automower.PLATFORMS", + [Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) diff --git a/tests/components/husqvarna_automower/test_config_flow.py b/tests/components/husqvarna_automower/test_config_flow.py index 0a345eed627..31e8a9afcbd 100644 --- a/tests/components/husqvarna_automower/test_config_flow.py +++ b/tests/components/husqvarna_automower/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, patch +import pytest + from homeassistant import config_entries from homeassistant.components.husqvarna_automower.const import ( DOMAIN, @@ -21,12 +23,21 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.mark.parametrize( + ("new_scope", "amount"), + [ + ("iam:read amc:api", 1), + ("iam:read", 0), + ], +) +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host, - jwt, + jwt: str, + new_scope: str, + amount: int, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -56,7 +67,7 @@ async def test_full_flow( OAUTH2_TOKEN, json={ "access_token": jwt, - "scope": "iam:read amc:api", + "scope": new_scope, "expires_in": 86399, "refresh_token": "mock-refresh-token", "provider": "husqvarna", @@ -72,14 +83,14 @@ async def test_full_flow( ) as mock_setup: await hass.config_entries.flow.async_configure(result["flow_id"]) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == amount + assert len(mock_setup.mock_calls) == amount +@pytest.mark.usefixtures("current_request_with_host") async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, mock_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, mock_automower_client: AsyncMock, @@ -129,14 +140,25 @@ async def test_config_non_unique_profile( assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("scope", "step_id", "reason", "new_scope"), + [ + ("iam:read amc:api", "reauth_confirm", "reauth_successful", "iam:read amc:api"), + ("iam:read", "missing_scope", "reauth_successful", "iam:read amc:api"), + ("iam:read", "missing_scope", "missing_amc_scope", "iam:read"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_config_entry: MockConfigEntry, - current_request_with_host: None, mock_automower_client: AsyncMock, - jwt, + jwt: str, + step_id: str, + new_scope: str, + reason: str, ) -> None: """Test the reauthentication case updates the existing config entry.""" @@ -148,7 +170,7 @@ async def test_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 result = flows[0] - assert result["step_id"] == "reauth_confirm" + assert result["step_id"] == step_id result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) state = config_entry_oauth2_flow._encode_jwt( @@ -172,7 +194,7 @@ async def test_reauth( OAUTH2_TOKEN, json={ "access_token": "mock-updated-token", - "scope": "iam:read amc:api", + "scope": new_scope, "expires_in": 86399, "refresh_token": "mock-refresh-token", "provider": "husqvarna", @@ -191,7 +213,7 @@ async def test_reauth( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "reauth_successful" + assert result.get("reason") == reason assert mock_config_entry.unique_id == USER_ID assert "token" in mock_config_entry.data @@ -200,14 +222,23 @@ async def test_reauth( assert mock_config_entry.data["token"].get("refresh_token") == "mock-refresh-token" +@pytest.mark.parametrize( + ("user_id", "reason"), + [ + ("wrong_user_id", "wrong_account"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_config_entry: MockConfigEntry, - current_request_with_host: None, mock_automower_client: AsyncMock, jwt, + user_id: str, + reason: str, + scope: str, ) -> None: """Test the reauthentication aborts, if user tries to reauthenticate with another account.""" @@ -247,7 +278,7 @@ async def test_reauth_wrong_account( "expires_in": 86399, "refresh_token": "mock-refresh-token", "provider": "husqvarna", - "user_id": "wrong-user-id", + "user_id": user_id, "token_type": "Bearer", "expires_at": 1697753347, }, @@ -262,7 +293,7 @@ async def test_reauth_wrong_account( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "wrong_account" + assert result.get("reason") == reason assert mock_config_entry.unique_id == USER_ID assert "token" in mock_config_entry.data diff --git a/tests/components/husqvarna_automower/test_device_tracker.py b/tests/components/husqvarna_automower/test_device_tracker.py index 015be201ccc..91f5e40b154 100644 --- a/tests/components/husqvarna_automower/test_device_tracker.py +++ b/tests/components/husqvarna_automower/test_device_tracker.py @@ -20,7 +20,7 @@ async def test_device_tracker_snapshot( mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test device tracker with a snapshot.""" + """Snapshot test of the device tracker.""" with patch( "homeassistant.components.husqvarna_automower.PLATFORMS", [Platform.DEVICE_TRACKER], diff --git a/tests/components/husqvarna_automower/test_diagnostics.py b/tests/components/husqvarna_automower/test_diagnostics.py index c19345e507e..eeb6b46e6c4 100644 --- a/tests/components/husqvarna_automower/test_diagnostics.py +++ b/tests/components/husqvarna_automower/test_diagnostics.py @@ -39,6 +39,7 @@ async def test_entry_diagnostics( assert result == snapshot +@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index dbf1d429eee..84fe1b9e891 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -5,7 +5,11 @@ import http import time from unittest.mock import AsyncMock -from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError +from aioautomower.exceptions import ( + ApiException, + AuthException, + HusqvarnaWSServerHandshakeError, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -39,6 +43,26 @@ async def test_load_unload_entry( assert entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("scope"), + [ + ("iam:read"), + ], +) +async def test_load_missing_scope( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if the entry starts a reauth with the missing token scope.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "missing_scope" + + @pytest.mark.parametrize( ("expires_at", "status", "expected_state"), [ @@ -75,19 +99,25 @@ async def test_expired_token_refresh_failure( assert mock_config_entry.state is expected_state +@pytest.mark.parametrize( + ("exception", "entry_state"), + [ + (ApiException, ConfigEntryState.SETUP_RETRY), + (AuthException, ConfigEntryState.SETUP_ERROR), + ], +) async def test_update_failed( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, + exception: Exception, + entry_state: ConfigEntryState, ) -> None: - """Test load and unload entry.""" - getattr(mock_automower_client, "get_status").side_effect = ApiException( - "Test error" - ) + """Test update failed.""" + mock_automower_client.get_status.side_effect = exception("Test error") await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] - - assert entry.state is ConfigEntryState.SETUP_RETRY + assert entry.state is entry_state async def test_websocket_not_available( diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index c8aea0e7c98..5d5cacfc6bf 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -1,11 +1,13 @@ """Tests for lawn_mower module.""" +from datetime import timedelta from unittest.mock import AsyncMock from aioautomower.exceptions import ApiException from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory import pytest +from voluptuous.error import MultipleInvalid from homeassistant.components.husqvarna_automower.const import DOMAIN from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL @@ -38,11 +40,11 @@ async def test_lawn_mower_states( assert state is not None assert state.state == LawnMowerActivity.DOCKED - for activity, state, expected_state in [ + for activity, state, expected_state in ( ("UNKNOWN", "PAUSED", LawnMowerActivity.PAUSED), ("MOWING", "NOT_APPLICABLE", LawnMowerActivity.MOWING), ("NOT_APPLICABLE", "ERROR", LawnMowerActivity.ERROR), - ]: + ): values[TEST_MOWER_ID].mower.activity = activity values[TEST_MOWER_ID].mower.state = state mock_automower_client.get_status.return_value = values @@ -70,19 +72,117 @@ async def test_lawn_mower_commands( ) -> None: """Test lawn_mower commands.""" await setup_integration(hass, mock_config_entry) - - getattr(mock_automower_client, aioautomower_command).side_effect = ApiException( - "Test error" + await hass.services.async_call( + domain="lawn_mower", + service=service, + service_data={"entity_id": "lawn_mower.test_mower_1"}, + blocking=True, ) + mocked_method = getattr(mock_automower_client.commands, aioautomower_command) + mocked_method.assert_called_once_with(TEST_MOWER_ID) - with pytest.raises(HomeAssistantError) as exc_info: + getattr( + mock_automower_client.commands, aioautomower_command + ).side_effect = ApiException("Test error") + with pytest.raises( + HomeAssistantError, + match="Failed to send command: Test error", + ): await hass.services.async_call( domain="lawn_mower", service=service, - service_data={"entity_id": "lawn_mower.test_mower_1"}, + target={"entity_id": "lawn_mower.test_mower_1"}, blocking=True, ) - assert ( - str(exc_info.value) - == "Command couldn't be sent to the command queue: Test error" + + +@pytest.mark.parametrize( + ("aioautomower_command", "extra_data", "service", "service_data"), + [ + ( + "start_for", + timedelta(hours=3), + "override_schedule", + { + "duration": {"days": 0, "hours": 3, "minutes": 0}, + "override_mode": "mow", + }, + ), + ( + "park_for", + timedelta(days=1, hours=12, minutes=30), + "override_schedule", + { + "duration": {"days": 1, "hours": 12, "minutes": 30}, + "override_mode": "park", + }, + ), + ], +) +async def test_lawn_mower_service_commands( + hass: HomeAssistant, + aioautomower_command: str, + extra_data: int | None, + service: str, + service_data: dict[str, int] | None, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lawn_mower commands.""" + await setup_integration(hass, mock_config_entry) + mocked_method = AsyncMock() + setattr(mock_automower_client.commands, aioautomower_command, mocked_method) + await hass.services.async_call( + domain=DOMAIN, + service=service, + target={"entity_id": "lawn_mower.test_mower_1"}, + service_data=service_data, + blocking=True, ) + mocked_method.assert_called_once_with(TEST_MOWER_ID, extra_data) + + getattr( + mock_automower_client.commands, aioautomower_command + ).side_effect = ApiException("Test error") + with pytest.raises( + HomeAssistantError, + match="Failed to send command: Test error", + ): + await hass.services.async_call( + domain=DOMAIN, + service=service, + target={"entity_id": "lawn_mower.test_mower_1"}, + service_data=service_data, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("service", "service_data"), + [ + ( + "override_schedule", + { + "duration": {"days": 1, "hours": 12, "minutes": 30}, + "override_mode": "fly_to_moon", + }, + ), + ], +) +async def test_lawn_mower_wrong_service_commands( + hass: HomeAssistant, + service: str, + service_data: dict[str, int] | None, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lawn_mower commands.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + domain=DOMAIN, + service=service, + target={"entity_id": "lawn_mower.test_mower_1"}, + service_data=service_data, + blocking=True, + ) diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index a883ed43e81..0547d6a9b2e 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -35,11 +35,14 @@ async def test_number_commands( service_data={"value": "3"}, blocking=True, ) - mocked_method = mock_automower_client.set_cutting_height - assert len(mocked_method.mock_calls) == 1 + mocked_method = mock_automower_client.commands.set_cutting_height + mocked_method.assert_called_once_with(TEST_MOWER_ID, 3) mocked_method.side_effect = ApiException("Test error") - with pytest.raises(HomeAssistantError) as exc_info: + with pytest.raises( + HomeAssistantError, + match="Command couldn't be sent to the command queue: Test error", + ): await hass.services.async_call( domain="number", service="set_value", @@ -47,10 +50,6 @@ async def test_number_commands( service_data={"value": "3"}, blocking=True, ) - assert ( - str(exc_info.value) - == "Command couldn't be sent to the command queue: Test error" - ) assert len(mocked_method.mock_calls) == 2 @@ -68,7 +67,9 @@ async def test_number_workarea_commands( values[TEST_MOWER_ID].work_areas[123456].cutting_height = 75 mock_automower_client.get_status.return_value = values mocked_method = AsyncMock() - setattr(mock_automower_client, "set_cutting_height_workarea", mocked_method) + setattr( + mock_automower_client.commands, "set_cutting_height_workarea", mocked_method + ) await hass.services.async_call( domain="number", service="set_value", @@ -76,13 +77,16 @@ async def test_number_workarea_commands( service_data={"value": "75"}, blocking=True, ) - assert len(mocked_method.mock_calls) == 1 + mocked_method.assert_called_once_with(TEST_MOWER_ID, 75, 123456) state = hass.states.get(entity_id) assert state.state is not None assert state.state == "75" mocked_method.side_effect = ApiException("Test error") - with pytest.raises(HomeAssistantError) as exc_info: + with pytest.raises( + HomeAssistantError, + match="Command couldn't be sent to the command queue: Test error", + ): await hass.services.async_call( domain="number", service="set_value", @@ -90,10 +94,6 @@ async def test_number_workarea_commands( service_data={"value": "75"}, blocking=True, ) - assert ( - str(exc_info.value) - == "Command couldn't be sent to the command queue: Test error" - ) assert len(mocked_method.mock_calls) == 2 @@ -123,14 +123,14 @@ async def test_workarea_deleted( @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_snapshot_number( +async def test_number_snapshot( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test states of the number entity.""" + """Snapshot tests of the number entities.""" with patch( "homeassistant.components.husqvarna_automower.PLATFORMS", [Platform.NUMBER], diff --git a/tests/components/husqvarna_automower/test_select.py b/tests/components/husqvarna_automower/test_select.py index 9e255eb410f..2728bb5e672 100644 --- a/tests/components/husqvarna_automower/test_select.py +++ b/tests/components/husqvarna_automower/test_select.py @@ -38,15 +38,15 @@ async def test_select_states( assert state is not None assert state.state == "evening_only" - for state, expected_state in [ + for state, expected_state in ( ( HeadlightModes.ALWAYS_OFF, "always_off", ), (HeadlightModes.ALWAYS_ON, "always_on"), (HeadlightModes.EVENING_AND_NIGHT, "evening_and_night"), - ]: - values[TEST_MOWER_ID].headlight.mode = state + ): + values[TEST_MOWER_ID].settings.headlight.mode = state mock_automower_client.get_status.return_value = values freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -81,11 +81,15 @@ async def test_select_commands( }, blocking=True, ) - mocked_method = mock_automower_client.set_headlight_mode + mocked_method = mock_automower_client.commands.set_headlight_mode + mocked_method.assert_called_once_with(TEST_MOWER_ID, service.upper()) assert len(mocked_method.mock_calls) == 1 mocked_method.side_effect = ApiException("Test error") - with pytest.raises(HomeAssistantError) as exc_info: + with pytest.raises( + HomeAssistantError, + match="Command couldn't be sent to the command queue: Test error", + ): await hass.services.async_call( domain="select", service="select_option", @@ -95,8 +99,4 @@ async def test_select_commands( }, blocking=True, ) - assert ( - str(exc_info.value) - == "Command couldn't be sent to the command queue: Test error" - ) assert len(mocked_method.mock_calls) == 2 diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index 2c0661f82cb..8f30a3dcb04 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -131,10 +131,10 @@ async def test_error_sensor( ) await setup_integration(hass, mock_config_entry) - for state, expected_state in [ + for state, expected_state in ( (None, "no_error"), ("can_error", "can_error"), - ]: + ): values[TEST_MOWER_ID].mower.error_key = state mock_automower_client.get_status.return_value = values freezer.tick(SCAN_INTERVAL) @@ -144,14 +144,14 @@ async def test_error_sensor( assert state.state == expected_state -async def test_sensor( +async def test_sensor_snapshot( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test states of the sensors.""" + """Snapshot test of the sensors.""" with patch( "homeassistant.components.husqvarna_automower.PLATFORMS", [Platform.SENSOR], diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index aab1128a746..08450158876 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ApiException -from aioautomower.model import MowerStates, RestrictedReasons +from aioautomower.model import MowerModes from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,6 +26,8 @@ from tests.common import ( snapshot_platform, ) +TEST_ZONE_ID = "AAAAAAAA-BBBB-CCCC-DDDD-123456789101" + async def test_switch_states( hass: HomeAssistant, @@ -39,12 +41,11 @@ async def test_switch_states( ) await setup_integration(hass, mock_config_entry) - for state, restricted_reson, expected_state in [ - (MowerStates.RESTRICTED, RestrictedReasons.NOT_APPLICABLE, "off"), - (MowerStates.IN_OPERATION, RestrictedReasons.NONE, "on"), - ]: - values[TEST_MOWER_ID].mower.state = state - values[TEST_MOWER_ID].planner.restricted_reason = restricted_reson + for mode, expected_state in ( + (MowerModes.HOME, "off"), + (MowerModes.MAIN_AREA, "on"), + ): + values[TEST_MOWER_ID].mower.mode = mode mock_automower_client.get_status.return_value = values freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -76,32 +77,107 @@ async def test_switch_commands( service_data={"entity_id": "switch.test_mower_1_enable_schedule"}, blocking=True, ) - mocked_method = getattr(mock_automower_client, aioautomower_command) - assert len(mocked_method.mock_calls) == 1 + mocked_method = getattr(mock_automower_client.commands, aioautomower_command) + mocked_method.assert_called_once_with(TEST_MOWER_ID) mocked_method.side_effect = ApiException("Test error") - with pytest.raises(HomeAssistantError) as exc_info: + with pytest.raises( + HomeAssistantError, + match="Command couldn't be sent to the command queue: Test error", + ): await hass.services.async_call( domain="switch", service=service, service_data={"entity_id": "switch.test_mower_1_enable_schedule"}, blocking=True, ) - assert ( - str(exc_info.value) - == "Command couldn't be sent to the command queue: Test error" - ) assert len(mocked_method.mock_calls) == 2 -async def test_switch( +@pytest.mark.parametrize( + ("service", "boolean", "excepted_state"), + [ + ("turn_off", False, "off"), + ("turn_on", True, "on"), + ("toggle", True, "on"), + ], +) +async def test_stay_out_zone_switch_commands( + hass: HomeAssistant, + service: str, + boolean: bool, + excepted_state: str, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch commands.""" + entity_id = "switch.test_mower_1_avoid_danger_zone" + await setup_integration(hass, mock_config_entry) + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + values[TEST_MOWER_ID].stay_out_zones.zones[TEST_ZONE_ID].enabled = boolean + mock_automower_client.get_status.return_value = values + mocked_method = AsyncMock() + setattr(mock_automower_client.commands, "switch_stay_out_zone", mocked_method) + await hass.services.async_call( + domain="switch", + service=service, + service_data={"entity_id": entity_id}, + blocking=True, + ) + mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_ZONE_ID, boolean) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == excepted_state + + mocked_method.side_effect = ApiException("Test error") + with pytest.raises( + HomeAssistantError, + match="Command couldn't be sent to the command queue: Test error", + ): + await hass.services.async_call( + domain="switch", + service=service, + service_data={"entity_id": entity_id}, + blocking=True, + ) + assert len(mocked_method.mock_calls) == 2 + + +async def test_zones_deleted( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test if stay-out-zone is deleted after removed.""" + + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + current_entries = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + del values[TEST_MOWER_ID].stay_out_zones.zones[TEST_ZONE_ID] + mock_automower_client.get_status.return_value = values + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) == (current_entries - 1) + + +async def test_switch_snapshot( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test states of the switch.""" + """Snapshot tests of the switches.""" with patch( "homeassistant.components.husqvarna_automower.PLATFORMS", [Platform.SWITCH], diff --git a/tests/components/hvv_departures/test_config_flow.py b/tests/components/hvv_departures/test_config_flow.py index d9545b903c1..c85bfb7f6ee 100644 --- a/tests/components/hvv_departures/test_config_flow.py +++ b/tests/components/hvv_departures/test_config_flow.py @@ -144,7 +144,7 @@ async def test_user_flow_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.hvv_departures.hub.GTI.init", side_effect=InvalidAuth( "ERROR_TEXT", - "Bei der Verarbeitung der Anfrage ist ein technisches Problem aufgetreten.", + "Bei der Verarbeitung der Anfrage ist ein technisches Problem aufgetreten.", # codespell:ignore ist "Authentication failed!", ), ): @@ -343,7 +343,7 @@ async def test_options_flow_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.hvv_departures.hub.GTI.departureList", side_effect=InvalidAuth( "ERROR_TEXT", - "Bei der Verarbeitung der Anfrage ist ein technisches Problem aufgetreten.", + "Bei der Verarbeitung der Anfrage ist ein technisches Problem aufgetreten.", # codespell:ignore ist "Authentication failed!", ), ): diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 11670cb3565..eb1518eb7f2 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -1,18 +1,26 @@ """Common fixtures for the Hydrawise tests.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from unittest.mock import AsyncMock, patch from pydrawise.schema import ( Controller, ControllerHardware, + ControllerWaterUseSummary, + CustomSensorTypeEnum, + LocalizedValueType, ScheduledZoneRun, ScheduledZoneRuns, + Sensor, + SensorModel, + SensorStatus, + UnitsSummary, User, Zone, ) import pytest +from typing_extensions import Generator from homeassistant.components.hydrawise.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME @@ -23,7 +31,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.hydrawise.async_setup_entry", return_value=True @@ -36,7 +44,7 @@ def mock_legacy_pydrawise( user: User, controller: Controller, zones: list[Zone], -) -> Generator[AsyncMock, None, None]: +) -> Generator[AsyncMock]: """Mock LegacyHydrawiseAsync.""" with patch( "pydrawise.legacy.LegacyHydrawiseAsync", autospec=True @@ -53,17 +61,23 @@ def mock_pydrawise( user: User, controller: Controller, zones: list[Zone], -) -> Generator[AsyncMock, None, None]: + sensors: list[Sensor], + controller_water_use_summary: ControllerWaterUseSummary, +) -> Generator[AsyncMock]: """Mock Hydrawise.""" with patch("pydrawise.client.Hydrawise", autospec=True) as mock_pydrawise: user.controllers = [controller] controller.zones = zones + controller.sensors = sensors mock_pydrawise.return_value.get_user.return_value = user + mock_pydrawise.return_value.get_water_use_summary.return_value = ( + controller_water_use_summary + ) yield mock_pydrawise.return_value @pytest.fixture -def mock_auth() -> Generator[AsyncMock, None, None]: +def mock_auth() -> Generator[AsyncMock]: """Mock pydrawise Auth.""" with patch("pydrawise.auth.Auth", autospec=True) as mock_auth: yield mock_auth.return_value @@ -72,7 +86,11 @@ def mock_auth() -> Generator[AsyncMock, None, None]: @pytest.fixture def user() -> User: """Hydrawise User fixture.""" - return User(customer_id=12345, email="asdf@asdf.com") + return User( + customer_id=12345, + email="asdf@asdf.com", + units=UnitsSummary(units_name="imperial"), + ) @pytest.fixture @@ -86,9 +104,50 @@ def controller() -> Controller: ), last_contact_time=datetime.fromtimestamp(1693292420), online=True, + sensors=[], ) +@pytest.fixture +def sensors() -> list[Sensor]: + """Hydrawise sensor fixtures.""" + return [ + Sensor( + id=337844, + name="Rain sensor ", + model=SensorModel( + id=3318, + name="Rain Sensor (normally closed wire)", + active=True, + off_level=1, + off_timer=0, + divisor=0.0, + flow_rate=0.0, + sensor_type=CustomSensorTypeEnum.LEVEL_CLOSED, + ), + status=SensorStatus(water_flow=None, active=False), + ), + Sensor( + id=337845, + name="Flow meter", + model=SensorModel( + id=3324, + name="1, 1½ or 2 inch NPT Flow Meter", + active=True, + off_level=0, + off_timer=0, + divisor=0.52834, + flow_rate=3.7854, + sensor_type=CustomSensorTypeEnum.FLOW, + ), + status=SensorStatus( + water_flow=LocalizedValueType(value=577.0044752010709, unit="gal"), + active=None, + ), + ), + ] + + @pytest.fixture def zones() -> list[Zone]: """Hydrawise zone fixtures.""" @@ -123,6 +182,18 @@ def zones() -> list[Zone]: ] +@pytest.fixture +def controller_water_use_summary() -> ControllerWaterUseSummary: + """Mock water use summary for the controller.""" + return ControllerWaterUseSummary( + total_use=345.6, + total_active_use=332.6, + total_inactive_use=13.0, + active_use_by_zone_id={5965394: 120.1, 5965395: 0.0}, + unit="gal", + ) + + @pytest.fixture def mock_config_entry_legacy() -> MockConfigEntry: """Mock ConfigEntry.""" diff --git a/tests/components/hydrawise/snapshots/test_binary_sensor.ambr b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..9886345595d --- /dev/null +++ b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_all_binary_sensors[binary_sensor.home_controller_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.home_controller_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connectivity', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '52496_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.home_controller_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'connectivity', + 'friendly_name': 'Home Controller Connectivity', + }), + 'context': , + 'entity_id': 'binary_sensor.home_controller_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_binary_sensors[binary_sensor.home_controller_rain_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.home_controller_rain_sensor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain sensor', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rain_sensor', + 'unique_id': '52496_rain_sensor', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.home_controller_rain_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'moisture', + 'friendly_name': 'Home Controller Rain sensor', + }), + 'context': , + 'entity_id': 'binary_sensor.home_controller_rain_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensors[binary_sensor.zone_one_watering-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zone_one_watering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Watering', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'watering', + 'unique_id': '5965394_is_watering', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.zone_one_watering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'running', + 'friendly_name': 'Zone One Watering', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_one_watering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensors[binary_sensor.zone_two_watering-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.zone_two_watering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Watering', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'watering', + 'unique_id': '5965395_is_watering', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.zone_two_watering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'running', + 'friendly_name': 'Zone Two Watering', + }), + 'context': , + 'entity_id': 'binary_sensor.zone_two_watering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/hydrawise/snapshots/test_sensor.ambr b/tests/components/hydrawise/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3472de98460 --- /dev/null +++ b/tests/components/hydrawise/snapshots/test_sensor.ambr @@ -0,0 +1,469 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.home_controller_daily_active_water_use-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_controller_daily_active_water_use', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily active water use', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_active_water_use', + 'unique_id': '52496_daily_active_water_use', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.home_controller_daily_active_water_use-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'volume', + 'friendly_name': 'Home Controller Daily active water use', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_controller_daily_active_water_use', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1259.0279593584', + }) +# --- +# name: test_all_sensors[sensor.home_controller_daily_inactive_water_use-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_controller_daily_inactive_water_use', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily inactive water use', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_inactive_water_use', + 'unique_id': '52496_daily_inactive_water_use', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.home_controller_daily_inactive_water_use-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'volume', + 'friendly_name': 'Home Controller Daily inactive water use', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_controller_daily_inactive_water_use', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.210353192', + }) +# --- +# name: test_all_sensors[sensor.home_controller_daily_total_water_use-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_controller_daily_total_water_use', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily total water use', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_total_water_use', + 'unique_id': '52496_daily_total_water_use', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.home_controller_daily_total_water_use-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'volume', + 'friendly_name': 'Home Controller Daily total water use', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_controller_daily_total_water_use', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1308.2383125504', + }) +# --- +# name: test_all_sensors[sensor.zone_one_daily_active_water_use-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone_one_daily_active_water_use', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily active water use', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_active_water_use', + 'unique_id': '5965394_daily_active_water_use', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.zone_one_daily_active_water_use-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'volume', + 'friendly_name': 'Zone One Daily active water use', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_one_daily_active_water_use', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '454.6279552584', + }) +# --- +# name: test_all_sensors[sensor.zone_one_next_cycle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone_one_next_cycle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next cycle', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_cycle', + 'unique_id': '5965394_next_cycle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.zone_one_next_cycle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'timestamp', + 'friendly_name': 'Zone One Next cycle', + }), + 'context': , + 'entity_id': 'sensor.zone_one_next_cycle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-04T19:49:57+00:00', + }) +# --- +# name: test_all_sensors[sensor.zone_one_watering_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone_one_watering_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Watering time', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'watering_time', + 'unique_id': '5965394_watering_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.zone_one_watering_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'friendly_name': 'Zone One Watering time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_one_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_sensors[sensor.zone_two_daily_active_water_use-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone_two_daily_active_water_use', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:water-outline', + 'original_name': 'Daily active water use', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_active_water_use', + 'unique_id': '5965395_daily_active_water_use', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.zone_two_daily_active_water_use-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'volume', + 'friendly_name': 'Zone Two Daily active water use', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_two_daily_active_water_use', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_sensors[sensor.zone_two_next_cycle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone_two_next_cycle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next cycle', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_cycle', + 'unique_id': '5965395_next_cycle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.zone_two_next_cycle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'timestamp', + 'friendly_name': 'Zone Two Next cycle', + }), + 'context': , + 'entity_id': 'sensor.zone_two_next_cycle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_sensors[sensor.zone_two_watering_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zone_two_watering_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Watering time', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'watering_time', + 'unique_id': '5965395_watering_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.zone_two_watering_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'friendly_name': 'Zone Two Watering time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zone_two_watering_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29', + }) +# --- diff --git a/tests/components/hydrawise/snapshots/test_switch.ambr b/tests/components/hydrawise/snapshots/test_switch.ambr new file mode 100644 index 00000000000..977bd15f004 --- /dev/null +++ b/tests/components/hydrawise/snapshots/test_switch.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_all_switches[switch.zone_one_automatic_watering-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.zone_one_automatic_watering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Automatic watering', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_watering', + 'unique_id': '5965394_auto_watering', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switches[switch.zone_one_automatic_watering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'switch', + 'friendly_name': 'Zone One Automatic watering', + }), + 'context': , + 'entity_id': 'switch.zone_one_automatic_watering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switches[switch.zone_one_manual_watering-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.zone_one_manual_watering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Manual watering', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_watering', + 'unique_id': '5965394_manual_watering', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switches[switch.zone_one_manual_watering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'switch', + 'friendly_name': 'Zone One Manual watering', + }), + 'context': , + 'entity_id': 'switch.zone_one_manual_watering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_switches[switch.zone_two_automatic_watering-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.zone_two_automatic_watering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Automatic watering', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_watering', + 'unique_id': '5965395_auto_watering', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switches[switch.zone_two_automatic_watering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'switch', + 'friendly_name': 'Zone Two Automatic watering', + }), + 'context': , + 'entity_id': 'switch.zone_two_automatic_watering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_switches[switch.zone_two_manual_watering-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.zone_two_manual_watering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Manual watering', + 'platform': 'hydrawise', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_watering', + 'unique_id': '5965395_manual_watering', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_switches[switch.zone_two_manual_watering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by hydrawise.com', + 'device_class': 'switch', + 'friendly_name': 'Zone Two Manual watering', + }), + 'context': , + 'entity_id': 'switch.zone_two_manual_watering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index f4702758136..a42f9b1c044 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -1,34 +1,35 @@ """Test Hydrawise binary_sensor.""" +from collections.abc import Awaitable, Callable from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Controller +from syrupy.assertion import SnapshotAssertion from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_states( +async def test_all_binary_sensors( hass: HomeAssistant, - mock_added_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test binary_sensor states.""" - connectivity = hass.states.get("binary_sensor.home_controller_connectivity") - assert connectivity is not None - assert connectivity.state == "on" - - watering1 = hass.states.get("binary_sensor.zone_one_watering") - assert watering1 is not None - assert watering1.state == "off" - - watering2 = hass.states.get("binary_sensor.zone_two_watering") - assert watering2 is not None - assert watering2.state == "on" + """Test that all binary sensors are working.""" + with patch( + "homeassistant.components.hydrawise.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_update_data_fails( @@ -47,4 +48,23 @@ async def test_update_data_fails( connectivity = hass.states.get("binary_sensor.home_controller_connectivity") assert connectivity is not None - assert connectivity.state == "unavailable" + assert connectivity.state == STATE_OFF + + +async def test_controller_offline( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + freezer: FrozenDateTimeFactory, + controller: Controller, +) -> None: + """Test the binary_sensor for the controller being online.""" + # Make the coordinator refresh data. + controller.online = False + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + connectivity = hass.states.get("binary_sensor.home_controller_connectivity") + assert connectivity + assert connectivity.state == STATE_OFF diff --git a/tests/components/hydrawise/test_entity_availability.py b/tests/components/hydrawise/test_entity_availability.py new file mode 100644 index 00000000000..58ded5fe6c3 --- /dev/null +++ b/tests/components/hydrawise/test_entity_availability.py @@ -0,0 +1,65 @@ +"""Test entity availability.""" + +from collections.abc import Awaitable, Callable +from datetime import timedelta +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Controller + +from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + +_SPECIAL_ENTITIES = {"binary_sensor.home_controller_connectivity": STATE_OFF} + + +async def test_controller_offline( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], + entity_registry: er.EntityRegistry, + controller: Controller, +) -> None: + """Test availability for sensors when controller is offline.""" + controller.online = False + config_entry = await mock_add_config_entry() + _test_availability(hass, config_entry, entity_registry) + + +async def test_api_offline( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], + entity_registry: er.EntityRegistry, + mock_pydrawise: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability of sensors when API call fails.""" + config_entry = await mock_add_config_entry() + mock_pydrawise.get_user.reset_mock(return_value=True) + mock_pydrawise.get_user.side_effect = ClientError + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + _test_availability(hass, config_entry, entity_registry) + + +def _test_availability( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries + for entity_entry in entity_entries: + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" + assert state.state == _SPECIAL_ENTITIES.get( + entity_entry.entity_id, STATE_UNAVAILABLE + ) diff --git a/tests/components/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py index f0edb79b349..af75ad69ade 100644 --- a/tests/components/hydrawise/test_sensor.py +++ b/tests/components/hydrawise/test_sensor.py @@ -1,34 +1,38 @@ """Test Hydrawise sensor.""" from collections.abc import Awaitable, Callable +from unittest.mock import patch -from freezegun.api import FrozenDateTimeFactory -from pydrawise.schema import Zone +from pydrawise.schema import Controller, User, Zone import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") -async def test_states( +async def test_all_sensors( hass: HomeAssistant, - mock_added_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test sensor states.""" - watering_time1 = hass.states.get("sensor.zone_one_watering_time") - assert watering_time1 is not None - assert watering_time1.state == "0" - - watering_time2 = hass.states.get("sensor.zone_two_watering_time") - assert watering_time2 is not None - assert watering_time2.state == "29" - - next_cycle = hass.states.get("sensor.zone_one_next_cycle") - assert next_cycle is not None - assert next_cycle.state == "2023-10-04T19:49:57+00:00" + """Test that all sensors are working.""" + with patch( + "homeassistant.components.hydrawise.PLATFORMS", + [Platform.SENSOR], + ): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) @pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") @@ -43,4 +47,51 @@ async def test_suspended_state( next_cycle = hass.states.get("sensor.zone_one_next_cycle") assert next_cycle is not None - assert next_cycle.state == "9999-12-31T23:59:59+00:00" + assert next_cycle.state == "unknown" + + +async def test_no_sensor_and_water_state( + hass: HomeAssistant, + controller: Controller, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], +) -> None: + """Test rain sensor, flow sensor, and water use in the absence of flow and rain sensors.""" + controller.sensors = [] + await mock_add_config_entry() + + assert hass.states.get("sensor.zone_one_daily_active_water_use") is None + assert hass.states.get("sensor.zone_two_daily_active_water_use") is None + assert hass.states.get("sensor.home_controller_daily_active_water_use") is None + assert hass.states.get("sensor.home_controller_daily_inactive_water_use") is None + assert hass.states.get("binary_sensor.home_controller_rain_sensor") is None + + sensor = hass.states.get("binary_sensor.home_controller_connectivity") + assert sensor is not None + assert sensor.state == "on" + + +@pytest.mark.parametrize( + ("hydrawise_unit_system", "unit_system", "expected_state"), + [ + ("imperial", METRIC_SYSTEM, "454.6279552584"), + ("imperial", US_CUSTOMARY_SYSTEM, "120.1"), + ("metric", METRIC_SYSTEM, "120.1"), + ("metric", US_CUSTOMARY_SYSTEM, "31.7270634882136"), + ], +) +async def test_volume_unit_conversion( + hass: HomeAssistant, + unit_system: UnitSystem, + hydrawise_unit_system: str, + expected_state: str, + user: User, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], +) -> None: + """Test volume unit conversion.""" + hass.config.units = unit_system + user.units.units_name = hydrawise_unit_system + await mock_add_config_entry() + + daily_active_water_use = hass.states.get("sensor.zone_one_daily_active_water_use") + assert daily_active_water_use is not None + assert daily_active_water_use.state == expected_state diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index f044d3467cd..ce60011b593 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -1,40 +1,41 @@ """Test Hydrawise switch.""" +from collections.abc import Awaitable, Callable from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from pydrawise.schema import Zone import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.hydrawise.const import DEFAULT_WATERING_TIME from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform -async def test_states( +async def test_all_switches( hass: HomeAssistant, - mock_added_config_entry: MockConfigEntry, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test switch states.""" - watering1 = hass.states.get("switch.zone_one_manual_watering") - assert watering1 is not None - assert watering1.state == "off" - - watering2 = hass.states.get("switch.zone_two_manual_watering") - assert watering2 is not None - assert watering2.state == "on" - - auto_watering1 = hass.states.get("switch.zone_one_automatic_watering") - assert auto_watering1 is not None - assert auto_watering1.state == "on" - - auto_watering2 = hass.states.get("switch.zone_two_automatic_watering") - assert auto_watering2 is not None - assert auto_watering2.state == "on" + """Test that all switches are working.""" + with patch( + "homeassistant.components.hydrawise.PLATFORMS", + [Platform.SWITCH], + ): + config_entry = await mock_add_config_entry() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) async def test_manual_watering_services( diff --git a/tests/components/hyperion/test_camera.py b/tests/components/hyperion/test_camera.py index 0169759f328..41b66f4ad4a 100644 --- a/tests/components/hyperion/test_camera.py +++ b/tests/components/hyperion/test_camera.py @@ -198,7 +198,7 @@ async def test_device_info( device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device - assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.config_entries == [TEST_CONFIG_ENTRY_ID] assert device.identifiers == {(DOMAIN, device_id)} assert device.manufacturer == HYPERION_MANUFACTURER_NAME assert device.model == HYPERION_MODEL_NAME diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index e1e7711e702..b7aef3ac2ac 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -803,7 +803,7 @@ async def test_device_info( device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device - assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.config_entries == [TEST_CONFIG_ENTRY_ID] assert device.identifiers == {(DOMAIN, device_id)} assert device.manufacturer == HYPERION_MANUFACTURER_NAME assert device.model == HYPERION_MODEL_NAME diff --git a/tests/components/hyperion/test_sensor.py b/tests/components/hyperion/test_sensor.py index 8900db177fc..bc58c07ac7b 100644 --- a/tests/components/hyperion/test_sensor.py +++ b/tests/components/hyperion/test_sensor.py @@ -52,24 +52,26 @@ async def test_sensor_has_correct_entities(hass: HomeAssistant) -> None: assert entity_state, f"Couldn't find entity: {entity_id}" -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Verify device information includes expected details.""" client = create_mock_client() client.components = TEST_COMPONENTS await setup_test_config_entry(hass, hyperion_client=client) device_identifer = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, device_identifer)}) assert device - assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.config_entries == [TEST_CONFIG_ENTRY_ID] assert device.identifiers == {(DOMAIN, device_identifer)} assert device.manufacturer == HYPERION_MANUFACTURER_NAME assert device.model == HYPERION_MODEL_NAME assert device.name == TEST_INSTANCE_1["friendly_name"] - entity_registry = er.async_get(hass) entities_from_device = [ entry.entity_id for entry in er.async_entries_for_device(entity_registry, device.id) diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index da458820c81..17a1872f832 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -170,7 +170,7 @@ async def test_device_info( device = device_registry.async_get_device(identifiers={(DOMAIN, device_identifer)}) assert device - assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.config_entries == [TEST_CONFIG_ENTRY_ID] assert device.identifiers == {(DOMAIN, device_identifer)} assert device.manufacturer == HYPERION_MANUFACTURER_NAME assert device.model == HYPERION_MODEL_NAME diff --git a/tests/components/iaqualink/test_init.py b/tests/components/iaqualink/test_init.py index d450ced1fd7..8e157b8d1e3 100644 --- a/tests/components/iaqualink/test_init.py +++ b/tests/components/iaqualink/test_init.py @@ -346,7 +346,7 @@ async def test_entity_assumed_and_available( light = get_aqualink_device( system, name="aux_1", cls=IaquaLightSwitch, data={"state": "1"} ) - devices = {d.name: d for d in [light]} + devices = {light.name: light} system.get_devices = AsyncMock(return_value=devices) system.update = AsyncMock() diff --git a/tests/components/iaqualink/test_utils.py b/tests/components/iaqualink/test_utils.py index b9aba93523c..7a7b213f1a7 100644 --- a/tests/components/iaqualink/test_utils.py +++ b/tests/components/iaqualink/test_utils.py @@ -16,10 +16,9 @@ async def test_await_or_reraise(hass: HomeAssistant) -> None: await await_or_reraise(async_noop()) with pytest.raises(Exception) as exc_info: - async_ex = async_raises(Exception("Test exception")) - await await_or_reraise(async_ex()) + await await_or_reraise(async_raises(Exception("Test exception"))()) assert str(exc_info.value) == "Test exception" + async_ex = async_raises(AqualinkServiceException) with pytest.raises(HomeAssistantError): - async_ex = async_raises(AqualinkServiceException) await await_or_reraise(async_ex()) diff --git a/tests/components/ibeacon/test_config_flow.py b/tests/components/ibeacon/test_config_flow.py index 0833508d03f..f7a1aec7edb 100644 --- a/tests/components/ibeacon/test_config_flow.py +++ b/tests/components/ibeacon/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.ibeacon.const import CONF_ALLOW_NAMELESS_UUIDS, DOMAIN from homeassistant.core import HomeAssistant @@ -10,9 +12,8 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_setup_user_no_bluetooth( - hass: HomeAssistant, mock_bluetooth_adapters: None -) -> None: +@pytest.mark.usefixtures("mock_bluetooth_adapters") +async def test_setup_user_no_bluetooth(hass: HomeAssistant) -> None: """Test setting up via user interaction when bluetooth is not enabled.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -22,7 +23,8 @@ async def test_setup_user_no_bluetooth( assert result["reason"] == "bluetooth_not_available" -async def test_setup_user(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_setup_user(hass: HomeAssistant) -> None: """Test setting up via user interaction with bluetooth enabled.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -39,9 +41,8 @@ async def test_setup_user(hass: HomeAssistant, enable_bluetooth: None) -> None: assert result2["data"] == {} -async def test_setup_user_already_setup( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_setup_user_already_setup(hass: HomeAssistant) -> None: """Test setting up via user when already setup .""" MockConfigEntry(domain=DOMAIN).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -52,7 +53,8 @@ async def test_setup_user_already_setup( assert result["reason"] == "single_instance_allowed" -async def test_options_flow(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_options_flow(hass: HomeAssistant) -> None: """Test config flow options.""" config_entry = MockConfigEntry(domain=DOMAIN) config_entry.add_to_hass(hass) diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index 0880f745ec2..c9177362f35 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -40,7 +40,7 @@ from tests.components.bluetooth import ( @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/ibeacon/test_device_tracker.py b/tests/components/ibeacon/test_device_tracker.py index 481a1315325..dcc21b5bfc9 100644 --- a/tests/components/ibeacon/test_device_tracker.py +++ b/tests/components/ibeacon/test_device_tracker.py @@ -11,7 +11,9 @@ from homeassistant.components.bluetooth import ( async_ble_device_from_address, async_last_service_info, ) -from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS +from homeassistant.components.bluetooth.const import ( # pylint: disable=hass-component-root-import + UNAVAILABLE_TRACK_SECONDS, +) from homeassistant.components.ibeacon.const import ( DOMAIN, UNAVAILABLE_TIMEOUT, @@ -42,7 +44,7 @@ from tests.components.bluetooth import ( @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/ibeacon/test_init.py b/tests/components/ibeacon/test_init.py index 5a30417efe1..0604b818acd 100644 --- a/tests/components/ibeacon/test_init.py +++ b/tests/components/ibeacon/test_init.py @@ -15,7 +15,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/ibeacon/test_sensor.py b/tests/components/ibeacon/test_sensor.py index fb6322162d4..e2ddf1dd7bc 100644 --- a/tests/components/ibeacon/test_sensor.py +++ b/tests/components/ibeacon/test_sensor.py @@ -4,7 +4,9 @@ from datetime import timedelta import pytest -from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS +from homeassistant.components.bluetooth.const import ( # pylint: disable=hass-component-root-import + UNAVAILABLE_TRACK_SECONDS, +) from homeassistant.components.ibeacon.const import DOMAIN, UPDATE_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ( @@ -34,7 +36,7 @@ from tests.components.bluetooth import ( @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py index 8159039aff4..91f3f2de40e 100644 --- a/tests/components/idasen_desk/conftest.py +++ b/tests/components/idasen_desk/conftest.py @@ -5,21 +5,24 @@ from unittest import mock from unittest.mock import AsyncMock, MagicMock import pytest +from typing_extensions import Generator @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> Generator[None]: """Auto mock bluetooth.""" with mock.patch( "homeassistant.components.idasen_desk.bluetooth.async_ble_device_from_address" ): - yield MagicMock() + yield @pytest.fixture(autouse=False) def mock_desk_api(): """Set up idasen desk API fixture.""" - with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: + with mock.patch( + "homeassistant.components.idasen_desk.coordinator.Desk" + ) as desk_patched: mock_desk = MagicMock() def mock_init( diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index a861dc5f5e2..c27cdea58aa 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -305,4 +305,4 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: } assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 - desk_connect.assert_called_with(ANY, auto_reconnect=False) + desk_connect.assert_called_with(ANY, retry=False) diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py index 3c18d604549..0110fe7d820 100644 --- a/tests/components/idasen_desk/test_cover.py +++ b/tests/components/idasen_desk/test_cover.py @@ -1,7 +1,7 @@ """Test the IKEA Idasen Desk cover.""" from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from bleak.exc import BleakError import pytest @@ -39,6 +39,7 @@ async def test_cover_available( assert state.state == STATE_OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 60 + mock_desk_api.connect = AsyncMock() mock_desk_api.is_connected = False mock_desk_api.trigger_update_callback(None) diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py index 5b8258c8d33..60f1fb3e5e3 100644 --- a/tests/components/idasen_desk/test_init.py +++ b/tests/components/idasen_desk/test_init.py @@ -1,5 +1,6 @@ """Test the IKEA Idasen Desk init.""" +import asyncio from unittest import mock from unittest.mock import AsyncMock, MagicMock @@ -53,6 +54,77 @@ async def test_no_ble_device(hass: HomeAssistant, mock_desk_api: MagicMock) -> N assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_reconnect_on_bluetooth_callback( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test that a reconnect is made after the bluetooth callback is triggered.""" + with mock.patch( + "homeassistant.components.idasen_desk.bluetooth.async_register_callback" + ) as mock_register_callback: + await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + mock_desk_api.connect.assert_called_once() + mock_register_callback.assert_called_once() + + mock_desk_api.is_connected = False + _, register_callback_args, _ = mock_register_callback.mock_calls[0] + bt_callback = register_callback_args[1] + bt_callback(None, None) + await hass.async_block_till_done() + assert mock_desk_api.connect.call_count == 2 + + +async def test_duplicated_disconnect_is_no_op( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test that calling disconnect while disconnecting is a no-op.""" + await init_integration(hass) + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_disconnect"}, blocking=True + ) + await hass.async_block_till_done() + + async def mock_disconnect(): + await asyncio.sleep(0) + + mock_desk_api.disconnect.reset_mock() + mock_desk_api.disconnect.side_effect = mock_disconnect + + # Since the disconnect button was pressed but the desk indicates "connected", + # any update event will call disconnect() + mock_desk_api.is_connected = True + mock_desk_api.trigger_update_callback(None) + mock_desk_api.trigger_update_callback(None) + mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + +async def test_ensure_connection_state( + hass: HomeAssistant, mock_desk_api: MagicMock +) -> None: + """Test that the connection state is ensured.""" + await init_integration(hass) + + mock_desk_api.connect.reset_mock() + mock_desk_api.is_connected = False + mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + mock_desk_api.connect.assert_called_once() + + await hass.services.async_call( + "button", "press", {"entity_id": "button.test_disconnect"}, blocking=True + ) + await hass.async_block_till_done() + + mock_desk_api.disconnect.reset_mock() + mock_desk_api.is_connected = True + mock_desk_api.trigger_update_callback(None) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: """Test successful unload of entry.""" entry = await init_integration(hass) diff --git a/tests/components/idasen_desk/test_sensors.py b/tests/components/idasen_desk/test_sensors.py index f56a45104eb..a236555a506 100644 --- a/tests/components/idasen_desk/test_sensors.py +++ b/tests/components/idasen_desk/test_sensors.py @@ -2,16 +2,15 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.core import HomeAssistant from . import init_integration -async def test_height_sensor( - hass: HomeAssistant, - mock_desk_api: MagicMock, - entity_registry_enabled_by_default: None, -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_height_sensor(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: """Test height sensor.""" await init_integration(hass) diff --git a/tests/components/image/conftest.py b/tests/components/image/conftest.py index 35c9f0a86af..65bbf2e0c4f 100644 --- a/tests/components/image/conftest.py +++ b/tests/components/image/conftest.py @@ -1,8 +1,7 @@ """Test helpers for image.""" -from collections.abc import Generator - import pytest +from typing_extensions import Generator from homeassistant.components import image from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -125,7 +124,7 @@ class MockImagePlatform: @pytest.fixture(name="config_flow") -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" class MockFlow(ConfigFlow): @@ -147,14 +146,16 @@ async def mock_image_config_entry_fixture( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, image.DOMAIN) + await hass.config_entries.async_forward_entry_setups( + config_entry, [image.DOMAIN] + ) return True async def async_unload_entry_init( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload(config_entry, image.DOMAIN) + await hass.config_entries.async_unload_platforms(config_entry, [image.DOMAIN]) return True mock_integration( diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 62027552fb0..577d3fc47db 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -1,5 +1,7 @@ """The tests for the image_processing component.""" +from asyncio import AbstractEventLoop +from collections.abc import Callable from unittest.mock import PropertyMock, patch import pytest @@ -24,7 +26,11 @@ async def setup_homeassistant(hass: HomeAssistant): @pytest.fixture -def aiohttp_unused_port_factory(event_loop, unused_tcp_port_factory, socket_enabled): +def aiohttp_unused_port_factory( + event_loop: AbstractEventLoop, + unused_tcp_port_factory: Callable[[], int], + socket_enabled: None, +) -> Callable[[], int]: """Return aiohttp_unused_port and allow opening sockets.""" return unused_tcp_port_factory @@ -83,11 +89,11 @@ async def test_setup_component_with_service(hass: HomeAssistant) -> None: "homeassistant.components.demo.camera.Path.read_bytes", return_value=b"Test", ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_get_image_from_camera( mock_camera_read, hass: HomeAssistant, aiohttp_unused_port_factory, - enable_custom_integrations: None, ) -> None: """Grab an image from camera entity.""" await setup_image_processing(hass, aiohttp_unused_port_factory) @@ -106,11 +112,11 @@ async def test_get_image_from_camera( "homeassistant.components.image_processing.async_get_image", side_effect=HomeAssistantError(), ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_get_image_without_exists_camera( mock_image, hass: HomeAssistant, aiohttp_unused_port_factory, - enable_custom_integrations: None, ) -> None: """Try to get image without exists camera.""" await setup_image_processing(hass, aiohttp_unused_port_factory) @@ -182,10 +188,10 @@ async def test_face_event_call_no_confidence( assert event_data[0]["entity_id"] == "image_processing.demo_face" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_update_missing_camera( hass: HomeAssistant, aiohttp_unused_port_factory, - enable_custom_integrations: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test when entity does not set camera.""" diff --git a/tests/components/image_upload/test_init.py b/tests/components/image_upload/test_init.py index 1117befc7fd..d404f1f841e 100644 --- a/tests/components/image_upload/test_init.py +++ b/tests/components/image_upload/test_init.py @@ -7,7 +7,7 @@ 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.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -49,7 +49,14 @@ async def test_upload_image( tempdir = pathlib.Path(tempdir) item_folder: pathlib.Path = tempdir / item["id"] - assert (item_folder / "original").read_bytes() == TEST_IMAGE.read_bytes() + test_image_bytes = TEST_IMAGE.read_bytes() + assert (item_folder / "original").read_bytes() == test_image_bytes + + # fetch original image + res = await client.get(f"/api/image/serve/{item['id']}/original") + assert res.status == 200 + fetched_image_bytes = await res.read() + assert fetched_image_bytes == test_image_bytes # fetch non-existing image res = await client.get("/api/image/serve/non-existing/256x256") @@ -70,7 +77,7 @@ async def test_upload_image( msg = await ws_client.receive_json() assert msg["id"] == 6 - assert msg["type"] == ws_const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert msg["result"] == [item] @@ -81,7 +88,7 @@ async def test_upload_image( msg = await ws_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == ws_const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] # Ensure removed from disk diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index dfe5fa2040f..354c9fbe24e 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -1,16 +1,16 @@ """Fixtures for imap tests.""" -from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response import pytest +from typing_extensions import AsyncGenerator, Generator from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.imap.async_setup_entry", return_value=True @@ -62,7 +62,7 @@ async def mock_imap_protocol( imap_pending_idle: bool, imap_login_state: str, imap_select_state: str, -) -> AsyncGenerator[MagicMock, None]: +) -> AsyncGenerator[MagicMock]: """Mock the aioimaplib IMAP protocol handler.""" with patch( diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 677eea7a473..037960c9e5d 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -59,6 +59,11 @@ TEST_CONTENT_TEXT_PLAIN = ( b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" ) +TEST_CONTENT_TEXT_PLAIN_EMPTY = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: 7bit\r\n\r\n \r\n" +) + TEST_CONTENT_TEXT_BASE64 = ( b'Content-Type: text/plain; charset="utf-8"\r\n' b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5\r\n" @@ -108,6 +113,15 @@ TEST_CONTENT_MULTIPART = ( + b"\r\n--Mark=_100584970350292485166--\r\n" ) +TEST_CONTENT_MULTIPART_EMPTY_PLAIN = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_PLAIN_EMPTY + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + TEST_CONTENT_MULTIPART_BASE64 = ( b"\r\nThis is a multi-part message in MIME format.\r\n" b"\r\n--Mark=_100584970350292485166\r\n" @@ -155,6 +169,18 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN = ( ], ) +TEST_FETCH_RESPONSE_TEXT_PLAIN_EMPTY = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN_EMPTY)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN_EMPTY), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( "OK", [ @@ -249,6 +275,19 @@ TEST_FETCH_RESPONSE_MULTIPART = ( b"Fetch completed (0.0001 + 0.000 secs).", ], ) +TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_EMPTY_PLAIN)).encode( + "utf-8" + ) + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_EMPTY_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_MULTIPART_BASE64 = ( "OK", [ diff --git a/tests/components/imap/test_diagnostics.py b/tests/components/imap/test_diagnostics.py index 721e09352f2..23450104aed 100644 --- a/tests/components/imap/test_diagnostics.py +++ b/tests/components/imap/test_diagnostics.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock import pytest from homeassistant.components import imap -from homeassistant.components.sensor.const import SensorStateClass +from homeassistant.components.sensor import SensorStateClass from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index a8f51142d8d..40c3ce013e4 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components.imap import DOMAIN from homeassistant.components.imap.const import CONF_CHARSET from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder -from homeassistant.components.sensor.const import SensorStateClass +from homeassistant.components.sensor import SensorStateClass from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -29,6 +29,7 @@ from .const import ( TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_MULTIPART_BASE64, TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, + TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -76,7 +77,7 @@ async def test_entry_startup_and_unload( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert await config_entry.async_unload(hass) + assert await hass.config_entries.async_unload(config_entry.entry_id) @pytest.mark.parametrize( @@ -116,6 +117,7 @@ async def test_entry_startup_fails( (TEST_FETCH_RESPONSE_TEXT_OTHER, True), (TEST_FETCH_RESPONSE_HTML, True), (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, True), (TEST_FETCH_RESPONSE_MULTIPART_BASE64, True), (TEST_FETCH_RESPONSE_BINARY, True), ], @@ -129,6 +131,7 @@ async def test_entry_startup_fails( "other", "html", "multipart", + "multipart_empty_plain", "multipart_base64", "binary", ], @@ -449,7 +452,7 @@ async def test_handle_cleanup_exception( # Fail cleaning up mock_imap_protocol.close.side_effect = imap_close - assert await config_entry.async_unload(hass) + assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert "Error while cleaning up imap connection" in caplog.text diff --git a/tests/components/imgw_pib/__init__.py b/tests/components/imgw_pib/__init__.py index c684b596949..adea1c40925 100644 --- a/tests/components/imgw_pib/__init__.py +++ b/tests/components/imgw_pib/__init__.py @@ -1,9 +1,13 @@ """Tests for the IMGW-PIB integration.""" +from homeassistant.core import HomeAssistant + from tests.common import MockConfigEntry -async def init_integration(hass, config_entry: MockConfigEntry) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> MockConfigEntry: """Set up the IMGW-PIB integration in Home Assistant.""" config_entry.add_to_hass(hass) diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index b22b8b68661..1d278856b5b 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -1,11 +1,11 @@ """Common fixtures for the IMGW-PIB tests.""" -from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch from imgw_pib import HydrologicalData, SensorData import pytest +from typing_extensions import Generator from homeassistant.components.imgw_pib.const import DOMAIN @@ -27,7 +27,7 @@ HYDROLOGICAL_DATA = HydrologicalData( @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.imgw_pib.async_setup_entry", return_value=True @@ -36,7 +36,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_imgw_pib_client() -> Generator[AsyncMock, None, None]: +def mock_imgw_pib_client() -> Generator[AsyncMock]: """Mock a ImgwPib client.""" with ( patch( diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 0bce7c96d7c..2638e468d92 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,4 +1,108 @@ # serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-entry] + 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.river_name_station_name_flood_alarm_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flood alarm level', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flood_alarm_level', + 'unique_id': '123_flood_alarm_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_flood_alarm_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'distance', + 'friendly_name': 'River Name (Station Name) Flood alarm level', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_flood_alarm_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '630.0', + }) +# --- +# name: test_sensor[sensor.river_name_station_name_flood_warning_level-entry] + 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.river_name_station_name_flood_warning_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flood warning level', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flood_warning_level', + 'unique_id': '123_flood_warning_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_flood_warning_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'distance', + 'friendly_name': 'River Name (Station Name) Flood warning level', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_flood_warning_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '590.0', + }) +# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py index 17c80891b1e..e1b7cda7c88 100644 --- a/tests/components/imgw_pib/test_init.py +++ b/tests/components/imgw_pib/test_init.py @@ -1,6 +1,6 @@ """Test init of IMGW-PIB integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from imgw_pib import ApiError @@ -15,13 +15,14 @@ from tests.common import MockConfigEntry async def test_config_not_ready( hass: HomeAssistant, - mock_imgw_pib_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test for setup failure if the connection to the service fails.""" - mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") - - await init_integration(hass, mock_config_entry) + with patch( + "homeassistant.components.imgw_pib.ImgwPib.create", + side_effect=ApiError("API Error"), + ): + await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py index 2d17f7246fc..276c021fad5 100644 --- a/tests/components/imgw_pib/test_sensor.py +++ b/tests/components/imgw_pib/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from imgw_pib import ApiError +import pytest from syrupy import SnapshotAssertion from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL @@ -18,6 +19,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat ENTITY_ID = "sensor.river_name_station_name_water_level" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/improv_ble/conftest.py b/tests/components/improv_ble/conftest.py index ea548efeb15..3781be341c5 100644 --- a/tests/components/improv_ble/conftest.py +++ b/tests/components/improv_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/incomfort/__init__.py b/tests/components/incomfort/__init__.py new file mode 100644 index 00000000000..dd398f37a68 --- /dev/null +++ b/tests/components/incomfort/__init__.py @@ -0,0 +1 @@ +"""Tests for the Intergas InComfort integration.""" diff --git a/tests/components/incomfort/conftest.py b/tests/components/incomfort/conftest.py new file mode 100644 index 00000000000..64885e38b65 --- /dev/null +++ b/tests/components/incomfort/conftest.py @@ -0,0 +1,152 @@ +"""Fixtures for Intergas InComfort integration.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from incomfortclient import DisplayCode +import pytest +from typing_extensions import Generator + +from homeassistant.components.incomfort import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + "host": "192.168.1.12", + "username": "admin", + "password": "verysecret", +} + +MOCK_HEATER_STATUS = { + "display_code": DisplayCode(126), + "display_text": "standby", + "fault_code": None, + "is_burning": False, + "is_failed": False, + "is_pumping": False, + "is_tapping": False, + "heater_temp": 35.34, + "tap_temp": 30.21, + "pressure": 1.86, + "serial_no": "c0ffeec0ffee", + "nodenr": 249, + "rf_message_rssi": 30, + "rfstatus_cntr": 0, +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.incomfort.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_entry_data() -> dict[str, Any]: + """Mock config entry data for fixture.""" + return MOCK_CONFIG + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, mock_entry_data: dict[str, Any] +) -> ConfigEntry: + """Mock a config entry setup for incomfort integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=mock_entry_data) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_heater_status() -> dict[str, Any]: + """Mock heater status.""" + return dict(MOCK_HEATER_STATUS) + + +@pytest.fixture +def mock_room_status() -> dict[str, Any]: + """Mock room status.""" + return {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0} + + +@pytest.fixture +def mock_incomfort( + hass: HomeAssistant, + mock_heater_status: dict[str, Any], + mock_room_status: dict[str, Any], +) -> Generator[MagicMock, None]: + """Mock the InComfort gateway client.""" + + class MockRoom: + """Mocked InComfort room class.""" + + override: float + room_no: int + room_temp: float + setpoint: float + status: dict[str, Any] + set_override: MagicMock + + def __init__(self) -> None: + """Initialize mocked room.""" + self.room_no = 1 + self.status = mock_room_status + self.set_override = MagicMock() + + @property + def override(self) -> str: + return mock_room_status["override"] + + @property + def room_temp(self) -> float: + return mock_room_status["room_temp"] + + @property + def setpoint(self) -> float: + return mock_room_status["setpoint"] + + class MockHeater: + """Mocked InComfort heater class.""" + + serial_no: str + status: dict[str, Any] + rooms: list[MockRoom] + is_failed: bool + is_pumping: bool + display_code: int + display_text: str | None + fault_code: int | None + is_burning: bool + is_tapping: bool + heater_temp: float + tap_temp: float + pressure: float + serial_no: str + nodenr: int + rf_message_rssi: int + rfstatus_cntr: int + + def __init__(self) -> None: + """Initialize mocked heater.""" + self.serial_no = "c0ffeec0ffee" + + async def update(self) -> None: + self.status = mock_heater_status + for key, value in mock_heater_status.items(): + setattr(self, key, value) + self.rooms = [MockRoom()] + + with patch( + "homeassistant.components.incomfort.coordinator.InComfortGateway", MagicMock() + ) as patch_gateway: + patch_gateway().heaters = AsyncMock() + patch_gateway().heaters.return_value = [MockHeater()] + patch_gateway().mock_heater_status = mock_heater_status + patch_gateway().mock_room_status = mock_room_status + yield patch_gateway diff --git a/tests/components/incomfort/snapshots/test_binary_sensor.ambr b/tests/components/incomfort/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..565abcaa26f --- /dev/null +++ b/tests/components/incomfort/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1651 @@ +# serializer version: 1 +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_burner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_burner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_burner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Burner', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_burner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'c0ffeec0ffee_failed', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'fault_code': 'none', + 'friendly_name': 'Boiler Fault', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_hot_water_tap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water tap', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_hot_water_tap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Hot water tap', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_running-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_running_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_running_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_running_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_burning][binary_sensor.boiler_running_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_burner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_burner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_burner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Burner', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_burner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'c0ffeec0ffee_failed', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'fault_code': , + 'friendly_name': 'Boiler Fault', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_hot_water_tap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water tap', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_hot_water_tap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Hot water tap', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_running-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_running_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_running_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_running_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_failed][binary_sensor.boiler_running_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_burner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_burner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_burner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Burner', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_burner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'c0ffeec0ffee_failed', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'fault_code': 'none', + 'friendly_name': 'Boiler Fault', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_hot_water_tap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water tap', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_hot_water_tap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Hot water tap', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_running-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_running_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_running_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_running_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_pumping][binary_sensor.boiler_running_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_burner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_burner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_burner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Burner', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_burner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'c0ffeec0ffee_failed', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'fault_code': 'none', + 'friendly_name': 'Boiler Fault', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_hot_water_tap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water tap', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_hot_water_tap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Hot water tap', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_running-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_running_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_running_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_running_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_binary_sensors_alt[is_tapping][binary_sensor.boiler_running_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_burner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_burner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Burner', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_burner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Burner', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_burner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fault', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'c0ffeec0ffee_failed', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'fault_code': 'none', + 'friendly_name': 'Boiler Fault', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_hot_water_tap-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water tap', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_hot_water_tap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Hot water tap', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_hot_water_tap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_running-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_pumping', + 'unique_id': 'c0ffeec0ffee_is_pumping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_running-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_running_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_burning', + 'unique_id': 'c0ffeec0ffee_is_burning', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_running_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_running_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.boiler_running_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Running', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'is_tapping', + 'unique_id': 'c0ffeec0ffee_is_tapping', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[binary_sensor.boiler_running_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Boiler Running', + }), + 'context': , + 'entity_id': 'binary_sensor.boiler_running_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr new file mode 100644 index 00000000000..05b2d4878d0 --- /dev/null +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_setup_platform[climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 18.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/incomfort/snapshots/test_sensor.ambr b/tests/components/incomfort/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8c9ea60f455 --- /dev/null +++ b/tests/components/incomfort/snapshots/test_sensor.ambr @@ -0,0 +1,156 @@ +# serializer version: 1 +# name: test_setup_platform[sensor.boiler_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.boiler_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_cv_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_platform[sensor.boiler_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Boiler Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.86', + }) +# --- +# name: test_setup_platform[sensor.boiler_tap_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.boiler_tap_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tap temperature', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tap_temperature', + 'unique_id': 'c0ffeec0ffee_tap_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_platform[sensor.boiler_tap_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Boiler Tap temperature', + 'is_tapping': False, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_tap_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.21', + }) +# --- +# name: test_setup_platform[sensor.boiler_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.boiler_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_cv_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_setup_platform[sensor.boiler_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Boiler Temperature', + 'is_pumping': False, + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.boiler_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35.34', + }) +# --- diff --git a/tests/components/incomfort/snapshots/test_water_heater.ambr b/tests/components/incomfort/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..06b0d0c1e52 --- /dev/null +++ b/tests/components/incomfort/snapshots/test_water_heater.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_setup_platform[water_heater.boiler-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_temp': 80.0, + 'min_temp': 30.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'water_heater', + 'entity_category': None, + 'entity_id': 'water_heater.boiler', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boiler', + 'unique_id': 'c0ffeec0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[water_heater.boiler-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 35.3, + 'display_code': , + 'display_text': 'standby', + 'friendly_name': 'Boiler', + 'is_burning': False, + 'max_temp': 80.0, + 'min_temp': 30.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'water_heater.boiler', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standby', + }) +# --- diff --git a/tests/components/incomfort/test_binary_sensor.py b/tests/components/incomfort/test_binary_sensor.py new file mode 100644 index 00000000000..c724cf4b7b2 --- /dev/null +++ b/tests/components/incomfort/test_binary_sensor.py @@ -0,0 +1,57 @@ +"""Binary sensor tests for Intergas InComfort integration.""" + +from unittest.mock import MagicMock, patch + +from incomfortclient import FaultCode +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MOCK_HEATER_STATUS + +from tests.common import snapshot_platform + + +@patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_setup_platform( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort entities are set up correctly.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_heater_status", + [ + MOCK_HEATER_STATUS + | { + "is_failed": True, + "display_code": None, + "fault_code": FaultCode.CV_TEMPERATURE_TOO_HIGH_E1, + }, + MOCK_HEATER_STATUS | {"is_pumping": True}, + MOCK_HEATER_STATUS | {"is_burning": True}, + MOCK_HEATER_STATUS | {"is_tapping": True}, + ], + ids=["is_failed", "is_pumping", "is_burning", "is_tapping"], +) +@patch("homeassistant.components.incomfort.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_setup_binary_sensors_alt( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort heater .""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py new file mode 100644 index 00000000000..d5f7397aaaf --- /dev/null +++ b/tests/components/incomfort/test_climate.py @@ -0,0 +1,25 @@ +"""Climate sensor tests for Intergas InComfort integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import snapshot_platform + + +@patch("homeassistant.components.incomfort.PLATFORMS", [Platform.CLIMATE]) +async def test_setup_platform( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort entities are set up correctly.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py new file mode 100644 index 00000000000..7a942dab817 --- /dev/null +++ b/tests/components/incomfort/test_config_flow.py @@ -0,0 +1,159 @@ +"""Tests for the Intergas InComfort config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from aiohttp import ClientResponseError +from incomfortclient import IncomfortError, InvalidHeaterList +import pytest + +from homeassistant.components.incomfort import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +) -> None: + """Test we get the full form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_incomfort: MagicMock +) -> None: + """Test we van import from YAML.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Intergas InComfort/Intouch Lan2RF gateway" + assert result["data"] == MOCK_CONFIG + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exc", "abort_reason"), + [ + (IncomfortError(ClientResponseError(None, None, status=401)), "auth_error"), + (IncomfortError(ClientResponseError(None, None, status=404)), "not_found"), + (IncomfortError(ClientResponseError(None, None, status=500)), "unknown"), + (IncomfortError, "unknown"), + (InvalidHeaterList, "no_heaters"), + (ValueError, "unknown"), + (TimeoutError, "timeout_error"), + ], +) +async def test_import_fails( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_incomfort: MagicMock, + exc: Exception, + abort_reason: str, +) -> None: + """Test YAML import fails.""" + mock_incomfort().heaters.side_effect = exc + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_entry_already_configured(hass: HomeAssistant) -> None: + """Test aborting if the entry is already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCK_CONFIG[CONF_HOST], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exc", "error", "base"), + [ + ( + IncomfortError(ClientResponseError(None, None, status=401)), + "auth_error", + CONF_PASSWORD, + ), + ( + IncomfortError(ClientResponseError(None, None, status=404)), + "not_found", + "base", + ), + ( + IncomfortError(ClientResponseError(None, None, status=500)), + "unknown", + "base", + ), + (IncomfortError, "unknown", "base"), + (ValueError, "unknown", "base"), + (TimeoutError, "timeout_error", "base"), + (InvalidHeaterList, "no_heaters", "base"), + ], +) +async def test_form_validation( + hass: HomeAssistant, + mock_incomfort: MagicMock, + exc: Exception, + error: str, + base: str, +) -> None: + """Test form validation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Simulate an issue + mock_incomfort().heaters.side_effect = exc + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + base: error, + } + + # Fix the issue and retry + mock_incomfort().heaters.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONFIG + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert "errors" not in result diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py new file mode 100644 index 00000000000..0390a47a616 --- /dev/null +++ b/tests/components/incomfort/test_init.py @@ -0,0 +1,93 @@ +"""Tests for Intergas InComfort integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from aiohttp import ClientResponseError +from freezegun.api import FrozenDateTimeFactory +from incomfortclient import IncomfortError +import pytest + +from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import async_fire_time_changed + + +async def test_setup_platforms( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort integration is set up correctly.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_coordinator_updates( + hass: HomeAssistant, + mock_incomfort: MagicMock, + freezer: FrozenDateTimeFactory, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort coordinator is updating.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("climate.thermostat_1") + assert state is not None + assert state.attributes["current_temperature"] == 21.4 + mock_incomfort().mock_room_status["room_temp"] = 20.91 + + state = hass.states.get("sensor.boiler_pressure") + assert state is not None + assert state.state == "1.86" + mock_incomfort().mock_heater_status["pressure"] = 1.84 + + freezer.tick(timedelta(seconds=UPDATE_INTERVAL + 5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("climate.thermostat_1") + assert state is not None + assert state.attributes["current_temperature"] == 20.9 + + state = hass.states.get("sensor.boiler_pressure") + assert state is not None + assert state.state == "1.84" + + +@pytest.mark.parametrize( + "exc", + [ + IncomfortError(ClientResponseError(None, None, status=401)), + IncomfortError(ClientResponseError(None, None, status=500)), + IncomfortError(ValueError("some_error")), + TimeoutError, + ], +) +async def test_coordinator_update_fails( + hass: HomeAssistant, + mock_incomfort: MagicMock, + freezer: FrozenDateTimeFactory, + exc: Exception, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort coordinator update fails.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + state = hass.states.get("sensor.boiler_pressure") + assert state is not None + assert state.state == "1.86" + + with patch.object( + mock_incomfort().heaters.return_value[0], "update", side_effect=exc + ): + freezer.tick(timedelta(seconds=UPDATE_INTERVAL + 5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.boiler_pressure") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/incomfort/test_sensor.py b/tests/components/incomfort/test_sensor.py new file mode 100644 index 00000000000..d01fd9b403e --- /dev/null +++ b/tests/components/incomfort/test_sensor.py @@ -0,0 +1,25 @@ +"""Sensor tests for Intergas InComfort integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import snapshot_platform + + +@patch("homeassistant.components.incomfort.PLATFORMS", [Platform.SENSOR]) +async def test_setup_platform( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort entities are set up correctly.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/incomfort/test_water_heater.py b/tests/components/incomfort/test_water_heater.py new file mode 100644 index 00000000000..5b7aebc50a8 --- /dev/null +++ b/tests/components/incomfort/test_water_heater.py @@ -0,0 +1,25 @@ +"""Water heater tests for Intergas InComfort integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import snapshot_platform + + +@patch("homeassistant.components.incomfort.PLATFORMS", [Platform.WATER_HEATER]) +async def test_setup_platform( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: ConfigEntry, +) -> None: + """Test the incomfort entities are set up correctly.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 9d672b7ceb0..aba153cf8a8 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -7,6 +7,7 @@ import logging from unittest.mock import ANY, MagicMock, Mock, call, patch import pytest +from typing_extensions import Generator from homeassistant.components import influxdb from homeassistant.components.influxdb.const import DEFAULT_BUCKET @@ -51,7 +52,9 @@ def mock_batch_timeout(hass, monkeypatch): @pytest.fixture(name="mock_client") -def mock_client_fixture(request): +def mock_client_fixture( + request: pytest.FixtureRequest, +) -> Generator[MagicMock]: """Patch the InfluxDBClient object with mock for version under test.""" if request.param == influxdb.API_VERSION_2: client_target = f"{INFLUX_CLIENT_PATH}V2" @@ -63,7 +66,7 @@ def mock_client_fixture(request): @pytest.fixture(name="get_mock_call") -def get_mock_call_fixture(request): +def get_mock_call_fixture(request: pytest.FixtureRequest): """Get version specific lambda to make write API call mock.""" def v2_call(body, precision): diff --git a/tests/components/influxdb/test_sensor.py b/tests/components/influxdb/test_sensor.py index d3464c7e417..48cae2a3ae6 100644 --- a/tests/components/influxdb/test_sensor.py +++ b/tests/components/influxdb/test_sensor.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch from influxdb.exceptions import InfluxDBClientError, InfluxDBServerError from influxdb_client.rest import ApiException import pytest +from typing_extensions import Generator from voluptuous import Invalid from homeassistant.components import sensor @@ -79,7 +80,9 @@ class Table: @pytest.fixture(name="mock_client") -def mock_client_fixture(request): +def mock_client_fixture( + request: pytest.FixtureRequest, +) -> Generator[MagicMock]: """Patch the InfluxDBClient object with mock for version under test.""" if request.param == API_VERSION_2: client_target = f"{INFLUXDB_CLIENT_PATH}V2" @@ -108,7 +111,7 @@ def _make_v1_resultset(*args): def _make_v1_databases_resultset(): """Create a mock V1 'show databases' resultset.""" - for name in [DEFAULT_DATABASE, "db2"]: + for name in (DEFAULT_DATABASE, "db2"): yield {"name": name} @@ -126,7 +129,7 @@ def _make_v2_resultset(*args): def _make_v2_buckets_resultset(): """Create a mock V2 'buckets()' resultset.""" - records = [Record({"name": name}) for name in [DEFAULT_BUCKET, "bucket2"]] + records = [Record({"name": name}) for name in (DEFAULT_BUCKET, "bucket2")] return [Table(records)] diff --git a/tests/components/inkbird/conftest.py b/tests/components/inkbird/conftest.py index 3450cb933fe..cb68332dd83 100644 --- a/tests/components/inkbird/conftest.py +++ b/tests/components/inkbird/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index 2a616691e62..b2e99836477 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -1,6 +1,7 @@ """The tests for the input_boolean component.""" import logging +from typing import Any from unittest.mock import patch import pytest @@ -30,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index 8f041d6c848..8e2f078a5e4 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -4,8 +4,9 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components.input_boolean import DOMAIN -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant @@ -16,9 +17,8 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test attributes to be excluded.""" now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index 568d0076318..e59d0543751 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -1,6 +1,7 @@ """The tests for the input_test component.""" import logging +from typing import Any from unittest.mock import patch import pytest @@ -27,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index 74023b73342..19ff8427dac 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -4,8 +4,9 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components.input_button import DOMAIN -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant @@ -16,9 +17,8 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test attributes to be excluded.""" now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 9d218e6d6ec..fdbb9a7803f 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -1,6 +1,7 @@ """Tests for the Input slider component.""" import datetime +from typing import Any from unittest.mock import patch import pytest @@ -45,7 +46,7 @@ INITIAL_DATETIME = f"{INITIAL_DATE} {INITIAL_TIME}" @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): @@ -688,7 +689,7 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) - async def test_timestamp(hass: HomeAssistant) -> None: """Test timestamp.""" - hass.config.set_time_zone("America/Los_Angeles") + await hass.config.async_set_time_zone("America/Los_Angeles") assert await async_setup_component( hass, diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index d32e8ec3471..dafe1d5301b 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -4,8 +4,9 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components.input_datetime import CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant @@ -16,9 +17,8 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test attributes to be excluded.""" now = dt_util.utcnow() assert await async_setup_component( diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 62b95fe16b3..73e41f347ce 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -1,5 +1,6 @@ """The tests for the Input number component.""" +from typing import Any from unittest.mock import patch import pytest @@ -29,7 +30,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index 78f709511de..986f53e9311 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -4,6 +4,8 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components.input_number import ( ATTR_MAX, ATTR_MIN, @@ -11,7 +13,6 @@ from homeassistant.components.input_number import ( ATTR_STEP, DOMAIN, ) -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant @@ -22,9 +23,8 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test attributes to be excluded.""" now = dt_util.utcnow() assert await async_setup_component( diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 431f8b7d078..153d8ed848d 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -1,5 +1,6 @@ """The tests for the Input select component.""" +from typing import Any from unittest.mock import patch import pytest @@ -36,7 +37,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None, minor_version=STORAGE_VERSION_MINOR): diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index b12fe57d431..107608b7774 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -4,8 +4,9 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components.input_select import ATTR_OPTIONS, DOMAIN -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant @@ -16,9 +17,8 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test attributes to be excluded.""" now = dt_util.utcnow() assert await async_setup_component( diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index d98ee4f7668..3cae98b6dfe 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -1,5 +1,6 @@ """The tests for the Input text component.""" +from typing import Any from unittest.mock import patch import pytest @@ -36,7 +37,7 @@ TEST_VAL_MAX = 22 @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index a81160b32c7..21309f0a8ab 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -4,6 +4,8 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components.input_text import ( ATTR_MAX, ATTR_MIN, @@ -12,7 +14,6 @@ from homeassistant.components.input_text import ( DOMAIN, MODE_TEXT, ) -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant @@ -23,9 +24,8 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test attributes to be excluded.""" now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py index dea9fb4e34f..6b5f5cf5e09 100644 --- a/tests/components/insteon/mock_devices.py +++ b/tests/components/insteon/mock_devices.py @@ -85,7 +85,7 @@ class MockDevices: ) for device in [ - self._devices[addr] for addr in [addr1, addr2, addr3, addr4, addr5] + self._devices[addr] for addr in (addr1, addr2, addr3, addr4, addr5) ]: device.async_read_config = AsyncMock() device.aldb.async_write = AsyncMock() @@ -105,7 +105,7 @@ class MockDevices: ) for device in [ - self._devices[addr] for addr in [addr2, addr3, addr4, addr5] + self._devices[addr] for addr in (addr2, addr3, addr4, addr5) ]: device.async_status = AsyncMock() self._devices[addr1].async_status = AsyncMock(side_effect=AttributeError) diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py index c919e7a9d22..4376628d9a4 100644 --- a/tests/components/insteon/test_api_aldb.py +++ b/tests/components/insteon/test_api_aldb.py @@ -303,7 +303,7 @@ async def test_bad_address( record = _aldb_dict(0) ws_id = 0 - for call in ["get", "write", "load", "reset", "add_default_links", "notify"]: + for call in ("get", "write", "load", "reset", "add_default_links", "notify"): ws_id += 1 await ws_client.send_json( { @@ -316,7 +316,7 @@ async def test_bad_address( assert not msg["success"] assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND - for call in ["change", "create"]: + for call in ("change", "create"): ws_id += 1 await ws_client.send_json( { diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 74ef759006c..aee35cb8994 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -491,7 +491,7 @@ async def test_bad_address( ) ws_id = 0 - for call in ["get", "write", "load", "reset"]: + for call in ("get", "write", "load", "reset"): ws_id += 1 params = { ID: ws_id, diff --git a/tests/components/integration/snapshots/test_sensor.ambr b/tests/components/integration/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5747e6489b9 --- /dev/null +++ b/tests/components/integration/snapshots/test_sensor.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_initial_state[BTU/h-power-h] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'integration', + 'icon': 'mdi:chart-histogram', + 'source': 'sensor.source', + 'state_class': , + 'unit_of_measurement': 'BTU', + }), + 'context': , + 'entity_id': 'sensor.integration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_initial_state[ft\xb3/min-volume_flow_rate-min] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'integration', + 'icon': 'mdi:chart-histogram', + 'source': 'sensor.source', + 'state_class': , + 'unit_of_measurement': 'ft³', + }), + 'context': , + 'entity_id': 'sensor.integration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_initial_state[kW-None-h] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'integration', + 'icon': 'mdi:chart-histogram', + 'source': 'sensor.source', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.integration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_initial_state[kW-power-h] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'integration', + 'source': 'sensor.source', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.integration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index 179984f20f2..f8387d85174 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant import config_entries from homeassistant.components.integration.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import selector from tests.common import MockConfigEntry @@ -35,6 +36,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1, "source": input_sensor_entity_id, "unit_time": "min", + "max_sub_interval": {"seconds": 0}, }, ) await hass.async_block_till_done() @@ -48,6 +50,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1.0, "source": "sensor.input", "unit_time": "min", + "max_sub_interval": {"seconds": 0}, } assert len(mock_setup_entry.mock_calls) == 1 @@ -59,6 +62,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1.0, "source": "sensor.input", "unit_time": "min", + "max_sub_interval": {"seconds": 0}, } assert config_entry.title == "My integration" @@ -71,7 +75,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") @pytest.mark.parametrize("platform", ["sensor"]) @@ -88,6 +92,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "source": "sensor.input", "unit_prefix": "k", "unit_time": "min", + "max_sub_interval": {"minutes": 1}, }, title="My integration", ) @@ -95,35 +100,51 @@ async def test_options(hass: HomeAssistant, platform) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + hass.states.async_set("sensor.input", 10, {"unit_of_measurement": "dog"}) + hass.states.async_set("sensor.valid", 10, {"unit_of_measurement": "dog"}) + hass.states.async_set("sensor.invalid", 10, {"unit_of_measurement": "cat"}) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "round") == 1.0 + source = schema["source"] + assert isinstance(source, selector.EntitySelector) + assert source.config["include_entities"] == [ + "sensor.input", + "sensor.valid", + ] + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ + "method": "right", "round": 2.0, + "source": "sensor.input", + "max_sub_interval": {"minutes": 1}, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "method": "left", + "method": "right", "name": "My integration", "round": 2.0, "source": "sensor.input", "unit_prefix": "k", "unit_time": "min", + "max_sub_interval": {"minutes": 1}, } assert config_entry.data == {} assert config_entry.options == { - "method": "left", + "method": "right", "name": "My integration", "round": 2.0, "source": "sensor.input", "unit_prefix": "k", "unit_time": "min", + "max_sub_interval": {"minutes": 1}, } assert config_entry.title == "My integration" @@ -131,7 +152,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: await hass.async_block_till_done() # Check the entity was updated, no new entity was created - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 4 # Check the state of the entity has changed as expected hass.states.async_set("sensor.input", 10, {"unit_of_measurement": "dog"}) diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index f92a4a67585..9fee54f4500 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -4,7 +4,7 @@ import pytest from homeassistant.components.integration.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -30,6 +30,7 @@ async def test_setup_and_remove_config_entry( "source": "sensor.input", "unit_prefix": "k", "unit_time": "min", + "max_sub_interval": {"minutes": 1}, }, title="My integration", ) @@ -60,3 +61,151 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(integration_entity_id) is None assert entity_registry.async_get(integration_entity_id) is None + + +@pytest.mark.parametrize("platform", ["sensor"]) +async def test_entry_changed(hass: HomeAssistant, platform) -> None: + """Test reconfiguring.""" + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + def _create_mock_entity(domain: str, name: str) -> er.RegistryEntry: + config_entry = MockConfigEntry( + data={}, + domain="test", + title=f"{name}", + ) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + identifiers={("test", name)}, config_entry_id=config_entry.entry_id + ) + return entity_registry.async_get_or_create( + domain, "test", name, suggested_object_id=name, device_id=device_entry.id + ) + + def _get_device_config_entries(entry: er.RegistryEntry) -> set[str]: + assert entry.device_id + device = device_registry.async_get(entry.device_id) + assert device + return device.config_entries + + # Set up entities, with backing devices and config entries + input_entry = _create_mock_entity("sensor", "input") + valid_entry = _create_mock_entity("sensor", "valid") + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "left", + "name": "My integration", + "source": "sensor.input", + "unit_time": "min", + }, + title="My integration", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.entry_id in _get_device_config_entries(input_entry) + assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + + hass.config_entries.async_update_entry( + config_entry, options={**config_entry.options, "source": "sensor.valid"} + ) + await hass.async_block_till_done() + + # Check that the config entry association has updated + assert config_entry.entry_id not in _get_device_config_entries(input_entry) + assert config_entry.entry_id in _get_device_config_entries(valid_entry) + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for source entity device for Integration.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + # Configure the configuration entry for Integration + integration_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "Integration", + "round": 1.0, + "source": "sensor.test_source", + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="Integration", + ) + integration_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the integration sensor + integration_entity = entity_registry.async_get("sensor.integration") + assert integration_entity is not None + assert integration_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to Integration config entry + device_registry.async_get_or_create( + config_entry_id=integration_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=integration_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, two devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + integration_config_entry.entry_id + ) + assert len(devices_before_reload) == 3 + + # Config entry reload + await hass.config_entries.async_reload(integration_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the integration sensor after reload + integration_entity = entity_registry.async_get("sensor.integration") + assert integration_entity is not None + assert integration_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + integration_config_entry.entry_id + ) + assert len(devices_after_reload) == 1 diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 555cb44caf5..243504cb3e0 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -1,13 +1,16 @@ """The tests for the integration sensor platform.""" from datetime import timedelta +from typing import Any from freezegun import freeze_time import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.integration.const import DOMAIN from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -16,18 +19,71 @@ from homeassistant.const import ( UnitOfInformation, UnitOfPower, UnitOfTime, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + condition, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, - mock_restore_cache, + async_fire_time_changed, mock_restore_cache_with_extra_data, ) +DEFAULT_MAX_SUB_INTERVAL = {"minutes": 1} + + +@pytest.mark.parametrize( + ("unit_of_measurement", "device_class", "unit_time"), + [ + (UnitOfPower.KILO_WATT, SensorDeviceClass.POWER, "h"), + (UnitOfPower.KILO_WATT, None, "h"), + (UnitOfPower.BTU_PER_HOUR, SensorDeviceClass.POWER, "h"), + ( + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + SensorDeviceClass.VOLUME_FLOW_RATE, + "min", + ), + ], +) +async def test_initial_state( + hass: HomeAssistant, + unit_of_measurement: str, + device_class: SensorDeviceClass, + unit_time: str, + snapshot: SnapshotAssertion, +) -> None: + """Test integration sensor state.""" + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.source", + "round": 2, + "method": "left", + "unit_time": unit_time, + } + } + + assert await async_setup_component(hass, "sensor", config) + hass.states.async_set( + "sensor.source", + "1", + { + ATTR_DEVICE_CLASS: device_class, + ATTR_UNIT_OF_MEASUREMENT: unit_of_measurement, + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.integration") == snapshot + @pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) async def test_state(hass: HomeAssistant, method) -> None: @@ -42,13 +98,23 @@ async def test_state(hass: HomeAssistant, method) -> None: } } + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + assert state.attributes.get("state_class") is SensorStateClass.TOTAL + assert "device_class" not in state.attributes + now = dt_util.utcnow() with freeze_time(now): - assert await async_setup_component(hass, "sensor", config) - entity_id = config["sensor"]["source"] hass.states.async_set( - entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} + entity_id, + 1, + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, + }, ) await hass.async_block_till_done() @@ -138,42 +204,6 @@ async def test_state(hass: HomeAssistant, method) -> None: async def test_restore_state(hass: HomeAssistant) -> None: - """Test integration sensor state is restored correctly.""" - mock_restore_cache( - hass, - ( - State( - "sensor.integration", - "100.0", - { - "device_class": SensorDeviceClass.ENERGY, - "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, - }, - ), - ), - ) - - config = { - "sensor": { - "platform": "integration", - "name": "integration", - "source": "sensor.power", - "round": 2, - } - } - - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.integration") - assert state - assert state.state == "100.00" - assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR - assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY - assert state.attributes.get("last_good_state") is None - - -async def test_restore_unavailable_state(hass: HomeAssistant) -> None: """Test integration sensor state is restored correctly.""" mock_restore_cache_with_extra_data( hass, @@ -229,9 +259,7 @@ async def test_restore_unavailable_state(hass: HomeAssistant) -> None: }, ], ) -async def test_restore_unavailable_state_failed( - hass: HomeAssistant, extra_attributes -) -> None: +async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> None: """Test integration sensor state is restored correctly.""" mock_restore_cache_with_extra_data( hass, @@ -263,42 +291,7 @@ async def test_restore_unavailable_state_failed( state = hass.states.get("sensor.integration") assert state - assert state.state == STATE_UNAVAILABLE - - -async def test_restore_state_failed(hass: HomeAssistant) -> None: - """Test integration sensor state is restored correctly.""" - mock_restore_cache( - hass, - ( - State( - "sensor.integration", - "INVALID", - { - "last_reset": "2019-10-06T21:00:00.000000", - }, - ), - ), - ) - - config = { - "sensor": { - "platform": "integration", - "name": "integration", - "source": "sensor.power", - } - } - - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.integration") - assert state - assert state.state == "unknown" - assert state.attributes.get("unit_of_measurement") is None - assert state.attributes.get("state_class") is SensorStateClass.TOTAL - - assert "device_class" not in state.attributes + assert state.state == STATE_UNKNOWN async def test_trapezoidal(hass: HomeAssistant) -> None: @@ -321,7 +314,7 @@ async def test_trapezoidal(hass: HomeAssistant) -> None: start_time = dt_util.utcnow() with freeze_time(start_time) as freezer: # Testing a power sensor with non-monotonic intervals and values - for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: + for time, value in ((20, 10), (30, 30), (40, 5), (50, 0)): freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, @@ -360,7 +353,7 @@ async def test_left(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Testing a power sensor with non-monotonic intervals and values - for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: + for time, value in ((20, 10), (30, 30), (40, 5), (50, 0)): now = dt_util.utcnow() + timedelta(minutes=time) with freeze_time(now): hass.states.async_set( @@ -400,7 +393,7 @@ async def test_right(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Testing a power sensor with non-monotonic intervals and values - for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: + for time, value in ((20, 10), (30, 30), (40, 5), (50, 0)): now = dt_util.utcnow() + timedelta(minutes=time) with freeze_time(now): hass.states.async_set( @@ -745,3 +738,148 @@ async def test_device_id( integration_entity = entity_registry.async_get("sensor.integration") assert integration_entity is not None assert integration_entity.device_id == source_entity.device_id + + +def _integral_sensor_config(max_sub_interval: dict[str, int] | None) -> dict[str, Any]: + sensor = { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "method": "right", + } + if max_sub_interval is not None: + sensor["max_sub_interval"] = max_sub_interval + return {"sensor": sensor} + + +async def _setup_integral_sensor( + hass: HomeAssistant, max_sub_interval: dict[str, int] | None +) -> None: + await async_setup_component( + hass, "sensor", _integral_sensor_config(max_sub_interval=max_sub_interval) + ) + await hass.async_block_till_done() + + +async def _update_source_sensor(hass: HomeAssistant, value: int | str) -> None: + hass.states.async_set( + _integral_sensor_config(max_sub_interval=DEFAULT_MAX_SUB_INTERVAL)["sensor"][ + "source" + ], + value, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + force_update=True, + ) + await hass.async_block_till_done() + + +async def test_on_valid_source_expect_update_on_time( + hass: HomeAssistant, +) -> None: + """Test whether time based integration updates the integral on a valid source.""" + start_time = dt_util.utcnow() + + with freeze_time(start_time) as freezer: + await _setup_integral_sensor(hass, max_sub_interval=DEFAULT_MAX_SUB_INTERVAL) + await _update_source_sensor(hass, 100) + state_before_max_sub_interval_exceeded = hass.states.get("sensor.integration") + + freezer.tick(61) + async_fire_time_changed(hass, dt_util.now()) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert ( + condition.async_numeric_state(hass, state_before_max_sub_interval_exceeded) + is False + ) + assert state_before_max_sub_interval_exceeded.state != state.state + assert condition.async_numeric_state(hass, state) is True + assert float(state.state) > 1.69 # approximately 100 * 61 / 3600 + assert float(state.state) < 1.8 + + +async def test_on_unvailable_source_expect_no_update_on_time( + hass: HomeAssistant, +) -> None: + """Test whether time based integration handles unavailability of the source properly.""" + + start_time = dt_util.utcnow() + with freeze_time(start_time) as freezer: + await _setup_integral_sensor(hass, max_sub_interval=DEFAULT_MAX_SUB_INTERVAL) + await _update_source_sensor(hass, 100) + freezer.tick(61) + async_fire_time_changed(hass, dt_util.now()) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert condition.async_numeric_state(hass, state) is True + + await _update_source_sensor(hass, STATE_UNAVAILABLE) + await hass.async_block_till_done() + + freezer.tick(61) + async_fire_time_changed(hass, dt_util.now()) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert condition.state(hass, state, STATE_UNAVAILABLE) is True + + +async def test_on_statechanges_source_expect_no_update_on_time( + hass: HomeAssistant, +) -> None: + """Test whether state changes cancel time based integration.""" + + start_time = dt_util.utcnow() + with freeze_time(start_time) as freezer: + await _setup_integral_sensor(hass, max_sub_interval=DEFAULT_MAX_SUB_INTERVAL) + await _update_source_sensor(hass, 100) + + freezer.tick(30) + await hass.async_block_till_done() + await _update_source_sensor(hass, 101) + + state_after_30s = hass.states.get("sensor.integration") + assert condition.async_numeric_state(hass, state_after_30s) is True + + freezer.tick(35) + async_fire_time_changed(hass, dt_util.now()) + await hass.async_block_till_done() + state_after_65s = hass.states.get("sensor.integration") + assert (dt_util.now() - start_time).total_seconds() > 60 + # No state change because the timer was cancelled because of an update after 30s + assert state_after_65s == state_after_30s + + freezer.tick(35) + async_fire_time_changed(hass, dt_util.now()) + await hass.async_block_till_done() + state_after_105s = hass.states.get("sensor.integration") + # Update based on time + assert float(state_after_105s.state) > float(state_after_65s.state) + + +async def test_on_no_max_sub_interval_expect_no_timebased_updates( + hass: HomeAssistant, +) -> None: + """Test whether integratal is not updated by time when max_sub_interval is not configured.""" + + start_time = dt_util.utcnow() + with freeze_time(start_time) as freezer: + await _setup_integral_sensor(hass, max_sub_interval=None) + await _update_source_sensor(hass, 100) + await hass.async_block_till_done() + await _update_source_sensor(hass, 101) + await hass.async_block_till_done() + + state_after_last_state_change = hass.states.get("sensor.integration") + + assert ( + condition.async_numeric_state(hass, state_after_last_state_change) is True + ) + + freezer.tick(100) + async_fire_time_changed(hass, dt_util.now()) + await hass.async_block_till_done() + state_after_100s = hass.states.get("sensor.integration") + assert state_after_100s == state_after_last_state_change diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index fa7a48ef9ac..1aae4fb6dd6 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -1,14 +1,14 @@ """Fixtures for IntelliFire integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp.client_reqrep import ConnectionKey import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.intellifire.async_setup_entry", return_value=True @@ -17,7 +17,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_fireplace_finder_none() -> Generator[None, MagicMock, None]: +def mock_fireplace_finder_none() -> Generator[MagicMock]: """Mock fireplace finder.""" mock_found_fireplaces = Mock() mock_found_fireplaces.ips = [] @@ -28,7 +28,7 @@ def mock_fireplace_finder_none() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_fireplace_finder_single() -> Generator[None, MagicMock, None]: +def mock_fireplace_finder_single() -> Generator[MagicMock]: """Mock fireplace finder.""" mock_found_fireplaces = Mock() mock_found_fireplaces.ips = ["192.168.1.69"] @@ -39,7 +39,7 @@ def mock_fireplace_finder_single() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]: +def mock_intellifire_config_flow() -> Generator[MagicMock]: """Return a mocked IntelliFire client.""" data_mock = Mock() data_mock.serial = "12345" diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 77a6a368c01..7288c4855af 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -29,15 +29,16 @@ async def test_http_handle_intent( intent_type = "OrderBeer" - async def async_handle(self, intent): + async def async_handle(self, intent_obj): """Handle the intent.""" - assert intent.context.user_id == hass_admin_user.id - response = intent.create_response() + assert intent_obj.context.user_id == hass_admin_user.id + response = intent_obj.create_response() response.async_set_speech( - "I've ordered a {}!".format(intent.slots["type"]["value"]) + "I've ordered a {}!".format(intent_obj.slots["type"]["value"]) ) response.async_set_card( - "Beer ordered", "You chose a {}.".format(intent.slots["type"]["value"]) + "Beer ordered", + "You chose a {}.".format(intent_obj.slots["type"]["value"]), ) return response @@ -90,7 +91,7 @@ async def test_cover_intents_loading(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert response.speech["plain"]["speech"] == "Opened garage door" + assert response.speech["plain"]["speech"] == "Opening garage door" assert len(calls) == 1 call = calls[0] assert call.domain == "cover" @@ -236,7 +237,12 @@ async def test_turn_on_all(hass: HomeAssistant) -> None: hass.states.async_set("light.test_light_2", "off") calls = async_mock_service(hass, "light", SERVICE_TURN_ON) - await intent.async_handle(hass, "test", "HassTurnOn", {"name": {"value": "all"}}) + await intent.async_handle( + hass, + "test", + "HassTurnOn", + {"name": {"value": "all"}, "domain": {"value": "light"}}, + ) await hass.async_block_till_done() # All lights should be on now @@ -422,7 +428,7 @@ async def test_get_state_intent( assert not result.matched_states and not result.unmatched_states # Test unknown area failure - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError): await intent.async_handle( hass, "test", diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py new file mode 100644 index 00000000000..a884fd13de5 --- /dev/null +++ b/tests/components/intent/test_timers.py @@ -0,0 +1,1601 @@ +"""Tests for intent timers.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.intent.timers import ( + MultipleTimersMatchedError, + TimerEventType, + TimerInfo, + TimerManager, + TimerNotFoundError, + TimersNotSupportedError, + _round_time, + async_device_supports_timers, + async_register_timer_handler, +) +from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + floor_registry as fr, + intent, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def init_components(hass: HomeAssistant) -> None: + """Initialize required components for tests.""" + assert await async_setup_component(hass, "intent", {}) + + +async def test_start_finish_timer(hass: HomeAssistant, init_components) -> None: + """Test starting a timer and having it finish.""" + device_id = "test_device" + timer_name = "test timer" + started_event = asyncio.Event() + finished_event = asyncio.Event() + + timer_id: str | None = None + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id + + assert timer.name == timer_name + assert timer.device_id == device_id + assert timer.start_hours is None + assert timer.start_minutes is None + assert timer.start_seconds == 0 + assert timer.seconds_left == 0 + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + started_event.set() + elif event_type == TimerEventType.FINISHED: + assert timer.id == timer_id + finished_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "seconds": {"value": 0}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await asyncio.gather(started_event.wait(), finished_event.wait()) + + +async def test_cancel_timer(hass: HomeAssistant, init_components) -> None: + """Test cancelling a timer.""" + device_id = "test_device" + timer_name: str | None = None + started_event = asyncio.Event() + cancelled_event = asyncio.Event() + + timer_id: str | None = None + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id + + assert timer.device_id == device_id + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if timer_name is not None: + assert timer.name == timer_name + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + assert ( + timer.seconds_left + == (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.CANCELLED: + assert timer.id == timer_id + assert timer.seconds_left == 0 + cancelled_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + # Cancel by starting time + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=device_id, + ) + + async with asyncio.timeout(1): + await started_event.wait() + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + { + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Cancel by name + timer_name = "test timer" + started_event.clear() + cancelled_event.clear() + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=device_id, + ) + + async with asyncio.timeout(1): + await started_event.wait() + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": timer_name}}, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + +async def test_increase_timer(hass: HomeAssistant, init_components) -> None: + """Test increasing the time of a running timer.""" + device_id = "test_device" + started_event = asyncio.Event() + updated_event = asyncio.Event() + cancelled_event = asyncio.Event() + + timer_name = "test timer" + timer_id: str | None = None + original_total_seconds = -1 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id, original_total_seconds + + assert timer.device_id == device_id + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if timer_name is not None: + assert timer.name == timer_name + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + original_total_seconds = ( + (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.id == timer_id + + # Timer was increased + assert timer.seconds_left > original_total_seconds + updated_event.set() + elif event_type == TimerEventType.CANCELLED: + assert timer.id == timer_id + cancelled_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Adding 0 seconds has no effect + result = await intent.async_handle( + hass, + "test", + intent.INTENT_INCREASE_TIMER, + { + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + "hours": {"value": 0}, + "minutes": {"value": 0}, + "seconds": {"value": 0}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert not updated_event.is_set() + + # Add 30 seconds to the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_INCREASE_TIMER, + { + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + "hours": {"value": 1}, + "minutes": {"value": 5}, + "seconds": {"value": 30}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + + # Cancel the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": timer_name}}, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + +async def test_decrease_timer(hass: HomeAssistant, init_components) -> None: + """Test decreasing the time of a running timer.""" + device_id = "test_device" + started_event = asyncio.Event() + updated_event = asyncio.Event() + cancelled_event = asyncio.Event() + + timer_name = "test timer" + timer_id: str | None = None + original_total_seconds = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id, original_total_seconds + + assert timer.device_id == device_id + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if timer_name is not None: + assert timer.name == timer_name + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + original_total_seconds = ( + (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.id == timer_id + + # Timer was decreased + assert timer.seconds_left <= (original_total_seconds - 30) + + updated_event.set() + elif event_type == TimerEventType.CANCELLED: + assert timer.id == timer_id + cancelled_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Remove 30 seconds from the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_DECREASE_TIMER, + { + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + "seconds": {"value": 30}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Cancel the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": timer_name}}, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + +async def test_decrease_timer_below_zero(hass: HomeAssistant, init_components) -> None: + """Test decreasing the time of a running timer below 0 seconds.""" + started_event = asyncio.Event() + updated_event = asyncio.Event() + finished_event = asyncio.Event() + + device_id = "test_device" + timer_id: str | None = None + original_total_seconds = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_id, original_total_seconds + + assert timer.device_id == device_id + assert timer.name is None + assert timer.start_hours == 1 + assert timer.start_minutes == 2 + assert timer.start_seconds == 3 + + if event_type == TimerEventType.STARTED: + timer_id = timer.id + original_total_seconds = ( + (60 * 60 * timer.start_hours) + + (60 * timer.start_minutes) + + timer.start_seconds + ) + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.id == timer_id + + # Timer was decreased below zero + assert timer.seconds_left == 0 + + updated_event.set() + elif event_type == TimerEventType.FINISHED: + assert timer.id == timer_id + finished_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Remove more time than was on the timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_DECREASE_TIMER, + { + "start_hours": {"value": 1}, + "start_minutes": {"value": 2}, + "start_seconds": {"value": 3}, + "seconds": {"value": original_total_seconds + 1}, + }, + device_id=device_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await asyncio.gather( + started_event.wait(), updated_event.wait(), finished_event.wait() + ) + + +async def test_find_timer_failed(hass: HomeAssistant, init_components) -> None: + """Test finding a timer with the wrong info.""" + device_id = "test_device" + + for intent_name in ( + intent.INTENT_START_TIMER, + intent.INTENT_CANCEL_TIMER, + intent.INTENT_PAUSE_TIMER, + intent.INTENT_UNPAUSE_TIMER, + intent.INTENT_INCREASE_TIMER, + intent.INTENT_DECREASE_TIMER, + intent.INTENT_TIMER_STATUS, + ): + if intent_name in ( + intent.INTENT_START_TIMER, + intent.INTENT_INCREASE_TIMER, + intent.INTENT_DECREASE_TIMER, + ): + slots = {"minutes": {"value": 5}} + else: + slots = {} + + # No device id + with pytest.raises(TimersNotSupportedError): + await intent.async_handle( + hass, + "test", + intent_name, + slots, + device_id=None, + ) + + # Unregistered device + with pytest.raises(TimersNotSupportedError): + await intent.async_handle( + hass, + "test", + intent_name, + slots, + device_id=device_id, + ) + + # Must register a handler before we can do anything with timers + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + async_register_timer_handler(hass, device_id, handle_timer) + + # Start a 5 minute timer for pizza + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 5}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Right name + result = await intent.async_handle( + hass, + "test", + intent.INTENT_INCREASE_TIMER, + {"name": {"value": "PIZZA "}, "minutes": {"value": 1}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wrong name + with pytest.raises(intent.IntentError): + await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"name": {"value": "does-not-exist"}}, + device_id=device_id, + ) + + # Right start time + result = await intent.async_handle( + hass, + "test", + intent.INTENT_INCREASE_TIMER, + {"start_minutes": {"value": 5}, "minutes": {"value": 1}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wrong start time + with pytest.raises(intent.IntentError): + await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"start_minutes": {"value": 1}}, + device_id=device_id, + ) + + +async def test_disambiguation( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test finding a timer by disambiguating with area/floor.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + cancelled_event = asyncio.Event() + timer_info: TimerInfo | None = None + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal timer_info + + if event_type == TimerEventType.CANCELLED: + timer_info = timer + cancelled_event.set() + + # Alice is upstairs in the study + floor_upstairs = floor_registry.async_create("upstairs") + area_study = area_registry.async_create("study") + area_study = area_registry.async_update( + area_study.id, floor_id=floor_upstairs.floor_id + ) + device_alice_study = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "alice")}, + ) + device_registry.async_update_device(device_alice_study.id, area_id=area_study.id) + + # Bob is downstairs in the kitchen + floor_downstairs = floor_registry.async_create("downstairs") + area_kitchen = area_registry.async_create("kitchen") + area_kitchen = area_registry.async_update( + area_kitchen.id, floor_id=floor_downstairs.floor_id + ) + device_bob_kitchen_1 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "bob")}, + ) + device_registry.async_update_device( + device_bob_kitchen_1.id, area_id=area_kitchen.id + ) + + async_register_timer_handler(hass, device_alice_study.id, handle_timer) + async_register_timer_handler(hass, device_bob_kitchen_1.id, handle_timer) + + # Alice: set a 3 minute timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 3}}, + device_id=device_alice_study.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Bob: set a 3 minute timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 3}}, + device_id=device_bob_kitchen_1.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Alice should hear her timer listed first + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_alice_study.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 + assert timers[0].get(ATTR_DEVICE_ID) == device_alice_study.id + assert timers[1].get(ATTR_DEVICE_ID) == device_bob_kitchen_1.id + + # Bob should hear his timer listed first + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_bob_kitchen_1.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 + assert timers[0].get(ATTR_DEVICE_ID) == device_bob_kitchen_1.id + assert timers[1].get(ATTR_DEVICE_ID) == device_alice_study.id + + # Alice: cancel my timer + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=device_alice_study.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Verify this is the 3 minute timer from Alice + assert timer_info is not None + assert timer_info.device_id == device_alice_study.id + assert timer_info.start_minutes == 3 + + # Cancel Bob's timer + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=device_bob_kitchen_1.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Add two new devices in two new areas, one upstairs and one downstairs + area_bedroom = area_registry.async_create("bedroom") + area_bedroom = area_registry.async_update( + area_bedroom.id, floor_id=floor_upstairs.floor_id + ) + device_alice_bedroom = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "alice-2")}, + ) + device_registry.async_update_device( + device_alice_bedroom.id, area_id=area_bedroom.id + ) + + area_living_room = area_registry.async_create("living_room") + area_living_room = area_registry.async_update( + area_living_room.id, floor_id=floor_downstairs.floor_id + ) + device_bob_living_room = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "bob-2")}, + ) + device_registry.async_update_device( + device_bob_living_room.id, area_id=area_living_room.id + ) + + async_register_timer_handler(hass, device_alice_bedroom.id, handle_timer) + async_register_timer_handler(hass, device_bob_living_room.id, handle_timer) + + # Alice: set a 3 minute timer (study) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 3}}, + device_id=device_alice_study.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Alice: set a 3 minute timer (bedroom) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 3}}, + device_id=device_alice_bedroom.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Bob: set a 3 minute timer (kitchen) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 3}}, + device_id=device_bob_kitchen_1.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Bob: set a 3 minute timer (living room) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 3}}, + device_id=device_bob_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Alice should hear the timer in her area first, then on her floor, then + # elsewhere. + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_alice_study.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 4 + assert timers[0].get(ATTR_DEVICE_ID) == device_alice_study.id + assert timers[1].get(ATTR_DEVICE_ID) == device_alice_bedroom.id + assert timers[2].get(ATTR_DEVICE_ID) == device_bob_kitchen_1.id + assert timers[3].get(ATTR_DEVICE_ID) == device_bob_living_room.id + + # Alice cancels the study timer from study + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=device_alice_study.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Verify this is the 3 minute timer from Alice in the study + assert timer_info is not None + assert timer_info.device_id == device_alice_study.id + assert timer_info.start_minutes == 3 + + # Trying to cancel the remaining two timers from a disconnected area fails + area_garage = area_registry.async_create("garage") + device_garage = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "garage")}, + ) + device_registry.async_update_device(device_garage.id, area_id=area_garage.id) + async_register_timer_handler(hass, device_garage.id, handle_timer) + + with pytest.raises(MultipleTimersMatchedError): + await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {}, + device_id=device_garage.id, + ) + + # Alice cancels the bedroom timer from study (same floor) + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=device_alice_study.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + # Verify this is the 3 minute timer from Alice in the bedroom + assert timer_info is not None + assert timer_info.device_id == device_alice_bedroom.id + assert timer_info.start_minutes == 3 + + # Add a second device in the kitchen + device_bob_kitchen_2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "bob-3")}, + ) + device_registry.async_update_device( + device_bob_kitchen_2.id, area_id=area_kitchen.id + ) + + async_register_timer_handler(hass, device_bob_kitchen_2.id, handle_timer) + + # Bob cancels the kitchen timer from a different device + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=device_bob_kitchen_2.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + assert timer_info is not None + assert timer_info.device_id == device_bob_kitchen_1.id + assert timer_info.start_minutes == 3 + + # Bob cancels the living room timer from the kitchen + cancelled_event.clear() + timer_info = None + result = await intent.async_handle( + hass, "test", intent.INTENT_CANCEL_TIMER, {}, device_id=device_bob_kitchen_2.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await cancelled_event.wait() + + assert timer_info is not None + assert timer_info.device_id == device_bob_living_room.id + assert timer_info.start_minutes == 3 + + +async def test_pause_unpause_timer(hass: HomeAssistant, init_components) -> None: + """Test pausing and unpausing a running timer.""" + device_id = "test_device" + + started_event = asyncio.Event() + updated_event = asyncio.Event() + + expected_active = True + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + if event_type == TimerEventType.STARTED: + started_event.set() + elif event_type == TimerEventType.UPDATED: + assert timer.is_active == expected_active + updated_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 5}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Pause the timer + expected_active = False + result = await intent.async_handle( + hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + + # Pausing again will fail because there are no running timers + with pytest.raises(TimerNotFoundError): + await intent.async_handle( + hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id + ) + + # Unpause the timer + updated_event.clear() + expected_active = True + result = await intent.async_handle( + hass, "test", intent.INTENT_UNPAUSE_TIMER, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + + # Unpausing again will fail because there are no paused timers + with pytest.raises(TimerNotFoundError): + await intent.async_handle( + hass, "test", intent.INTENT_UNPAUSE_TIMER, {}, device_id=device_id + ) + + +async def test_timer_not_found(hass: HomeAssistant) -> None: + """Test invalid timer ids raise TimerNotFoundError.""" + timer_manager = TimerManager(hass) + + with pytest.raises(TimerNotFoundError): + timer_manager.cancel_timer("does-not-exist") + + with pytest.raises(TimerNotFoundError): + timer_manager.add_time("does-not-exist", 1) + + with pytest.raises(TimerNotFoundError): + timer_manager.remove_time("does-not-exist", 1) + + with pytest.raises(TimerNotFoundError): + timer_manager.pause_timer("does-not-exist") + + with pytest.raises(TimerNotFoundError): + timer_manager.unpause_timer("does-not-exist") + + +async def test_timer_manager_pause_unpause(hass: HomeAssistant) -> None: + """Test that pausing/unpausing again will not have an affect.""" + timer_manager = TimerManager(hass) + + # Start a timer + handle_timer = MagicMock() + + device_id = "test_device" + timer_manager.register_handler(device_id, handle_timer) + + timer_id = timer_manager.start_timer( + device_id, + hours=None, + minutes=5, + seconds=None, + language=hass.config.language, + ) + + assert timer_id in timer_manager.timers + assert timer_manager.timers[timer_id].is_active + + # Pause + handle_timer.reset_mock() + timer_manager.pause_timer(timer_id) + handle_timer.assert_called_once() + + # Pausing again does not call handler + handle_timer.reset_mock() + timer_manager.pause_timer(timer_id) + handle_timer.assert_not_called() + + # Unpause + handle_timer.reset_mock() + timer_manager.unpause_timer(timer_id) + handle_timer.assert_called_once() + + # Unpausing again does not call handler + handle_timer.reset_mock() + timer_manager.unpause_timer(timer_id) + handle_timer.assert_not_called() + + +async def test_timers_not_supported(hass: HomeAssistant) -> None: + """Test unregistered device ids raise TimersNotSupportedError.""" + timer_manager = TimerManager(hass) + + with pytest.raises(TimersNotSupportedError): + timer_manager.start_timer( + "does-not-exist", + hours=None, + minutes=5, + seconds=None, + language=hass.config.language, + ) + + # Start a timer + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + device_id = "test_device" + unregister = timer_manager.register_handler(device_id, handle_timer) + + timer_id = timer_manager.start_timer( + device_id, + hours=None, + minutes=5, + seconds=None, + language=hass.config.language, + ) + + # Unregister handler so device no longer "supports" timers + unregister() + + # All operations on the timer should not crash + timer_manager.add_time(timer_id, 1) + + timer_manager.remove_time(timer_id, 1) + + timer_manager.pause_timer(timer_id) + + timer_manager.unpause_timer(timer_id) + + timer_manager.cancel_timer(timer_id) + + +async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> None: + """Test getting the status of named timers.""" + device_id = "test_device" + + started_event = asyncio.Event() + num_started = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal num_started + + if event_type == TimerEventType.STARTED: + num_started += 1 + if num_started == 4: + started_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + # Start timers with names + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 15}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "cookies"}, "minutes": {"value": 20}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "chicken"}, "hours": {"value": 2}, "seconds": {"value": 30}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wait for all timers to start + async with asyncio.timeout(1): + await started_event.wait() + + # No constraints returns all timers + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 4 + assert {t.get(ATTR_NAME) for t in timers} == {"pizza", "cookies", "chicken"} + + # Get status of cookie timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "cookies"}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "cookies" + assert timers[0].get("start_minutes") == 20 + + # Get status of pizza timers + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "pizza"}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 + assert timers[0].get(ATTR_NAME) == "pizza" + assert timers[1].get(ATTR_NAME) == "pizza" + assert {timers[0].get("start_minutes"), timers[1].get("start_minutes")} == {10, 15} + + # Get status of one pizza timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "pizza"}, "start_minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "pizza" + assert timers[0].get("start_minutes") == 10 + + # Get status of one chicken timer + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + { + "name": {"value": "chicken"}, + "start_hours": {"value": 2}, + "start_seconds": {"value": 30}, + }, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "chicken" + assert timers[0].get("start_hours") == 2 + assert timers[0].get("start_minutes") == 0 + assert timers[0].get("start_seconds") == 30 + + # Wrong name results in an empty list + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"name": {"value": "does-not-exist"}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + # Wrong start time results in an empty list + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + { + "start_hours": {"value": 100}, + "start_minutes": {"value": 100}, + "start_seconds": {"value": 100}, + }, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + +async def test_area_filter( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test targeting timers by area name.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) + + area_kitchen = area_registry.async_create("kitchen") + device_kitchen = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "kitchen-device")}, + ) + device_registry.async_update_device(device_kitchen.id, area_id=area_kitchen.id) + + area_living_room = area_registry.async_create("living room") + device_living_room = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("test", "living_room-device")}, + ) + device_registry.async_update_device( + device_living_room.id, area_id=area_living_room.id + ) + + started_event = asyncio.Event() + num_timers = 3 + num_started = 0 + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + nonlocal num_started + + if event_type == TimerEventType.STARTED: + num_started += 1 + if num_started == num_timers: + started_event.set() + + async_register_timer_handler(hass, device_kitchen.id, handle_timer) + async_register_timer_handler(hass, device_living_room.id, handle_timer) + + # Start timers in different areas + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "pizza"}, "minutes": {"value": 10}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "tv"}, "minutes": {"value": 10}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"name": {"value": "media"}, "minutes": {"value": 15}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Wait for all timers to start + async with asyncio.timeout(1): + await started_event.wait() + + # No constraints returns all timers + result = await intent.async_handle( + hass, "test", intent.INTENT_TIMER_STATUS, {}, device_id=device_kitchen.id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == num_timers + assert {t.get(ATTR_NAME) for t in timers} == {"pizza", "tv", "media"} + + # Filter by area (target kitchen from living room) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "kitchen"}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "pizza" + + # Filter by area (target living room from kitchen) + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "living room"}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 2 + assert {t.get(ATTR_NAME) for t in timers} == {"tv", "media"} + + # Filter by area + name + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "living room"}, "name": {"value": "tv"}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "tv" + + # Filter by area + time + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "living room"}, "start_minutes": {"value": 15}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + assert timers[0].get(ATTR_NAME) == "media" + + # Filter by area that doesn't exist + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + {"area": {"value": "does-not-exist"}}, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 0 + + # Cancel by area + time + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"area": {"value": "living room"}, "start_minutes": {"value": 15}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Cancel by area + result = await intent.async_handle( + hass, + "test", + intent.INTENT_CANCEL_TIMER, + {"area": {"value": "living room"}}, + device_id=device_living_room.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + # Get status with device missing + with patch( + "homeassistant.helpers.device_registry.DeviceRegistry.async_get", + return_value=None, + ): + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + + # Get status with area missing + with patch( + "homeassistant.helpers.area_registry.AreaRegistry.async_get_area", + return_value=None, + ): + result = await intent.async_handle( + hass, + "test", + intent.INTENT_TIMER_STATUS, + device_id=device_kitchen.id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + timers = result.speech_slots.get("timers", []) + assert len(timers) == 1 + + +def test_round_time() -> None: + """Test lower-precision time rounded.""" + + # hours + assert _round_time(1, 10, 30) == (1, 0, 0) + assert _round_time(1, 48, 30) == (2, 0, 0) + assert _round_time(2, 25, 30) == (2, 30, 0) + + # minutes + assert _round_time(0, 1, 10) == (0, 1, 0) + assert _round_time(0, 1, 48) == (0, 2, 0) + assert _round_time(0, 2, 25) == (0, 2, 30) + + # seconds + assert _round_time(0, 0, 6) == (0, 0, 6) + assert _round_time(0, 0, 15) == (0, 0, 10) + assert _round_time(0, 0, 58) == (0, 1, 0) + assert _round_time(0, 0, 25) == (0, 0, 20) + assert _round_time(0, 0, 35) == (0, 0, 30) + + +async def test_start_timer_with_conversation_command( + hass: HomeAssistant, init_components +) -> None: + """Test starting a timer with an conversation command and having it finish.""" + device_id = "test_device" + timer_name = "test timer" + test_command = "turn on the lights" + agent_id = "test_agent" + finished_event = asyncio.Event() + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + if event_type == TimerEventType.FINISHED: + assert timer.conversation_command == test_command + assert timer.conversation_agent_id == agent_id + finished_event.set() + + async_register_timer_handler(hass, device_id, handle_timer) + + # Device id is required if no conversation command + timer_manager = TimerManager(hass) + with pytest.raises(ValueError): + timer_manager.start_timer( + device_id=None, + hours=None, + minutes=5, + seconds=None, + language=hass.config.language, + ) + + with patch("homeassistant.components.conversation.async_converse") as mock_converse: + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + { + "name": {"value": timer_name}, + "seconds": {"value": 0}, + "conversation_command": {"value": test_command}, + }, + device_id=device_id, + conversation_agent_id=agent_id, + ) + + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await finished_event.wait() + + mock_converse.assert_called_once() + assert mock_converse.call_args.args[1] == test_command + + +async def test_pause_unpause_timer_disambiguate( + hass: HomeAssistant, init_components +) -> None: + """Test disamgibuating timers by their paused state.""" + device_id = "test_device" + started_timer_ids: list[str] = [] + paused_timer_ids: list[str] = [] + unpaused_timer_ids: list[str] = [] + + started_event = asyncio.Event() + updated_event = asyncio.Event() + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + if event_type == TimerEventType.STARTED: + started_event.set() + started_timer_ids.append(timer.id) + elif event_type == TimerEventType.UPDATED: + updated_event.set() + if timer.is_active: + unpaused_timer_ids.append(timer.id) + else: + paused_timer_ids.append(timer.id) + + async_register_timer_handler(hass, device_id, handle_timer) + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 5}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + + # Pause the timer + result = await intent.async_handle( + hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + + # Start another timer + started_event.clear() + result = await intent.async_handle( + hass, + "test", + intent.INTENT_START_TIMER, + {"minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await started_event.wait() + assert len(started_timer_ids) == 2 + + # We can pause the more recent timer without more information because the + # first one is paused. + updated_event.clear() + result = await intent.async_handle( + hass, "test", intent.INTENT_PAUSE_TIMER, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + assert len(paused_timer_ids) == 2 + assert paused_timer_ids[1] == started_timer_ids[1] + + # We have to explicitly unpause now + updated_event.clear() + result = await intent.async_handle( + hass, + "test", + intent.INTENT_UNPAUSE_TIMER, + {"start_minutes": {"value": 10}}, + device_id=device_id, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + assert len(unpaused_timer_ids) == 1 + assert unpaused_timer_ids[0] == started_timer_ids[1] + + # We can resume the older timer without more information because the + # second one is running. + updated_event.clear() + result = await intent.async_handle( + hass, "test", intent.INTENT_UNPAUSE_TIMER, {}, device_id=device_id + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + + async with asyncio.timeout(1): + await updated_event.wait() + assert len(unpaused_timer_ids) == 2 + assert unpaused_timer_ids[1] == started_timer_ids[0] + + +async def test_async_device_supports_timers(hass: HomeAssistant) -> None: + """Test async_device_supports_timers function.""" + device_id = "test_device" + + # Before intent initialization + assert not async_device_supports_timers(hass, device_id) + + # After intent initialization + assert await async_setup_component(hass, "intent", {}) + assert not async_device_supports_timers(hass, device_id) + + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + async_register_timer_handler(hass, device_id, handle_timer) + + # After handler registration + assert async_device_supports_timers(hass, device_id) diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 14e5dd62d51..5f4c7b97b63 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -22,6 +22,8 @@ async def test_intent_script(hass: HomeAssistant) -> None: { "intent_script": { "HelloWorld": { + "description": "Intent to control a test service.", + "platforms": ["switch"], "action": { "service": "test.service", "data_template": {"hello": "{{ name }}"}, @@ -36,6 +38,17 @@ async def test_intent_script(hass: HomeAssistant) -> None: }, ) + handlers = [ + intent_handler + for intent_handler in intent.async_get(hass) + if intent_handler.intent_type == "HelloWorld" + ] + + assert len(handlers) == 1 + handler = handlers[0] + assert handler.description == "Intent to control a test service." + assert handler.platforms == {"switch"} + response = await intent.async_handle( hass, "test", "HelloWorld", {"name": {"value": "Paulus"}} ) @@ -78,6 +91,16 @@ async def test_intent_script_wait_response(hass: HomeAssistant) -> None: }, ) + handlers = [ + intent_handler + for intent_handler in intent.async_get(hass) + if intent_handler.intent_type == "HelloWorldWaitResponse" + ] + + assert len(handlers) == 1 + handler = handlers[0] + assert handler.platforms is None + response = await intent.async_handle( hass, "test", "HelloWorldWaitResponse", {"name": {"value": "Paulus"}} ) diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index ef9b667f03d..38bb1dbf126 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import patch from pyipma import IPMAException import pytest +from typing_extensions import Generator from homeassistant.components.ipma.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -11,11 +12,11 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.components.ipma import MockLocation +from . import MockLocation @pytest.fixture(name="ipma_setup", autouse=True) -def ipma_setup_fixture(request): +def ipma_setup_fixture() -> Generator[None]: """Patch ipma setup entry.""" with patch("homeassistant.components.ipma.async_setup_entry", return_value=True): yield diff --git a/tests/components/ipma/test_sensor.py b/tests/components/ipma/test_sensor.py index d5f6a3ab5bb..adff8206add 100644 --- a/tests/components/ipma/test_sensor.py +++ b/tests/components/ipma/test_sensor.py @@ -2,12 +2,14 @@ from unittest.mock import patch +from homeassistant.core import HomeAssistant + from . import ENTRY_CONFIG, MockLocation from tests.common import MockConfigEntry -async def test_ipma_fire_risk_create_sensors(hass): +async def test_ipma_fire_risk_create_sensors(hass: HomeAssistant) -> None: """Test creation of fire risk sensors.""" with patch("pyipma.location.Location.get", return_value=MockLocation()): @@ -21,7 +23,7 @@ async def test_ipma_fire_risk_create_sensors(hass): assert state.state == "3" -async def test_ipma_uv_index_create_sensors(hass): +async def test_ipma_uv_index_create_sensors(hass: HomeAssistant) -> None: """Test creation of uv index sensors.""" with patch("pyipma.location.Location.get", return_value=MockLocation()): diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index 7150286e4f9..b7ef1347ca5 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -15,7 +15,6 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNKNOWN @@ -101,10 +100,7 @@ async def test_failed_get_observation_forecast(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_forecast_service( hass: HomeAssistant, diff --git a/tests/components/ipp/conftest.py b/tests/components/ipp/conftest.py index f650b370200..5e39a16f3b1 100644 --- a/tests/components/ipp/conftest.py +++ b/tests/components/ipp/conftest.py @@ -1,11 +1,11 @@ """Fixtures for IPP integration tests.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch from pyipp import Printer import pytest +from typing_extensions import Generator from homeassistant.components.ipp.const import CONF_BASE_PATH, DOMAIN from homeassistant.const import ( @@ -39,7 +39,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.ipp.async_setup_entry", return_value=True @@ -60,9 +60,7 @@ async def mock_printer( @pytest.fixture -def mock_ipp_config_flow( - mock_printer: Printer, -) -> Generator[None, MagicMock, None]: +def mock_ipp_config_flow(mock_printer: Printer) -> Generator[MagicMock]: """Return a mocked IPP client.""" with patch( @@ -74,9 +72,7 @@ def mock_ipp_config_flow( @pytest.fixture -def mock_ipp( - request: pytest.FixtureRequest, mock_printer: Printer -) -> Generator[None, MagicMock, None]: +def mock_ipp(mock_printer: Printer) -> Generator[MagicMock]: """Return a mocked IPP client.""" with patch( diff --git a/tests/components/ipp/test_init.py b/tests/components/ipp/test_init.py index 5742d47674d..e1050bc5c21 100644 --- a/tests/components/ipp/test_init.py +++ b/tests/components/ipp/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from pyipp import IPPConnectionError -from homeassistant.components.ipp.const import DOMAIN +from homeassistant.components.ipp.coordinator import IPPDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -37,10 +37,9 @@ async def test_load_unload_config_entry( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id in hass.data[DOMAIN] assert mock_config_entry.state is ConfigEntryState.LOADED + assert isinstance(mock_config_entry.runtime_data, IPPDataUpdateCoordinator) await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.entry_id not in hass.data[DOMAIN] assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/isal/__init__.py b/tests/components/isal/__init__.py new file mode 100644 index 00000000000..388be1aa266 --- /dev/null +++ b/tests/components/isal/__init__.py @@ -0,0 +1 @@ +"""Tests for the Intelligent Storage Acceleration integration.""" diff --git a/tests/components/isal/test_init.py b/tests/components/isal/test_init.py new file mode 100644 index 00000000000..66e9984dfe2 --- /dev/null +++ b/tests/components/isal/test_init.py @@ -0,0 +1,10 @@ +"""Test the Intelligent Storage Acceleration setup.""" + +from homeassistant.components.isal import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_setup(hass: HomeAssistant) -> None: + """Ensure we can setup.""" + assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/islamic_prayer_times/conftest.py b/tests/components/islamic_prayer_times/conftest.py index f1b4a8f675c..ae9b1f45eb9 100644 --- a/tests/components/islamic_prayer_times/conftest.py +++ b/tests/components/islamic_prayer_times/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the islamic_prayer_times tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.islamic_prayer_times.async_setup_entry", diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 2a2597ef0ce..025a202e6da 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -21,9 +21,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) -def set_utc(hass: HomeAssistant) -> None: +async def set_utc(hass: HomeAssistant) -> None: """Set timezone to UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") async def test_successful_config_entry(hass: HomeAssistant) -> None: diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 7bd1a1192ad..153f0012a2c 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -15,9 +15,9 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def set_utc(hass: HomeAssistant) -> None: +async def set_utc(hass: HomeAssistant) -> None: """Set timezone to UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") @pytest.mark.parametrize( diff --git a/tests/components/ista_ecotrend/__init__.py b/tests/components/ista_ecotrend/__init__.py new file mode 100644 index 00000000000..93426111a06 --- /dev/null +++ b/tests/components/ista_ecotrend/__init__.py @@ -0,0 +1 @@ +"""Tests for the ista EcoTrend integration.""" diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py new file mode 100644 index 00000000000..2218ef05ba7 --- /dev/null +++ b/tests/components/ista_ecotrend/conftest.py @@ -0,0 +1,168 @@ +"""Common fixtures for the ista EcoTrend tests.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.ista_ecotrend.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="ista_config_entry") +def mock_ista_config_entry() -> MockConfigEntry: + """Mock ista EcoTrend configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="26e93f1a-c828-11ea-87d0-0242ac130003", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ista_ecotrend.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_ista() -> Generator[MagicMock]: + """Mock Pyecotrend_ista client.""" + + with ( + patch( + "homeassistant.components.ista_ecotrend.PyEcotrendIsta", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.ista_ecotrend.config_flow.PyEcotrendIsta", + new=mock_client, + ), + patch( + "homeassistant.components.ista_ecotrend.coordinator.PyEcotrendIsta", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_account.return_value = { + "firstName": "Max", + "lastName": "Istamann", + "activeConsumptionUnit": "26e93f1a-c828-11ea-87d0-0242ac130003", + } + client.get_consumption_unit_details.return_value = { + "consumptionUnits": [ + { + "id": "26e93f1a-c828-11ea-87d0-0242ac130003", + "address": { + "street": "Luxemburger Str.", + "houseNumber": "1", + }, + }, + { + "id": "eaf5c5c8-889f-4a3c-b68c-e9a676505762", + "address": { + "street": "Bahnhofsstr.", + "houseNumber": "1A", + }, + }, + ] + } + client.get_uuids.return_value = [ + "26e93f1a-c828-11ea-87d0-0242ac130003", + "eaf5c5c8-889f-4a3c-b68c-e9a676505762", + ] + client.get_consumption_data = get_consumption_data + + yield client + + +def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: + """Mock function get_consumption_data.""" + return { + "consumptionUnitId": obj_uuid, + "consumptions": [ + { + "date": {"month": 5, "year": 2024}, + "readings": [ + { + "type": "heating", + "value": "35", + "additionalValue": "38,0", + }, + { + "type": "warmwater", + "value": "1,0", + "additionalValue": "57,0", + }, + { + "type": "water", + "value": "5,0", + }, + ], + }, + { + "date": {"month": 4, "year": 2024}, + "readings": [ + { + "type": "heating", + "value": "104", + "additionalValue": "113,0", + }, + { + "type": "warmwater", + "value": "1,1", + "additionalValue": "61,1", + }, + { + "type": "water", + "value": "6,8", + }, + ], + }, + ], + "costs": [ + { + "date": {"month": 5, "year": 2024}, + "costsByEnergyType": [ + { + "type": "heating", + "value": 21, + }, + { + "type": "warmwater", + "value": 7, + }, + { + "type": "water", + "value": 3, + }, + ], + }, + { + "date": {"month": 4, "year": 2024}, + "costsByEnergyType": [ + { + "type": "heating", + "value": 62, + }, + { + "type": "warmwater", + "value": 7, + }, + { + "type": "water", + "value": 2, + }, + ], + }, + ], + } diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr new file mode 100644 index 00000000000..a9d13510b54 --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + '26e93f1a-c828-11ea-87d0-0242ac130003', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Luxemburger Str. 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_registry.1 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Bahnhofsstr. 1A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..c312f9b6350 --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr @@ -0,0 +1,915 @@ +# serializer version: 1 +# name: test_setup.32 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + '26e93f1a-c828-11ea-87d0-0242ac130003', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Luxemburger Str. 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_setup.33 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://ecotrend.ista.de/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ista_ecotrend', + 'eaf5c5c8-889f-4a3c-b68c-e9a676505762', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ista SE', + 'model': 'ista EcoTrend', + 'name': 'Bahnhofsstr. 1A', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bahnhofsstr. 1A Heating', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_heating_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Bahnhofsstr. 1A Heating cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_heating_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_heating_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_heating_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_heating_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Bahnhofsstr. 1A Heating energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_heating_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Bahnhofsstr. 1A Hot water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Bahnhofsstr. 1A Hot water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_hot_water_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_hot_water_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Bahnhofsstr. 1A Hot water energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_hot_water_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Bahnhofsstr. 1A Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bahnhofsstr_1a_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'eaf5c5c8-889f-4a3c-b68c-e9a676505762_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.bahnhofsstr_1a_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Bahnhofsstr. 1A Water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.bahnhofsstr_1a_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_heating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Heating', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Luxemburger Str. 1 Heating', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_heating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '35', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_heating_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Luxemburger Str. 1 Heating cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_heating_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_heating_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Heating energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_heating_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_heating_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Luxemburger Str. 1 Heating energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_heating_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_hot_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Luxemburger Str. 1 Hot water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_hot_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Luxemburger Str. 1 Hot water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_hot_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_hot_water_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hot water energy', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_hot_water_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_hot_water_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Luxemburger Str. 1 Hot water energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_hot_water_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '57.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_water', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Luxemburger Str. 1 Water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water_cost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.luxemburger_str_1_water_cost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water cost', + 'platform': 'ista_ecotrend', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '26e93f1a-c828-11ea-87d0-0242ac130003_water_cost', + 'unit_of_measurement': 'EUR', + }) +# --- +# name: test_setup[sensor.luxemburger_str_1_water_cost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Luxemburger Str. 1 Water cost', + 'state_class': , + 'unit_of_measurement': 'EUR', + }), + 'context': , + 'entity_id': 'sensor.luxemburger_str_1_water_cost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr new file mode 100644 index 00000000000..9536c5336db --- /dev/null +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -0,0 +1,175 @@ +# serializer version: 1 +# name: test_get_statistics + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 104, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 35, + }), + ]) +# --- +# name: test_get_statistics.1 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 113.0, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 38.0, + }), + ]) +# --- +# name: test_get_statistics.2 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 62, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 21, + }), + ]) +# --- +# name: test_get_statistics.3 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 1.1, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 1.0, + }), + ]) +# --- +# name: test_get_statistics.4 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 61.1, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 57.0, + }), + ]) +# --- +# name: test_get_statistics.5 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + ]) +# --- +# name: test_get_statistics.6 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 6.8, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 5.0, + }), + ]) +# --- +# name: test_get_statistics.7 + list([ + ]) +# --- +# name: test_get_statistics.8 + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 2, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 3, + }), + ]) +# --- +# name: test_get_values_by_type + dict({ + 'additionalValue': '38,0', + 'type': 'heating', + 'value': '35', + }) +# --- +# name: test_get_values_by_type.1 + dict({ + 'additionalValue': '57,0', + 'type': 'warmwater', + 'value': '1,0', + }) +# --- +# name: test_get_values_by_type.2 + dict({ + 'type': 'water', + 'value': '5,0', + }) +# --- +# name: test_get_values_by_type.3 + dict({ + 'type': 'heating', + 'value': 21, + }) +# --- +# name: test_get_values_by_type.4 + dict({ + 'type': 'warmwater', + 'value': 7, + }) +# --- +# name: test_get_values_by_type.5 + dict({ + 'type': 'water', + 'value': 3, + }) +# --- +# name: test_last_day_of_month + datetime.datetime(2024, 1, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.1 + datetime.datetime(2024, 2, 29, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.10 + datetime.datetime(2024, 11, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.11 + datetime.datetime(2024, 12, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.2 + datetime.datetime(2024, 3, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.3 + datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.4 + datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.5 + datetime.datetime(2024, 6, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.6 + datetime.datetime(2024, 7, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.7 + datetime.datetime(2024, 8, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.8 + datetime.datetime(2024, 9, 30, 0, 0, tzinfo=datetime.timezone.utc) +# --- +# name: test_last_day_of_month.9 + datetime.datetime(2024, 10, 31, 0, 0, tzinfo=datetime.timezone.utc) +# --- diff --git a/tests/components/ista_ecotrend/test_config_flow.py b/tests/components/ista_ecotrend/test_config_flow.py new file mode 100644 index 00000000000..b702b0331e8 --- /dev/null +++ b/tests/components/ista_ecotrend/test_config_flow.py @@ -0,0 +1,192 @@ +"""Test the ista EcoTrend config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyecotrend_ista import LoginError, ServerError +import pytest + +from homeassistant.components.ista_ecotrend.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.mark.usefixtures("mock_ista") +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": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Max Istamann" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (LoginError(None), "invalid_auth"), + (ServerError, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_form_invalid_auth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_ista: MagicMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_ista.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_ista.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Max Istamann" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth( + hass: HomeAssistant, + ista_config_entry: AsyncMock, + mock_ista: MagicMock, +) -> None: + """Test reauth flow.""" + + ista_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": ista_config_entry.entry_id, + "unique_id": ista_config_entry.unique_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert ista_config_entry.data == { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + } + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (LoginError(None), "invalid_auth"), + (ServerError, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_reauth_error_and_recover( + hass: HomeAssistant, + ista_config_entry: AsyncMock, + mock_ista: MagicMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test reauth flow.""" + + ista_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": ista_config_entry.entry_id, + "unique_id": ista_config_entry.unique_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_ista.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_ista.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert ista_config_entry.data == { + CONF_EMAIL: "new@example.com", + CONF_PASSWORD: "new-password", + } + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/ista_ecotrend/test_init.py b/tests/components/ista_ecotrend/test_init.py new file mode 100644 index 00000000000..a15e4577252 --- /dev/null +++ b/tests/components/ista_ecotrend/test_init.py @@ -0,0 +1,90 @@ +"""Test the ista EcoTrend init.""" + +from unittest.mock import MagicMock + +from pyecotrend_ista import KeycloakError, LoginError, ParserError, ServerError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_ista") +async def test_entry_setup_unload( + hass: HomeAssistant, ista_config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("side_effect"), + [ServerError, ParserError], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + side_effect: Exception, +) -> None: + """Test config entry not ready.""" + mock_ista.login.side_effect = side_effect + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("side_effect"), + [LoginError, KeycloakError], +) +async def test_config_entry_auth_failed( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + mock_ista: MagicMock, + side_effect: Exception, +) -> None: + """Test config entry not ready.""" + mock_ista.login.side_effect = side_effect + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(ista_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +@pytest.mark.usefixtures("mock_ista") +async def test_device_registry( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device registry.""" + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + + for device in dr.async_entries_for_config_entry( + device_registry, ista_config_entry.entry_id + ): + assert device == snapshot diff --git a/tests/components/ista_ecotrend/test_sensor.py b/tests/components/ista_ecotrend/test_sensor.py new file mode 100644 index 00000000000..82a15872b59 --- /dev/null +++ b/tests/components/ista_ecotrend/test_sensor.py @@ -0,0 +1,28 @@ +"""Tests for the ista EcoTrend Sensors.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_ista", "entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + ista_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of ista EcoTrend sensor platform.""" + + ista_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(ista_config_entry.entry_id) + await hass.async_block_till_done() + + assert ista_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, ista_config_entry.entry_id) diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py new file mode 100644 index 00000000000..616abdea8d6 --- /dev/null +++ b/tests/components/ista_ecotrend/test_util.py @@ -0,0 +1,146 @@ +"""Tests for the ista EcoTrend utility functions.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ista_ecotrend.util import ( + IstaConsumptionType, + IstaValueType, + as_number, + get_native_value, + get_statistics, + get_values_by_type, + last_day_of_month, +) + +from .conftest import get_consumption_data + + +def test_as_number() -> None: + """Test as_number formatting function.""" + assert as_number("10") == 10 + assert isinstance(as_number("10"), int) + + assert as_number("9,5") == 9.5 + assert isinstance(as_number("9,5"), float) + + assert as_number(None) is None + assert isinstance(as_number(10.0), float) + + +def test_last_day_of_month(snapshot: SnapshotAssertion) -> None: + """Test determining last day of month.""" + + for month in range(12): + assert last_day_of_month(month=month + 1, year=2024) == snapshot + + +def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: + """Test get_values_by_type function.""" + consumptions = { + "readings": [ + { + "type": "heating", + "value": "35", + "additionalValue": "38,0", + }, + { + "type": "warmwater", + "value": "1,0", + "additionalValue": "57,0", + }, + { + "type": "water", + "value": "5,0", + }, + ], + } + + assert get_values_by_type(consumptions, IstaConsumptionType.HEATING) == snapshot + assert get_values_by_type(consumptions, IstaConsumptionType.HOT_WATER) == snapshot + assert get_values_by_type(consumptions, IstaConsumptionType.WATER) == snapshot + + costs = { + "costsByEnergyType": [ + { + "type": "heating", + "value": 21, + }, + { + "type": "warmwater", + "value": 7, + }, + { + "type": "water", + "value": 3, + }, + ], + } + + assert get_values_by_type(costs, IstaConsumptionType.HEATING) == snapshot + assert get_values_by_type(costs, IstaConsumptionType.HOT_WATER) == snapshot + assert get_values_by_type(costs, IstaConsumptionType.WATER) == snapshot + + assert get_values_by_type({}, IstaConsumptionType.HEATING) == {} + assert get_values_by_type({"readings": []}, IstaConsumptionType.HEATING) == {} + + +def test_get_native_value() -> None: + """Test getting native value for sensor states.""" + test_data = get_consumption_data("26e93f1a-c828-11ea-87d0-0242ac130003") + + assert get_native_value(test_data, IstaConsumptionType.HEATING) == 35 + assert get_native_value(test_data, IstaConsumptionType.HOT_WATER) == 1.0 + assert get_native_value(test_data, IstaConsumptionType.WATER) == 5.0 + + assert ( + get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) + == 21 + ) + assert ( + get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.COSTS) + == 7 + ) + assert ( + get_native_value(test_data, IstaConsumptionType.WATER, IstaValueType.COSTS) == 3 + ) + + assert ( + get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.ENERGY) + == 38.0 + ) + assert ( + get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.ENERGY) + == 57.0 + ) + + no_data = {"consumptions": None, "costs": None} + assert get_native_value(no_data, IstaConsumptionType.HEATING) is None + assert ( + get_native_value(no_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) + is None + ) + + +def test_get_statistics(snapshot: SnapshotAssertion) -> None: + """Test get_statistics function.""" + test_data = get_consumption_data("26e93f1a-c828-11ea-87d0-0242ac130003") + for consumption_type in IstaConsumptionType: + assert get_statistics(test_data, consumption_type) == snapshot + assert get_statistics({"consumptions": None}, consumption_type) is None + assert ( + get_statistics(test_data, consumption_type, IstaValueType.ENERGY) + == snapshot + ) + assert ( + get_statistics( + {"consumptions": None}, consumption_type, IstaValueType.ENERGY + ) + is None + ) + assert ( + get_statistics(test_data, consumption_type, IstaValueType.COSTS) == snapshot + ) + assert ( + get_statistics({"costs": None}, consumption_type, IstaValueType.COSTS) + is None + ) diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 9f668e1ec62..6591e402ec2 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant import config_entries from homeassistant.components.izone.const import DISPATCH_CONTROLLER_DISCOVERED, IZONE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.dispatcher import async_dispatcher_send @pytest.fixture @@ -20,8 +21,6 @@ def mock_disco(): def _mock_start_discovery(hass, mock_disco): - from homeassistant.helpers.dispatcher import async_dispatcher_send - def do_disovered(*args): async_dispatcher_send(hass, DISPATCH_CONTROLLER_DISCOVERED, True) return mock_disco diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py index ea46c669af7..40d03212ceb 100644 --- a/tests/components/jellyfin/conftest.py +++ b/tests/components/jellyfin/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from jellyfin_apiclient_python import JellyfinClient @@ -10,6 +9,7 @@ from jellyfin_apiclient_python.api import API from jellyfin_apiclient_python.configuration import Config from jellyfin_apiclient_python.connection_manager import ConnectionManager import pytest +from typing_extensions import Generator from homeassistant.components.jellyfin.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -37,7 +37,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.jellyfin.async_setup_entry", return_value=True @@ -46,7 +46,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_client_device_id() -> Generator[None, MagicMock, None]: +def mock_client_device_id() -> Generator[MagicMock]: """Mock generating device id.""" with patch( "homeassistant.components.jellyfin.config_flow._generate_client_device_id" @@ -108,7 +108,7 @@ def mock_client( @pytest.fixture -def mock_jellyfin(mock_client: MagicMock) -> Generator[None, MagicMock, None]: +def mock_jellyfin(mock_client: MagicMock) -> Generator[MagicMock]: """Return a mocked Jellyfin.""" with patch( "homeassistant.components.jellyfin.client_wrapper.Jellyfin", autospec=True @@ -144,6 +144,8 @@ def api_artwork_side_effect(*args, **kwargs): def api_audio_url_side_effect(*args, **kwargs): """Handle variable responses for audio_url method.""" item_id = args[0] + if audio_codec := kwargs.get("audio_codec"): + return f"http://localhost/Audio/{item_id}/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec={audio_codec}" return f"http://localhost/Audio/{item_id}/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000" diff --git a/tests/components/jellyfin/snapshots/test_media_source.ambr b/tests/components/jellyfin/snapshots/test_media_source.ambr index 6d629f245a0..6f46aaf3f9b 100644 --- a/tests/components/jellyfin/snapshots/test_media_source.ambr +++ b/tests/components/jellyfin/snapshots/test_media_source.ambr @@ -1,4 +1,16 @@ # serializer version: 1 +# name: test_audio_codec_resolve[aac] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=aac' +# --- +# name: test_audio_codec_resolve[mp3] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=mp3' +# --- +# name: test_audio_codec_resolve[vorbis] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=vorbis' +# --- +# name: test_audio_codec_resolve[wma] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=wma' +# --- # name: test_movie_library dict({ 'can_expand': False, diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index b55766c2c68..c84a12d26a5 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -3,9 +3,14 @@ from unittest.mock import MagicMock import pytest +from voluptuous.error import Invalid from homeassistant import config_entries -from homeassistant.components.jellyfin.const import CONF_CLIENT_DEVICE_ID, DOMAIN +from homeassistant.components.jellyfin.const import ( + CONF_AUDIO_CODEC, + CONF_CLIENT_DEVICE_ID, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -435,3 +440,57 @@ async def test_reauth_exception( ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" + + +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + assert config_entry.options == {} + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + # Audio Codec + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert CONF_AUDIO_CODEC not in config_entry.options + + # Bad + result = await hass.config_entries.options.async_init(config_entry.entry_id) + with pytest.raises(Invalid): + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_AUDIO_CODEC: "ogg"} + ) + + +@pytest.mark.parametrize( + "codec", + [("aac"), ("wma"), ("vorbis"), ("mp3")], +) +async def test_setting_codec( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + codec: str, +) -> None: + """Test setting the audio_codec.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_AUDIO_CODEC: codec} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert config_entry.options[CONF_AUDIO_CODEC] == codec diff --git a/tests/components/jellyfin/test_media_source.py b/tests/components/jellyfin/test_media_source.py index b8bbfea00d9..a57d51de1f1 100644 --- a/tests/components/jellyfin/test_media_source.py +++ b/tests/components/jellyfin/test_media_source.py @@ -48,6 +48,10 @@ async def test_resolve( assert play_media.mime_type == "audio/flac" assert play_media.url == snapshot + mock_api.audio_url.assert_called_with("TRACK-UUID") + assert mock_api.audio_url.call_count == 1 + mock_api.audio_url.reset_mock() + # Test resolving a movie mock_api.get_item.side_effect = None mock_api.get_item.return_value = load_json_fixture("movie.json") @@ -71,6 +75,42 @@ async def test_resolve( ) +@pytest.mark.parametrize( + "audio_codec", + [("aac"), ("wma"), ("vorbis"), ("mp3")], +) +async def test_audio_codec_resolve( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, + snapshot: SnapshotAssertion, + audio_codec: str, +) -> None: + """Test resolving Jellyfin media items with audio codec.""" + + # Test resolving a track + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("track.json") + + result = await hass.config_entries.options.async_init(init_integration.entry_id) + await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"audio_codec": audio_codec} + ) + assert init_integration.options["audio_codec"] == audio_codec + + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/TRACK-UUID", "media_player.jellyfin_device" + ) + + assert play_media.mime_type == "audio/flac" + assert play_media.url == snapshot + + mock_api.audio_url.assert_called_with("TRACK-UUID", audio_codec=audio_codec) + assert mock_api.audio_url.call_count == 1 + + async def test_root( hass: HomeAssistant, mock_client: MagicMock, diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index e1352f789ac..60726fc3a3e 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -27,7 +27,7 @@ def make_nyc_test_params(dtime, results, havdalah_offset=0): } return ( dtime, - jewish_calendar.CANDLE_LIGHT_DEFAULT, + jewish_calendar.DEFAULT_CANDLE_LIGHT, havdalah_offset, True, "America/New_York", @@ -49,7 +49,7 @@ def make_jerusalem_test_params(dtime, results, havdalah_offset=0): } return ( dtime, - jewish_calendar.CANDLE_LIGHT_DEFAULT, + jewish_calendar.DEFAULT_CANDLE_LIGHT, havdalah_offset, False, "Asia/Jerusalem", diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py new file mode 100644 index 00000000000..5e16289f473 --- /dev/null +++ b/tests/components/jewish_calendar/conftest.py @@ -0,0 +1,28 @@ +"""Common fixtures for the jewish_calendar tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.jewish_calendar.const import DEFAULT_NAME, DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=DEFAULT_NAME, + domain=DOMAIN, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.jewish_calendar.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index bced831462a..b60e7698266 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -1,25 +1,28 @@ """The tests for the Jewish calendar binary sensors.""" from datetime import datetime as dt, timedelta +import logging import pytest -from homeassistant.components import jewish_calendar from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from . import ( - HDATE_DEFAULT_ALTITUDE, - alter_time, - make_jerusalem_test_params, - make_nyc_test_params, -) +from . import alter_time, make_jerusalem_test_params, make_nyc_test_params + +from tests.common import MockConfigEntry, async_fire_time_changed + +_LOGGER = logging.getLogger(__name__) -from tests.common import async_fire_time_changed MELACHA_PARAMS = [ make_nyc_test_params( @@ -170,7 +173,6 @@ MELACHA_TEST_IDS = [ ) async def test_issur_melacha_sensor( hass: HomeAssistant, - entity_registry: er.EntityRegistry, now, candle_lighting, havdalah, @@ -184,54 +186,38 @@ async def test_issur_melacha_sensor( time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.set_time_zone(tzname) + await hass.config.async_set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude with alter_time(test_time): - assert await async_setup_component( - hass, - jewish_calendar.DOMAIN, - { - "jewish_calendar": { - "name": "test", - "language": "english", - "diaspora": diaspora, - "candle_lighting_minutes_before_sunset": candle_lighting, - "havdalah_minutes_after_sunset": havdalah, - } + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LANGUAGE: "english", + CONF_DIASPORA: diaspora, + CONF_CANDLE_LIGHT_MINUTES: candle_lighting, + CONF_HAVDALAH_OFFSET_MINUTES: havdalah, }, ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.test_issur_melacha_in_effect").state + hass.states.get( + "binary_sensor.jewish_calendar_issur_melacha_in_effect" + ).state == result["state"] ) - entity = entity_registry.async_get("binary_sensor.test_issur_melacha_in_effect") - target_uid = "_".join( - map( - str, - [ - latitude, - longitude, - tzname, - HDATE_DEFAULT_ALTITUDE, - diaspora, - "english", - candle_lighting, - havdalah, - "issur_melacha_in_effect", - ], - ) - ) - assert entity.unique_id == target_uid with alter_time(result["update"]): async_fire_time_changed(hass, result["update"]) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.test_issur_melacha_in_effect").state + hass.states.get( + "binary_sensor.jewish_calendar_issur_melacha_in_effect" + ).state == result["new_state"] ) @@ -272,27 +258,27 @@ async def test_issur_melacha_sensor_update( time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.set_time_zone(tzname) + await hass.config.async_set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude with alter_time(test_time): - assert await async_setup_component( - hass, - jewish_calendar.DOMAIN, - { - "jewish_calendar": { - "name": "test", - "language": "english", - "diaspora": diaspora, - "candle_lighting_minutes_before_sunset": candle_lighting, - "havdalah_minutes_after_sunset": havdalah, - } + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LANGUAGE: "english", + CONF_DIASPORA: diaspora, + CONF_CANDLE_LIGHT_MINUTES: candle_lighting, + CONF_HAVDALAH_OFFSET_MINUTES: havdalah, }, ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.test_issur_melacha_in_effect").state + hass.states.get( + "binary_sensor.jewish_calendar_issur_melacha_in_effect" + ).state == result[0] ) @@ -301,7 +287,9 @@ async def test_issur_melacha_sensor_update( async_fire_time_changed(hass, test_time) await hass.async_block_till_done() assert ( - hass.states.get("binary_sensor.test_issur_melacha_in_effect").state + hass.states.get( + "binary_sensor.jewish_calendar_issur_melacha_in_effect" + ).state == result[1] ) @@ -314,7 +302,7 @@ async def test_no_discovery_info( assert await async_setup_component( hass, BINARY_SENSOR_DOMAIN, - {BINARY_SENSOR_DOMAIN: {"platform": jewish_calendar.DOMAIN}}, + {BINARY_SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert BINARY_SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py new file mode 100644 index 00000000000..3189571a5a7 --- /dev/null +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -0,0 +1,140 @@ +"""Test the Jewish calendar config flow.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_DIASPORA, + DEFAULT_LANGUAGE, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LANGUAGE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_TIME_ZONE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test user config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DIASPORA: DEFAULT_DIASPORA, CONF_LANGUAGE: DEFAULT_LANGUAGE}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data[CONF_DIASPORA] == DEFAULT_DIASPORA + assert entries[0].data[CONF_LANGUAGE] == DEFAULT_LANGUAGE + assert entries[0].data[CONF_LATITUDE] == hass.config.latitude + assert entries[0].data[CONF_LONGITUDE] == hass.config.longitude + assert entries[0].data[CONF_ELEVATION] == hass.config.elevation + assert entries[0].data[CONF_TIME_ZONE] == hass.config.time_zone + + +@pytest.mark.parametrize("diaspora", [True, False]) +@pytest.mark.parametrize("language", ["hebrew", "english"]) +async def test_import_no_options(hass: HomeAssistant, language, diaspora) -> None: + """Test that the import step works.""" + conf = { + DOMAIN: {CONF_NAME: "test", CONF_LANGUAGE: language, CONF_DIASPORA: diaspora} + } + + assert await async_setup_component(hass, DOMAIN, conf.copy()) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + for entry_key, entry_val in entries[0].data.items(): + assert entry_val == conf[DOMAIN][entry_key] + + +async def test_import_with_options(hass: HomeAssistant) -> None: + """Test that the import step works.""" + conf = { + DOMAIN: { + CONF_NAME: "test", + CONF_DIASPORA: DEFAULT_DIASPORA, + CONF_LANGUAGE: DEFAULT_LANGUAGE, + CONF_CANDLE_LIGHT_MINUTES: 20, + CONF_HAVDALAH_OFFSET_MINUTES: 50, + CONF_LATITUDE: 31.76, + CONF_LONGITUDE: 35.235, + } + } + + # Simulate HomeAssistant setting up the component + assert await async_setup_component(hass, DOMAIN, conf.copy()) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + for entry_key, entry_val in entries[0].data.items(): + assert entry_val == conf[DOMAIN][entry_key] + for entry_key, entry_val in entries[0].options.items(): + assert entry_val == conf[DOMAIN][entry_key] + + +async def test_single_instance_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if already setup.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Test updating options.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CANDLE_LIGHT_MINUTES: 25, + CONF_HAVDALAH_OFFSET_MINUTES: 34, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].options[CONF_CANDLE_LIGHT_MINUTES] == 25 + assert entries[0].options[CONF_HAVDALAH_OFFSET_MINUTES] == 34 diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py new file mode 100644 index 00000000000..b8454b41a60 --- /dev/null +++ b/tests/components/jewish_calendar/test_init.py @@ -0,0 +1,76 @@ +"""Tests for the Jewish Calendar component's init.""" + +from hdate import Location + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSORS +from homeassistant.components.jewish_calendar import get_unique_prefix +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_DIASPORA, + DEFAULT_LANGUAGE, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + + +async def test_import_unique_id_migration(hass: HomeAssistant) -> None: + """Test unique_id migration.""" + yaml_conf = { + DOMAIN: { + CONF_NAME: "test", + CONF_DIASPORA: DEFAULT_DIASPORA, + CONF_LANGUAGE: DEFAULT_LANGUAGE, + CONF_CANDLE_LIGHT_MINUTES: 20, + CONF_HAVDALAH_OFFSET_MINUTES: 50, + CONF_LATITUDE: 31.76, + CONF_LONGITUDE: 35.235, + } + } + + # Create an entry in the entity registry with the data from conf + ent_reg = er.async_get(hass) + location = Location( + latitude=yaml_conf[DOMAIN][CONF_LATITUDE], + longitude=yaml_conf[DOMAIN][CONF_LONGITUDE], + timezone=hass.config.time_zone, + diaspora=DEFAULT_DIASPORA, + ) + old_prefix = get_unique_prefix(location, DEFAULT_LANGUAGE, 20, 50) + sample_entity = ent_reg.async_get_or_create( + BINARY_SENSORS, + DOMAIN, + unique_id=f"{old_prefix}_erev_shabbat_hag", + suggested_object_id=f"{DOMAIN}_erev_shabbat_hag", + ) + # Save the existing unique_id, DEFAULT_LANGUAGE should be part of it + old_unique_id = sample_entity.unique_id + assert DEFAULT_LANGUAGE in old_unique_id + + # Simulate HomeAssistant setting up the component + assert await async_setup_component(hass, DOMAIN, yaml_conf.copy()) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + for entry_key, entry_val in entries[0].data.items(): + assert entry_val == yaml_conf[DOMAIN][entry_key] + for entry_key, entry_val in entries[0].options.items(): + assert entry_val == yaml_conf[DOMAIN][entry_key] + + # Assert that the unique_id was updated + new_unique_id = ent_reg.async_get(sample_entity.entity_id).unique_id + assert new_unique_id != old_unique_id + assert DEFAULT_LANGUAGE not in new_unique_id + + # Confirm that when the component is reloaded, the unique_id is not changed + assert ent_reg.async_get(sample_entity.entity_id).unique_id == new_unique_id + + # Confirm that all the unique_ids are prefixed correctly + await hass.config_entries.async_reload(entries[0].entry_id) + er_entries = er.async_entries_for_config_entry(ent_reg, entries[0].entry_id) + assert all(entry.unique_id.startswith(entries[0].entry_id) for entry in er_entries) diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index d9f43236965..509e17017d5 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -2,45 +2,56 @@ from datetime import datetime as dt, timedelta +from hdate import htables import pytest -from homeassistant.components import jewish_calendar from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from . import ( - HDATE_DEFAULT_ALTITUDE, - alter_time, - make_jerusalem_test_params, - make_nyc_test_params, -) +from . import alter_time, make_jerusalem_test_params, make_nyc_test_params -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_jewish_calendar_min_config(hass: HomeAssistant) -> None: """Test minimum jewish calendar configuration.""" - assert await async_setup_component( - hass, jewish_calendar.DOMAIN, {"jewish_calendar": {}} - ) + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert hass.states.get("sensor.jewish_calendar_date") is not None async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None: """Test jewish calendar sensor with language set to hebrew.""" - assert await async_setup_component( - hass, jewish_calendar.DOMAIN, {"jewish_calendar": {"language": "hebrew"}} - ) + entry = MockConfigEntry(domain=DOMAIN, data={"language": "hebrew"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert hass.states.get("sensor.jewish_calendar_date") is not None TEST_PARAMS = [ - (dt(2018, 9, 3), "UTC", 31.778, 35.235, "english", "date", False, "23 Elul 5778"), + ( + dt(2018, 9, 3), + "UTC", + 31.778, + 35.235, + "english", + "date", + False, + "23 Elul 5778", + None, + ), ( dt(2018, 9, 3), "UTC", @@ -50,8 +61,19 @@ TEST_PARAMS = [ "date", False, 'כ"ג אלול ה\' תשע"ח', + None, + ), + ( + dt(2018, 9, 10), + "UTC", + 31.778, + 35.235, + "hebrew", + "holiday", + False, + "א' ראש השנה", + None, ), - (dt(2018, 9, 10), "UTC", 31.778, 35.235, "hebrew", "holiday", False, "א' ראש השנה"), ( dt(2018, 9, 10), "UTC", @@ -61,6 +83,15 @@ TEST_PARAMS = [ "holiday", False, "Rosh Hashana I", + { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "rosh_hashana_i", + "type": "YOM_TOV", + "type_id": 1, + "options": [h.description.english for h in htables.HOLIDAYS], + }, ), ( dt(2018, 9, 8), @@ -71,6 +102,12 @@ TEST_PARAMS = [ "parshat_hashavua", False, "נצבים", + { + "device_class": "enum", + "friendly_name": "Jewish Calendar Parshat Hashavua", + "icon": "mdi:book-open-variant", + "options": [p.hebrew for p in htables.PARASHAOT], + }, ), ( dt(2018, 9, 8), @@ -81,6 +118,7 @@ TEST_PARAMS = [ "t_set_hakochavim", True, dt(2018, 9, 8, 19, 45), + None, ), ( dt(2018, 9, 8), @@ -91,6 +129,7 @@ TEST_PARAMS = [ "t_set_hakochavim", False, dt(2018, 9, 8, 19, 19), + None, ), ( dt(2018, 10, 14), @@ -101,6 +140,7 @@ TEST_PARAMS = [ "parshat_hashavua", False, "לך לך", + None, ), ( dt(2018, 10, 14, 17, 0, 0), @@ -111,6 +151,7 @@ TEST_PARAMS = [ "date", False, "ה' מרחשוון ה' תשע\"ט", + None, ), ( dt(2018, 10, 14, 19, 0, 0), @@ -121,6 +162,13 @@ TEST_PARAMS = [ "date", False, "ו' מרחשוון ה' תשע\"ט", + { + "hebrew_year": 5779, + "hebrew_month_name": "מרחשוון", + "hebrew_day": 6, + "icon": "mdi:star-david", + "friendly_name": "Jewish Calendar Date", + }, ), ] @@ -148,6 +196,7 @@ TEST_IDS = [ "sensor", "diaspora", "result", + "attrs", ), TEST_PARAMS, ids=TEST_IDS, @@ -162,27 +211,26 @@ async def test_jewish_calendar_sensor( sensor, diaspora, result, + attrs, ) -> None: """Test Jewish calendar sensor output.""" time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.set_time_zone(tzname) + await hass.config.async_set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude with alter_time(test_time): - assert await async_setup_component( - hass, - jewish_calendar.DOMAIN, - { - "jewish_calendar": { - "name": "test", - "language": language, - "diaspora": diaspora, - } + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LANGUAGE: language, + CONF_DIASPORA: diaspora, }, ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() future = dt_util.utcnow() + timedelta(seconds=30) @@ -195,13 +243,11 @@ async def test_jewish_calendar_sensor( else result ) - sensor_object = hass.states.get(f"sensor.test_{sensor}") + sensor_object = hass.states.get(f"sensor.jewish_calendar_{sensor}") assert sensor_object.state == result - if sensor == "holiday": - assert sensor_object.attributes.get("id") == "rosh_hashana_i" - assert sensor_object.attributes.get("type") == "YOM_TOV" - assert sensor_object.attributes.get("type_id") == 1 + if attrs: + assert sensor_object.attributes == attrs SHABBAT_PARAMS = [ @@ -497,7 +543,6 @@ SHABBAT_TEST_IDS = [ ) async def test_shabbat_times_sensor( hass: HomeAssistant, - entity_registry: er.EntityRegistry, language, now, candle_lighting, @@ -512,24 +557,24 @@ async def test_shabbat_times_sensor( time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.set_time_zone(tzname) + await hass.config.async_set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude with alter_time(test_time): - assert await async_setup_component( - hass, - jewish_calendar.DOMAIN, - { - "jewish_calendar": { - "name": "test", - "language": language, - "diaspora": diaspora, - "candle_lighting_minutes_before_sunset": candle_lighting, - "havdalah_minutes_after_sunset": havdalah, - } + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LANGUAGE: language, + CONF_DIASPORA: diaspora, + }, + options={ + CONF_CANDLE_LIGHT_MINUTES: candle_lighting, + CONF_HAVDALAH_OFFSET_MINUTES: havdalah, }, ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() future = dt_util.utcnow() + timedelta(seconds=30) @@ -548,30 +593,10 @@ async def test_shabbat_times_sensor( else result_value ) - assert hass.states.get(f"sensor.test_{sensor_type}").state == str( + assert hass.states.get(f"sensor.jewish_calendar_{sensor_type}").state == str( result_value ), f"Value for {sensor_type}" - entity = entity_registry.async_get(f"sensor.test_{sensor_type}") - target_sensor_type = sensor_type.replace("parshat_hashavua", "weekly_portion") - target_uid = "_".join( - map( - str, - [ - latitude, - longitude, - tzname, - HDATE_DEFAULT_ALTITUDE, - diaspora, - language, - candle_lighting, - havdalah, - target_sensor_type, - ], - ) - ) - assert entity.unique_id == target_uid - OMER_PARAMS = [ (dt(2019, 4, 21, 0), "1"), @@ -597,16 +622,16 @@ async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None: test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) with alter_time(test_time): - assert await async_setup_component( - hass, jewish_calendar.DOMAIN, {"jewish_calendar": {"name": "test"}} - ) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() future = dt_util.utcnow() + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert hass.states.get("sensor.test_day_of_the_omer").state == result + assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == result DAFYOMI_PARAMS = [ @@ -631,16 +656,16 @@ async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None: test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) with alter_time(test_time): - assert await async_setup_component( - hass, jewish_calendar.DOMAIN, {"jewish_calendar": {"name": "test"}} - ) + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() future = dt_util.utcnow() + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert hass.states.get("sensor.test_daf_yomi").state == result + assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == result async def test_no_discovery_info( @@ -651,7 +676,7 @@ async def test_no_discovery_info( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {"platform": jewish_calendar.DOMAIN}}, + {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jvc_projector/conftest.py b/tests/components/jvc_projector/conftest.py index 10603e8ae39..dd012d3f355 100644 --- a/tests/components/jvc_projector/conftest.py +++ b/tests/components/jvc_projector/conftest.py @@ -1,9 +1,9 @@ """Fixtures for JVC Projector integration.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.jvc_projector.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT @@ -15,7 +15,9 @@ from tests.common import MockConfigEntry @pytest.fixture(name="mock_device") -def fixture_mock_device(request) -> Generator[None, AsyncMock, None]: +def fixture_mock_device( + request: pytest.FixtureRequest, +) -> Generator[MagicMock]: """Return a mocked JVC Projector device.""" target = "homeassistant.components.jvc_projector.JvcProjector" if hasattr(request, "param"): diff --git a/tests/components/kaleidescape/conftest.py b/tests/components/kaleidescape/conftest.py index c86d8f2ccd0..5cd2a8ebb18 100644 --- a/tests/components/kaleidescape/conftest.py +++ b/tests/components/kaleidescape/conftest.py @@ -1,11 +1,11 @@ """Fixtures for Kaleidescape integration.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import MagicMock, patch from kaleidescape import Dispatcher from kaleidescape.device import Automation, Movie, Power, System import pytest +from typing_extensions import Generator from homeassistant.components.kaleidescape.const import DOMAIN from homeassistant.const import CONF_HOST @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry @pytest.fixture(name="mock_device") -def fixture_mock_device() -> Generator[None, AsyncMock, None]: +def fixture_mock_device() -> Generator[MagicMock]: """Return a mocked Kaleidescape device.""" with patch( "homeassistant.components.kaleidescape.KaleidescapeDevice", autospec=True @@ -64,6 +64,7 @@ def fixture_mock_config_entry() -> MockConfigEntry: @pytest.fixture(name="mock_integration") async def fixture_mock_integration( hass: HomeAssistant, + mock_device: MagicMock, mock_config_entry: MockConfigEntry, ) -> MockConfigEntry: """Return a mock ConfigEntry setup for Kaleidescape integration.""" diff --git a/tests/components/kaleidescape/test_config_flow.py b/tests/components/kaleidescape/test_config_flow.py index 5d9f8dba146..ecb5b164093 100644 --- a/tests/components/kaleidescape/test_config_flow.py +++ b/tests/components/kaleidescape/test_config_flow.py @@ -1,7 +1,9 @@ """Tests for Kaleidescape config flow.""" import dataclasses -from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest from homeassistant.components.kaleidescape.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER @@ -11,12 +13,9 @@ from homeassistant.data_entry_flow import FlowResultType from . import MOCK_HOST, MOCK_SSDP_DISCOVERY_INFO -from tests.common import MockConfigEntry - -async def test_user_config_flow_success( - hass: HomeAssistant, mock_device: AsyncMock -) -> None: +@pytest.mark.usefixtures("mock_device") +async def test_user_config_flow_success(hass: HomeAssistant) -> None: """Test user config flow success.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -35,7 +34,7 @@ async def test_user_config_flow_success( async def test_user_config_flow_bad_connect_errors( - hass: HomeAssistant, mock_device: AsyncMock + hass: HomeAssistant, mock_device: MagicMock ) -> None: """Test errors when connection error occurs.""" mock_device.connect.side_effect = ConnectionError @@ -50,7 +49,7 @@ async def test_user_config_flow_bad_connect_errors( async def test_user_config_flow_unsupported_device_errors( - hass: HomeAssistant, mock_device: AsyncMock + hass: HomeAssistant, mock_device: MagicMock ) -> None: """Test errors when connecting to unsupported device.""" mock_device.is_server_only = True @@ -64,9 +63,8 @@ async def test_user_config_flow_unsupported_device_errors( assert result["errors"] == {"base": "unsupported"} -async def test_user_config_flow_device_exists_abort( - hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry -) -> None: +@pytest.mark.usefixtures("mock_device", "mock_integration") +async def test_user_config_flow_device_exists_abort(hass: HomeAssistant) -> None: """Test flow aborts when device already configured.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: MOCK_HOST} @@ -75,9 +73,8 @@ async def test_user_config_flow_device_exists_abort( assert result["reason"] == "already_configured" -async def test_ssdp_config_flow_success( - hass: HomeAssistant, mock_device: AsyncMock -) -> None: +@pytest.mark.usefixtures("mock_device") +async def test_ssdp_config_flow_success(hass: HomeAssistant) -> None: """Test ssdp config flow success.""" discovery_info = dataclasses.replace(MOCK_SSDP_DISCOVERY_INFO) result = await hass.config_entries.flow.async_init( @@ -97,7 +94,7 @@ async def test_ssdp_config_flow_success( async def test_ssdp_config_flow_bad_connect_aborts( - hass: HomeAssistant, mock_device: AsyncMock + hass: HomeAssistant, mock_device: MagicMock ) -> None: """Test abort when connection error occurs.""" mock_device.connect.side_effect = ConnectionError @@ -112,7 +109,7 @@ async def test_ssdp_config_flow_bad_connect_aborts( async def test_ssdp_config_flow_unsupported_device_aborts( - hass: HomeAssistant, mock_device: AsyncMock + hass: HomeAssistant, mock_device: MagicMock ) -> None: """Test abort when connecting to unsupported device.""" mock_device.is_server_only = True diff --git a/tests/components/kaleidescape/test_init.py b/tests/components/kaleidescape/test_init.py index 28d90290996..01769b9fc57 100644 --- a/tests/components/kaleidescape/test_init.py +++ b/tests/components/kaleidescape/test_init.py @@ -1,6 +1,8 @@ """Tests for Kaleidescape config entry.""" -from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest from homeassistant.components.kaleidescape.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -14,7 +16,7 @@ from tests.common import MockConfigEntry async def test_unload_config_entry( hass: HomeAssistant, - mock_device: AsyncMock, + mock_device: MagicMock, mock_integration: MockConfigEntry, ) -> None: """Test config entry loading and unloading.""" @@ -32,7 +34,7 @@ async def test_unload_config_entry( async def test_config_entry_not_ready( hass: HomeAssistant, - mock_device: AsyncMock, + mock_device: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test config entry not ready.""" @@ -45,12 +47,8 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_device( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_device: AsyncMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_device", "mock_integration") +async def test_device(device_registry: dr.DeviceRegistry) -> None: """Test device.""" device = device_registry.async_get_device( identifiers={("kaleidescape", MOCK_SERIAL)} diff --git a/tests/components/kaleidescape/test_media_player.py b/tests/components/kaleidescape/test_media_player.py index ad7dcbcaa51..2180a6b7d0d 100644 --- a/tests/components/kaleidescape/test_media_player.py +++ b/tests/components/kaleidescape/test_media_player.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from kaleidescape import const as kaleidescape_const from kaleidescape.device import Movie +import pytest from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.const import ( @@ -25,17 +26,12 @@ from homeassistant.helpers import device_registry as dr from . import MOCK_SERIAL -from tests.common import MockConfigEntry - ENTITY_ID = f"media_player.kaleidescape_device_{MOCK_SERIAL}" FRIENDLY_NAME = f"Kaleidescape Device {MOCK_SERIAL}" -async def test_entity( - hass: HomeAssistant, - mock_device: MagicMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_device", "mock_integration") +async def test_entity(hass: HomeAssistant) -> None: """Test entity attributes.""" entity = hass.states.get(ENTITY_ID) assert entity is not None @@ -43,11 +39,8 @@ async def test_entity( assert entity.attributes["friendly_name"] == FRIENDLY_NAME -async def test_update_state( - hass: HomeAssistant, - mock_device: MagicMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_integration") +async def test_update_state(hass: HomeAssistant, mock_device: MagicMock) -> None: """Tests dispatched signals update player.""" entity = hass.states.get(ENTITY_ID) assert entity is not None @@ -105,11 +98,8 @@ async def test_update_state( assert entity.state == STATE_PAUSED -async def test_services( - hass: HomeAssistant, - mock_device: MagicMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_integration") +async def test_services(hass: HomeAssistant, mock_device: MagicMock) -> None: """Test service calls.""" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -168,12 +158,8 @@ async def test_services( assert mock_device.previous.call_count == 1 -async def test_device( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_device: MagicMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_device", "mock_integration") +async def test_device(device_registry: dr.DeviceRegistry) -> None: """Test device attributes.""" device = device_registry.async_get_device( identifiers={("kaleidescape", MOCK_SERIAL)} diff --git a/tests/components/kaleidescape/test_remote.py b/tests/components/kaleidescape/test_remote.py index 3573d04395d..a1db5a60999 100644 --- a/tests/components/kaleidescape/test_remote.py +++ b/tests/components/kaleidescape/test_remote.py @@ -15,25 +15,17 @@ from homeassistant.exceptions import HomeAssistantError from . import MOCK_SERIAL -from tests.common import MockConfigEntry - ENTITY_ID = f"remote.kaleidescape_device_{MOCK_SERIAL}" -async def test_entity( - hass: HomeAssistant, - mock_device: MagicMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_device", "mock_integration") +async def test_entity(hass: HomeAssistant) -> None: """Test entity attributes.""" assert hass.states.get(ENTITY_ID) -async def test_commands( - hass: HomeAssistant, - mock_device: MagicMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_integration") +async def test_commands(hass: HomeAssistant, mock_device: MagicMock) -> None: """Test service calls.""" await hass.services.async_call( REMOTE_DOMAIN, @@ -140,11 +132,8 @@ async def test_commands( assert mock_device.menu_toggle.call_count == 1 -async def test_unknown_command( - hass: HomeAssistant, - mock_device: MagicMock, - mock_integration: MockConfigEntry, -) -> None: +@pytest.mark.usefixtures("mock_device", "mock_integration") +async def test_unknown_command(hass: HomeAssistant) -> None: """Test service calls.""" with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( diff --git a/tests/components/kaleidescape/test_sensor.py b/tests/components/kaleidescape/test_sensor.py index 70406872464..e68b065f4b8 100644 --- a/tests/components/kaleidescape/test_sensor.py +++ b/tests/components/kaleidescape/test_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from kaleidescape import const as kaleidescape_const +import pytest from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -10,17 +11,13 @@ from homeassistant.helpers import entity_registry as er from . import MOCK_SERIAL -from tests.common import MockConfigEntry - ENTITY_ID = f"sensor.kaleidescape_device_{MOCK_SERIAL}" FRIENDLY_NAME = f"Kaleidescape Device {MOCK_SERIAL}" +@pytest.mark.usefixtures("mock_integration") async def test_sensors( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_device: MagicMock, - mock_integration: MockConfigEntry, + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_device: MagicMock ) -> None: """Test sensors.""" entity = hass.states.get(f"{ENTITY_ID}_media_location") diff --git a/tests/components/kegtron/conftest.py b/tests/components/kegtron/conftest.py index 472cadddada..44728e0e5ce 100644 --- a/tests/components/kegtron/conftest.py +++ b/tests/components/kegtron/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/keymitt_ble/conftest.py b/tests/components/keymitt_ble/conftest.py index 3df082c4361..44f68a1c8ae 100644 --- a/tests/components/keymitt_ble/conftest.py +++ b/tests/components/keymitt_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/kira/test_init.py b/tests/components/kira/test_init.py index a200c25d2a3..e57519667ce 100644 --- a/tests/components/kira/test_init.py +++ b/tests/components/kira/test_init.py @@ -79,7 +79,7 @@ async def test_kira_creates_codes(work_dir) -> None: async def test_load_codes(work_dir) -> None: """Kira should ignore invalid codes.""" code_path = os.path.join(work_dir, "codes.yaml") - with open(code_path, "w") as code_file: + with open(code_path, "w", encoding="utf8") as code_file: code_file.write(KIRA_CODES) res = kira.load_codes(code_path) assert len(res) == 1, "Expected exactly 1 valid Kira code" diff --git a/tests/components/kitchen_sink/test_lock.py b/tests/components/kitchen_sink/test_lock.py index ad5e9b7515d..e86300a4d35 100644 --- a/tests/components/kitchen_sink/test_lock.py +++ b/tests/components/kitchen_sink/test_lock.py @@ -16,7 +16,12 @@ from homeassistant.components.lock import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_STATE_CHANGED, + STATE_OPEN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -103,4 +108,4 @@ async def test_opening(hass: HomeAssistant) -> None: LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True ) state = hass.states.get(OPENABLE_LOCK) - assert state.state == STATE_UNLOCKED + assert state.state == STATE_OPEN diff --git a/tests/components/kitchen_sink/test_notify.py b/tests/components/kitchen_sink/test_notify.py index 6d02bacb7be..df025087b6b 100644 --- a/tests/components/kitchen_sink/test_notify.py +++ b/tests/components/kitchen_sink/test_notify.py @@ -1,17 +1,17 @@ """The tests for the demo button component.""" -from collections.abc import AsyncGenerator from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from typing_extensions import AsyncGenerator from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.components.notify import ( + ATTR_MESSAGE, DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, ) -from homeassistant.components.notify.const import ATTR_MESSAGE from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -21,7 +21,7 @@ ENTITY_DIRECT_MESSAGE = "notify.mybox_personal_notifier" @pytest.fixture -async def notify_only() -> AsyncGenerator[None, None]: +async def notify_only() -> AsyncGenerator[None]: """Enable only the button platform.""" with patch( "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", diff --git a/tests/components/kmtronic/conftest.py b/tests/components/kmtronic/conftest.py index 98205288aa3..5dc349508e3 100644 --- a/tests/components/kmtronic/conftest.py +++ b/tests/components/kmtronic/conftest.py @@ -1,13 +1,13 @@ """Define fixtures for kmtronic tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.kmtronic.async_setup_entry", return_value=True diff --git a/tests/components/knocki/__init__.py b/tests/components/knocki/__init__.py new file mode 100644 index 00000000000..4ebf6b0dd01 --- /dev/null +++ b/tests/components/knocki/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Knocki integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/knocki/conftest.py b/tests/components/knocki/conftest.py new file mode 100644 index 00000000000..e1bc2e29cde --- /dev/null +++ b/tests/components/knocki/conftest.py @@ -0,0 +1,57 @@ +"""Common fixtures for the Knocki tests.""" + +from unittest.mock import AsyncMock, patch + +from knocki import TokenResponse, Trigger +import pytest +from typing_extensions import Generator + +from homeassistant.components.knocki.const import DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_json_array_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.knocki.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_knocki_client() -> Generator[AsyncMock]: + """Mock a Knocki client.""" + with ( + patch( + "homeassistant.components.knocki.KnockiClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.knocki.config_flow.KnockiClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = TokenResponse(token="test-token", user_id="test-id") + client.get_triggers.return_value = [ + Trigger.from_dict(trigger) + for trigger in load_json_array_fixture("triggers.json", DOMAIN) + ] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Knocki", + unique_id="test-id", + data={ + CONF_TOKEN: "test-token", + }, + ) diff --git a/tests/components/knocki/fixtures/triggers.json b/tests/components/knocki/fixtures/triggers.json new file mode 100644 index 00000000000..13dc3906b35 --- /dev/null +++ b/tests/components/knocki/fixtures/triggers.json @@ -0,0 +1,16 @@ +[ + { + "device": "KNC1-W-00000214", + "gesture": "d060b870-15ba-42c9-a932-2d2951087152", + "details": { + "description": "Eeee", + "name": "Aaaa", + "id": 31 + }, + "type": "homeassistant", + "user": "7a4d5bf9-01b1-413b-bb4d-77728e931dcc", + "updatedAt": 1716378013721, + "createdAt": 1716378013721, + "id": "1a050b25-7fed-4e0e-b5af-792b8b4650de" + } +] diff --git a/tests/components/knocki/snapshots/test_event.ambr b/tests/components/knocki/snapshots/test_event.ambr new file mode 100644 index 00000000000..fba1c90b45d --- /dev/null +++ b/tests/components/knocki/snapshots/test_event.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_entities[event.knc1_w_00000214_aaaa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'triggered', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.knc1_w_00000214_aaaa', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Aaaa', + 'platform': 'knocki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'knocki', + 'unique_id': 'KNC1-W-00000214_31', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[event.knc1_w_00000214_aaaa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'triggered', + ]), + 'friendly_name': 'KNC1-W-00000214 Aaaa', + }), + 'context': , + 'entity_id': 'event.knc1_w_00000214_aaaa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/knocki/test_config_flow.py b/tests/components/knocki/test_config_flow.py new file mode 100644 index 00000000000..baf43c3ad30 --- /dev/null +++ b/tests/components/knocki/test_config_flow.py @@ -0,0 +1,109 @@ +"""Tests for the Knocki event platform.""" + +from unittest.mock import AsyncMock + +from knocki import KnockiConnectionError +import pytest + +from homeassistant.components.knocki.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + CONF_TOKEN: "test-token", + } + assert result["result"].unique_id == "test-id" + assert len(mock_knocki_client.link.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplcate_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_knocki_client: AsyncMock, +) -> None: + """Test abort when setting up duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("field"), ["login", "link"]) +@pytest.mark.parametrize( + ("exception", "error"), + [(KnockiConnectionError, "cannot_connect"), (Exception, "unknown")], +) +async def test_exceptions( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_setup_entry: AsyncMock, + field: str, + exception: Exception, + error: str, +) -> None: + """Test exceptions.""" + getattr(mock_knocki_client, field).side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + getattr(mock_knocki_client, field).side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/knocki/test_event.py b/tests/components/knocki/test_event.py new file mode 100644 index 00000000000..a53e2811854 --- /dev/null +++ b/tests/components/knocki/test_event.py @@ -0,0 +1,75 @@ +"""Tests for the Knocki event platform.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock + +from knocki import Event, EventType, Trigger, TriggerDetails +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_entities( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2022-01-01T12:00:00Z") +async def test_subscription( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subscription.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("event.knc1_w_00000214_aaaa").state == STATE_UNKNOWN + + event_function: Callable[[Event], None] = ( + mock_knocki_client.register_listener.call_args[0][1] + ) + + async def _call_event_function( + device_id: str = "KNC1-W-00000214", trigger_id: int = 31 + ) -> None: + event_function( + Event( + EventType.TRIGGERED, + Trigger( + device_id=device_id, details=TriggerDetails(trigger_id, "aaaa") + ), + ) + ) + await hass.async_block_till_done() + + await _call_event_function(device_id="KNC1-W-00000215") + assert hass.states.get("event.knc1_w_00000214_aaaa").state == STATE_UNKNOWN + + await _call_event_function(trigger_id=32) + assert hass.states.get("event.knc1_w_00000214_aaaa").state == STATE_UNKNOWN + + await _call_event_function() + assert ( + hass.states.get("event.knc1_w_00000214_aaaa").state + == "2022-01-01T12:00:00.000+00:00" + ) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_knocki_client.register_listener.return_value.called diff --git a/tests/components/knocki/test_init.py b/tests/components/knocki/test_init.py new file mode 100644 index 00000000000..7db0e1047b5 --- /dev/null +++ b/tests/components/knocki/test_init.py @@ -0,0 +1,43 @@ +"""Test the Home Knocki init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from knocki import KnockiConnectionError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_initialization_failure( + hass: HomeAssistant, + mock_knocki_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test initialization failure.""" + mock_knocki_client.get_triggers.side_effect = KnockiConnectionError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index a580fc9eb2c..cd7146b565b 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import json +from typing import Any from unittest.mock import DEFAULT, AsyncMock, Mock, patch import pytest @@ -43,7 +44,7 @@ class KNXTestKit: INDIVIDUAL_ADDRESS = "1.2.3" - def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry): + def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: """Init KNX test helper class.""" self.hass: HomeAssistant = hass self.mock_config_entry: MockConfigEntry = mock_config_entry @@ -265,7 +266,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -async def knx(request, hass, mock_config_entry: MockConfigEntry): +async def knx(hass: HomeAssistant, mock_config_entry: MockConfigEntry): """Create a KNX TestKit instance.""" knx_test_kit = KNXTestKit(hass, mock_config_entry) yield knx_test_kit @@ -273,7 +274,7 @@ async def knx(request, hass, mock_config_entry: MockConfigEntry): @pytest.fixture -def load_knxproj(hass_storage): +def load_knxproj(hass_storage: dict[str, Any]) -> None: """Mock KNX project data.""" hass_storage[KNX_PROJECT_STORAGE_KEY] = { "version": 1, diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 240fde9ee8b..9c431386b43 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -1,5 +1,7 @@ """Test KNX climate.""" +import pytest + from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema from homeassistant.const import CONF_NAME, STATE_IDLE @@ -52,8 +54,12 @@ async def test_climate_basic_temperature_set( assert len(events) == 1 -async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: - """Test KNX climate hvac mode.""" +@pytest.mark.parametrize("heat_cool_ga", [None, "4/4/4"]) +async def test_climate_on_off( + hass: HomeAssistant, knx: KNXTestKit, heat_cool_ga: str | None +) -> None: + """Test KNX climate on/off.""" + on_off_ga = "3/3/3" await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -61,14 +67,110 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", - ClimateSchema.CONF_CONTROLLER_MODE_ADDRESS: "1/2/6", - ClimateSchema.CONF_CONTROLLER_MODE_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_ON_OFF_ADDRESS: "1/2/8", - ClimateSchema.CONF_OPERATION_MODES: ["Auto"], + ClimateSchema.CONF_ON_OFF_ADDRESS: on_off_ga, + ClimateSchema.CONF_ON_OFF_STATE_ADDRESS: "1/2/9", } + | ( + { + ClimateSchema.CONF_HEAT_COOL_ADDRESS: heat_cool_ga, + ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS: "1/2/11", + } + if heat_cool_ga + else {} + ) + } + ) + + await hass.async_block_till_done() + # read heat/cool state + if heat_cool_ga: + await knx.assert_read("1/2/11") + await knx.receive_response("1/2/11", 0) # cool + # read temperature state + await knx.assert_read("1/2/3") + await knx.receive_response("1/2/3", RAW_FLOAT_20_0) + # read target temperature state + await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + # read on/off state + await knx.assert_read("1/2/9") + await knx.receive_response("1/2/9", 1) + + # turn off + await hass.services.async_call( + "climate", + "turn_off", + {"entity_id": "climate.test"}, + blocking=True, + ) + await knx.assert_write(on_off_ga, 0) + assert hass.states.get("climate.test").state == "off" + + # turn on + await hass.services.async_call( + "climate", + "turn_on", + {"entity_id": "climate.test"}, + blocking=True, + ) + await knx.assert_write(on_off_ga, 1) + if heat_cool_ga: + # does not fall back to default hvac mode after turn_on + assert hass.states.get("climate.test").state == "cool" + else: + assert hass.states.get("climate.test").state == "heat" + + # set hvac mode to off triggers turn_off if no controller_mode is available + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.test", "hvac_mode": HVACMode.OFF}, + blocking=True, + ) + await knx.assert_write(on_off_ga, 0) + assert hass.states.get("climate.test").state == "off" + + # set hvac mode to heat + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.test", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + if heat_cool_ga: + await knx.assert_write(heat_cool_ga, 1) + await knx.assert_write(on_off_ga, 1) + else: + await knx.assert_write(on_off_ga, 1) + assert hass.states.get("climate.test").state == "heat" + + +@pytest.mark.parametrize("on_off_ga", [None, "4/4/4"]) +async def test_climate_hvac_mode( + hass: HomeAssistant, knx: KNXTestKit, on_off_ga: str | None +) -> None: + """Test KNX climate hvac mode.""" + controller_mode_ga = "3/3/3" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_CONTROLLER_MODE_ADDRESS: controller_mode_ga, + ClimateSchema.CONF_CONTROLLER_MODE_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_OPERATION_MODES: ["Auto"], + } + | ( + { + ClimateSchema.CONF_ON_OFF_ADDRESS: on_off_ga, + } + if on_off_ga + else {} + ) } ) - async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater @@ -82,24 +184,56 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/5") await knx.receive_response("1/2/5", RAW_FLOAT_22_0) - # turn hvac off + # turn hvac mode to off - set_hvac_mode() doesn't send to on_off if dedicated hvac mode is available await hass.services.async_call( "climate", "set_hvac_mode", {"entity_id": "climate.test", "hvac_mode": HVACMode.OFF}, blocking=True, ) - await knx.assert_write("1/2/8", False) + await knx.assert_write(controller_mode_ga, (0x06,)) + if on_off_ga: + await knx.assert_write(on_off_ga, 0) + assert hass.states.get("climate.test").state == "off" - # turn hvac on + # set hvac to non default mode await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.test", "hvac_mode": HVACMode.HEAT}, + {"entity_id": "climate.test", "hvac_mode": HVACMode.COOL}, blocking=True, ) - await knx.assert_write("1/2/8", True) - await knx.assert_write("1/2/6", (0x01,)) + await knx.assert_write(controller_mode_ga, (0x03,)) + if on_off_ga: + await knx.assert_write(on_off_ga, 1) + assert hass.states.get("climate.test").state == "cool" + + # turn off + await hass.services.async_call( + "climate", + "turn_off", + {"entity_id": "climate.test"}, + blocking=True, + ) + if on_off_ga: + await knx.assert_write(on_off_ga, 0) + else: + await knx.assert_write(controller_mode_ga, (0x06,)) + assert hass.states.get("climate.test").state == "off" + + # turn on + await hass.services.async_call( + "climate", + "turn_on", + {"entity_id": "climate.test"}, + blocking=True, + ) + if on_off_ga: + await knx.assert_write(on_off_ga, 1) + else: + # restore last hvac mode + await knx.assert_write(controller_mode_ga, (0x03,)) + assert hass.states.get("climate.test").state == "cool" async def test_climate_preset_mode( @@ -182,7 +316,6 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: ) assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py index e2dcfc8d112..c8c6bd4f346 100644 --- a/tests/components/knx/test_datetime.py +++ b/tests/components/knx/test_datetime.py @@ -50,7 +50,7 @@ async def test_datetime(hass: HomeAssistant, knx: KNXTestKit) -> None: async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX datetime with passive_address, restoring state and respond_to_read.""" - hass.config.set_time_zone("Europe/Vienna") + await hass.config.async_set_time_zone("Europe/Vienna") test_address = "1/1/1" test_passive_address = "3/3/3" fake_state = State("datetime.test", "2022-03-03T03:04:05+00:00") diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index 3c8bf58169b..136dddefaab 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -1,10 +1,15 @@ """Tests for KNX device triggers.""" +import logging + import pytest import voluptuous_serialize from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.knx import DOMAIN, device_trigger from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant, ServiceCall @@ -22,36 +27,13 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: return async_mock_service(hass, "test", "automation") -async def test_get_triggers( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - knx: KNXTestKit, -) -> None: - """Test we get the expected triggers from knx.""" - await knx.setup_integration({}) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} - ) - expected_trigger = { - "platform": "device", - "domain": DOMAIN, - "device_id": device_entry.id, - "type": "telegram", - "metadata": {}, - } - triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, device_entry.id - ) - assert expected_trigger in triggers - - async def test_if_fires_on_telegram( hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry, knx: KNXTestKit, ) -> None: - """Test for telegram triggers firing.""" + """Test telegram device triggers firing.""" await knx.setup_integration({}) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} @@ -63,6 +45,102 @@ async def test_if_fires_on_telegram( automation.DOMAIN, { automation.DOMAIN: [ + # "catch_all" trigger + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "group_value_write": True, + "group_value_response": True, + "group_value_read": True, + "incoming": True, + "outgoing": True, + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}"), + "id": (" {{ trigger.id }}"), + }, + }, + }, + # "specific" trigger + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "id": "test-id", + "type": "telegram", + "destination": [ + "1/2/3", + "1/516", # "1/516" -> "1/2/4" in 2level format + ], + "group_value_write": True, + "group_value_response": False, + "group_value_read": False, + "incoming": True, + "outgoing": False, + }, + "action": { + "service": "test.automation", + "data_template": { + "specific": ("telegram - {{ trigger.destination }}"), + "id": (" {{ trigger.id }}"), + }, + }, + }, + ] + }, + ) + + # "specific" shall ignore destination address + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 1 + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 0/0/1" + assert test_call.data["id"] == 0 + + await knx.receive_write("1/2/4", (0x03, 0x2F)) + assert len(calls) == 2 + test_call = calls.pop() + assert test_call.data["specific"] == "telegram - 1/2/4" + assert test_call.data["id"] == "test-id" + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 1/2/4" + assert test_call.data["id"] == 0 + + # "specific" shall ignore GroupValueRead + await knx.receive_read("1/2/4") + assert len(calls) == 1 + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 1/2/4" + assert test_call.data["id"] == 0 + + +async def test_default_if_fires_on_telegram( + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test default telegram device triggers firing.""" + # by default (without a user changing any) extra_fields are not added to the trigger and + # pre 2024.2 device triggers did only support "destination" field so they didn't have + # "group_value_write", "group_value_response", "group_value_read", "incoming", "outgoing" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # "catch_all" trigger { "trigger": { "platform": "device", @@ -78,6 +156,7 @@ async def test_if_fires_on_telegram( }, }, }, + # "specific" trigger { "trigger": { "platform": "device", @@ -114,6 +193,16 @@ async def test_if_fires_on_telegram( assert test_call.data["catch_all"] == "telegram - 1/2/4" assert test_call.data["id"] == 0 + # "specific" shall catch GroupValueRead as it is not set explicitly + await knx.receive_read("1/2/4") + assert len(calls) == 2 + test_call = calls.pop() + assert test_call.data["specific"] == "telegram - 1/2/4" + assert test_call.data["id"] == "test-id" + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 1/2/4" + assert test_call.data["id"] == 0 + async def test_remove_device_trigger( hass: HomeAssistant, @@ -165,12 +254,35 @@ async def test_remove_device_trigger( assert len(calls) == 0 -async def test_get_trigger_capabilities_node_status( +async def test_get_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, knx: KNXTestKit, ) -> None: - """Test we get the expected capabilities from a node_status trigger.""" + """Test we get the expected device triggers from knx.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert expected_trigger in triggers + + +async def test_get_trigger_capabilities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test we get the expected capabilities telegram device trigger.""" await knx.setup_integration({}) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} @@ -202,5 +314,117 @@ async def test_get_trigger_capabilities_node_status( "sort": False, }, }, - } + }, + { + "name": "group_value_write", + "optional": True, + "default": True, + "selector": { + "boolean": {}, + }, + }, + { + "name": "group_value_response", + "optional": True, + "default": True, + "selector": { + "boolean": {}, + }, + }, + { + "name": "group_value_read", + "optional": True, + "default": True, + "selector": { + "boolean": {}, + }, + }, + { + "name": "incoming", + "optional": True, + "default": True, + "selector": { + "boolean": {}, + }, + }, + { + "name": "outgoing", + "optional": True, + "default": True, + "selector": { + "boolean": {}, + }, + }, ] + + +async def test_invalid_device_trigger( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid telegram device trigger configuration.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + caplog.clear() + with caplog.at_level(logging.ERROR): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "invalid": True, + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}"), + "id": (" {{ trigger.id }}"), + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert ( + "Unnamed automation failed to setup triggers and has been disabled: " + "extra keys not allowed @ data['invalid']. Got None" + in caplog.records[0].message + ) + + +async def test_invalid_trigger_configuration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test invalid telegram device trigger configuration at attach_trigger.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + # After changing the config in async_attach_trigger, the config is validated again + # against the integration trigger. This test checks if this validation works. + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_attach_trigger( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "group_value_write": "invalid", + }, + None, + {}, + ) diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index 0b43433c01e..bb60e66f7e7 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -31,12 +31,12 @@ from tests.typing import ClientSessionGenerator @pytest.mark.parametrize("hass_config", [{}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, knx: KNXTestKit, - mock_hass_config: None, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" @@ -51,9 +51,9 @@ async def test_diagnostics( @pytest.mark.parametrize("hass_config", [{"knx": {"wrong_key": {}}}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_diagnostic_config_error( hass: HomeAssistant, - mock_hass_config: None, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, knx: KNXTestKit, @@ -72,10 +72,10 @@ async def test_diagnostic_config_error( @pytest.mark.parametrize("hass_config", [{}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_diagnostic_redact( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_hass_config: None, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics redacting data.""" @@ -107,12 +107,12 @@ async def test_diagnostic_redact( @pytest.mark.parametrize("hass_config", [{}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_diagnostics_project( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, knx: KNXTestKit, - mock_hass_config: None, load_knxproj: None, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index d2b7653cfe8..e0b4c78e322 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -8,7 +8,12 @@ import pytest from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema -from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_ENTITY_ID, + CONF_TYPE, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -237,6 +242,54 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/1/8", (3,)) +async def test_expose_value_template( + hass: HomeAssistant, knx: KNXTestKit, caplog: pytest.LogCaptureFixture +) -> None: + """Test an expose with value_template.""" + entity_id = "fake.entity" + attribute = "brightness" + binary_address = "1/1/1" + percent_address = "2/2/2" + await knx.setup_integration( + { + CONF_KNX_EXPOSE: [ + { + CONF_TYPE: "binary", + KNX_ADDRESS: binary_address, + CONF_ENTITY_ID: entity_id, + CONF_VALUE_TEMPLATE: "{{ not value == 'on' }}", + }, + { + CONF_TYPE: "percentU8", + KNX_ADDRESS: percent_address, + CONF_ENTITY_ID: entity_id, + CONF_ATTRIBUTE: attribute, + CONF_VALUE_TEMPLATE: "{{ 255 - value }}", + }, + ] + }, + ) + + # Change attribute to 0 + hass.states.async_set(entity_id, "on", {attribute: 0}) + await hass.async_block_till_done() + await knx.assert_write(binary_address, False) + await knx.assert_write(percent_address, (255,)) + + # Change attribute to 255 + hass.states.async_set(entity_id, "off", {attribute: 255}) + await hass.async_block_till_done() + await knx.assert_write(binary_address, True) + await knx.assert_write(percent_address, (0,)) + + # Change attribute to null (eg. light brightness) + hass.states.async_set(entity_id, "off", {attribute: None}) + await hass.async_block_till_done() + # without explicit `None`-handling or default value this fails with + # TypeError: unsupported operand type(s) for -: 'int' and 'NoneType' + assert "Error rendering value template for KNX expose" in caplog.text + + async def test_expose_conversion_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, knx: KNXTestKit ) -> None: diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index c857022750c..6cf5d8026b9 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -22,7 +22,7 @@ async def test_diagnostic_entities( """Test diagnostic entities.""" await knx.setup_integration({}) - for entity_id in [ + for entity_id in ( "sensor.knx_interface_individual_address", "sensor.knx_interface_connection_established", "sensor.knx_interface_connection_type", @@ -31,14 +31,14 @@ async def test_diagnostic_entities( "sensor.knx_interface_outgoing_telegrams", "sensor.knx_interface_outgoing_telegram_errors", "sensor.knx_interface_telegrams", - ]: + ): entity = entity_registry.async_get(entity_id) assert entity.entity_category is EntityCategory.DIAGNOSTIC - for entity_id in [ + for entity_id in ( "sensor.knx_interface_incoming_telegrams", "sensor.knx_interface_outgoing_telegrams", - ]: + ): entity = entity_registry.async_get(entity_id) assert entity.disabled is True @@ -54,14 +54,14 @@ async def test_diagnostic_entities( assert len(events) == 3 # 5 polled sensors - 2 disabled events.clear() - for entity_id, test_state in [ + for entity_id, test_state in ( ("sensor.knx_interface_individual_address", "0.0.0"), ("sensor.knx_interface_connection_type", "Tunnel TCP"), # skipping connected_since timestamp ("sensor.knx_interface_incoming_telegram_errors", "1"), ("sensor.knx_interface_outgoing_telegram_errors", "2"), ("sensor.knx_interface_telegrams", "31"), - ]: + ): assert hass.states.get(entity_id).state == test_state await knx.xknx.connection_manager.connection_state_changed( @@ -85,14 +85,14 @@ async def test_diagnostic_entities( await hass.async_block_till_done() assert len(events) == 6 # all diagnostic sensors - counters are reset on connect - for entity_id, test_state in [ + for entity_id, test_state in ( ("sensor.knx_interface_individual_address", "1.1.1"), ("sensor.knx_interface_connection_type", "Tunnel UDP"), # skipping connected_since timestamp ("sensor.knx_interface_incoming_telegram_errors", "0"), ("sensor.knx_interface_outgoing_telegram_errors", "0"), ("sensor.knx_interface_telegrams", "0"), - ]: + ): assert hass.states.get(entity_id).state == test_state diff --git a/tests/components/knx/test_repairs.py b/tests/components/knx/test_repairs.py index 4ad06e0addb..690d6e450cb 100644 --- a/tests/components/knx/test_repairs.py +++ b/tests/components/knx/test_repairs.py @@ -54,14 +54,14 @@ async def test_knx_notify_service_issue( # Assert the issue is present assert len(issue_registry.issues) == 1 assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id="migrate_notify", + domain="notify", + issue_id=f"migrate_notify_{DOMAIN}_notify", ) # Test confirm step in repair flow resp = await http_client.post( RepairsFlowIndexView.url, - json={"handler": DOMAIN, "issue_id": "migrate_notify"}, + json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}_notify"}, ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -78,7 +78,7 @@ async def test_knx_notify_service_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue( - domain=DOMAIN, - issue_id="migrate_notify", + domain="notify", + issue_id=f"migrate_notify_{DOMAIN}_notify", ) assert len(issue_registry.issues) == 0 diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index e93f59ba574..7f748af5ceb 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -290,7 +290,7 @@ async def test_reload_service( async def test_service_setup_failed(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test service setup failed.""" await knx.setup_integration({}) - await knx.mock_config_entry.async_unload(hass) + await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( @@ -299,4 +299,4 @@ async def test_service_setup_failed(hass: HomeAssistant, knx: KNXTestKit) -> Non {"address": "1/2/3", "payload": True, "response": False}, blocking=True, ) - assert str(exc_info.value) == "KNX entry not loaded" + assert str(exc_info.value) == "KNX entry not loaded" diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 844fc073d61..2eda718f5ac 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -51,8 +51,8 @@ MOCK_TELEGRAMS = [ def assert_telegram_history(telegrams: list[TelegramDict]) -> bool: """Assert that the mock telegrams are equal to the given telegrams. Omitting timestamp.""" assert len(telegrams) == len(MOCK_TELEGRAMS) - for index in range(len(telegrams)): - test_telegram = copy(telegrams[index]) # don't modify the original + for index, value in enumerate(telegrams): + test_telegram = copy(value) # don't modify the original comp_telegram = MOCK_TELEGRAMS[index] assert datetime.fromisoformat(test_telegram["timestamp"]) if isinstance(test_telegram["payload"], tuple): @@ -66,7 +66,7 @@ async def test_store_telegam_history( hass: HomeAssistant, knx: KNXTestKit, hass_storage: dict[str, Any], -): +) -> None: """Test storing telegram history.""" await knx.setup_integration({}) @@ -89,7 +89,7 @@ async def test_load_telegam_history( hass: HomeAssistant, knx: KNXTestKit, hass_storage: dict[str, Any], -): +) -> None: """Test telegram history restoration.""" hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} await knx.setup_integration({}) @@ -103,7 +103,7 @@ async def test_remove_telegam_history( hass: HomeAssistant, knx: KNXTestKit, hass_storage: dict[str, Any], -): +) -> None: """Test telegram history removal when configured to size 0.""" hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} knx.mock_config_entry.add_to_hass(hass) diff --git a/tests/components/knx/test_trigger.py b/tests/components/knx/test_trigger.py new file mode 100644 index 00000000000..d957082de18 --- /dev/null +++ b/tests/components/knx/test_trigger.py @@ -0,0 +1,346 @@ +"""Tests for KNX integration specific triggers.""" + +import logging + +import pytest + +from homeassistant.components import automation +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.setup import async_setup_component + +from .conftest import KNXTestKit + +from tests.common import async_mock_service + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_telegram_trigger( + hass: HomeAssistant, + calls: list[ServiceCall], + knx: KNXTestKit, +) -> None: + """Test telegram triggers firing.""" + await knx.setup_integration({}) + + # "id" field added to action to test if `trigger_data` passed correctly in `async_attach_trigger` + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # "catch_all" trigger + { + "trigger": { + "platform": "knx.telegram", + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}"), + "id": (" {{ trigger.id }}"), + }, + }, + }, + # "specific" trigger + { + "trigger": { + "platform": "knx.telegram", + "id": "test-id", + "destination": ["1/2/3", 2564], # 2564 -> "1/2/4" in raw format + "group_value_write": True, + "group_value_response": False, + "group_value_read": False, + "incoming": True, + "outgoing": True, + }, + "action": { + "service": "test.automation", + "data_template": { + "specific": ("telegram - {{ trigger.destination }}"), + "id": (" {{ trigger.id }}"), + }, + }, + }, + ] + }, + ) + + # "specific" shall ignore destination address + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 1 + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 0/0/1" + assert test_call.data["id"] == 0 + + await knx.receive_write("1/2/4", (0x03, 0x2F)) + assert len(calls) == 2 + test_call = calls.pop() + assert test_call.data["specific"] == "telegram - 1/2/4" + assert test_call.data["id"] == "test-id" + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 1/2/4" + assert test_call.data["id"] == 0 + + # "specific" shall ignore GroupValueRead + await knx.receive_read("1/2/4") + assert len(calls) == 1 + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 1/2/4" + assert test_call.data["id"] == 0 + + +@pytest.mark.parametrize( + ("payload", "type_option", "expected_value", "expected_unit"), + [ + ((0x4C,), {"type": "percent"}, 30, "%"), + ((0x03,), {}, None, None), # "dpt" omitted defaults to None + ((0x0C, 0x1A), {"type": "temperature"}, 21.00, "°C"), + ], +) +async def test_telegram_trigger_dpt_option( + hass: HomeAssistant, + calls: list[ServiceCall], + knx: KNXTestKit, + payload: tuple[int, ...], + type_option: dict[str, bool], + expected_value: int | None, + expected_unit: str | None, +) -> None: + """Test telegram trigger type option.""" + await knx.setup_integration({}) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # "catch_all" trigger + { + "trigger": { + "platform": "knx.telegram", + **type_option, + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}"), + "trigger": (" {{ trigger }}"), + }, + }, + }, + ] + }, + ) + await knx.receive_write("0/0/1", payload) + + assert len(calls) == 1 + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 0/0/1" + assert test_call.data["trigger"]["value"] == expected_value + assert test_call.data["trigger"]["unit"] == expected_unit + + await knx.receive_read("0/0/1") + + assert len(calls) == 1 + test_call = calls.pop() + assert test_call.data["catch_all"] == "telegram - 0/0/1" + assert test_call.data["trigger"]["value"] is None + assert test_call.data["trigger"]["unit"] is None + + +@pytest.mark.parametrize( + "group_value_options", + [ + { + "group_value_write": True, + "group_value_response": True, + "group_value_read": False, + }, + { + "group_value_write": False, + "group_value_response": False, + "group_value_read": True, + }, + { + # "group_value_write": True, # omitted defaults to True + "group_value_response": False, + "group_value_read": False, + }, + ], +) +@pytest.mark.parametrize( + "direction_options", + [ + { + "incoming": True, + "outgoing": True, + }, + { + # "incoming": True, # omitted defaults to True + "outgoing": False, + }, + { + "incoming": False, + "outgoing": True, + }, + ], +) +async def test_telegram_trigger_options( + hass: HomeAssistant, + calls: list[ServiceCall], + knx: KNXTestKit, + group_value_options: dict[str, bool], + direction_options: dict[str, bool], +) -> None: + """Test telegram trigger options.""" + await knx.setup_integration({}) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # "catch_all" trigger + { + "trigger": { + "platform": "knx.telegram", + **group_value_options, + **direction_options, + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}"), + }, + }, + }, + ] + }, + ) + await knx.receive_write("0/0/1", 1) + if group_value_options.get("group_value_write", True) and direction_options.get( + "incoming", True + ): + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + else: + assert len(calls) == 0 + + await knx.receive_response("0/0/1", 1) + if group_value_options["group_value_response"] and direction_options.get( + "incoming", True + ): + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + else: + assert len(calls) == 0 + + await knx.receive_read("0/0/1") + if group_value_options["group_value_read"] and direction_options.get( + "incoming", True + ): + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + else: + assert len(calls) == 0 + + await hass.services.async_call( + "knx", + "send", + {"address": "0/0/1", "payload": True}, + blocking=True, + ) + await knx.assert_write("0/0/1", True) + if ( + group_value_options.get("group_value_write", True) + and direction_options["outgoing"] + ): + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + else: + assert len(calls) == 0 + + +async def test_remove_telegram_trigger( + hass: HomeAssistant, + calls: list[ServiceCall], + knx: KNXTestKit, +) -> None: + """Test for removed callback when telegram trigger not used.""" + automation_name = "telegram_trigger_automation" + await knx.setup_integration({}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "alias": automation_name, + "trigger": { + "platform": "knx.telegram", + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}") + }, + }, + } + ] + }, + ) + + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: f"automation.{automation_name}"}, + blocking=True, + ) + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 0 + + +async def test_invalid_trigger( + hass: HomeAssistant, + knx: KNXTestKit, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid telegram trigger configuration.""" + await knx.setup_integration({}) + caplog.clear() + with caplog.at_level(logging.ERROR): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "knx.telegram", + "invalid": True, + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}"), + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert ( + "Unnamed automation failed to setup triggers and has been disabled: " + "extra keys not allowed @ data['invalid']. Got None" + in caplog.records[0].message + ) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 78cbb98a7a0..ca60905b0ba 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -14,7 +14,7 @@ from tests.typing import WebSocketGenerator async def test_knx_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator -): +) -> None: """Test knx/info command.""" await knx.setup_integration({}) client = await hass_ws_client(hass) @@ -33,7 +33,7 @@ async def test_knx_info_command_with_project( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, load_knxproj: None, -): +) -> None: """Test knx/info command with loaded project.""" await knx.setup_integration({}) client = await hass_ws_client(hass) @@ -55,7 +55,7 @@ async def test_knx_project_file_process( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], -): +) -> None: """Test knx/project_file_process command for storing and loading new data.""" _file_id = "1234" _password = "pw-test" @@ -93,7 +93,7 @@ async def test_knx_project_file_process_error( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, -): +) -> None: """Test knx/project_file_process exception handling.""" await knx.setup_integration({}) client = await hass_ws_client(hass) @@ -126,7 +126,7 @@ async def test_knx_project_file_remove( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, load_knxproj: None, -): +) -> None: """Test knx/project_file_remove command.""" await knx.setup_integration({}) client = await hass_ws_client(hass) @@ -146,7 +146,7 @@ async def test_knx_get_project( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, load_knxproj: None, -): +) -> None: """Test retrieval of kxnproject from store.""" await knx.setup_integration({}) client = await hass_ws_client(hass) @@ -161,7 +161,7 @@ async def test_knx_get_project( async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator -): +) -> None: """Test knx/group_monitor_info command.""" await knx.setup_integration({}) client = await hass_ws_client(hass) @@ -176,7 +176,7 @@ async def test_knx_group_monitor_info_command( async def test_knx_subscribe_telegrams_command_recent_telegrams( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator -): +) -> None: """Test knx/subscribe_telegrams command sending recent telegrams.""" await knx.setup_integration( { @@ -224,7 +224,7 @@ async def test_knx_subscribe_telegrams_command_recent_telegrams( async def test_knx_subscribe_telegrams_command_no_project( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator -): +) -> None: """Test knx/subscribe_telegrams command without project data.""" await knx.setup_integration( { @@ -299,7 +299,7 @@ async def test_knx_subscribe_telegrams_command_project( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, load_knxproj: None, -): +) -> None: """Test knx/subscribe_telegrams command with project data.""" await knx.setup_integration({}) client = await hass_ws_client(hass) diff --git a/tests/components/kodi/__init__.py b/tests/components/kodi/__init__.py index d55a67ba235..f78207be404 100644 --- a/tests/components/kodi/__init__.py +++ b/tests/components/kodi/__init__.py @@ -11,13 +11,14 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant from .util import MockConnection from tests.common import MockConfigEntry -async def init_integration(hass) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Kodi integration in Home Assistant.""" entry_data = { CONF_NAME: "name", diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 2a3c1f7544f..d3de349018e 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -6,7 +6,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.kodi import DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -25,7 +25,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -61,7 +61,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["turn_off", "turn_on"] + for trigger in ("turn_off", "turn_on") ] # Test triggers are either kodi specific triggers or media_player entity triggers @@ -75,7 +75,10 @@ async def test_get_triggers( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls, kodi_media_player + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], + kodi_media_player, ) -> None: """Test for turn_on and turn_off triggers firing.""" entry = entity_registry.async_get(kodi_media_player) @@ -148,7 +151,10 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls, kodi_media_player + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], + kodi_media_player, ) -> None: """Test for turn_on and turn_off triggers firing.""" entry = entity_registry.async_get(kodi_media_player) diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index 6c97b65554d..af958f19f3a 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -2,13 +2,13 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from pykoplenti import MeData, VersionData import pytest +from typing_extensions import Generator -from homeassistant.components.kostal_plenticore.helper import Plenticore +from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -27,7 +27,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_plenticore() -> Generator[Plenticore, None, None]: +def mock_plenticore() -> Generator[Plenticore]: """Set up a Plenticore mock with some default values.""" with patch( "homeassistant.components.kostal_plenticore.Plenticore", autospec=True diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index d94256ebf1a..c982e2af818 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Kostal Plenticore Solar Inverter config flow.""" -from collections.abc import Generator from unittest.mock import ANY, AsyncMock, MagicMock, patch from pykoplenti import ApiClient, AuthenticationException, SettingsData import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -25,7 +25,7 @@ def mock_apiclient() -> ApiClient: @pytest.fixture -def mock_apiclient_class(mock_apiclient) -> Generator[type[ApiClient], None, None]: +def mock_apiclient_class(mock_apiclient) -> Generator[type[ApiClient]]: """Return a mocked ApiClient class.""" with patch( "homeassistant.components.kostal_plenticore.config_flow.ApiClient", diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 57d1bb50bba..1c3a9efe2e5 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -3,7 +3,7 @@ from pykoplenti import SettingsData from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.kostal_plenticore.helper import Plenticore +from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index 93550405897..a18cf32c5a1 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -1,10 +1,10 @@ """Test Kostal Plenticore helper.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from pykoplenti import ApiClient, ExtendedApiClient, SettingsData import pytest +from typing_extensions import Generator from homeassistant.components.kostal_plenticore.const import DOMAIN from homeassistant.core import HomeAssistant @@ -14,10 +14,10 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_apiclient() -> Generator[ApiClient, None, None]: +def mock_apiclient() -> Generator[ApiClient]: """Return a mocked ApiClient class.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", + "homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient", autospec=True, ) as mock_api_class: apiclient = MagicMock(spec=ExtendedApiClient) diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index 41e3a6c0b6c..9d94c6f9951 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -1,11 +1,11 @@ """Test Kostal Plenticore number.""" -from collections.abc import Generator from datetime import timedelta from unittest.mock import patch from pykoplenti import ApiClient, SettingsData import pytest +from typing_extensions import Generator from homeassistant.components.number import ( ATTR_MAX, @@ -16,17 +16,17 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture -def mock_plenticore_client() -> Generator[ApiClient, None, None]: +def mock_plenticore_client() -> Generator[ApiClient]: """Return a patched ExtendedApiClient.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", + "homeassistant.components.kostal_plenticore.coordinator.ExtendedApiClient", autospec=True, ) as plenticore_client_class: yield plenticore_client_class.return_value @@ -92,12 +92,13 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: return setting_values +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_all_entries( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default: None, ) -> None: """Test if all available entries are setup.""" @@ -106,17 +107,19 @@ async def test_setup_all_entries( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - ent_reg = async_get(hass) - assert ent_reg.async_get("number.scb_battery_min_soc") is not None - assert ent_reg.async_get("number.scb_battery_min_home_consumption") is not None + assert entity_registry.async_get("number.scb_battery_min_soc") is not None + assert ( + entity_registry.async_get("number.scb_battery_min_home_consumption") is not None + ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setup_no_entries( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default: None, ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" @@ -140,17 +143,16 @@ async def test_setup_no_entries( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - ent_reg = async_get(hass) - assert ent_reg.async_get("number.scb_battery_min_soc") is None - assert ent_reg.async_get("number.scb_battery_min_home_consumption") is None + assert entity_registry.async_get("number.scb_battery_min_soc") is None + assert entity_registry.async_get("number.scb_battery_min_home_consumption") is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_has_value( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default: None, ) -> None: """Test if number has a value if data is provided on update.""" @@ -170,12 +172,12 @@ async def test_number_has_value( assert state.attributes[ATTR_MAX] == 100 +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_is_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default: None, ) -> None: """Test if number is unavailable if no data is provided on update.""" @@ -191,12 +193,12 @@ async def test_number_is_unavailable( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_set_value( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default: None, ) -> None: """Test if a new value could be set.""" diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index 121300457fe..e3fc136a3fb 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -2,7 +2,7 @@ from pykoplenti import SettingsData -from homeassistant.components.kostal_plenticore.helper import Plenticore +from homeassistant.components.kostal_plenticore.coordinator import Plenticore from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/kraken/test_config_flow.py b/tests/components/kraken/test_config_flow.py index e1971ec3ab8..d2221d161c2 100644 --- a/tests/components/kraken/test_config_flow.py +++ b/tests/components/kraken/test_config_flow.py @@ -7,7 +7,11 @@ from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TICKER_INFORMATION_RESPONSE, TRADEABLE_ASSET_PAIR_RESPONSE +from .const import ( + MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE, + TICKER_INFORMATION_RESPONSE, + TRADEABLE_ASSET_PAIR_RESPONSE, +) from tests.common import MockConfigEntry @@ -94,3 +98,64 @@ async def test_options(hass: HomeAssistant) -> None: assert ada_eth_sensor.state == "0.0003494" assert hass.states.get("sensor.xbt_usd_ask") is None + + +async def test_deselect_removed_pair(hass: HomeAssistant) -> None: + """Test options for Kraken.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_SCAN_INTERVAL: 60, + CONF_TRACKED_ASSET_PAIRS: [ + "XBT/USD", + ], + }, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.kraken.config_flow.KrakenAPI.get_tradable_asset_pairs", + return_value=TRADEABLE_ASSET_PAIR_RESPONSE, + ), + patch( + "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", + return_value=TRADEABLE_ASSET_PAIR_RESPONSE, + ), + patch( + "pykrakenapi.KrakenAPI.get_ticker_information", + return_value=TICKER_INFORMATION_RESPONSE, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.kraken.config_flow.KrakenAPI.get_tradable_asset_pairs", + return_value=MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE, + ), + patch( + "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", + return_value=MISSING_PAIR_TRADEABLE_ASSET_PAIR_RESPONSE, + ), + patch( + "pykrakenapi.KrakenAPI.get_ticker_information", + return_value=TICKER_INFORMATION_RESPONSE, + ), + ): + result = await hass.config_entries.options.async_init(entry.entry_id) + schema = result["data_schema"].schema + assert "XBT/USD" in schema.get(CONF_TRACKED_ASSET_PAIRS).options + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_SCAN_INTERVAL: 10, + CONF_TRACKED_ASSET_PAIRS: ["ADA/ETH"], + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + ada_eth_sensor = hass.states.get("sensor.ada_eth_ask") + assert ada_eth_sensor.state == "0.0003494" diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index fd0a1dc72d1..a08875bfdce 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from pykrakenapi.pykrakenapi import KrakenAPIError +import pytest from homeassistant.components.kraken.const import ( CONF_TRACKED_ASSET_PAIRS, @@ -26,10 +27,10 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - entity_registry_enabled_by_default: None, ) -> None: """Test that sensor has a value.""" with ( diff --git a/tests/components/lacrosse_view/conftest.py b/tests/components/lacrosse_view/conftest.py index 8edee952bf0..a6294c64210 100644 --- a/tests/components/lacrosse_view/conftest.py +++ b/tests/components/lacrosse_view/conftest.py @@ -1,13 +1,13 @@ """Define fixtures for LaCrosse View tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.lacrosse_view.async_setup_entry", return_value=True diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index ed4d2e0990e..4d274d10baa 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -1,6 +1,6 @@ """Mock inputs for tests.""" -from lmcloud.const import LaMarzoccoModel +from lmcloud.const import MachineModel from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -18,31 +18,34 @@ PASSWORD_SELECTION = { USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} -MODEL_DICT = { - LaMarzoccoModel.GS3_AV: ("GS01234", "GS3 AV"), - LaMarzoccoModel.GS3_MP: ("GS01234", "GS3 MP"), - LaMarzoccoModel.LINEA_MICRA: ("MR01234", "Linea Micra"), - LaMarzoccoModel.LINEA_MINI: ("LM01234", "Linea Mini"), +SERIAL_DICT = { + MachineModel.GS3_AV: "GS01234", + MachineModel.GS3_MP: "GS01234", + MachineModel.LINEA_MICRA: "MR01234", + MachineModel.LINEA_MINI: "LM01234", } +WAKE_UP_SLEEP_ENTRY_IDS = ["Os2OswX", "aXFz5bJ"] + async def async_init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Set up the La Marzocco 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() def get_bluetooth_service_info( - model: LaMarzoccoModel, serial: str + model: MachineModel, serial: str ) -> BluetoothServiceInfo: """Return a mocked BluetoothServiceInfo.""" - if model in (LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP): + if model in (MachineModel.GS3_AV, MachineModel.GS3_MP): name = f"GS3_{serial}" - elif model == LaMarzoccoModel.LINEA_MINI: + elif model == MachineModel.LINEA_MINI: name = f"MINI_{serial}" - elif model == LaMarzoccoModel.LINEA_MICRA: + elif model == MachineModel.LINEA_MICRA: name = f"MICRA_{serial}" return BluetoothServiceInfo( name=name, diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index d76e44d60af..6741ac0797c 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -1,22 +1,23 @@ """Lamarzocco session fixtures.""" -from collections.abc import Generator +from collections.abc import Callable +import json from unittest.mock import MagicMock, patch -from lmcloud.const import LaMarzoccoModel +from bleak.backends.device import BLEDevice +from lmcloud.const import FirmwareType, MachineModel, SteamLevel +from lmcloud.lm_machine import LaMarzoccoMachine +from lmcloud.models import LaMarzoccoDeviceInfo import pytest +from typing_extensions import Generator -from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant -from . import MODEL_DICT, USER_INPUT, async_init_integration +from . import SERIAL_DICT, USER_INPUT, async_init_integration -from tests.common import ( - MockConfigEntry, - load_json_array_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture @pytest.fixture @@ -27,12 +28,13 @@ def mock_config_entry( entry = MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, + version=2, data=USER_INPUT | { - CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_MODEL: mock_lamarzocco.model, CONF_HOST: "host", - CONF_NAME: "name", - CONF_MAC: "mac", + CONF_TOKEN: "token", + CONF_NAME: "GS3", }, unique_id=mock_lamarzocco.serial_number, ) @@ -44,79 +46,96 @@ def mock_config_entry( async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock ) -> MockConfigEntry: - """Set up the LaMetric integration for testing.""" + """Set up the La Marzocco integration for testing.""" await async_init_integration(hass, mock_config_entry) return mock_config_entry @pytest.fixture -def device_fixture() -> LaMarzoccoModel: +def device_fixture() -> MachineModel: """Return the device fixture for a specific device.""" - return LaMarzoccoModel.GS3_AV + return MachineModel.GS3_AV @pytest.fixture -def mock_lamarzocco( - request: pytest.FixtureRequest, device_fixture: LaMarzoccoModel -) -> Generator[MagicMock, None, None]: - """Return a mocked LM client.""" - model_name = device_fixture +def mock_device_info() -> LaMarzoccoDeviceInfo: + """Return a mocked La Marzocco device info.""" + return LaMarzoccoDeviceInfo( + model=MachineModel.GS3_AV, + serial_number="GS01234", + name="GS3", + communication_key="token", + ) - (serial_number, true_model_name) = MODEL_DICT[model_name] + +@pytest.fixture +def mock_cloud_client( + mock_device_info: LaMarzoccoDeviceInfo, +) -> Generator[MagicMock]: + """Return a mocked LM cloud client.""" + with ( + patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoCloudClient", + autospec=True, + ) as cloud_client, + patch( + "homeassistant.components.lamarzocco.LaMarzoccoCloudClient", + new=cloud_client, + ), + ): + client = cloud_client.return_value + client.get_customer_fleet.return_value = { + mock_device_info.serial_number: mock_device_info + } + yield client + + +@pytest.fixture +def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: + """Return a mocked LM client.""" + model = device_fixture + + serial_number = SERIAL_DICT[model] + + dummy_machine = LaMarzoccoMachine( + model=model, + serial_number=serial_number, + name=serial_number, + ) + config = load_json_object_fixture("config.json", DOMAIN) + statistics = json.loads(load_fixture("statistics.json", DOMAIN)) + + dummy_machine.parse_config(config) + dummy_machine.parse_statistics(statistics) with ( patch( - "homeassistant.components.lamarzocco.coordinator.LaMarzoccoClient", + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoMachine", autospec=True, ) as lamarzocco_mock, - patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoClient", - new=lamarzocco_mock, - ), ): lamarzocco = lamarzocco_mock.return_value - lamarzocco.machine_info = { - "machine_name": serial_number, - "serial_number": serial_number, - } + lamarzocco.name = dummy_machine.name + lamarzocco.model = dummy_machine.model + lamarzocco.serial_number = dummy_machine.serial_number + lamarzocco.full_model_name = dummy_machine.full_model_name + lamarzocco.config = dummy_machine.config + lamarzocco.statistics = dummy_machine.statistics + lamarzocco.firmware = dummy_machine.firmware + lamarzocco.steam_level = SteamLevel.LEVEL_1 - lamarzocco.model_name = model_name - lamarzocco.true_model_name = true_model_name - lamarzocco.machine_name = serial_number - lamarzocco.serial_number = serial_number - - lamarzocco.firmware_version = "1.1" - lamarzocco.latest_firmware_version = "1.2" - lamarzocco.gateway_version = "v2.2-rc0" - lamarzocco.latest_gateway_version = "v3.1-rc4" - lamarzocco.update_firmware.return_value = True - - lamarzocco.current_status = load_json_object_fixture( - "current_status.json", DOMAIN - ) - lamarzocco.config = load_json_object_fixture("config.json", DOMAIN) - lamarzocco.statistics = load_json_array_fixture("statistics.json", DOMAIN) - lamarzocco.schedule = load_json_array_fixture("schedule.json", DOMAIN) - - lamarzocco.get_all_machines.return_value = [ - (serial_number, model_name), - ] - lamarzocco.check_local_connection.return_value = True - lamarzocco.initialized = False - lamarzocco.websocket_connected = True + lamarzocco.firmware[FirmwareType.GATEWAY].latest_version = "v3.5-rc3" + lamarzocco.firmware[FirmwareType.MACHINE].latest_version = "1.55" async def websocket_connect_mock( - callback: MagicMock, use_sigterm_handler: MagicMock + notify_callback: Callable | None, ) -> None: """Mock the websocket connect method.""" return None - lamarzocco.lm_local_api.websocket_connect = websocket_connect_mock - - lamarzocco.lm_bluetooth = MagicMock() - lamarzocco.lm_bluetooth.address = "AA:BB:CC:DD:EE:FF" + lamarzocco.websocket_connect = websocket_connect_mock yield lamarzocco @@ -133,5 +152,13 @@ def remove_local_connection( @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture +def mock_ble_device() -> BLEDevice: + """Return a mock BLE device.""" + return BLEDevice( + "00:00:00:00:00:00", "GS_GS01234", details={"path": "path"}, rssi=50 + ) diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json index 60d11b0d470..ea6e2ee76b8 100644 --- a/tests/components/lamarzocco/fixtures/config.json +++ b/tests/components/lamarzocco/fixtures/config.json @@ -13,11 +13,16 @@ "schedulingType": "weeklyScheduling" } ], - "machine_sn": "GS01234", + "machine_sn": "Sn01239157", "machine_hw": "2", "isPlumbedIn": true, "isBackFlushEnabled": false, "standByTime": 0, + "smartStandBy": { + "enabled": true, + "minutes": 10, + "mode": "LastBrewing" + }, "tankStatus": true, "groupCapabilities": [ { @@ -121,58 +126,32 @@ } ] }, - "weeklySchedulingConfig": { - "enabled": true, - "monday": { + "wakeUpSleepEntries": [ + { + "days": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ], "enabled": true, - "h_on": 6, - "h_off": 16, - "m_on": 0, - "m_off": 0 + "id": "Os2OswX", + "steam": true, + "timeOff": "24:0", + "timeOn": "22:0" }, - "tuesday": { + { + "days": ["sunday"], "enabled": true, - "h_on": 6, - "h_off": 16, - "m_on": 0, - "m_off": 0 - }, - "wednesday": { - "enabled": true, - "h_on": 6, - "h_off": 16, - "m_on": 0, - "m_off": 0 - }, - "thursday": { - "enabled": true, - "h_on": 6, - "h_off": 16, - "m_on": 0, - "m_off": 0 - }, - "friday": { - "enabled": true, - "h_on": 6, - "h_off": 16, - "m_on": 0, - "m_off": 0 - }, - "saturday": { - "enabled": true, - "h_on": 6, - "h_off": 16, - "m_on": 0, - "m_off": 0 - }, - "sunday": { - "enabled": true, - "h_on": 6, - "h_off": 16, - "m_on": 0, - "m_off": 0 + "id": "aXFz5bJ", + "steam": true, + "timeOff": "7:30", + "timeOn": "7:0" } - }, + ], "clock": "1901-07-08T10:29:00", "firmwareVersions": [ { diff --git a/tests/components/lamarzocco/fixtures/current_status.json b/tests/components/lamarzocco/fixtures/current_status.json deleted file mode 100644 index f99c3d5c331..00000000000 --- a/tests/components/lamarzocco/fixtures/current_status.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "power": true, - "global_auto": "Enabled", - "enable_prebrewing": true, - "coffee_boiler_on": true, - "steam_boiler_on": true, - "enable_preinfusion": false, - "steam_boiler_enable": true, - "steam_temp": 113, - "steam_set_temp": 128, - "steam_level_set": 3, - "coffee_temp": 93, - "coffee_set_temp": 95, - "water_reservoir_contact": true, - "brew_active": false, - "drinks_k1": 13, - "drinks_k2": 2, - "drinks_k3": 42, - "drinks_k4": 34, - "total_flushing": 69, - "mon_auto": "Disabled", - "mon_on_time": "00:00", - "mon_off_time": "00:00", - "tue_auto": "Disabled", - "tue_on_time": "00:00", - "tue_off_time": "00:00", - "wed_auto": "Disabled", - "wed_on_time": "00:00", - "wed_off_time": "00:00", - "thu_auto": "Disabled", - "thu_on_time": "00:00", - "thu_off_time": "00:00", - "fri_auto": "Disabled", - "fri_on_time": "00:00", - "fri_off_time": "00:00", - "sat_auto": "Disabled", - "sat_on_time": "00:00", - "sat_off_time": "00:00", - "sun_auto": "Disabled", - "sun_on_time": "00:00", - "sun_off_time": "00:00", - "dose_k1": 1023, - "dose_k2": 1023, - "dose_k3": 1023, - "dose_k4": 1023, - "dose_hot_water": 1023, - "prebrewing_ton_k1": 3, - "prebrewing_toff_k1": 5, - "prebrewing_ton_k2": 3, - "prebrewing_toff_k2": 5, - "prebrewing_ton_k3": 3, - "prebrewing_toff_k3": 5, - "prebrewing_ton_k4": 3, - "prebrewing_toff_k4": 5, - "preinfusion_k1": 4, - "preinfusion_k2": 4, - "preinfusion_k3": 4, - "preinfusion_k4": 4 -} diff --git a/tests/components/lamarzocco/fixtures/schedule.json b/tests/components/lamarzocco/fixtures/schedule.json deleted file mode 100644 index 62550caaa0b..00000000000 --- a/tests/components/lamarzocco/fixtures/schedule.json +++ /dev/null @@ -1,44 +0,0 @@ -[ - { - "day": "MONDAY", - "enable": "Disabled", - "on": "00:00", - "off": "00:00" - }, - { - "day": "TUESDAY", - "enable": "Disabled", - "on": "00:00", - "off": "00:00" - }, - { - "day": "WEDNESDAY", - "enable": "Enabled", - "on": "08:00", - "off": "13:00" - }, - { - "day": "THURSDAY", - "enable": "Disabled", - "on": "00:00", - "off": "00:00" - }, - { - "day": "FRIDAY", - "enable": "Enabled", - "on": "06:00", - "off": "09:00" - }, - { - "day": "SATURDAY", - "enable": "Enabled", - "on": "10:00", - "off": "23:00" - }, - { - "day": "SUNDAY", - "enable": "Disabled", - "on": "00:00", - "off": "00:00" - } -] diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index f08c2c28851..df47ac002e6 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_binary_sensors[GS01234_backflush_active-binary_sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'GS01234 Backflush active', + }), + 'context': , + 'entity_id': 'binary_sensor.gs01234_backflush_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[GS01234_backflush_active-entry] + 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.gs01234_backflush_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Backflush active', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backflush_enabled', + 'unique_id': 'GS01234_backflush_enabled', + 'unit_of_measurement': None, + }) +# --- # name: test_binary_sensors[GS01234_brewing_active-binary_sensor] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/lamarzocco/snapshots/test_calendar.ambr b/tests/components/lamarzocco/snapshots/test_calendar.ambr index 676c0f1b2ad..2fd5dab846a 100644 --- a/tests/components/lamarzocco/snapshots/test_calendar.ambr +++ b/tests/components/lamarzocco/snapshots/test_calendar.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_calendar_edge_cases[start_date0-end_date0] dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -15,7 +15,7 @@ # --- # name: test_calendar_edge_cases[start_date1-end_date1] dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -29,7 +29,7 @@ # --- # name: test_calendar_edge_cases[start_date2-end_date2] dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -43,7 +43,7 @@ # --- # name: test_calendar_edge_cases[start_date3-end_date3] dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -57,7 +57,7 @@ # --- # name: test_calendar_edge_cases[start_date4-end_date4] dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ ]), }), @@ -65,7 +65,7 @@ # --- # name: test_calendar_edge_cases[start_date5-end_date5] dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', @@ -83,26 +83,7 @@ }), }) # --- -# name: test_calendar_events - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'all_day': False, - 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end_time': '2024-01-13 23:00:00', - 'friendly_name': 'GS01234 Auto on/off schedule', - 'location': '', - 'message': 'Machine My LaMarzocco on', - 'start_time': '2024-01-13 10:00:00', - }), - 'context': , - 'entity_id': 'calendar.gs01234_auto_on_off_schedule', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_calendar_events.1 +# name: test_calendar_events[entry.GS01234_auto_on_off_schedule_axfz5bj] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -114,7 +95,7 @@ 'disabled_by': None, 'domain': 'calendar', 'entity_category': None, - 'entity_id': 'calendar.gs01234_auto_on_off_schedule', + 'entity_id': 'calendar.gs01234_auto_on_off_schedule_axfz5bj', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -126,86 +107,267 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Auto on/off schedule', + 'original_name': 'Auto on/off schedule (aXFz5bJ)', 'platform': 'lamarzocco', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'auto_on_off_schedule', - 'unique_id': 'GS01234_auto_on_off_schedule', + 'unique_id': 'GS01234_auto_on_off_schedule_aXFz5bJ', 'unit_of_measurement': None, }) # --- -# name: test_calendar_events.2 +# name: test_calendar_events[entry.GS01234_auto_on_off_schedule_os2oswx] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.gs01234_auto_on_off_schedule_os2oswx', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto on/off schedule (Os2OswX)', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off_schedule', + 'unique_id': 'GS01234_auto_on_off_schedule_Os2OswX', + 'unit_of_measurement': None, + }) +# --- +# name: test_calendar_events[events.GS01234_auto_on_off_schedule_axfz5bj] dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_axfz5bj': dict({ 'events': list([ dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-13T23:00:00-08:00', - 'start': '2024-01-13T10:00:00-08:00', + 'end': '2024-01-14T07:30:00-08:00', + 'start': '2024-01-14T07:00:00-08:00', 'summary': 'Machine My LaMarzocco on', }), dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-17T13:00:00-08:00', - 'start': '2024-01-17T08:00:00-08:00', + 'end': '2024-01-21T07:30:00-08:00', + 'start': '2024-01-21T07:00:00-08:00', 'summary': 'Machine My LaMarzocco on', }), dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-19T09:00:00-08:00', - 'start': '2024-01-19T06:00:00-08:00', + 'end': '2024-01-28T07:30:00-08:00', + 'start': '2024-01-28T07:00:00-08:00', 'summary': 'Machine My LaMarzocco on', }), dict({ 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-20T23:00:00-08:00', - 'start': '2024-01-20T10:00:00-08:00', - 'summary': 'Machine My LaMarzocco on', - }), - dict({ - 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-24T13:00:00-08:00', - 'start': '2024-01-24T08:00:00-08:00', - 'summary': 'Machine My LaMarzocco on', - }), - dict({ - 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-26T09:00:00-08:00', - 'start': '2024-01-26T06:00:00-08:00', - 'summary': 'Machine My LaMarzocco on', - }), - dict({ - 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-27T23:00:00-08:00', - 'start': '2024-01-27T10:00:00-08:00', - 'summary': 'Machine My LaMarzocco on', - }), - dict({ - 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-01-31T13:00:00-08:00', - 'start': '2024-01-31T08:00:00-08:00', - 'summary': 'Machine My LaMarzocco on', - }), - dict({ - 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-02-02T09:00:00-08:00', - 'start': '2024-02-02T06:00:00-08:00', - 'summary': 'Machine My LaMarzocco on', - }), - dict({ - 'description': 'Machine is scheduled to turn on at the start time and off at the end time', - 'end': '2024-02-03T23:00:00-08:00', - 'start': '2024-02-03T10:00:00-08:00', + 'end': '2024-02-04T07:30:00-08:00', + 'start': '2024-02-04T07:00:00-08:00', 'summary': 'Machine My LaMarzocco on', }), ]), }), }) # --- +# name: test_calendar_events[events.GS01234_auto_on_off_schedule_os2oswx] + dict({ + 'calendar.gs01234_auto_on_off_schedule_os2oswx': dict({ + 'events': list([ + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-13T00:00:00-08:00', + 'start': '2024-01-12T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-14T00:00:00-08:00', + 'start': '2024-01-13T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-15T00:00:00-08:00', + 'start': '2024-01-14T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-16T00:00:00-08:00', + 'start': '2024-01-15T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-17T00:00:00-08:00', + 'start': '2024-01-16T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-18T00:00:00-08:00', + 'start': '2024-01-17T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-19T00:00:00-08:00', + 'start': '2024-01-18T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-20T00:00:00-08:00', + 'start': '2024-01-19T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-21T00:00:00-08:00', + 'start': '2024-01-20T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-22T00:00:00-08:00', + 'start': '2024-01-21T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-23T00:00:00-08:00', + 'start': '2024-01-22T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-24T00:00:00-08:00', + 'start': '2024-01-23T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-25T00:00:00-08:00', + 'start': '2024-01-24T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-26T00:00:00-08:00', + 'start': '2024-01-25T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-27T00:00:00-08:00', + 'start': '2024-01-26T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-28T00:00:00-08:00', + 'start': '2024-01-27T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-29T00:00:00-08:00', + 'start': '2024-01-28T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-30T00:00:00-08:00', + 'start': '2024-01-29T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-01-31T00:00:00-08:00', + 'start': '2024-01-30T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-01T00:00:00-08:00', + 'start': '2024-01-31T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-02T00:00:00-08:00', + 'start': '2024-02-01T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-03T00:00:00-08:00', + 'start': '2024-02-02T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + dict({ + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end': '2024-02-04T00:00:00-08:00', + 'start': '2024-02-03T22:00:00-08:00', + 'summary': 'Machine My LaMarzocco on', + }), + ]), + }), + }) +# --- +# name: test_calendar_events[state.GS01234_auto_on_off_schedule_axfz5bj] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end_time': '2024-01-14 07:30:00', + 'friendly_name': 'GS01234 Auto on/off schedule (aXFz5bJ)', + 'location': '', + 'message': 'Machine My LaMarzocco on', + 'start_time': '2024-01-14 07:00:00', + }), + 'context': , + 'entity_id': 'calendar.gs01234_auto_on_off_schedule_axfz5bj', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_calendar_events[state.GS01234_auto_on_off_schedule_os2oswx] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': False, + 'description': 'Machine is scheduled to turn on at the start time and off at the end time', + 'end_time': '2024-01-13 00:00:00', + 'friendly_name': 'GS01234 Auto on/off schedule (Os2OswX)', + 'location': '', + 'message': 'Machine My LaMarzocco on', + 'start_time': '2024-01-12 22:00:00', + }), + 'context': , + 'entity_id': 'calendar.gs01234_auto_on_off_schedule_os2oswx', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_no_calendar_events_global_disable dict({ - 'calendar.gs01234_auto_on_off_schedule': dict({ + 'calendar.gs01234_auto_on_off_schedule_os2oswx': dict({ 'events': list([ ]), }), diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index ec44100fe1e..b185557bd08 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -2,297 +2,108 @@ # name: test_diagnostics dict({ 'config': dict({ - 'boilerTargetTemperature': dict({ - 'CoffeeBoiler1': 95, - 'SteamBoiler': 123.9000015258789, - }), - 'boilers': list([ - dict({ - 'current': 123.80000305175781, - 'id': 'SteamBoiler', - 'isEnabled': True, - 'target': 123.9000015258789, + 'backflush_enabled': False, + 'boilers': dict({ + 'CoffeeBoiler1': dict({ + 'current_temperature': 96.5, + 'enabled': True, + 'target_temperature': 95, }), - dict({ - 'current': 96.5, - 'id': 'CoffeeBoiler1', - 'isEnabled': True, - 'target': 95, - }), - ]), - 'clock': '1901-07-08T10:29:00', - 'firmwareVersions': list([ - dict({ - 'fw_version': '1.40', - 'name': 'machine_firmware', - }), - dict({ - 'fw_version': 'v3.1-rc4', - 'name': 'gateway_firmware', - }), - ]), - 'groupCapabilities': list([ - dict({ - 'capabilities': dict({ - 'boilerId': 'CoffeeBoiler1', - 'groupNumber': 'Group1', - 'groupType': 'AV_Group', - 'hasFlowmeter': True, - 'hasScale': False, - 'numberOfDoses': 4, - }), - 'doseMode': dict({ - 'brewingType': 'PulsesType', - 'groupNumber': 'Group1', - }), - 'doses': list([ - dict({ - 'doseIndex': 'DoseA', - 'doseType': 'PulsesType', - 'groupNumber': 'Group1', - 'stopTarget': 135, - }), - dict({ - 'doseIndex': 'DoseB', - 'doseType': 'PulsesType', - 'groupNumber': 'Group1', - 'stopTarget': 97, - }), - dict({ - 'doseIndex': 'DoseC', - 'doseType': 'PulsesType', - 'groupNumber': 'Group1', - 'stopTarget': 108, - }), - dict({ - 'doseIndex': 'DoseD', - 'doseType': 'PulsesType', - 'groupNumber': 'Group1', - 'stopTarget': 121, - }), - ]), - }), - ]), - 'isBackFlushEnabled': False, - 'isPlumbedIn': True, - 'machineCapabilities': list([ - dict({ - 'coffeeBoilersNumber': 1, - 'family': 'GS3AV', - 'groupsNumber': 1, - 'hasCupWarmer': False, - 'machineModes': list([ - 'BrewingMode', - 'StandBy', - ]), - 'schedulingType': 'weeklyScheduling', - 'steamBoilersNumber': 1, - 'teaDosesNumber': 1, - }), - ]), - 'machineMode': 'BrewingMode', - 'machine_hw': '2', - 'machine_sn': '**REDACTED**', - 'preinfusionMode': dict({ - 'Group1': dict({ - 'groupNumber': 'Group1', - 'preinfusionStyle': 'PreinfusionByDoseType', + 'SteamBoiler': dict({ + 'current_temperature': 123.80000305175781, + 'enabled': True, + 'target_temperature': 123.9000015258789, }), }), - 'preinfusionModesAvailable': list([ - 'ByDoseType', - ]), - 'preinfusionSettings': dict({ - 'Group1': list([ - dict({ - 'doseType': 'DoseA', - 'groupNumber': 'Group1', - 'preWetHoldTime': 1, - 'preWetTime': 0.5, - }), - dict({ - 'doseType': 'DoseB', - 'groupNumber': 'Group1', - 'preWetHoldTime': 1, - 'preWetTime': 0.5, - }), - dict({ - 'doseType': 'DoseC', - 'groupNumber': 'Group1', - 'preWetHoldTime': 3.299999952316284, - 'preWetTime': 3.299999952316284, - }), - dict({ - 'doseType': 'DoseD', - 'groupNumber': 'Group1', - 'preWetHoldTime': 2, - 'preWetTime': 2, - }), - ]), - 'mode': 'TypeB', - }), - 'standByTime': 0, - 'tankStatus': True, - 'teaDoses': dict({ - 'DoseA': dict({ - 'doseIndex': 'DoseA', - 'stopTarget': 8, - }), - }), - 'version': 'v1', - 'weeklySchedulingConfig': dict({ - 'enabled': True, - 'friday': dict({ - 'enabled': True, - 'h_off': 16, - 'h_on': 6, - 'm_off': 0, - 'm_on': 0, - }), - 'monday': dict({ - 'enabled': True, - 'h_off': 16, - 'h_on': 6, - 'm_off': 0, - 'm_on': 0, - }), - 'saturday': dict({ - 'enabled': True, - 'h_off': 16, - 'h_on': 6, - 'm_off': 0, - 'm_on': 0, - }), - 'sunday': dict({ - 'enabled': True, - 'h_off': 16, - 'h_on': 6, - 'm_off': 0, - 'm_on': 0, - }), - 'thursday': dict({ - 'enabled': True, - 'h_off': 16, - 'h_on': 6, - 'm_off': 0, - 'm_on': 0, - }), - 'tuesday': dict({ - 'enabled': True, - 'h_off': 16, - 'h_on': 6, - 'm_off': 0, - 'm_on': 0, - }), - 'wednesday': dict({ - 'enabled': True, - 'h_off': 16, - 'h_on': 6, - 'm_off': 0, - 'm_on': 0, - }), - }), - }), - 'current_status': dict({ 'brew_active': False, - 'coffee_boiler_on': True, - 'coffee_set_temp': 95, - 'coffee_temp': 93, - 'dose_hot_water': 1023, - 'dose_k1': 1023, - 'dose_k2': 1023, - 'dose_k3': 1023, - 'dose_k4': 1023, - 'drinks_k1': 13, - 'drinks_k2': 2, - 'drinks_k3': 42, - 'drinks_k4': 34, - 'enable_prebrewing': True, - 'enable_preinfusion': False, - 'fri_auto': 'Disabled', - 'fri_off_time': '00:00', - 'fri_on_time': '00:00', - 'global_auto': 'Enabled', - 'mon_auto': 'Disabled', - 'mon_off_time': '00:00', - 'mon_on_time': '00:00', - 'power': True, - 'prebrewing_toff_k1': 5, - 'prebrewing_toff_k2': 5, - 'prebrewing_toff_k3': 5, - 'prebrewing_toff_k4': 5, - 'prebrewing_ton_k1': 3, - 'prebrewing_ton_k2': 3, - 'prebrewing_ton_k3': 3, - 'prebrewing_ton_k4': 3, - 'preinfusion_k1': 4, - 'preinfusion_k2': 4, - 'preinfusion_k3': 4, - 'preinfusion_k4': 4, - 'sat_auto': 'Disabled', - 'sat_off_time': '00:00', - 'sat_on_time': '00:00', - 'steam_boiler_enable': True, - 'steam_boiler_on': True, - 'steam_level_set': 3, - 'steam_set_temp': 128, - 'steam_temp': 113, - 'sun_auto': 'Disabled', - 'sun_off_time': '00:00', - 'sun_on_time': '00:00', - 'thu_auto': 'Disabled', - 'thu_off_time': '00:00', - 'thu_on_time': '00:00', - 'total_flushing': 69, - 'tue_auto': 'Disabled', - 'tue_off_time': '00:00', - 'tue_on_time': '00:00', - 'water_reservoir_contact': True, - 'wed_auto': 'Disabled', - 'wed_off_time': '00:00', - 'wed_on_time': '00:00', - }), - 'firmware': dict({ - 'gateway': dict({ - 'latest_version': 'v3.1-rc4', - 'version': 'v2.2-rc0', + 'brew_active_duration': 0, + 'dose_hot_water': 8, + 'doses': dict({ + '1': 135, + '2': 97, + '3': 108, + '4': 121, }), - 'machine': dict({ - 'latest_version': '1.2', - 'version': '1.1', + 'plumbed_in': True, + 'prebrew_configuration': dict({ + '1': dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + '2': dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + '3': dict({ + 'off_time': 3.299999952316284, + 'on_time': 3.299999952316284, + }), + '4': dict({ + 'off_time': 2, + 'on_time': 2, + }), }), + 'prebrew_mode': 'TypeB', + 'smart_standby': dict({ + 'enabled': True, + 'minutes': 10, + 'mode': 'LastBrewing', + }), + 'turned_on': True, + 'wake_up_sleep_entries': dict({ + 'Os2OswX': dict({ + 'days': list([ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ]), + 'enabled': True, + 'entry_id': 'Os2OswX', + 'steam': True, + 'time_off': '24:0', + 'time_on': '22:0', + }), + 'aXFz5bJ': dict({ + 'days': list([ + 'sunday', + ]), + 'enabled': True, + 'entry_id': 'aXFz5bJ', + 'steam': True, + 'time_off': '7:30', + 'time_on': '7:0', + }), + }), + 'water_contact': True, }), - 'machine_info': dict({ - 'machine_name': 'GS01234', - 'serial_number': '**REDACTED**', - }), + 'firmware': list([ + dict({ + 'machine': dict({ + 'current_version': '1.40', + 'latest_version': '1.55', + }), + }), + dict({ + 'gateway': dict({ + 'current_version': 'v3.1-rc4', + 'latest_version': 'v3.5-rc3', + }), + }), + ]), + 'model': 'GS3 AV', 'statistics': dict({ - 'stats': list([ - dict({ - 'coffeeType': 0, - 'count': 1047, - }), - dict({ - 'coffeeType': 1, - 'count': 560, - }), - dict({ - 'coffeeType': 2, - 'count': 468, - }), - dict({ - 'coffeeType': 3, - 'count': 312, - }), - dict({ - 'coffeeType': 4, - 'count': 2252, - }), - dict({ - 'coffeeType': -1, - 'count': 1740, - }), - ]), + 'continous': 2252, + 'drink_stats': dict({ + '1': 1047, + '2': 560, + '3': 468, + '4': 312, + }), + 'total_flushes': 1740, }), }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index da35bf718f6..8265e7d7646 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -56,7 +56,7 @@ 'unit_of_measurement': , }) # --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV] +# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 AV] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -72,10 +72,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '128', + 'state': '123.900001525879', }) # --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV].1 +# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 AV].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -113,7 +113,7 @@ 'unit_of_measurement': , }) # --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP] +# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 MP] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -129,10 +129,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '128', + 'state': '123.900001525879', }) # --- -# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP].1 +# name: test_gs3_exclusive[steam_target_temperature-131-set_temp-kwargs0-GS3 MP].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -170,7 +170,7 @@ 'unit_of_measurement': , }) # --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV] +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 AV] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -186,10 +186,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1023', + 'state': '8', }) # --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV].1 +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 AV].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -227,7 +227,7 @@ 'unit_of_measurement': , }) # --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP] +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 MP] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -243,10 +243,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1023', + 'state': '8', }) # --- -# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP].1 +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_tea_water-kwargs1-GS3 MP].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -284,7 +284,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-set_dose-kwargs3-GS3 AV][GS01234_dose_key_1-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Dose Key 1', @@ -299,10 +299,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1023', + 'state': '135', }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-set_dose-kwargs3-GS3 AV][GS01234_dose_key_2-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Dose Key 2', @@ -317,10 +317,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1023', + 'state': '97', }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-set_dose-kwargs3-GS3 AV][GS01234_dose_key_3-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Dose Key 3', @@ -335,10 +335,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1023', + 'state': '108', }) # --- -# name: test_pre_brew_infusion_key_numbers[dose-6-set_dose-kwargs3-GS3 AV][GS01234_dose_key_4-state] +# name: test_pre_brew_infusion_key_numbers[dose-6-Disabled-set_dose-kwargs3-GS3 AV][GS01234_dose_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Dose Key 4', @@ -353,10 +353,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1023', + 'state': '121', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-configure_prebrew-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -372,10 +372,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-configure_prebrew-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -391,10 +391,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-configure_prebrew-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -410,10 +410,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '3.29999995231628', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-configure_prebrew-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS01234_prebrew_off_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -429,10 +429,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '2', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-configure_prebrew-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -448,10 +448,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-configure_prebrew-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -467,10 +467,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-configure_prebrew-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -486,10 +486,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '3.29999995231628', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-configure_prebrew-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS01234_prebrew_on_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -505,10 +505,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '2', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-configure_prebrew-kwargs2-GS3 AV][GS01234_preinfusion_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -524,10 +524,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-configure_prebrew-kwargs2-GS3 AV][GS01234_preinfusion_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -543,10 +543,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-configure_prebrew-kwargs2-GS3 AV][GS01234_preinfusion_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -562,10 +562,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '3.29999995231628', }) # --- -# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-configure_prebrew-kwargs2-GS3 AV][GS01234_preinfusion_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS01234_preinfusion_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -581,10 +581,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '2', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-6-kwargs0-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -600,10 +600,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-6-kwargs0-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -641,7 +641,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-6-kwargs0-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -657,10 +657,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-6-kwargs0-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -698,7 +698,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-6-kwargs1-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -714,10 +714,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-6-kwargs1-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -755,7 +755,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-6-kwargs1-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -771,10 +771,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-6-kwargs1-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -812,7 +812,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-7-kwargs2-Linea Mini] +# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -828,10 +828,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-7-kwargs2-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -869,7 +869,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-7-kwargs2-Micra] +# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -885,10 +885,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[preinfusion_time-7-kwargs2-Micra].1 +# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 1ee5ae7115f..be56af2b092 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -14,7 +14,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'preinfusion', }) # --- # name: test_pre_brew_infusion_select[GS3 AV].1 @@ -34,7 +34,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.gs01234_prebrew_infusion_mode', 'has_entity_name': True, 'hidden_by': None, @@ -71,7 +71,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'preinfusion', }) # --- # name: test_pre_brew_infusion_select[Linea Mini].1 @@ -91,7 +91,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.lm01234_prebrew_infusion_mode', 'has_entity_name': True, 'hidden_by': None, @@ -128,7 +128,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'preinfusion', }) # --- # name: test_pre_brew_infusion_select[Micra].1 @@ -148,7 +148,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.mr01234_prebrew_infusion_mode', 'has_entity_name': True, 'hidden_by': None, @@ -185,7 +185,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3', + 'state': '1', }) # --- # name: test_steam_boiler_level[Micra].1 diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index 71422b8b850..2237a8416e1 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -50,7 +50,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '93', + 'state': '96.5', }) # --- # name: test_sensors[GS01234_current_steam_temperature-entry] @@ -104,7 +104,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '113', + 'state': '123.800003051758', }) # --- # name: test_sensors[GS01234_shot_timer-entry] @@ -205,7 +205,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '13', + 'state': '1047', }) # --- # name: test_sensors[GS01234_total_flushes_made-entry] @@ -255,6 +255,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '69', + 'state': '1740', }) # --- diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 59053c5c478..09864be1d5c 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -1,4 +1,96 @@ # serializer version: 1 +# name: test_auto_on_off_switches[entry.auto_on_off_Os2OswX] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234_auto_on_off_os2oswx', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto on/off (Os2OswX)', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off', + 'unique_id': 'GS01234_auto_on_off_Os2OswX', + 'unit_of_measurement': None, + }) +# --- +# name: test_auto_on_off_switches[entry.auto_on_off_aXFz5bJ] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234_auto_on_off_axfz5bj', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto on/off (aXFz5bJ)', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off', + 'unique_id': 'GS01234_auto_on_off_aXFz5bJ', + 'unit_of_measurement': None, + }) +# --- +# name: test_auto_on_off_switches[state.auto_on_off_Os2OswX] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Auto on/off (Os2OswX)', + }), + 'context': , + 'entity_id': 'switch.gs01234_auto_on_off_os2oswx', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_auto_on_off_switches[state.auto_on_off_aXFz5bJ] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Auto on/off (aXFz5bJ)', + }), + 'context': , + 'entity_id': 'switch.gs01234_auto_on_off_axfz5bj', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_device DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -20,16 +112,16 @@ 'labels': set({ }), 'manufacturer': 'La Marzocco', - 'model': 'GS3 AV', + 'model': , 'name': 'GS01234', 'name_by_user': None, 'serial_number': 'GS01234', 'suggested_area': None, - 'sw_version': '1.1', + 'sw_version': '1.40', 'via_device_id': None, }) # --- -# name: test_switches[-set_power-args_on0-args_off0] +# name: test_switches[-set_power] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234', @@ -42,7 +134,7 @@ 'state': 'on', }) # --- -# name: test_switches[-set_power-args_on0-args_off0].1 +# name: test_switches[-set_power].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -75,141 +167,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_switches[-set_power-kwargs_on0-kwargs_off0] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234', - 'icon': 'mdi:power', - }), - 'context': , - 'entity_id': 'switch.gs01234', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_switches[-set_power-kwargs_on0-kwargs_off0].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.gs01234', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:power', - 'original_name': None, - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'GS01234_main', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[_auto_on_off-set_auto_on_off_global-args_on1-args_off1] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Auto on/off', - }), - 'context': , - 'entity_id': 'switch.gs01234_auto_on_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_switches[_auto_on_off-set_auto_on_off_global-args_on1-args_off1].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.gs01234_auto_on_off', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Auto on/off', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'auto_on_off', - 'unique_id': 'GS01234_auto_on_off', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[_auto_on_off-set_auto_on_off_global-kwargs_on1-kwargs_off1] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Auto on/off', - 'icon': 'mdi:alarm', - }), - 'context': , - 'entity_id': 'switch.gs01234_auto_on_off', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_switches[_auto_on_off-set_auto_on_off_global-kwargs_on1-kwargs_off1].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.gs01234_auto_on_off', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:alarm', - 'original_name': 'Auto on/off', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'auto_on_off', - 'unique_id': 'GS01234_auto_on_off', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[_steam_boiler-set_steam-args_on2-args_off2] +# name: test_switches[_steam_boiler-set_steam] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Steam boiler', @@ -222,53 +180,7 @@ 'state': 'on', }) # --- -# name: test_switches[_steam_boiler-set_steam-args_on2-args_off2].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.gs01234_steam_boiler', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Steam boiler', - 'platform': 'lamarzocco', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'steam_boiler', - 'unique_id': 'GS01234_steam_boiler_enable', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[_steam_boiler-set_steam-kwargs_on2-kwargs_off2] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'GS01234 Steam boiler', - 'icon': 'mdi:water-boiler', - }), - 'context': , - 'entity_id': 'switch.gs01234_steam_boiler', - 'last_changed': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_switches[_steam_boiler-set_steam-kwargs_on2-kwargs_off2].1 +# name: test_switches[_steam_boiler-set_steam].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 811b1a6f598..4ab8e35ffd0 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -7,8 +7,8 @@ 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 'friendly_name': 'GS01234 Gateway firmware', 'in_progress': False, - 'installed_version': 'v2.2-rc0', - 'latest_version': 'v3.1-rc4', + 'installed_version': 'v3.1-rc4', + 'latest_version': 'v3.5-rc3', 'release_summary': None, 'release_url': None, 'skipped_version': None, @@ -64,8 +64,8 @@ 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 'friendly_name': 'GS01234 Machine firmware', 'in_progress': False, - 'installed_version': '1.1', - 'latest_version': '1.2', + 'installed_version': '1.40', + 'latest_version': '1.55', 'release_summary': None, 'release_url': None, 'skipped_version': None, diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index bb1e16f09a5..d363b96ca21 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -1,7 +1,10 @@ """Tests for La Marzocco binary sensors.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from lmcloud.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion @@ -11,10 +14,11 @@ from homeassistant.helpers import entity_registry as er from . import async_init_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed BINARY_SENSORS = ( "brewing_active", + "backflush_active", "water_tank_empty", ) @@ -70,3 +74,29 @@ async def test_brew_active_unavailable( ) assert state assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_going_unavailable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor is going unavailable after an unsuccessful update.""" + brewing_active_sensor = ( + f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active" + ) + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get(brewing_active_sensor) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(brewing_active_sensor) + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lamarzocco/test_calendar.py b/tests/components/lamarzocco/test_calendar.py index 8cc529c226f..dd590a20db1 100644 --- a/tests/components/lamarzocco/test_calendar.py +++ b/tests/components/lamarzocco/test_calendar.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import async_init_integration +from . import WAKE_UP_SLEEP_ENTRY_IDS, async_init_integration from tests.common import MockConfigEntry @@ -33,34 +33,44 @@ async def test_calendar_events( ) -> None: """Test the calendar.""" - test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.DEFAULT_TIME_ZONE) + test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.get_default_time_zone()) freezer.move_to(test_time) await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number - state = hass.states.get(f"calendar.{serial_number}_auto_on_off_schedule") - assert state - assert state == snapshot + for identifier in WAKE_UP_SLEEP_ENTRY_IDS: + identifier = identifier.lower() + state = hass.states.get( + f"calendar.{serial_number}_auto_on_off_schedule_{identifier}" + ) + assert state + assert state == snapshot( + name=f"state.{serial_number}_auto_on_off_schedule_{identifier}" + ) - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot( + name=f"entry.{serial_number}_auto_on_off_schedule_{identifier}" + ) - events = await hass.services.async_call( - CALENDAR_DOMAIN, - SERVICE_GET_EVENTS, - { - ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule", - EVENT_START_DATETIME: test_time, - EVENT_END_DATETIME: test_time + timedelta(days=23), - }, - blocking=True, - return_response=True, - ) + events = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule_{identifier}", + EVENT_START_DATETIME: test_time, + EVENT_END_DATETIME: test_time + timedelta(days=23), + }, + blocking=True, + return_response=True, + ) - assert events == snapshot + assert events == snapshot( + name=f"events.{serial_number}_auto_on_off_schedule_{identifier}" + ) @pytest.mark.parametrize( @@ -86,16 +96,8 @@ async def test_calendar_edge_cases( end_date: datetime, ) -> None: """Test edge cases.""" - start_date = start_date.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) - end_date = end_date.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) - - # set schedule to be only on Sunday, 07:00 - 07:30 - mock_lamarzocco.schedule[2]["enable"] = "Disabled" - mock_lamarzocco.schedule[4]["enable"] = "Disabled" - mock_lamarzocco.schedule[5]["enable"] = "Disabled" - mock_lamarzocco.schedule[6]["enable"] = "Enabled" - mock_lamarzocco.schedule[6]["on"] = "07:00" - mock_lamarzocco.schedule[6]["off"] = "07:30" + start_date = start_date.replace(tzinfo=dt_util.get_default_time_zone()) + end_date = end_date.replace(tzinfo=dt_util.get_default_time_zone()) await async_init_integration(hass, mock_config_entry) @@ -103,7 +105,7 @@ async def test_calendar_edge_cases( CALENDAR_DOMAIN, SERVICE_GET_EVENTS, { - ATTR_ENTITY_ID: f"calendar.{mock_lamarzocco.serial_number}_auto_on_off_schedule", + ATTR_ENTITY_ID: f"calendar.{mock_lamarzocco.serial_number}_auto_on_off_schedule_{WAKE_UP_SLEEP_ENTRY_IDS[1].lower()}", EVENT_START_DATETIME: start_date, EVENT_END_DATETIME: end_date, }, @@ -123,22 +125,26 @@ async def test_no_calendar_events_global_disable( ) -> None: """Assert no events when global auto on/off is disabled.""" - mock_lamarzocco.current_status["global_auto"] = "Disabled" - test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.DEFAULT_TIME_ZONE) + wake_up_sleep_entry_id = WAKE_UP_SLEEP_ENTRY_IDS[0] + + mock_lamarzocco.config.wake_up_sleep_entries[wake_up_sleep_entry_id].enabled = False + test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.get_default_time_zone()) freezer.move_to(test_time) await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number - state = hass.states.get(f"calendar.{serial_number}_auto_on_off_schedule") + state = hass.states.get( + f"calendar.{serial_number}_auto_on_off_schedule_{wake_up_sleep_entry_id.lower()}" + ) assert state events = await hass.services.async_call( CALENDAR_DOMAIN, SERVICE_GET_EVENTS, { - ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule", + ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule_{wake_up_sleep_entry_id.lower()}", EVENT_START_DATETIME: test_time, EVENT_END_DATETIME: test_time + timedelta(days=23), }, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 14f794000d8..92ecd0a13f4 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -1,17 +1,26 @@ """Test the La Marzocco config flow.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from lmcloud.exceptions import AuthFail, RequestNotSuccessful +from lmcloud.models import LaMarzoccoDeviceInfo -from homeassistant import config_entries -from homeassistant.components.lamarzocco.const import ( - CONF_MACHINE, - CONF_USE_BLUETOOTH, - DOMAIN, +from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE +from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN +from homeassistant.config_entries import ( + SOURCE_BLUETOOTH, + SOURCE_REAUTH, + SOURCE_USER, + ConfigEntryState, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, ) -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -21,7 +30,7 @@ from tests.common import MockConfigEntry async def __do_successful_user_step( - hass: HomeAssistant, result: FlowResult + hass: HomeAssistant, result: FlowResult, mock_cloud_client: MagicMock ) -> FlowResult: """Successfully configure the user step.""" result2 = await hass.config_entries.flow.async_configure( @@ -36,51 +45,63 @@ async def __do_successful_user_step( async def __do_sucessful_machine_selection_step( - hass: HomeAssistant, result2: FlowResult, mock_lamarzocco: MagicMock + hass: HomeAssistant, result2: FlowResult, mock_device_info: LaMarzoccoDeviceInfo ) -> None: """Successfully configure the machine selection step.""" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, - }, - ) + + with patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_device_info.serial_number, + }, + ) await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == mock_lamarzocco.serial_number + assert result3["title"] == "GS3" assert result3["data"] == { **USER_INPUT, CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_MODEL: mock_device_info.model, + CONF_NAME: mock_device_info.name, + CONF_TOKEN: mock_device_info.communication_key, } -async def test_form(hass: HomeAssistant, mock_lamarzocco: MagicMock) -> None: +async def test_form( + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_device_info: LaMarzoccoDeviceInfo, +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "user" - result2 = await __do_successful_user_step(hass, result) - await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) - - assert len(mock_lamarzocco.check_local_connection.mock_calls) == 1 + result2 = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) async def test_form_abort_already_configured( hass: HomeAssistant, - mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, + mock_device_info: LaMarzoccoDeviceInfo, mock_config_entry: MockConfigEntry, ) -> None: """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -98,7 +119,7 @@ async def test_form_abort_already_configured( result2["flow_id"], { CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_MACHINE: mock_device_info.serial_number, }, ) await hass.async_block_till_done() @@ -108,13 +129,15 @@ async def test_form_abort_already_configured( async def test_form_invalid_auth( - hass: HomeAssistant, mock_lamarzocco: MagicMock + hass: HomeAssistant, + mock_device_info: LaMarzoccoDeviceInfo, + mock_cloud_client: MagicMock, ) -> None: """Test invalid auth error.""" - mock_lamarzocco.get_all_machines.side_effect = AuthFail("") + mock_cloud_client.get_customer_fleet.side_effect = AuthFail("") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) result2 = await hass.config_entries.flow.async_configure( @@ -124,20 +147,22 @@ async def test_form_invalid_auth( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 # test recovery from failure - mock_lamarzocco.get_all_machines.side_effect = None - result2 = await __do_successful_user_step(hass, result) - await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + mock_cloud_client.get_customer_fleet.side_effect = None + result2 = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) async def test_form_invalid_host( - hass: HomeAssistant, mock_lamarzocco: MagicMock + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_device_info: LaMarzoccoDeviceInfo, ) -> None: """Test invalid auth error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -148,38 +173,41 @@ async def test_form_invalid_host( ) await hass.async_block_till_done() - mock_lamarzocco.check_local_connection.return_value = False - assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "machine_selection" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - CONF_MACHINE: mock_lamarzocco.serial_number, - }, - ) + with patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", + return_value=False, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_device_info.serial_number, + }, + ) await hass.async_block_till_done() assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"host": "cannot_connect"} - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 # test recovery from failure - mock_lamarzocco.check_local_connection.return_value = True - await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) async def test_form_cannot_connect( - hass: HomeAssistant, mock_lamarzocco: MagicMock + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_device_info: LaMarzoccoDeviceInfo, ) -> None: """Test cannot connect error.""" - mock_lamarzocco.get_all_machines.return_value = [] + mock_cloud_client.get_customer_fleet.return_value = {} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) result2 = await hass.config_entries.flow.async_configure( @@ -189,9 +217,9 @@ async def test_form_cannot_connect( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "no_machines"} - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - mock_lamarzocco.get_all_machines.side_effect = RequestNotSuccessful("") + mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("") result2 = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -199,21 +227,26 @@ async def test_form_cannot_connect( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 2 + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2 # test recovery from failure - mock_lamarzocco.get_all_machines.side_effect = None - mock_lamarzocco.get_all_machines.return_value = [ - (mock_lamarzocco.serial_number, mock_lamarzocco.model_name) - ] - result2 = await __do_successful_user_step(hass, result) - await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + mock_cloud_client.get_customer_fleet.side_effect = None + mock_cloud_client.get_customer_fleet.return_value = { + mock_device_info.serial_number: mock_device_info + } + result2 = await __do_successful_user_step(hass, result, mock_cloud_client) + await __do_sucessful_machine_selection_step(hass, result2, mock_device_info) async def test_reauth_flow( - hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test that the reauth flow.""" + + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( DOMAIN, context={ @@ -235,19 +268,21 @@ async def test_reauth_flow( assert result2["type"] is FlowResultType.ABORT await hass.async_block_till_done() assert result2["reason"] == "reauth_successful" - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 assert mock_config_entry.data[CONF_PASSWORD] == "new_password" async def test_bluetooth_discovery( - hass: HomeAssistant, mock_lamarzocco: MagicMock + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, ) -> None: """Test bluetooth discovery.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model_name, mock_lamarzocco.serial_number + mock_lamarzocco.model, mock_lamarzocco.serial_number ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, data=service_info + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info ) assert result["type"] is FlowResultType.FORM @@ -260,82 +295,95 @@ async def test_bluetooth_discovery( assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "machine_selection" - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - }, - ) + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 + with patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + }, + ) await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == mock_lamarzocco.serial_number + assert result3["title"] == "GS3" assert result3["data"] == { **USER_INPUT, CONF_HOST: "192.168.1.1", CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_NAME: service_info.name, + CONF_NAME: "GS3", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_MODEL: mock_lamarzocco.model, + CONF_TOKEN: "token", } - assert len(mock_lamarzocco.check_local_connection.mock_calls) == 1 - async def test_bluetooth_discovery_errors( - hass: HomeAssistant, mock_lamarzocco: MagicMock + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_cloud_client: MagicMock, + mock_device_info: LaMarzoccoDeviceInfo, ) -> None: """Test bluetooth discovery errors.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model_name, mock_lamarzocco.serial_number + mock_lamarzocco.model, mock_lamarzocco.serial_number ) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, + context={"source": SOURCE_BLUETOOTH}, data=service_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - mock_lamarzocco.get_all_machines.return_value = [("GS98765", "GS3 MP")] + mock_cloud_client.get_customer_fleet.return_value = {"GS98765", ""} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "machine_not_found"} - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1 - mock_lamarzocco.get_all_machines.return_value = [ - (mock_lamarzocco.serial_number, mock_lamarzocco.model_name) - ] + mock_cloud_client.get_customer_fleet.return_value = { + mock_device_info.serial_number: mock_device_info + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "machine_selection" - assert len(mock_lamarzocco.get_all_machines.mock_calls) == 2 + assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2 - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - CONF_HOST: "192.168.1.1", - }, - ) + with patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + }, + ) await hass.async_block_till_done() assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == mock_lamarzocco.serial_number + assert result3["title"] == "GS3" assert result3["data"] == { **USER_INPUT, CONF_HOST: "192.168.1.1", CONF_MACHINE: mock_lamarzocco.serial_number, - CONF_NAME: service_info.name, + CONF_NAME: "GS3", CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_MODEL: mock_lamarzocco.model, + CONF_TOKEN: "token", } diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index a4bc25f64af..2c812f79438 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -1,15 +1,19 @@ """Test initialization of lamarzocco.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from lmcloud.const import FirmwareType from lmcloud.exceptions import AuthFail, RequestNotSuccessful +import pytest +from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir -from . import async_init_integration, get_bluetooth_service_info +from . import USER_INPUT, async_init_integration, get_bluetooth_service_info from tests.common import MockConfigEntry @@ -20,7 +24,9 @@ async def test_load_unload_config_entry( mock_lamarzocco: MagicMock, ) -> None: """Test loading and unloading the integration.""" - await async_init_integration(hass, mock_config_entry) + 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 @@ -36,11 +42,13 @@ async def test_config_entry_not_ready( mock_lamarzocco: MagicMock, ) -> None: """Test the La Marzocco configuration entry not ready.""" - mock_lamarzocco.update_local_machine_status.side_effect = RequestNotSuccessful("") + mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") - await async_init_integration(hass, mock_config_entry) + 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_lamarzocco.update_local_machine_status.mock_calls) == 1 + assert len(mock_lamarzocco.get_config.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -50,11 +58,13 @@ async def test_invalid_auth( mock_lamarzocco: MagicMock, ) -> None: """Test auth error during setup.""" - mock_lamarzocco.update_local_machine_status.side_effect = AuthFail("") - await async_init_integration(hass, mock_config_entry) + mock_lamarzocco.get_config.side_effect = AuthFail("") + 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.SETUP_ERROR - assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 + assert len(mock_lamarzocco.get_config.mock_calls) == 1 flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -68,27 +78,132 @@ async def test_invalid_auth( assert flow["context"].get("entry_id") == mock_config_entry.entry_id +async def test_v1_migration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_client: MagicMock, + mock_lamarzocco: MagicMock, +) -> None: + """Test v1 -> v2 Migration.""" + entry_v1 = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=mock_lamarzocco.serial_number, + data={ + **USER_INPUT, + CONF_HOST: "host", + CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_MAC: "aa:bb:cc:dd:ee:ff", + }, + ) + + entry_v1.add_to_hass(hass) + await hass.config_entries.async_setup(entry_v1.entry_id) + await hass.async_block_till_done() + + assert entry_v1.version == 2 + assert dict(entry_v1.data) == dict(mock_config_entry.data) | { + CONF_MAC: "aa:bb:cc:dd:ee:ff" + } + + +async def test_migration_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_client: MagicMock, + mock_lamarzocco: MagicMock, +) -> None: + """Test errors during migration.""" + + mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("Error") + + entry_v1 = MockConfigEntry( + domain=DOMAIN, + version=1, + unique_id=mock_lamarzocco.serial_number, + data={ + **USER_INPUT, + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + entry_v1.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry_v1.entry_id) + assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_config_flow_entry_migration_downgrade( + hass: HomeAssistant, +) -> None: + """Test that config entry fails setup if the version is from the future.""" + entry = MockConfigEntry(domain=DOMAIN, version=3) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) + + async def test_bluetooth_is_set_from_discovery( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock, ) -> None: - """Assert we're not searching for a new BT device when we already found one previously.""" - - # remove the bluetooth configuration from entry - data = mock_config_entry.data.copy() - del data[CONF_NAME] - del data[CONF_MAC] - hass.config_entries.async_update_entry(mock_config_entry, data=data) + """Check we can fill a device from discovery info.""" service_info = get_bluetooth_service_info( - mock_lamarzocco.model_name, mock_lamarzocco.serial_number + mock_lamarzocco.model, mock_lamarzocco.serial_number ) - with patch( - "homeassistant.components.lamarzocco.coordinator.async_discovered_service_info", - return_value=[service_info], + with ( + patch( + "homeassistant.components.lamarzocco.async_discovered_service_info", + return_value=[service_info], + ) as discovery, + patch( + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoMachine" + ) as init_device, ): await async_init_integration(hass, mock_config_entry) - mock_lamarzocco.init_bluetooth_with_known_device.assert_called_once() + discovery.assert_called_once() + init_device.assert_called_once() + _, kwargs = init_device.call_args + assert kwargs["bluetooth_client"] is not None assert mock_config_entry.data[CONF_NAME] == service_info.name assert mock_config_entry.data[CONF_MAC] == service_info.address + + +async def test_websocket_closed_on_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test the websocket is closed on unload.""" + with patch( + "homeassistant.components.lamarzocco.LaMarzoccoLocalClient", + autospec=True, + ) as local_client: + client = local_client.return_value + client.websocket = AsyncMock() + client.websocket.connected = True + await async_init_integration(hass, mock_config_entry) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + client.websocket.close.assert_called_once() + + +@pytest.mark.parametrize( + ("version", "issue_exists"), [("v3.5-rc6", False), ("v3.3-rc4", True)] +) +async def test_gateway_version_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, + version: str, + issue_exists: bool, +) -> None: + """Make sure we get the issue for certain gateway firmware versions.""" + mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = version + + await async_init_integration(hass, mock_config_entry) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "unsupported_gateway_firmware") + assert (issue is not None) == issue_exists diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index 8cba3d2387d..288c78c26dd 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock -from lmcloud.const import KEYS_PER_MODEL, LaMarzoccoModel +from lmcloud.const import ( + KEYS_PER_MODEL, + BoilerType, + MachineModel, + PhysicalKey, + PrebrewMode, +) import pytest from syrupy import SnapshotAssertion @@ -15,17 +21,22 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("init_integration") +from . import async_init_integration + +from tests.common import MockConfigEntry async def test_coffee_boiler( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the La Marzocco coffee temperature Number.""" + + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number state = hass.states.get(f"number.{serial_number}_coffee_target_temperature") @@ -47,35 +58,34 @@ async def test_coffee_boiler( SERVICE_SET_VALUE, { ATTR_ENTITY_ID: f"number.{serial_number}_coffee_target_temperature", - ATTR_VALUE: 95, + ATTR_VALUE: 94, }, blocking=True, ) - assert len(mock_lamarzocco.set_coffee_temp.mock_calls) == 1 - mock_lamarzocco.set_coffee_temp.assert_called_once_with( - temperature=95, ble_device=None + assert len(mock_lamarzocco.set_temp.mock_calls) == 1 + mock_lamarzocco.set_temp.assert_called_once_with( + boiler=BoilerType.COFFEE, temperature=94 ) -@pytest.mark.parametrize( - "device_fixture", [LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP] -) +@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV, MachineModel.GS3_MP]) @pytest.mark.parametrize( ("entity_name", "value", "func_name", "kwargs"), [ ( "steam_target_temperature", 131, - "set_steam_temp", - {"temperature": 131, "ble_device": None}, + "set_temp", + {"boiler": BoilerType.STEAM, "temperature": 131}, ), - ("tea_water_duration", 15, "set_dose_hot_water", {"value": 15}), + ("tea_water_duration", 15, "set_dose_tea_water", {"dose": 15}), ], ) async def test_gs3_exclusive( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, @@ -85,7 +95,7 @@ async def test_gs3_exclusive( kwargs: dict[str, float], ) -> None: """Test exclusive entities for GS3 AV/MP.""" - + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number func = getattr(mock_lamarzocco, func_name) @@ -118,14 +128,15 @@ async def test_gs3_exclusive( @pytest.mark.parametrize( - "device_fixture", [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.LINEA_MINI] + "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] ) async def test_gs3_exclusive_none( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Ensure GS3 exclusive is None for unsupported models.""" - + await async_init_integration(hass, mock_config_entry) ENTITIES = ("steam_target_temperature", "tea_water_duration") serial_number = mock_lamarzocco.serial_number @@ -135,29 +146,50 @@ async def test_gs3_exclusive_none( @pytest.mark.parametrize( - "device_fixture", [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.LINEA_MINI] + "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] ) @pytest.mark.parametrize( - ("entity_name", "value", "kwargs"), + ("entity_name", "function_name", "prebrew_mode", "value", "kwargs"), [ - ("prebrew_off_time", 6, {"on_time": 3000, "off_time": 6000, "key": 1}), - ("prebrew_on_time", 6, {"on_time": 6000, "off_time": 5000, "key": 1}), - ("preinfusion_time", 7, {"off_time": 7000, "key": 1}), + ( + "prebrew_off_time", + "set_prebrew_time", + PrebrewMode.PREBREW, + 6, + {"prebrew_off_time": 6.0, "key": PhysicalKey.A}, + ), + ( + "prebrew_on_time", + "set_prebrew_time", + PrebrewMode.PREBREW, + 6, + {"prebrew_on_time": 6.0, "key": PhysicalKey.A}, + ), + ( + "preinfusion_time", + "set_preinfusion_time", + PrebrewMode.PREINFUSION, + 7, + {"preinfusion_time": 7.0, "key": PhysicalKey.A}, + ), ], ) async def test_pre_brew_infusion_numbers( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, entity_name: str, + function_name: str, + prebrew_mode: PrebrewMode, value: float, kwargs: dict[str, float], ) -> None: """Test the La Marzocco prebrew/-infusion sensors.""" - mock_lamarzocco.current_status["enable_preinfusion"] = True + mock_lamarzocco.config.prebrew_mode = prebrew_mode + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number @@ -168,12 +200,8 @@ async def test_pre_brew_infusion_numbers( entry = entity_registry.async_get(state.entity_id) assert entry - assert entry.device_id assert entry == snapshot - device = device_registry.async_get(entry.device_id) - assert device - # service call await hass.services.async_call( NUMBER_DOMAIN, @@ -185,43 +213,97 @@ async def test_pre_brew_infusion_numbers( blocking=True, ) - assert len(mock_lamarzocco.configure_prebrew.mock_calls) == 1 - mock_lamarzocco.configure_prebrew.assert_called_once_with(**kwargs) + function = getattr(mock_lamarzocco, function_name) + function.assert_called_once_with(**kwargs) -@pytest.mark.parametrize("device_fixture", [LaMarzoccoModel.GS3_AV]) +@pytest.mark.parametrize( + "device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI] +) +@pytest.mark.parametrize( + ("prebrew_mode", "entity", "unavailable"), + [ + ( + PrebrewMode.PREBREW, + ("prebrew_off_time", "prebrew_on_time"), + ("preinfusion_time",), + ), + ( + PrebrewMode.PREINFUSION, + ("preinfusion_time",), + ("prebrew_off_time", "prebrew_on_time"), + ), + ], +) +async def test_pre_brew_infusion_numbers_unavailable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + prebrew_mode: PrebrewMode, + entity: tuple[str, ...], + unavailable: tuple[str, ...], +) -> None: + """Test entities are unavailable depending on selected state.""" + + mock_lamarzocco.config.prebrew_mode = prebrew_mode + await async_init_integration(hass, mock_config_entry) + + serial_number = mock_lamarzocco.serial_number + for entity_name in entity: + state = hass.states.get(f"number.{serial_number}_{entity_name}") + assert state + assert state.state != STATE_UNAVAILABLE + + for entity_name in unavailable: + state = hass.states.get(f"number.{serial_number}_{entity_name}") + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("entity_name", "value", "function_name", "kwargs"), + ("entity_name", "value", "prebrew_mode", "function_name", "kwargs"), [ ( "prebrew_off_time", 6, - "configure_prebrew", - {"on_time": 3000, "off_time": 6000}, + PrebrewMode.PREBREW, + "set_prebrew_time", + {"prebrew_off_time": 6.0}, ), ( "prebrew_on_time", 6, - "configure_prebrew", - {"on_time": 6000, "off_time": 5000}, + PrebrewMode.PREBREW, + "set_prebrew_time", + {"prebrew_on_time": 6.0}, ), - ("preinfusion_time", 7, "configure_prebrew", {"off_time": 7000}), - ("dose", 6, "set_dose", {"value": 6}), + ( + "preinfusion_time", + 7, + PrebrewMode.PREINFUSION, + "set_preinfusion_time", + {"preinfusion_time": 7.0}, + ), + ("dose", 6, PrebrewMode.DISABLED, "set_dose", {"dose": 6}), ], ) async def test_pre_brew_infusion_key_numbers( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, entity_name: str, value: float, + prebrew_mode: PrebrewMode, function_name: str, kwargs: dict[str, float], ) -> None: """Test the La Marzocco number sensors for GS3AV model.""" - mock_lamarzocco.current_status["enable_preinfusion"] = True + mock_lamarzocco.config.prebrew_mode = prebrew_mode + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number @@ -230,7 +312,7 @@ async def test_pre_brew_infusion_key_numbers( state = hass.states.get(f"number.{serial_number}_{entity_name}") assert state is None - for key in range(1, KEYS_PER_MODEL[mock_lamarzocco.model_name] + 1): + for key in PhysicalKey: state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}") assert state assert state == snapshot(name=f"{serial_number}_{entity_name}_key_{key}-state") @@ -248,17 +330,18 @@ async def test_pre_brew_infusion_key_numbers( kwargs["key"] = key - assert len(func.mock_calls) == key + assert len(func.mock_calls) == key.value func.assert_called_with(**kwargs) -@pytest.mark.parametrize("device_fixture", [LaMarzoccoModel.GS3_AV]) +@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV]) async def test_disabled_entites( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test the La Marzocco prebrew/-infusion sensors for GS3AV model.""" - + await async_init_integration(hass, mock_config_entry) ENTITIES = ( "prebrew_off_time", "prebrew_on_time", @@ -269,21 +352,22 @@ async def test_disabled_entites( serial_number = mock_lamarzocco.serial_number for entity_name in ENTITIES: - for key in range(1, KEYS_PER_MODEL[mock_lamarzocco.model_name] + 1): + for key in PhysicalKey: state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}") assert state is None @pytest.mark.parametrize( "device_fixture", - [LaMarzoccoModel.GS3_MP, LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.LINEA_MINI], + [MachineModel.GS3_MP, MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI], ) -async def test_not_existing_key_entites( +async def test_not_existing_key_entities( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Assert not existing key entities.""" - + await async_init_integration(hass, mock_config_entry) serial_number = mock_lamarzocco.serial_number for entity in ( @@ -292,42 +376,6 @@ async def test_not_existing_key_entites( "preinfusion_time", "set_dose", ): - for key in range(1, KEYS_PER_MODEL[LaMarzoccoModel.GS3_AV] + 1): + for key in range(1, KEYS_PER_MODEL[MachineModel.GS3_AV] + 1): state = hass.states.get(f"number.{serial_number}_{entity}_key_{key}") assert state is None - - -@pytest.mark.parametrize( - "device_fixture", - [LaMarzoccoModel.GS3_MP], -) -async def test_not_existing_entites( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, -) -> None: - """Assert not existing entities.""" - - serial_number = mock_lamarzocco.serial_number - - for entity in ( - "prebrew_off_time", - "prebrew_on_time", - "preinfusion_time", - "set_dose", - ): - state = hass.states.get(f"number.{serial_number}_{entity}") - assert state is None - - -@pytest.mark.parametrize("device_fixture", [LaMarzoccoModel.LINEA_MICRA]) -async def test_not_settable_entites( - hass: HomeAssistant, - mock_lamarzocco: MagicMock, -) -> None: - """Assert not settable causes error.""" - - serial_number = mock_lamarzocco.serial_number - - state = hass.states.get(f"number.{serial_number}_preinfusion_time") - assert state - assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 497a95f6d0d..e3521b473bd 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from lmcloud.const import LaMarzoccoModel +from lmcloud.const import MachineModel, PrebrewMode, SteamLevel import pytest from syrupy import SnapshotAssertion @@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er pytestmark = pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize("device_fixture", [LaMarzoccoModel.LINEA_MICRA]) +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MICRA]) async def test_steam_boiler_level( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -44,18 +44,17 @@ async def test_steam_boiler_level( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.{serial_number}_steam_level", - ATTR_OPTION: "1", + ATTR_OPTION: "2", }, blocking=True, ) - assert len(mock_lamarzocco.set_steam_level.mock_calls) == 1 - mock_lamarzocco.set_steam_level.assert_called_once_with(1, None) + mock_lamarzocco.set_steam_level.assert_called_once_with(level=SteamLevel.LEVEL_2) @pytest.mark.parametrize( "device_fixture", - [LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP, LaMarzoccoModel.LINEA_MINI], + [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MINI], ) async def test_steam_boiler_level_none( hass: HomeAssistant, @@ -70,7 +69,7 @@ async def test_steam_boiler_level_none( @pytest.mark.parametrize( "device_fixture", - [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.GS3_AV, LaMarzoccoModel.LINEA_MINI], + [MachineModel.LINEA_MICRA, MachineModel.GS3_AV, MachineModel.LINEA_MINI], ) async def test_pre_brew_infusion_select( hass: HomeAssistant, @@ -97,20 +96,17 @@ async def test_pre_brew_infusion_select( SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.{serial_number}_prebrew_infusion_mode", - ATTR_OPTION: "preinfusion", + ATTR_OPTION: "prebrew", }, blocking=True, ) - assert len(mock_lamarzocco.select_pre_brew_infusion_mode.mock_calls) == 1 - mock_lamarzocco.select_pre_brew_infusion_mode.assert_called_once_with( - mode="Preinfusion" - ) + mock_lamarzocco.set_prebrew_mode.assert_called_once_with(mode=PrebrewMode.PREBREW) @pytest.mark.parametrize( "device_fixture", - [LaMarzoccoModel.GS3_MP], + [MachineModel.GS3_MP], ) async def test_pre_brew_infusion_select_none( hass: HomeAssistant, diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index b5f551309b6..1ce56724fa3 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from lmcloud.const import MachineModel import pytest from syrupy import SnapshotAssertion @@ -71,3 +72,17 @@ async def test_shot_timer_unavailable( state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") assert state assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) +async def test_no_steam_linea_mini( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure Linea Mini has no steam temp.""" + await async_init_integration(hass, mock_config_entry) + + serial_number = mock_lamarzocco.serial_number + state = hass.states.get(f"sensor.{serial_number}_current_temp_steam") + assert state is None diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index e1924f0a8ca..4f60b264a1d 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock import pytest from syrupy import SnapshotAssertion -from homeassistant.components.lamarzocco.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -15,35 +14,39 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from . import WAKE_UP_SLEEP_ENTRY_IDS, async_init_integration -pytestmark = pytest.mark.usefixtures("init_integration") +from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("entity_name", "method_name", "args_on", "args_off"), + ( + "entity_name", + "method_name", + ), [ - ("", "set_power", (True, None), (False, None)), ( - "_auto_on_off", - "set_auto_on_off_global", - (True,), - (False,), + "", + "set_power", + ), + ( + "_steam_boiler", + "set_steam", ), - ("_steam_boiler", "set_steam", (True, None), (False, None)), ], ) async def test_switches( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, entity_name: str, method_name: str, - args_on: tuple, - args_off: tuple, ) -> None: """Test the La Marzocco switches.""" + await async_init_integration(hass, mock_config_entry) + serial_number = mock_lamarzocco.serial_number control_fn = getattr(mock_lamarzocco, method_name) @@ -66,7 +69,7 @@ async def test_switches( ) assert len(control_fn.mock_calls) == 1 - control_fn.assert_called_once_with(*args_off) + control_fn.assert_called_once_with(False) await hass.services.async_call( SWITCH_DOMAIN, @@ -78,18 +81,21 @@ async def test_switches( ) assert len(control_fn.mock_calls) == 2 - control_fn.assert_called_with(*args_on) + control_fn.assert_called_with(True) async def test_device( hass: HomeAssistant, mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test the device for one switch.""" + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") assert state @@ -102,24 +108,53 @@ async def test_device( assert device == snapshot -async def test_call_without_bluetooth_works( +async def test_auto_on_off_switches( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test that if not using bluetooth, the switch still works.""" + """Test the auto on off/switches.""" + + await async_init_integration(hass, mock_config_entry) + serial_number = mock_lamarzocco.serial_number - coordinator = hass.data[DOMAIN][mock_config_entry.entry_id] - coordinator._use_bluetooth = False - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: f"switch.{serial_number}_steam_boiler", - }, - blocking=True, - ) + for wake_up_sleep_entry_id in WAKE_UP_SLEEP_ENTRY_IDS: + state = hass.states.get( + f"switch.{serial_number}_auto_on_off_{wake_up_sleep_entry_id}" + ) + assert state + assert state == snapshot(name=f"state.auto_on_off_{wake_up_sleep_entry_id}") - assert len(mock_lamarzocco.set_steam.mock_calls) == 1 - mock_lamarzocco.set_steam.assert_called_once_with(False, None) + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot(name=f"entry.auto_on_off_{wake_up_sleep_entry_id}") + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: f"switch.{serial_number}_auto_on_off_{wake_up_sleep_entry_id}", + }, + blocking=True, + ) + + wake_up_sleep_entry = mock_lamarzocco.config.wake_up_sleep_entries[ + wake_up_sleep_entry_id + ] + wake_up_sleep_entry.enabled = False + + mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: f"switch.{serial_number}_auto_on_off_{wake_up_sleep_entry_id}", + }, + blocking=True, + ) + wake_up_sleep_entry.enabled = True + mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index 3b1323d1c73..02330daf794 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from lmcloud.const import LaMarzoccoUpdateableComponent +from lmcloud.const import FirmwareType import pytest from syrupy import SnapshotAssertion @@ -18,8 +18,8 @@ pytestmark = pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( ("entity_name", "component"), [ - ("machine_firmware", LaMarzoccoUpdateableComponent.MACHINE), - ("gateway_firmware", LaMarzoccoUpdateableComponent.GATEWAY), + ("machine_firmware", FirmwareType.MACHINE), + ("gateway_firmware", FirmwareType.GATEWAY), ], ) async def test_update_entites( @@ -28,7 +28,7 @@ async def test_update_entites( entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, entity_name: str, - component: LaMarzoccoUpdateableComponent, + component: FirmwareType, ) -> None: """Test the La Marzocco update entities.""" diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index bd2ae275970..dd3885b78d9 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from demetriek import CloudDevice, Device -from pydantic import parse_raw_as +from pydantic import parse_raw_as # pylint: disable=no-name-in-module import pytest +from typing_extensions import Generator from homeassistant.components.application_credentials import ( ClientCredential, @@ -46,7 +46,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.lametric.async_setup_entry", return_value=True @@ -55,7 +55,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_lametric_cloud() -> Generator[MagicMock, None, None]: +def mock_lametric_cloud() -> Generator[MagicMock]: """Return a mocked LaMetric Cloud client.""" with patch( "homeassistant.components.lametric.config_flow.LaMetricCloud", autospec=True @@ -74,7 +74,7 @@ def device_fixture() -> str: @pytest.fixture -def mock_lametric(request, device_fixture: str) -> Generator[MagicMock, None, None]: +def mock_lametric(device_fixture: str) -> Generator[MagicMock]: """Return a mocked LaMetric TIME client.""" with ( patch( diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index e755329b93d..a6cdca5b426 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -227,7 +227,6 @@ async def test_button_error( {ATTR_ENTITY_ID: "button.frenck_s_lametric_next_app"}, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("button.frenck_s_lametric_next_app") assert state @@ -250,7 +249,6 @@ async def test_button_connection_error( {ATTR_ENTITY_ID: "button.frenck_s_lametric_next_app"}, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("button.frenck_s_lametric_next_app") assert state diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index d5466abbd41..681abf850d2 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -150,7 +150,6 @@ async def test_number_error( }, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("number.frenck_s_lametric_volume") assert state @@ -180,7 +179,6 @@ async def test_number_connection_error( }, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("number.frenck_s_lametric_volume") assert state diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index bd7bc775714..6b3fa291e9c 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -94,7 +94,6 @@ async def test_select_error( }, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("select.frenck_s_lametric_brightness_mode") assert state @@ -124,7 +123,6 @@ async def test_select_connection_error( }, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("select.frenck_s_lametric_brightness_mode") assert state diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index b81428bb402..367d5605e06 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -114,7 +114,6 @@ async def test_switch_error( }, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("switch.frenck_s_lametric_bluetooth") assert state @@ -143,7 +142,6 @@ async def test_switch_connection_error( }, blocking=True, ) - await hass.async_block_till_done() state = hass.states.get("switch.frenck_s_lametric_bluetooth") assert state diff --git a/tests/components/landisgyr_heat_meter/conftest.py b/tests/components/landisgyr_heat_meter/conftest.py index df7e4a44ce9..22f29b3a4b1 100644 --- a/tests/components/landisgyr_heat_meter/conftest.py +++ b/tests/components/landisgyr_heat_meter/conftest.py @@ -1,13 +1,13 @@ """Define fixtures for Landis + Gyr Heat Meter tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.landisgyr_heat_meter.async_setup_entry", diff --git a/tests/components/lastfm/__init__.py b/tests/components/lastfm/__init__.py index 8f133607c8d..9fe946f8dff 100644 --- a/tests/components/lastfm/__init__.py +++ b/tests/components/lastfm/__init__.py @@ -6,6 +6,7 @@ from pylast import PyLastError, Track from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.typing import UNDEFINED, UndefinedType API_KEY = "asdasdasdasdasd" USERNAME_1 = "testaccount1" @@ -52,16 +53,16 @@ class MockUser: username: str = USERNAME_1, now_playing_result: Track | None = None, thrown_error: Exception | None = None, - friends: list = [], - recent_tracks: list[Track] = [], - top_tracks: list[Track] = [], + friends: list | UndefinedType = UNDEFINED, + recent_tracks: list[Track] | UndefinedType = UNDEFINED, + top_tracks: list[Track] | UndefinedType = UNDEFINED, ) -> None: """Initialize the mock.""" self._now_playing_result = now_playing_result self._thrown_error = thrown_error - self._friends = friends - self._recent_tracks = recent_tracks - self._top_tracks = top_tracks + self._friends = [] if friends is UNDEFINED else friends + self._recent_tracks = [] if recent_tracks is UNDEFINED else recent_tracks + self._top_tracks = [] if top_tracks is UNDEFINED else top_tracks self.name = username def get_name(self, capitalized: bool) -> str: diff --git a/tests/components/lastfm/conftest.py b/tests/components/lastfm/conftest.py index 0575df2bbca..361bb401521 100644 --- a/tests/components/lastfm/conftest.py +++ b/tests/components/lastfm/conftest.py @@ -11,16 +11,11 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.lastfm import ( - API_KEY, - USERNAME_1, - USERNAME_2, - MockNetwork, - MockUser, -) +from . import API_KEY, USERNAME_1, USERNAME_2, MockNetwork, MockUser -ComponentSetup = Callable[[MockConfigEntry, MockUser], Awaitable[None]] +from tests.common import MockConfigEntry + +type ComponentSetup = Callable[[MockConfigEntry, MockUser], Awaitable[None]] @pytest.fixture(name="config_entry") diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py index 87115cb1900..e7066ed43c1 100644 --- a/tests/components/lawn_mower/test_init.py +++ b/tests/components/lawn_mower/test_init.py @@ -1,9 +1,9 @@ """The tests for the lawn mower integration.""" -from collections.abc import Generator from unittest.mock import MagicMock import pytest +from typing_extensions import Generator from homeassistant.components.lawn_mower import ( DOMAIN as LAWN_MOWER_DOMAIN, @@ -52,7 +52,7 @@ class MockLawnMowerEntity(LawnMowerEntity): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -67,8 +67,8 @@ async def test_lawn_mower_setup(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup( - config_entry, Platform.LAWN_MOWER + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.LAWN_MOWER] ) return True diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index 6571b63ddf1..f24fdbc054f 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -12,6 +12,7 @@ import pytest from homeassistant.components.lcn.const import DOMAIN from homeassistant.components.lcn.helpers import generate_unique_id from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -78,7 +79,7 @@ def create_config_entry(name): @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 4ef43e826f3..67bd7568254 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -11,7 +11,7 @@ from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.lcn import device_trigger from homeassistant.components.lcn.const import DOMAIN, KEY_ACTIONS, SENDKEYS from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.setup import async_setup_component @@ -34,13 +34,13 @@ async def test_get_triggers_module_device( CONF_DEVICE_ID: device.id, "metadata": {}, } - for trigger in [ + for trigger in ( "transmitter", "transponder", "fingerprint", "codelock", "send_keys", - ] + ) ] triggers = await async_get_device_automations( @@ -72,7 +72,7 @@ async def test_get_triggers_non_module_device( async def test_if_fires_on_transponder_event( - hass: HomeAssistant, calls, entry, lcn_connection + hass: HomeAssistant, calls: list[ServiceCall], entry, lcn_connection ) -> None: """Test for transponder event triggers firing.""" address = (0, 7, False) @@ -119,7 +119,7 @@ async def test_if_fires_on_transponder_event( async def test_if_fires_on_fingerprint_event( - hass: HomeAssistant, calls, entry, lcn_connection + hass: HomeAssistant, calls: list[ServiceCall], entry, lcn_connection ) -> None: """Test for fingerprint event triggers firing.""" address = (0, 7, False) @@ -166,7 +166,7 @@ async def test_if_fires_on_fingerprint_event( async def test_if_fires_on_codelock_event( - hass: HomeAssistant, calls, entry, lcn_connection + hass: HomeAssistant, calls: list[ServiceCall], entry, lcn_connection ) -> None: """Test for codelock event triggers firing.""" address = (0, 7, False) @@ -213,7 +213,7 @@ async def test_if_fires_on_codelock_event( async def test_if_fires_on_transmitter_event( - hass: HomeAssistant, calls, entry, lcn_connection + hass: HomeAssistant, calls: list[ServiceCall], entry, lcn_connection ) -> None: """Test for transmitter event triggers firing.""" address = (0, 7, False) @@ -269,7 +269,7 @@ async def test_if_fires_on_transmitter_event( async def test_if_fires_on_send_keys_event( - hass: HomeAssistant, calls, entry, lcn_connection + hass: HomeAssistant, calls: list[ServiceCall], entry, lcn_connection ) -> None: """Test for send_keys event triggers firing.""" address = (0, 7, False) diff --git a/tests/components/ld2410_ble/conftest.py b/tests/components/ld2410_ble/conftest.py index 58dca37ce83..3e9b4f872a2 100644 --- a/tests/components/ld2410_ble/conftest.py +++ b/tests/components/ld2410_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/leaone/conftest.py b/tests/components/leaone/conftest.py index 2f89e80f893..c2bfa61117a 100644 --- a/tests/components/leaone/conftest.py +++ b/tests/components/leaone/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/led_ble/conftest.py b/tests/components/led_ble/conftest.py index 280eb0d6f17..aaaa561b66e 100644 --- a/tests/components/led_ble/conftest.py +++ b/tests/components/led_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/lg_netcast/conftest.py b/tests/components/lg_netcast/conftest.py index 4faee2c6f06..eb13d5c8c67 100644 --- a/tests/components/lg_netcast/conftest.py +++ b/tests/components/lg_netcast/conftest.py @@ -2,10 +2,12 @@ import pytest +from homeassistant.core import HomeAssistant, ServiceCall + from tests.common import async_mock_service @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") diff --git a/tests/components/lg_netcast/test_config_flow.py b/tests/components/lg_netcast/test_config_flow.py index c159b8fb9d2..2ecbadbaf44 100644 --- a/tests/components/lg_netcast/test_config_flow.py +++ b/tests/components/lg_netcast/test_config_flow.py @@ -187,7 +187,7 @@ async def test_import_not_online(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" -async def test_import_duplicate_error(hass): +async def test_import_duplicate_error(hass: HomeAssistant) -> None: """Test that errors are shown when duplicates are added during import.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -217,7 +217,7 @@ async def test_import_duplicate_error(hass): assert result["reason"] == "already_configured" -async def test_display_access_token_aborted(hass: HomeAssistant): +async def test_display_access_token_aborted(hass: HomeAssistant) -> None: """Test Access token display is cancelled.""" def _async_track_time_interval( diff --git a/tests/components/lg_netcast/test_trigger.py b/tests/components/lg_netcast/test_trigger.py index e75dac501c3..b0c2a86ec21 100644 --- a/tests/components/lg_netcast/test_trigger.py +++ b/tests/components/lg_netcast/test_trigger.py @@ -77,7 +77,9 @@ async def test_lg_netcast_turn_on_trigger_device_id( assert len(calls) == 0 -async def test_lg_netcast_turn_on_trigger_entity_id(hass: HomeAssistant, calls): +async def test_lg_netcast_turn_on_trigger_entity_id( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for turn_on triggers by entity firing.""" await setup_lgnetcast(hass) diff --git a/tests/components/lidarr/conftest.py b/tests/components/lidarr/conftest.py index 5aabc0a822b..588acb2b87f 100644 --- a/tests/components/lidarr/conftest.py +++ b/tests/components/lidarr/conftest.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from http import HTTPStatus from aiohttp.client_exceptions import ClientError from aiopyarr.lidarr_client import LidarrClient import pytest +from typing_extensions import Generator from homeassistant.components.lidarr.const import DOMAIN from homeassistant.const import ( @@ -32,7 +33,7 @@ MOCK_INPUT = {CONF_URL: URL, CONF_VERIFY_SSL: False} CONF_DATA = MOCK_INPUT | {CONF_API_KEY: API_KEY} -ComponentSetup = Callable[[], Awaitable[None]] +type ComponentSetup = Callable[[], Awaitable[None]] def mock_error( @@ -132,7 +133,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: async def mock_setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, -) -> Generator[ComponentSetup, None, None]: +) -> Generator[ComponentSetup]: """Set up the lidarr integration in Home Assistant.""" config_entry.add_to_hass(hass) diff --git a/tests/components/lidarr/test_sensor.py b/tests/components/lidarr/test_sensor.py index 3b3f661ce23..0c19355a252 100644 --- a/tests/components/lidarr/test_sensor.py +++ b/tests/components/lidarr/test_sensor.py @@ -1,5 +1,7 @@ """The tests for Lidarr sensor platform.""" +import pytest + from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -7,10 +9,10 @@ from homeassistant.core import HomeAssistant from .conftest import ComponentSetup +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, - entity_registry_enabled_by_default: None, connection, ) -> None: """Test for successfully setting up the Lidarr platform.""" diff --git a/tests/components/lifx/conftest.py b/tests/components/lifx/conftest.py index c126ca20ecd..093f2309e53 100644 --- a/tests/components/lifx/conftest.py +++ b/tests/components/lifx/conftest.py @@ -41,11 +41,6 @@ def mock_effect_conductor(): yield mock_conductor -@pytest.fixture(autouse=True) -def lifx_mock_get_source_ip(mock_get_source_ip): - """Mock network util's async_get_source_ip.""" - - @pytest.fixture(autouse=True) def lifx_no_wait_for_timeouts(): """Avoid waiting for timeouts in tests.""" diff --git a/tests/components/lifx/test_migration.py b/tests/components/lifx/test_migration.py index 0604ee1c8a7..62018790906 100644 --- a/tests/components/lifx/test_migration.py +++ b/tests/components/lifx/test_migration.py @@ -65,7 +65,7 @@ async def test_migration_device_online_end_to_end( assert migrated_entry is not None - assert device.config_entries == {migrated_entry.entry_id} + assert device.config_entries == [migrated_entry.entry_id] assert light_entity_reg.config_entry_id == migrated_entry.entry_id assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] @@ -195,7 +195,7 @@ async def test_migration_device_online_end_to_end_after_downgrade( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) await hass.async_block_till_done() - assert device.config_entries == {config_entry.entry_id} + assert device.config_entries == [config_entry.entry_id] assert light_entity_reg.config_entry_id == config_entry.entry_id assert er.async_entries_for_config_entry(entity_reg, config_entry) == [] @@ -276,7 +276,7 @@ async def test_migration_device_online_end_to_end_ignores_other_devices( assert new_entry is not None assert legacy_entry is None - assert device.config_entries == {legacy_config_entry.entry_id} + assert device.config_entries == [legacy_config_entry.entry_id] assert light_entity_reg.config_entry_id == legacy_config_entry.entry_id assert ignored_entity_reg.config_entry_id == other_domain_config_entry.entry_id assert garbage_entity_reg.config_entry_id == legacy_config_entry.entry_id diff --git a/tests/components/light/common.py b/tests/components/light/common.py index 26c4d18706d..4c3e95b5ef9 100644 --- a/tests/components/light/common.py +++ b/tests/components/light/common.py @@ -4,6 +4,8 @@ All containing methods are legacy helpers that should not be used by new components. Instead call the service directly. """ +from typing import Any, Literal + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, @@ -101,7 +103,7 @@ async def async_turn_on( """Turn all or specified light on.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_ENTITY_ID, entity_id), (ATTR_PROFILE, profile), (ATTR_TRANSITION, transition), @@ -118,7 +120,7 @@ async def async_turn_on( (ATTR_EFFECT, effect), (ATTR_COLOR_NAME, color_name), (ATTR_WHITE, white), - ] + ) if value is not None } @@ -135,11 +137,11 @@ async def async_turn_off(hass, entity_id=ENTITY_MATCH_ALL, transition=None, flas """Turn all or specified light off.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_ENTITY_ID, entity_id), (ATTR_TRANSITION, transition), (ATTR_FLASH, flash), - ] + ) if value is not None } @@ -202,7 +204,7 @@ async def async_toggle( """Turn all or specified light on.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_ENTITY_ID, entity_id), (ATTR_PROFILE, profile), (ATTR_TRANSITION, transition), @@ -216,7 +218,7 @@ async def async_toggle( (ATTR_FLASH, flash), (ATTR_EFFECT, effect), (ATTR_COLOR_NAME, color_name), - ] + ) if value is not None } @@ -250,13 +252,12 @@ class MockLight(MockToggleEntity, LightEntity): def __init__( self, - name, - state, - unique_id=None, + name: str | None, + state: Literal["on", "off"] | None, supported_color_modes: set[ColorMode] | None = None, - ): + ) -> None: """Initialize the mock light.""" - super().__init__(name, state, unique_id) + super().__init__(name, state) if supported_color_modes is None: supported_color_modes = {ColorMode.ONOFF} self._attr_supported_color_modes = supported_color_modes @@ -265,7 +266,7 @@ class MockLight(MockToggleEntity, LightEntity): color_mode = next(iter(supported_color_modes)) self._attr_color_mode = color_mode - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" super().turn_on(**kwargs) for key, value in kwargs.items(): diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index d2a13f22253..8848ce19621 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -33,7 +33,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -66,14 +66,14 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in [ + for action in ( "brightness_decrease", "brightness_increase", "flash", "turn_off", "turn_on", "toggle", - ] + ) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -123,7 +123,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["turn_on", "turn_off", "toggle"] + for action in ("turn_on", "turn_off", "toggle") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -466,12 +466,12 @@ async def test_get_action_capabilities_features_legacy( assert capabilities == expected +@pytest.mark.usefixtures("enable_custom_integrations") async def test_action( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off actions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -631,12 +631,12 @@ async def test_action( assert turn_on_calls[-1].data == {"entity_id": entry.entity_id, "flash": FLASH_LONG} +@pytest.mark.usefixtures("enable_custom_integrations") async def test_action_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off actions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index eeee8530085..11dea49ea60 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -10,12 +10,14 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.light import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .common import MockLight + from tests.common import ( MockConfigEntry, async_get_device_automation_capabilities, @@ -23,7 +25,6 @@ from tests.common import ( async_mock_service, setup_test_component_platform, ) -from tests.components.light.common import MockLight @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -32,7 +33,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -61,7 +62,7 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -109,7 +110,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -180,12 +181,12 @@ async def test_get_condition_capabilities_legacy( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -267,12 +268,12 @@ async def test_if_state( assert calls[1].data["some"] == "is_off event - test_event2" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -330,7 +331,7 @@ async def test_if_fires_on_for_condition( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], mock_light_entities: list[MockLight], ) -> None: """Test for firing if condition is on with delay.""" diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index c38ab14061f..ab3babd1b64 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -9,7 +9,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.light import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -38,7 +38,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -67,7 +67,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -115,7 +115,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -184,12 +184,12 @@ async def test_get_trigger_capabilities_legacy( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -277,12 +277,12 @@ async def test_if_fires_on_state_change( } +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -331,12 +331,12 @@ async def test_if_fires_on_state_change_legacy( ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 6a04d5e33cc..eeb32f1b17a 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1,5 +1,6 @@ """The tests for the Light component.""" +from typing import Literal from unittest.mock import MagicMock, mock_open, patch import pytest @@ -22,13 +23,14 @@ from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.setup import async_setup_component import homeassistant.util.color as color_util +from .common import MockLight + from tests.common import ( MockEntityPlatform, MockUser, async_mock_service, setup_test_component_platform, ) -from tests.components.light.common import MockLight orig_Profiles = light.Profiles @@ -980,9 +982,9 @@ async def test_light_brightness_step(hass: HomeAssistant) -> None: assert entity0.state == "off" # 126 - 126; brightness is 0, light should turn off +@pytest.mark.usefixtures("enable_custom_integrations") async def test_light_brightness_pct_conversion( hass: HomeAssistant, - enable_custom_integrations: None, mock_light_entities: list[MockLight], ) -> None: """Test that light brightness percent conversion.""" @@ -1143,7 +1145,7 @@ invalid_no_brightness_no_color_no_transition,,, @pytest.mark.parametrize("light_state", [STATE_ON, STATE_OFF]) async def test_light_backwards_compatibility_supported_color_modes( - hass: HomeAssistant, light_state + hass: HomeAssistant, light_state: Literal["on", "off"] ) -> None: """Test supported_color_modes if not implemented by the entity.""" entities = [ diff --git a/tests/components/light/test_intent.py b/tests/components/light/test_intent.py index b21b9367bba..1f5a9e7ce27 100644 --- a/tests/components/light/test_intent.py +++ b/tests/components/light/test_intent.py @@ -34,25 +34,6 @@ async def test_intent_set_color(hass: HomeAssistant) -> None: assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) -async def test_intent_set_color_tests_feature(hass: HomeAssistant) -> None: - """Test the set color intent.""" - hass.states.async_set("light.hello", "off") - calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) - await intent.async_setup_intents(hass) - - response = await async_handle( - hass, - "test", - intent.INTENT_SET, - {"name": {"value": "Hello"}, "color": {"value": "blue"}}, - ) - - # Response should contain one failed target - assert len(response.success_results) == 0 - assert len(response.failed_results) == 1 - assert len(calls) == 0 - - async def test_intent_set_color_and_brightness(hass: HomeAssistant) -> None: """Test the set color intent.""" hass.states.async_set( @@ -81,3 +62,30 @@ async def test_intent_set_color_and_brightness(hass: HomeAssistant) -> None: assert call.data.get(ATTR_ENTITY_ID) == "light.hello_2" assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 + + +async def test_intent_set_temperature(hass: HomeAssistant) -> None: + """Test setting the color temperature in kevin via intent.""" + hass.states.async_set( + "light.test", "off", {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP]} + ) + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + await async_handle( + hass, + "test", + intent.INTENT_SET, + { + "name": {"value": "Test"}, + "temperature": {"value": 2000}, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == light.DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == "light.test" + assert call.data.get(light.ATTR_COLOR_TEMP_KELVIN) == 2000 diff --git a/tests/components/linear_garage_door/__init__.py b/tests/components/linear_garage_door/__init__.py index e5abc6c943c..67bd1ee2da2 100644 --- a/tests/components/linear_garage_door/__init__.py +++ b/tests/components/linear_garage_door/__init__.py @@ -1 +1,22 @@ """Tests for the Linear Garage Door integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.linear_garage_door.PLATFORMS", + platforms, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/linear_garage_door/conftest.py b/tests/components/linear_garage_door/conftest.py new file mode 100644 index 00000000000..306da23ebf9 --- /dev/null +++ b/tests/components/linear_garage_door/conftest.py @@ -0,0 +1,67 @@ +"""Common fixtures for the Linear Garage Door tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.linear_garage_door import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.linear_garage_door.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_linear() -> Generator[AsyncMock]: + """Mock a Linear Garage Door client.""" + with ( + patch( + "homeassistant.components.linear_garage_door.coordinator.Linear", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.linear_garage_door.config_flow.Linear", + new=mock_client, + ), + ): + client = mock_client.return_value + client.login.return_value = True + client.get_devices.return_value = load_json_array_fixture( + "get_devices.json", DOMAIN + ) + client.get_sites.return_value = load_json_array_fixture( + "get_sites.json", DOMAIN + ) + device_states = load_json_object_fixture("get_device_state.json", DOMAIN) + client.get_device_state.side_effect = lambda device_id: device_states[device_id] + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="acefdd4b3a4a0911067d1cf51414201e", + title="test-site-name", + data={ + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) diff --git a/tests/components/linear_garage_door/fixtures/get_device_state.json b/tests/components/linear_garage_door/fixtures/get_device_state.json new file mode 100644 index 00000000000..14247610e06 --- /dev/null +++ b/tests/components/linear_garage_door/fixtures/get_device_state.json @@ -0,0 +1,42 @@ +{ + "test1": { + "GDO": { + "Open_B": "true", + "Open_P": "100" + }, + "Light": { + "On_B": "true", + "On_P": "100" + } + }, + "test2": { + "GDO": { + "Open_B": "false", + "Open_P": "0" + }, + "Light": { + "On_B": "false", + "On_P": "0" + } + }, + "test3": { + "GDO": { + "Open_B": "false", + "Opening_P": "0" + }, + "Light": { + "On_B": "false", + "On_P": "0" + } + }, + "test4": { + "GDO": { + "Open_B": "true", + "Opening_P": "100" + }, + "Light": { + "On_B": "true", + "On_P": "100" + } + } +} diff --git a/tests/components/linear_garage_door/fixtures/get_device_state_1.json b/tests/components/linear_garage_door/fixtures/get_device_state_1.json new file mode 100644 index 00000000000..1f41d4fd153 --- /dev/null +++ b/tests/components/linear_garage_door/fixtures/get_device_state_1.json @@ -0,0 +1,42 @@ +{ + "test1": { + "GDO": { + "Open_B": "true", + "Opening_P": "100" + }, + "Light": { + "On_B": "false", + "On_P": "0" + } + }, + "test2": { + "GDO": { + "Open_B": "false", + "Opening_P": "0" + }, + "Light": { + "On_B": "true", + "On_P": "100" + } + }, + "test3": { + "GDO": { + "Open_B": "false", + "Opening_P": "0" + }, + "Light": { + "On_B": "false", + "On_P": "0" + } + }, + "test4": { + "GDO": { + "Open_B": "true", + "Opening_P": "100" + }, + "Light": { + "On_B": "true", + "On_P": "100" + } + } +} diff --git a/tests/components/linear_garage_door/fixtures/get_devices.json b/tests/components/linear_garage_door/fixtures/get_devices.json new file mode 100644 index 00000000000..da6eeaf7448 --- /dev/null +++ b/tests/components/linear_garage_door/fixtures/get_devices.json @@ -0,0 +1,22 @@ +[ + { + "id": "test1", + "name": "Test Garage 1", + "subdevices": ["GDO", "Light"] + }, + { + "id": "test2", + "name": "Test Garage 2", + "subdevices": ["GDO", "Light"] + }, + { + "id": "test3", + "name": "Test Garage 3", + "subdevices": ["GDO", "Light"] + }, + { + "id": "test4", + "name": "Test Garage 4", + "subdevices": ["GDO", "Light"] + } +] diff --git a/tests/components/linear_garage_door/fixtures/get_sites.json b/tests/components/linear_garage_door/fixtures/get_sites.json new file mode 100644 index 00000000000..2b0a49b9007 --- /dev/null +++ b/tests/components/linear_garage_door/fixtures/get_sites.json @@ -0,0 +1 @@ +[{ "id": "test-site-id", "name": "test-site-name" }] diff --git a/tests/components/linear_garage_door/snapshots/test_cover.ambr b/tests/components/linear_garage_door/snapshots/test_cover.ambr new file mode 100644 index 00000000000..96745e1d92a --- /dev/null +++ b/tests/components/linear_garage_door/snapshots/test_cover.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_covers[cover.test_garage_1-entry] + 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.test_garage_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test1-GDO', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_garage_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Test Garage 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_garage_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_covers[cover.test_garage_2-entry] + 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.test_garage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test2-GDO', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_garage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Test Garage 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_garage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_covers[cover.test_garage_3-entry] + 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.test_garage_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test3-GDO', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_garage_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Test Garage 3', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_garage_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'opening', + }) +# --- +# name: test_covers[cover.test_garage_4-entry] + 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.test_garage_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'test4-GDO', + 'unit_of_measurement': None, + }) +# --- +# name: test_covers[cover.test_garage_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Test Garage 4', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_garage_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closing', + }) +# --- diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr index 72886410924..2543ca42156 100644 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr @@ -71,7 +71,7 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', - 'title': 'Mock Title', + 'title': 'test-site-name', 'unique_id': None, 'version': 1, }), diff --git a/tests/components/linear_garage_door/snapshots/test_light.ambr b/tests/components/linear_garage_door/snapshots/test_light.ambr new file mode 100644 index 00000000000..ba64a2b0a04 --- /dev/null +++ b/tests/components/linear_garage_door/snapshots/test_light.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_data[light.test_garage_1_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_garage_1_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'test1-Light', + 'unit_of_measurement': None, + }) +# --- +# name: test_data[light.test_garage_1_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'friendly_name': 'Test Garage 1 Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_garage_1_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_data[light.test_garage_2_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_garage_2_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'test2-Light', + 'unit_of_measurement': None, + }) +# --- +# name: test_data[light.test_garage_2_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Test Garage 2 Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_garage_2_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_data[light.test_garage_3_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_garage_3_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'test3-Light', + 'unit_of_measurement': None, + }) +# --- +# name: test_data[light.test_garage_3_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Test Garage 3 Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_garage_3_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_data[light.test_garage_4_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_garage_4_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'linear_garage_door', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'test4-Light', + 'unit_of_measurement': None, + }) +# --- +# name: test_data[light.test_garage_4_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'friendly_name': 'Test Garage 4 Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_garage_4_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/linear_garage_door/test_config_flow.py b/tests/components/linear_garage_door/test_config_flow.py index 9704268e650..4599bd24aef 100644 --- a/tests/components/linear_garage_door/test_config_flow.py +++ b/tests/components/linear_garage_door/test_config_flow.py @@ -1,180 +1,141 @@ """Test the Linear Garage Door config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from linear_garage_door.errors import InvalidLoginError +import pytest -from homeassistant import config_entries from homeassistant.components.linear_garage_door.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .util import async_init_integration +from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, mock_linear: AsyncMock, 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} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] - with ( - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", - return_value=[{"id": "test-site-id", "name": "test-site-name"}], - ), - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.close", - return_value=None, - ), - patch( - "uuid.uuid4", - return_value="test-uuid", - ), + with patch( + "uuid.uuid4", + return_value="test-uuid", ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "email": "test-email", - "password": "test-password", + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() - with patch( - "homeassistant.components.linear_garage_door.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"site": "test-site-id"} - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"site": "test-site-id"} + ) + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "test-site-name" - assert result3["data"] == { - "email": "test-email", - "password": "test-password", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-site-name" + assert result["data"] == { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", "site_id": "test-site-id", "device_id": "test-uuid", } assert len(mock_setup_entry.mock_calls) == 1 -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth( + hass: HomeAssistant, + mock_linear: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test reauthentication.""" - - with patch( - "homeassistant.components.linear_garage_door.async_setup_entry", - return_value=True, - ): - entry = await async_init_integration(hass) - - result1 = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": entry.entry_id, - "title_placeholders": {"name": entry.title}, - "unique_id": entry.unique_id, - }, - data=entry.data, - ) - assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user" - - with ( - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.get_sites", - return_value=[{"id": "test-site-id", "name": "test-site-name"}], - ), - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.close", - return_value=None, - ), - patch( - "uuid.uuid4", - return_value="test-uuid", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result1["flow_id"], - { - "email": "new-email", - "password": "new-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - entries = hass.config_entries.async_entries() - assert len(entries) == 1 - assert entries[0].data == { - "email": "new-email", - "password": "new-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - } - - -async def test_form_invalid_login(hass: HomeAssistant) -> 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.linear_garage_door.config_flow.Linear.login", - side_effect=InvalidLoginError, - ), - patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.close", - return_value=None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "email": "test-email", - "password": "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_exception(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_USER}, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + "title_placeholders": {"name": mock_config_entry.title}, + "unique_id": mock_config_entry.unique_id, + }, + data=mock_config_entry.data, ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" with patch( - "homeassistant.components.linear_garage_door.config_flow.Linear.login", - side_effect=Exception, + "uuid.uuid4", + return_value="test-uuid", ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "email": "test-email", - "password": "test-password", + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", }, ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data == { + CONF_EMAIL: "new-email", + CONF_PASSWORD: "new-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + } + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [(InvalidLoginError, "invalid_auth"), (Exception, "unknown")], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_linear: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle invalid auth.""" + mock_linear.login.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + mock_linear.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + }, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"site": "test-site-id"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/linear_garage_door/test_coordinator.py b/tests/components/linear_garage_door/test_coordinator.py deleted file mode 100644 index be38b316c56..00000000000 --- a/tests/components/linear_garage_door/test_coordinator.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Test data update coordinator for Linear Garage Door.""" - -from unittest.mock import patch - -from linear_garage_door.errors import InvalidLoginError - -from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_invalid_password( - hass: HomeAssistant, -) -> None: - """Test invalid password.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "email": "test-email", - "password": "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - side_effect=InvalidLoginError( - "Login provided is invalid, please check the email and password" - ), - ): - assert not await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert entries - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_ERROR - flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) - assert flows - assert len(flows) == 1 - assert flows[0]["context"]["source"] == "reauth" - - -async def test_invalid_login( - hass: HomeAssistant, -) -> None: - """Test invalid login.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "email": "test-email", - "password": "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - side_effect=InvalidLoginError("Some other error"), - ): - assert not await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert entries - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/linear_garage_door/test_cover.py b/tests/components/linear_garage_door/test_cover.py index 6236d2ba39c..f4593ff4d60 100644 --- a/tests/components/linear_garage_door/test_cover.py +++ b/tests/components/linear_garage_door/test_cover.py @@ -1,221 +1,124 @@ """Test Linear Garage Door cover.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, +) +from homeassistant.components.linear_garage_door import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, + Platform, ) -from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util +from homeassistant.helpers import entity_registry as er -from .util import async_init_integration +from . import setup_integration -from tests.common import async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) -async def test_data(hass: HomeAssistant) -> None: +async def test_covers( + hass: HomeAssistant, + mock_linear: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: """Test that data gets parsed and returned appropriately.""" - await async_init_integration(hass) + await setup_integration(hass, mock_config_entry, [Platform.COVER]) - assert hass.data[DOMAIN] - entries = hass.config_entries.async_entries(DOMAIN) - assert entries - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - assert hass.states.get("cover.test_garage_1").state == STATE_OPEN - assert hass.states.get("cover.test_garage_2").state == STATE_CLOSED - assert hass.states.get("cover.test_garage_3").state == STATE_OPENING - assert hass.states.get("cover.test_garage_4").state == STATE_CLOSING + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_open_cover(hass: HomeAssistant) -> None: +async def test_open_cover( + hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: """Test that opening the cover works as intended.""" - await async_init_integration(hass) + await setup_integration(hass, mock_config_entry, [Platform.COVER]) - with patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.operate_device" - ) as operate_device: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_1"}, - blocking=True, - ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) - assert operate_device.call_count == 0 + assert mock_linear.operate_device.call_count == 0 - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.operate_device", - return_value=None, - ) as operate_device, - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.close", - return_value=True, - ), - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_2"}, - blocking=True, - ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) - assert operate_device.call_count == 1 - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", - return_value=[ - { - "id": "test1", - "name": "Test Garage 1", - "subdevices": ["GDO", "Light"], - }, - { - "id": "test2", - "name": "Test Garage 2", - "subdevices": ["GDO", "Light"], - }, - ], - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", - side_effect=lambda id: { - "test1": { - "GDO": {"Open_B": "true", "Open_P": "100"}, - "Light": {"On_B": "true", "On_P": "100"}, - }, - "test2": { - "GDO": {"Open_B": "false", "Opening_P": "0"}, - "Light": {"On_B": "false", "On_P": "0"}, - }, - "test3": { - "GDO": {"Open_B": "false", "Opening_P": "0"}, - "Light": {"On_B": "false", "On_P": "0"}, - }, - "test4": { - "GDO": {"Open_B": "true", "Opening_P": "100"}, - "Light": {"On_B": "true", "On_P": "100"}, - }, - }[id], - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.close", - return_value=True, - ), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() - - assert hass.states.get("cover.test_garage_2").state == STATE_OPENING + assert mock_linear.operate_device.call_count == 1 -async def test_close_cover(hass: HomeAssistant) -> None: +async def test_close_cover( + hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: """Test that closing the cover works as intended.""" - await async_init_integration(hass) + await setup_integration(hass, mock_config_entry, [Platform.COVER]) - with patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.operate_device" - ) as operate_device: - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_2"}, - blocking=True, - ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) - assert operate_device.call_count == 0 + assert mock_linear.operate_device.call_count == 0 - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.operate_device", - return_value=None, - ) as operate_device, - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.close", - return_value=True, - ), - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_garage_1"}, - blocking=True, - ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) - assert operate_device.call_count == 1 - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", - return_value=[ - { - "id": "test1", - "name": "Test Garage 1", - "subdevices": ["GDO", "Light"], - }, - { - "id": "test2", - "name": "Test Garage 2", - "subdevices": ["GDO", "Light"], - }, - ], - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", - side_effect=lambda id: { - "test1": { - "GDO": {"Open_B": "true", "Opening_P": "100"}, - "Light": {"On_B": "true", "On_P": "100"}, - }, - "test2": { - "GDO": {"Open_B": "false", "Open_P": "0"}, - "Light": {"On_B": "false", "On_P": "0"}, - }, - "test3": { - "GDO": {"Open_B": "false", "Opening_P": "0"}, - "Light": {"On_B": "false", "On_P": "0"}, - }, - "test4": { - "GDO": {"Open_B": "true", "Opening_P": "100"}, - "Light": {"On_B": "true", "On_P": "100"}, - }, - }[id], - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.close", - return_value=True, - ), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) - await hass.async_block_till_done() + assert mock_linear.operate_device.call_count == 1 + + +async def test_update_cover_state( + hass: HomeAssistant, + mock_linear: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that closing the cover works as intended.""" + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + assert hass.states.get("cover.test_garage_1").state == STATE_OPEN + assert hass.states.get("cover.test_garage_2").state == STATE_CLOSED + + device_states = load_json_object_fixture("get_device_state_1.json", DOMAIN) + mock_linear.get_device_state.side_effect = lambda device_id: device_states[ + device_id + ] + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) assert hass.states.get("cover.test_garage_1").state == STATE_CLOSING + assert hass.states.get("cover.test_garage_2").state == STATE_OPENING diff --git a/tests/components/linear_garage_door/test_diagnostics.py b/tests/components/linear_garage_door/test_diagnostics.py index a9565441bbb..6bf7415bde5 100644 --- a/tests/components/linear_garage_door/test_diagnostics.py +++ b/tests/components/linear_garage_door/test_diagnostics.py @@ -1,11 +1,14 @@ """Test diagnostics of Linear Garage Door.""" +from unittest.mock import AsyncMock + from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant -from .util import async_init_integration +from . import setup_integration +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -14,8 +17,12 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + mock_linear: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - entry = await async_init_integration(hass) - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + await setup_integration(hass, mock_config_entry, []) + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) assert result == snapshot diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py index 63975c8bd3f..640264eb207 100644 --- a/tests/components/linear_garage_door/test_init.py +++ b/tests/components/linear_garage_door/test_init.py @@ -1,64 +1,53 @@ """Test Linear Garage Door init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock + +from linear_garage_door import InvalidLoginError +import pytest -from homeassistant.components.linear_garage_door.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: """Test the unload entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "email": "test-email", - "password": "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - config_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", - return_value=[ - {"id": "test", "name": "Test Garage", "subdevices": ["GDO", "Light"]} - ], - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", - return_value={ - "GDO": {"Open_B": "true", "Open_P": "100"}, - "Light": {"On_B": "true", "On_P": "10"}, - }, - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.close", - return_value=True, - ), - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry, []) + assert mock_config_entry.state is ConfigEntryState.LOADED - assert hass.data[DOMAIN] + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - entries = hass.config_entries.async_entries(DOMAIN) - assert entries - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - with patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.close", - return_value=True, - ): - await hass.config_entries.async_unload(entries[0].entry_id) - await hass.async_block_till_done() - assert entries[0].state is ConfigEntryState.NOT_LOADED +@pytest.mark.parametrize( + ("side_effect", "entry_state"), + [ + ( + InvalidLoginError( + "Login provided is invalid, please check the email and password" + ), + ConfigEntryState.SETUP_ERROR, + ), + (InvalidLoginError("Invalid login"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_failure( + hass: HomeAssistant, + mock_linear: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + entry_state: ConfigEntryState, +) -> None: + """Test reauth trigger setup.""" + + mock_linear.login.side_effect = side_effect + + await setup_integration(hass, mock_config_entry, []) + assert mock_config_entry.state == entry_state diff --git a/tests/components/linear_garage_door/test_light.py b/tests/components/linear_garage_door/test_light.py new file mode 100644 index 00000000000..351ddad813a --- /dev/null +++ b/tests/components/linear_garage_door/test_light.py @@ -0,0 +1,124 @@ +"""Test Linear Garage Door light.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.linear_garage_door import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_BRIGHTNESS, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + + +async def test_data( + hass: HomeAssistant, + mock_linear: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that data gets parsed and returned appropriately.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_turn_on( + hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that turning on the light works as intended.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_linear.operate_device.call_count == 1 + + +async def test_turn_on_with_brightness( + hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that turning on the light works as intended.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_2_light", CONF_BRIGHTNESS: 50}, + blocking=True, + ) + + mock_linear.operate_device.assert_called_once_with( + "test2", "Light", "DimPercent:20" + ) + + +async def test_turn_off( + hass: HomeAssistant, mock_linear: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that turning off the light works as intended.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_linear.operate_device.call_count == 1 + + +async def test_update_light_state( + hass: HomeAssistant, + mock_linear: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that turning off the light works as intended.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + assert hass.states.get("light.test_garage_1_light").state == STATE_ON + assert hass.states.get("light.test_garage_2_light").state == STATE_OFF + + device_states = load_json_object_fixture("get_device_state_1.json", DOMAIN) + mock_linear.get_device_state.side_effect = lambda device_id: device_states[ + device_id + ] + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + + assert hass.states.get("light.test_garage_1_light").state == STATE_OFF + assert hass.states.get("light.test_garage_2_light").state == STATE_ON diff --git a/tests/components/linear_garage_door/util.py b/tests/components/linear_garage_door/util.py deleted file mode 100644 index 30dbdbd06d5..00000000000 --- a/tests/components/linear_garage_door/util.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Utilities for Linear Garage Door testing.""" - -from unittest.mock import patch - -from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def async_init_integration(hass: HomeAssistant) -> MockConfigEntry: - """Initialize mock integration.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="acefdd4b3a4a0911067d1cf51414201e", - data={ - "email": "test-email", - "password": "test-password", - "site_id": "test-site-id", - "device_id": "test-uuid", - }, - ) - config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.login", - return_value=True, - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_devices", - return_value=[ - { - "id": "test1", - "name": "Test Garage 1", - "subdevices": ["GDO", "Light"], - }, - { - "id": "test2", - "name": "Test Garage 2", - "subdevices": ["GDO", "Light"], - }, - { - "id": "test3", - "name": "Test Garage 3", - "subdevices": ["GDO", "Light"], - }, - { - "id": "test4", - "name": "Test Garage 4", - "subdevices": ["GDO", "Light"], - }, - ], - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.get_device_state", - side_effect=lambda id: { - "test1": { - "GDO": {"Open_B": "true", "Open_P": "100"}, - "Light": {"On_B": "true", "On_P": "100"}, - }, - "test2": { - "GDO": {"Open_B": "false", "Open_P": "0"}, - "Light": {"On_B": "false", "On_P": "0"}, - }, - "test3": { - "GDO": {"Open_B": "false", "Opening_P": "0"}, - "Light": {"On_B": "false", "On_P": "0"}, - }, - "test4": { - "GDO": {"Open_B": "true", "Opening_P": "100"}, - "Light": {"On_B": "true", "On_P": "100"}, - }, - }[id], - ), - patch( - "homeassistant.components.linear_garage_door.coordinator.Linear.close", - return_value=True, - ), - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry diff --git a/tests/components/litejet/__init__.py b/tests/components/litejet/__init__.py index 3116d9e810d..bf992836043 100644 --- a/tests/components/litejet/__init__.py +++ b/tests/components/litejet/__init__.py @@ -3,13 +3,14 @@ from homeassistant.components import scene, switch from homeassistant.components.litejet import DOMAIN from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry async def async_init_integration( - hass, use_switch=False, use_scene=False + hass: HomeAssistant, use_switch: bool = False, use_scene: bool = False ) -> MockConfigEntry: """Set up the LiteJet integration in Home Assistant.""" diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index 9746ab92cad..216084c26bc 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -9,7 +9,7 @@ import pytest from homeassistant import setup from homeassistant.components import automation -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.util.dt as dt_util from . import async_init_integration @@ -31,7 +31,7 @@ ENTITY_OTHER_SWITCH_NUMBER = 2 @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -72,10 +72,10 @@ async def simulate_time(hass, mock_litejet, delta): "homeassistant.helpers.condition.dt_util.utcnow", return_value=mock_litejet.start_time + delta, ): - _LOGGER.info("now=%s", dt_util.utcnow()) + _LOGGER.info("*** now=%s", dt_util.utcnow()) async_fire_time_changed_exact(hass, mock_litejet.start_time + delta) await hass.async_block_till_done() - _LOGGER.info("done with now=%s", dt_util.utcnow()) + _LOGGER.info("*** done with now=%s", dt_util.utcnow()) async def setup_automation(hass, trigger): @@ -100,7 +100,9 @@ async def setup_automation(hass, trigger): await hass.async_block_till_done() -async def test_simple(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_simple( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test the simplest form of a LiteJet trigger.""" await setup_automation( hass, {"platform": "litejet", "number": ENTITY_OTHER_SWITCH_NUMBER} @@ -113,7 +115,9 @@ async def test_simple(hass: HomeAssistant, calls, mock_litejet) -> None: assert calls[0].data["id"] == 0 -async def test_only_release(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_only_release( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test the simplest form of a LiteJet trigger.""" await setup_automation( hass, {"platform": "litejet", "number": ENTITY_OTHER_SWITCH_NUMBER} @@ -124,7 +128,9 @@ async def test_only_release(hass: HomeAssistant, calls, mock_litejet) -> None: assert len(calls) == 0 -async def test_held_more_than_short(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_held_more_than_short( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test a too short hold.""" await setup_automation( hass, @@ -141,7 +147,9 @@ async def test_held_more_than_short(hass: HomeAssistant, calls, mock_litejet) -> assert len(calls) == 0 -async def test_held_more_than_long(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_held_more_than_long( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test a hold that is long enough.""" await setup_automation( hass, @@ -161,7 +169,9 @@ async def test_held_more_than_long(hass: HomeAssistant, calls, mock_litejet) -> assert len(calls) == 1 -async def test_held_less_than_short(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_held_less_than_short( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test a hold that is short enough.""" await setup_automation( hass, @@ -180,7 +190,9 @@ async def test_held_less_than_short(hass: HomeAssistant, calls, mock_litejet) -> assert calls[0].data["id"] == 0 -async def test_held_less_than_long(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_held_less_than_long( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test a hold that is too long.""" await setup_automation( hass, @@ -199,7 +211,9 @@ async def test_held_less_than_long(hass: HomeAssistant, calls, mock_litejet) -> assert len(calls) == 0 -async def test_held_in_range_short(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_held_in_range_short( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test an in-range trigger with a too short hold.""" await setup_automation( hass, @@ -218,7 +232,7 @@ async def test_held_in_range_short(hass: HomeAssistant, calls, mock_litejet) -> async def test_held_in_range_just_right( - hass: HomeAssistant, calls, mock_litejet + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet ) -> None: """Test an in-range trigger with a just right hold.""" await setup_automation( @@ -240,7 +254,9 @@ async def test_held_in_range_just_right( assert calls[0].data["id"] == 0 -async def test_held_in_range_long(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_held_in_range_long( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test an in-range trigger with a too long hold.""" await setup_automation( hass, @@ -260,7 +276,9 @@ async def test_held_in_range_long(hass: HomeAssistant, calls, mock_litejet) -> N assert len(calls) == 0 -async def test_reload(hass: HomeAssistant, calls, mock_litejet) -> None: +async def test_reload( + hass: HomeAssistant, calls: list[ServiceCall], mock_litejet +) -> None: """Test reloading automation.""" await setup_automation( hass, diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index c72f747db88..69b3f7ce3ab 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -15,10 +15,10 @@ from .conftest import setup_integration @pytest.mark.freeze_time("2022-09-18 23:00:44+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, mock_account: MagicMock, - entity_registry_enabled_by_default: None, ) -> None: """Tests binary sensors.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index f4ad12aeb20..21b16097603 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -41,8 +41,6 @@ async def test_unload_entry(hass: HomeAssistant, mock_account: MagicMock) -> Non getattr(mock_account.robots[0], "start_cleaning").assert_called_once() assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - assert hass.data[litterrobot.DOMAIN] == {} @pytest.mark.parametrize( diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 8d1f2b68e05..360d13096a7 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest +from homeassistant.components.litterrobot.sensor import icon_for_gauge_level from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN, SensorDeviceClass from homeassistant.const import PERCENTAGE, STATE_UNKNOWN, UnitOfMass from homeassistant.core import HomeAssistant @@ -47,7 +48,6 @@ async def test_sleep_time_sensor_with_sleep_disabled( async def test_gauge_icon() -> None: """Test icon generator for gauge sensor.""" - from homeassistant.components.litterrobot.sensor import icon_for_gauge_level GAUGE_EMPTY = "mdi:gauge-empty" GAUGE_LOW = "mdi:gauge-low" diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 68ebae1e239..735ee6653aa 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -143,6 +143,7 @@ async def test_commands( service: str, command: str, extra: dict[str, Any], + issue_registry: ir.IssueRegistry, ) -> None: """Test sending commands to the vacuum.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) @@ -163,5 +164,4 @@ async def test_commands( ) getattr(mock_account.robots[0], command).assert_called_once() - issue_registry = ir.async_get(hass) assert set(issue_registry.issues.keys()) == issues diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index 82f69be5fd1..6d2c38544a5 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -1,6 +1,6 @@ """Fixtures for local calendar.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from http import HTTPStatus from pathlib import Path from typing import Any @@ -9,6 +9,7 @@ import urllib from aiohttp import ClientWebSocketResponse import pytest +from typing_extensions import Generator from homeassistant.components.local_calendar import LocalCalendarStore from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN @@ -60,9 +61,7 @@ def mock_store_read_side_effect() -> Any | None: @pytest.fixture(name="store", autouse=True) -def mock_store( - ics_content: str, store_read_side_effect: Any | None -) -> Generator[None, None, None]: +def mock_store(ics_content: str, store_read_side_effect: Any | None) -> Generator[None]: """Test cleanup, remove any media storage persisted during the test.""" stores: dict[Path, FakeStore] = {} @@ -87,11 +86,11 @@ def mock_time_zone() -> str: @pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant, time_zone: str): +async def set_time_zone(hass: HomeAssistant, time_zone: str): """Set the time zone for the tests.""" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) @pytest.fixture(name="config_entry") @@ -108,7 +107,7 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.async_block_till_done() -GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]] +type GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]] @pytest.fixture(name="get_events") @@ -130,7 +129,7 @@ def event_fields(data: dict[str, str]) -> dict[str, str]: """Filter event API response to minimum fields.""" return { k: data[k] - for k in ["summary", "start", "end", "recurrence_id", "location"] + for k in ("summary", "start", "end", "recurrence_id", "location") if data.get(k) } @@ -169,7 +168,7 @@ class Client: return resp.get("result") -ClientFixture = Callable[[], Awaitable[Client]] +type ClientFixture = Callable[[], Awaitable[Client]] @pytest.fixture diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 2fa0063dfd8..61908faeca6 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -785,7 +785,7 @@ async def test_all_day_iter_order( setup_integration: None, get_events: GetEventsFn, event_order: list[str], -): +) -> None: """Test the sort order of an all day events depending on the time zone.""" client = await ws_client() await client.cmd_result( diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py index 721eed19736..ed12391f8a9 100644 --- a/tests/components/local_calendar/test_diagnostics.py +++ b/tests/components/local_calendar/test_diagnostics.py @@ -48,6 +48,7 @@ async def setup_diag(hass): @freeze_time("2023-03-13 12:05:00-07:00") +@pytest.mark.usefixtures("socket_enabled") async def test_empty_calendar( hass: HomeAssistant, setup_integration: None, @@ -55,7 +56,6 @@ async def test_empty_calendar( hass_admin_credential: Credentials, config_entry: MockConfigEntry, aiohttp_client: ClientSessionGenerator, - socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics against an empty calendar.""" @@ -76,6 +76,7 @@ async def test_empty_calendar( @freeze_time("2023-03-13 12:05:00-07:00") +@pytest.mark.usefixtures("socket_enabled") async def test_api_date_time_event( hass: HomeAssistant, setup_integration: None, @@ -84,7 +85,6 @@ async def test_api_date_time_event( config_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, aiohttp_client: ClientSessionGenerator, - socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test an event with a start/end date time.""" diff --git a/tests/components/local_ip/test_config_flow.py b/tests/components/local_ip/test_config_flow.py index 554163bbc1c..3f9233f5b97 100644 --- a/tests/components/local_ip/test_config_flow.py +++ b/tests/components/local_ip/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -async def test_config_flow(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_config_flow(hass: HomeAssistant) -> None: """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -25,7 +25,7 @@ async def test_config_flow(hass: HomeAssistant, mock_get_source_ip) -> None: assert state -async def test_already_setup(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_already_setup(hass: HomeAssistant) -> None: """Test we abort if already setup.""" MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/local_ip/test_init.py b/tests/components/local_ip/test_init.py index cc4f4dd4968..51e0628a417 100644 --- a/tests/components/local_ip/test_init.py +++ b/tests/components/local_ip/test_init.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_basic_setup(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_basic_setup(hass: HomeAssistant) -> None: """Test component setup creates entry from config.""" entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) diff --git a/tests/components/local_todo/conftest.py b/tests/components/local_todo/conftest.py index ca0ef4d3965..67ef76172b7 100644 --- a/tests/components/local_todo/conftest.py +++ b/tests/components/local_todo/conftest.py @@ -1,11 +1,11 @@ """Common fixtures for the local_todo tests.""" -from collections.abc import Generator from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.local_todo import LocalTodoListStore from homeassistant.components.local_todo.const import ( @@ -24,7 +24,7 @@ TEST_ENTITY = "todo.my_tasks" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.local_todo.async_setup_entry", return_value=True @@ -72,9 +72,7 @@ def mock_store_read_side_effect() -> Any | None: @pytest.fixture(name="store", autouse=True) -def mock_store( - ics_content: str, store_read_side_effect: Any | None -) -> Generator[None, None, None]: +def mock_store(ics_content: str, store_read_side_effect: Any | None) -> Generator[None]: """Fixture that sets up a fake local storage object.""" stores: dict[Path, FakeStore] = {} diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 3074cdcf88f..e54ee925437 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -61,9 +61,9 @@ async def ws_move_item( @pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant) -> None: +async def set_time_zone(hass: HomeAssistant) -> None: """Set the time zone for the tests that keesp UTC-6 all year round.""" - hass.config.set_time_zone("America/Regina") + await hass.config.async_set_time_zone("America/Regina") EXPECTED_ADD_ITEM = { diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index fdb38c68d6c..305497ebbd6 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -3,11 +3,13 @@ from http import HTTPStatus from unittest.mock import patch +from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries from homeassistant.components import locative from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.locative import DOMAIN, TRACKER_UPDATE from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant @@ -15,14 +17,18 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + @pytest.fixture(autouse=True) -def mock_dev_track(mock_device_tracker_conf): +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: """Mock device tracker config loading.""" @pytest.fixture -async def locative_client(hass, hass_client): +async def locative_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Locative mock client.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/lock/conftest.py b/tests/components/lock/conftest.py index 07399a39e92..f1715687339 100644 --- a/tests/components/lock/conftest.py +++ b/tests/components/lock/conftest.py @@ -1,10 +1,10 @@ """Fixtures for the lock entity platform tests.""" -from collections.abc import Generator from typing import Any from unittest.mock import MagicMock import pytest +from typing_extensions import Generator from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -65,7 +65,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -98,10 +98,11 @@ async def setup_lock_platform_test_entity( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, LOCK_DOMAIN) + await hass.config_entries.async_forward_entry_setups( + config_entry, [LOCK_DOMAIN] + ) return True - MockPlatform(hass, f"{TEST_DOMAIN}.config_flow") mock_integration( hass, MockModule( diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 3b46117ccd2..e77e7edd005 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -129,7 +129,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["lock", "unlock"] + for action in ("lock", "unlock") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 749e1037662..97afe9fb759 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -10,11 +10,13 @@ from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -32,7 +34,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -61,13 +63,15 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in [ + for condition in ( "is_locked", "is_unlocked", "is_unlocking", "is_locking", "is_jammed", - ] + "is_open", + "is_opening", + ) ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -115,13 +119,15 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in [ + for condition in ( "is_locked", "is_unlocked", "is_unlocking", "is_locking", "is_jammed", - ] + "is_open", + "is_opening", + ) ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -133,7 +139,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -243,6 +249,42 @@ async def test_if_state( }, }, }, + { + "trigger": {"platform": "event", "event_type": "test_event6"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "is_opening", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_opening - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event7"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "is_open", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_open - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, ] }, ) @@ -277,12 +319,24 @@ async def test_if_state( assert len(calls) == 5 assert calls[4].data["some"] == "is_jammed - event - test_event5" + hass.states.async_set(entry.entity_id, STATE_OPENING) + hass.bus.async_fire("test_event6") + await hass.async_block_till_done() + assert len(calls) == 6 + assert calls[5].data["some"] == "is_opening - event - test_event6" + + hass.states.async_set(entry.entity_id, STATE_OPEN) + hass.bus.async_fire("test_event7") + await hass.async_block_till_done() + assert len(calls) == 7 + assert calls[6].data["some"] == "is_open - event - test_event7" + async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 3ad992d4458..3cbfbb1a04c 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -7,16 +7,18 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.lock import DOMAIN +from homeassistant.components.lock import DOMAIN, LockEntityFeature from homeassistant.const import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -37,7 +39,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -55,7 +57,11 @@ async def test_get_triggers( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity_entry = entity_registry.async_get_or_create( - DOMAIN, "test", "5678", device_id=device_entry.id + DOMAIN, + "test", + "5678", + device_id=device_entry.id, + supported_features=LockEntityFeature.OPEN, ) expected_triggers = [ { @@ -66,7 +72,15 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["locked", "unlocked", "unlocking", "locking", "jammed"] + for trigger in ( + "locked", + "unlocked", + "unlocking", + "locking", + "jammed", + "open", + "opening", + ) ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -104,6 +118,7 @@ async def test_get_triggers_hidden_auxiliary( device_id=device_entry.id, entity_category=entity_category, hidden_by=hidden_by, + supported_features=LockEntityFeature.OPEN, ) expected_triggers = [ { @@ -114,7 +129,15 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["locked", "unlocked", "unlocking", "locking", "jammed"] + for trigger in ( + "locked", + "unlocked", + "unlocking", + "locking", + "jammed", + "open", + "opening", + ) ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -141,7 +164,7 @@ async def test_get_trigger_capabilities( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 5 + assert len(triggers) == 7 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( hass, DeviceAutomationType.TRIGGER, trigger @@ -172,7 +195,7 @@ async def test_get_trigger_capabilities_legacy( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 5 + assert len(triggers) == 7 for trigger in triggers: trigger["entity_id"] = entity_registry.async_get(trigger["entity_id"]).entity_id capabilities = await async_get_device_automation_capabilities( @@ -189,7 +212,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -247,6 +270,25 @@ async def test_if_fires_on_state_change( }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "open", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "open - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}} - {{ trigger.for }}" + ) + }, + }, + }, ] }, ) @@ -269,12 +311,21 @@ async def test_if_fires_on_state_change( == f"unlocked - device - {entry.entity_id} - locked - unlocked - None" ) + # Fake that the entity is opens. + hass.states.async_set(entry.entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert len(calls) == 3 + assert ( + calls[2].data["some"] + == f"open - device - {entry.entity_id} - unlocked - open - None" + ) + async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -331,7 +382,7 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -439,6 +490,28 @@ async def test_if_fires_on_state_change_with_for( }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": entry.id, + "type": "opening", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "turn_on {{ trigger.platform }}" + " - {{ trigger.entity_id }}" + " - {{ trigger.from_state.state }}" + " - {{ trigger.to_state.state }}" + " - {{ trigger.for }}" + ) + }, + }, + }, ] }, ) @@ -492,3 +565,15 @@ async def test_if_fires_on_state_change_with_for( calls[3].data["some"] == f"turn_on device - {entry.entity_id} - jammed - locking - 0:00:05" ) + + hass.states.async_set(entry.entity_id, STATE_OPENING) + await hass.async_block_till_done() + assert len(calls) == 4 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) + await hass.async_block_till_done() + assert len(calls) == 5 + await hass.async_block_till_done() + assert ( + calls[4].data["some"] + == f"turn_on device - {entry.entity_id} - locking - opening - 0:00:05" + ) diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index e98a7bd9eda..f0547fbbeae 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -22,6 +22,7 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, LockEntityFeature, ) +from homeassistant.const import STATE_OPEN, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er @@ -55,6 +56,8 @@ async def test_lock_default(hass: HomeAssistant, mock_lock_entity: MockLock) -> assert mock_lock_entity.is_locked is None assert mock_lock_entity.is_locking is None assert mock_lock_entity.is_unlocking is None + assert mock_lock_entity.is_opening is None + assert mock_lock_entity.is_open is None async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: @@ -85,6 +88,19 @@ async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> N assert mock_lock_entity.state == STATE_JAMMED assert not mock_lock_entity.is_locked + mock_lock_entity._attr_is_jammed = False + mock_lock_entity._attr_is_opening = True + assert mock_lock_entity.is_opening + assert mock_lock_entity.state == STATE_OPENING + assert mock_lock_entity.is_opening + + mock_lock_entity._attr_is_opening = False + mock_lock_entity._attr_is_open = True + assert not mock_lock_entity.is_opening + assert mock_lock_entity.state == STATE_OPEN + assert not mock_lock_entity.is_opening + assert mock_lock_entity.is_open + @pytest.mark.parametrize( ("code_format", "supported_features"), diff --git a/tests/components/lock/test_reproduce_state.py b/tests/components/lock/test_reproduce_state.py index 4fa06d9320b..e501e03ebcd 100644 --- a/tests/components/lock/test_reproduce_state.py +++ b/tests/components/lock/test_reproduce_state.py @@ -14,9 +14,11 @@ async def test_reproducing_states( """Test reproducing Lock states.""" hass.states.async_set("lock.entity_locked", "locked", {}) hass.states.async_set("lock.entity_unlocked", "unlocked", {}) + hass.states.async_set("lock.entity_opened", "open", {}) lock_calls = async_mock_service(hass, "lock", "lock") unlock_calls = async_mock_service(hass, "lock", "unlock") + open_calls = async_mock_service(hass, "lock", "open") # These calls should do nothing as entities already in desired state await async_reproduce_state( @@ -24,11 +26,13 @@ async def test_reproducing_states( [ State("lock.entity_locked", "locked"), State("lock.entity_unlocked", "unlocked", {}), + State("lock.entity_opened", "open", {}), ], ) assert len(lock_calls) == 0 assert len(unlock_calls) == 0 + assert len(open_calls) == 0 # Test invalid state is handled await async_reproduce_state(hass, [State("lock.entity_locked", "not_supported")]) @@ -36,13 +40,15 @@ async def test_reproducing_states( assert "not_supported" in caplog.text assert len(lock_calls) == 0 assert len(unlock_calls) == 0 + assert len(open_calls) == 0 # Make sure correct services are called await async_reproduce_state( hass, [ - State("lock.entity_locked", "unlocked"), + State("lock.entity_locked", "open"), State("lock.entity_unlocked", "locked"), + State("lock.entity_opened", "unlocked"), # Should not raise State("lock.non_existing", "on"), ], @@ -54,4 +60,8 @@ async def test_reproducing_states( assert len(unlock_calls) == 1 assert unlock_calls[0].domain == "lock" - assert unlock_calls[0].data == {"entity_id": "lock.entity_locked"} + assert unlock_calls[0].data == {"entity_id": "lock.entity_opened"} + + assert len(open_calls) == 1 + assert open_calls[0].domain == "lock" + assert open_calls[0].data == {"entity_id": "lock.entity_locked"} diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index 67b83a19768..67f12955581 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -27,7 +27,7 @@ class MockRow: event_type: str, data: dict[str, Any] | None = None, context: Context | None = None, - ): + ) -> None: """Init the fake row.""" self.event_type = event_type self.event_data = json.dumps(data, cls=JSONEncoder) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d752b896401..3534192a43e 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -61,16 +61,16 @@ EMPTY_CONFIG = logbook.CONFIG_SCHEMA({logbook.DOMAIN: {}}) @pytest.fixture -async def hass_(recorder_mock, hass): +async def hass_(recorder_mock: Recorder, hass: HomeAssistant) -> HomeAssistant: """Set up things to be run when tests are started.""" assert await async_setup_component(hass, logbook.DOMAIN, EMPTY_CONFIG) return hass @pytest.fixture -def set_utc(hass): +async def set_utc(hass): """Set timezone to UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") async def test_service_call_create_logbook_entry(hass_) -> None: diff --git a/tests/components/logbook/test_models.py b/tests/components/logbook/test_models.py index 459fd0e06c9..7021711014f 100644 --- a/tests/components/logbook/test_models.py +++ b/tests/components/logbook/test_models.py @@ -5,7 +5,7 @@ from unittest.mock import Mock from homeassistant.components.logbook.models import LazyEventPartialState -def test_lazy_event_partial_state_context(): +def test_lazy_event_partial_state_context() -> None: """Test we can extract context from a lazy event partial state.""" state = LazyEventPartialState( Mock( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 1be0e5bd9af..ac653737614 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -15,7 +15,7 @@ from homeassistant.components.logbook import websocket_api from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.util import get_instance from homeassistant.components.script import EVENT_SCRIPT_STARTED -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, @@ -47,9 +47,9 @@ from tests.typing import RecorderInstanceGenerator, WebSocketGenerator @pytest.fixture -def set_utc(hass): +async def set_utc(hass): """Set timezone to UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") def listeners_without_writes(listeners: dict[str, int]) -> dict[str, int]: @@ -630,7 +630,7 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] assert msg["event"]["start_time"] == now.timestamp() @@ -679,17 +679,17 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( { "entity_id": "light.alpha", "state": "off", - "when": alpha_off_state.last_updated.timestamp(), + "when": alpha_off_state.last_updated_timestamp, }, { "entity_id": "light.zulu", "state": "off", - "when": zulu_off_state.last_updated.timestamp(), + "when": zulu_off_state.last_updated_timestamp, }, { "entity_id": "light.zulu", "state": "on", - "when": zulu_on_state.last_updated.timestamp(), + "when": zulu_on_state.last_updated_timestamp, }, ] @@ -1033,7 +1033,7 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] assert msg["event"]["start_time"] == now.timestamp() @@ -1082,17 +1082,17 @@ async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( { "entity_id": "light.alpha", "state": "off", - "when": alpha_off_state.last_updated.timestamp(), + "when": alpha_off_state.last_updated_timestamp, }, { "entity_id": "light.zulu", "state": "off", - "when": zulu_off_state.last_updated.timestamp(), + "when": zulu_off_state.last_updated_timestamp, }, { "entity_id": "light.zulu", "state": "on", - "when": zulu_on_state.last_updated.timestamp(), + "when": zulu_on_state.last_updated_timestamp, }, ] @@ -1201,7 +1201,7 @@ async def test_subscribe_unsubscribe_logbook_stream( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] assert msg["event"]["start_time"] == now.timestamp() @@ -1241,17 +1241,17 @@ async def test_subscribe_unsubscribe_logbook_stream( { "entity_id": "light.alpha", "state": "off", - "when": alpha_off_state.last_updated.timestamp(), + "when": alpha_off_state.last_updated_timestamp, }, { "entity_id": "light.zulu", "state": "off", - "when": zulu_off_state.last_updated.timestamp(), + "when": zulu_off_state.last_updated_timestamp, }, { "entity_id": "light.zulu", "state": "on", - "when": zulu_on_state.last_updated.timestamp(), + "when": zulu_on_state.last_updated_timestamp, }, ] @@ -1514,7 +1514,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] @@ -1613,7 +1613,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_with_end_time( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] @@ -1716,7 +1716,7 @@ async def test_subscribe_unsubscribe_logbook_stream_entities_past_only( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] @@ -1804,7 +1804,7 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( { "entity_id": "binary_sensor.is_light", "state": "on", - "when": current_state.last_updated.timestamp(), + "when": current_state.last_updated_timestamp, } ] @@ -1817,7 +1817,7 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( { "entity_id": "binary_sensor.four_days_ago", "state": "off", - "when": four_day_old_state.last_updated.timestamp(), + "when": four_day_old_state.last_updated_timestamp, } ] @@ -2363,7 +2363,7 @@ async def test_subscribe_disconnected( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] @@ -2790,7 +2790,7 @@ async def test_logbook_stream_ignores_forced_updates( { "entity_id": "binary_sensor.is_light", "state": "off", - "when": state.last_updated.timestamp(), + "when": state.last_updated_timestamp, } ] assert msg["event"]["start_time"] == now.timestamp() diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index c2fcc7f208e..5bc280535f9 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -5,7 +5,7 @@ from unittest.mock import patch from homeassistant import loader from homeassistant.components.logger.helpers import async_get_domain_config -from homeassistant.components.websocket_api import const +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -28,7 +28,7 @@ async def test_integration_log_info( msg = await websocket_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert {"domain": "http", "level": logging.DEBUG} in msg["result"] assert {"domain": "websocket_api", "level": logging.DEBUG} in msg["result"] @@ -51,7 +51,7 @@ async def test_integration_log_level_logger_not_loaded( msg = await websocket_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert not msg["success"] @@ -74,7 +74,7 @@ async def test_integration_log_level( msg = await websocket_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert async_get_domain_config(hass).overrides == { @@ -124,7 +124,7 @@ async def test_custom_integration_log_level( msg = await websocket_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert async_get_domain_config(hass).overrides == { @@ -153,7 +153,7 @@ async def test_integration_log_level_unknown_integration( msg = await websocket_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert not msg["success"] @@ -180,7 +180,7 @@ async def test_module_log_level( msg = await websocket_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert async_get_domain_config(hass).overrides == { @@ -216,7 +216,7 @@ async def test_module_log_level_override( msg = await websocket_client.receive_json() assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert async_get_domain_config(hass).overrides == { @@ -235,7 +235,7 @@ async def test_module_log_level_override( msg = await websocket_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert async_get_domain_config(hass).overrides == { @@ -254,7 +254,7 @@ async def test_module_log_level_override( msg = await websocket_client.receive_json() assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert async_get_domain_config(hass).overrides == { diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index b4265873457..57ef19d0fcb 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -1,12 +1,12 @@ """Contains fixtures for Loqed tests.""" -from collections.abc import AsyncGenerator import json from typing import Any from unittest.mock import AsyncMock, Mock, patch from loqedAPI import loqed import pytest +from typing_extensions import AsyncGenerator from homeassistant.components.loqed import DOMAIN from homeassistant.components.loqed.const import CONF_CLOUDHOOK_URL @@ -81,7 +81,7 @@ def lock_fixture() -> loqed.Lock: @pytest.fixture(name="integration") async def integration_fixture( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock -) -> AsyncGenerator[MockConfigEntry, None]: +) -> AsyncGenerator[MockConfigEntry]: """Set up the loqed integration with a config entry.""" config: dict[str, Any] = {DOMAIN: {CONF_API_TOKEN: ""}} config_entry.add_to_hass(hass) diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 3d52feead79..e6bff2203a9 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -15,14 +15,15 @@ from homeassistant.helpers.network import get_url from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture +from tests.typing import ClientSessionGenerator async def test_webhook_accepts_valid_message( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, integration: MockConfigEntry, lock: loqed.Lock, -): +) -> None: """Test webhook called with valid message.""" await async_setup_component(hass, "http", {"http": {}}) client = await hass_client_no_auth() @@ -41,7 +42,7 @@ async def test_webhook_accepts_valid_message( async def test_setup_webhook_in_bridge( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock -): +) -> None: """Test webhook setup in loqed bridge.""" config: dict[str, Any] = {DOMAIN: {}} config_entry.add_to_hass(hass) @@ -64,7 +65,7 @@ async def test_setup_webhook_in_bridge( async def test_cannot_connect_to_bridge_will_retry( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock -): +) -> None: """Test webhook setup in loqed bridge.""" config: dict[str, Any] = {DOMAIN: {}} config_entry.add_to_hass(hass) @@ -80,7 +81,7 @@ async def test_cannot_connect_to_bridge_will_retry( async def test_setup_cloudhook_in_bridge( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock -): +) -> None: """Test webhook setup in loqed bridge.""" config: dict[str, Any] = {DOMAIN: {}} config_entry.add_to_hass(hass) @@ -111,7 +112,7 @@ async def test_setup_cloudhook_in_bridge( async def test_setup_cloudhook_from_entry_in_bridge( hass: HomeAssistant, cloud_config_entry: MockConfigEntry, lock: loqed.Lock -): +) -> None: """Test webhook setup in loqed bridge.""" webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) @@ -142,7 +143,9 @@ async def test_setup_cloudhook_from_entry_in_bridge( lock.registerWebhook.assert_called_with(f"{get_url(hass)}/api/webhook/Webhook_id") -async def test_unload_entry(hass, integration: MockConfigEntry, lock: loqed.Lock): +async def test_unload_entry( + hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock +) -> None: """Test successful unload of entry.""" assert await hass.config_entries.async_unload(integration.entry_id) @@ -153,7 +156,9 @@ async def test_unload_entry(hass, integration: MockConfigEntry, lock: loqed.Lock assert not hass.data.get(DOMAIN) -async def test_unload_entry_fails(hass, integration: MockConfigEntry, lock: loqed.Lock): +async def test_unload_entry_fails( + hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock +) -> None: """Test unsuccessful unload of entry.""" lock.deleteWebhook = AsyncMock(side_effect=Exception) diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index f0a193ec705..632ea731d0c 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -1,10 +1,10 @@ """Test the Lovelace Cast platform.""" -from collections.abc import Generator from time import time from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.lovelace import cast as lovelace_cast from homeassistant.components.media_player import MediaClass @@ -17,7 +17,7 @@ from tests.common import async_mock_service @pytest.fixture(autouse=True) -def mock_onboarding_done() -> Generator[MagicMock, None, None]: +def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding. Enabled to prevent creating default dashboards during test execution. diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 47c4981ba2a..7577c4dcc0d 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -1,10 +1,11 @@ """Test the Lovelace initialization.""" -from collections.abc import Generator +import time from typing import Any from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components import frontend from homeassistant.components.lovelace import const, dashboard @@ -16,7 +17,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_onboarding_done() -> Generator[MagicMock, None, None]: +def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding. Enabled to prevent creating default dashboards during test execution. @@ -29,7 +30,9 @@ def mock_onboarding_done() -> Generator[MagicMock, None, None]: async def test_lovelace_from_storage( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], ) -> None: """Test we load lovelace config from storage.""" assert await async_setup_component(hass, "lovelace", {}) @@ -82,7 +85,9 @@ async def test_lovelace_from_storage( async def test_lovelace_from_storage_save_before_load( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], ) -> None: """Test we can load lovelace config from storage.""" assert await async_setup_component(hass, "lovelace", {}) @@ -100,7 +105,9 @@ async def test_lovelace_from_storage_save_before_load( async def test_lovelace_from_storage_delete( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], ) -> None: """Test we delete lovelace config from storage.""" assert await async_setup_component(hass, "lovelace", {}) @@ -180,6 +187,44 @@ async def test_lovelace_from_yaml( assert len(events) == 1 + # Make sure when the mtime changes, we reload the config + with ( + patch( + "homeassistant.components.lovelace.dashboard.load_yaml_dict", + return_value={"hello": "yo3"}, + ), + patch( + "homeassistant.components.lovelace.dashboard.os.path.getmtime", + return_value=time.time(), + ), + ): + await client.send_json({"id": 9, "type": "lovelace/config", "force": False}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"hello": "yo3"} + + assert len(events) == 2 + + # If the mtime is lower, preserve the cache + with ( + patch( + "homeassistant.components.lovelace.dashboard.load_yaml_dict", + return_value={"hello": "yo4"}, + ), + patch( + "homeassistant.components.lovelace.dashboard.os.path.getmtime", + return_value=0, + ), + ): + await client.send_json({"id": 10, "type": "lovelace/config", "force": False}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"hello": "yo3"} + + assert len(events) == 2 + @pytest.mark.parametrize("url_path", ["test-panel", "test-panel-no-sidebar"]) async def test_dashboard_from_yaml( @@ -313,7 +358,9 @@ async def test_wrong_key_dashboard_from_yaml(hass: HomeAssistant) -> None: async def test_storage_dashboards( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], ) -> None: """Test we load lovelace config from storage.""" assert await async_setup_component(hass, "lovelace", {}) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index a88745e4500..dc111ab601e 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -1,10 +1,10 @@ """Test the Lovelace initialization.""" -from collections.abc import Generator from typing import Any from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -13,7 +13,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def mock_onboarding_not_done() -> Generator[MagicMock, None, None]: +def mock_onboarding_not_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -23,7 +23,7 @@ def mock_onboarding_not_done() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_onboarding_done() -> Generator[MagicMock, None, None]: +def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -33,7 +33,7 @@ def mock_onboarding_done() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_add_onboarding_listener() -> Generator[MagicMock, None, None]: +def mock_add_onboarding_listener() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_add_listener", diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 7591960b589..281fb001fc2 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -2,9 +2,11 @@ import copy from typing import Any -from unittest.mock import patch +from unittest.mock import ANY, patch import uuid +import pytest + from homeassistant.components.lovelace import dashboard, resources from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,8 +19,9 @@ RESOURCE_EXAMPLES = [ ] +@pytest.mark.parametrize("list_cmd", ["lovelace/resources", "lovelace/resources/list"]) async def test_yaml_resources( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, list_cmd: str ) -> None: """Test defining resources in configuration.yaml.""" assert await async_setup_component( @@ -28,14 +31,15 @@ async def test_yaml_resources( client = await hass_ws_client(hass) # Fetch data - await client.send_json({"id": 5, "type": "lovelace/resources"}) + await client.send_json({"id": 5, "type": list_cmd}) response = await client.receive_json() assert response["success"] assert response["result"] == RESOURCE_EXAMPLES +@pytest.mark.parametrize("list_cmd", ["lovelace/resources", "lovelace/resources/list"]) async def test_yaml_resources_backwards( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, list_cmd: str ) -> None: """Test defining resources in YAML ll config (legacy).""" with patch( @@ -49,14 +53,18 @@ async def test_yaml_resources_backwards( client = await hass_ws_client(hass) # Fetch data - await client.send_json({"id": 5, "type": "lovelace/resources"}) + await client.send_json({"id": 5, "type": list_cmd}) response = await client.receive_json() assert response["success"] assert response["result"] == RESOURCE_EXAMPLES +@pytest.mark.parametrize("list_cmd", ["lovelace/resources", "lovelace/resources/list"]) async def test_storage_resources( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + list_cmd: str, ) -> None: """Test defining resources in storage config.""" resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] @@ -70,14 +78,18 @@ async def test_storage_resources( client = await hass_ws_client(hass) # Fetch data - await client.send_json({"id": 5, "type": "lovelace/resources"}) + await client.send_json({"id": 5, "type": list_cmd}) response = await client.receive_json() assert response["success"] assert response["result"] == resource_config +@pytest.mark.parametrize("list_cmd", ["lovelace/resources", "lovelace/resources/list"]) async def test_storage_resources_import( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + list_cmd: str, ) -> None: """Test importing resources from storage config.""" assert await async_setup_component(hass, "lovelace", {}) @@ -89,8 +101,43 @@ async def test_storage_resources_import( client = await hass_ws_client(hass) - # Fetch data - await client.send_json({"id": 5, "type": "lovelace/resources"}) + # Subscribe + await client.send_json_auto_id({"type": "lovelace/resources/subscribe"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + event_id = response["id"] + + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [] + + # Fetch data - this also loads the resources + await client.send_json_auto_id({"type": list_cmd}) + + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [ + { + "change_type": "added", + "item": { + "id": ANY, + "type": "js", + "url": "/local/bla.js", + }, + "resource_id": ANY, + }, + { + "change_type": "added", + "item": { + "id": ANY, + "type": "css", + "url": "/local/bla.css", + }, + "resource_id": ANY, + }, + ] + response = await client.receive_json() assert response["success"] assert ( @@ -103,18 +150,31 @@ async def test_storage_resources_import( ) # Add a resource - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": "lovelace/resources/create", "res_type": "module", "url": "/local/yo.js", } ) + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [ + { + "change_type": "added", + "item": { + "id": ANY, + "type": "module", + "url": "/local/yo.js", + }, + "resource_id": ANY, + } + ] + response = await client.receive_json() assert response["success"] - await client.send_json({"id": 7, "type": "lovelace/resources"}) + await client.send_json_auto_id({"type": list_cmd}) response = await client.receive_json() assert response["success"] @@ -125,19 +185,32 @@ async def test_storage_resources_import( # Update a resource first_item = response["result"][0] - await client.send_json( + await client.send_json_auto_id( { - "id": 8, "type": "lovelace/resources/update", "resource_id": first_item["id"], "res_type": "css", "url": "/local/updated.css", } ) + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [ + { + "change_type": "updated", + "item": { + "id": first_item["id"], + "type": "css", + "url": "/local/updated.css", + }, + "resource_id": first_item["id"], + } + ] + response = await client.receive_json() assert response["success"] - await client.send_json({"id": 9, "type": "lovelace/resources"}) + await client.send_json_auto_id({"type": list_cmd}) response = await client.receive_json() assert response["success"] @@ -145,18 +218,31 @@ async def test_storage_resources_import( assert first_item["type"] == "css" assert first_item["url"] == "/local/updated.css" - # Delete resources - await client.send_json( + # Delete a resource + await client.send_json_auto_id( { - "id": 10, "type": "lovelace/resources/delete", "resource_id": first_item["id"], } ) + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [ + { + "change_type": "removed", + "item": { + "id": first_item["id"], + "type": "css", + "url": "/local/updated.css", + }, + "resource_id": first_item["id"], + } + ] + response = await client.receive_json() assert response["success"] - await client.send_json({"id": 11, "type": "lovelace/resources"}) + await client.send_json_auto_id({"type": list_cmd}) response = await client.receive_json() assert response["success"] @@ -164,8 +250,12 @@ async def test_storage_resources_import( assert first_item["id"] not in (item["id"] for item in response["result"]) +@pytest.mark.parametrize("list_cmd", ["lovelace/resources", "lovelace/resources/list"]) async def test_storage_resources_import_invalid( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + list_cmd: str, ) -> None: """Test importing resources from storage config.""" assert await async_setup_component(hass, "lovelace", {}) @@ -178,7 +268,7 @@ async def test_storage_resources_import_invalid( client = await hass_ws_client(hass) # Fetch data - await client.send_json({"id": 5, "type": "lovelace/resources"}) + await client.send_json({"id": 5, "type": list_cmd}) response = await client.receive_json() assert response["success"] assert response["result"] == [] @@ -188,8 +278,12 @@ async def test_storage_resources_import_invalid( ) +@pytest.mark.parametrize("list_cmd", ["lovelace/resources", "lovelace/resources/list"]) async def test_storage_resources_safe_mode( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], + list_cmd: str, ) -> None: """Test defining resources in storage config.""" @@ -205,7 +299,7 @@ async def test_storage_resources_safe_mode( hass.config.safe_mode = True # Fetch data - await client.send_json({"id": 5, "type": "lovelace/resources"}) + await client.send_json({"id": 5, "type": list_cmd}) response = await client.receive_json() assert response["success"] assert response["result"] == [] diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 9bd8543004c..d53ebf2871f 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -1,10 +1,10 @@ """Tests for Lovelace system health.""" -from collections.abc import Generator from typing import Any from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.lovelace import dashboard from homeassistant.core import HomeAssistant @@ -14,7 +14,7 @@ from tests.common import get_system_health_info @pytest.fixture(autouse=True) -def mock_onboarding_done() -> Generator[MagicMock, None, None]: +def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding. Enabled to prevent creating default dashboards during test execution. diff --git a/tests/components/luftdaten/conftest.py b/tests/components/luftdaten/conftest.py index e083e8c97c7..e1aac7caeb0 100644 --- a/tests/components/luftdaten/conftest.py +++ b/tests/components/luftdaten/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN from homeassistant.const import CONF_SHOW_ON_MAP @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.luftdaten.async_setup_entry", return_value=True @@ -35,7 +35,7 @@ def mock_setup_entry() -> Generator[None, None, None]: @pytest.fixture -def mock_luftdaten() -> Generator[None, MagicMock, None]: +def mock_luftdaten() -> Generator[MagicMock]: """Return a mocked Luftdaten client.""" with ( patch( diff --git a/tests/components/lutron/conftest.py b/tests/components/lutron/conftest.py index e94e337ce1d..90f96f1783d 100644 --- a/tests/components/lutron/conftest.py +++ b/tests/components/lutron/conftest.py @@ -1,13 +1,13 @@ """Provide common Lutron fixtures and mocks.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.lutron.async_setup_entry", return_value=True diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index e4904838e1a..47b2a4891cf 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -7,7 +7,7 @@ from urllib.error import HTTPError import pytest from homeassistant.components.lutron.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -146,77 +146,3 @@ MOCK_DATA_IMPORT = { CONF_USERNAME: "lutron", CONF_PASSWORD: "integration", } - - -async def test_import( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, -) -> None: - """Test import flow.""" - with ( - patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), - patch("homeassistant.components.lutron.config_flow.Lutron.guid", "12345678901"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == MOCK_DATA_IMPORT - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("raise_error", "reason"), - [ - (HTTPError("", 404, "", Message(), None), "cannot_connect"), - (Exception, "unknown"), - ], -) -async def test_import_flow_failure( - hass: HomeAssistant, raise_error: Exception, reason: str -) -> None: - """Test handling errors while importing.""" - - with patch( - "homeassistant.components.lutron.config_flow.Lutron.load_xml_db", - side_effect=raise_error, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - - -async def test_import_flow_guid_failure(hass: HomeAssistant) -> None: - """Test handling errors while importing.""" - - with ( - patch("homeassistant.components.lutron.config_flow.Lutron.load_xml_db"), - patch("homeassistant.components.lutron.config_flow.Lutron.guid", "123"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_already_configured(hass: HomeAssistant) -> None: - """Test we abort import when entry is already configured.""" - - entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_DATA_IMPORT, unique_id="12345678901" - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_DATA_IMPORT - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index cc785f71e19..9b25e2a0164 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -9,6 +9,7 @@ from homeassistant.components.lutron_caseta.const import ( CONF_KEYFILE, ) from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -83,7 +84,7 @@ _LEAP_DEVICE_TYPES = { } -async def async_setup_integration(hass, mock_bridge) -> MockConfigEntry: +async def async_setup_integration(hass: HomeAssistant, mock_bridge) -> MockConfigEntry: """Set up a mock bridge.""" mock_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) mock_entry.add_to_hass(hass) diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 0e638065cf7..208dd36cccd 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -33,7 +33,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_TYPE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -103,7 +103,7 @@ MOCK_BUTTON_DEVICES = [ @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -148,7 +148,7 @@ async def test_get_triggers(hass: HomeAssistant) -> None: CONF_TYPE: "press", "metadata": {}, } - for subtype in ["on", "stop", "off", "raise", "lower"] + for subtype in ("on", "stop", "off", "raise", "lower") ] expected_triggers += [ { @@ -159,7 +159,7 @@ async def test_get_triggers(hass: HomeAssistant) -> None: CONF_TYPE: "release", "metadata": {}, } - for subtype in ["on", "stop", "off", "raise", "lower"] + for subtype in ("on", "stop", "off", "raise", "lower") ] triggers = await async_get_device_automations( @@ -220,7 +220,7 @@ async def test_none_serial_keypad( async def test_if_fires_on_button_event( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry ) -> None: """Test for press trigger firing.""" await _async_setup_lutron_with_picos(hass) @@ -271,7 +271,7 @@ async def test_if_fires_on_button_event( async def test_if_fires_on_button_event_without_lip( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry ) -> None: """Test for press trigger firing on a device that does not support lip.""" await _async_setup_lutron_with_picos(hass) @@ -319,7 +319,9 @@ async def test_if_fires_on_button_event_without_lip( assert calls[0].data["some"] == "test_trigger_button_press" -async def test_validate_trigger_config_no_device(hass: HomeAssistant, calls) -> None: +async def test_validate_trigger_config_no_device( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for no press with no device.""" assert await async_setup_component( @@ -358,7 +360,7 @@ async def test_validate_trigger_config_no_device(hass: HomeAssistant, calls) -> async def test_validate_trigger_config_unknown_device( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test for no press with an unknown device.""" @@ -442,7 +444,7 @@ async def test_validate_trigger_invalid_triggers( async def test_if_fires_on_button_event_late_setup( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry ) -> None: """Test for press trigger firing with integration getting setup late.""" config_entry_id = await _async_setup_lutron_with_picos(hass) diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index b6e8840c85c..51c96b9d9a9 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -111,7 +111,7 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( await hass.async_block_till_done() for device in device_registry.devices.values(): - if device.config_entries == {config_entry.entry_id}: + if device.config_entries == [config_entry.entry_id]: dr_device_id = device.id break diff --git a/tests/components/lyric/test_config_flow.py b/tests/components/lyric/test_config_flow.py index 73b3aae2d3d..e1a8d1131dc 100644 --- a/tests/components/lyric/test_config_flow.py +++ b/tests/components/lyric/test_config_flow.py @@ -45,11 +45,11 @@ async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: assert result["reason"] == "missing_credentials" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_impl, ) -> None: """Check full flow.""" @@ -112,11 +112,11 @@ async def test_full_flow( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, mock_impl, ) -> None: """Test reauthentication flow.""" diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index 296a4fbfa6b..31e831c3bae 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -164,7 +164,7 @@ async def test_delete_from_mailbox(mock_http_client: TestClient) -> None: msgsha1 = sha1(msgtxt1.encode("utf-8")).hexdigest() msgsha2 = sha1(msgtxt2.encode("utf-8")).hexdigest() - for msg in [msgsha1, msgsha2]: + for msg in (msgsha1, msgsha2): url = f"/api/mailbox/delete/TestMailbox/{msg}" req = await mock_http_client.delete(url) assert req.status == HTTPStatus.OK diff --git a/tests/components/mailgun/test_init.py b/tests/components/mailgun/test_init.py index e2274f03d23..908e98ae31e 100644 --- a/tests/components/mailgun/test_init.py +++ b/tests/components/mailgun/test_init.py @@ -3,21 +3,26 @@ import hashlib import hmac +from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries from homeassistant.components import mailgun, webhook from homeassistant.config import async_process_ha_core_config from homeassistant.const import CONF_API_KEY, CONF_DOMAIN -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + API_KEY = "abc123" @pytest.fixture -async def http_client(hass, hass_client_no_auth): +async def http_client( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> TestClient: """Initialize a Home Assistant Server for testing this module.""" await async_setup_component(hass, webhook.DOMAIN, {}) return await hass_client_no_auth() diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 7a264134320..6c9ba9ee9a0 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -7,6 +7,7 @@ from freezegun import freeze_time import pytest from homeassistant.components import alarm_control_panel +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.demo import alarm_control_panel as demo from homeassistant.const import ( ATTR_CODE, @@ -315,7 +316,7 @@ async def test_with_specific_pending( await hass.services.async_call( alarm_control_panel.DOMAIN, service, - {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "1234"}, blocking=True, ) @@ -1456,3 +1457,70 @@ async def test_restore_state_triggered_long_ago(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_DISARMED + + +async def test_default_arming_states(hass: HomeAssistant) -> None: + """Test default arming_states.""" + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + { + "alarm_control_panel": { + "platform": "manual", + "name": "test", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test") + assert state.attributes["supported_features"] == ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION + | AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ) + + +async def test_arming_states(hass: HomeAssistant) -> None: + """Test arming_states.""" + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + { + "alarm_control_panel": { + "platform": "manual", + "name": "test", + "arming_states": ["armed_away", "armed_home"], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test") + assert state.attributes["supported_features"] == ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.TRIGGER + ) + + +async def test_invalid_arming_states(hass: HomeAssistant) -> None: + """Test invalid arming_states.""" + assert await async_setup_component( + hass, + alarm_control_panel.DOMAIN, + { + "alarm_control_panel": { + "platform": "manual", + "name": "test", + "arming_states": ["invalid", "armed_home"], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test") + assert state is None diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 5c2704db937..a1c913135a7 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -380,7 +380,7 @@ async def test_with_specific_pending( await hass.services.async_call( alarm_control_panel.DOMAIN, service, - {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: "1234"}, blocking=True, ) @@ -1442,7 +1442,7 @@ async def test_state_changes_are_published_to_mqtt( mqtt_mock.async_publish.reset_mock() # Arm in home mode - await common.async_alarm_arm_home(hass) + await common.async_alarm_arm_home(hass, "1234") await hass.async_block_till_done() mqtt_mock.async_publish.assert_called_once_with( "alarm/state", STATE_ALARM_PENDING, 0, True @@ -1462,7 +1462,7 @@ async def test_state_changes_are_published_to_mqtt( mqtt_mock.async_publish.reset_mock() # Arm in away mode - await common.async_alarm_arm_away(hass) + await common.async_alarm_arm_away(hass, "1234") await hass.async_block_till_done() mqtt_mock.async_publish.assert_called_once_with( "alarm/state", STATE_ALARM_PENDING, 0, True @@ -1482,7 +1482,7 @@ async def test_state_changes_are_published_to_mqtt( mqtt_mock.async_publish.reset_mock() # Arm in night mode - await common.async_alarm_arm_night(hass) + await common.async_alarm_arm_night(hass, "1234") await hass.async_block_till_done() mqtt_mock.async_publish.assert_called_once_with( "alarm/state", STATE_ALARM_PENDING, 0, True diff --git a/tests/components/map/test_init.py b/tests/components/map/test_init.py index 6d79afefab3..afafdd1eb16 100644 --- a/tests/components/map/test_init.py +++ b/tests/components/map/test_init.py @@ -1,10 +1,10 @@ """Test the Map initialization.""" -from collections.abc import Generator from typing import Any from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.map import DOMAIN from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant @@ -15,7 +15,7 @@ from tests.common import MockModule, mock_integration @pytest.fixture -def mock_onboarding_not_done() -> Generator[MagicMock, None, None]: +def mock_onboarding_not_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -25,7 +25,7 @@ def mock_onboarding_not_done() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_onboarding_done() -> Generator[MagicMock, None, None]: +def mock_onboarding_done() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -35,7 +35,7 @@ def mock_onboarding_done() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_create_map_dashboard() -> Generator[MagicMock, None, None]: +def mock_create_map_dashboard() -> Generator[MagicMock]: """Mock the create map dashboard function.""" with patch( "homeassistant.components.map._create_map_dashboard", @@ -98,19 +98,21 @@ async def test_create_dashboards_when_not_onboarded( assert hass_storage[DOMAIN]["data"] == {"migrated": True} -async def test_create_issue_when_not_manually_configured(hass: HomeAssistant) -> None: +async def test_create_issue_when_not_manually_configured( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test creating issue registry issues.""" assert await async_setup_component(hass, DOMAIN, {}) - issue_registry = ir.async_get(hass) assert not issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, "deprecated_yaml_map" ) -async def test_create_issue_when_manually_configured(hass: HomeAssistant) -> None: +async def test_create_issue_when_manually_configured( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test creating issue registry issues.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - issue_registry = ir.async_get(hass) assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, "deprecated_yaml_map") diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 953c66f58d1..75784bb56c5 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -2,6 +2,7 @@ from http import HTTPStatus import io +from pathlib import Path from unittest.mock import patch import wave @@ -33,7 +34,7 @@ def get_empty_wav() -> bytes: @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py index 18227914df4..bb5448a8a09 100644 --- a/tests/components/matrix/conftest.py +++ b/tests/components/matrix/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path import re import tempfile from unittest.mock import patch @@ -23,6 +24,7 @@ from nio import ( ) from PIL import Image import pytest +from typing_extensions import Generator from homeassistant.components.matrix import ( CONF_COMMANDS, @@ -304,9 +306,9 @@ def command_events(hass: HomeAssistant): @pytest.fixture -def image_path(tmp_path): +def image_path(tmp_path: Path) -> Generator[tempfile._TemporaryFileWrapper]: """Provide the Path to a mock image.""" image = Image.new("RGBA", size=(50, 50), color=(256, 0, 0)) - image_file = tempfile.NamedTemporaryFile(dir=tmp_path) - image.save(image_file, "PNG") - return image_file + with tempfile.NamedTemporaryFile(dir=tmp_path) as image_file: + image.save(image_file, "PNG") + yield image_file diff --git a/tests/components/matrix/test_commands.py b/tests/components/matrix/test_commands.py index f71ec22e794..8539252ad66 100644 --- a/tests/components/matrix/test_commands.py +++ b/tests/components/matrix/test_commands.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components.matrix import MatrixBot, RoomID from homeassistant.core import Event, HomeAssistant -from tests.components.matrix.conftest import ( +from .conftest import ( MOCK_EXPRESSION_COMMANDS, MOCK_WORD_COMMANDS, TEST_MXID, @@ -131,7 +131,7 @@ async def test_commands( matrix_bot: MatrixBot, command_events: list[Event], command_params: CommandTestParameters, -): +) -> None: """Test that the configured commands are used correctly.""" room = MatrixRoom(room_id=command_params.room_id, own_user_id=matrix_bot._mx_id) @@ -160,7 +160,7 @@ async def test_non_commands( matrix_bot: MatrixBot, command_events: list[Event], command_params: CommandTestParameters, -): +) -> None: """Test that normal/non-qualifying messages don't wrongly trigger commands.""" room = MatrixRoom(room_id=command_params.room_id, own_user_id=matrix_bot._mx_id) @@ -173,7 +173,7 @@ async def test_non_commands( assert len(command_events) == 0 -async def test_commands_parsing(hass: HomeAssistant, matrix_bot: MatrixBot): +async def test_commands_parsing(hass: HomeAssistant, matrix_bot: MatrixBot) -> None: """Test that the configured commands were parsed correctly.""" await hass.async_start() diff --git a/tests/components/matrix/test_login.py b/tests/components/matrix/test_login.py index 8112d98fc8c..ad9bf660402 100644 --- a/tests/components/matrix/test_login.py +++ b/tests/components/matrix/test_login.py @@ -6,12 +6,7 @@ import pytest from homeassistant.components.matrix import MatrixBot from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from tests.components.matrix.conftest import ( - TEST_DEVICE_ID, - TEST_MXID, - TEST_PASSWORD, - TEST_TOKEN, -) +from .conftest import TEST_DEVICE_ID, TEST_MXID, TEST_PASSWORD, TEST_TOKEN @dataclass @@ -90,7 +85,7 @@ bad_password_missing_access_token = LoginTestParameters( ) async def test_login( matrix_bot: MatrixBot, caplog: pytest.LogCaptureFixture, params: LoginTestParameters -): +) -> None: """Test logging in with the given parameters and expected state.""" await matrix_bot._client.logout() matrix_bot._password = params.password @@ -105,7 +100,7 @@ async def test_login( assert set(caplog.messages).issuperset(params.expected_caplog_messages) -async def test_get_auth_tokens(matrix_bot: MatrixBot, mock_load_json): +async def test_get_auth_tokens(matrix_bot: MatrixBot, mock_load_json) -> None: """Test loading access_tokens from a mocked file.""" # Test loading good tokens. diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py index bfd6d5824cb..cae8dbef76d 100644 --- a/tests/components/matrix/test_matrix_bot.py +++ b/tests/components/matrix/test_matrix_bot.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .conftest import TEST_NOTIFIER_NAME -async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): +async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot) -> None: """Test hass/MatrixBot state.""" services = hass.services.async_services() diff --git a/tests/components/matrix/test_rooms.py b/tests/components/matrix/test_rooms.py index 66d1afbf532..e8e94224066 100644 --- a/tests/components/matrix/test_rooms.py +++ b/tests/components/matrix/test_rooms.py @@ -9,9 +9,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import MOCK_CONFIG_DATA - -from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS +from .conftest import MOCK_CONFIG_DATA, TEST_BAD_ROOM, TEST_JOINABLE_ROOMS async def test_join( diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py index 47c3e08aa48..cdea2270cf9 100644 --- a/tests/components/matrix/test_send_message.py +++ b/tests/components/matrix/test_send_message.py @@ -1,5 +1,7 @@ """Test the send_message service.""" +import pytest + from homeassistant.components.matrix import ( ATTR_FORMAT, ATTR_IMAGES, @@ -10,12 +12,16 @@ from homeassistant.components.matrix.const import FORMAT_HTML, SERVICE_SEND_MESS from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET from homeassistant.core import HomeAssistant -from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS +from .conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS async def test_send_message( - hass: HomeAssistant, matrix_bot: MatrixBot, image_path, matrix_events, caplog -): + hass: HomeAssistant, + matrix_bot: MatrixBot, + image_path, + matrix_events, + caplog: pytest.LogCaptureFixture, +) -> None: """Test the send_message service.""" await hass.async_start() @@ -55,8 +61,11 @@ async def test_send_message( async def test_unsendable_message( - hass: HomeAssistant, matrix_bot: MatrixBot, matrix_events, caplog -): + hass: HomeAssistant, + matrix_bot: MatrixBot, + matrix_events, + caplog: pytest.LogCaptureFixture, +) -> None: """Test the send_message service with an invalid room.""" assert len(matrix_events) == 0 await matrix_bot._login() diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index a04bf68d28a..05fd776e57a 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -3,13 +3,13 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from matter_server.client.models.node import MatterNode from matter_server.common.const import SCHEMA_VERSION from matter_server.common.models import ServerInfoMessage import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.core import HomeAssistant @@ -22,7 +22,7 @@ MOCK_COMPR_FABRIC_ID = 1234 @pytest.fixture(name="matter_client") -async def matter_client_fixture() -> AsyncGenerator[MagicMock, None]: +async def matter_client_fixture() -> AsyncGenerator[MagicMock]: """Fixture for a Matter client.""" with patch( "homeassistant.components.matter.MatterClient", autospec=True @@ -70,7 +70,7 @@ async def integration_fixture( @pytest.fixture(name="create_backup") -def create_backup_fixture() -> Generator[AsyncMock, None, None]: +def create_backup_fixture() -> Generator[AsyncMock]: """Mock Supervisor create backup of add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_create_backup" @@ -79,7 +79,7 @@ def create_backup_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="addon_store_info") -def addon_store_info_fixture() -> Generator[AsyncMock, None, None]: +def addon_store_info_fixture() -> Generator[AsyncMock]: """Mock Supervisor add-on store info.""" with patch( "homeassistant.components.hassio.addon_manager.async_get_addon_store_info" @@ -94,7 +94,7 @@ def addon_store_info_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="addon_info") -def addon_info_fixture() -> Generator[AsyncMock, None, None]: +def addon_info_fixture() -> Generator[AsyncMock]: """Mock Supervisor add-on info.""" with patch( "homeassistant.components.hassio.addon_manager.async_get_addon_info", @@ -158,7 +158,7 @@ def addon_running_fixture( @pytest.fixture(name="install_addon") def install_addon_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock -) -> Generator[AsyncMock, None, None]: +) -> Generator[AsyncMock]: """Mock install add-on.""" async def install_addon_side_effect(hass: HomeAssistant, slug: str) -> None: @@ -181,7 +181,7 @@ def install_addon_fixture( @pytest.fixture(name="start_addon") -def start_addon_fixture() -> Generator[AsyncMock, None, None]: +def start_addon_fixture() -> Generator[AsyncMock]: """Mock start add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_start_addon" @@ -190,7 +190,7 @@ def start_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="stop_addon") -def stop_addon_fixture() -> Generator[AsyncMock, None, None]: +def stop_addon_fixture() -> Generator[AsyncMock]: """Mock stop add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_stop_addon" @@ -199,7 +199,7 @@ def stop_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="uninstall_addon") -def uninstall_addon_fixture() -> Generator[AsyncMock, None, None]: +def uninstall_addon_fixture() -> Generator[AsyncMock]: """Mock uninstall add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_uninstall_addon" @@ -208,7 +208,7 @@ def uninstall_addon_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="update_addon") -def update_addon_fixture() -> Generator[AsyncMock, None, None]: +def update_addon_fixture() -> Generator[AsyncMock]: """Mock update add-on.""" with patch( "homeassistant.components.hassio.addon_manager.async_update_addon" diff --git a/tests/components/matter/fixtures/nodes/air-purifier.json b/tests/components/matter/fixtures/nodes/air-purifier.json new file mode 100644 index 00000000000..daa143d57e8 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/air-purifier.json @@ -0,0 +1,706 @@ +{ + "node_id": 143, + "date_commissioned": "2024-05-27T08:56:55.931757", + "last_interview": "2024-05-27T08:56:55.931762", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1, 2, 3, 4, 5], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Air Purifier", + "0/40/4": 32769, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "29E3B8A925484953", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5kMA==", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "veth90ad201", + "1": true, + "2": null, + "3": null, + "4": "niHggbas", + "5": [], + "6": ["/oAAAAAAAACcIeD//oG2rA=="], + "7": 0 + }, + { + "0": "veth5a7d8ed", + "1": true, + "2": null, + "3": null, + "4": "nn997EzL", + "5": [], + "6": ["/oAAAAAAAACcf33//uxMyw=="], + "7": 0 + }, + { + "0": "veth3408146", + "1": true, + "2": null, + "3": null, + "4": "XqhU7ti3", + "5": [], + "6": ["/oAAAAAAAABcqFT//u7Ytw=="], + "7": 0 + }, + { + "0": "veth3f3d040", + "1": true, + "2": null, + "3": null, + "4": "Vlz/o96u", + "5": [], + "6": ["/oAAAAAAAABUXP///qPerg=="], + "7": 0 + }, + { + "0": "vethf3a8950", + "1": true, + "2": null, + "3": null, + "4": "Ikj8iJ0V", + "5": [], + "6": ["/oAAAAAAAAAgSPz//oidFQ=="], + "7": 0 + }, + { + "0": "vethb3a8e95", + "1": true, + "2": null, + "3": null, + "4": "Pm3ij+z4", + "5": [], + "6": ["/oAAAAAAAAA8beL//o/s+A=="], + "7": 0 + }, + { + "0": "veth02a8c45", + "1": true, + "2": null, + "3": null, + "4": "xlbQTHOq", + "5": [], + "6": ["/oAAAAAAAADEVtD//kxzqg=="], + "7": 0 + }, + { + "0": "veth2daa408", + "1": true, + "2": null, + "3": null, + "4": "ZucpYWOy", + "5": [], + "6": ["/oAAAAAAAABk5yn//mFjsg=="], + "7": 0 + }, + { + "0": "hassio", + "1": true, + "2": null, + "3": null, + "4": "AkKEd951", + "5": ["rB4gAQ=="], + "6": ["/oAAAAAAAAAAQoT//nfedQ=="], + "7": 0 + }, + { + "0": "docker0", + "1": true, + "2": null, + "3": null, + "4": "AkI4C0xe", + "5": ["rB7oAQ=="], + "6": [], + "7": 0 + }, + { + "0": "end0", + "1": true, + "2": null, + "3": null, + "4": "redacted", + "5": [], + "6": [], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": [], + "6": [], + "7": 0 + } + ], + "0/51/1": 2, + "0/51/2": 22, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "redacted", + "2": "redacted", + "254": 2 + } + ], + "0/62/1": [ + { + "1": "redacted", + "2": 65521, + "3": 1, + "4": 143, + "5": "", + "254": 2 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": ["redacted"], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 45, + "1": 1 + } + ], + "1/29/1": [3, 29, 113, 114, 514], + "1/29/2": [], + "1/29/3": [2, 3, 4, 5], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/113/0": 100, + "1/113/1": 1, + "1/113/2": 0, + "1/113/3": true, + "1/113/4": null, + "1/113/5": [ + { + "0": 0, + "1": "111112222233" + }, + { + "0": 1, + "1": "gtin8xxx" + }, + { + "0": 2, + "1": "4444455555666" + }, + { + "0": 3, + "1": "gtin14xxxxxxxx" + }, + { + "0": 4, + "1": "oem20xxxxxxxxxxxxxxx" + } + ], + "1/113/65532": 7, + "1/113/65533": 1, + "1/113/65528": [], + "1/113/65529": [0], + "1/113/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/114/0": 100, + "1/114/1": 1, + "1/114/2": 0, + "1/114/3": true, + "1/114/4": null, + "1/114/5": [ + { + "0": 0, + "1": "111112222233" + }, + { + "0": 1, + "1": "gtin8xxx" + }, + { + "0": 2, + "1": "4444455555666" + }, + { + "0": 3, + "1": "gtin14xxxxxxxx" + }, + { + "0": 4, + "1": "oem20xxxxxxxxxxxxxxx" + } + ], + "1/114/65532": 7, + "1/114/65533": 1, + "1/114/65528": [], + "1/114/65529": [0], + "1/114/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "1/514/0": 5, + "1/514/1": 2, + "1/514/2": null, + "1/514/3": 255, + "1/514/4": 10, + "1/514/5": null, + "1/514/6": 255, + "1/514/7": 1, + "1/514/8": 0, + "1/514/9": 3, + "1/514/10": 0, + "1/514/11": 0, + "1/514/65532": 63, + "1/514/65533": 4, + "1/514/65528": [], + "1/514/65529": [0], + "1/514/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 65528, 65529, 65531, 65532, 65533 + ], + "2/3/0": 0, + "2/3/1": 0, + "2/3/65532": 0, + "2/3/65533": 4, + "2/3/65528": [], + "2/3/65529": [0], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 44, + "1": 1 + } + ], + "2/29/1": [ + 3, 29, 91, 1036, 1037, 1043, 1045, 1066, 1067, 1068, 1069, 1070, 1071 + ], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/91/0": 1, + "2/91/65532": 15, + "2/91/65533": 1, + "2/91/65528": [], + "2/91/65529": [], + "2/91/65531": [0, 65528, 65529, 65531, 65532, 65533], + "2/1036/0": 2.0, + "2/1036/1": 0.0, + "2/1036/2": 1000.0, + "2/1036/3": 1.0, + "2/1036/4": 320, + "2/1036/5": 1.0, + "2/1036/6": 320, + "2/1036/7": 0.0, + "2/1036/8": 0, + "2/1036/9": 0, + "2/1036/10": 1, + "2/1036/65532": 63, + "2/1036/65533": 3, + "2/1036/65528": [], + "2/1036/65529": [], + "2/1036/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1037/0": 2.0, + "2/1037/1": 0.0, + "2/1037/2": 1000.0, + "2/1037/3": 1.0, + "2/1037/4": 320, + "2/1037/5": 1.0, + "2/1037/6": 320, + "2/1037/7": 0.0, + "2/1037/8": 0, + "2/1037/9": 0, + "2/1037/10": 1, + "2/1037/65532": 63, + "2/1037/65533": 3, + "2/1037/65528": [], + "2/1037/65529": [], + "2/1037/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1043/0": 2.0, + "2/1043/1": 0.0, + "2/1043/2": 1000.0, + "2/1043/3": 1.0, + "2/1043/4": 320, + "2/1043/5": 1.0, + "2/1043/6": 320, + "2/1043/7": 0.0, + "2/1043/8": 0, + "2/1043/9": 0, + "2/1043/10": 1, + "2/1043/65532": 63, + "2/1043/65533": 3, + "2/1043/65528": [], + "2/1043/65529": [], + "2/1043/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1045/0": 2.0, + "2/1045/1": 0.0, + "2/1045/2": 1000.0, + "2/1045/3": 1.0, + "2/1045/4": 320, + "2/1045/5": 1.0, + "2/1045/6": 320, + "2/1045/7": 0.0, + "2/1045/8": 0, + "2/1045/9": 0, + "2/1045/10": 1, + "2/1045/65532": 63, + "2/1045/65533": 3, + "2/1045/65528": [], + "2/1045/65529": [], + "2/1045/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1066/0": 2.0, + "2/1066/1": 0.0, + "2/1066/2": 1000.0, + "2/1066/3": 1.0, + "2/1066/4": 320, + "2/1066/5": 1.0, + "2/1066/6": 320, + "2/1066/7": 0.0, + "2/1066/8": 0, + "2/1066/9": 0, + "2/1066/10": 1, + "2/1066/65532": 63, + "2/1066/65533": 3, + "2/1066/65528": [], + "2/1066/65529": [], + "2/1066/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1067/0": 2.0, + "2/1067/1": 0.0, + "2/1067/2": 1000.0, + "2/1067/3": 1.0, + "2/1067/4": 320, + "2/1067/5": 1.0, + "2/1067/6": 320, + "2/1067/7": 0.0, + "2/1067/8": 0, + "2/1067/9": 0, + "2/1067/10": 1, + "2/1067/65532": 63, + "2/1067/65533": 3, + "2/1067/65528": [], + "2/1067/65529": [], + "2/1067/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1068/0": 2.0, + "2/1068/1": 0.0, + "2/1068/2": 1000.0, + "2/1068/3": 1.0, + "2/1068/4": 320, + "2/1068/5": 1.0, + "2/1068/6": 320, + "2/1068/7": 0.0, + "2/1068/8": 0, + "2/1068/9": 0, + "2/1068/10": 1, + "2/1068/65532": 63, + "2/1068/65533": 3, + "2/1068/65528": [], + "2/1068/65529": [], + "2/1068/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1069/0": 2.0, + "2/1069/1": 0.0, + "2/1069/2": 1000.0, + "2/1069/3": 1.0, + "2/1069/4": 320, + "2/1069/5": 1.0, + "2/1069/6": 320, + "2/1069/7": 0.0, + "2/1069/8": 0, + "2/1069/9": 0, + "2/1069/10": 1, + "2/1069/65532": 63, + "2/1069/65533": 3, + "2/1069/65528": [], + "2/1069/65529": [], + "2/1069/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1070/0": 2.0, + "2/1070/1": 0.0, + "2/1070/2": 1000.0, + "2/1070/3": 1.0, + "2/1070/4": 320, + "2/1070/5": 1.0, + "2/1070/6": 320, + "2/1070/7": 0.0, + "2/1070/8": 0, + "2/1070/9": 0, + "2/1070/10": 1, + "2/1070/65532": 63, + "2/1070/65533": 3, + "2/1070/65528": [], + "2/1070/65529": [], + "2/1070/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "2/1071/0": 2.0, + "2/1071/1": 0.0, + "2/1071/2": 1000.0, + "2/1071/3": 1.0, + "2/1071/4": 320, + "2/1071/5": 1.0, + "2/1071/6": 320, + "2/1071/7": 0.0, + "2/1071/8": 0, + "2/1071/9": 0, + "2/1071/10": 1, + "2/1071/65532": 63, + "2/1071/65533": 3, + "2/1071/65528": [], + "2/1071/65529": [], + "2/1071/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "3/3/0": 0, + "3/3/1": 0, + "3/3/65532": 0, + "3/3/65533": 4, + "3/3/65528": [], + "3/3/65529": [0, 64], + "3/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "3/29/0": [ + { + "0": 770, + "1": 2 + } + ], + "3/29/1": [3, 29, 1026], + "3/29/2": [], + "3/29/3": [], + "3/29/65532": 0, + "3/29/65533": 2, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "3/1026/0": 2000, + "3/1026/1": -500, + "3/1026/2": 6000, + "3/1026/3": 0, + "3/1026/65532": 0, + "3/1026/65533": 4, + "3/1026/65528": [], + "3/1026/65529": [], + "3/1026/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "4/3/0": 0, + "4/3/1": 0, + "4/3/65532": 0, + "4/3/65533": 4, + "4/3/65528": [], + "4/3/65529": [0, 64], + "4/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "4/29/0": [ + { + "0": 775, + "1": 2 + } + ], + "4/29/1": [3, 29, 1029], + "4/29/2": [], + "4/29/3": [], + "4/29/65532": 0, + "4/29/65533": 2, + "4/29/65528": [], + "4/29/65529": [], + "4/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "4/1029/0": 5000, + "4/1029/1": 0, + "4/1029/2": 10000, + "4/1029/3": 0, + "4/1029/65532": 0, + "4/1029/65533": 3, + "4/1029/65528": [], + "4/1029/65529": [], + "4/1029/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "5/3/0": 0, + "5/3/1": 0, + "5/3/65532": 0, + "5/3/65533": 4, + "5/3/65528": [], + "5/3/65529": [0, 64], + "5/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "5/29/0": [ + { + "0": 769, + "1": 2 + } + ], + "5/29/1": [3, 29, 513], + "5/29/2": [], + "5/29/3": [], + "5/29/65532": 0, + "5/29/65533": 2, + "5/29/65528": [], + "5/29/65529": [], + "5/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "5/513/0": 2000, + "5/513/3": 500, + "5/513/4": 3000, + "5/513/18": 2000, + "5/513/27": 2, + "5/513/28": 0, + "5/513/41": 0, + "5/513/65532": 1, + "5/513/65533": 6, + "5/513/65528": [], + "5/513/65529": [0], + "5/513/65531": [0, 3, 4, 18, 27, 28, 41, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json b/tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json new file mode 100644 index 00000000000..5b1e1cfaba6 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json @@ -0,0 +1,502 @@ +{ + "node_id": 36, + "date_commissioned": "2024-05-18T13:06:23.766788", + "last_interview": "2024-05-18T13:06:23.766793", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Matter", + "0/40/2": 4251, + "0/40/3": "Dimmable Plugin Unit", + "0/40/4": 4098, + "0/40/5": "", + "0/40/6": "", + "0/40/7": 1, + "0/40/8": "1.0", + "0/40/9": 131365, + "0/40/10": "2.1.25", + "0/40/15": "1000_0030_D228", + "0/40/18": "E2B4285EEDD3A387", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "r0", + "1": true, + "2": null, + "3": null, + "4": "AAemN9h0", + "5": ["wKhr7Q=="], + "6": ["/oAAAAAAAAACB6b//jfYdA=="], + "7": 1 + } + ], + "0/51/1": 2, + "0/51/2": 86407, + "0/51/3": 24, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "0": 26, + "1": "Logging~", + "2": 16224, + "3": 4056, + "4": 16352 + }, + { + "0": 26, + "1": "Logging", + "2": 16224, + "3": 4056, + "4": 16352 + }, + { + "0": 34, + "1": "cnR3X3JlY5c=", + "2": 5560, + "3": 862, + "4": 5856 + }, + { + "0": 36, + "1": "rtw_intz", + "2": 832, + "3": 200, + "4": 992 + }, + { + "0": 14, + "1": "interacZ", + "2": 4784, + "3": 1090, + "4": 5088 + }, + { + "0": 37, + "1": "cmd_thr", + "2": 3880, + "3": 718, + "4": 4064 + }, + { + "0": 4, + "1": "LOGUART\u0010", + "2": 3896, + "3": 974, + "4": 4064 + }, + { + "0": 3, + "1": "log_ser\n", + "2": 4968, + "3": 1242, + "4": 5088 + }, + { + "0": 35, + "1": "rtw_xmi\u0014", + "2": 840, + "3": 168, + "4": 992 + }, + { + "0": 49, + "1": "mesh_pr", + "2": 680, + "3": 42, + "4": 992 + }, + { + "0": 47, + "1": "BLE_app", + "2": 4864, + "3": 1112, + "4": 5088 + }, + { + "0": 44, + "1": "trace_t", + "2": 280, + "3": 68, + "4": 480 + }, + { + "0": 45, + "1": "UpperSt", + "2": 2904, + "3": 620, + "4": 3040 + }, + { + "0": 46, + "1": "HCI I/F", + "2": 1800, + "3": 356, + "4": 2016 + }, + { + "0": 8, + "1": "Tmr Svc", + "2": 3940, + "3": 933, + "4": 4076 + }, + { + "0": 38, + "1": "lev_snt", + "2": 3960, + "3": 930, + "4": 4064 + }, + { + "0": 27, + "1": "iot_thr", + "2": 7968, + "3": 1984, + "4": 8160 + }, + { + "0": 28, + "1": "iot_thr", + "2": 7968, + "3": 1984, + "4": 8160 + }, + { + "0": 2, + "1": "lev_hea", + "2": 3824, + "3": 831, + "4": 4064 + }, + { + "0": 23, + "1": "Wifi_Co", + "2": 7872, + "3": 1879, + "4": 8160 + }, + { + "0": 40, + "1": "lev_ota", + "2": 7896, + "3": 1442, + "4": 8160 + }, + { + "0": 39, + "1": "Schedul", + "2": 1696, + "3": 404, + "4": 2016 + }, + { + "0": 29, + "1": "AWS_MQT", + "2": 7832, + "3": 1824, + "4": 8160 + }, + { + "0": 41, + "1": "lev_net", + "2": 7768, + "3": 1788, + "4": 8160 + }, + { + "0": 18, + "1": "Lev_Tim", + "2": 3976, + "3": 948, + "4": 4064 + }, + { + "0": 1, + "1": "WATCHDO", + "2": 888, + "3": 212, + "4": 992 + }, + { + "0": 9, + "1": "TCP_IP", + "2": 3808, + "3": 644, + "4": 3968 + }, + { + "0": 50, + "1": "Bluetoo", + "2": 8000, + "3": 1990, + "4": 8160 + }, + { + "0": 20, + "1": "SHADOW_", + "2": 3736, + "3": 924, + "4": 4064 + }, + { + "0": 17, + "1": "NV_PROP", + "2": 1824, + "3": 446, + "4": 2016 + }, + { + "0": 16, + "1": "DIM_TAS", + "2": 1920, + "3": 460, + "4": 2016 + }, + { + "0": 19, + "1": "Lev_But", + "2": 3872, + "3": 956, + "4": 4064 + }, + { + "0": 7, + "1": "IDLE", + "2": 1944, + "3": 478, + "4": 2040 + }, + { + "0": 51, + "1": "CHIP", + "2": 6840, + "3": 1126, + "4": 8160 + } + ], + "0/52/1": 62880, + "0/52/2": 249440, + "0/52/3": 259456, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -66, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/2": 5, + "0/62/3": 2, + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/15": 0, + "1/8/16": 10, + "1/8/17": null, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 15, 16, 17, 20, 16384, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 267, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/leak-sensor.json b/tests/components/matter/fixtures/nodes/leak-sensor.json new file mode 100644 index 00000000000..35cfb281e11 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/leak-sensor.json @@ -0,0 +1,185 @@ +{ + "node_id": 32, + "date_commissioned": "2024-06-21T14:13:02.370603", + "last_interview": "2024-06-21T14:14:49.941142", + "interview_version": 6, + "available": true, + "is_bridge": true, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 44, 48, 49, 50, 51, 52, 56, 60, 62, 63], + "0/29/2": [31], + "0/29/3": [1, 2], + "0/29/65528": [], + "0/29/65529": [], + "0/29/65530": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/29/65532": 0, + "0/29/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65530": [], + "0/31/65531": [0, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/65532": 0, + "0/31/65533": 1, + "0/40/0": 1, + "0/40/1": "Mock", + "0/40/2": 65521, + "0/40/3": "Water Leak Detector", + "0/40/4": 32768, + "0/40/5": "Water Leak Detector", + "0/40/6": "", + "0/40/7": 0, + "0/40/8": "", + "0/40/9": 234946562, + "0/40/10": "14.1.0.2", + "0/40/15": "", + "0/40/17": true, + "0/40/18": "", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65530": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 17, 18, 19, 65528, 65529, 65530, + 65531, 65532, 65533 + ], + "0/40/65532": 0, + "0/40/65533": 2, + "0/43/0": "en", + "0/43/1": ["en"], + "0/43/65528": [], + "0/43/65529": [], + "0/43/65530": [], + "0/43/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "0/43/65532": 0, + "0/43/65533": 1, + "0/44/0": 1, + "0/44/1": 4, + "0/44/2": [], + "0/44/65528": [], + "0/44/65529": [], + "0/44/65530": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/44/65532": 0, + "0/44/65533": 1, + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 2, + "0/48/3": 2, + "0/48/4": false, + "0/48/65528": [], + "0/48/65529": [], + "0/48/65530": [], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/48/65532": 0, + "0/48/65533": 1, + "0/49/3": 30, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65530": [], + "0/49/65531": [3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/49/65532": 4, + "0/49/65533": 1, + "0/50/65528": [], + "0/50/65529": [], + "0/50/65530": [], + "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/51/1": 7, + "0/51/2": 17, + "0/51/8": false, + "0/51/65528": [], + "0/51/65529": [], + "0/51/65530": [], + "0/51/65531": [0, 1, 2, 8, 65528, 65529, 65530, 65531, 65532, 65533], + "0/51/65532": 0, + "0/51/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65530": [], + "0/52/65531": [65528, 65529, 65530, 65531, 65532, 65533], + "0/52/65532": 0, + "0/52/65533": 1, + "0/56/0": 1718979287000000, + "0/56/1": 3, + "0/56/7": 1718982887000000, + "0/56/65528": [], + "0/56/65529": [], + "0/56/65530": [], + "0/56/65531": [0, 1, 7, 65528, 65529, 65530, 65531, 65532, 65533], + "0/56/65532": 0, + "0/56/65533": 2, + "0/60/0": 0, + "0/60/65528": [], + "0/60/65529": [], + "0/60/65530": [], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/65533": 1, + "0/62/2": 5, + "0/62/3": 3, + "0/62/5": 3, + "0/62/65528": [], + "0/62/65529": [], + "0/62/65530": [], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/65532": 0, + "0/62/65533": 1, + "0/63/65528": [], + "0/63/65529": [], + "0/63/65530": [], + "0/63/65531": [65528, 65529, 65530, 65531, 65532, 65533], + "0/63/65532": 0, + "0/63/65533": 2, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [], + "1/3/65530": [], + "1/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "1/3/65532": 0, + "1/3/65533": 4, + "1/4/65528": [], + "1/4/65529": [], + "1/4/65530": [], + "1/4/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/4/65532": 0, + "1/4/65533": 4, + "1/29/0": [ + { + "0": 67, + "1": 1 + } + ], + "1/29/1": [3, 4, 5, 29, 57, 69], + "1/29/2": [], + "1/29/3": [], + "1/29/65528": [], + "1/29/65529": [], + "1/29/65530": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/29/65532": 0, + "1/29/65533": 1, + "1/69/0": true, + "1/69/65528": [], + "1/69/65529": [], + "1/69/65530": [], + "1/69/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/69/65532": 0, + "1/69/65533": 1 + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/multi-endpoint-light.json b/tests/components/matter/fixtures/nodes/multi-endpoint-light.json new file mode 100644 index 00000000000..e3a01da9e7c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/multi-endpoint-light.json @@ -0,0 +1,1637 @@ +{ + "node_id": 197, + "date_commissioned": "2024-06-21T00:23:41.026916", + "last_interview": "2024-06-21T00:23:41.026923", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63, 64], + "0/29/2": [41], + "0/29/3": [1, 2, 3, 4, 5, 6], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65530": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 18 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65530": [0, 1], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Inovelli", + "0/40/2": 4961, + "0/40/3": "VTM31-SN", + "0/40/4": 1, + "0/40/5": "Inovelli", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "0.0.0.1", + "0/40/9": 100, + "0/40/10": "1.0.0", + "0/40/11": "20231207", + "0/40/12": "850007431228", + "0/40/13": "https://inovelli.com/products/thread-matter-white-series-smart-2-1-on-off-dimmer-switch", + "0/40/14": "White Series Smart 2-1 Switch", + "0/40/15": "", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65530": [0], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65530": [0, 1, 2], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65530": [], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "guA0lmuCSNw=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "guA0lmuCSNw=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65530": [], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/51/0": [ + { + "0": "MyHome442262884", + "1": true, + "2": null, + "3": null, + "4": "pNwAIEFBCBY=", + "5": [], + "6": [], + "7": 4 + } + ], + "0/51/1": 102, + "0/51/2": 1069632, + "0/51/3": 297, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65530": [3], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome442262884", + "0/53/3": 27622, + "0/53/4": 9430595440367257820, + "0/53/5": "QP2Ea5ozpY2d", + "0/53/6": 0, + "0/53/7": [ + { + "0": 8852464968076080128, + "1": 12, + "2": 9216, + "3": 1183717, + "4": 39695, + "5": 3, + "6": -74, + "7": -74, + "8": 64, + "9": 15, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 13720920983629429643, + "1": 17, + "2": 13312, + "3": 256914, + "4": 61057, + "5": 2, + "6": -84, + "7": -84, + "8": 16, + "9": 1, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9388760890908673655, + "1": 26, + "2": 17408, + "3": 2054526, + "4": 79216, + "5": 2, + "6": -85, + "7": -86, + "8": 1, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 6844302060861963395, + "1": 48, + "2": 21504, + "3": 23719, + "4": 9471, + "5": 2, + "6": -84, + "7": -84, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 14305772551860424697, + "1": 1, + "2": 23552, + "3": 189996, + "4": 65613, + "5": 2, + "6": -85, + "7": -85, + "8": 21, + "9": 1, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17491005778920105492, + "1": 44, + "2": 28672, + "3": 310232, + "4": 144381, + "5": 3, + "6": -61, + "7": -61, + "8": 5, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 7968688256206678783, + "1": 9, + "2": 30720, + "3": 31923, + "4": 15482, + "5": 2, + "6": -88, + "7": -89, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 2195983971765588925, + "1": 3, + "2": 31744, + "3": 658867, + "4": 53332, + "5": 3, + "6": -77, + "7": -78, + "8": 51, + "9": 2, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8533237363532831991, + "1": 32, + "2": 38912, + "3": 196496, + "4": 66926, + "5": 3, + "6": -75, + "7": -75, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 15462742133285018414, + "1": 30, + "2": 51200, + "3": 156349, + "4": 91387, + "5": 1, + "6": -93, + "7": -94, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9106713788407201067, + "1": 14, + "2": 54272, + "3": 228318, + "4": 145504, + "5": 3, + "6": -65, + "7": -65, + "8": 59, + "9": 6, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 9392545512105173771, + "1": 0, + "2": 0, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + }, + { + "0": 0, + "1": 1024, + "2": 1, + "3": 28, + "4": 1, + "5": 0, + "6": 0, + "7": 13, + "8": true, + "9": false + }, + { + "0": 0, + "1": 2048, + "2": 2, + "3": 53, + "4": 1, + "5": 0, + "6": 0, + "7": 3, + "8": true, + "9": false + }, + { + "0": 8852464968076080128, + "1": 9216, + "2": 9, + "3": 28, + "4": 1, + "5": 3, + "6": 2, + "7": 12, + "8": true, + "9": true + }, + { + "0": 0, + "1": 11264, + "2": 11, + "3": 28, + "4": 1, + "5": 0, + "6": 0, + "7": 119, + "8": true, + "9": false + }, + { + "0": 13720920983629429643, + "1": 13312, + "2": 13, + "3": 28, + "4": 1, + "5": 2, + "6": 2, + "7": 17, + "8": true, + "9": true + }, + { + "0": 9388760890908673655, + "1": 17408, + "2": 17, + "3": 28, + "4": 1, + "5": 2, + "6": 0, + "7": 27, + "8": true, + "9": true + }, + { + "0": 6844302060861963395, + "1": 21504, + "2": 21, + "3": 28, + "4": 1, + "5": 2, + "6": 2, + "7": 48, + "8": true, + "9": true + }, + { + "0": 14305772551860424697, + "1": 23552, + "2": 23, + "3": 28, + "4": 1, + "5": 2, + "6": 2, + "7": 1, + "8": true, + "9": true + }, + { + "0": 0, + "1": 27648, + "2": 27, + "3": 53, + "4": 1, + "5": 0, + "6": 0, + "7": 36, + "8": true, + "9": false + }, + { + "0": 17491005778920105492, + "1": 28672, + "2": 28, + "3": 38, + "4": 1, + "5": 3, + "6": 3, + "7": 44, + "8": true, + "9": true + }, + { + "0": 14584221614789315818, + "1": 29696, + "2": 29, + "3": 28, + "4": 1, + "5": 0, + "6": 0, + "7": 15, + "8": true, + "9": false + }, + { + "0": 7968688256206678783, + "1": 30720, + "2": 30, + "3": 28, + "4": 1, + "5": 2, + "6": 1, + "7": 9, + "8": true, + "9": true + }, + { + "0": 2195983971765588925, + "1": 31744, + "2": 31, + "3": 28, + "4": 1, + "5": 3, + "6": 1, + "7": 4, + "8": true, + "9": true + }, + { + "0": 8533237363532831991, + "1": 38912, + "2": 38, + "3": 28, + "4": 1, + "5": 3, + "6": 3, + "7": 32, + "8": true, + "9": true + }, + { + "0": 0, + "1": 45056, + "2": 44, + "3": 28, + "4": 1, + "5": 0, + "6": 0, + "7": 5, + "8": true, + "9": false + }, + { + "0": 5655139244129535392, + "1": 50176, + "2": 49, + "3": 28, + "4": 1, + "5": 0, + "6": 0, + "7": 10, + "8": true, + "9": false + }, + { + "0": 15462742133285018414, + "1": 51200, + "2": 50, + "3": 38, + "4": 1, + "5": 1, + "6": 0, + "7": 30, + "8": true, + "9": true + }, + { + "0": 9106713788407201067, + "1": 54272, + "2": 53, + "3": 28, + "4": 1, + "5": 3, + "6": 3, + "7": 14, + "8": true, + "9": true + }, + { + "0": 0, + "1": 55296, + "2": 54, + "3": 28, + "4": 2, + "5": 0, + "6": 0, + "7": 99, + "8": true, + "9": false + }, + { + "0": 0, + "1": 62464, + "2": 61, + "3": 28, + "4": 1, + "5": 0, + "6": 0, + "7": 51, + "8": true, + "9": false + } + ], + "0/53/9": 544200770, + "0/53/10": 68, + "0/53/11": 57, + "0/53/12": 158, + "0/53/13": 9, + "0/53/14": 66, + "0/53/15": 49, + "0/53/16": 3, + "0/53/17": 17, + "0/53/18": 36, + "0/53/19": 38, + "0/53/20": 33, + "0/53/21": 39, + "0/53/22": 240406, + "0/53/23": 214223, + "0/53/24": 26183, + "0/53/25": 214223, + "0/53/26": 203603, + "0/53/27": 26183, + "0/53/28": 240407, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 408189, + "0/53/34": 10621, + "0/53/35": 0, + "0/53/36": 70745, + "0/53/37": 0, + "0/53/38": 1949, + "0/53/39": 1239481, + "0/53/40": 99469, + "0/53/41": 976396, + "0/53/42": 1046263, + "0/53/43": 0, + "0/53/44": 41, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 1, + "0/53/49": 21522, + "0/53/50": 0, + "0/53/51": 163615, + "0/53/52": 0, + "0/53/53": 8039, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 111822352547840, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65530": [], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65530": [], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRxRgkBwEkCAEwCUEE+6maBmkSYz6Mc9CPP3rVE6+GVAI1RSaoMuQPvtSHBroJwa2mFK7Aah+sESC00TJ2vzX7jiix1pooU7vKr7hAHDcKNQEoARgkAgE2AwQCBAEYMAQUfXAGsiTrIa0biWN7/3bBx6IQNycwBRR0PzXGsFYhV/yy0eOyHr2WB98K3hgwC0A6AV48fcu123c1UzRL9vZoUGrLYUe3fMtdk27EMXARmFoecygVw3UxOyRE1e7ovYyq1l/B+OS46cFn+Z1Op1TBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE2Zdr5BSI2FoASW7GpgNmBbxI18Gw0g/s2d/3ZLNWQJo3+HNMCxP0f+lmfQPqTta6hH2eALCXvrOemwZwB4OVkDcKNQEpARgkAmAwBBR0PzXGsFYhV/yy0eOyHr2WB98K3jAFFCTX1BG9PZC96rn83WyNVu55l7B8GDALQC2XiH6ek61BOXlOMlWF4CQZkjKupEy4prJWFWaNGg+vcJ7sR/xBtfhfThZhg1Re1atY3aapbB6V2j4xJiCq9HgY", + "254": 18 + } + ], + "0/62/1": [ + { + "1": "BDT3GwcQ+jgb6JHKilDo0cIOCVRVzt/Qp1MGXzpJumBOSFenMDvr940AGy6NI4WfqROrVh9KmrroTnXEqOIhA6Y=", + "2": 4939, + "3": 2, + "4": 197, + "5": "", + "254": 18 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AyYU2nLdAyYVar5MzhgmBLnNRi0kBQA3BiYU2nLdAyYVar5MzhgkBwEkCAEwCUEENrEEk8M5ztCYkE5UAh3jIAN89pc0KFJ/gbwBIWeN3Ws5aFKjFWCndluUHWDEWPtSMxWTrno8vATU3x8j+yycijcKNQEpARgkAmAwBBQErHkbm0I53zyvS+R5vrTzJR1doTAFFASseRubQjnfPK9L5Hm+tPMlHV2hGDALQLv4FZpuAoq/m0iIdjOY2OTPnm3JjQIWd4QLBf4ncy6uPlPhdDlvanQvCxSl7xaF/XW8j+EsWacZDK15mD4jzuQY", + "FTABAQAkAgE3AycUxxt9sfxycj8mFZkiBagYJgQNz0YtJAUANwYnFMcbfbH8cnI/JhWZIgWoGCQHASQIATAJQQTGlfTQVqZk2GnxHCh364hEd0J4+rUEblxiWQDmYIienGmHY50RviHxI+875LHFTo9rcntChj+TPxP00yUIw3yoNwo1ASkBGCQCYDAEFK5Ln3+cjAgPxBcWXXzMO1MEyW6oMAUUrkuff5yMCA/EFxZdfMw7UwTJbqgYMAtAleRSrdtPawWmPJ2A0t6EFlYTVKtqseAiuHxSwE+U4sEeL+QCO9OCT6f1bsTzD5KDjqTBlWPSjeUDfd5u61o30Bg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEENPcbBxD6OBvokcqKUOjRwg4JVFXO39CnUwZfOkm6YE5IV6cwO+v3jQAbLo0jhZ+pE6tWH0qauuhOdcSo4iEDpjcKNQEpARgkAmAwBBQk19QRvT2Qveq5/N1sjVbueZewfDAFFCTX1BG9PZC96rn83WyNVu55l7B8GDALQEUvBGKd7aRh6/0l82kua682xBcREAV7Xn4PFsZ7tEs7H4PYHnCZTzgSC7mqY2u0y2AhTztdJ7tCeffml9HQQGwY" + ], + "0/62/5": 18, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65530": [], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65530": [], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "Vendor", + "1": "Inovelli" + }, + { + "0": "Product", + "1": "VTM31-SN" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65530": [], + "0/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65530": [], + "1/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65530": [], + "1/4/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/5/0": null, + "1/5/1": 0, + "1/5/2": 0, + "1/5/3": false, + "1/5/4": 128, + "1/5/65532": 1, + "1/5/65533": 4, + "1/5/65528": [0, 1, 2, 3, 4, 6], + "1/5/65529": [0, 1, 2, 3, 4, 5, 6], + "1/5/65530": [], + "1/5/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65530": [], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/8/0": 1, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/15": 0, + "1/8/16": 5, + "1/8/17": 137, + "1/8/18": 15, + "1/8/19": 5, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65530": [], + "1/8/65531": [ + 0, 1, 2, 3, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65530, 65531, + 65532, 65533 + ], + "1/29/0": [ + { + "0": 257, + "1": 1 + } + ], + "1/29/1": [3, 4, 5, 6, 8, 29, 64, 80, 305134641], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65530": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/64/0": [ + { + "0": "DeviceType", + "1": "DimmableLight" + } + ], + "1/64/65532": 0, + "1/64/65533": 1, + "1/64/65528": [], + "1/64/65529": [], + "1/64/65530": [], + "1/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "1/80/0": "Switch Mode", + "1/80/1": 0, + "1/80/2": [ + { + "0": "OnOff+Single", + "1": 0, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "OnOff+Dumb", + "1": 1, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "OnOff+AUX", + "1": 2, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "OnOff+Full Wave", + "1": 3, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Dimmer+Single", + "1": 4, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Dimmer+Dumb", + "1": 5, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Dimmer+Aux", + "1": 6, + "2": [ + { + "0": 0, + "1": 0 + } + ] + } + ], + "1/80/3": 4, + "1/80/65532": 0, + "1/80/65533": 1, + "1/80/65528": [], + "1/80/65529": [0], + "1/80/65530": [], + "1/80/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/305134641/305070080": 1, + "1/305134641/305070081": 20, + "1/305134641/305070082": 127, + "1/305134641/305070083": 127, + "1/305134641/305070084": 127, + "1/305134641/305070085": 127, + "1/305134641/305070086": 127, + "1/305134641/305070087": 127, + "1/305134641/305070088": 127, + "1/305134641/305070089": 1, + "1/305134641/305070090": 255, + "1/305134641/305070091": false, + "1/305134641/305070092": 0, + "1/305134641/305070093": 255, + "1/305134641/305070094": 255, + "1/305134641/305070095": 255, + "1/305134641/305070097": 11, + "1/305134641/305070101": true, + "1/305134641/305070102": 0, + "1/305134641/305070106": 0, + "1/305134641/305070112": 30, + "1/305134641/305070113": false, + "1/305134641/305070130": 5, + "1/305134641/305070132": false, + "1/305134641/305070133": false, + "1/305134641/305070134": false, + "1/305134641/305070135": 254, + "1/305134641/305070136": 2, + "1/305134641/305070175": 35, + "1/305134641/305070176": 35, + "1/305134641/305070177": 33, + "1/305134641/305070178": 1, + "1/305134641/305070336": false, + "1/305134641/305070338": false, + "1/305134641/305070339": false, + "1/305134641/305070340": true, + "1/305134641/305070341": true, + "1/305134641/305070342": false, + "1/305134641/65532": 0, + "1/305134641/65533": 1, + "1/305134641/65528": [], + "1/305134641/65529": [305070081, 305070083, 305070276], + "1/305134641/65530": [], + "1/305134641/65531": [ + 65528, 65529, 65530, 65531, 305070080, 305070081, 305070082, 305070083, + 305070084, 305070085, 305070086, 305070087, 305070088, 305070089, + 305070090, 305070091, 305070092, 305070093, 305070094, 305070095, + 305070097, 305070101, 305070102, 305070106, 305070112, 305070113, + 305070130, 305070132, 305070133, 305070134, 305070135, 305070136, + 305070175, 305070176, 305070177, 305070178, 305070336, 305070338, + 305070339, 305070340, 305070341, 305070342, 65532, 65533 + ], + "2/3/0": 0, + "2/3/1": 2, + "2/3/65532": 0, + "2/3/65533": 4, + "2/3/65528": [], + "2/3/65529": [0, 64], + "2/3/65530": [], + "2/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 260, + "1": 1 + } + ], + "2/29/1": [3, 29, 30, 64, 80], + "2/29/2": [3, 6, 8], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 1, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65530": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "2/30/0": [], + "2/30/65532": 0, + "2/30/65533": 1, + "2/30/65528": [], + "2/30/65529": [], + "2/30/65530": [], + "2/30/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "2/64/0": [ + { + "0": "DeviceType", + "1": "DimmableSwitch" + } + ], + "2/64/65532": 0, + "2/64/65533": 1, + "2/64/65528": [], + "2/64/65529": [], + "2/64/65530": [], + "2/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "2/80/0": "Smart Bulb Mode", + "2/80/1": 0, + "2/80/2": [ + { + "0": "Smart Bulb Disable", + "1": 0, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Smart Bulb Enable", + "1": 1, + "2": [ + { + "0": 0, + "1": 0 + } + ] + } + ], + "2/80/3": 0, + "2/80/65532": 0, + "2/80/65533": 1, + "2/80/65528": [], + "2/80/65529": [0], + "2/80/65530": [], + "2/80/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "3/3/0": 0, + "3/3/1": 2, + "3/3/65532": 0, + "3/3/65533": 4, + "3/3/65528": [], + "3/3/65529": [0, 64], + "3/3/65530": [], + "3/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "3/29/0": [ + { + "0": 15, + "1": 1 + } + ], + "3/29/1": [3, 29, 59, 64, 80], + "3/29/2": [], + "3/29/3": [], + "3/29/65532": 0, + "3/29/65533": 1, + "3/29/65528": [], + "3/29/65529": [], + "3/29/65530": [], + "3/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "3/59/0": 2, + "3/59/1": 0, + "3/59/2": 5, + "3/59/65532": 30, + "3/59/65533": 1, + "3/59/65528": [], + "3/59/65529": [], + "3/59/65530": [1, 2, 3, 4, 5, 6], + "3/59/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "3/64/0": [ + { + "0": "Button", + "1": "Up" + } + ], + "3/64/65532": 0, + "3/64/65533": 1, + "3/64/65528": [], + "3/64/65529": [], + "3/64/65530": [], + "3/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "3/80/0": "Dimming Edge", + "3/80/1": 0, + "3/80/2": [ + { + "0": "Leading", + "1": 0, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Trailing", + "1": 1, + "2": [ + { + "0": 0, + "1": 0 + } + ] + } + ], + "3/80/3": 0, + "3/80/65532": 0, + "3/80/65533": 1, + "3/80/65528": [], + "3/80/65529": [0], + "3/80/65530": [], + "3/80/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "4/3/0": 0, + "4/3/1": 2, + "4/3/65532": 0, + "4/3/65533": 4, + "4/3/65528": [], + "4/3/65529": [0, 64], + "4/3/65530": [], + "4/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "4/29/0": [ + { + "0": 15, + "1": 1 + } + ], + "4/29/1": [3, 29, 59, 64, 80], + "4/29/2": [], + "4/29/3": [], + "4/29/65532": 0, + "4/29/65533": 1, + "4/29/65528": [], + "4/29/65529": [], + "4/29/65530": [], + "4/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "4/59/0": 2, + "4/59/1": 0, + "4/59/2": 5, + "4/59/65532": 30, + "4/59/65533": 1, + "4/59/65528": [], + "4/59/65529": [], + "4/59/65530": [1, 2, 3, 4, 5, 6], + "4/59/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "4/64/0": [ + { + "0": "Button", + "1": "Down" + } + ], + "4/64/65532": 0, + "4/64/65533": 1, + "4/64/65528": [], + "4/64/65529": [], + "4/64/65530": [], + "4/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "4/80/0": "Dimming Speed", + "4/80/1": 0, + "4/80/2": [ + { + "0": "Instant", + "1": 0, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "500ms", + "1": 5, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "800ms", + "1": 8, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "1s", + "1": 10, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "1.5s", + "1": 15, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "2s", + "1": 20, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "2.5s", + "1": 25, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "3s", + "1": 30, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "3.5s", + "1": 35, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "4s", + "1": 40, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "5s", + "1": 50, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "6s", + "1": 60, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "7s", + "1": 70, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "8s", + "1": 80, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "10s", + "1": 100, + "2": [ + { + "0": 0, + "1": 0 + } + ] + } + ], + "4/80/3": 20, + "4/80/65532": 0, + "4/80/65533": 1, + "4/80/65528": [], + "4/80/65529": [0], + "4/80/65530": [], + "4/80/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "5/3/0": 0, + "5/3/1": 2, + "5/3/65532": 0, + "5/3/65533": 4, + "5/3/65528": [], + "5/3/65529": [0, 64], + "5/3/65530": [], + "5/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "5/29/0": [ + { + "0": 15, + "1": 1 + } + ], + "5/29/1": [3, 29, 59, 64, 80], + "5/29/2": [], + "5/29/3": [], + "5/29/65532": 0, + "5/29/65533": 1, + "5/29/65528": [], + "5/29/65529": [], + "5/29/65530": [], + "5/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "5/59/0": 2, + "5/59/1": 0, + "5/59/2": 5, + "5/59/65532": 30, + "5/59/65533": 1, + "5/59/65528": [], + "5/59/65529": [], + "5/59/65530": [1, 2, 3, 4, 5, 6], + "5/59/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "5/64/0": [ + { + "0": "Button", + "1": "Config" + } + ], + "5/64/65532": 0, + "5/64/65533": 1, + "5/64/65528": [], + "5/64/65529": [], + "5/64/65530": [], + "5/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "5/80/0": "Relay", + "5/80/1": 0, + "5/80/2": [ + { + "0": "Relay Click Enable", + "1": 0, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Relay Click Disable", + "1": 1, + "2": [ + { + "0": 0, + "1": 0 + } + ] + } + ], + "5/80/3": 1, + "5/80/65532": 0, + "5/80/65533": 1, + "5/80/65528": [], + "5/80/65529": [0], + "5/80/65530": [], + "5/80/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "6/3/0": 0, + "6/3/1": 2, + "6/3/65532": 0, + "6/3/65533": 4, + "6/3/65528": [], + "6/3/65529": [0, 64], + "6/3/65530": [], + "6/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "6/4/0": 128, + "6/4/65532": 1, + "6/4/65533": 4, + "6/4/65528": [0, 1, 2, 3], + "6/4/65529": [0, 1, 2, 3, 4, 5], + "6/4/65530": [], + "6/4/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "6/5/0": null, + "6/5/1": 0, + "6/5/2": 0, + "6/5/3": false, + "6/5/4": 128, + "6/5/65532": 0, + "6/5/65533": 4, + "6/5/65528": [0, 1, 2, 3, 4, 6], + "6/5/65529": [0, 1, 2, 3, 4, 5, 6], + "6/5/65530": [], + "6/5/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "6/6/0": false, + "6/6/16384": true, + "6/6/16385": 0, + "6/6/16386": 0, + "6/6/16387": 0, + "6/6/65532": 1, + "6/6/65533": 4, + "6/6/65528": [], + "6/6/65529": [0, 1, 2, 64, 65, 66], + "6/6/65530": [], + "6/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "6/8/0": 224, + "6/8/1": 0, + "6/8/2": 1, + "6/8/3": 254, + "6/8/15": 0, + "6/8/17": 254, + "6/8/16384": 128, + "6/8/65532": 0, + "6/8/65533": 5, + "6/8/65528": [], + "6/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "6/8/65530": [], + "6/8/65531": [ + 0, 1, 2, 3, 15, 17, 16384, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "6/29/0": [ + { + "0": 269, + "1": 1 + } + ], + "6/29/1": [3, 4, 5, 6, 8, 29, 64, 80, 768], + "6/29/2": [], + "6/29/3": [], + "6/29/65532": 0, + "6/29/65533": 1, + "6/29/65528": [], + "6/29/65529": [], + "6/29/65530": [], + "6/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "6/64/0": [ + { + "0": "DeviceType", + "1": "DimmableLight" + }, + { + "0": "Light", + "1": "LED Bar" + } + ], + "6/64/65532": 0, + "6/64/65533": 1, + "6/64/65528": [], + "6/64/65529": [], + "6/64/65530": [], + "6/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "6/80/0": "LED Color", + "6/80/1": 0, + "6/80/2": [ + { + "0": "Red", + "1": 0, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Orange", + "1": 1, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Lemon", + "1": 2, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Lime", + "1": 3, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Green", + "1": 4, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Teal", + "1": 5, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Cyan", + "1": 6, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Aqua", + "1": 7, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Blue", + "1": 8, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Violet", + "1": 9, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Magenta", + "1": 10, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "Pink", + "1": 11, + "2": [ + { + "0": 0, + "1": 0 + } + ] + }, + { + "0": "White", + "1": 12, + "2": [ + { + "0": 0, + "1": 0 + } + ] + } + ], + "6/80/3": 2, + "6/80/65532": 0, + "6/80/65533": 1, + "6/80/65528": [], + "6/80/65529": [0], + "6/80/65530": [], + "6/80/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "6/768/0": 6, + "6/768/1": 170, + "6/768/2": 0, + "6/768/3": 24939, + "6/768/4": 24701, + "6/768/7": 500, + "6/768/8": 0, + "6/768/15": 0, + "6/768/16": 0, + "6/768/16385": 0, + "6/768/16394": 25, + "6/768/16395": 0, + "6/768/16396": 65279, + "6/768/16397": 0, + "6/768/16400": 0, + "6/768/65532": 25, + "6/768/65533": 5, + "6/768/65528": [], + "6/768/65529": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 71, 75, 76], + "6/768/65530": [], + "6/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16385, 16394, 16395, 16396, 16397, 16400, + 65528, 65529, 65530, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index 8d523f5443a..3b4831a7485 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -24,7 +24,7 @@ "0/40/0": 1, "0/40/1": "Nabu Casa", "0/40/2": 65521, - "0/40/3": "Mock OnOffPluginUnit (powerplug/switch)", + "0/40/3": "Mock OnOffPluginUnit", "0/40/4": 32768, "0/40/5": "", "0/40/6": "XX", diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json index 3f6e83ca460..46575640adf 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -354,11 +354,11 @@ ], "1/29/0": [ { - "type": 257, - "revision": 1 + "0": 256, + "1": 1 } ], - "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/1": [3, 4, 6, 29], "1/29/2": [], "1/29/3": [], "1/29/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json index 18cb68c8926..a6c73564af0 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -354,11 +354,11 @@ ], "1/29/0": [ { - "type": 257, - "revision": 1 + "0": 256, + "1": 1 } ], - "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/1": [3, 4, 6, 29], "1/29/2": [], "1/29/3": [], "1/29/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/room-airconditioner.json b/tests/components/matter/fixtures/nodes/room-airconditioner.json index 11c29b0d8f4..770e217e68c 100644 --- a/tests/components/matter/fixtures/nodes/room-airconditioner.json +++ b/tests/components/matter/fixtures/nodes/room-airconditioner.json @@ -43,9 +43,9 @@ "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], "0/40/0": 17, "0/40/1": "TEST_VENDOR", - "0/40/2": 65521, + "0/40/2": 4617, "0/40/3": "Room AirConditioner", - "0/40/4": 32774, + "0/40/4": 32775, "0/40/5": "", "0/40/6": "**REDACTED**", "0/40/7": 0, diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 5f6c48dfcc6..da2ef179c44 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -29,6 +29,7 @@ from .common import load_and_parse_node_fixture, setup_integration_with_node_fix ) async def test_device_registry_single_node_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, node_fixture: str, name: str, @@ -40,8 +41,7 @@ async def test_device_registry_single_node_device( matter_client, ) - dev_reg = dr.async_get(hass) - entry = dev_reg.async_get_device( + entry = device_registry.async_get_device( identifiers={ (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") } @@ -63,6 +63,7 @@ async def test_device_registry_single_node_device( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_single_node_device_alt( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test additional device with different attribute values.""" @@ -72,8 +73,7 @@ async def test_device_registry_single_node_device_alt( matter_client, ) - dev_reg = dr.async_get(hass) - entry = dev_reg.async_get_device( + entry = device_registry.async_get_device( identifiers={ (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") } @@ -81,7 +81,7 @@ async def test_device_registry_single_node_device_alt( assert entry is not None # test name is derived from productName (because nodeLabel is absent) - assert entry.name == "Mock OnOffPluginUnit (powerplug/switch)" + assert entry.name == "Mock OnOffPluginUnit" # test serial id NOT present as additional identifier assert (DOMAIN, "serial_TEST_SN") not in entry.identifiers @@ -91,6 +91,7 @@ async def test_device_registry_single_node_device_alt( @pytest.mark.skip("Waiting for a new test fixture") async def test_device_registry_bridge( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test bridge devices are set up correctly with via_device.""" @@ -100,10 +101,10 @@ async def test_device_registry_bridge( matter_client, ) - dev_reg = dr.async_get(hass) - # Validate bridge - bridge_entry = dev_reg.async_get_device(identifiers={(DOMAIN, "mock-hub-id")}) + bridge_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "mock-hub-id")} + ) assert bridge_entry is not None assert bridge_entry.name == "My Mock Bridge" @@ -113,7 +114,7 @@ async def test_device_registry_bridge( assert bridge_entry.sw_version == "123.4.5" # Device 1 - device1_entry = dev_reg.async_get_device( + device1_entry = device_registry.async_get_device( identifiers={(DOMAIN, "mock-id-kitchen-ceiling")} ) assert device1_entry is not None @@ -126,7 +127,7 @@ async def test_device_registry_bridge( assert device1_entry.sw_version == "67.8.9" # Device 2 - device2_entry = dev_reg.async_get_device( + device2_entry = device_registry.async_get_device( identifiers={(DOMAIN, "mock-id-living-room-ceiling")} ) assert device2_entry is not None @@ -162,16 +163,48 @@ async def test_node_added_subscription( ) ) - entity_state = hass.states.get("light.mock_onoff_light") + entity_state = hass.states.get("light.mock_onoff_light_light") assert not entity_state node_added_callback(EventType.NODE_ADDED, node) await hass.async_block_till_done() - entity_state = hass.states.get("light.mock_onoff_light") + entity_state = hass.states.get("light.mock_onoff_light_light") assert entity_state +async def test_device_registry_single_node_composed_device( + hass: HomeAssistant, + matter_client: MagicMock, +) -> None: + """Test that a composed device within a standalone node only creates one HA device entry.""" + await setup_integration_with_node_fixture( + hass, + "air-purifier", + matter_client, + ) + dev_reg = dr.async_get(hass) + assert len(dev_reg.devices) == 1 + + +async def test_multi_endpoint_name( + hass: HomeAssistant, + matter_client: MagicMock, +) -> None: + """Test that the entity name gets postfixed if the device has multiple primary endpoints.""" + await setup_integration_with_node_fixture( + hass, + "multi-endpoint-light", + matter_client, + ) + entity_state = hass.states.get("light.inovelli_light_1") + assert entity_state + assert entity_state.name == "Inovelli Light (1)" + entity_state = hass.states.get("light.inovelli_light_6") + assert entity_state + assert entity_state.name == "Inovelli Light (6)" + + async def test_get_clean_name_() -> None: """Test get_clean_name helper. diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index b47c014f6b2..853da113e21 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -202,6 +202,7 @@ async def test_set_wifi_credentials( async def test_node_diagnostics( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test the node diagnostics command.""" @@ -212,8 +213,7 @@ async def test_node_diagnostics( matter_client, ) # get the device registry entry for the mocked node - dev_reg = dr.async_get(hass) - entry = dev_reg.async_get_device( + entry = device_registry.async_get_device( identifiers={ (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") } @@ -254,7 +254,7 @@ async def test_node_diagnostics( assert msg["result"] == diag_res # repeat test with a device id that does not have a node attached - new_entry = dev_reg.async_get_or_create( + new_entry = device_registry.async_get_or_create( config_entry_id=list(entry.config_entries)[0], identifiers={(DOMAIN, "MatterNodeDevice")}, ) @@ -276,6 +276,7 @@ async def test_node_diagnostics( async def test_ping_node( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test the ping_node command.""" @@ -286,8 +287,7 @@ async def test_ping_node( matter_client, ) # get the device registry entry for the mocked node - dev_reg = dr.async_get(hass) - entry = dev_reg.async_get_device( + entry = device_registry.async_get_device( identifiers={ (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") } @@ -314,7 +314,7 @@ async def test_ping_node( assert msg["result"] == ping_result # repeat test with a device id that does not have a node attached - new_entry = dev_reg.async_get_or_create( + new_entry = device_registry.async_get_or_create( config_entry_id=list(entry.config_entries)[0], identifiers={(DOMAIN, "MatterNodeDevice")}, ) @@ -336,6 +336,7 @@ async def test_ping_node( async def test_open_commissioning_window( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test the open_commissioning_window command.""" @@ -346,8 +347,7 @@ async def test_open_commissioning_window( matter_client, ) # get the device registry entry for the mocked node - dev_reg = dr.async_get(hass) - entry = dev_reg.async_get_device( + entry = device_registry.async_get_device( identifiers={ (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") } @@ -380,7 +380,7 @@ async def test_open_commissioning_window( assert msg["result"] == dataclass_to_dict(commissioning_parameters) # repeat test with a device id that does not have a node attached - new_entry = dev_reg.async_get_or_create( + new_entry = device_registry.async_get_or_create( config_entry_id=list(entry.config_entries)[0], identifiers={(DOMAIN, "MatterNodeDevice")}, ) @@ -402,6 +402,7 @@ async def test_open_commissioning_window( async def test_remove_matter_fabric( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test the remove_matter_fabric command.""" @@ -412,8 +413,7 @@ async def test_remove_matter_fabric( matter_client, ) # get the device registry entry for the mocked node - dev_reg = dr.async_get(hass) - entry = dev_reg.async_get_device( + entry = device_registry.async_get_device( identifiers={ (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") } @@ -435,7 +435,7 @@ async def test_remove_matter_fabric( matter_client.remove_matter_fabric.assert_called_once_with(1, 3) # repeat test with a device id that does not have a node attached - new_entry = dev_reg.async_get_or_create( + new_entry = device_registry.async_get_or_create( config_entry_id=list(entry.config_entries)[0], identifiers={(DOMAIN, "MatterNodeDevice")}, ) @@ -458,6 +458,7 @@ async def test_remove_matter_fabric( async def test_interview_node( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, matter_client: MagicMock, ) -> None: """Test the interview_node command.""" @@ -468,8 +469,7 @@ async def test_interview_node( matter_client, ) # get the device registry entry for the mocked node - dev_reg = dr.async_get(hass) - entry = dev_reg.async_get_device( + entry = device_registry.async_get_device( identifiers={ (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") } @@ -485,7 +485,7 @@ async def test_interview_node( matter_client.interview_node.assert_called_once_with(1) # repeat test with a device id that does not have a node attached - new_entry = dev_reg.async_get_or_create( + new_entry = device_registry.async_get_or_create( config_entry_id=list(entry.config_entries)[0], identifiers={(DOMAIN, "MatterNodeDevice")}, ) diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 97a22d6dc98..becedc0af62 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -1,10 +1,10 @@ """Test Matter binary sensors.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest +from typing_extensions import Generator from homeassistant.components.matter.binary_sensor import ( DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS, @@ -21,7 +21,7 @@ from .common import ( @pytest.fixture(autouse=True) -def binary_sensor_platform() -> Generator[None, None, None]: +def binary_sensor_platform() -> Generator[None]: """Load only the binary sensor platform.""" with patch( "homeassistant.components.matter.discovery.DISCOVERY_SCHEMAS", @@ -32,29 +32,6 @@ def binary_sensor_platform() -> Generator[None, None, None]: yield -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_contact_sensor( - hass: HomeAssistant, - matter_client: MagicMock, - eve_contact_sensor_node: MatterNode, -) -> None: - """Test contact sensor.""" - entity_id = "binary_sensor.eve_door_door" - state = hass.states.get(entity_id) - assert state - assert state.state == "on" - - set_node_attribute(eve_contact_sensor_node, 1, 69, 0, True) - await trigger_subscription_callback( - hass, matter_client, data=(eve_contact_sensor_node.node_id, "1/69/0", True) - ) - - state = hass.states.get(entity_id) - assert state - assert state.state == "off" - - @pytest.fixture(name="occupancy_sensor_node") async def occupancy_sensor_node_fixture( hass: HomeAssistant, matter_client: MagicMock @@ -87,6 +64,43 @@ async def test_occupancy_sensor( assert state.state == "off" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("eve-contact-sensor", "binary_sensor.eve_door_door"), + ("leak-sensor", "binary_sensor.water_leak_detector_water_leak"), + ], +) +async def test_boolean_state_sensors( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test if binary sensors get created from devices with Boolean State cluster.""" + node = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + + # invert the value + cur_attr_value = node.get_attribute_value(1, 69, 0) + set_node_attribute(node, 1, 69, 0, not cur_attr_value) + await trigger_subscription_callback( + hass, matter_client, data=(node.node_id, "1/69/0", not cur_attr_value) + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_battery_sensor( diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index de4626ef3d1..6a4cf34a640 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -7,7 +7,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 HVACAction, HVACMode +from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode from homeassistant.core import HomeAssistant from .common import ( @@ -37,73 +37,36 @@ async def room_airconditioner( # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_thermostat( +async def test_thermostat_base( hass: HomeAssistant, matter_client: MagicMock, thermostat: MatterNode, ) -> None: - """Test thermostat.""" - # test default temp range - state = hass.states.get("climate.longan_link_hvac") + """Test thermostat base attributes and state updates.""" + # test entity attributes + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["min_temp"] == 7 assert state.attributes["max_temp"] == 35 - - # test set temperature when target temp is None assert state.attributes["temperature"] is None assert state.state == HVACMode.COOL - with pytest.raises( - ValueError, match="Current target_temperature should not be None" - ): - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "temperature": 22.5, - }, - blocking=True, - ) - with pytest.raises(ValueError, match="Temperature must be provided"): - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 26, - }, - blocking=True, - ) - # change system mode to heat_cool - set_node_attribute(thermostat, 1, 513, 28, 1) - await trigger_subscription_callback(hass, matter_client) - with pytest.raises( - ValueError, - match="current target_temperature_low and target_temperature_high should not be None", - ): - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.state == HVACMode.HEAT_COOL - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 26, - }, - blocking=True, - ) + # test supported features correctly parsed + # including temperature_range support + mask = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + assert state.attributes["supported_features"] & mask == mask - # initial state + # test common state updates from device set_node_attribute(thermostat, 1, 513, 3, 1600) set_node_attribute(thermostat, 1, 513, 4, 3000) set_node_attribute(thermostat, 1, 513, 5, 1600) set_node_attribute(thermostat, 1, 513, 6, 3000) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["min_temp"] == 16 assert state.attributes["max_temp"] == 30 @@ -117,68 +80,56 @@ async def test_thermostat( # test system mode update from device set_node_attribute(thermostat, 1, 513, 28, 0) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state 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 == 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 == HVACMode.DRY - # test running state update from device set_node_attribute(thermostat, 1, 513, 41, 1) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.HEATING set_node_attribute(thermostat, 1, 513, 41, 8) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.HEATING set_node_attribute(thermostat, 1, 513, 41, 2) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.COOLING set_node_attribute(thermostat, 1, 513, 41, 16) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.COOLING set_node_attribute(thermostat, 1, 513, 41, 4) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.FAN set_node_attribute(thermostat, 1, 513, 41, 32) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.FAN set_node_attribute(thermostat, 1, 513, 41, 64) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.FAN set_node_attribute(thermostat, 1, 513, 41, 66) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["hvac_action"] == HVACAction.OFF @@ -186,7 +137,7 @@ async def test_thermostat( set_node_attribute(thermostat, 1, 513, 28, 4) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.state == HVACMode.HEAT @@ -194,152 +145,119 @@ async def test_thermostat( set_node_attribute(thermostat, 1, 513, 18, 2000) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.attributes["temperature"] == 20 + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_thermostat_service_calls( + hass: HomeAssistant, + matter_client: MagicMock, + thermostat: MatterNode, +) -> None: + """Test climate platform service calls.""" + # test single-setpoint temperature adjustment when cool mode is active + state = hass.states.get("climate.longan_link_hvac_thermostat") + assert state + assert state.state == HVACMode.COOL await hass.services.async_call( "climate", "set_temperature", { - "entity_id": "climate.longan_link_hvac", + "entity_id": "climate.longan_link_hvac_thermostat", "temperature": 25, }, blocking=True, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, - 50, - ), + attribute_path="1/513/17", + value=2500, ) - matter_client.send_device_command.reset_mock() + matter_client.write_attribute.reset_mock() - # change system mode to cool - set_node_attribute(thermostat, 1, 513, 28, 3) + # ensure that no command is executed when the temperature is the same + set_node_attribute(thermostat, 1, 513, 17, 2500) await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "climate", + "set_temperature", + { + "entity_id": "climate.longan_link_hvac_thermostat", + "temperature": 25, + }, + blocking=True, + ) - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.state == HVACMode.COOL + assert matter_client.write_attribute.call_count == 0 + matter_client.write_attribute.reset_mock() - # change occupied cooling setpoint to 18 - set_node_attribute(thermostat, 1, 513, 17, 1800) + # test single-setpoint temperature adjustment when heat mode is active + set_node_attribute(thermostat, 1, 513, 28, 4) await trigger_subscription_callback(hass, matter_client) - - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state - assert state.attributes["temperature"] == 18 + assert state.state == HVACMode.HEAT await hass.services.async_call( "climate", "set_temperature", { - "entity_id": "climate.longan_link_hvac", - "temperature": 16, + "entity_id": "climate.longan_link_hvac_thermostat", + "temperature": 20, }, blocking=True, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, -20 - ), + attribute_path="1/513/18", + value=2000, ) - matter_client.send_device_command.reset_mock() + matter_client.write_attribute.reset_mock() - # change system mode to heat_cool + # test dual setpoint temperature adjustments when heat_cool mode is active set_node_attribute(thermostat, 1, 513, 28, 1) await trigger_subscription_callback(hass, matter_client) - with pytest.raises( - ValueError, match="temperature_low and temperature_high must be provided" - ): - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "temperature": 18, - }, - blocking=True, - ) - - state = hass.states.get("climate.longan_link_hvac") + state = hass.states.get("climate.longan_link_hvac_thermostat") assert state assert state.state == HVACMode.HEAT_COOL - # change occupied cooling setpoint to 18 - set_node_attribute(thermostat, 1, 513, 17, 2500) - await trigger_subscription_callback(hass, matter_client) - # change occupied heating setpoint to 18 - set_node_attribute(thermostat, 1, 513, 18, 1700) - await trigger_subscription_callback(hass, matter_client) - - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.attributes["target_temp_low"] == 17 - assert state.attributes["target_temp_high"] == 25 - - # change target_temp_low to 18 await hass.services.async_call( "climate", "set_temperature", { - "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 25, + "entity_id": "climate.longan_link_hvac_thermostat", + "target_temp_low": 10, + "target_temp_high": 30, }, blocking=True, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, 10 - ), + attribute_path="1/513/18", + value=1000, ) - matter_client.send_device_command.reset_mock() - set_node_attribute(thermostat, 1, 513, 18, 1800) - await trigger_subscription_callback(hass, matter_client) - - # change target_temp_high to 26 - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 26, - }, - blocking=True, - ) - - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_args_list[1] == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, 10 - ), + attribute_path="1/513/17", + value=3000, ) - matter_client.send_device_command.reset_mock() - set_node_attribute(thermostat, 1, 513, 17, 2600) - await trigger_subscription_callback(hass, matter_client) + matter_client.write_attribute.reset_mock() + # test change HAVC mode to heat await hass.services.async_call( "climate", "set_hvac_mode", { - "entity_id": "climate.longan_link_hvac", + "entity_id": "climate.longan_link_hvac_thermostat", "hvac_mode": HVACMode.HEAT, }, blocking=True, @@ -356,17 +274,6 @@ async def test_thermostat( ) matter_client.send_device_command.reset_mock() - with pytest.raises(ValueError, match="Unsupported hvac mode dry in Matter"): - await hass.services.async_call( - "climate", - "set_hvac_mode", - { - "entity_id": "climate.longan_link_hvac", - "hvac_mode": HVACMode.DRY, - }, - blocking=True, - ) - # change target_temp and hvac_mode in the same call matter_client.send_device_command.reset_mock() matter_client.write_attribute.reset_mock() @@ -374,14 +281,14 @@ async def test_thermostat( "climate", "set_temperature", { - "entity_id": "climate.longan_link_hvac", + "entity_id": "climate.longan_link_hvac_thermostat", "temperature": 22, "hvac_mode": HVACMode.COOL, }, blocking=True, ) - assert matter_client.write_attribute.call_count == 1 - assert matter_client.write_attribute.call_args == call( + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( node_id=thermostat.node_id, attribute_path=create_attribute_path_from_attribute( endpoint_id=1, @@ -389,14 +296,12 @@ async def test_thermostat( ), value=3, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_args_list[1] == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, -40 - ), + attribute_path="1/513/17", + value=2200, ) + matter_client.write_attribute.reset_mock() # This tests needs to be adjusted to remove lingering tasks @@ -407,8 +312,36 @@ async def test_room_airconditioner( room_airconditioner: MatterNode, ) -> None: """Test if a climate entity is created for a Room Airconditioner device.""" - state = hass.states.get("climate.room_airconditioner") + state = hass.states.get("climate.room_airconditioner_thermostat") assert state assert state.attributes["current_temperature"] == 20 assert state.attributes["min_temp"] == 16 assert state.attributes["max_temp"] == 32 + + # test supported features correctly parsed + # WITHOUT temperature_range support + mask = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF + assert state.attributes["supported_features"] & mask == mask + + # test supported HVAC modes include fan and dry modes + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT_COOL, + ] + # test fan-only hvac mode + set_node_attribute(room_airconditioner, 1, 513, 28, 7) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.room_airconditioner_thermostat") + assert state + assert state.state == HVACMode.FAN_ONLY + + # test dry hvac mode + set_node_attribute(room_airconditioner, 1, 513, 28, 8) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.room_airconditioner_thermostat") + assert state + assert state.state == HVACMode.DRY diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index 39ae40172c1..562cf4bb86a 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -2,13 +2,13 @@ from __future__ import annotations -from collections.abc import Generator from ipaddress import ip_address from typing import Any from unittest.mock import DEFAULT, AsyncMock, MagicMock, call, patch from matter_server.client.exceptions import CannotConnect, InvalidServerVersion import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.hassio import HassioAPIError, HassioServiceInfo @@ -58,7 +58,7 @@ ZEROCONF_INFO_UDP = ZeroconfServiceInfo( @pytest.fixture(name="setup_entry", autouse=True) -def setup_entry_fixture() -> Generator[AsyncMock, None, None]: +def setup_entry_fixture() -> Generator[AsyncMock]: """Mock entry setup.""" with patch( "homeassistant.components.matter.async_setup_entry", return_value=True @@ -67,7 +67,7 @@ def setup_entry_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="unload_entry", autouse=True) -def unload_entry_fixture() -> Generator[AsyncMock, None, None]: +def unload_entry_fixture() -> Generator[AsyncMock]: """Mock entry unload.""" with patch( "homeassistant.components.matter.async_unload_entry", return_value=True @@ -76,7 +76,7 @@ def unload_entry_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="client_connect", autouse=True) -def client_connect_fixture() -> Generator[AsyncMock, None, None]: +def client_connect_fixture() -> Generator[AsyncMock]: """Mock server version.""" with patch( "homeassistant.components.matter.config_flow.MatterClient.connect" @@ -85,7 +85,7 @@ def client_connect_fixture() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="supervisor") -def supervisor_fixture() -> Generator[MagicMock, None, None]: +def supervisor_fixture() -> Generator[MagicMock]: """Mock Supervisor.""" with patch( "homeassistant.components.matter.config_flow.is_hassio", return_value=True @@ -100,9 +100,7 @@ def discovery_info_fixture() -> Any: @pytest.fixture(name="get_addon_discovery_info", autouse=True) -def get_addon_discovery_info_fixture( - discovery_info: Any, -) -> Generator[AsyncMock, None, None]: +def get_addon_discovery_info_fixture(discovery_info: Any) -> Generator[AsyncMock]: """Mock get add-on discovery info.""" with patch( "homeassistant.components.hassio.addon_manager.async_get_addon_discovery_info", @@ -112,7 +110,7 @@ def get_addon_discovery_info_fixture( @pytest.fixture(name="addon_setup_time", autouse=True) -def addon_setup_time_fixture() -> Generator[int, None, None]: +def addon_setup_time_fixture() -> Generator[int]: """Mock add-on setup sleep time.""" with patch( "homeassistant.components.matter.config_flow.ADDON_SETUP_TIMEOUT", new=0 @@ -121,7 +119,7 @@ def addon_setup_time_fixture() -> Generator[int, None, None]: @pytest.fixture(name="not_onboarded") -def mock_onboarded_fixture() -> Generator[MagicMock, None, None]: +def mock_onboarded_fixture() -> Generator[MagicMock]: """Mock that Home Assistant is not yet onboarded.""" with patch( "homeassistant.components.matter.config_flow.async_is_onboarded", diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index ff6e933a1ab..f526205234d 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -27,11 +27,11 @@ from .common import ( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_lift", "cover.mock_lift_window_covering"), - ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), - ("window-covering_tilt", "cover.mock_tilt_window_covering"), - ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), - ("window-covering_full", "cover.mock_full_window_covering"), + ("window-covering_lift", "cover.mock_lift_window_covering_cover"), + ("window-covering_pa-lift", "cover.longan_link_wncv_da01_cover"), + ("window-covering_tilt", "cover.mock_tilt_window_covering_cover"), + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering_cover"), + ("window-covering_full", "cover.mock_full_window_covering_cover"), ], ) async def test_cover( @@ -105,9 +105,9 @@ async def test_cover( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_lift", "cover.mock_lift_window_covering"), - ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), - ("window-covering_full", "cover.mock_full_window_covering"), + ("window-covering_lift", "cover.mock_lift_window_covering_cover"), + ("window-covering_pa-lift", "cover.longan_link_wncv_da01_cover"), + ("window-covering_full", "cover.mock_full_window_covering_cover"), ], ) async def test_cover_lift( @@ -162,7 +162,7 @@ async def test_cover_lift( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_lift", "cover.mock_lift_window_covering"), + ("window-covering_lift", "cover.mock_lift_window_covering_cover"), ], ) async def test_cover_lift_only( @@ -207,7 +207,7 @@ async def test_cover_lift_only( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), + ("window-covering_pa-lift", "cover.longan_link_wncv_da01_cover"), ], ) async def test_cover_position_aware_lift( @@ -259,9 +259,9 @@ async def test_cover_position_aware_lift( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_tilt", "cover.mock_tilt_window_covering"), - ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), - ("window-covering_full", "cover.mock_full_window_covering"), + ("window-covering_tilt", "cover.mock_tilt_window_covering_cover"), + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering_cover"), + ("window-covering_full", "cover.mock_full_window_covering_cover"), ], ) async def test_cover_tilt( @@ -317,7 +317,7 @@ async def test_cover_tilt( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_tilt", "cover.mock_tilt_window_covering"), + ("window-covering_tilt", "cover.mock_tilt_window_covering_cover"), ], ) async def test_cover_tilt_only( @@ -360,7 +360,7 @@ async def test_cover_tilt_only( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering_cover"), ], ) async def test_cover_position_aware_tilt( @@ -410,7 +410,7 @@ async def test_cover_full_features( "window-covering_full", matter_client, ) - entity_id = "cover.mock_full_window_covering" + entity_id = "cover.mock_full_window_covering_cover" state = hass.states.get(entity_id) assert state diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index a44b5929f65..a0664612aba 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.lock import ( STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, STATE_UNLOCKED, STATE_UNLOCKING, LockEntityFeature, @@ -33,7 +34,7 @@ async def test_lock( "lock", "unlock", { - "entity_id": "lock.mock_door_lock", + "entity_id": "lock.mock_door_lock_lock", }, blocking=True, ) @@ -51,7 +52,7 @@ async def test_lock( "lock", "lock", { - "entity_id": "lock.mock_door_lock", + "entity_id": "lock.mock_door_lock_lock", }, blocking=True, ) @@ -65,35 +66,35 @@ async def test_lock( ) matter_client.send_device_command.reset_mock() - state = hass.states.get("lock.mock_door_lock") + state = hass.states.get("lock.mock_door_lock_lock") assert state assert state.state == STATE_LOCKED set_node_attribute(door_lock, 1, 257, 0, 0) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("lock.mock_door_lock") + state = hass.states.get("lock.mock_door_lock_lock") assert state assert state.state == STATE_UNLOCKING set_node_attribute(door_lock, 1, 257, 0, 2) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("lock.mock_door_lock") + state = hass.states.get("lock.mock_door_lock_lock") assert state assert state.state == STATE_UNLOCKED set_node_attribute(door_lock, 1, 257, 0, 0) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("lock.mock_door_lock") + state = hass.states.get("lock.mock_door_lock_lock") assert state assert state.state == STATE_LOCKING set_node_attribute(door_lock, 1, 257, 0, None) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("lock.mock_door_lock") + state = hass.states.get("lock.mock_door_lock_lock") assert state assert state.state == STATE_UNKNOWN @@ -115,13 +116,13 @@ async def test_lock_requires_pin( # set door state to unlocked set_node_attribute(door_lock, 1, 257, 0, 2) + await trigger_subscription_callback(hass, matter_client) with pytest.raises(ServiceValidationError): # Lock door using invalid code format - await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( "lock", "lock", - {"entity_id": "lock.mock_door_lock", ATTR_CODE: "1234"}, + {"entity_id": "lock.mock_door_lock_lock", ATTR_CODE: "1234"}, blocking=True, ) @@ -130,7 +131,7 @@ async def test_lock_requires_pin( await hass.services.async_call( "lock", "lock", - {"entity_id": "lock.mock_door_lock", ATTR_CODE: code}, + {"entity_id": "lock.mock_door_lock_lock", ATTR_CODE: code}, blocking=True, ) assert matter_client.send_device_command.call_count == 1 @@ -144,13 +145,13 @@ async def test_lock_requires_pin( # Lock door using default code default_code = "7654321" entity_registry.async_update_entity_options( - "lock.mock_door_lock", "lock", {"default_code": default_code} + "lock.mock_door_lock_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"}, + {"entity_id": "lock.mock_door_lock_lock"}, blocking=True, ) assert matter_client.send_device_command.call_count == 2 @@ -170,7 +171,7 @@ async def test_lock_with_unbolt( door_lock_with_unbolt: MatterNode, ) -> None: """Test door lock.""" - state = hass.states.get("lock.mock_door_lock") + state = hass.states.get("lock.mock_door_lock_lock") assert state assert state.state == STATE_LOCKED assert state.attributes["supported_features"] & LockEntityFeature.OPEN @@ -179,7 +180,7 @@ async def test_lock_with_unbolt( "lock", "unlock", { - "entity_id": "lock.mock_door_lock", + "entity_id": "lock.mock_door_lock_lock", }, blocking=True, ) @@ -197,7 +198,7 @@ async def test_lock_with_unbolt( "lock", "open", { - "entity_id": "lock.mock_door_lock", + "entity_id": "lock.mock_door_lock_lock", }, blocking=True, ) @@ -208,3 +209,10 @@ async def test_lock_with_unbolt( command=clusters.DoorLock.Commands.UnlockDoor(), timed_request_timeout_ms=1000, ) + + set_node_attribute(door_lock_with_unbolt, 1, 257, 3, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock_lock") + assert state + assert state.state == STATE_OPEN diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 2bdcfb6adb7..a7bd7c91f7b 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -40,11 +40,10 @@ async def test_generic_switch_node( generic_switch_node: MatterNode, ) -> None: """Test event entity for a GenericSwitch node.""" - state = hass.states.get("event.mock_generic_switch") + state = hass.states.get("event.mock_generic_switch_button") assert state assert state.state == "unknown" - # the switch endpoint has no label so the entity name should be the device itself - assert state.name == "Mock Generic Switch" + assert state.name == "Mock Generic Switch Button" # check event_types from featuremap 30 assert state.attributes[ATTR_EVENT_TYPES] == [ "initial_press", @@ -71,7 +70,7 @@ async def test_generic_switch_node( data=None, ), ) - state = hass.states.get("event.mock_generic_switch") + state = hass.states.get("event.mock_generic_switch_button") assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" # trigger firing a multi press event await trigger_subscription_callback( @@ -90,7 +89,7 @@ async def test_generic_switch_node( data={"NewPosition": 3}, ), ) - state = hass.states.get("event.mock_generic_switch") + state = hass.states.get("event.mock_generic_switch_button") assert state.attributes[ATTR_EVENT_TYPE] == "multi_press_ongoing" assert state.attributes["NewPosition"] == 3 @@ -106,8 +105,8 @@ async def test_generic_switch_multi_node( state_button_1 = hass.states.get("event.mock_generic_switch_button_1") assert state_button_1 assert state_button_1.state == "unknown" - # name should be 'DeviceName Button 1' due to the label set to just '1' - assert state_button_1.name == "Mock Generic Switch Button 1" + # name should be 'DeviceName Button (1)' due to the label set to just '1' + assert state_button_1.name == "Mock Generic Switch Button (1)" # check event_types from featuremap 14 assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ "initial_press", diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py new file mode 100644 index 00000000000..30bd7f4a009 --- /dev/null +++ b/tests/components/matter/test_fan.py @@ -0,0 +1,275 @@ +"""Test Matter Fan platform.""" + +from unittest.mock import MagicMock, call + +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + FanEntityFeature, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="air_purifier") +async def air_purifier_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Air Purifier node (containing Fan cluster).""" + return await setup_integration_with_node_fixture( + hass, "air-purifier", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_fan_base( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +) -> None: + """Test Fan platform.""" + entity_id = "fan.air_purifier_fan" + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_modes"] == [ + "low", + "medium", + "high", + "auto", + "natural_wind", + "sleep_wind", + ] + assert state.attributes["direction"] == "forward" + assert state.attributes["oscillating"] is False + assert state.attributes["percentage"] is None + assert state.attributes["percentage_step"] == 10 + assert state.attributes["preset_mode"] == "auto" + mask = ( + FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED + ) + assert state.attributes["supported_features"] & mask == mask + # handle fan mode update + set_node_attribute(air_purifier, 1, 514, 0, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["preset_mode"] == "low" + # handle direction update + set_node_attribute(air_purifier, 1, 514, 11, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["direction"] == "reverse" + # handle rock/oscillation update + set_node_attribute(air_purifier, 1, 514, 8, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["oscillating"] is True + # handle wind mode active translates to correct preset + set_node_attribute(air_purifier, 1, 514, 10, 2) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["preset_mode"] == "natural_wind" + set_node_attribute(air_purifier, 1, 514, 10, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.attributes["preset_mode"] == "sleep_wind" + + +async def test_fan_turn_on_with_percentage( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +) -> None: + """Test turning on the fan with a specific percentage.""" + entity_id = "fan.air_purifier_fan" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/2", + value=50, + ) + + +async def test_fan_turn_on_with_preset_mode( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +) -> None: + """Test turning on the fan with a specific preset mode.""" + entity_id = "fan.air_purifier_fan" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "medium"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=2, + ) + # test again with wind feature as preset mode + for preset_mode, value in (("natural_wind", 2), ("sleep_wind", 1)): + matter_client.write_attribute.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/10", + value=value, + ) + # test again where preset_mode is omitted in the service call + # which should select a default preset mode + matter_client.write_attribute.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=5, + ) + # test again if wind mode is explicitly turned off when we set a new preset mode + matter_client.write_attribute.reset_mock() + set_node_attribute(air_purifier, 1, 514, 10, 2) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "medium"}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=air_purifier.node_id, + attribute_path="1/514/10", + value=0, + ) + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=2, + ) + + +async def test_fan_turn_off( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +) -> None: + """Test turning off the fan.""" + entity_id = "fan.air_purifier_fan" + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=0, + ) + matter_client.write_attribute.reset_mock() + # test again if wind mode is turned off + set_node_attribute(air_purifier, 1, 514, 10, 2) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=air_purifier.node_id, + attribute_path="1/514/10", + value=0, + ) + assert matter_client.write_attribute.call_args_list[1] == call( + node_id=air_purifier.node_id, + attribute_path="1/514/0", + value=0, + ) + + +async def test_fan_oscillate( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +) -> None: + """Test oscillating the fan.""" + entity_id = "fan.air_purifier_fan" + for oscillating, value in ((True, 1), (False, 0)): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: entity_id, ATTR_OSCILLATING: oscillating}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/8", + value=value, + ) + matter_client.write_attribute.reset_mock() + + +async def test_fan_set_direction( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier: MatterNode, +) -> None: + """Test oscillating the fan.""" + entity_id = "fan.air_purifier_fan" + for direction, value in ((DIRECTION_FORWARD, 0), (DIRECTION_REVERSE, 1)): + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: entity_id, ATTR_DIRECTION: direction}, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=air_purifier.node_id, + attribute_path="1/514/11", + value=value, + ) + matter_client.write_attribute.reset_mock() diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index 37eab91894a..d3712f24d12 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, call, patch from matter_server.client.exceptions import CannotConnect, InvalidServerVersion @@ -12,6 +11,7 @@ from matter_server.common.errors import MatterError from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models import MatterNodeData import pytest +from typing_extensions import Generator from homeassistant.components.hassio import HassioAPIError from homeassistant.components.matter.const import DOMAIN @@ -32,14 +32,14 @@ from tests.typing import WebSocketGenerator @pytest.fixture(name="connect_timeout") -def connect_timeout_fixture() -> Generator[int, None, None]: +def connect_timeout_fixture() -> Generator[int]: """Mock the connect timeout.""" with patch("homeassistant.components.matter.CONNECT_TIMEOUT", new=0) as timeout: yield timeout @pytest.fixture(name="listen_ready_timeout") -def listen_ready_timeout_fixture() -> Generator[int, None, None]: +def listen_ready_timeout_fixture() -> Generator[int]: """Mock the listen ready timeout.""" with patch( "homeassistant.components.matter.LISTEN_READY_TIMEOUT", new=0 @@ -69,7 +69,7 @@ async def test_entry_setup_unload( assert matter_client.connect.call_count == 1 assert entry.state is ConfigEntryState.LOADED - entity_state = hass.states.get("light.mock_onoff_light") + entity_state = hass.states.get("light.mock_onoff_light_light") assert entity_state assert entity_state.state != STATE_UNAVAILABLE @@ -77,7 +77,7 @@ async def test_entry_setup_unload( assert matter_client.disconnect.call_count == 1 assert entry.state is ConfigEntryState.NOT_LOADED - entity_state = hass.states.get("light.mock_onoff_light") + entity_state = hass.states.get("light.mock_onoff_light_light") assert entity_state assert entity_state.state == STATE_UNAVAILABLE @@ -386,7 +386,7 @@ async def test_update_addon( backup_calls: int, update_addon_side_effect: Exception | None, create_backup_side_effect: Exception | None, -): +) -> None: """Test update the Matter add-on during entry setup.""" addon_info.return_value["version"] = addon_version addon_info.return_value["update_available"] = update_available @@ -414,8 +414,7 @@ async def test_update_addon( # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_issue_registry_invalid_version( - hass: HomeAssistant, - matter_client: MagicMock, + hass: HomeAssistant, matter_client: MagicMock, issue_registry: ir.IssueRegistry ) -> None: """Test issue registry for invalid version.""" original_connect_side_effect = matter_client.connect.side_effect @@ -433,10 +432,9 @@ async def test_issue_registry_invalid_version( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - issue_reg = ir.async_get(hass) entry_state = entry.state assert entry_state is ConfigEntryState.SETUP_RETRY - assert issue_reg.async_get_issue(DOMAIN, "invalid_server_version") + assert issue_registry.async_get_issue(DOMAIN, "invalid_server_version") matter_client.connect.side_effect = original_connect_side_effect @@ -444,7 +442,7 @@ async def test_issue_registry_invalid_version( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert not issue_reg.async_get_issue(DOMAIN, "invalid_server_version") + assert not issue_registry.async_get_issue(DOMAIN, "invalid_server_version") @pytest.mark.parametrize( @@ -455,7 +453,7 @@ async def test_issue_registry_invalid_version( ], ) async def test_stop_addon( - hass, + hass: HomeAssistant, matter_client: MagicMock, addon_installed: AsyncMock, addon_running: AsyncMock, @@ -463,7 +461,7 @@ async def test_stop_addon( stop_addon: AsyncMock, stop_addon_side_effect: Exception | None, entry_state: ConfigEntryState, -): +) -> None: """Test stop the Matter add-on on entry unload if entry is disabled.""" stop_addon.side_effect = stop_addon_side_effect entry = MockConfigEntry( @@ -627,7 +625,7 @@ async def test_remove_config_entry_device( device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id )[0] - entity_id = "light.m5stamp_lighting_app" + entity_id = "light.m5stamp_lighting_app_light" assert device_entry assert entity_registry.async_get(entity_id) diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 775790701d1..4fd73b6457b 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -22,17 +22,17 @@ from .common import ( [ ( "extended-color-light", - "light.mock_extended_color_light", + "light.mock_extended_color_light_light", ["color_temp", "hs", "xy"], ), ( "color-temperature-light", - "light.mock_color_temperature_light", + "light.mock_color_temperature_light_light", ["color_temp"], ), - ("dimmable-light", "light.mock_dimmable_light", ["brightness"]), - ("onoff-light", "light.mock_onoff_light", ["onoff"]), - ("onoff-light-with-levelcontrol-present", "light.d215s", ["onoff"]), + ("dimmable-light", "light.mock_dimmable_light_light", ["brightness"]), + ("onoff-light", "light.mock_onoff_light_light", ["onoff"]), + ("onoff-light-with-levelcontrol-present", "light.d215s_light", ["onoff"]), ], ) async def test_light_turn_on_off( @@ -113,9 +113,10 @@ async def test_light_turn_on_off( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("extended-color-light", "light.mock_extended_color_light"), - ("color-temperature-light", "light.mock_color_temperature_light"), - ("dimmable-light", "light.mock_dimmable_light"), + ("extended-color-light", "light.mock_extended_color_light_light"), + ("color-temperature-light", "light.mock_color_temperature_light_light"), + ("dimmable-light", "light.mock_dimmable_light_light"), + ("dimmable-plugin-unit", "light.dimmable_plugin_unit_light"), ], ) async def test_dimmable_light( @@ -188,8 +189,8 @@ async def test_dimmable_light( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("extended-color-light", "light.mock_extended_color_light"), - ("color-temperature-light", "light.mock_color_temperature_light"), + ("extended-color-light", "light.mock_extended_color_light_light"), + ("color-temperature-light", "light.mock_color_temperature_light_light"), ], ) async def test_color_temperature_light( @@ -286,7 +287,7 @@ async def test_color_temperature_light( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("extended-color-light", "light.mock_extended_color_light"), + ("extended-color-light", "light.mock_extended_color_light_light"), ], ) async def test_extended_color_light( diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py new file mode 100644 index 00000000000..917f8138c7a --- /dev/null +++ b/tests/components/matter/test_number.py @@ -0,0 +1,56 @@ +"""Test Matter number entities.""" + +from unittest.mock import MagicMock + +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="light_node") +async def dimmable_light_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a flow sensor node.""" + return await setup_integration_with_node_fixture( + hass, "dimmable-light", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_level_control_config_entities( + hass: HomeAssistant, + matter_client: MagicMock, + light_node: MatterNode, +) -> None: + """Test number entities are created for the LevelControl cluster (config) attributes.""" + state = hass.states.get("number.mock_dimmable_light_on_level") + assert state + assert state.state == "255" + + state = hass.states.get("number.mock_dimmable_light_on_transition_time") + assert state + assert state.state == "0.0" + + state = hass.states.get("number.mock_dimmable_light_off_transition_time") + assert state + assert state.state == "0.0" + + state = hass.states.get("number.mock_dimmable_light_on_off_transition_time") + assert state + assert state.state == "0.0" + + set_node_attribute(light_node, 1, 0x00000008, 0x0011, 20) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("number.mock_dimmable_light_on_level") + assert state + assert state.state == "20" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index c8af0647d31..2c9bfae94ce 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,7 +1,6 @@ """Test Matter sensors.""" -from datetime import UTC, datetime, timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode import pytest @@ -16,8 +15,6 @@ from .common import ( trigger_subscription_callback, ) -from tests.common import async_fire_time_changed - @pytest.fixture(name="flow_sensor_node") async def flow_sensor_node_fixture( @@ -87,6 +84,16 @@ async def air_quality_sensor_node_fixture( ) +@pytest.fixture(name="air_purifier_node") +async def air_purifier_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an air purifier node.""" + return await setup_integration_with_node_fixture( + hass, "air-purifier", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -280,26 +287,6 @@ async def test_eve_energy_sensors( assert state.attributes["device_class"] == "current" assert state.attributes["friendly_name"] == "Eve Energy Plug Current" - # test if the sensor gets polled on interval - eve_energy_plug_node.update_attribute("1/319486977/319422472", 237.0) - async_fire_time_changed(hass, datetime.now(UTC) + timedelta(seconds=31)) - await hass.async_block_till_done() - entity_id = "sensor.eve_energy_plug_voltage" - state = hass.states.get(entity_id) - assert state - assert state.state == "237.0" - - # test extra poll triggered when secondary value (switch state) changes - set_node_attribute(eve_energy_plug_node, 1, 6, 0, True) - eve_energy_plug_node.update_attribute("1/319486977/319422474", 5.0) - with patch("homeassistant.components.matter.entity.EXTRA_POLL_DELAY", 0.0): - await trigger_subscription_callback(hass, matter_client) - await hass.async_block_till_done() - entity_id = "sensor.eve_energy_plug_power" - state = hass.states.get(entity_id) - assert state - assert state.state == "5.0" - # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) @@ -356,3 +343,110 @@ async def test_air_quality_sensor( state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm10") assert state assert state.state == "50.0" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_air_purifier_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier_node: MatterNode, +) -> None: + """Test Air quality sensors are creayted for air purifier device.""" + # Carbon Dioxide + state = hass.states.get("sensor.air_purifier_carbon_dioxide") + assert state + assert state.state == "2.0" + + # PM1 + state = hass.states.get("sensor.air_purifier_pm1") + assert state + assert state.state == "2.0" + + # PM2.5 + state = hass.states.get("sensor.air_purifier_pm2_5") + assert state + assert state.state == "2.0" + + # PM10 + state = hass.states.get("sensor.air_purifier_pm10") + assert state + assert state.state == "2.0" + + # Temperature + state = hass.states.get("sensor.air_purifier_temperature") + assert state + assert state.state == "20.0" + + # Humidity + state = hass.states.get("sensor.air_purifier_humidity") + assert state + assert state.state == "50.0" + + # VOCS + state = hass.states.get("sensor.air_purifier_vocs") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "volatile_organic_compounds_parts" + assert state.attributes["friendly_name"] == "Air Purifier VOCs" + + # Air Quality + state = hass.states.get("sensor.air_purifier_air_quality") + assert state + assert state.state == "good" + expected_options = [ + "extremely_poor", + "very_poor", + "poor", + "fair", + "good", + "moderate", + "unknown", + ] + assert set(state.attributes["options"]) == set(expected_options) + assert state.attributes["device_class"] == "enum" + assert state.attributes["friendly_name"] == "Air Purifier Air quality" + + # Carbon MonoOxide + state = hass.states.get("sensor.air_purifier_carbon_monoxide") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "carbon_monoxide" + assert state.attributes["friendly_name"] == "Air Purifier Carbon monoxide" + + # Nitrogen Dioxide + state = hass.states.get("sensor.air_purifier_nitrogen_dioxide") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "nitrogen_dioxide" + assert state.attributes["friendly_name"] == "Air Purifier Nitrogen dioxide" + + # Ozone Concentration + state = hass.states.get("sensor.air_purifier_ozone") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "ozone" + assert state.attributes["friendly_name"] == "Air Purifier Ozone" + + # Hepa Filter Condition + state = hass.states.get("sensor.air_purifier_hepa_filter_condition") + assert state + assert state.state == "100" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "%" + assert state.attributes["friendly_name"] == "Air Purifier Hepa filter condition" + + # Activated Carbon Filter Condition + state = hass.states.get("sensor.air_purifier_activated_carbon_filter_condition") + assert state + assert state.state == "100" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "%" diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 5fc23fa7b34..0327e9ea5fe 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -41,7 +41,7 @@ async def test_turn_on( powerplug_node: MatterNode, ) -> None: """Test turning on a switch.""" - state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") + state = hass.states.get("switch.mock_onoffpluginunit_switch") assert state assert state.state == "off" @@ -49,7 +49,7 @@ async def test_turn_on( "switch", "turn_on", { - "entity_id": "switch.mock_onoffpluginunit_powerplug_switch", + "entity_id": "switch.mock_onoffpluginunit_switch", }, blocking=True, ) @@ -64,7 +64,7 @@ async def test_turn_on( set_node_attribute(powerplug_node, 1, 6, 0, True) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") + state = hass.states.get("switch.mock_onoffpluginunit_switch") assert state assert state.state == "on" @@ -77,7 +77,7 @@ async def test_turn_off( powerplug_node: MatterNode, ) -> None: """Test turning off a switch.""" - state = hass.states.get("switch.mock_onoffpluginunit_powerplug_switch") + state = hass.states.get("switch.mock_onoffpluginunit_switch") assert state assert state.state == "off" @@ -85,7 +85,7 @@ async def test_turn_off( "switch", "turn_off", { - "entity_id": "switch.mock_onoffpluginunit_powerplug_switch", + "entity_id": "switch.mock_onoffpluginunit_switch", }, blocking=True, ) @@ -109,7 +109,23 @@ async def test_switch_unit( # A switch entity should be discovered as fallback for ANY Matter device (endpoint) # that has the OnOff cluster and does not fall into an explicit discovery schema # by another platform (e.g. light, lock etc.). - state = hass.states.get("switch.mock_switchunit") + state = hass.states.get("switch.mock_switchunit_switch") assert state assert state.state == "off" - assert state.attributes["friendly_name"] == "Mock SwitchUnit" + assert state.attributes["friendly_name"] == "Mock SwitchUnit Switch" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_power_switch( + hass: HomeAssistant, + matter_client: MagicMock, +) -> None: + """Test if a Power switch entity is created for a device that supports that.""" + await setup_integration_with_node_fixture( + hass, "room-airconditioner", matter_client + ) + state = hass.states.get("switch.room_airconditioner_power") + assert state + assert state.state == "off" + assert state.attributes["friendly_name"] == "Room AirConditioner Power" diff --git a/tests/components/maxcube/conftest.py b/tests/components/maxcube/conftest.py index 82a852a5201..88e40edfdd0 100644 --- a/tests/components/maxcube/conftest.py +++ b/tests/components/maxcube/conftest.py @@ -10,6 +10,8 @@ from maxcube.windowshutter import MaxWindowShutter import pytest from homeassistant.components.maxcube import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import now @@ -99,7 +101,14 @@ def hass_config(): @pytest.fixture -async def cube(hass, hass_config, room, thermostat, wallthermostat, windowshutter): +async def cube( + hass: HomeAssistant, + hass_config: ConfigType, + room, + thermostat, + wallthermostat, + windowshutter, +): """Build and setup a cube mock with a single room and some devices.""" with patch("homeassistant.components.maxcube.MaxCube") as mock: cube = mock.return_value diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index e1e7dc57c47..48e616f8fd2 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -227,7 +227,7 @@ async def test_thermostat_set_no_temperature( }, blocking=True, ) - cube.set_temperature_mode.assert_not_called() + cube.set_temperature_mode.assert_not_called() async def test_thermostat_set_preset_on( diff --git a/tests/components/mealie/__init__.py b/tests/components/mealie/__init__.py new file mode 100644 index 00000000000..3e85e241c6f --- /dev/null +++ b/tests/components/mealie/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Mealie integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/mealie/conftest.py b/tests/components/mealie/conftest.py new file mode 100644 index 00000000000..dd6309cb524 --- /dev/null +++ b/tests/components/mealie/conftest.py @@ -0,0 +1,58 @@ +"""Mealie tests configuration.""" + +from unittest.mock import patch + +from aiomealie import Mealplan, MealplanResponse +from mashumaro.codecs.orjson import ORJSONDecoder +import pytest +from typing_extensions import Generator + +from homeassistant.components.mealie.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN, CONF_HOST + +from tests.common import MockConfigEntry, load_fixture +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mealie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mealie_client() -> Generator[AsyncMock]: + """Mock a Mealie client.""" + with ( + patch( + "homeassistant.components.mealie.coordinator.MealieClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.mealie.config_flow.MealieClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_mealplans.return_value = MealplanResponse.from_json( + load_fixture("get_mealplans.json", DOMAIN) + ) + client.get_mealplan_today.return_value = ORJSONDecoder(list[Mealplan]).decode( + load_fixture("get_mealplan_today.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Mealie", + data={CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"}, + entry_id="01J0BC4QM2YBRP6H5G933CETT7", + ) diff --git a/tests/components/mealie/fixtures/get_mealplan_today.json b/tests/components/mealie/fixtures/get_mealplan_today.json new file mode 100644 index 00000000000..1413f4a0113 --- /dev/null +++ b/tests/components/mealie/fixtures/get_mealplan_today.json @@ -0,0 +1,253 @@ +[ + { + "date": "2024-01-21", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "40393996-417e-4487-a081-28608a668826", + "id": 192, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "40393996-417e-4487-a081-28608a668826", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Cauliflower Salad", + "slug": "cauliflower-salad", + "image": "qLdv", + "recipeYield": "6 servings", + "totalTime": "2 Hours 35 Minutes", + "prepTime": "25 Minutes", + "cookTime": null, + "performTime": "10 Minutes", + "description": "This is a wonderful option for picnics and grill outs when you are looking for a new take on potato salad. This simple side salad made with cauliflower, peas, and hard boiled eggs can be made the day ahead and chilled until party time!", + "recipeCategory": [], + "tags": [], + "tools": [ + { + "id": "6e199f62-8356-46cf-8f6f-ea923780a1e3", + "name": "Stove", + "slug": "stove", + "onHand": false + } + ], + "rating": null, + "orgURL": "https://www.allrecipes.com/recipe/142152/cauliflower-salad/", + "dateAdded": "2023-12-29", + "dateUpdated": "2024-01-06T13:38:55.116185", + "createdAt": "2023-12-29T00:46:50.138612", + "updateAt": "2024-01-06T13:38:55.119029", + "lastMade": "2024-01-06T22:59:59" + } + }, + { + "date": "2024-01-21", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "872bb477-8d90-4025-98b0-07a9d0d9ce3a", + "id": 206, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "872bb477-8d90-4025-98b0-07a9d0d9ce3a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "15 Minute Cheesy Sausage & Veg Pasta", + "slug": "15-minute-cheesy-sausage-veg-pasta", + "image": "BeNc", + "recipeYield": "", + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Easy, cheesy, sausage pasta! In the whirlwind of mid-week mayhem, dinner doesn’t have to be a chore – this 15-minute pasta, featuring HECK’s Chicken Italia Chipolatas is your ticket to a delicious and hassle-free mid-week meal.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.annabelkarmel.com/recipes/15-minute-cheesy-sausage-veg-pasta/", + "dateAdded": "2024-01-01", + "dateUpdated": "2024-01-01T20:40:40.441381", + "createdAt": "2024-01-01T20:40:40.443048", + "updateAt": "2024-01-01T20:40:40.443050", + "lastMade": null + } + }, + { + "date": "2024-01-21", + "entryType": "lunch", + "title": "", + "text": "", + "recipeId": "744a9831-fa56-4f61-9e12-fc5ebce58ed9", + "id": 207, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "744a9831-fa56-4f61-9e12-fc5ebce58ed9", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "cake", + "slug": "cake", + "image": null, + "recipeYield": null, + "totalTime": null, + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": null, + "dateAdded": "2024-01-01", + "dateUpdated": "2024-01-01T14:39:11.214806", + "createdAt": "2024-01-01T14:39:11.216709", + "updateAt": "2024-01-01T14:39:11.216711", + "lastMade": null + } + }, + { + "date": "2024-01-21", + "entryType": "lunch", + "title": "", + "text": "", + "recipeId": "27455eb2-31d3-4682-84ff-02a114bf293a", + "id": 208, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "27455eb2-31d3-4682-84ff-02a114bf293a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Pomegranate chicken with almond couscous", + "slug": "pomegranate-chicken-with-almond-couscous", + "image": "lF4p", + "recipeYield": "4 servings", + "totalTime": "20 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Jazz up chicken breasts in this fruity, sweetly spiced sauce with pomegranate seeds, toasted almonds and tagine paste", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.bbcgoodfood.com/recipes/pomegranate-chicken-almond-couscous", + "dateAdded": "2023-12-29", + "dateUpdated": "2023-12-29T08:29:03.178355", + "createdAt": "2023-12-29T08:29:03.180819", + "updateAt": "2023-12-29T08:29:03.180820", + "lastMade": null + } + }, + { + "date": "2024-01-21", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "4233330e-6947-4042-90b7-44c405b70714", + "id": 209, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "4233330e-6947-4042-90b7-44c405b70714", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Csirkés és tofus empanadas", + "slug": "csirkes-es-tofus-empanadas", + "image": "ALqz", + "recipeYield": "16 servings", + "totalTime": "95", + "prepTime": "40", + "cookTime": null, + "performTime": "15", + "description": "", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://streetkitchen.hu/street-kitchen/csirkes-es-tofus-empanadas/", + "dateAdded": "2023-12-29", + "dateUpdated": "2023-12-29T07:56:20.087496", + "createdAt": "2023-12-29T07:53:47.765573", + "updateAt": "2023-12-29T07:56:20.090890", + "lastMade": null + } + }, + { + "date": "2024-01-21", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "id": 210, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "All-American Beef Stew Recipe", + "slug": "all-american-beef-stew-recipe", + "image": "356X", + "recipeYield": "6 servings", + "totalTime": "3 Hours 15 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "3 Hours 10 Minutes", + "description": "This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.seriouseats.com/all-american-beef-stew-recipe", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-21T03:04:45.606075", + "createdAt": "2024-01-20T20:41:29.266390", + "updateAt": "2024-01-21T03:04:45.609563", + "lastMade": null + } + }, + { + "date": "2024-01-21", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "27455eb2-31d3-4682-84ff-02a114bf293a", + "id": 223, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "27455eb2-31d3-4682-84ff-02a114bf293a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Pomegranate chicken with almond couscous", + "slug": "pomegranate-chicken-with-almond-couscous", + "image": "lF4p", + "recipeYield": "4 servings", + "totalTime": "20 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Jazz up chicken breasts in this fruity, sweetly spiced sauce with pomegranate seeds, toasted almonds and tagine paste", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.bbcgoodfood.com/recipes/pomegranate-chicken-almond-couscous", + "dateAdded": "2023-12-29", + "dateUpdated": "2023-12-29T08:29:03.178355", + "createdAt": "2023-12-29T08:29:03.180819", + "updateAt": "2023-12-29T08:29:03.180820", + "lastMade": null + } + } +] diff --git a/tests/components/mealie/fixtures/get_mealplans.json b/tests/components/mealie/fixtures/get_mealplans.json new file mode 100644 index 00000000000..2d63b753d99 --- /dev/null +++ b/tests/components/mealie/fixtures/get_mealplans.json @@ -0,0 +1,612 @@ +{ + "page": 1, + "per_page": 50, + "total": 14, + "total_pages": 1, + "items": [ + { + "date": "2024-01-22", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "c5f00a93-71a2-4e48-900f-d9ad0bb9de93", + "id": 230, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "c5f00a93-71a2-4e48-900f-d9ad0bb9de93", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Zoete aardappel curry traybake", + "slug": "zoete-aardappel-curry-traybake", + "image": "AiIo", + "recipeYield": "2 servings", + "totalTime": "40 Minutes", + "prepTime": null, + "cookTime": null, + "performTime": null, + "description": "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/", + "dateAdded": "2024-01-22", + "dateUpdated": "2024-01-22T00:27:46.324512", + "createdAt": "2024-01-22T00:27:46.327546", + "updateAt": "2024-01-22T00:27:46.327548", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "breakfast", + "title": "", + "text": "", + "recipeId": "5b055066-d57d-4fd0-8dfd-a2c2f07b36f1", + "id": 229, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "5b055066-d57d-4fd0-8dfd-a2c2f07b36f1", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Roast Chicken", + "slug": "roast-chicken", + "image": "JeQ2", + "recipeYield": "6 servings", + "totalTime": "1 Hour 35 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "1 Hour 20 Minutes", + "description": "The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://tastesbetterfromscratch.com/roast-chicken/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T15:29:25.664540", + "createdAt": "2024-01-21T15:29:25.667450", + "updateAt": "2024-01-21T15:29:25.667452", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "lunch", + "title": "", + "text": "", + "recipeId": "e360a0cc-18b0-4a84-a91b-8aa59e2451c9", + "id": 226, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "e360a0cc-18b0-4a84-a91b-8aa59e2451c9", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Receta de pollo al curry en 10 minutos (con vídeo incluido)", + "slug": "receta-de-pollo-al-curry-en-10-minutos-con-video-incluido", + "image": "INQz", + "recipeYield": "2 servings", + "totalTime": "10 Minutes", + "prepTime": "3 Minutes", + "cookTime": null, + "performTime": "7 Minutes", + "description": "Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...", + "recipeCategory": [], + "tags": [], + "tools": [ + { + "id": "1170e609-20d3-45b8-b0c7-3a4cfa614e88", + "name": "Backofen", + "slug": "backofen", + "onHand": false + }, + { + "id": "9ab522ad-c3be-4dad-8b4f-d9d53817f4d0", + "name": "Magimix blender", + "slug": "magimix-blender", + "onHand": false + }, + { + "id": "b4ca27dc-9bf6-48be-ad10-3e7056cb24bc", + "name": "Alluminio", + "slug": "alluminio", + "onHand": false + } + ], + "rating": null, + "orgURL": "https://www.directoalpaladar.com/recetas-de-carnes-y-aves/receta-de-pollo-al-curry-en-10-minutos", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T12:56:31.483701", + "createdAt": "2024-01-21T12:45:28.589669", + "updateAt": "2024-01-21T12:56:31.487406", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "lunch", + "title": "", + "text": "", + "recipeId": "9c7b8aee-c93c-4b1b-ab48-2625d444743a", + "id": 224, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "9c7b8aee-c93c-4b1b-ab48-2625d444743a", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Boeuf bourguignon : la vraie recette (2)", + "slug": "boeuf-bourguignon-la-vraie-recette-2", + "image": "nj5M", + "recipeYield": "4 servings", + "totalTime": "5 Hours", + "prepTime": "1 Hour", + "cookTime": null, + "performTime": "4 Hours", + "description": "bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre", + "recipeCategory": [], + "tags": [ + { + "id": "01c2f4ac-54ce-49bc-9bd7-8a49f353a3a4", + "name": "Poivre", + "slug": "poivre" + }, + { + "id": "90a26cea-a8a1-41a1-9e8c-e94e3c40f7a7", + "name": "Sel", + "slug": "sel" + }, + { + "id": "d7b01a4b-5206-4bd2-b9c4-d13b95ca0edb", + "name": "Beurre", + "slug": "beurre" + }, + { + "id": "304faaf8-13ec-4537-91f3-9f39a3585545", + "name": "Facile", + "slug": "facile" + }, + { + "id": "6508fb05-fb60-4bed-90c4-584bd6d74cb5", + "name": "Daube", + "slug": "daube" + }, + { + "id": "18ff59b6-b599-456a-896b-4b76448b08ca", + "name": "Bourguignon", + "slug": "bourguignon" + }, + { + "id": "685a0d90-8de4-494e-8eb8-68e7f5d5ffbe", + "name": "Vin Rouge", + "slug": "vin-rouge" + }, + { + "id": "5dedc8b5-30f5-4d6e-875f-34deefd01883", + "name": "Oignon", + "slug": "oignon" + }, + { + "id": "065b79e0-6276-4ebb-9428-7018b40c55bb", + "name": "Bouquet Garni", + "slug": "bouquet-garni" + }, + { + "id": "d858b1d9-2ca1-46d4-acc2-3d03f991f03f", + "name": "Moyen", + "slug": "moyen" + }, + { + "id": "bded0bd8-8d41-4ec5-ad73-e0107fb60908", + "name": "Boeuf Bourguignon : La Vraie Recette", + "slug": "boeuf-bourguignon-la-vraie-recette" + }, + { + "id": "7f99b04f-914a-408b-a057-511ca1125734", + "name": "Carotte", + "slug": "carotte" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T08:45:28.780361", + "createdAt": "2024-01-21T08:45:28.782322", + "updateAt": "2024-01-21T08:45:28.782324", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "f79f7e9d-4b58-4930-a586-2b127f16ee34", + "id": 222, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "f79f7e9d-4b58-4930-a586-2b127f16ee34", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)", + "slug": "eukole-makaronada-me-kephtedakia-ston-phourno-1", + "image": "En9o", + "recipeYield": "6 servings", + "totalTime": null, + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "50 Minutes", + "description": "Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T09:08:58.056854", + "createdAt": "2024-01-21T09:08:58.059401", + "updateAt": "2024-01-21T09:08:58.059403", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "47595e4c-52bc-441d-b273-3edf4258806d", + "id": 221, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "47595e4c-52bc-441d-b273-3edf4258806d", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce", + "slug": "greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce", + "image": "Kn62", + "recipeYield": "4 servings", + "totalTime": "1 Hour", + "prepTime": "40 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.ambitiouskitchen.com/greek-turkey-meatballs/", + "dateAdded": "2024-01-04", + "dateUpdated": "2024-01-04T11:51:00.843570", + "createdAt": "2024-01-04T11:51:00.847033", + "updateAt": "2024-01-04T11:51:00.847035", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "side", + "title": "", + "text": "", + "recipeId": "9d553779-607e-471b-acf3-84e6be27b159", + "id": 220, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "9d553779-607e-471b-acf3-84e6be27b159", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Einfacher Nudelauflauf mit Brokkoli", + "slug": "einfacher-nudelauflauf-mit-brokkoli", + "image": "nOPT", + "recipeYield": "4 servings", + "totalTime": "35 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T03:04:25.718367", + "createdAt": "2024-01-21T02:13:11.323363", + "updateAt": "2024-01-21T03:04:25.721489", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "92635fd0-f2dc-4e78-a6e4-ecd556ad361f", + "id": 219, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "92635fd0-f2dc-4e78-a6e4-ecd556ad361f", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Pampered Chef Double Chocolate Mocha Trifle", + "slug": "pampered-chef-double-chocolate-mocha-trifle", + "image": "ibL6", + "recipeYield": "12 servings", + "totalTime": "1 Hour 15 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "1 Hour", + "description": "This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.", + "recipeCategory": [], + "tags": [ + { + "id": "0248c21d-c85a-47b2-aaf6-fb6caf1b7726", + "name": "Weeknight", + "slug": "weeknight" + }, + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": 3, + "orgURL": "https://www.food.com/recipe/pampered-chef-double-chocolate-mocha-trifle-74963", + "dateAdded": "2024-01-06", + "dateUpdated": "2024-01-06T08:11:21.427447", + "createdAt": "2024-01-06T06:29:24.966994", + "updateAt": "2024-01-06T08:11:21.430079", + "lastMade": null + } + }, + { + "date": "2024-01-22", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "8bdd3656-5e7e-45d3-a3c4-557390846a22", + "id": 217, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "8bdd3656-5e7e-45d3-a3c4-557390846a22", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Cheeseburger Sliders (Easy, 30-min Recipe)", + "slug": "cheeseburger-sliders-easy-30-min-recipe", + "image": "beGq", + "recipeYield": "24 servings", + "totalTime": "30 Minutes", + "prepTime": "8 Minutes", + "cookTime": null, + "performTime": "22 Minutes", + "description": "Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.", + "recipeCategory": [], + "tags": [ + { + "id": "7a4ca427-642f-4428-8dc7-557ea9c8d1b4", + "name": "Cheeseburger Sliders", + "slug": "cheeseburger-sliders" + }, + { + "id": "941558d2-50d5-4c9d-8890-a0258f18d493", + "name": "Sliders", + "slug": "sliders" + } + ], + "tools": [], + "rating": 5, + "orgURL": "https://natashaskitchen.com/cheeseburger-sliders/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T07:43:24.261010", + "createdAt": "2024-01-21T06:49:35.466777", + "updateAt": "2024-01-21T06:49:35.466778", + "lastMade": "2024-01-22T04:59:59" + } + }, + { + "date": "2024-01-22", + "entryType": "lunch", + "title": "", + "text": "", + "recipeId": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "id": 216, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "All-American Beef Stew Recipe", + "slug": "all-american-beef-stew-recipe", + "image": "356X", + "recipeYield": "6 servings", + "totalTime": "3 Hours 15 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "3 Hours 10 Minutes", + "description": "This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.seriouseats.com/all-american-beef-stew-recipe", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-21T03:04:45.606075", + "createdAt": "2024-01-20T20:41:29.266390", + "updateAt": "2024-01-21T03:04:45.609563", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "id": 212, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "48f39d27-4b8e-4c14-bf36-4e1e6497e75e", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "All-American Beef Stew Recipe", + "slug": "all-american-beef-stew-recipe", + "image": "356X", + "recipeYield": "6 servings", + "totalTime": "3 Hours 15 Minutes", + "prepTime": "5 Minutes", + "cookTime": null, + "performTime": "3 Hours 10 Minutes", + "description": "This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://www.seriouseats.com/all-american-beef-stew-recipe", + "dateAdded": "2024-01-20", + "dateUpdated": "2024-01-21T03:04:45.606075", + "createdAt": "2024-01-20T20:41:29.266390", + "updateAt": "2024-01-21T03:04:45.609563", + "lastMade": null + } + }, + { + "date": "2024-01-22", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "9d553779-607e-471b-acf3-84e6be27b159", + "id": 211, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "9d553779-607e-471b-acf3-84e6be27b159", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Einfacher Nudelauflauf mit Brokkoli", + "slug": "einfacher-nudelauflauf-mit-brokkoli", + "image": "nOPT", + "recipeYield": "4 servings", + "totalTime": "35 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "20 Minutes", + "description": "Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!", + "recipeCategory": [], + "tags": [ + { + "id": "78318c97-75c7-4d06-95b6-51ef8f4a0257", + "name": "< 4 Hours", + "slug": "4-hours" + } + ], + "tools": [], + "rating": null, + "orgURL": "https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/", + "dateAdded": "2024-01-21", + "dateUpdated": "2024-01-21T03:04:25.718367", + "createdAt": "2024-01-21T02:13:11.323363", + "updateAt": "2024-01-21T03:04:25.721489", + "lastMade": null + } + }, + { + "date": "2024-01-23", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "25b814f2-d9bf-4df0-b40d-d2f2457b4317", + "id": 196, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "25b814f2-d9bf-4df0-b40d-d2f2457b4317", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Miso Udon Noodles with Spinach and Tofu", + "slug": "miso-udon-noodles-with-spinach-and-tofu", + "image": "5G1v", + "recipeYield": "2 servings", + "totalTime": "25 Minutes", + "prepTime": "10 Minutes", + "cookTime": null, + "performTime": "15 Minutes", + "description": "Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.allrecipes.com/recipe/284039/miso-udon-noodles-with-spinach-and-tofu/", + "dateAdded": "2024-01-05", + "dateUpdated": "2024-01-05T16:35:00.264511", + "createdAt": "2024-01-05T16:00:45.090493", + "updateAt": "2024-01-05T16:35:00.267508", + "lastMade": null + } + }, + { + "date": "2024-01-22", + "entryType": "dinner", + "title": "", + "text": "", + "recipeId": "55c88810-4cf1-4d86-ae50-63b15fd173fb", + "id": 195, + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "recipe": { + "id": "55c88810-4cf1-4d86-ae50-63b15fd173fb", + "userId": "1ce8b5fe-04e8-4b80-aab1-d92c94685c6d", + "groupId": "0bf60b2e-ca89-42a9-94d4-8f67ca72b157", + "name": "Mousse de saumon", + "slug": "mousse-de-saumon", + "image": "rrNL", + "recipeYield": "12 servings", + "totalTime": "17 Minutes", + "prepTime": "15 Minutes", + "cookTime": null, + "performTime": "2 Minutes", + "description": "Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !", + "recipeCategory": [], + "tags": [], + "tools": [], + "rating": null, + "orgURL": "https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon", + "dateAdded": "2024-01-02", + "dateUpdated": "2024-01-02T06:35:05.206948", + "createdAt": "2024-01-02T06:33:15.329794", + "updateAt": "2024-01-02T06:35:05.209189", + "lastMade": "2024-01-02T22:59:59" + } + } + ], + "next": null, + "previous": null +} diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..6af53c112de --- /dev/null +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -0,0 +1,359 @@ +# serializer version: 1 +# name: test_api_calendar + list([ + dict({ + 'entity_id': 'calendar.mealie_breakfast', + 'name': 'Mealie Breakfast', + }), + dict({ + 'entity_id': 'calendar.mealie_dinner', + 'name': 'Mealie Dinner', + }), + dict({ + 'entity_id': 'calendar.mealie_lunch', + 'name': 'Mealie Lunch', + }), + dict({ + 'entity_id': 'calendar.mealie_side', + 'name': 'Mealie Side', + }), + ]) +# --- +# name: test_api_events + list([ + dict({ + 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + 'end': dict({ + 'date': '2024-01-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-22', + }), + 'summary': 'Zoete aardappel curry traybake', + 'uid': None, + }), + dict({ + 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', + 'end': dict({ + 'date': '2024-01-24', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-23', + }), + 'summary': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', + 'uid': None, + }), + dict({ + 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', + 'end': dict({ + 'date': '2024-01-24', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-23', + }), + 'summary': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', + 'uid': None, + }), + dict({ + 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', + 'end': dict({ + 'date': '2024-01-24', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-23', + }), + 'summary': 'Pampered Chef Double Chocolate Mocha Trifle', + 'uid': None, + }), + dict({ + 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', + 'end': dict({ + 'date': '2024-01-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-22', + }), + 'summary': 'Cheeseburger Sliders (Easy, 30-min Recipe)', + 'uid': None, + }), + dict({ + 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', + 'end': dict({ + 'date': '2024-01-24', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-23', + }), + 'summary': 'All-American Beef Stew Recipe', + 'uid': None, + }), + dict({ + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'end': dict({ + 'date': '2024-01-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-22', + }), + 'summary': 'Einfacher Nudelauflauf mit Brokkoli', + 'uid': None, + }), + dict({ + 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', + 'end': dict({ + 'date': '2024-01-24', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-23', + }), + 'summary': 'Miso Udon Noodles with Spinach and Tofu', + 'uid': None, + }), + dict({ + 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', + 'end': dict({ + 'date': '2024-01-23', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2024-01-22', + }), + 'summary': 'Mousse de saumon', + 'uid': None, + }), + ]) +# --- +# name: test_entities[calendar.mealie_breakfast-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_breakfast', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Breakfast', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'breakfast', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_breakfast', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_breakfast-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', + 'end_time': '2024-01-24 00:00:00', + 'friendly_name': 'Mealie Breakfast', + 'location': '', + 'message': 'Roast Chicken', + 'start_time': '2024-01-23 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_breakfast', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[calendar.mealie_dinner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_dinner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dinner', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dinner', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_dinner', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_dinner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", + 'end_time': '2024-01-23 00:00:00', + 'friendly_name': 'Mealie Dinner', + 'location': '', + 'message': 'Zoete aardappel curry traybake', + 'start_time': '2024-01-22 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_dinner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[calendar.mealie_lunch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_lunch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lunch', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lunch', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_lunch', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_lunch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', + 'end_time': '2024-01-24 00:00:00', + 'friendly_name': 'Mealie Lunch', + 'location': '', + 'message': 'Receta de pollo al curry en 10 minutos (con vídeo incluido)', + 'start_time': '2024-01-23 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_lunch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[calendar.mealie_side-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.mealie_side', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Side', + 'platform': 'mealie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'side', + 'unique_id': '01J0BC4QM2YBRP6H5G933CETT7_side', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[calendar.mealie_side-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', + 'end_time': '2024-01-24 00:00:00', + 'friendly_name': 'Mealie Side', + 'location': '', + 'message': 'Einfacher Nudelauflauf mit Brokkoli', + 'start_time': '2024-01-23 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.mealie_side', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr new file mode 100644 index 00000000000..c2752d938e4 --- /dev/null +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'mealie', + '01J0BC4QM2YBRP6H5G933CETT7', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'name': 'Mealie', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/mealie/test_calendar.py b/tests/components/mealie/test_calendar.py new file mode 100644 index 00000000000..9df2c1810fd --- /dev/null +++ b/tests/components/mealie/test_calendar.py @@ -0,0 +1,69 @@ +"""Tests for the Mealie calendar.""" + +from datetime import date +from http import HTTPStatus +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + + +async def test_api_calendar( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the API returns the calendar.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == snapshot + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the API returns the calendar.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_api_events( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the Mealie calendar view.""" + await setup_integration(hass, mock_config_entry) + + client = await hass_client() + response = await client.get( + "/api/calendars/calendar.mealie_dinner?start=2023-08-01&end=2023-11-01" + ) + assert mock_mealie_client.get_mealplans.called == 1 + assert mock_mealie_client.get_mealplans.call_args_list[1].args == ( + date(2023, 8, 1), + date(2023, 11, 1), + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert events == snapshot diff --git a/tests/components/mealie/test_config_flow.py b/tests/components/mealie/test_config_flow.py new file mode 100644 index 00000000000..ac68ed2fac5 --- /dev/null +++ b/tests/components/mealie/test_config_flow.py @@ -0,0 +1,107 @@ +"""Tests for the Mealie config flow.""" + +from unittest.mock import AsyncMock + +from aiomealie import MealieAuthenticationError, MealieConnectionError +import pytest + +from homeassistant.components.mealie.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Mealie" + assert result["data"] == { + CONF_HOST: "demo.mealie.io", + CONF_API_TOKEN: "token", + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (MealieConnectionError, "cannot_connect"), + (MealieAuthenticationError, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_mealie_client.get_mealplan_today.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_mealie_client.get_mealplan_today.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "demo.mealie.io", CONF_API_TOKEN: "token"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/mealie/test_init.py b/tests/components/mealie/test_init.py new file mode 100644 index 00000000000..7d63ad135f9 --- /dev/null +++ b/tests/components/mealie/test_init.py @@ -0,0 +1,70 @@ +"""Tests for the Mealie integration.""" + +from unittest.mock import AsyncMock + +from aiomealie import MealieAuthenticationError, MealieConnectionError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.mealie.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exc", "state"), + [ + (MealieConnectionError, ConfigEntryState.SETUP_RETRY), + (MealieAuthenticationError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_initialization_failure( + hass: HomeAssistant, + mock_mealie_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exc: Exception, + state: ConfigEntryState, +) -> None: + """Test initialization failure.""" + mock_mealie_client.get_mealplans.side_effect = exc + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state diff --git a/tests/components/medcom_ble/conftest.py b/tests/components/medcom_ble/conftest.py index 7c5b0dad22e..41f797f3e1d 100644 --- a/tests/components/medcom_ble/conftest.py +++ b/tests/components/medcom_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/media_extractor/__init__.py b/tests/components/media_extractor/__init__.py index 79130f1ea4b..631bdc19ed7 100644 --- a/tests/components/media_extractor/__init__.py +++ b/tests/components/media_extractor/__init__.py @@ -2,8 +2,7 @@ from typing import Any -from tests.common import load_json_object_fixture -from tests.components.media_extractor.const import ( +from .const import ( AUDIO_QUERY, NO_FORMATS_RESPONSE, SOUNDCLOUD_TRACK, @@ -12,6 +11,8 @@ from tests.components.media_extractor.const import ( YOUTUBE_VIDEO, ) +from tests.common import load_json_object_fixture + def _get_base_fixture(url: str) -> str: return { diff --git a/tests/components/media_extractor/conftest.py b/tests/components/media_extractor/conftest.py index 4b7411340ae..1d198681f3f 100644 --- a/tests/components/media_extractor/conftest.py +++ b/tests/components/media_extractor/conftest.py @@ -1,17 +1,19 @@ -"""The tests for Media Extractor integration.""" +"""Common fixtures for the Media Extractor tests.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.media_extractor import DOMAIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component +from . import MockYoutubeDL +from .const import AUDIO_QUERY + from tests.common import async_mock_service -from tests.components.media_extractor import MockYoutubeDL -from tests.components.media_extractor.const import AUDIO_QUERY @pytest.fixture(autouse=True) @@ -53,3 +55,12 @@ def empty_media_extractor_config() -> dict[str, Any]: def audio_media_extractor_config() -> dict[str, Any]: """Media extractor config for audio.""" return {DOMAIN: {"default_query": AUDIO_QUERY}} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.media_extractor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/media_extractor/test_config_flow.py b/tests/components/media_extractor/test_config_flow.py new file mode 100644 index 00000000000..bfee5ec4879 --- /dev/null +++ b/tests/components/media_extractor/test_config_flow.py @@ -0,0 +1,56 @@ +"""Tests for the Media extractor config flow.""" + +from homeassistant.components.media_extractor.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Media extractor" + assert result.get("data") == {} + assert result.get("options") == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_single_instance_allowed(hass: HomeAssistant) -> None: + """Test we abort if already setup.""" + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={} + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "Media extractor" + assert result.get("data") == {} + assert result.get("options") == {} + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index 388ea3be1fd..8c8a1407ccc 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -19,14 +19,10 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from . import YOUTUBE_EMPTY_PLAYLIST, YOUTUBE_PLAYLIST, YOUTUBE_VIDEO, MockYoutubeDL +from .const import NO_FORMATS_RESPONSE, SOUNDCLOUD_TRACK + from tests.common import load_json_object_fixture -from tests.components.media_extractor import ( - YOUTUBE_EMPTY_PLAYLIST, - YOUTUBE_PLAYLIST, - YOUTUBE_VIDEO, - MockYoutubeDL, -) -from tests.components.media_extractor.const import NO_FORMATS_RESPONSE, SOUNDCLOUD_TRACK async def test_play_media_service_is_registered(hass: HomeAssistant) -> None: @@ -36,6 +32,7 @@ async def test_play_media_service_is_registered(hass: HomeAssistant) -> None: assert hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA) assert hass.services.has_service(DOMAIN, SERVICE_EXTRACT_MEDIA_URL) + assert len(hass.config_entries.async_entries(DOMAIN)) @pytest.mark.parametrize( diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index e3d89a9ca2e..783846d8857 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -111,7 +111,7 @@ class DescrMediaPlayer(SimpleMediaPlayer): @pytest.fixture(params=[ExtendedMediaPlayer, SimpleMediaPlayer]) -def player(hass, request): +def player(hass: HomeAssistant, request: pytest.FixtureRequest) -> mp.MediaPlayerEntity: """Return a media player.""" return request.param(hass) diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index d64161b8409..186cd674b39 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -15,7 +15,7 @@ from homeassistant.const import ( STATE_PLAYING, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -33,7 +33,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -62,14 +62,14 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in [ + for condition in ( "is_buffering", "is_off", "is_on", "is_idle", "is_paused", "is_playing", - ] + ) ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -117,14 +117,14 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in [ + for condition in ( "is_buffering", "is_off", "is_on", "is_idle", "is_paused", "is_playing", - ] + ) ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -136,7 +136,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -337,7 +337,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 4c507b4bd66..e9d5fbd646e 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_PLAYING, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -38,7 +38,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -209,7 +209,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -321,7 +321,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -380,7 +380,7 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 436a9e3d05f..11898edfc36 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, ) -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index b0ea7fe8e94..9ddf50d04f4 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -1,16 +1,30 @@ """The tests for the media_player platform.""" +import pytest + from homeassistant.components.media_player import ( DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, intent as media_player_intent, ) -from homeassistant.const import STATE_IDLE -from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.components.media_player.const import MediaPlayerEntityFeature +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + STATE_IDLE, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from tests.common import async_mock_service @@ -20,14 +34,19 @@ async def test_pause_media_player_intent(hass: HomeAssistant) -> None: await media_player_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_media_player" - hass.states.async_set(entity_id, STATE_IDLE) - calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PAUSE} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + calls = async_mock_service( + hass, + DOMAIN, + SERVICE_MEDIA_PAUSE, + ) response = await intent.async_handle( hass, "test", media_player_intent.INTENT_MEDIA_PAUSE, - {"name": {"value": "test media player"}}, ) await hass.async_block_till_done() @@ -38,20 +57,43 @@ async def test_pause_media_player_intent(hass: HomeAssistant) -> None: assert call.service == SERVICE_MEDIA_PAUSE assert call.data == {"entity_id": entity_id} + # Test if not playing + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + ) + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + ) + async def test_unpause_media_player_intent(hass: HomeAssistant) -> None: """Test HassMediaUnpause intent for media players.""" await media_player_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_media_player" - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set(entity_id, STATE_PAUSED) calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) response = await intent.async_handle( hass, "test", media_player_intent.INTENT_MEDIA_UNPAUSE, - {"name": {"value": "test media player"}}, ) await hass.async_block_till_done() @@ -62,20 +104,35 @@ async def test_unpause_media_player_intent(hass: HomeAssistant) -> None: assert call.service == SERVICE_MEDIA_PLAY assert call.data == {"entity_id": entity_id} + # Test if not paused + hass.states.async_set( + entity_id, + STATE_PLAYING, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_UNPAUSE, + ) + async def test_next_media_player_intent(hass: HomeAssistant) -> None: """Test HassMediaNext intent for media players.""" await media_player_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_media_player" - hass.states.async_set(entity_id, STATE_IDLE) + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.NEXT_TRACK} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_NEXT_TRACK) response = await intent.async_handle( hass, "test", media_player_intent.INTENT_MEDIA_NEXT, - {"name": {"value": "test media player"}}, ) await hass.async_block_till_done() @@ -86,20 +143,98 @@ async def test_next_media_player_intent(hass: HomeAssistant) -> None: assert call.service == SERVICE_MEDIA_NEXT_TRACK assert call.data == {"entity_id": entity_id} + # Test if not playing + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_NEXT, + ) + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_NEXT, + {"name": {"value": "test media player"}}, + ) + + +async def test_previous_media_player_intent(hass: HomeAssistant) -> None: + """Test HassMediaPrevious intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PREVIOUS_TRACK} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PREVIOUS, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_MEDIA_PREVIOUS_TRACK + assert call.data == {"entity_id": entity_id} + + # Test if not playing + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PREVIOUS, + ) + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PREVIOUS, + {"name": {"value": "test media player"}}, + ) + async def test_volume_media_player_intent(hass: HomeAssistant) -> None: """Test HassSetVolume intent for media players.""" await media_player_intent.async_setup_intents(hass) entity_id = f"{DOMAIN}.test_media_player" - hass.states.async_set(entity_id, STATE_IDLE) + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) calls = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_SET) response = await intent.async_handle( hass, "test", media_player_intent.INTENT_SET_VOLUME, - {"name": {"value": "test media player"}, "volume_level": {"value": 50}}, + {"volume_level": {"value": 50}}, ) await hass.async_block_till_done() @@ -109,3 +244,418 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None: assert call.domain == DOMAIN assert call.service == SERVICE_VOLUME_SET assert call.data == {"entity_id": entity_id, "volume_level": 0.5} + + # Test if not playing + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME, + {"volume_level": {"value": 50}}, + ) + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME, + {"volume_level": {"value": 50}}, + ) + + +async def test_multiple_media_players( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test HassMedia* intents with multiple media players.""" + await media_player_intent.async_setup_intents(hass) + + attributes = { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + } + + # House layout + # Floor 1 (ground): + # - Kitchen + # - Smart speaker + # - Living room + # - TV + # - Smart speaker + # Floor 2 (upstairs): + # - Bedroom + # - TV + # - Smart speaker + # - Bathroom + # - Smart speaker + + # Floor 1 + floor_1 = floor_registry.async_create("first floor", aliases={"ground"}) + area_kitchen = area_registry.async_get_or_create("kitchen") + area_kitchen = area_registry.async_update( + area_kitchen.id, floor_id=floor_1.floor_id + ) + area_living_room = area_registry.async_get_or_create("living room") + area_living_room = area_registry.async_update( + area_living_room.id, floor_id=floor_1.floor_id + ) + + kitchen_smart_speaker = entity_registry.async_get_or_create( + "media_player", "test", "kitchen_smart_speaker" + ) + kitchen_smart_speaker = entity_registry.async_update_entity( + kitchen_smart_speaker.entity_id, name="smart speaker", area_id=area_kitchen.id + ) + hass.states.async_set( + kitchen_smart_speaker.entity_id, STATE_PAUSED, attributes=attributes + ) + + living_room_smart_speaker = entity_registry.async_get_or_create( + "media_player", "test", "living_room_smart_speaker" + ) + living_room_smart_speaker = entity_registry.async_update_entity( + living_room_smart_speaker.entity_id, + name="smart speaker", + area_id=area_living_room.id, + ) + hass.states.async_set( + living_room_smart_speaker.entity_id, STATE_PAUSED, attributes=attributes + ) + + living_room_tv = entity_registry.async_get_or_create( + "media_player", "test", "living_room_tv" + ) + living_room_tv = entity_registry.async_update_entity( + living_room_tv.entity_id, name="TV", area_id=area_living_room.id + ) + hass.states.async_set( + living_room_tv.entity_id, STATE_PLAYING, attributes=attributes + ) + + # Floor 2 + floor_2 = floor_registry.async_create("second floor", aliases={"upstairs"}) + area_bedroom = area_registry.async_get_or_create("bedroom") + area_bedroom = area_registry.async_update( + area_bedroom.id, floor_id=floor_2.floor_id + ) + area_bathroom = area_registry.async_get_or_create("bathroom") + area_bathroom = area_registry.async_update( + area_bathroom.id, floor_id=floor_2.floor_id + ) + + bedroom_tv = entity_registry.async_get_or_create( + "media_player", "test", "bedroom_tv" + ) + bedroom_tv = entity_registry.async_update_entity( + bedroom_tv.entity_id, name="TV", area_id=area_bedroom.id + ) + hass.states.async_set(bedroom_tv.entity_id, STATE_PLAYING, attributes=attributes) + + bedroom_smart_speaker = entity_registry.async_get_or_create( + "media_player", "test", "bedroom_smart_speaker" + ) + bedroom_smart_speaker = entity_registry.async_update_entity( + bedroom_smart_speaker.entity_id, name="smart speaker", area_id=area_bedroom.id + ) + hass.states.async_set( + bedroom_smart_speaker.entity_id, STATE_PAUSED, attributes=attributes + ) + + bathroom_smart_speaker = entity_registry.async_get_or_create( + "media_player", "test", "bathroom_smart_speaker" + ) + bathroom_smart_speaker = entity_registry.async_update_entity( + bathroom_smart_speaker.entity_id, name="smart speaker", area_id=area_bathroom.id + ) + hass.states.async_set( + bathroom_smart_speaker.entity_id, STATE_PAUSED, attributes=attributes + ) + + # ----- + + # There are multiple TV's currently playing + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + {"name": {"value": "TV"}}, + ) + + # Pause the upstairs TV + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + {"name": {"value": "TV"}, "floor": {"value": "upstairs"}}, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": bedroom_tv.entity_id} + hass.states.async_set(bedroom_tv.entity_id, STATE_PAUSED, attributes=attributes) + + # Now we can pause the only playing TV (living room) + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + {"name": {"value": "TV"}}, + ) + + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": living_room_tv.entity_id} + hass.states.async_set(living_room_tv.entity_id, STATE_PAUSED, attributes=attributes) + + # Unpause the kitchen smart speaker (explicit area) + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_UNPAUSE, + {"name": {"value": "smart speaker"}, "area": {"value": "kitchen"}}, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": kitchen_smart_speaker.entity_id} + hass.states.async_set( + kitchen_smart_speaker.entity_id, STATE_PLAYING, attributes=attributes + ) + + # Unpause living room smart speaker (context area) + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_UNPAUSE, + { + "name": {"value": "smart speaker"}, + "preferred_area_id": {"value": area_living_room.id}, + }, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": living_room_smart_speaker.entity_id} + hass.states.async_set( + living_room_smart_speaker.entity_id, STATE_PLAYING, attributes=attributes + ) + + # Unpause all of the upstairs media players + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_UNPAUSE, + {"floor": {"value": "upstairs"}}, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 3 + assert {call.data["entity_id"] for call in calls} == { + bedroom_tv.entity_id, + bedroom_smart_speaker.entity_id, + bathroom_smart_speaker.entity_id, + } + for entity in (bedroom_tv, bedroom_smart_speaker, bathroom_smart_speaker): + hass.states.async_set(entity.entity_id, STATE_PLAYING, attributes=attributes) + + # Pause bedroom TV (context floor) + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + { + "name": {"value": "TV"}, + "preferred_floor_id": {"value": floor_2.floor_id}, + }, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": bedroom_tv.entity_id} + hass.states.async_set(bedroom_tv.entity_id, STATE_PAUSED, attributes=attributes) + + # Set volume in the bathroom + calls = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_SET) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME, + {"area": {"value": "bathroom"}, "volume_level": {"value": 50}}, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": bathroom_smart_speaker.entity_id, + "volume_level": 0.5, + } + + # Next track in the kitchen (only media player that is playing on ground floor) + hass.states.async_set( + living_room_smart_speaker.entity_id, STATE_PAUSED, attributes=attributes + ) + + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_NEXT_TRACK) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_NEXT, + {"floor": {"value": "ground"}}, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": kitchen_smart_speaker.entity_id} + + # Pause the kitchen smart speaker (all ground floor media players are now paused) + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + {"area": {"value": "kitchen"}}, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": kitchen_smart_speaker.entity_id} + + hass.states.async_set( + kitchen_smart_speaker.entity_id, STATE_PAUSED, attributes=attributes + ) + + # Unpause with no context (only kitchen should be resumed) + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_UNPAUSE, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": kitchen_smart_speaker.entity_id} + + hass.states.async_set( + kitchen_smart_speaker.entity_id, STATE_PLAYING, attributes=attributes + ) + + +async def test_manual_pause_unpause( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test unpausing a media player that was manually paused outside of voice.""" + await media_player_intent.async_setup_intents(hass) + + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PAUSE} + + # Create two playing devices + device_1 = entity_registry.async_get_or_create("media_player", "test", "device-1") + device_1 = entity_registry.async_update_entity(device_1.entity_id, name="device 1") + hass.states.async_set(device_1.entity_id, STATE_PLAYING, attributes=attributes) + + device_2 = entity_registry.async_get_or_create("media_player", "test", "device-2") + device_2 = entity_registry.async_update_entity(device_2.entity_id, name="device 2") + hass.states.async_set(device_2.entity_id, STATE_PLAYING, attributes=attributes) + + # Pause both devices by voice + context = Context() + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + context=context, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 2 + + hass.states.async_set( + device_1.entity_id, STATE_PAUSED, attributes=attributes, context=context + ) + hass.states.async_set( + device_2.entity_id, STATE_PAUSED, attributes=attributes, context=context + ) + + # Unpause both devices by voice + context = Context() + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_UNPAUSE, + context=context, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 2 + + hass.states.async_set( + device_1.entity_id, STATE_PLAYING, attributes=attributes, context=context + ) + hass.states.async_set( + device_2.entity_id, STATE_PLAYING, attributes=attributes, context=context + ) + + # Pause the first device by voice + context = Context() + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PAUSE) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_PAUSE, + {"name": {"value": "device 1"}}, + context=context, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": device_1.entity_id} + + hass.states.async_set( + device_1.entity_id, STATE_PAUSED, attributes=attributes, context=context + ) + + # "Manually" pause the second device (outside of voice) + context = Context() + hass.states.async_set( + device_2.entity_id, STATE_PAUSED, attributes=attributes, context=context + ) + + # Unpause with no constraints. + # Should resume the more recently (manually) paused device. + context = Context() + calls = async_mock_service(hass, DOMAIN, SERVICE_MEDIA_PLAY) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_UNPAUSE, + context=context, + ) + await hass.async_block_till_done() + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + assert calls[0].data == {"entity_id": device_2.entity_id} diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index 9902aa689ae..4c7fbd06edc 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -1,6 +1,5 @@ """Test Local Media Source.""" -from collections.abc import AsyncGenerator from http import HTTPStatus import io from pathlib import Path @@ -8,6 +7,7 @@ from tempfile import TemporaryDirectory from unittest.mock import patch import pytest +from typing_extensions import AsyncGenerator from homeassistant.components import media_source, websocket_api from homeassistant.components.media_source import const @@ -20,7 +20,7 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -async def temp_dir(hass: HomeAssistant) -> AsyncGenerator[str, None]: +async def temp_dir(hass: HomeAssistant) -> AsyncGenerator[str]: """Return a temp dir.""" with TemporaryDirectory() as tmpdirname: target_dir = Path(tmpdirname) / "another_subdir" diff --git a/tests/components/melcloud/snapshots/test_diagnostics.ambr b/tests/components/melcloud/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..7b0173c240e --- /dev/null +++ b/tests/components/melcloud/snapshots/test_diagnostics.ambr @@ -0,0 +1,23 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'entities': dict({ + }), + 'entry': dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'melcloud', + 'entry_id': 'TEST_ENTRY_ID', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'melcloud', + 'unique_id': 'UNIQUE_TEST_ID', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 621838e8c67..c1c6c10ac4c 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -9,7 +9,8 @@ import pytest from homeassistant import config_entries from homeassistant.components.melcloud.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_RECONFIGURE +from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -305,3 +306,136 @@ async def test_client_errors_reauthentication( assert result["type"] is 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_reconfigure_flow( + hass: HomeAssistant, mock_login, mock_request_info, error, reason +) -> None: + """Test re-configuration flow.""" + 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_RECONFIGURE, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test-password"}, + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == reason + assert result["type"] is 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"], + {CONF_PASSWORD: "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry + assert entry.title == "Mock Title" + assert entry.data == { + "username": "test-email@test-domain.com", + "token": "test-token", + "password": "test-password", + } + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (TimeoutError(), "cannot_connect"), + (AttributeError(name="get"), "invalid_auth"), + ], +) +async def test_form_errors_reconfigure( + 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_RECONFIGURE, + "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"], + {CONF_PASSWORD: "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is 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"], + {CONF_PASSWORD: "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry + assert entry.title == "Mock Title" + assert entry.data == { + "username": "test-email@test-domain.com", + "token": "test-token", + "password": "test-password", + } diff --git a/tests/components/melcloud/test_diagnostics.py b/tests/components/melcloud/test_diagnostics.py new file mode 100644 index 00000000000..cbb35eadfd4 --- /dev/null +++ b/tests/components/melcloud/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Test the DSMR Reader component diagnostics.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.melcloud.const import DOMAIN +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_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + options={}, + entry_id="TEST_ENTRY_ID", + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", return_value=True + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py index ff59f925961..ceb14faf8fb 100644 --- a/tests/components/melissa/test_climate.py +++ b/tests/components/melissa/test_climate.py @@ -12,7 +12,7 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from tests.components.melissa import setup_integration +from . import setup_integration async def test_setup_platform( diff --git a/tests/components/melissa/test_init.py b/tests/components/melissa/test_init.py index d809f42e409..2eebc012fe1 100644 --- a/tests/components/melissa/test_init.py +++ b/tests/components/melissa/test_init.py @@ -2,7 +2,7 @@ from homeassistant.core import HomeAssistant -from tests.components.melissa import setup_integration +from . import setup_integration async def test_setup(hass: HomeAssistant, mock_melissa) -> None: diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index b75eb370555..38bc1a62d51 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Generator from datetime import UTC, datetime, time, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, _patch, patch from melnor_bluetooth.device import Device import pytest +from typing_extensions import Generator from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak from homeassistant.components.melnor.const import DOMAIN @@ -57,7 +57,7 @@ FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak( @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" @@ -245,7 +245,7 @@ def mock_melnor_device(): @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Patch async setup entry to return True.""" with patch( "homeassistant.components.melnor.async_setup_entry", return_value=True @@ -253,10 +253,9 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup -# pylint: disable=dangerous-default-value def patch_async_discovered_service_info( - return_value: list[BluetoothServiceInfoBleak] = [FAKE_SERVICE_INFO_1], -): + return_value: list[BluetoothServiceInfoBleak], +) -> _patch: """Patch async_discovered_service_info a mocked device info.""" return patch( "homeassistant.components.melnor.config_flow.async_discovered_service_info", diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py index 377954c22df..b90fdd39ce9 100644 --- a/tests/components/melnor/test_config_flow.py +++ b/tests/components/melnor/test_config_flow.py @@ -40,7 +40,7 @@ async def test_user_step_discovered_devices( ) -> None: """Test we properly handle device picking.""" - with patch_async_discovered_service_info(): + with patch_async_discovered_service_info([FAKE_SERVICE_INFO_1]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py index d5d61516c08..c3126f7b76a 100644 --- a/tests/components/meraki/test_device_tracker.py +++ b/tests/components/meraki/test_device_tracker.py @@ -1,8 +1,10 @@ """The tests the for Meraki device tracker.""" +from asyncio import AbstractEventLoop from http import HTTPStatus import json +from aiohttp.test_utils import TestClient import pytest from homeassistant.components import device_tracker @@ -16,9 +18,15 @@ from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + @pytest.fixture -def meraki_client(event_loop, hass, hass_client): +def meraki_client( + event_loop: AbstractEventLoop, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> TestClient: """Meraki mock client.""" loop = event_loop diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py index 8ea5ce605f0..6556c96bff9 100644 --- a/tests/components/met/__init__.py +++ b/tests/components/met/__init__.py @@ -4,11 +4,14 @@ from unittest.mock import patch from homeassistant.components.met.const import CONF_TRACK_HOME, DOMAIN from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def init_integration(hass, track_home=False) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, track_home: bool = False +) -> MockConfigEntry: """Set up the Met integration in Home Assistant.""" entry_data = { CONF_NAME: "test", diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index c494c4afeb9..c7f0311edef 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -1,5 +1,7 @@ """Tests for Met.no config flow.""" +from collections.abc import Generator +from typing import Any from unittest.mock import ANY, patch import pytest @@ -17,7 +19,7 @@ from tests.common import MockConfigEntry @pytest.fixture(name="met_setup", autouse=True) -def met_setup_fixture(request): +def met_setup_fixture(request: pytest.FixtureRequest) -> Generator[Any]: """Patch met setup entry.""" if "disable_autouse_fixture" in request.keywords: yield diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 95547ead14d..80820ef0186 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -84,19 +84,22 @@ async def test_not_tracking_home(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 0 -async def test_remove_hourly_entity(hass: HomeAssistant, mock_weather) -> None: +async def test_remove_hourly_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather +) -> None: """Test removing the hourly entity.""" # Pre-create registry entry for disabled by default hourly weather - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, "10-20-hourly", suggested_object_id="forecast_somewhere_hourly", disabled_by=None, ) - assert list(registry.entities.keys()) == ["weather.forecast_somewhere_hourly"] + assert list(entity_registry.entities.keys()) == [ + "weather.forecast_somewhere_hourly" + ] await hass.config_entries.flow.async_init( "met", @@ -105,4 +108,4 @@ async def test_remove_hourly_entity(hass: HomeAssistant, mock_weather) -> None: ) await hass.async_block_till_done() assert hass.states.async_entity_ids("weather") == ["weather.forecast_somewhere"] - assert list(registry.entities.keys()) == ["weather.forecast_somewhere"] + assert list(entity_registry.entities.keys()) == ["weather.forecast_somewhere"] diff --git a/tests/components/met_eireann/__init__.py b/tests/components/met_eireann/__init__.py index 86c3090b0ca..c38f197691a 100644 --- a/tests/components/met_eireann/__init__.py +++ b/tests/components/met_eireann/__init__.py @@ -4,11 +4,12 @@ from unittest.mock import patch from homeassistant.components.met_eireann.const import DOMAIN from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def init_integration(hass) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Met Éireann integration in Home Assistant.""" entry_data = { CONF_NAME: "test", diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index a660c18f7b3..1e385c9a600 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -10,7 +10,6 @@ from homeassistant.components.met_eireann import UPDATE_INTERVAL from homeassistant.components.met_eireann.const import DOMAIN from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.config_entries import ConfigEntry @@ -65,10 +64,7 @@ async def test_weather(hass: HomeAssistant, mock_weather) -> None: @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_forecast_service( hass: HomeAssistant, diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index 6bddd1d2596..db84e85075e 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -8,7 +8,7 @@ import requests_mock from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.helpers import device_registry as dr from .const import ( DEVICE_KEY_KINGSLYNN, @@ -27,7 +27,9 @@ from tests.common import MockConfigEntry, load_fixture @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_sensor_site_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + requests_mock: requests_mock.Mocker, ) -> None: """Test the Met Office sensor platform.""" # all metoffice test data encapsulated in here @@ -54,9 +56,10 @@ async def test_one_sensor_site_running( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - dev_reg = get_dev_reg(hass) - assert len(dev_reg.devices) == 1 - device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + assert len(device_registry.devices) == 1 + device_wavertree = device_registry.async_get_device( + identifiers=DEVICE_KEY_WAVERTREE + ) assert device_wavertree.name == "Met Office Wavertree" running_sensor_ids = hass.states.async_entity_ids("sensor") @@ -75,7 +78,9 @@ async def test_one_sensor_site_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_sensor_sites_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + requests_mock: requests_mock.Mocker, ) -> None: """Test we handle two sets of sensors running for two different sites.""" @@ -115,11 +120,14 @@ async def test_two_sensor_sites_running( await hass.config_entries.async_setup(entry2.entry_id) await hass.async_block_till_done() - dev_reg = get_dev_reg(hass) - assert len(dev_reg.devices) == 2 - device_kingslynn = dev_reg.async_get_device(identifiers=DEVICE_KEY_KINGSLYNN) + assert len(device_registry.devices) == 2 + device_kingslynn = device_registry.async_get_device( + identifiers=DEVICE_KEY_KINGSLYNN + ) assert device_kingslynn.name == "Met Office King's Lynn" - device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + device_wavertree = device_registry.async_get_device( + identifiers=DEVICE_KEY_WAVERTREE + ) assert device_wavertree.name == "Met Office Wavertree" running_sensor_ids = hass.states.async_entity_ids("sensor") diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 64a85897738..5176aff9e7d 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -14,13 +14,11 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.metoffice.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import utcnow from .const import ( @@ -73,7 +71,9 @@ async def wavertree_data(requests_mock: requests_mock.Mocker) -> dict[str, _Matc @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_connect( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + requests_mock: requests_mock.Mocker, ) -> None: """Test we handle cannot connect error.""" @@ -89,13 +89,12 @@ async def test_site_cannot_connect( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - dev_reg = get_dev_reg(hass) - assert len(dev_reg.devices) == 0 + assert len(device_registry.devices) == 0 assert hass.states.get("weather.met_office_wavertree_3hourly") is None assert hass.states.get("weather.met_office_wavertree_daily") is None - for sensor_id in WAVERTREE_SENSOR_RESULTS: - sensor_name, _ = WAVERTREE_SENSOR_RESULTS[sensor_id] + for sensor in WAVERTREE_SENSOR_RESULTS.values(): + sensor_name = sensor[0] sensor = hass.states.get(f"sensor.wavertree_{sensor_name}") assert sensor is None @@ -103,7 +102,6 @@ async def test_site_cannot_connect( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_update( hass: HomeAssistant, - entity_registry: er.EntityRegistry, requests_mock: requests_mock.Mocker, wavertree_data, ) -> None: @@ -134,7 +132,7 @@ async def test_site_cannot_update( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_weather_site_running( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, requests_mock: requests_mock.Mocker, wavertree_data, ) -> None: @@ -148,9 +146,10 @@ async def test_one_weather_site_running( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - dev_reg = get_dev_reg(hass) - assert len(dev_reg.devices) == 1 - device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + assert len(device_registry.devices) == 1 + device_wavertree = device_registry.async_get_device( + identifiers=DEVICE_KEY_WAVERTREE + ) assert device_wavertree.name == "Met Office Wavertree" # Wavertree daily weather platform expected results @@ -167,7 +166,7 @@ async def test_one_weather_site_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_weather_sites_running( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, requests_mock: requests_mock.Mocker, wavertree_data, ) -> None: @@ -199,11 +198,14 @@ async def test_two_weather_sites_running( await hass.config_entries.async_setup(entry2.entry_id) await hass.async_block_till_done() - dev_reg = get_dev_reg(hass) - assert len(dev_reg.devices) == 2 - device_kingslynn = dev_reg.async_get_device(identifiers=DEVICE_KEY_KINGSLYNN) + assert len(device_registry.devices) == 2 + device_kingslynn = device_registry.async_get_device( + identifiers=DEVICE_KEY_KINGSLYNN + ) assert device_kingslynn.name == "Met Office King's Lynn" - device_wavertree = dev_reg.async_get_device(identifiers=DEVICE_KEY_WAVERTREE) + device_wavertree = device_registry.async_get_device( + identifiers=DEVICE_KEY_WAVERTREE + ) assert device_wavertree.name == "Met Office Wavertree" # Wavertree daily weather platform expected results @@ -251,10 +253,7 @@ async def test_new_config_entry( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_forecast_service( hass: HomeAssistant, @@ -371,7 +370,6 @@ async def test_legacy_config_entry_is_removed( async def test_forecast_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, no_sensor, diff --git a/tests/components/microbees/test_config_flow.py b/tests/components/microbees/test_config_flow.py index 327d0214f7a..d168dcd5017 100644 --- a/tests/components/microbees/test_config_flow.py +++ b/tests/components/microbees/test_config_flow.py @@ -19,10 +19,10 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, aioclient_mock: AiohttpClientMocker, microbees: AsyncMock, ) -> None: @@ -80,10 +80,10 @@ async def test_full_flow( assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, microbees: AsyncMock, config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, @@ -133,13 +133,13 @@ async def test_config_non_unique_profile( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_reauth_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, microbees: AsyncMock, - current_request_with_host, ) -> None: """Test reauth an existing profile reauthenticates the config entry.""" await setup_integration(hass, config_entry) @@ -194,13 +194,13 @@ async def test_config_reauth_profile( assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, microbees: AsyncMock, - current_request_with_host, ) -> None: """Test reauth with wrong account.""" await setup_integration(hass, config_entry) @@ -255,12 +255,12 @@ async def test_config_reauth_wrong_account( assert result["reason"] == "wrong_account" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_flow_with_invalid_credentials( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, microbees: AsyncMock, - current_request_with_host, ) -> None: """Test flow with invalid credentials.""" result = await hass.config_entries.flow.async_init( @@ -310,6 +310,7 @@ async def test_config_flow_with_invalid_credentials( (Exception("Unexpected error"), "unknown"), ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_unexpected_exceptions( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -318,7 +319,6 @@ async def test_unexpected_exceptions( microbees: AsyncMock, exception: Exception, error: str, - current_request_with_host, ) -> None: """Test unknown error from server.""" await setup_integration(hass, config_entry) diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index c395dc82419..082def901c5 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -1,6 +1,7 @@ """Tests for Microsoft text-to-speech.""" from http import HTTPStatus +from pathlib import Path from unittest.mock import patch from pycsspeechtts import pycsspeechtts @@ -14,7 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.microsoft.tts import SUPPORTED_LANGUAGES from homeassistant.config import async_process_ha_core_config -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceNotFound from homeassistant.setup import async_setup_component @@ -24,13 +25,13 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir @pytest.fixture -async def calls(hass: HomeAssistant): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Mock media player calls.""" return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -54,7 +55,10 @@ def mock_tts(): async def test_service_say( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts, + calls: list[ServiceCall], ) -> None: """Test service call say.""" @@ -95,7 +99,10 @@ async def test_service_say( async def test_service_say_en_gb_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts, + calls: list[ServiceCall], ) -> None: """Test service call say with en-gb code in the config.""" @@ -144,7 +151,10 @@ async def test_service_say_en_gb_config( async def test_service_say_en_gb_service( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts, + calls: list[ServiceCall], ) -> None: """Test service call say with en-gb code in the service.""" @@ -188,7 +198,10 @@ async def test_service_say_en_gb_service( async def test_service_say_fa_ir_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts, + calls: list[ServiceCall], ) -> None: """Test service call say with fa-ir code in the config.""" @@ -237,7 +250,10 @@ async def test_service_say_fa_ir_config( async def test_service_say_fa_ir_service( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts, + calls: list[ServiceCall], ) -> None: """Test service call say with fa-ir code in the service.""" @@ -286,22 +302,24 @@ async def test_service_say_fa_ir_service( def test_supported_languages() -> None: """Test list of supported languages.""" - for lang in ["en-us", "fa-ir", "en-gb"]: + for lang in ("en-us", "fa-ir", "en-gb"): assert lang in SUPPORTED_LANGUAGES assert "en-US" not in SUPPORTED_LANGUAGES - for lang in [ + for lang in ( "en", "en-uk", "english", "english (united states)", "jennyneural", "en-us-jennyneural", - ]: + ): assert lang not in {s.lower() for s in SUPPORTED_LANGUAGES} assert len(SUPPORTED_LANGUAGES) > 100 -async def test_invalid_language(hass: HomeAssistant, mock_tts, calls) -> None: +async def test_invalid_language( + hass: HomeAssistant, mock_tts, calls: list[ServiceCall] +) -> None: """Test setup component with invalid language.""" await async_setup_component( hass, @@ -326,7 +344,10 @@ async def test_invalid_language(hass: HomeAssistant, mock_tts, calls) -> None: async def test_service_say_error( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts, calls + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts, + calls: list[ServiceCall], ) -> None: """Test service call say with http error.""" mock_tts.return_value.speak.side_effect = pycsspeechtts.requests.HTTPError diff --git a/tests/components/microsoft_face_detect/test_image_processing.py b/tests/components/microsoft_face_detect/test_image_processing.py index 0c0bcb59c0b..7525663143f 100644 --- a/tests/components/microsoft_face_detect/test_image_processing.py +++ b/tests/components/microsoft_face_detect/test_image_processing.py @@ -4,8 +4,8 @@ from unittest.mock import PropertyMock, patch import pytest -import homeassistant.components.image_processing as ip -import homeassistant.components.microsoft_face as mf +from homeassistant.components.image_processing import DOMAIN as IP_DOMAIN +from homeassistant.components.microsoft_face import DOMAIN as MF_DOMAIN, FACE_API_URL from homeassistant.const import ATTR_ENTITY_PICTURE from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component @@ -15,16 +15,16 @@ from tests.components.image_processing import common from tests.test_util.aiohttp import AiohttpClientMocker CONFIG = { - ip.DOMAIN: { + IP_DOMAIN: { "platform": "microsoft_face_detect", "source": {"entity_id": "camera.demo_camera", "name": "test local"}, "attributes": ["age", "gender"], }, "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, + MF_DOMAIN: {"api_key": "12345678abcdef6"}, } -ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}" +ENDPOINT_URL = f"https://westus.{FACE_API_URL}" @pytest.fixture(autouse=True) @@ -57,17 +57,17 @@ def poll_mock(): async def test_setup_platform(hass: HomeAssistant, store_mock) -> None: """Set up platform with one entity.""" config = { - ip.DOMAIN: { + IP_DOMAIN: { "platform": "microsoft_face_detect", "source": {"entity_id": "camera.demo_camera"}, "attributes": ["age", "gender"], }, "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, + MF_DOMAIN: {"api_key": "12345678abcdef6"}, } - with assert_setup_component(1, ip.DOMAIN): - await async_setup_component(hass, ip.DOMAIN, config) + with assert_setup_component(1, IP_DOMAIN): + await async_setup_component(hass, IP_DOMAIN, config) await hass.async_block_till_done() assert hass.states.get("image_processing.microsoftface_demo_camera") @@ -76,16 +76,16 @@ async def test_setup_platform(hass: HomeAssistant, store_mock) -> None: async def test_setup_platform_name(hass: HomeAssistant, store_mock) -> None: """Set up platform with one entity and set name.""" config = { - ip.DOMAIN: { + IP_DOMAIN: { "platform": "microsoft_face_detect", "source": {"entity_id": "camera.demo_camera", "name": "test local"}, }, "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, + MF_DOMAIN: {"api_key": "12345678abcdef6"}, } - with assert_setup_component(1, ip.DOMAIN): - await async_setup_component(hass, ip.DOMAIN, config) + with assert_setup_component(1, IP_DOMAIN): + await async_setup_component(hass, IP_DOMAIN, config) await hass.async_block_till_done() assert hass.states.get("image_processing.test_local") @@ -108,7 +108,7 @@ async def test_ms_detect_process_image( text=load_fixture("persons.json", "microsoft_face_detect"), ) - await async_setup_component(hass, ip.DOMAIN, CONFIG) + await async_setup_component(hass, IP_DOMAIN, CONFIG) await hass.async_block_till_done() state = hass.states.get("camera.demo_camera") diff --git a/tests/components/microsoft_face_identify/test_image_processing.py b/tests/components/microsoft_face_identify/test_image_processing.py index 6258448dd05..1f162e0eb9b 100644 --- a/tests/components/microsoft_face_identify/test_image_processing.py +++ b/tests/components/microsoft_face_identify/test_image_processing.py @@ -4,8 +4,8 @@ from unittest.mock import PropertyMock, patch import pytest -import homeassistant.components.image_processing as ip -import homeassistant.components.microsoft_face as mf +from homeassistant.components.image_processing import DOMAIN as IP_DOMAIN +from homeassistant.components.microsoft_face import DOMAIN as MF_DOMAIN, FACE_API_URL from homeassistant.const import ATTR_ENTITY_PICTURE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component @@ -43,32 +43,32 @@ def poll_mock(): CONFIG = { - ip.DOMAIN: { + IP_DOMAIN: { "platform": "microsoft_face_identify", "source": {"entity_id": "camera.demo_camera", "name": "test local"}, "group": "Test Group1", }, "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, + MF_DOMAIN: {"api_key": "12345678abcdef6"}, } -ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}" +ENDPOINT_URL = f"https://westus.{FACE_API_URL}" async def test_setup_platform(hass: HomeAssistant, store_mock) -> None: """Set up platform with one entity.""" config = { - ip.DOMAIN: { + IP_DOMAIN: { "platform": "microsoft_face_identify", "source": {"entity_id": "camera.demo_camera"}, "group": "Test Group1", }, "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, + MF_DOMAIN: {"api_key": "12345678abcdef6"}, } - with assert_setup_component(1, ip.DOMAIN): - await async_setup_component(hass, ip.DOMAIN, config) + with assert_setup_component(1, IP_DOMAIN): + await async_setup_component(hass, IP_DOMAIN, config) await hass.async_block_till_done() assert hass.states.get("image_processing.microsoftface_demo_camera") @@ -77,17 +77,17 @@ async def test_setup_platform(hass: HomeAssistant, store_mock) -> None: async def test_setup_platform_name(hass: HomeAssistant, store_mock) -> None: """Set up platform with one entity and set name.""" config = { - ip.DOMAIN: { + IP_DOMAIN: { "platform": "microsoft_face_identify", "source": {"entity_id": "camera.demo_camera", "name": "test local"}, "group": "Test Group1", }, "camera": {"platform": "demo"}, - mf.DOMAIN: {"api_key": "12345678abcdef6"}, + MF_DOMAIN: {"api_key": "12345678abcdef6"}, } - with assert_setup_component(1, ip.DOMAIN): - await async_setup_component(hass, ip.DOMAIN, config) + with assert_setup_component(1, IP_DOMAIN): + await async_setup_component(hass, IP_DOMAIN, config) await hass.async_block_till_done() assert hass.states.get("image_processing.test_local") @@ -110,7 +110,7 @@ async def test_ms_identify_process_image( text=load_fixture("persons.json", "microsoft_face_identify"), ) - await async_setup_component(hass, ip.DOMAIN, CONFIG) + await async_setup_component(hass, IP_DOMAIN, CONFIG) await hass.async_block_till_done() state = hass.states.get("camera.demo_camera") diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index ad8521c7787..36278573ec3 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -210,7 +210,7 @@ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: with ( patch("librouteros.connect"), - patch.object(mikrotik.hub.MikrotikData, "command", new=mock_command), + patch.object(mikrotik.coordinator.MikrotikData, "command", new=mock_command), ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 1eec2132a91..f07f773f7b8 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -31,9 +31,10 @@ from tests.common import MockConfigEntry, async_fire_time_changed, patch @pytest.fixture -def mock_device_registry_devices(hass: HomeAssistant) -> None: +def mock_device_registry_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Create device registry devices so the device tracker entities are enabled.""" - dev_reg = dr.async_get(hass) config_entry = MockConfigEntry(domain="something_else") config_entry.add_to_hass(hass) @@ -45,7 +46,7 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: "00:00:00:00:00:04", ) ): - dev_reg.async_get_or_create( + device_registry.async_get_or_create( name=f"Device {idx}", config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, device)}, @@ -82,7 +83,7 @@ async def test_device_trackers( device_2 = hass.states.get("device_tracker.device_2") assert device_2 is None - with patch.object(mikrotik.hub.MikrotikData, "command", new=mock_command): + with patch.object(mikrotik.coordinator.MikrotikData, "command", new=mock_command): # test device_2 is added after connecting to wireless network WIRELESS_DATA.append(DEVICE_2_WIRELESS) @@ -150,7 +151,9 @@ async def test_arp_ping_success( ) -> None: """Test arp ping devices to confirm they are connected.""" - with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=True): + with patch.object( + mikrotik.coordinator.MikrotikData, "do_arp_ping", return_value=True + ): await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) # test wired device_2 show as home if arp ping returns True @@ -163,7 +166,9 @@ async def test_arp_ping_timeout( hass: HomeAssistant, mock_device_registry_devices ) -> None: """Test arp ping timeout so devices are shown away.""" - with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=False): + with patch.object( + mikrotik.coordinator.MikrotikData, "do_arp_ping", return_value=False + ): await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) # test wired device_2 show as not_home if arp ping times out @@ -262,7 +267,9 @@ async def test_update_failed(hass: HomeAssistant, mock_device_registry_devices) await setup_mikrotik_entry(hass) with patch.object( - mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect + mikrotik.coordinator.MikrotikData, + "command", + side_effect=mikrotik.errors.CannotConnect, ): async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index cc6a737e75a..97245480300 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -6,7 +6,6 @@ from librouteros.exceptions import ConnectionClosed, LibRouterosError import pytest from homeassistant.components import mikrotik -from homeassistant.components.mikrotik.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -84,4 +83,3 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert entry.entry_id not in hass.data[DOMAIN] diff --git a/tests/components/min_max/test_config_flow.py b/tests/components/min_max/test_config_flow.py index 4a408524d09..93f8426e428 100644 --- a/tests/components/min_max/test_config_flow.py +++ b/tests/components/min_max/test_config_flow.py @@ -63,7 +63,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") @pytest.mark.parametrize("platform", ["sensor"]) diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py index ef8a9d960f6..d34db5114cc 100644 --- a/tests/components/minecraft_server/conftest.py +++ b/tests/components/minecraft_server/conftest.py @@ -13,7 +13,7 @@ from tests.common import MockConfigEntry @pytest.fixture def java_mock_config_entry() -> MockConfigEntry: - """Create YouTube entry in Home Assistant.""" + """Create Java Edition mock config entry.""" return MockConfigEntry( domain=DOMAIN, unique_id=None, @@ -29,7 +29,7 @@ def java_mock_config_entry() -> MockConfigEntry: @pytest.fixture def bedrock_mock_config_entry() -> MockConfigEntry: - """Create YouTube entry in Home Assistant.""" + """Create Bedrock Edition mock config entry.""" return MockConfigEntry( domain=DOMAIN, unique_id=None, diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 21136ac0815..41817986bcf 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -19,6 +19,8 @@ from .const import ( TEST_PORT, ) +from tests.common import MockConfigEntry + USER_INPUT = { CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, @@ -35,6 +37,29 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" +async def test_service_already_configured( + hass: HomeAssistant, bedrock_mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if service is already configured.""" + bedrock_mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), + ), + patch( + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_address_validation_failure(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with ( diff --git a/tests/components/mjpeg/conftest.py b/tests/components/mjpeg/conftest.py index e10c267d718..00eaf946113 100644 --- a/tests/components/mjpeg/conftest.py +++ b/tests/components/mjpeg/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from requests_mock import Mocker +from typing_extensions import Generator from homeassistant.components.mjpeg.const import ( CONF_MJPEG_URL, @@ -44,7 +44,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.mjpeg.async_setup_entry", return_value=True @@ -53,7 +53,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_reload_entry() -> Generator[AsyncMock, None, None]: +def mock_reload_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch("homeassistant.components.mjpeg.async_reload_entry") as mock_reload: yield mock_reload diff --git a/tests/components/moat/conftest.py b/tests/components/moat/conftest.py index 1f7f00c8d2f..2161d304d63 100644 --- a/tests/components/moat/conftest.py +++ b/tests/components/moat/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index aa53c4c6136..657b80a759a 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -2,13 +2,17 @@ from http import HTTPStatus +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.mobile_app.const import DOMAIN +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .const import REGISTER, REGISTER_CLEARTEXT +from tests.typing import ClientSessionGenerator + @pytest.fixture async def create_registrations(hass, webhook_client): @@ -53,7 +57,9 @@ async def push_registration(hass, webhook_client): @pytest.fixture -async def webhook_client(hass, hass_client): +async def webhook_client( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Provide an authenticated client for mobile_app to use.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py index 21d4d80c791..e3e2ce3227a 100644 --- a/tests/components/mobile_app/test_device_tracker.py +++ b/tests/components/mobile_app/test_device_tracker.py @@ -104,7 +104,9 @@ async def test_restoring_location( # mobile app doesn't support unloading, so we just reload device tracker await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - await hass.config_entries.async_forward_entry_setup(config_entry, "device_tracker") + await hass.config_entries.async_forward_entry_setups( + config_entry, ["device_tracker"] + ) await hass.async_block_till_done() state_2 = hass.states.get("device_tracker.test_1_2") diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index d080b7a5106..b333f91d985 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -5,7 +5,8 @@ from http import HTTPStatus import json from unittest.mock import patch -import pytest +from nacl.encoding import Base64Encoder +from nacl.secret import SecretBox from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN from homeassistant.const import CONF_WEBHOOK_ID @@ -66,13 +67,6 @@ async def test_registration_encryption( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test that registrations happen.""" - try: - from nacl.encoding import Base64Encoder - from nacl.secret import SecretBox - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) api_client = await hass_client() @@ -111,13 +105,6 @@ async def test_registration_encryption_legacy( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: """Test that registrations happen.""" - try: - from nacl.encoding import Base64Encoder - from nacl.secret import SecretBox - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) api_client = await hass_client() diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index dacaba32e16..57f7933b00f 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -10,13 +10,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockUser from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @pytest.fixture -async def setup_push_receiver(hass, aioclient_mock, hass_admin_user): +async def setup_push_receiver( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_admin_user: MockUser +) -> None: """Fixture that sets up a mocked push receiver.""" push_url = "https://mobile-push.home-assistant.dev/push" @@ -108,7 +110,9 @@ async def setup_push_receiver(hass, aioclient_mock, hass_admin_user): @pytest.fixture -async def setup_websocket_channel_only_push(hass, hass_admin_user): +async def setup_websocket_channel_only_push( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: """Set up local push.""" entry = MockConfigEntry( data={ diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index f39c963b45b..ca5c9936409 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -2,8 +2,11 @@ from binascii import unhexlify from http import HTTPStatus +import json from unittest.mock import ANY, patch +from nacl.encoding import Base64Encoder +from nacl.secret import SecretBox import pytest from homeassistant.components.camera import CameraEntityFeature @@ -24,6 +27,7 @@ from homeassistant.setup import async_setup_component from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE from tests.common import async_capture_events, async_mock_service +from tests.components.conversation import MockAgent @pytest.fixture @@ -34,15 +38,6 @@ async def homeassistant(hass): def encrypt_payload(secret_key, payload, encode_json=True): """Return a encrypted payload given a key and dictionary of data.""" - try: - from nacl.encoding import Base64Encoder - from nacl.secret import SecretBox - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - prepped_key = unhexlify(secret_key) if encode_json: @@ -56,15 +51,6 @@ def encrypt_payload(secret_key, payload, encode_json=True): def encrypt_payload_legacy(secret_key, payload, encode_json=True): """Return a encrypted payload given a key and dictionary of data.""" - try: - from nacl.encoding import Base64Encoder - from nacl.secret import SecretBox - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - keylen = SecretBox.KEY_SIZE prepped_key = secret_key.encode("utf-8") prepped_key = prepped_key[:keylen] @@ -81,15 +67,6 @@ def encrypt_payload_legacy(secret_key, payload, encode_json=True): def decrypt_payload(secret_key, encrypted_data): """Return a decrypted payload given a key and a string of encrypted data.""" - try: - from nacl.encoding import Base64Encoder - from nacl.secret import SecretBox - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - prepped_key = unhexlify(secret_key) decrypted_data = SecretBox(prepped_key).decrypt( @@ -102,15 +79,6 @@ def decrypt_payload(secret_key, encrypted_data): def decrypt_payload_legacy(secret_key, encrypted_data): """Return a decrypted payload given a key and a string of encrypted data.""" - try: - from nacl.encoding import Base64Encoder - from nacl.secret import SecretBox - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - keylen = SecretBox.KEY_SIZE prepped_key = secret_key.encode("utf-8") prepped_key = prepped_key[:keylen] @@ -1027,7 +995,7 @@ async def test_webhook_handle_conversation_process( homeassistant, create_registrations, webhook_client, - mock_conversation_agent, + mock_conversation_agent: MockAgent, ) -> None: """Test that we can converse.""" webhook_client.server.app.router._frozen = False diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 1253a856bbf..067fb2d123d 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -4,6 +4,7 @@ import copy from dataclasses import dataclass from datetime import timedelta import logging +from typing import Any from unittest import mock from freezegun.api import FrozenDateTimeFactory @@ -117,7 +118,12 @@ def mock_pymodbus_fixture(do_exception, register_words): @pytest.fixture(name="mock_modbus") async def mock_modbus_fixture( - hass, caplog, check_config_loaded, config_addon, do_config, mock_pymodbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + check_config_loaded, + config_addon, + do_config, + mock_pymodbus, ): """Load integration modbus using mocked pymodbus.""" conf = copy.deepcopy(do_config) @@ -177,7 +183,9 @@ async def do_next_cycle( @pytest.fixture(name="mock_test_state") -async def mock_test_state_fixture(hass, request): +async def mock_test_state_fixture( + hass: HomeAssistant, request: pytest.FixtureRequest +) -> Any: """Mock restore cache.""" mock_restore_cache(hass, request.param) return request.param @@ -192,6 +200,6 @@ async def mock_modbus_ha_fixture(hass, mock_modbus): @pytest.fixture(name="caplog_setup_text") -async def caplog_setup_text_fixture(caplog): +async def caplog_setup_text_fixture(caplog: pytest.LogCaptureFixture) -> str: """Return setup log of integration.""" return caplog.text diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 7ae933998cf..6aae0e7feae 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -291,7 +291,7 @@ async def test_config_virtual_binary_sensor(hass: HomeAssistant, mock_modbus) -> """Run config test for binary sensor.""" assert SENSOR_DOMAIN in hass.config.components - for addon in ["", " 1", " 2", " 3"]: + for addon in ("", " 1", " 2", " 3"): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}{addon}".replace(" ", "_") assert hass.states.get(entity_id) is not None diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 94778cdcbd2..a52285b22d7 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -2,14 +2,14 @@ import pytest -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.climate.const import ( +from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_SWING_MODE, ATTR_SWING_MODES, + DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, FAN_DIFFUSE, FAN_FOCUS, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 82c65576f02..920003ad0c9 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -136,7 +136,9 @@ from tests.common import async_fire_time_changed, get_fixture_path @pytest.fixture(name="mock_modbus_with_pymodbus") -async def mock_modbus_with_pymodbus_fixture(hass, caplog, do_config, mock_pymodbus): +async def mock_modbus_with_pymodbus_fixture( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, do_config, mock_pymodbus +): """Load integration modbus using mocked pymodbus.""" caplog.clear() caplog.set_level(logging.ERROR) @@ -1361,12 +1363,12 @@ async def test_pb_service_write( @pytest.fixture(name="mock_modbus_read_pymodbus") async def mock_modbus_read_pymodbus_fixture( - hass, + hass: HomeAssistant, do_group, do_type, do_scan_interval, do_return, - caplog, + caplog: pytest.LogCaptureFixture, mock_pymodbus, freezer: FrozenDateTimeFactory, ): diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 71cb64cc1b6..20ff558fce6 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -901,7 +901,7 @@ async def test_virtual_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_do_cycle, expected ) -> None: """Run test for sensor.""" - for i in range(len(expected)): + for i, expected_value in enumerate(expected): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") unique_id = f"{SLAVE_UNIQUE_ID}" if i: @@ -909,7 +909,7 @@ async def test_virtual_sensor( unique_id = f"{unique_id}_{i}" entry = entity_registry.async_get(entity_id) state = hass.states.get(entity_id).state - assert state == expected[i] + assert state == expected_value assert entry.unique_id == unique_id @@ -1071,12 +1071,12 @@ async def test_virtual_swap_sensor( hass: HomeAssistant, mock_do_cycle, expected ) -> None: """Run test for sensor.""" - for i in range(len(expected)): + for i, expected_value in enumerate(expected): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") if i: entity_id = f"{entity_id}_{i}" state = hass.states.get(entity_id).state - assert state == expected[i] + assert state == expected_value @pytest.mark.parametrize( diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 56c293b241a..4c39f83f688 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -102,7 +102,7 @@ async def test_full_zeroconf_flow_implementation( @patch( - "homeassistant.components.modern_forms.ModernFormsDevice.update", + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update", side_effect=ModernFormsConnectionError, ) async def test_connection_error( @@ -123,7 +123,7 @@ async def test_connection_error( @patch( - "homeassistant.components.modern_forms.ModernFormsDevice.update", + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update", side_effect=ModernFormsConnectionError, ) async def test_zeroconf_connection_error( @@ -151,7 +151,7 @@ async def test_zeroconf_connection_error( @patch( - "homeassistant.components.modern_forms.ModernFormsDevice.update", + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update", side_effect=ModernFormsConnectionError, ) async def test_zeroconf_confirm_connection_error( diff --git a/tests/components/modern_forms/test_fan.py b/tests/components/modern_forms/test_fan.py index 82ab6407c12..a1558be981c 100644 --- a/tests/components/modern_forms/test_fan.py +++ b/tests/components/modern_forms/test_fan.py @@ -191,7 +191,9 @@ async def test_fan_error( aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=400) - with patch("homeassistant.components.modern_forms.ModernFormsDevice.update"): + with patch( + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update" + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, @@ -211,9 +213,11 @@ async def test_fan_connection_error( await init_integration(hass, aioclient_mock) with ( - patch("homeassistant.components.modern_forms.ModernFormsDevice.update"), patch( - "homeassistant.components.modern_forms.ModernFormsDevice.fan", + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update" + ), + patch( + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.fan", side_effect=ModernFormsConnectionError, ), ): diff --git a/tests/components/modern_forms/test_init.py b/tests/components/modern_forms/test_init.py index 4f146dfcea5..0fb7c1d2931 100644 --- a/tests/components/modern_forms/test_init.py +++ b/tests/components/modern_forms/test_init.py @@ -15,7 +15,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @patch( - "homeassistant.components.modern_forms.ModernFormsDevice.update", + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update", side_effect=ModernFormsConnectionError, ) async def test_config_entry_not_ready( diff --git a/tests/components/modern_forms/test_light.py b/tests/components/modern_forms/test_light.py index 3b1cfdd90d2..0fa2a53f447 100644 --- a/tests/components/modern_forms/test_light.py +++ b/tests/components/modern_forms/test_light.py @@ -119,7 +119,9 @@ async def test_light_error( aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=400) - with patch("homeassistant.components.modern_forms.ModernFormsDevice.update"): + with patch( + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update" + ): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -139,9 +141,11 @@ async def test_light_connection_error( await init_integration(hass, aioclient_mock) with ( - patch("homeassistant.components.modern_forms.ModernFormsDevice.update"), patch( - "homeassistant.components.modern_forms.ModernFormsDevice.light", + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update" + ), + patch( + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.light", side_effect=ModernFormsConnectionError, ), ): diff --git a/tests/components/modern_forms/test_switch.py b/tests/components/modern_forms/test_switch.py index 8a2012bbd5f..d9e5443c06b 100644 --- a/tests/components/modern_forms/test_switch.py +++ b/tests/components/modern_forms/test_switch.py @@ -110,7 +110,9 @@ async def test_switch_error( aioclient_mock.clear_requests() aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=400) - with patch("homeassistant.components.modern_forms.ModernFormsDevice.update"): + with patch( + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update" + ): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -131,9 +133,11 @@ async def test_switch_connection_error( await init_integration(hass, aioclient_mock) with ( - patch("homeassistant.components.modern_forms.ModernFormsDevice.update"), patch( - "homeassistant.components.modern_forms.ModernFormsDevice.away", + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.update" + ), + patch( + "homeassistant.components.modern_forms.coordinator.ModernFormsDevice.away", side_effect=ModernFormsConnectionError, ), ): diff --git a/tests/components/moehlenhoff_alpha2/__init__.py b/tests/components/moehlenhoff_alpha2/__init__.py index 76bd1fd00aa..50087794560 100644 --- a/tests/components/moehlenhoff_alpha2/__init__.py +++ b/tests/components/moehlenhoff_alpha2/__init__.py @@ -1 +1,41 @@ """Tests for the moehlenhoff_alpha2 integration.""" + +from unittest.mock import patch + +import xmltodict + +from homeassistant.components.moehlenhoff_alpha2.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +MOCK_BASE_HOST = "fake-base-host" + + +async def mock_update_data(self): + """Mock Alpha2Base.update_data.""" + data = xmltodict.parse(load_fixture("static2.xml", DOMAIN)) + for _type in ("HEATAREA", "HEATCTRL", "IODEVICE"): + if not isinstance(data["Devices"]["Device"][_type], list): + data["Devices"]["Device"][_type] = [data["Devices"]["Device"][_type]] + self.static_data = data + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock integration setup.""" + with patch( + "homeassistant.components.moehlenhoff_alpha2.coordinator.Alpha2Base.update_data", + mock_update_data, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCK_BASE_HOST, + }, + entry_id="6fa019921cf8e7a3f57a3c2ed001a10d", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/moehlenhoff_alpha2/fixtures/static2.xml b/tests/components/moehlenhoff_alpha2/fixtures/static2.xml new file mode 100644 index 00000000000..9ac21ba4bd8 --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/fixtures/static2.xml @@ -0,0 +1,268 @@ + + + Alpha2Test + EZRCTRL1 + Alpha2Test + Alpha2Test + 03E8 + 0 + 2021-03-28T22:32:01 + 7 + 1 + 1 + 02.02 + 02.10 + 01 + 0 + 1 + 0 + 0 + MASTERID + 0 + 0 + 0 + 0 + 1 + 8.0 + 10 + 0 + ? + 2.0 + 0 + 0 + 16.0 + + 0 + 2021-00-00 + 12:00:00 + 2021-00-00 + 12:00:00 + + + 88:EE:10:01:10:01 + 1 + 0 + 192.168.130.171 + 192.168.100.100 + + + 255.255.255.0 + 255.255.255.0 + 192.168.130.10 + 192.168.130.1 + + + 4724520342C455A5 + 406AEFC55B49673275B4A526E1E903 + 55555 + 53900 + 53900 + 57995 + www.ezr-cloud1.de + 1 + Online + + + 0 + 0 + 0 + --- + 7777 + 0 + 0 + + + 42BA517ADAE755A4 + + + + 05:30 + 21:00 + + + 04:30 + 08:30 + + + 17:30 + 21:30 + + + 06:30 + 10:00 + + + 18:00 + 22:30 + + + 07:30 + 17:30 + + + + 0 + 0 + 0 + 2 + 2 + 0 + 30 + 20 + + + 0 + 1 + 0 + 0 + 0 + ? + + + 0 + + + 180 + 15 + 25 + 0 + + + 14 + 5 + + + 3 + 5 + + + Büro + 1 + 21.1 + 21.1 + 21.0 + 0.2 + 0 + 0 + 2 + 0 + 0 + 0 + 0 + 5.0 + 30.0 + 0 + 0.0 + 21.0 + 19.0 + 21.0 + 23.0 + 3.0 + 21.0 + 0 + 0 + 0 + BEF20EE23B04455A5C + 0 + 0 + 0 + 1 + + + 1 + 1 + 1 + 28 + 1 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 0 + 0 + 0 + 0 + + + 0 + 1 + 1 + 02.10 + 1 + 2 + 2 + 0 + 0 + 1 + + + \ No newline at end of file diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..dc6680ff99a --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.buro_io_device_1_battery-entry] + 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.buro_io_device_1_battery', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Büro IO device 1 battery', + 'platform': 'moehlenhoff_alpha2', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Alpha2Test:1:battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.buro_io_device_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Büro IO device 1 battery', + }), + 'context': , + 'entity_id': 'binary_sensor.buro_io_device_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr new file mode 100644 index 00000000000..7dfb9edb2e8 --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_buttons[button.sync_time-entry] + 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.sync_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync time', + 'platform': 'moehlenhoff_alpha2', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6fa019921cf8e7a3f57a3c2ed001a10d:sync_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.sync_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sync time', + }), + 'context': , + 'entity_id': 'button.sync_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr new file mode 100644 index 00000000000..c1a63271a33 --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_climate[climate.buro-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'preset_modes': list([ + 'auto', + 'day', + 'night', + ]), + 'target_temp_step': 0.2, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.buro', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Büro', + 'platform': 'moehlenhoff_alpha2', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'Alpha2Test:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.buro-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.1, + 'friendly_name': 'Büro', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'preset_mode': 'day', + 'preset_modes': list([ + 'auto', + 'day', + 'night', + ]), + 'supported_features': , + 'target_temp_step': 0.2, + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.buro', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3fee26a6ed5 --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_sensors[sensor.buro_heat_control_1_valve_opening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.buro_heat_control_1_valve_opening', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Büro heat control 1 valve opening', + 'platform': 'moehlenhoff_alpha2', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Alpha2Test:1:valve_opening', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.buro_heat_control_1_valve_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Büro heat control 1 valve opening', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.buro_heat_control_1_valve_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- diff --git a/tests/components/moehlenhoff_alpha2/test_binary_sensor.py b/tests/components/moehlenhoff_alpha2/test_binary_sensor.py new file mode 100644 index 00000000000..e650e9f9ba6 --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/test_binary_sensor.py @@ -0,0 +1,28 @@ +"""Tests for the Moehlenhoff Alpha2 binary sensors.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensors.""" + with patch( + "homeassistant.components.moehlenhoff_alpha2.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/moehlenhoff_alpha2/test_button.py b/tests/components/moehlenhoff_alpha2/test_button.py new file mode 100644 index 00000000000..d4465746d53 --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/test_button.py @@ -0,0 +1,28 @@ +"""Tests for the Moehlenhoff Alpha2 buttons.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test buttons.""" + with patch( + "homeassistant.components.moehlenhoff_alpha2.PLATFORMS", + [Platform.BUTTON], + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/moehlenhoff_alpha2/test_climate.py b/tests/components/moehlenhoff_alpha2/test_climate.py new file mode 100644 index 00000000000..a32f2b5bd4f --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/test_climate.py @@ -0,0 +1,28 @@ +"""Tests for the Moehlenhoff Alpha2 climate.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test climate.""" + with patch( + "homeassistant.components.moehlenhoff_alpha2.PLATFORMS", + [Platform.CLIMATE], + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/moehlenhoff_alpha2/test_config_flow.py b/tests/components/moehlenhoff_alpha2/test_config_flow.py index 33c67421958..24697765901 100644 --- a/tests/components/moehlenhoff_alpha2/test_config_flow.py +++ b/tests/components/moehlenhoff_alpha2/test_config_flow.py @@ -7,21 +7,10 @@ from homeassistant.components.moehlenhoff_alpha2.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import MOCK_BASE_HOST, mock_update_data + from tests.common import MockConfigEntry -MOCK_BASE_ID = "fake-base-id" -MOCK_BASE_NAME = "fake-base-name" -MOCK_BASE_HOST = "fake-base-host" - - -async def mock_update_data(self): - """Mock moehlenhoff_alpha2.Alpha2Base.update_data.""" - self.static_data = { - "Devices": { - "Device": {"ID": MOCK_BASE_ID, "NAME": MOCK_BASE_NAME, "HEATAREA": []} - } - } - async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" @@ -33,7 +22,10 @@ async def test_form(hass: HomeAssistant) -> None: assert not result["errors"] with ( - patch("moehlenhoff_alpha2.Alpha2Base.update_data", mock_update_data), + patch( + "homeassistant.components.moehlenhoff_alpha2.config_flow.Alpha2Base.update_data", + mock_update_data, + ), patch( "homeassistant.components.moehlenhoff_alpha2.async_setup_entry", return_value=True, @@ -46,7 +38,7 @@ async def test_form(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == MOCK_BASE_NAME + assert result2["title"] == "Alpha2Test" assert result2["data"] == {"host": MOCK_BASE_HOST} assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/moehlenhoff_alpha2/test_sensor.py b/tests/components/moehlenhoff_alpha2/test_sensor.py new file mode 100644 index 00000000000..931c744faea --- /dev/null +++ b/tests/components/moehlenhoff_alpha2/test_sensor.py @@ -0,0 +1,28 @@ +"""Tests for the Moehlenhoff Alpha2 sensors.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors.""" + with patch( + "homeassistant.components.moehlenhoff_alpha2.PLATFORMS", + [Platform.SENSOR], + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/monzo/__init__.py b/tests/components/monzo/__init__.py new file mode 100644 index 00000000000..db732171521 --- /dev/null +++ b/tests/components/monzo/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Monzo integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/monzo/conftest.py b/tests/components/monzo/conftest.py new file mode 100644 index 00000000000..451fd6b409d --- /dev/null +++ b/tests/components/monzo/conftest.py @@ -0,0 +1,125 @@ +"""Fixtures for tests.""" + +import time +from unittest.mock import AsyncMock, patch + +from monzopy.monzopy import UserAccount +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.monzo.api import AuthenticatedMonzoAPI +from homeassistant.components.monzo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +TEST_ACCOUNTS = [ + { + "id": "acc_curr", + "name": "Current Account", + "type": "uk_retail", + "balance": {"balance": 123, "total_balance": 321}, + }, + { + "id": "acc_flex", + "name": "Flex", + "type": "uk_monzo_flex", + "balance": {"balance": 123, "total_balance": 321}, + }, +] +TEST_POTS = [ + { + "id": "pot_savings", + "name": "Savings", + "style": "savings", + "balance": 134578, + "currency": "GBP", + "type": "instant_access", + } +] +TITLE = "jake" +USER_ID = 12345 + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET, DOMAIN), + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture +def polling_config_entry(expires_at: int) -> MockConfigEntry: + """Create Monzo entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=str(USER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": str(USER_ID), + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 60, + "expires_at": time.time() + 1000, + }, + "profile": TITLE, + }, + ) + + +@pytest.fixture(name="basic_monzo") +def mock_basic_monzo(): + """Mock monzo with one pot.""" + + mock = AsyncMock(spec=AuthenticatedMonzoAPI) + mock_user_account = AsyncMock(spec=UserAccount) + + mock_user_account.accounts.return_value = [] + + mock_user_account.pots.return_value = TEST_POTS + + mock.user_account = mock_user_account + + with patch( + "homeassistant.components.monzo.AuthenticatedMonzoAPI", + return_value=mock, + ): + yield mock + + +@pytest.fixture(name="monzo") +def mock_monzo(): + """Mock monzo.""" + + mock = AsyncMock(spec=AuthenticatedMonzoAPI) + mock_user_account = AsyncMock(spec=UserAccount) + + mock_user_account.accounts.return_value = TEST_ACCOUNTS + mock_user_account.pots.return_value = TEST_POTS + + mock.user_account = mock_user_account + + with patch( + "homeassistant.components.monzo.AuthenticatedMonzoAPI", + return_value=mock, + ): + yield mock diff --git a/tests/components/monzo/snapshots/test_sensor.ambr b/tests/components/monzo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9be5943d35c --- /dev/null +++ b/tests/components/monzo/snapshots/test_sensor.ambr @@ -0,0 +1,261 @@ +# serializer version: 1 +# name: test_all_entities[sensor.current_account_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.current_account_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monzo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'acc_curr_balance', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_all_entities[sensor.current_account_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Current Account Balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.current_account_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_all_entities[sensor.current_account_total_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.current_account_total_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total balance', + 'platform': 'monzo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_balance', + 'unique_id': 'acc_curr_total_balance', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_all_entities[sensor.current_account_total_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Current Account Total balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.current_account_total_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.21', + }) +# --- +# name: test_all_entities[sensor.flex_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flex_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monzo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': 'acc_flex_balance', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_all_entities[sensor.flex_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Flex Balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.flex_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.23', + }) +# --- +# name: test_all_entities[sensor.flex_total_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flex_total_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total balance', + 'platform': 'monzo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_balance', + 'unique_id': 'acc_flex_total_balance', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_all_entities[sensor.flex_total_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Flex Total balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.flex_total_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.21', + }) +# --- +# name: test_all_entities[sensor.savings_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.savings_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monzo', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pot_balance', + 'unique_id': 'pot_savings_pot_balance', + 'unit_of_measurement': 'GBP', + }) +# --- +# name: test_all_entities[sensor.savings_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monzo', + 'device_class': 'monetary', + 'friendly_name': 'Savings Balance', + 'unit_of_measurement': 'GBP', + }), + 'context': , + 'entity_id': 'sensor.savings_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1345.78', + }) +# --- diff --git a/tests/components/monzo/test_config_flow.py b/tests/components/monzo/test_config_flow.py new file mode 100644 index 00000000000..b7d0de9cdc3 --- /dev/null +++ b/tests/components/monzo/test_config_flow.py @@ -0,0 +1,293 @@ +"""Tests for config flow.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from monzopy import AuthorisationExpiredError +import pytest + +from homeassistant.components.monzo.application_credentials import ( + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.components.monzo.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import setup_integration +from .conftest import CLIENT_ID, USER_ID + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}/?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": "600", + }, + ) + with patch( + "homeassistant.components.monzo.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 0 + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "await_approval_confirmation" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"confirm": True} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DOMAIN + assert "result" in result + assert result["result"].unique_id == "600" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_config_non_unique_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup a non-unique profile.""" + await setup_integration(hass, polling_config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}/?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": str(USER_ID), + }, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_config_reauth_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + polling_config_entry: MockConfigEntry, + monzo: AsyncMock, +) -> None: + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, polling_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": polling_config_entry.entry_id, + }, + data=polling_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}/?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "new-mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": str(USER_ID), + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "await_approval_confirmation" + assert polling_config_entry.data["token"]["access_token"] == "mock-access-token" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"confirm": True} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert polling_config_entry.data["token"]["access_token"] == "new-mock-access-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_config_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + polling_config_entry: MockConfigEntry, +) -> None: + """Test reauth with wrong account.""" + await setup_integration(hass, polling_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": polling_config_entry.entry_id, + }, + data=polling_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}/?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": 12346, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_api_can_trigger_reauth( + hass: HomeAssistant, + polling_config_entry: MockConfigEntry, + monzo: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, polling_config_entry) + + monzo.user_account.accounts.side_effect = AuthorisationExpiredError() + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + + assert len(flows) == 1 + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert flow["context"]["source"] == SOURCE_REAUTH diff --git a/tests/components/monzo/test_sensor.py b/tests/components/monzo/test_sensor.py new file mode 100644 index 00000000000..bf88ce14931 --- /dev/null +++ b/tests/components/monzo/test_sensor.py @@ -0,0 +1,140 @@ +"""Tests for the Monzo component.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.monzo.const import DOMAIN +from homeassistant.components.monzo.sensor import ( + ACCOUNT_SENSORS, + POT_SENSORS, + MonzoSensorEntityDescription, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import TEST_ACCOUNTS, TEST_POTS + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.typing import ClientSessionGenerator + +EXPECTED_VALUE_GETTERS = { + "balance": lambda x: x["balance"]["balance"] / 100, + "total_balance": lambda x: x["balance"]["total_balance"] / 100, + "pot_balance": lambda x: x["balance"] / 100, +} + + +async def async_get_entity_id( + hass: HomeAssistant, + acc_id: str, + description: MonzoSensorEntityDescription, +) -> str | None: + """Get an entity id for a user's attribute.""" + entity_registry = er.async_get(hass) + unique_id = f"{acc_id}_{description.key}" + + return entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id) + + +def async_assert_state_equals( + entity_id: str, + state_obj: State, + expected: Any, + description: MonzoSensorEntityDescription, +) -> None: + """Assert at given state matches what is expected.""" + assert state_obj, f"Expected entity {entity_id} to exist but it did not" + + assert state_obj.state == str(expected), ( + f"Expected {expected} but was {state_obj.state} " + f"for measure {description.name}, {entity_id}" + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_default_enabled_entities( + hass: HomeAssistant, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities enabled by default.""" + await setup_integration(hass, polling_config_entry) + + for acc in TEST_ACCOUNTS: + for sensor_description in ACCOUNT_SENSORS: + entity_id = await async_get_entity_id(hass, acc["id"], sensor_description) + assert entity_id + assert entity_registry.async_is_registered(entity_id) + + state = hass.states.get(entity_id) + assert state.state == str( + EXPECTED_VALUE_GETTERS[sensor_description.key](acc) + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_unavailable_entity( + hass: HomeAssistant, + basic_monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities enabled by default.""" + await setup_integration(hass, polling_config_entry) + basic_monzo.user_account.pots.return_value = [{"id": "pot_savings"}] + freezer.tick(timedelta(minutes=100)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + entity_id = await async_get_entity_id(hass, TEST_POTS[0]["id"], POT_SENSORS[0]) + state = hass.states.get(entity_id) + assert state.state == "unknown" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, polling_config_entry.entry_id + ) + + +async def test_update_failed( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + monzo: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test all entities.""" + await setup_integration(hass, polling_config_entry) + + monzo.user_account.accounts.side_effect = Exception + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = await async_get_entity_id( + hass, TEST_ACCOUNTS[0]["id"], ACCOUNT_SENSORS[0] + ) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/moon/conftest.py b/tests/components/moon/conftest.py index 57e957077ab..6fa54fcb603 100644 --- a/tests/components/moon/conftest.py +++ b/tests/components/moon/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import patch import pytest +from typing_extensions import Generator from homeassistant.components.moon.const import DOMAIN @@ -22,7 +22,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.moon.async_setup_entry", return_value=True): yield diff --git a/tests/components/mopeka/conftest.py b/tests/components/mopeka/conftest.py index 1d6d0fc7eb7..d231390845e 100644 --- a/tests/components/mopeka/conftest.py +++ b/tests/components/mopeka/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index 4168c3a1f63..77171b06ad6 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -72,7 +72,7 @@ TEST_INTERFACES = [ @pytest.fixture(name="motion_blinds_connect", autouse=True) -def motion_blinds_connect_fixture(mock_get_source_ip): +def motion_blinds_connect_fixture(): """Mock Motionblinds connection and entry setup.""" with ( patch( diff --git a/tests/components/motionblinds_ble/conftest.py b/tests/components/motionblinds_ble/conftest.py index ae487957302..342e958eae4 100644 --- a/tests/components/motionblinds_ble/conftest.py +++ b/tests/components/motionblinds_ble/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator TEST_MAC = "abcd" TEST_NAME = f"MOTION_{TEST_MAC.upper()}" @@ -10,7 +11,9 @@ TEST_ADDRESS = "test_adress" @pytest.fixture(name="motionblinds_ble_connect", autouse=True) -def motion_blinds_connect_fixture(enable_bluetooth): +def motion_blinds_connect_fixture( + enable_bluetooth: None, +) -> Generator[tuple[AsyncMock, Mock]]: """Mock motion blinds ble connection and entry setup.""" device = Mock() device.name = TEST_NAME diff --git a/tests/components/motionblinds_ble/test_config_flow.py b/tests/components/motionblinds_ble/test_config_flow.py index f5a988a628d..4cab12269dd 100644 --- a/tests/components/motionblinds_ble/test_config_flow.py +++ b/tests/components/motionblinds_ble/test_config_flow.py @@ -1,8 +1,9 @@ """Test the Motionblinds Bluetooth config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from motionblindsble.const import MotionBlindType +import pytest from homeassistant import config_entries from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak @@ -13,6 +14,7 @@ from homeassistant.data_entry_flow import FlowResultType from .conftest import TEST_ADDRESS, TEST_MAC, TEST_NAME +from tests.common import MockConfigEntry from tests.components.bluetooth import generate_advertisement_data, generate_ble_device TEST_BLIND_TYPE = MotionBlindType.ROLLER.name.lower() @@ -43,9 +45,8 @@ BLIND_SERVICE_INFO = BluetoothServiceInfoBleak( ) -async def test_config_flow_manual_success( - hass: HomeAssistant, motionblinds_ble_connect -) -> None: +@pytest.mark.usefixtures("motionblinds_ble_connect") +async def test_config_flow_manual_success(hass: HomeAssistant) -> None: """Successful flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -76,9 +77,8 @@ async def test_config_flow_manual_success( assert result["options"] == {} -async def test_config_flow_manual_error_invalid_mac( - hass: HomeAssistant, motionblinds_ble_connect -) -> None: +@pytest.mark.usefixtures("motionblinds_ble_connect") +async def test_config_flow_manual_error_invalid_mac(hass: HomeAssistant) -> None: """Invalid MAC code error flow manually initialized by the user.""" # Initialize @@ -122,8 +122,9 @@ async def test_config_flow_manual_error_invalid_mac( assert result["options"] == {} +@pytest.mark.usefixtures("motionblinds_ble_connect") async def test_config_flow_manual_error_no_bluetooth_adapter( - hass: HomeAssistant, motionblinds_ble_connect + hass: HomeAssistant, ) -> None: """No Bluetooth adapter error flow manually initialized by the user.""" @@ -159,7 +160,7 @@ async def test_config_flow_manual_error_no_bluetooth_adapter( async def test_config_flow_manual_error_could_not_find_motor( - hass: HomeAssistant, motionblinds_ble_connect + hass: HomeAssistant, motionblinds_ble_connect: tuple[AsyncMock, Mock] ) -> None: """Could not find motor error flow manually initialized by the user.""" @@ -207,7 +208,7 @@ async def test_config_flow_manual_error_could_not_find_motor( async def test_config_flow_manual_error_no_devices_found( - hass: HomeAssistant, motionblinds_ble_connect + hass: HomeAssistant, motionblinds_ble_connect: tuple[AsyncMock, Mock] ) -> None: """No devices found error flow manually initialized by the user.""" @@ -229,9 +230,8 @@ async def test_config_flow_manual_error_no_devices_found( assert result["reason"] == const.ERROR_NO_DEVICES_FOUND -async def test_config_flow_bluetooth_success( - hass: HomeAssistant, motionblinds_ble_connect -) -> None: +@pytest.mark.usefixtures("motionblinds_ble_connect") +async def test_config_flow_bluetooth_success(hass: HomeAssistant) -> None: """Successful bluetooth discovery flow.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, @@ -256,3 +256,34 @@ async def test_config_flow_bluetooth_success( const.CONF_BLIND_TYPE: TEST_BLIND_TYPE, } assert result["options"] == {} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test the options flow.""" + entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id="0123456789", + data={ + const.CONF_BLIND_TYPE: MotionBlindType.ROLLER, + }, + ) + entry.add_to_hass(hass) + + 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) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + const.OPTION_PERMANENT_CONNECTION: True, + const.OPTION_DISCONNECT_TIME: 10, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 32763fbed3a..ccbdc022495 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -1,10 +1,13 @@ """Test the motionEye camera.""" +from asyncio import AbstractEventLoop +from collections.abc import Callable import copy -from typing import Any, cast +from typing import cast from unittest.mock import AsyncMock, Mock, call from aiohttp import web +from aiohttp.test_utils import TestServer from aiohttp.web_exceptions import HTTPBadGateway from motioneye_client.client import ( MotionEyeClientError, @@ -63,7 +66,11 @@ from tests.common import async_fire_time_changed @pytest.fixture -def aiohttp_server(event_loop, aiohttp_server, socket_enabled): +def aiohttp_server( + event_loop: AbstractEventLoop, + aiohttp_server: Callable[[], TestServer], + socket_enabled: None, +) -> Callable[[], TestServer]: """Return aiohttp_server and allow opening sockets.""" return aiohttp_server @@ -220,7 +227,7 @@ async def test_unload_camera(hass: HomeAssistant) -> None: async def test_get_still_image_from_camera( - aiohttp_server: Any, hass: HomeAssistant + aiohttp_server: Callable[[], TestServer], hass: HomeAssistant ) -> None: """Test getting a still image.""" @@ -261,7 +268,9 @@ async def test_get_still_image_from_camera( assert image_handler.called -async def test_get_stream_from_camera(aiohttp_server: Any, hass: HomeAssistant) -> None: +async def test_get_stream_from_camera( + aiohttp_server: Callable[[], TestServer], hass: HomeAssistant +) -> None: """Test getting a stream.""" stream_handler = AsyncMock(return_value="") @@ -330,7 +339,7 @@ async def test_device_info( device = device_registry.async_get_device(identifiers={device_identifier}) assert device - assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.config_entries == [TEST_CONFIG_ENTRY_ID] assert device.identifiers == {device_identifier} assert device.manufacturer == MOTIONEYE_MANUFACTURER assert device.model == MOTIONEYE_MANUFACTURER @@ -344,7 +353,7 @@ async def test_device_info( async def test_camera_option_stream_url_template( - aiohttp_server: Any, hass: HomeAssistant + aiohttp_server: Callable[[], TestServer], hass: HomeAssistant ) -> None: """Verify camera with a stream URL template option.""" client = create_mock_motioneye_client() @@ -384,7 +393,7 @@ async def test_camera_option_stream_url_template( async def test_get_stream_from_camera_with_broken_host( - aiohttp_server: Any, hass: HomeAssistant + aiohttp_server: Callable[[], TestServer], hass: HomeAssistant ) -> None: """Test getting a stream with a broken URL (no host).""" @@ -423,7 +432,6 @@ async def test_set_text_overlay_bad_entity_identifier(hass: HomeAssistant) -> No client.reset_mock() with pytest.raises(vol.error.MultipleInvalid): await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) - await hass.async_block_till_done() async def test_set_text_overlay_bad_empty(hass: HomeAssistant) -> None: @@ -432,7 +440,6 @@ async def test_set_text_overlay_bad_empty(hass: HomeAssistant) -> None: await setup_mock_motioneye_config_entry(hass, client=client) with pytest.raises(vol.error.MultipleInvalid): await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, {}) - await hass.async_block_till_done() async def test_set_text_overlay_bad_no_left_or_right(hass: HomeAssistant) -> None: @@ -443,7 +450,6 @@ async def test_set_text_overlay_bad_no_left_or_right(hass: HomeAssistant) -> Non data = {ATTR_ENTITY_ID: TEST_CAMERA_ENTITY_ID} with pytest.raises(vol.error.MultipleInvalid): await hass.services.async_call(DOMAIN, SERVICE_SET_TEXT_OVERLAY, data) - await hass.async_block_till_done() async def test_set_text_overlay_good(hass: HomeAssistant) -> None: diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index f895ed7fcb2..f8a750d50da 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -74,7 +74,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture(autouse=True) -async def setup_media_source(hass) -> None: +async def setup_media_source(hass: HomeAssistant) -> None: """Set up media source.""" assert await async_setup_component(hass, "media_source", {}) diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py index f0b8e2f7df7..9e5b0355387 100644 --- a/tests/components/motionmount/conftest.py +++ b/tests/components/motionmount/conftest.py @@ -1,9 +1,9 @@ """Fixtures for Vogel's MotionMount integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.motionmount.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT @@ -25,7 +25,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.motionmount.async_setup_entry", return_value=True @@ -34,7 +34,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_motionmount_config_flow() -> Generator[None, MagicMock, None]: +def mock_motionmount_config_flow() -> Generator[MagicMock]: """Return a mocked MotionMount config flow.""" with patch( diff --git a/tests/components/mpd/__init__.py b/tests/components/mpd/__init__.py new file mode 100644 index 00000000000..f5ad1301c14 --- /dev/null +++ b/tests/components/mpd/__init__.py @@ -0,0 +1 @@ +"""Tests for the Music Player Daemon integration.""" diff --git a/tests/components/mpd/conftest.py b/tests/components/mpd/conftest.py new file mode 100644 index 00000000000..818f085decc --- /dev/null +++ b/tests/components/mpd/conftest.py @@ -0,0 +1,43 @@ +"""Fixtures for Music Player Daemon integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mpd.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Music Player Daemon", + domain=DOMAIN, + data={CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.mpd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_mpd_client() -> Generator[AsyncMock, None, None]: + """Return a mock for Music Player Daemon client.""" + + with patch( + "homeassistant.components.mpd.config_flow.MPDClient", + autospec=True, + ) as mpd_client: + client = mpd_client.return_value + client.password = AsyncMock() + yield client diff --git a/tests/components/mpd/test_config_flow.py b/tests/components/mpd/test_config_flow.py new file mode 100644 index 00000000000..d17bef60446 --- /dev/null +++ b/tests/components/mpd/test_config_flow.py @@ -0,0 +1,191 @@ +"""Tests for the Music Player Daemon config flow.""" + +from socket import gaierror +from unittest.mock import AsyncMock + +import mpd +import pytest + +from homeassistant.components.mpd.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mpd_client: AsyncMock, +) -> None: + """Test the happy flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Music Player Daemon" + assert result["data"] == { + CONF_HOST: "192.168.0.1", + CONF_PORT: 6600, + CONF_PASSWORD: "test123", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (TimeoutError, "cannot_connect"), + (gaierror, "cannot_connect"), + (mpd.ConnectionError, "cannot_connect"), + (OSError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mpd_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle errors correctly.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + mock_mpd_client.password.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_mpd_client.password.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_existing_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort if an entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.0.1", CONF_PORT: 6600, CONF_PASSWORD: "test123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_import_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mpd_client: AsyncMock, +) -> None: + """Test the happy flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "192.168.0.1", + CONF_PORT: 6600, + CONF_PASSWORD: "test123", + CONF_NAME: "My PC", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My PC" + assert result["data"] == { + CONF_HOST: "192.168.0.1", + CONF_PORT: 6600, + CONF_PASSWORD: "test123", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (TimeoutError, "cannot_connect"), + (gaierror, "cannot_connect"), + (mpd.ConnectionError, "cannot_connect"), + (OSError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_import_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_mpd_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle errors correctly.""" + mock_mpd_client.password.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "192.168.0.1", + CONF_PORT: 6600, + CONF_PASSWORD: "test123", + CONF_NAME: "My PC", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +async def test_existing_entry_import( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort if an entry already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: "192.168.0.1", + CONF_PORT: 6600, + CONF_PASSWORD: "test123", + CONF_NAME: "My PC", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 91ece381f6d..bc4fa2e6634 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -1,10 +1,10 @@ """Test fixtures for mqtt component.""" -from collections.abc import Generator from random import getrandbits from unittest.mock import patch import pytest +from typing_extensions import Generator from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -21,7 +21,7 @@ def temp_dir_prefix() -> str: @pytest.fixture -def mock_temp_dir(temp_dir_prefix: str) -> Generator[None, None, str]: +def mock_temp_dir(temp_dir_prefix: str) -> Generator[str]: """Mock the certificate temp directory.""" with patch( # Patch temp dir name to avoid tests fail running in parallel diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index ff78d96d37e..a90e71cebe5 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1,5 +1,6 @@ """The tests the MQTT alarm control panel component.""" +from contextlib import AbstractContextManager, contextmanager import copy import json from typing import Any @@ -37,7 +38,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .test_common import ( help_custom_config, @@ -97,6 +98,17 @@ DEFAULT_CONFIG = { } } +DEFAULT_CONFIG_CODE_NOT_REQUIRED = { + mqtt.DOMAIN: { + alarm_control_panel.DOMAIN: { + "name": "test", + "state_topic": "alarm/state", + "command_topic": "alarm/command", + "code_arm_required": False, + } + } +} + DEFAULT_CONFIG_CODE = { mqtt.DOMAIN: { alarm_control_panel.DOMAIN: { @@ -134,6 +146,12 @@ DEFAULT_CONFIG_REMOTE_CODE_TEXT = { } +@contextmanager +def does_not_raise(): + """Do not raise error.""" + yield + + @pytest.mark.parametrize( ("hass_config", "valid"), [ @@ -209,6 +227,14 @@ async def test_update_state_via_state_topic( async_fire_mqtt_message(hass, "alarm/state", state) assert hass.states.get(entity_id).state == state + # Ignore empty payload (last state is STATE_ALARM_TRIGGERED) + async_fire_mqtt_message(hass, "alarm/state", "") + assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED + + # Reset state on `None` payload + async_fire_mqtt_message(hass, "alarm/state", "None") + assert hass.states.get(entity_id).state == STATE_UNKNOWN + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_ignore_update_state_if_unknown_via_state_topic( @@ -309,13 +335,17 @@ async def test_supported_features( @pytest.mark.parametrize( ("hass_config", "service", "payload"), [ - (DEFAULT_CONFIG, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (DEFAULT_CONFIG, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (DEFAULT_CONFIG, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (DEFAULT_CONFIG, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (DEFAULT_CONFIG, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (DEFAULT_CONFIG, SERVICE_ALARM_DISARM, "DISARM"), - (DEFAULT_CONFIG, SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_CODE_NOT_REQUIRED, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_CODE_NOT_REQUIRED, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_CODE_NOT_REQUIRED, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_CODE_NOT_REQUIRED, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_CODE_NOT_REQUIRED, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + (DEFAULT_CONFIG_CODE_NOT_REQUIRED, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_CODE_NOT_REQUIRED, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_no_code( @@ -338,34 +368,61 @@ async def test_publish_mqtt_no_code( @pytest.mark.parametrize( - ("hass_config", "service", "payload"), + ("hass_config", "service", "payload", "raises"), [ - (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (DEFAULT_CONFIG_CODE, SERVICE_ALARM_DISARM, "DISARM"), - (DEFAULT_CONFIG_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), + ( + DEFAULT_CONFIG_CODE, + SERVICE_ALARM_ARM_HOME, + "ARM_HOME", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_CODE, + SERVICE_ALARM_ARM_AWAY, + "ARM_AWAY", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_CODE, + SERVICE_ALARM_ARM_NIGHT, + "ARM_NIGHT", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_CODE, + SERVICE_ALARM_ARM_VACATION, + "ARM_VACATION", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_CODE, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + pytest.raises(ServiceValidationError), + ), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_DISARM, "DISARM", does_not_raise()), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER", does_not_raise()), ], ) async def test_publish_mqtt_with_code( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - service, - payload, + service: str, + payload: str, + raises: AbstractContextManager, ) -> None: """Test publishing of MQTT messages when code is configured.""" mqtt_mock = await mqtt_mock_entry() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test"}, - blocking=True, - ) + with raises: + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, + ) assert mqtt_mock.async_publish.call_count == call_count # Wrong code provided, should not publish @@ -388,38 +445,66 @@ async def test_publish_mqtt_with_code( @pytest.mark.parametrize( - ("hass_config", "service", "payload"), + ("hass_config", "service", "payload", "raises"), [ - (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_ARM_HOME, + "ARM_HOME", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_ARM_AWAY, + "ARM_AWAY", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_ARM_NIGHT, + "ARM_NIGHT", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_ARM_VACATION, + "ARM_VACATION", + pytest.raises(ServiceValidationError), + ), ( DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", + pytest.raises(ServiceValidationError), + ), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_DISARM, "DISARM", does_not_raise()), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_TRIGGER, + "TRIGGER", + does_not_raise(), ), - (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_DISARM, "DISARM"), - (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - service, - payload, + service: str, + payload: str, + raises: AbstractContextManager, ) -> None: """Test publishing of MQTT messages when remode code is configured.""" mqtt_mock = await mqtt_mock_entry() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test"}, - blocking=True, - ) + with raises: + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, + ) assert mqtt_mock.async_publish.call_count == call_count # Any code numbered provided, should publish @@ -433,19 +518,50 @@ async def test_publish_mqtt_with_remote_code( @pytest.mark.parametrize( - ("hass_config", "service", "payload"), + ("hass_config", "service", "payload", "raises"), [ - (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_ARM_HOME, + "ARM_HOME", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_ARM_AWAY, + "ARM_AWAY", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_ARM_NIGHT, + "ARM_NIGHT", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_ARM_VACATION, + "ARM_VACATION", + pytest.raises(ServiceValidationError), + ), ( DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", + pytest.raises(ServiceValidationError), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_DISARM, + "DISARM", + does_not_raise(), + ), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_TRIGGER, + "TRIGGER", + does_not_raise(), ), - (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_DISARM, "DISARM"), - (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code_text( @@ -453,18 +569,20 @@ async def test_publish_mqtt_with_remote_code_text( mqtt_mock_entry: MqttMockHAClientGenerator, service: str, payload: str, + raises: AbstractContextManager, ) -> None: """Test publishing of MQTT messages when remote text code is configured.""" mqtt_mock = await mqtt_mock_entry() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test"}, - blocking=True, - ) + with raises: + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test"}, + blocking=True, + ) assert mqtt_mock.async_publish.call_count == call_count # Any code numbered provided, should publish @@ -1282,7 +1400,7 @@ async def test_reload_after_invalid_config( ) -> None: """Test reloading yaml config fails.""" with patch( - "homeassistant.components.mqtt.async_delete_issue" + "homeassistant.components.mqtt.ir.async_delete_issue" ) as mock_async_remove_issue: assert await mqtt_mock_entry() assert hass.states.get("alarm_control_panel.test") is None @@ -1345,7 +1463,6 @@ async def test_reload_after_invalid_config( {}, blocking=True, ) - await hass.async_block_till_done() # Make sure the config is loaded now assert hass.states.get("alarm_control_panel.test") is not None diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 821a3f911b7..2bf78e59e42 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -32,7 +32,7 @@ from homeassistant.components.mqtt.climate import ( MQTT_CLIMATE_ATTRIBUTES_BLOCKED, VALUE_TEMPLATE_KEYS, ) -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -245,11 +245,11 @@ async def test_set_operation_pessimistic( await mqtt_mock_entry() state = hass.states.get(ENTITY_CLIMATE) - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN await common.async_set_hvac_mode(hass, "cool", ENTITY_CLIMATE) state = hass.states.get(ENTITY_CLIMATE) - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN async_fire_mqtt_message(hass, "mode-state", "cool") state = hass.states.get(ENTITY_CLIMATE) @@ -259,6 +259,16 @@ async def test_set_operation_pessimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.state == "cool" + # Ignored + async_fire_mqtt_message(hass, "mode-state", "") + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == "cool" + + # Reset with `None` + async_fire_mqtt_message(hass, "mode-state", "None") + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize( "hass_config", @@ -1011,11 +1021,7 @@ async def test_handle_action_received( """Test getting the action received via MQTT.""" await mqtt_mock_entry() - # Cycle through valid modes and also check for wrong input such as "None" (str(None)) - async_fire_mqtt_message(hass, "action", "None") - state = hass.states.get(ENTITY_CLIMATE) - hvac_action = state.attributes.get(ATTR_HVAC_ACTION) - assert hvac_action is None + # Cycle through valid modes # Redefine actions according to https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action actions = ["off", "preheating", "heating", "cooling", "drying", "idle", "fan"] assert all(elem in actions for elem in HVACAction) @@ -1025,6 +1031,18 @@ async def test_handle_action_received( hvac_action = state.attributes.get(ATTR_HVAC_ACTION) assert hvac_action == action + # Check empty payload is ignored (last action == "fan") + async_fire_mqtt_message(hass, "action", "") + state = hass.states.get(ENTITY_CLIMATE) + hvac_action = state.attributes.get(ATTR_HVAC_ACTION) + assert hvac_action == "fan" + + # Check "None" payload is resetting the action + async_fire_mqtt_message(hass, "action", "None") + state = hass.states.get(ENTITY_CLIMATE) + hvac_action = state.attributes.get(ATTR_HVAC_ACTION) + assert hvac_action is None + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_preset_mode_optimistic( @@ -1070,9 +1088,9 @@ async def test_set_preset_mode_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - with pytest.raises(ServiceValidationError): + with pytest.raises(ServiceValidationError) as excinfo: await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + assert "Preset mode invalid is not valid." in str(excinfo.value) @pytest.mark.parametrize( @@ -1128,9 +1146,9 @@ async def test_set_preset_mode_explicit_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - with pytest.raises(ServiceValidationError): + with pytest.raises(ServiceValidationError) as excinfo: await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + assert "Preset mode invalid is not valid." in str(excinfo.value) @pytest.mark.parametrize( @@ -1170,6 +1188,10 @@ async def test_set_preset_mode_pessimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" + async_fire_mqtt_message(hass, "preset-mode-state", "") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("preset_mode") == "comfort" + async_fire_mqtt_message(hass, "preset-mode-state", "None") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -1449,11 +1471,16 @@ async def test_get_with_templates( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("hvac_action") == "cooling" - # Test ignoring null values - async_fire_mqtt_message(hass, "action", "null") + # Test ignoring empty values + async_fire_mqtt_message(hass, "action", "") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("hvac_action") == "cooling" + # Test resetting with null values + async_fire_mqtt_message(hass, "action", "null") + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("hvac_action") is None + @pytest.mark.parametrize( "hass_config", diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index ba767f51ac6..d196e1998fb 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -16,7 +16,7 @@ import yaml from homeassistant import config as module_hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info -from homeassistant.components.mqtt.const import MQTT_DISCONNECTED +from homeassistant.components.mqtt.const import MQTT_CONNECTION_STATE from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.config_entries import ConfigEntryState @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HassJobType, HomeAssistant 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 @@ -65,9 +65,9 @@ _SENTINEL = object() DISCOVERY_COUNT = len(MQTT) -_MqttMessageType = list[tuple[str, str]] -_AttributesType = list[tuple[str, Any]] -_StateDataType = list[tuple[_MqttMessageType, str | None, _AttributesType | None]] +type _MqttMessageType = list[tuple[str, str]] +type _AttributesType = list[tuple[str, Any]] +type _StateDataType = list[tuple[_MqttMessageType, str | None, _AttributesType | None]] def help_all_subscribe_calls(mqtt_client_mock: MqttMockPahoClient) -> list[Any]: @@ -115,7 +115,7 @@ async def help_test_availability_when_connection_lost( assert state and state.state != STATE_UNAVAILABLE mqtt_mock.connected = False - async_dispatcher_send(hass, MQTT_DISCONNECTED) + async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False) await hass.async_block_till_done() state = hass.states.get(f"{domain}.test") @@ -1189,7 +1189,9 @@ async def help_test_entity_id_update_subscriptions( assert state is not None assert mqtt_mock.async_subscribe.call_count == len(topics) + 2 + DISCOVERY_COUNT for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call( + topic, ANY, ANY, ANY, HassJobType.Callback + ) mqtt_mock.async_subscribe.reset_mock() entity_registry.async_update_entity( @@ -1203,7 +1205,9 @@ async def help_test_entity_id_update_subscriptions( state = hass.states.get(f"{domain}.milk") assert state is not None for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call( + topic, ANY, ANY, ANY, HassJobType.Callback + ) async def help_test_entity_id_update_discovery_update( @@ -1825,7 +1829,7 @@ async def help_test_reloadable( entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): - await entry.async_setup(hass) + await hass.config_entries.async_setup(entry.entry_id) assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 576ba3f94b2..8df5de8e2fb 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -1,6 +1,6 @@ """Test config flow.""" -from collections.abc import Generator, Iterator +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from ssl import SSLError @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest +from typing_extensions import Generator import voluptuous as vol from homeassistant import config_entries @@ -33,7 +34,7 @@ MOCK_CLIENT_KEY = b"## mock key file ##" @pytest.fixture(autouse=True) -def mock_finish_setup() -> Generator[MagicMock, None, None]: +def mock_finish_setup() -> Generator[MagicMock]: """Mock out the finish setup method.""" with patch( "homeassistant.components.mqtt.MQTT.async_connect", return_value=True @@ -42,7 +43,7 @@ def mock_finish_setup() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_client_cert_check_fail() -> Generator[MagicMock, None, None]: +def mock_client_cert_check_fail() -> Generator[MagicMock]: """Mock the client certificate check.""" with patch( "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate", @@ -52,7 +53,7 @@ def mock_client_cert_check_fail() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_client_key_check_fail() -> Generator[MagicMock, None, None]: +def mock_client_key_check_fail() -> Generator[MagicMock]: """Mock the client key file check.""" with patch( "homeassistant.components.mqtt.config_flow.load_pem_private_key", @@ -62,7 +63,7 @@ def mock_client_key_check_fail() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_ssl_context() -> Generator[dict[str, MagicMock], None, None]: +def mock_ssl_context() -> Generator[dict[str, MagicMock]]: """Mock the SSL context used to load the cert chain and to load verify locations.""" with ( patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, @@ -81,7 +82,7 @@ def mock_ssl_context() -> Generator[dict[str, MagicMock], None, None]: @pytest.fixture -def mock_reload_after_entry_update() -> Generator[MagicMock, None, None]: +def mock_reload_after_entry_update() -> Generator[MagicMock]: """Mock out the reload after updating the entry.""" with patch( "homeassistant.components.mqtt._async_config_entry_updated" @@ -90,14 +91,14 @@ def mock_reload_after_entry_update() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_try_connection() -> Generator[MagicMock, None, None]: +def mock_try_connection() -> Generator[MagicMock]: """Mock the try connection method.""" with patch("homeassistant.components.mqtt.config_flow.try_connection") as mock_try: yield mock_try @pytest.fixture -def mock_try_connection_success() -> Generator[MqttMockPahoClient, None, None]: +def mock_try_connection_success() -> Generator[MqttMockPahoClient]: """Mock the try connection method with success.""" _mid = 1 @@ -121,7 +122,9 @@ def mock_try_connection_success() -> Generator[MqttMockPahoClient, None, None]: mock_client().on_unsubscribe(mock_client, 0, mid) return (0, mid) - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: mock_client().loop_start = loop_start mock_client().subscribe = _subscribe mock_client().unsubscribe = _unsubscribe @@ -130,12 +133,14 @@ def mock_try_connection_success() -> Generator[MqttMockPahoClient, None, None]: @pytest.fixture -def mock_try_connection_time_out() -> Generator[MagicMock, None, None]: +def mock_try_connection_time_out() -> Generator[MagicMock]: """Mock the try connection method with a time out.""" # Patch prevent waiting 5 sec for a timeout with ( - patch("paho.mqtt.client.Client") as mock_client, + patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client, patch("homeassistant.components.mqtt.config_flow.MQTT_TIMEOUT", 0), ): mock_client().loop_start = lambda *args: 1 @@ -145,7 +150,7 @@ def mock_try_connection_time_out() -> Generator[MagicMock, None, None]: @pytest.fixture def mock_process_uploaded_file( tmp_path: Path, mock_temp_dir: str -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) file_id_cert = str(uuid4()) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index b2b1d1bd9c6..4b46f49c629 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -123,6 +123,11 @@ async def test_state_via_state_topic( state = hass.states.get("cover.test") assert state.state == STATE_OPEN + async_fire_mqtt_message(hass, "state-topic", "None") + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize( "hass_config", diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 4a159b8f9b5..254885919b0 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -294,7 +294,7 @@ async def test_cleanup_device_tracker( # Verify retained discovery topic has been cleared mqtt_mock.async_publish.assert_called_once_with( - "homeassistant/device_tracker/bla/config", "", 0, True + "homeassistant/device_tracker/bla/config", None, 0, True ) @@ -325,6 +325,11 @@ async def test_setting_device_tracker_value_via_mqtt_message( state = hass.states.get("device_tracker.test") assert state.state == STATE_NOT_HOME + # Test an empty value is ignored and the state is retained + async_fire_mqtt_message(hass, "test-topic", "") + state = hass.states.get("device_tracker.test") + assert state.state == STATE_NOT_HOME + async def test_setting_device_tracker_value_via_mqtt_message_and_template( hass: HomeAssistant, diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 1ef80c0b81e..9e75ea5168b 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -529,16 +529,16 @@ async def test_non_unique_triggers( async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data["some"] == "press1" - assert calls[1].data["some"] == "press2" + all_calls = {calls[0].data["some"], calls[1].data["some"]} + assert all_calls == {"press1", "press2"} # Trigger second config references to same trigger # and triggers both attached instances. async_fire_mqtt_message(hass, "foobar/triggers/button2", "long_press") await hass.async_block_till_done() assert len(calls) == 2 - assert calls[0].data["some"] == "press1" - assert calls[1].data["some"] == "press2" + all_calls = {calls[0].data["some"], calls[1].data["some"]} + assert all_calls == {"press1", "press2"} # Removing the first trigger will clean up calls.clear() @@ -1358,7 +1358,7 @@ async def test_cleanup_trigger( # Verify retained discovery topic has been cleared mqtt_mock.async_publish.assert_called_once_with( - "homeassistant/device_automation/bla/config", "", 0, True + "homeassistant/device_automation/bla/config", None, 0, True ) diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 349a0603e48..f8b547ae1eb 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -152,6 +152,7 @@ async def test_entry_diagnostics( async def test_redact_diagnostics( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, hass_client: ClientSessionGenerator, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: @@ -266,9 +267,10 @@ async def test_redact_diagnostics( } # Disable the entity and remove the state - ent_registry = er.async_get(hass) - device_tracker_entry = er.async_entries_for_device(ent_registry, device_entry.id)[0] - ent_registry.async_update_entity( + device_tracker_entry = er.async_entries_for_device( + entity_registry, device_entry.id + )[0] + entity_registry.async_update_entity( device_tracker_entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER ) hass.states.async_remove(device_tracker_entry.entity_id) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 9560e93e01a..911d205269c 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -15,7 +15,13 @@ from homeassistant.components.mqtt.abbreviations import ( ABBREVIATIONS, DEVICE_ABBREVIATIONS, ) -from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components.mqtt.discovery import ( + MQTT_DISCOVERY_DONE, + MQTT_DISCOVERY_NEW, + MQTT_DISCOVERY_UPDATED, + MQTTDiscoveryPayload, + async_start, +) from homeassistant.const import ( EVENT_STATE_CHANGED, STATE_ON, @@ -26,8 +32,13 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.setup import async_setup_component +from homeassistant.util.signal_type import SignalTypeFormat from .test_common import help_all_subscribe_calls, help_test_unload_config_entry @@ -280,9 +291,7 @@ async def test_discovery_with_invalid_integration_info( state = hass.states.get("binary_sensor.beer") assert state is None - assert ( - "Unable to parse origin information from discovery message, got" in caplog.text - ) + assert "Unable to parse origin information from discovery message" in caplog.text async def test_discover_fan( @@ -818,7 +827,7 @@ async def test_cleanup_device( entity_registry: er.EntityRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: - """Test discvered device is cleaned up when entry removed from device.""" + """Test discovered device is cleaned up when entry removed from device.""" mqtt_mock = await mqtt_mock_entry() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) @@ -863,7 +872,7 @@ async def test_cleanup_device( # Verify retained discovery topic has been cleared mqtt_mock.async_publish.assert_called_once_with( - "homeassistant/sensor/bla/config", "", 0, True + "homeassistant/sensor/bla/config", None, 0, True ) @@ -967,10 +976,10 @@ async def test_cleanup_device_multiple_config_entries( connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None - assert device_entry.config_entries == { - mqtt_config_entry.entry_id, + assert device_entry.config_entries == [ config_entry.entry_id, - } + mqtt_config_entry.entry_id, + ] entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is not None @@ -993,7 +1002,7 @@ async def test_cleanup_device_multiple_config_entries( ) assert device_entry is not None entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") - assert device_entry.config_entries == {config_entry.entry_id} + assert device_entry.config_entries == [config_entry.entry_id] assert entity_entry is None # Verify state is removed @@ -1004,9 +1013,9 @@ async def test_cleanup_device_multiple_config_entries( # Verify retained discovery topic has been cleared mqtt_mock.async_publish.assert_has_calls( [ - call("homeassistant/sensor/bla/config", "", 0, True), - call("homeassistant/tag/bla/config", "", 0, True), - call("homeassistant/device_automation/bla/config", "", 0, True), + call("homeassistant/sensor/bla/config", None, 0, True), + call("homeassistant/tag/bla/config", None, 0, True), + call("homeassistant/device_automation/bla/config", None, 0, True), ], any_order=True, ) @@ -1061,10 +1070,10 @@ async def test_cleanup_device_multiple_config_entries_mqtt( connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None - assert device_entry.config_entries == { - mqtt_config_entry.entry_id, + assert device_entry.config_entries == [ config_entry.entry_id, - } + mqtt_config_entry.entry_id, + ] entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is not None @@ -1085,7 +1094,7 @@ async def test_cleanup_device_multiple_config_entries_mqtt( ) assert device_entry is not None entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") - assert device_entry.config_entries == {config_entry.entry_id} + assert device_entry.config_entries == [config_entry.entry_id] assert entity_entry is None # Verify state is removed @@ -1605,11 +1614,11 @@ async def test_clear_config_topic_disabled_entity( # Assert all valid discovery topics are cleared assert mqtt_mock.async_publish.call_count == 2 assert ( - call("homeassistant/sensor/sbfspot_0/sbfspot_12345/config", "", 0, True) + call("homeassistant/sensor/sbfspot_0/sbfspot_12345/config", None, 0, True) in mqtt_mock.async_publish.mock_calls ) assert ( - call("homeassistant/sensor/sbfspot_0/sbfspot_12345_1/config", "", 0, True) + call("homeassistant/sensor/sbfspot_0/sbfspot_12345_1/config", None, 0, True) in mqtt_mock.async_publish.mock_calls ) @@ -1765,3 +1774,33 @@ async def test_update_with_bad_config_not_breaks_discovery( state = hass.states.get("sensor.sbfspot_12345") assert state and state.state == "new_value" + + +@pytest.mark.parametrize( + "signal_message", + [ + MQTT_DISCOVERY_NEW, + MQTT_DISCOVERY_UPDATED, + MQTT_DISCOVERY_DONE, + ], +) +async def test_discovery_dispatcher_signal_type_messages( + hass: HomeAssistant, signal_message: SignalTypeFormat[MQTTDiscoveryPayload] +) -> None: + """Test discovery dispatcher messages.""" + + domain_id_tuple = ("sensor", "very_unique") + test_data = {"name": "test", "state_topic": "test-topic"} + calls = [] + + def _callback(*args) -> None: + calls.append(*args) + + unsub = async_dispatcher_connect( + hass, signal_message.format(*domain_id_tuple), _callback + ) + async_dispatcher_send(hass, signal_message.format(*domain_id_tuple), test_data) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0] == test_data + unsub() diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index c29250bff82..4e8918d330e 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -106,7 +106,7 @@ async def async_set_mode( """Set mode for all or specified humidifier.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_MODE, mode)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_MODE, mode)) if value is not None } @@ -119,7 +119,7 @@ async def async_set_humidity( """Set target humidity for all or specified humidifier.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_HUMIDITY, humidity)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_HUMIDITY, humidity)) if value is not None } diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 019f153c62a..cd710ba610e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3,30 +3,36 @@ import asyncio from copy import deepcopy from datetime import datetime, timedelta +from functools import partial import json +import logging import socket import ssl +import time from typing import Any, TypedDict -from unittest.mock import ANY, MagicMock, call, mock_open, patch +from unittest.mock import ANY, MagicMock, Mock, call, mock_open, patch +import certifi from freezegun.api import FrozenDateTimeFactory import paho.mqtt.client as paho_mqtt import pytest +from typing_extensions import Generator import voluptuous as vol from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.client import ( + _LOGGER as CLIENT_LOGGER, RECONNECT_INTERVAL_SECONDS, EnsureJobAfterCooldown, ) -from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.models import ( MessageCallbackType, MqttCommandTemplateException, MqttValueTemplateException, ReceiveMessage, ) +from homeassistant.components.mqtt.schemas import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import ( @@ -40,7 +46,7 @@ from homeassistant.const import ( UnitOfTemperature, ) import homeassistant.core as ha -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.entity import Entity @@ -95,23 +101,32 @@ def mock_storage(hass_storage: dict[str, Any]) -> None: @pytest.fixture -def calls() -> list[ReceiveMessage]: +def recorded_calls() -> list[ReceiveMessage]: """Fixture to hold recorded calls.""" return [] @pytest.fixture -def record_calls(calls: list[ReceiveMessage]) -> MessageCallbackType: +def record_calls(recorded_calls: list[ReceiveMessage]) -> MessageCallbackType: """Fixture to record calls.""" @callback def record_calls(msg: ReceiveMessage) -> None: """Record calls.""" - calls.append(msg) + recorded_calls.append(msg) return record_calls +@pytest.fixture +def client_debug_log() -> Generator[None]: + """Set the mqtt client log level to DEBUG.""" + logger = logging.getLogger("mqtt_client_tests_debug") + logger.setLevel(logging.DEBUG) + with patch.object(CLIENT_LOGGER, "parent", logger): + yield + + def help_assert_message( msg: ReceiveMessage, topic: str | None = None, @@ -166,7 +181,9 @@ async def test_mqtt_await_ack_at_disconnect( mid = 100 rc = 0 - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: mqtt_client = mock_client.return_value mqtt_client.connect = MagicMock( return_value=0, @@ -177,10 +194,15 @@ async def test_mqtt_await_ack_at_disconnect( mqtt_client.publish = MagicMock(return_value=FakeInfo()) entry = MockConfigEntry( domain=mqtt.DOMAIN, - data={"certificate": "auto", mqtt.CONF_BROKER: "test-broker"}, + data={ + "certificate": "auto", + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_DISCOVERY: False, + }, ) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + mqtt_client = mock_client.return_value # publish from MQTT client without awaiting @@ -201,6 +223,7 @@ async def test_mqtt_await_ack_at_disconnect( 0, False, ) + await hass.async_block_till_done(wait_background_tasks=True) async def test_publish( @@ -208,49 +231,50 @@ async def test_publish( ) -> None: """Test the publish function.""" mqtt_mock = await mqtt_mock_entry() + publish_mock: MagicMock = mqtt_mock._mqttc.publish await mqtt.async_publish(hass, "test-topic", "test-payload") await hass.async_block_till_done() - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0] == ( + assert publish_mock.called + assert publish_mock.call_args[0] == ( "test-topic", "test-payload", 0, False, ) - mqtt_mock.reset_mock() + publish_mock.reset_mock() await mqtt.async_publish(hass, "test-topic", "test-payload", 2, True) await hass.async_block_till_done() - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0] == ( + assert publish_mock.called + assert publish_mock.call_args[0] == ( "test-topic", "test-payload", 2, True, ) - mqtt_mock.reset_mock() + publish_mock.reset_mock() mqtt.publish(hass, "test-topic2", "test-payload2") await hass.async_block_till_done() - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0] == ( + assert publish_mock.called + assert publish_mock.call_args[0] == ( "test-topic2", "test-payload2", 0, False, ) - mqtt_mock.reset_mock() + publish_mock.reset_mock() mqtt.publish(hass, "test-topic2", "test-payload2", 2, True) await hass.async_block_till_done() - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0] == ( + assert publish_mock.called + assert publish_mock.call_args[0] == ( "test-topic2", "test-payload2", 2, True, ) - mqtt_mock.reset_mock() + publish_mock.reset_mock() # test binary pass-through mqtt.publish( @@ -261,8 +285,8 @@ async def test_publish( False, ) await hass.async_block_till_done() - assert mqtt_mock.async_publish.called - assert mqtt_mock.async_publish.call_args[0] == ( + assert publish_mock.called + assert publish_mock.call_args[0] == ( "test-topic3", b"\xde\xad\xbe\xef", 0, @@ -270,6 +294,25 @@ async def test_publish( ) mqtt_mock.reset_mock() + # test null payload + mqtt.publish( + hass, + "test-topic3", + None, + 0, + False, + ) + await hass.async_block_till_done() + assert publish_mock.called + assert publish_mock.call_args[0] == ( + "test-topic3", + None, + 0, + False, + ) + + publish_mock.reset_mock() + async def test_convert_outgoing_payload(hass: HomeAssistant) -> None: """Test the converting of outgoing MQTT payloads without template.""" @@ -893,9 +936,9 @@ def test_entity_device_info_schema() -> None: } ], ) +@pytest.mark.usefixtures("mock_hass_config") async def test_handle_logging_on_writing_the_entity_state( hass: HomeAssistant, - mock_hass_config: None, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: @@ -918,7 +961,11 @@ async def test_handle_logging_on_writing_the_entity_state( assert state is not None assert state.state == "initial_state" assert "Invalid value for sensor" in caplog.text - assert "Exception raised when updating state of" in caplog.text + assert ( + "Exception raised while updating " + "state of sensor.test_sensor, topic: 'test/state' " + "with payload: b'payload causing errors'" in caplog.text + ) async def test_receiving_non_utf8_message_gets_logged( @@ -939,10 +986,46 @@ async def test_receiving_non_utf8_message_gets_logged( ) +async def test_receiving_message_with_non_utf8_topic_gets_logged( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving a non utf8 encoded topic.""" + await mqtt_mock_entry() + await mqtt.async_subscribe(hass, "test-topic", record_calls) + + # Local import to avoid processing MQTT modules when running a testcase + # which does not use MQTT. + + # pylint: disable-next=import-outside-toplevel + from paho.mqtt.client import MQTTMessage + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.mqtt.models import MqttData + + msg = MQTTMessage(topic=b"tasmota/discovery/18FE34E0B760\xcc\x02") + msg.payload = b"Payload" + msg.qos = 2 + msg.retain = True + msg.timestamp = time.monotonic() + + mqtt_data: MqttData = hass.data["mqtt"] + assert mqtt_data.client + mqtt_data.client._async_mqtt_on_message(Mock(), None, msg) + + assert ( + "Skipping received retained message on invalid " + "topic b'tasmota/discovery/18FE34E0B760\\xcc\\x02' " + "(qos=2): b'Payload'" in caplog.text + ) + + async def test_all_subscriptions_run_when_decode_fails( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test all other subscriptions still run when decode fails for one.""" @@ -953,13 +1036,13 @@ async def test_all_subscriptions_run_when_decode_fails( async_fire_mqtt_message(hass, "test-topic", UnitOfTemperature.CELSIUS) await hass.async_block_till_done() - assert len(calls) == 1 + assert len(recorded_calls) == 1 async def test_subscribe_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of a topic.""" @@ -969,16 +1052,16 @@ async def test_subscribe_topic( async_fire_mqtt_message(hass, "test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "test-topic" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "test-topic" + assert recorded_calls[0].payload == "test-payload" unsub() async_fire_mqtt_message(hass, "test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(recorded_calls) == 1 # Cannot unsubscribe twice with pytest.raises(HomeAssistantError): @@ -996,13 +1079,35 @@ async def test_subscribe_topic_not_initialize( await mqtt.async_subscribe(hass, "test-topic", record_calls) +async def test_subscribe_mqtt_config_entry_disabled( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test the subscription of a topic when MQTT config entry is disabled.""" + mqtt_mock.connected = True + + mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + assert mqtt_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) + assert mqtt_config_entry.state is ConfigEntryState.NOT_LOADED + + await hass.config_entries.async_set_disabled_by( + mqtt_config_entry.entry_id, ConfigEntryDisabler.USER + ) + mqtt_mock.connected = False + + with pytest.raises(HomeAssistantError, match=r".*MQTT is not enabled"): + await mqtt.async_subscribe(hass, "test-topic", record_calls) + + @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.2) async def test_subscribe_and_resubscribe( hass: HomeAssistant, + client_debug_log: None, mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test resubscribing within the debounce time.""" @@ -1022,9 +1127,9 @@ async def test_subscribe_and_resubscribe( async_fire_mqtt_message(hass, "test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "test-topic" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "test-topic" + assert recorded_calls[0].payload == "test-payload" # assert unsubscribe was not called mqtt_client_mock.unsubscribe.assert_not_called() @@ -1038,7 +1143,7 @@ async def test_subscribe_and_resubscribe( async def test_subscribe_topic_non_async( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of a topic using the non-async function.""" @@ -1051,16 +1156,16 @@ async def test_subscribe_topic_non_async( async_fire_mqtt_message(hass, "test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "test-topic" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "test-topic" + assert recorded_calls[0].payload == "test-payload" await hass.async_add_executor_job(unsub) async_fire_mqtt_message(hass, "test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(recorded_calls) == 1 async def test_subscribe_bad_topic( @@ -1077,7 +1182,7 @@ async def test_subscribe_bad_topic( async def test_subscribe_topic_not_match( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test if subscribed topic is not a match.""" @@ -1087,13 +1192,13 @@ async def test_subscribe_topic_not_match( async_fire_mqtt_message(hass, "another-test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(recorded_calls) == 0 async def test_subscribe_topic_level_wildcard( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1103,15 +1208,15 @@ async def test_subscribe_topic_level_wildcard( async_fire_mqtt_message(hass, "test-topic/bier/on", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "test-topic/bier/on" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "test-topic/bier/on" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_topic_level_wildcard_no_subtree_match( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1121,13 +1226,13 @@ async def test_subscribe_topic_level_wildcard_no_subtree_match( async_fire_mqtt_message(hass, "test-topic/bier", "test-payload") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(recorded_calls) == 0 async def test_subscribe_topic_level_wildcard_root_topic_no_subtree_match( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1137,13 +1242,13 @@ async def test_subscribe_topic_level_wildcard_root_topic_no_subtree_match( async_fire_mqtt_message(hass, "test-topic-123", "test-payload") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(recorded_calls) == 0 async def test_subscribe_topic_subtree_wildcard_subtree_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1153,15 +1258,15 @@ async def test_subscribe_topic_subtree_wildcard_subtree_topic( async_fire_mqtt_message(hass, "test-topic/bier/on", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "test-topic/bier/on" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "test-topic/bier/on" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_topic_subtree_wildcard_root_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1171,15 +1276,15 @@ async def test_subscribe_topic_subtree_wildcard_root_topic( async_fire_mqtt_message(hass, "test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "test-topic" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "test-topic" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_topic_subtree_wildcard_no_match( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1189,13 +1294,13 @@ async def test_subscribe_topic_subtree_wildcard_no_match( async_fire_mqtt_message(hass, "another-test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(recorded_calls) == 0 async def test_subscribe_topic_level_wildcard_and_wildcard_root_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1205,15 +1310,15 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_root_topic( async_fire_mqtt_message(hass, "hi/test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "hi/test-topic" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "hi/test-topic" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1223,15 +1328,15 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic( async_fire_mqtt_message(hass, "hi/test-topic/here-iam", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "hi/test-topic/here-iam" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "hi/test-topic/here-iam" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1241,13 +1346,13 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match( async_fire_mqtt_message(hass, "hi/here-iam/test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(recorded_calls) == 0 async def test_subscribe_topic_level_wildcard_and_wildcard_no_match( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of wildcard topics.""" @@ -1257,13 +1362,13 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_no_match( async_fire_mqtt_message(hass, "hi/another-test-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(recorded_calls) == 0 async def test_subscribe_topic_sys_root( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of $ root topics.""" @@ -1273,15 +1378,15 @@ async def test_subscribe_topic_sys_root( async_fire_mqtt_message(hass, "$test-topic/subtree/on", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "$test-topic/subtree/on" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "$test-topic/subtree/on" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_topic_sys_root_and_wildcard_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of $ root and wildcard topics.""" @@ -1291,15 +1396,15 @@ async def test_subscribe_topic_sys_root_and_wildcard_topic( async_fire_mqtt_message(hass, "$test-topic/some-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "$test-topic/some-topic" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "$test-topic/some-topic" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_topic_sys_root_and_wildcard_subtree_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription of $ root and wildcard subtree topics.""" @@ -1309,15 +1414,15 @@ async def test_subscribe_topic_sys_root_and_wildcard_subtree_topic( async_fire_mqtt_message(hass, "$test-topic/subtree/some-topic", "test-payload") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == "$test-topic/subtree/some-topic" - assert calls[0].payload == "test-payload" + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == "$test-topic/subtree/some-topic" + assert recorded_calls[0].payload == "test-payload" async def test_subscribe_special_characters( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test the subscription to topics with special characters.""" @@ -1329,9 +1434,9 @@ async def test_subscribe_special_characters( async_fire_mqtt_message(hass, topic, payload) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].topic == topic - assert calls[0].payload == payload + assert len(recorded_calls) == 1 + assert recorded_calls[0].topic == topic + assert recorded_calls[0].payload == payload @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @@ -1478,6 +1583,8 @@ async def test_replaying_payload_same_topic( mqtt_client_mock.on_disconnect(None, None, 0) mqtt_client_mock.on_connect(None, None, None, 0) await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() mqtt_client_mock.subscribe.assert_called() # Simulate a (retained) message played back after reconnecting async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) @@ -1692,6 +1799,7 @@ async def test_not_calling_subscribe_when_unsubscribed_within_cooldown( assert not mqtt_client_mock.subscribe.called +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_unsubscribe_race( @@ -1703,6 +1811,9 @@ async def test_unsubscribe_race( mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() calls_a: list[ReceiveMessage] = [] calls_b: list[ReceiveMessage] = [] @@ -1763,6 +1874,10 @@ async def test_restore_subscriptions_on_reconnect( mqtt_mock = await mqtt_mock_entry() # Fake that the client is connected mqtt_mock().connected = True + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + mqtt_client_mock.subscribe.reset_mock() await mqtt.async_subscribe(hass, "test/state", record_calls) async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown @@ -1771,8 +1886,10 @@ async def test_restore_subscriptions_on_reconnect( mqtt_client_mock.on_disconnect(None, None, 0) mqtt_client_mock.on_connect(None, None, None, 0) + await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() assert mqtt_client_mock.subscribe.call_count == 2 @@ -1789,6 +1906,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( mqtt_client_mock: MqttMockPahoClient, mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, + freezer: FrozenDateTimeFactory, ) -> None: """Test active subscriptions are restored correctly on reconnect.""" mqtt_mock = await mqtt_mock_entry() @@ -1799,10 +1917,11 @@ async def test_restore_all_active_subscriptions_on_reconnect( await mqtt.async_subscribe(hass, "test/state", record_calls, qos=1) await mqtt.async_subscribe(hass, "test/state", record_calls, qos=0) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + freezer.tick(3) + async_fire_time_changed(hass) # cooldown await hass.async_block_till_done() - # the subscribtion with the highest QoS should survive + # the subscription with the highest QoS should survive expected = [ call([("test/state", 2)]), ] @@ -1815,15 +1934,18 @@ async def test_restore_all_active_subscriptions_on_reconnect( mqtt_client_mock.on_disconnect(None, None, 0) await hass.async_block_till_done() mqtt_client_mock.on_connect(None, None, None, 0) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + freezer.tick(3) + async_fire_time_changed(hass) # cooldown await hass.async_block_till_done() expected.append(call([("test/state", 1)])) assert mqtt_client_mock.subscribe.mock_calls == expected - async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + freezer.tick(3) + async_fire_time_changed(hass) # cooldown await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + freezer.tick(3) + async_fire_time_changed(hass) # cooldown await hass.async_block_till_done() @@ -1839,6 +1961,7 @@ async def test_subscribed_at_highest_qos( mqtt_client_mock: MqttMockPahoClient, mqtt_mock_entry: MqttMockHAClientGenerator, record_calls: MessageCallbackType, + freezer: FrozenDateTimeFactory, ) -> None: """Test the highest qos as assigned when subscribing to the same topic.""" mqtt_mock = await mqtt_mock_entry() @@ -1847,20 +1970,23 @@ async def test_subscribed_at_highest_qos( await mqtt.async_subscribe(hass, "test/state", record_calls, qos=0) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + freezer.tick(5) + async_fire_time_changed(hass) # cooldown await hass.async_block_till_done() assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) mqtt_client_mock.reset_mock() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + freezer.tick(5) + async_fire_time_changed(hass) # cooldown await hass.async_block_till_done() await hass.async_block_till_done() await mqtt.async_subscribe(hass, "test/state", record_calls, qos=1) await mqtt.async_subscribe(hass, "test/state", record_calls, qos=2) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + freezer.tick(5) + async_fire_time_changed(hass) # cooldown await hass.async_block_till_done() - # the subscribtion with the highest QoS should survive + # the subscription with the highest QoS should survive assert help_all_subscribe_calls(mqtt_client_mock) == [("test/state", 2)] @@ -1868,7 +1994,7 @@ async def test_reload_entry_with_restored_subscriptions( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, record_calls: MessageCallbackType, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], ) -> None: """Test reloading the config entry with with subscriptions restored.""" # Setup the MQTT entry @@ -1877,7 +2003,7 @@ async def test_reload_entry_with_restored_subscriptions( hass.config.components.add(mqtt.DOMAIN) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value={}): - await entry.async_setup(hass) + await hass.config_entries.async_setup(entry.entry_id) await mqtt.async_subscribe(hass, "test-topic", record_calls) await mqtt.async_subscribe(hass, "wild/+/card", record_calls) @@ -1886,12 +2012,12 @@ async def test_reload_entry_with_restored_subscriptions( async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload") await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[0].topic == "test-topic" - assert calls[0].payload == "test-payload" - assert calls[1].topic == "wild/any/card" - assert calls[1].payload == "wild-card-payload" - calls.clear() + assert len(recorded_calls) == 2 + assert recorded_calls[0].topic == "test-topic" + assert recorded_calls[0].payload == "test-payload" + assert recorded_calls[1].topic == "wild/any/card" + assert recorded_calls[1].payload == "wild-card-payload" + recorded_calls.clear() # Reload the entry with patch("homeassistant.config.load_yaml_config_file", return_value={}): @@ -1903,12 +2029,12 @@ async def test_reload_entry_with_restored_subscriptions( async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload2") await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[0].topic == "test-topic" - assert calls[0].payload == "test-payload2" - assert calls[1].topic == "wild/any/card" - assert calls[1].payload == "wild-card-payload2" - calls.clear() + assert len(recorded_calls) == 2 + assert recorded_calls[0].topic == "test-topic" + assert recorded_calls[0].payload == "test-payload2" + assert recorded_calls[1].topic == "wild/any/card" + assert recorded_calls[1].payload == "wild-card-payload2" + recorded_calls.clear() # Reload the entry again with patch("homeassistant.config.load_yaml_config_file", return_value={}): @@ -1920,11 +2046,11 @@ async def test_reload_entry_with_restored_subscriptions( async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload3") await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[0].topic == "test-topic" - assert calls[0].payload == "test-payload3" - assert calls[1].topic == "wild/any/card" - assert calls[1].payload == "wild-card-payload3" + assert len(recorded_calls) == 2 + assert recorded_calls[0].topic == "test-topic" + assert recorded_calls[0].payload == "test-payload3" + assert recorded_calls[1].topic == "wild/any/card" + assert recorded_calls[1].payload == "wild-card-payload3" @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 2) @@ -2113,7 +2239,9 @@ async def test_publish_error( entry.add_to_hass(hass) # simulate an Out of memory error - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: mock_client().connect = lambda *args: 1 mock_client().publish().rc = 1 assert await hass.config_entries.async_setup(entry.entry_id) @@ -2248,7 +2376,9 @@ async def test_setup_mqtt_client_protocol( protocol: int, ) -> None: """Test MQTT client protocol setup.""" - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: await mqtt_mock_entry() # check if protocol setup was correctly @@ -2268,7 +2398,9 @@ async def test_handle_mqtt_timeout_on_callback( mid = 100 rc = 0 - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: def _mock_ack(topic: str, qos: int = 0) -> tuple[int, int]: # Handle ACK for subscribe normally @@ -2313,7 +2445,9 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: mock_client().connect = MagicMock(side_effect=OSError("Connection error")) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -2348,7 +2482,9 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( def mock_tls_insecure_set(insecure_param) -> None: insecure_check["insecure"] = insecure_param - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: mock_client().tls_set = mock_tls_set mock_client().tls_insecure_set = mock_tls_insecure_set await mqtt_mock_entry() @@ -2356,8 +2492,6 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( assert calls - import certifi - expected_certificate = certifi.where() assert calls[0][0] == expected_certificate @@ -2464,6 +2598,9 @@ async def test_default_birth_message( "mqtt_config_entry_data", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}], ) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_no_birth_message( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -2471,23 +2608,26 @@ async def test_no_birth_message( ) -> None: """Test disabling birth message.""" await mqtt_mock_entry() - with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.1): - mqtt_client_mock.on_connect(None, None, 0, 0) - await hass.async_block_till_done() - await asyncio.sleep(0.2) - mqtt_client_mock.publish.assert_not_called() + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.reset_mock() + + # Assert no birth message was sent + mqtt_client_mock.on_connect(None, None, 0, 0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.publish.assert_not_called() async def callback(msg: ReceiveMessage) -> None: """Handle birth message.""" - # Assert the subscribe debouncer subscribes after - # about SUBSCRIBE_COOLDOWN (0.1) sec - # but sooner than INITIAL_SUBSCRIBE_COOLDOWN (1.0) - mqtt_client_mock.reset_mock() await mqtt.async_subscribe(hass, "homeassistant/some-topic", callback) await hass.async_block_till_done() - await asyncio.sleep(0.2) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() mqtt_client_mock.subscribe.assert_called() @@ -2568,15 +2708,16 @@ async def test_delayed_birth_message( } ], ) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_subscription_done_when_birth_message_is_sent( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, + record_calls: MessageCallbackType, mqtt_config_entry_data, - mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test sending birth message until initial subscription has been completed.""" - mqtt_mock = await mqtt_mock_entry() - hass.set_state(CoreState.starting) birth = asyncio.Event() @@ -2585,42 +2726,37 @@ async def test_subscription_done_when_birth_message_is_sent( entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.on_disconnect(None, None, 0, 0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() - - mqtt_component_mock = MagicMock( - return_value=hass.data["mqtt"].client, - wraps=hass.data["mqtt"].client, - ) - mqtt_component_mock._mqttc = mqtt_client_mock - - hass.data["mqtt"].client = mqtt_component_mock - mqtt_mock = hass.data["mqtt"].client - mqtt_mock.reset_mock() @callback def wait_birth(msg: ReceiveMessage) -> None: """Handle birth message.""" birth.set() + await mqtt.async_subscribe(hass, "topic/test", record_calls) + await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) + await hass.async_block_till_done() mqtt_client_mock.reset_mock() - with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0): - await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) - mqtt_client_mock.on_connect(None, None, 0, 0) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - # We wait until we receive a birth message - await asyncio.wait_for(birth.wait(), 1) - # Assert we already have subscribed at the client - # for new config payloads at the time we the birth message is received - assert ("homeassistant/+/+/config", 0) in help_all_subscribe_calls( - mqtt_client_mock - ) - assert ("homeassistant/+/+/+/config", 0) in help_all_subscribe_calls( - mqtt_client_mock - ) - mqtt_client_mock.publish.assert_called_with( - "homeassistant/status", "online", 0, False - ) + mqtt_client_mock.on_connect(None, None, 0, 0) + # We wait until we receive a birth message + await asyncio.wait_for(birth.wait(), 1) + + # Assert we already have subscribed at the client + # for new config payloads at the time we the birth message is received + subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) + assert ("homeassistant/+/+/config", 0) in subscribe_calls + assert ("homeassistant/+/+/+/config", 0) in subscribe_calls + mqtt_client_mock.publish.assert_called_with( + "homeassistant/status", "online", 0, False + ) + assert ("topic/test", 0) in subscribe_calls @pytest.mark.parametrize( @@ -2688,6 +2824,9 @@ async def test_no_will_message( } ], ) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_subscribes_topics_on_connect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -2696,6 +2835,10 @@ async def test_mqtt_subscribes_topics_on_connect( ) -> None: """Test subscription to topic on connect.""" await mqtt_mock_entry() + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.reset_mock() await mqtt.async_subscribe(hass, "topic/test", record_calls) await mqtt.async_subscribe(hass, "home/sensor", record_calls, 2) @@ -2704,6 +2847,8 @@ async def test_mqtt_subscribes_topics_on_connect( mqtt_client_mock.on_connect(None, None, 0, 0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() assert mqtt_client_mock.disconnect.call_count == 0 @@ -2753,6 +2898,59 @@ async def test_mqtt_subscribes_in_single_call( ] +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_BIRTH_MESSAGE: {}, + mqtt.CONF_DISCOVERY: False, + } + ], +) +@patch("homeassistant.components.mqtt.client.MAX_SUBSCRIBES_PER_CALL", 2) +@patch("homeassistant.components.mqtt.client.MAX_UNSUBSCRIBES_PER_CALL", 2) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +async def test_mqtt_subscribes_and_unsubscribes_in_chunks( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, +) -> None: + """Test chunked client subscriptions.""" + mqtt_mock = await mqtt_mock_entry() + # Fake that the client is connected + mqtt_mock().connected = True + + mqtt_client_mock.subscribe.reset_mock() + unsub_tasks: list[CALLBACK_TYPE] = [] + unsub_tasks.append(await mqtt.async_subscribe(hass, "topic/test1", record_calls)) + unsub_tasks.append(await mqtt.async_subscribe(hass, "home/sensor1", record_calls)) + unsub_tasks.append(await mqtt.async_subscribe(hass, "topic/test2", record_calls)) + unsub_tasks.append(await mqtt.async_subscribe(hass, "home/sensor2", record_calls)) + await hass.async_block_till_done() + # Make sure the debouncer finishes + await asyncio.sleep(0.2) + + assert mqtt_client_mock.subscribe.call_count == 2 + # Assert we have a 2 subscription calls with both 2 subscriptions + assert len(mqtt_client_mock.subscribe.mock_calls[0][1][0]) == 2 + assert len(mqtt_client_mock.subscribe.mock_calls[1][1][0]) == 2 + + # Unsubscribe all topics + for task in unsub_tasks: + task() + await hass.async_block_till_done() + # Make sure the debouncer finishes + await asyncio.sleep(0.2) + + assert mqtt_client_mock.unsubscribe.call_count == 2 + # Assert we have a 2 unsubscribe calls with both 2 topic + assert len(mqtt_client_mock.unsubscribe.mock_calls[0][1][0]) == 2 + assert len(mqtt_client_mock.unsubscribe.mock_calls[1][1][0]) == 2 + + async def test_default_entry_setting_are_applied( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -2795,8 +2993,8 @@ async def test_message_callback_exception_gets_logged( await mqtt_mock_entry() @callback - def bad_handler(*args) -> None: - """Record calls.""" + def bad_handler(msg: ReceiveMessage) -> None: + """Handle callback.""" raise ValueError("This is a bad message callback") await mqtt.async_subscribe(hass, "test-topic", bad_handler) @@ -2809,6 +3007,40 @@ async def test_message_callback_exception_gets_logged( ) +@pytest.mark.no_fail_on_log_exception +async def test_message_partial_callback_exception_gets_logged( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test exception raised by message handler.""" + await mqtt_mock_entry() + + @callback + def bad_handler(msg: ReceiveMessage) -> None: + """Handle callback.""" + raise ValueError("This is a bad message callback") + + def parial_handler( + msg_callback: MessageCallbackType, + attributes: set[str], + msg: ReceiveMessage, + ) -> None: + """Partial callback handler.""" + msg_callback(msg) + + await mqtt.async_subscribe( + hass, "test-topic", partial(parial_handler, bad_handler, {"some_attr"}) + ) + async_fire_mqtt_message(hass, "test-topic", "test") + await hass.async_block_till_done() + + assert ( + "Exception in bad_handler when handling msg on 'test-topic':" + " 'test'" in caplog.text + ) + + async def test_mqtt_ws_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -3334,66 +3566,6 @@ async def test_debug_info_wildcard( } in debug_info_data["entities"][0]["subscriptions"] -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() - config = { - "device": {"identifiers": ["helloworld"]}, - "name": "test", - "state_topic": "sensor/#", - "unique_id": "veryunique", - } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - - device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) - assert device is not None - - debug_info_data = debug_info.info_for_device(hass, device.id) - assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 - assert {"topic": "sensor/#", "messages": []} in debug_info_data["entities"][0][ - "subscriptions" - ] - - 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 - assert len(debug_info_data["entities"][0]["subscriptions"][0]["messages"]) == 2 - assert { - "topic": "sensor/#", - "messages": [ - { - "payload": "123", - "qos": 0, - "retain": False, - "time": dt1, - "topic": "sensor/abc", - }, - { - "payload": "123", - "qos": 0, - "retain": False, - "time": dt2, - "topic": "sensor/abc", - }, - ], - } == debug_info_data["entities"][0]["subscriptions"][0] - - async def test_debug_info_same_topic( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -3672,7 +3844,7 @@ async def test_unload_config_entry( async def test_publish_or_subscribe_without_valid_config_entry( hass: HomeAssistant, record_calls: MessageCallbackType ) -> None: - """Test internal publish function with bas use cases.""" + """Test internal publish function with bad use cases.""" with pytest.raises(HomeAssistantError): await mqtt.async_publish( hass, "some-topic", "test-payload", qos=0, retain=False, encoding=None @@ -3890,7 +4062,7 @@ async def test_link_config_entry( assert _check_entities() == 2 # reload entry and assert again - with patch("paho.mqtt.client.Client"): + with patch("homeassistant.components.mqtt.async_client.AsyncMQTTClient"): await hass.config_entries.async_reload(mqtt_config_entry.entry_id) await hass.async_block_till_done() @@ -3938,6 +4110,7 @@ async def test_link_config_entry( async def test_reload_config_entry( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test manual entities reloaded and set up correctly.""" await mqtt_mock_entry() @@ -4004,6 +4177,9 @@ async def test_reload_config_entry( assert await hass.config_entries.async_reload(entry.entry_id) assert entry.state is ConfigEntryState.LOADED await hass.async_block_till_done() + # Assert the MQTT client was connected gracefully + with caplog.at_level(logging.INFO): + assert "Disconnected from MQTT server mock-broker:1883" in caplog.text assert (state := hass.states.get("sensor.test_manual1")) is not None assert state.attributes["friendly_name"] == "test_manual1_updated" @@ -4322,7 +4498,7 @@ async def test_server_sock_connect_and_disconnect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test handling the socket connected and disconnected.""" @@ -4353,7 +4529,72 @@ async def test_server_sock_connect_and_disconnect( unsub() # Should have failed - assert len(calls) == 0 + assert len(recorded_calls) == 0 + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_server_sock_buffer_size( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling the socket buffer size fails.""" + mqtt_mock = await mqtt_mock_entry() + await hass.async_block_till_done() + assert mqtt_mock.connected is True + + mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_SUCCESS + + client, server = socket.socketpair( + family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0 + ) + client.setblocking(False) + server.setblocking(False) + with patch.object(client, "setsockopt", side_effect=OSError("foo")): + mqtt_client_mock.on_socket_open(mqtt_client_mock, None, client) + mqtt_client_mock.on_socket_register_write(mqtt_client_mock, None, client) + await hass.async_block_till_done() + assert "Unable to increase the socket buffer size" in caplog.text + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_server_sock_buffer_size_with_websocket( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling the socket buffer size fails.""" + mqtt_mock = await mqtt_mock_entry() + await hass.async_block_till_done() + assert mqtt_mock.connected is True + + mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_SUCCESS + + client, server = socket.socketpair( + family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0 + ) + client.setblocking(False) + server.setblocking(False) + + class FakeWebsocket(paho_mqtt.WebsocketWrapper): + def _do_handshake(self, *args, **kwargs): + pass + + wrapped_socket = FakeWebsocket(client, "127.0.01", 1, False, "/", None) + + with patch.object(client, "setsockopt", side_effect=OSError("foo")): + mqtt_client_mock.on_socket_open(mqtt_client_mock, None, wrapped_socket) + mqtt_client_mock.on_socket_register_write( + mqtt_client_mock, None, wrapped_socket + ) + await hass.async_block_till_done() + assert "Unable to increase the socket buffer size" in caplog.text @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @@ -4363,7 +4604,7 @@ async def test_client_sock_failure_after_connect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_mock_entry: MqttMockHAClientGenerator, - calls: list[ReceiveMessage], + recorded_calls: list[ReceiveMessage], record_calls: MessageCallbackType, ) -> None: """Test handling the socket connected and disconnected.""" @@ -4394,7 +4635,7 @@ async def test_client_sock_failure_after_connect( unsub() # Should have failed - assert len(calls) == 0 + assert len(recorded_calls) == 0 @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @@ -4440,4 +4681,32 @@ async def test_loop_write_failure( # Final for the disconnect callback await hass.async_block_till_done() - assert "Disconnected from MQTT server mock-broker:1883 (7)" in caplog.text + assert "Disconnected from MQTT server mock-broker:1883" in caplog.text + + +@pytest.mark.parametrize( + "attr", + [ + "EntitySubscription", + "MqttCommandTemplate", + "MqttValueTemplate", + "PayloadSentinel", + "PublishPayloadType", + "ReceiveMessage", + "ReceivePayloadType", + "async_prepare_subscribe_topics", + "async_publish", + "async_subscribe", + "async_subscribe_topics", + "async_unsubscribe_topics", + "async_wait_for_mqtt_client", + "publish", + "subscribe", + "valid_publish_topic", + "valid_qos_schema", + "valid_subscribe_topic", + ], +) +async def test_mqtt_integration_level_imports(hass: HomeAssistant, attr: str) -> None: + """Test mqtt integration level public published imports are available.""" + assert hasattr(mqtt, attr) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index a52d1ab42f4..c9c2928f991 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -13,6 +13,8 @@ from homeassistant.components.lock import ( STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_OPEN, + STATE_OPENING, STATE_UNLOCKED, STATE_UNLOCKING, LockEntityFeature, @@ -75,8 +77,10 @@ CONFIG_WITH_STATES = { "payload_unlock": "UNLOCK", "state_locked": "closed", "state_locking": "closing", - "state_unlocked": "open", - "state_unlocking": "opening", + "state_open": "open", + "state_opening": "opening", + "state_unlocked": "unlocked", + "state_unlocking": "unlocking", } } } @@ -87,8 +91,10 @@ CONFIG_WITH_STATES = { [ (CONFIG_WITH_STATES, "closed", STATE_LOCKED), (CONFIG_WITH_STATES, "closing", STATE_LOCKING), - (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), - (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "open", STATE_OPEN), + (CONFIG_WITH_STATES, "opening", STATE_OPENING), + (CONFIG_WITH_STATES, "unlocked", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "unlocking", STATE_UNLOCKING), ], ) async def test_controlling_state_via_topic( @@ -117,8 +123,10 @@ async def test_controlling_state_via_topic( [ (CONFIG_WITH_STATES, "closed", STATE_LOCKED), (CONFIG_WITH_STATES, "closing", STATE_LOCKING), - (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), - (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "open", STATE_OPEN), + (CONFIG_WITH_STATES, "opening", STATE_OPENING), + (CONFIG_WITH_STATES, "unlocked", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "unlocking", STATE_UNLOCKING), (CONFIG_WITH_STATES, "None", STATE_UNKNOWN), ], ) @@ -140,6 +148,12 @@ async def test_controlling_non_default_state_via_topic( state = hass.states.get("lock.test") assert state.state is lock_state + # Empty state is ignored + async_fire_mqtt_message(hass, "state-topic", "") + + state = hass.states.get("lock.test") + assert state.state is lock_state + @pytest.mark.parametrize( ("hass_config", "payload", "lock_state"), @@ -168,7 +182,7 @@ async def test_controlling_non_default_state_via_topic( CONFIG_WITH_STATES, ({"value_template": "{{ value_json.val }}"},), ), - '{"val":"opening"}', + '{"val":"unlocking"}', STATE_UNLOCKING, ), ( @@ -178,6 +192,24 @@ async def test_controlling_non_default_state_via_topic( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"open"}', + STATE_OPEN, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"opening"}', + STATE_OPENING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"unlocked"}', STATE_UNLOCKED, ), ( @@ -237,7 +269,7 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"open"}', - STATE_UNLOCKED, + STATE_OPEN, ), ( help_custom_config( @@ -246,6 +278,24 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"opening"}', + STATE_OPENING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"unlocked"}', + STATE_UNLOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"unlocking"}', STATE_UNLOCKING, ), ], @@ -483,7 +533,7 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_OPEN assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -545,7 +595,7 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state is STATE_OPEN assert state.attributes.get(ATTR_ASSUMED_STATE) diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 2bcd663c243..e46f0b56c15 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -335,6 +335,9 @@ async def test_default_entity_and_device_name( # Assert that no issues ware registered assert len(events) == 0 + await hass.async_block_till_done() + # Assert that no issues ware registered + assert len(events) == 0 async def test_name_attribute_is_set_or_not( diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index e5e1352abb7..b8c55dd2ffb 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -3,6 +3,7 @@ from collections.abc import Generator import copy import json +import logging from typing import Any from unittest.mock import patch @@ -91,11 +92,15 @@ def _test_run_select_setup_params( async def test_run_select_setup( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, topic: str, ) -> None: """Test that it fetches the given payload.""" await mqtt_mock_entry() + state = hass.states.get("select.test_select") + assert state.state == STATE_UNKNOWN + async_fire_mqtt_message(hass, topic, "milk") await hass.async_block_till_done() @@ -110,6 +115,15 @@ async def test_run_select_setup( state = hass.states.get("select.test_select") assert state.state == "beer" + if caplog.at_level(logging.DEBUG): + async_fire_mqtt_message(hass, topic, "") + await hass.async_block_till_done() + + assert "Ignoring empty payload" in caplog.text + + state = hass.states.get("select.test_select") + assert state.state == "beer" + @pytest.mark.parametrize( "hass_config", diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 5ab4b660963..bde85abf3fb 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -110,6 +110,72 @@ async def test_setting_sensor_value_via_mqtt_message( assert state.attributes.get("unit_of_measurement") == "fav unit" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "%", + "device_class": "battery", + "encoding": "", + } + } + } + ], +) +async def test_handling_undecoded_sensor_value( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry() + + state = hass.states.get("sensor.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "test-topic", b"88") + state = hass.states.get("sensor.test") + assert state.state == STATE_UNKNOWN + assert ( + "Invalid undecoded state message 'b'88'' received from 'test-topic'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + } + } + }, + ], +) +async def test_setting_sensor_to_long_state_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "".join("x" for _ in range(310))) + state = hass.states.get("sensor.test") + await hass.async_block_till_done() + + assert state.state == STATE_UNKNOWN + + assert "Cannot update state for entity sensor.test" in caplog.text + + @pytest.mark.parametrize( ("hass_config", "device_class", "native_value", "state_value", "log"), [ diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 77bec4accfb..28b88e2793d 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -61,8 +61,8 @@ DEFAULT_CONFIG = { async def async_turn_on( hass: HomeAssistant, - entity_id: str = ENTITY_MATCH_ALL, - parameters: dict[str, Any] = {}, + entity_id: str, + parameters: dict[str, Any], ) -> None: """Turn all or specified siren on.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -144,7 +144,7 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - await async_turn_on(hass, entity_id="siren.test") + await async_turn_on(hass, entity_id="siren.test", parameters={}) mqtt_mock.async_publish.assert_called_once_with( "command-topic", '{"state":"beer on"}', 2, False @@ -1118,7 +1118,7 @@ async def test_unload_entry( '{"state":"ON","tone":"siren"}', '{"state":"OFF","tone":"siren"}', ), - # Attriute volume_level 2 is invalid, but the state is valid and should update + # Attribute volume_level 2 is invalid, but the state is valid and should update ( "test-topic", '{"state":"ON","volume_level":0.5}', diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index 54acc935f1d..7247458a667 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -154,7 +154,7 @@ async def test_qos_encoding_default( {"test_topic1": {"topic": "test-topic1", "msg_callback": msg_callback}}, ) await async_subscribe_topics(hass, sub_state) - mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 0, "utf-8") + mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 0, "utf-8", None) async def test_qos_encoding_custom( @@ -183,7 +183,7 @@ async def test_qos_encoding_custom( }, ) await async_subscribe_topics(hass, sub_state) - mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 1, "utf-16") + mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 1, "utf-16", None) async def test_no_change( diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 9de3b27fc3d..e70c06c2c4a 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -1,11 +1,11 @@ """The tests for MQTT tag scanner.""" -from collections.abc import Generator import copy import json from unittest.mock import ANY, AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN @@ -47,7 +47,7 @@ DEFAULT_TAG_SCAN_JSON = ( @pytest.fixture -def tag_mock() -> Generator[AsyncMock, None, None]: +def tag_mock() -> Generator[AsyncMock]: """Fixture to mock tag.""" with patch("homeassistant.components.tag.async_scan_tag") as mock_tag: yield mock_tag @@ -587,7 +587,7 @@ async def test_cleanup_tag( identifiers={("mqtt", "helloworld")} ) assert device_entry1 is not None - assert device_entry1.config_entries == {config_entry.entry_id, mqtt_entry.entry_id} + assert device_entry1.config_entries == [config_entry.entry_id, mqtt_entry.entry_id] device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None @@ -599,7 +599,7 @@ async def test_cleanup_tag( identifiers={("mqtt", "helloworld")} ) assert device_entry1 is not None - assert device_entry1.config_entries == {mqtt_entry.entry_id} + assert device_entry1.config_entries == [mqtt_entry.entry_id] device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None mqtt_mock.async_publish.assert_not_called() @@ -623,7 +623,7 @@ async def test_cleanup_tag( # Verify retained discovery topic has been cleared mqtt_mock.async_publish.assert_called_once_with( - "homeassistant/tag/bla1/config", "", 0, True + "homeassistant/tag/bla1/config", None, 0, True ) diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 63c69d3cfac..2c58cae690d 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -142,7 +142,7 @@ async def test_forced_text_length( state = hass.states.get("text.test") assert state.state == "12345" assert ( - "ValueError: Entity text.test provides state 123456 " + "Entity text.test provides state 123456 " "which is too long (maximum length 5)" in caplog.text ) @@ -152,7 +152,7 @@ async def test_forced_text_length( state = hass.states.get("text.test") assert state.state == "12345" assert ( - "ValueError: Entity text.test provides state 1 " + "Entity text.test provides state 1 " "which is too short (minimum length 5)" in caplog.text ) # Valid update @@ -200,7 +200,7 @@ async def test_controlling_validation_state_via_topic( async_fire_mqtt_message(hass, "state-topic", "other") await hass.async_block_till_done() assert ( - "ValueError: Entity text.test provides state other which does not match expected pattern (y|n)" + "Entity text.test provides state other which does not match expected pattern (y|n)" in caplog.text ) state = hass.states.get("text.test") @@ -211,7 +211,7 @@ async def test_controlling_validation_state_via_topic( async_fire_mqtt_message(hass, "state-topic", "yesyesyesyes") await hass.async_block_till_done() assert ( - "ValueError: Entity text.test provides state yesyesyesyes which is too long (maximum length 10)" + "Entity text.test provides state yesyesyesyes which is too long (maximum length 10)" in caplog.text ) state = hass.states.get("text.test") @@ -222,7 +222,7 @@ async def test_controlling_validation_state_via_topic( async_fire_mqtt_message(hass, "state-topic", "y") await hass.async_block_till_done() assert ( - "ValueError: Entity text.test provides state y which is too short (minimum length 2)" + "Entity text.test provides state y which is too short (minimum length 2)" in caplog.text ) state = hass.states.get("text.test") @@ -285,6 +285,36 @@ async def test_attribute_validation_max_not_greater_then_max_state_length( assert "max text length must be <= 255" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + text.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "state_topic": "state-topic", + } + } + } + ], +) +async def test_validation_payload_greater_then_max_state_length( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the max value of of max configuration attribute.""" + assert await mqtt_mock_entry() + + state = hass.states.get("text.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "state-topic", "".join("x" for _ in range(310))) + + assert "Cannot update state for entity text.test" in caplog.text + + @pytest.mark.parametrize( "hass_config", [ diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index ceb9207e0c2..2e0506a02ab 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -6,10 +6,11 @@ import pytest from homeassistant.components import automation from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF -from homeassistant.core import HomeAssistant +from homeassistant.core import HassJobType, HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message, async_mock_service, mock_component +from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -18,19 +19,23 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass: HomeAssistant): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @pytest.fixture(autouse=True) -async def setup_comp(hass: HomeAssistant, mqtt_mock_entry): +async def setup_comp( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> MqttMockHAClient: """Initialize components.""" mock_component(hass, "group") return await mqtt_mock_entry() -async def test_if_fires_on_topic_match(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_topic_match( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test if message is fired on topic match.""" assert await async_setup_component( hass, @@ -68,7 +73,9 @@ async def test_if_fires_on_topic_match(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_on_topic_and_payload_match(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_topic_and_payload_match( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test if message is fired on topic and payload match.""" assert await async_setup_component( hass, @@ -90,7 +97,9 @@ async def test_if_fires_on_topic_and_payload_match(hass: HomeAssistant, calls) - assert len(calls) == 1 -async def test_if_fires_on_topic_and_payload_match2(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_topic_and_payload_match2( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test if message is fired on topic and payload match. Make sure a payload which would render as a non string can still be matched. @@ -116,7 +125,7 @@ async def test_if_fires_on_topic_and_payload_match2(hass: HomeAssistant, calls) async def test_if_fires_on_templated_topic_and_payload_match( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test if message is fired on templated topic and payload match.""" assert await async_setup_component( @@ -147,7 +156,9 @@ async def test_if_fires_on_templated_topic_and_payload_match( assert len(calls) == 1 -async def test_if_fires_on_payload_template(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_payload_template( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test if message is fired on templated topic and payload match.""" assert await async_setup_component( hass, @@ -179,7 +190,7 @@ async def test_if_fires_on_payload_template(hass: HomeAssistant, calls) -> None: async def test_non_allowed_templates( - hass: HomeAssistant, calls, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, calls: list[ServiceCall], caplog: pytest.LogCaptureFixture ) -> None: """Test non allowed function in template.""" assert await async_setup_component( @@ -203,7 +214,7 @@ async def test_non_allowed_templates( async def test_if_not_fires_on_topic_but_no_payload_match( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test if message is not fired on topic but no payload.""" assert await async_setup_component( @@ -226,7 +237,9 @@ async def test_if_not_fires_on_topic_but_no_payload_match( assert len(calls) == 0 -async def test_encoding_default(hass: HomeAssistant, calls, setup_comp) -> None: +async def test_encoding_default( + hass: HomeAssistant, calls: list[ServiceCall], setup_comp +) -> None: """Test default encoding.""" assert await async_setup_component( hass, @@ -239,10 +252,14 @@ async def test_encoding_default(hass: HomeAssistant, calls, setup_comp) -> None: }, ) - setup_comp.async_subscribe.assert_called_with("test-topic", ANY, 0, "utf-8") + setup_comp.async_subscribe.assert_called_with( + "test-topic", ANY, 0, "utf-8", HassJobType.Callback + ) -async def test_encoding_custom(hass: HomeAssistant, calls, setup_comp) -> None: +async def test_encoding_custom( + hass: HomeAssistant, calls: list[ServiceCall], setup_comp +) -> None: """Test default encoding.""" assert await async_setup_component( hass, @@ -255,4 +272,6 @@ async def test_encoding_custom(hass: HomeAssistant, calls, setup_comp) -> None: }, ) - setup_comp.async_subscribe.assert_called_with("test-topic", ANY, 0, None) + setup_comp.async_subscribe.assert_called_with( + "test-topic", ANY, 0, None, HassJobType.Callback + ) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index c485e8a9c27..290f561e1ad 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -129,8 +129,8 @@ async def test_return_default_get_file_path( with patch( "homeassistant.components.mqtt.util.TEMP_DIR_NAME", f"home-assistant-mqtt-other-{getrandbits(10):03x}", - ) as mock_temp_dir: - tempdir = Path(tempfile.gettempdir()) / mock_temp_dir + ) as temp_dir_name: + tempdir = Path(tempfile.gettempdir()) / temp_dir_name assert await hass.async_add_executor_job(_get_file_path, tempdir) diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index 7563752b2d7..0a06759c7e6 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -2,6 +2,7 @@ from copy import deepcopy import json +import logging from typing import Any from unittest.mock import patch @@ -12,7 +13,6 @@ from homeassistant.components.mqtt import vacuum as mqttvacuum from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC from homeassistant.components.mqtt.vacuum import ( ALL_SERVICES, - CONF_SCHEMA, MQTT_VACUUM_ATTRIBUTES_BLOCKED, SERVICE_TO_STRING, services_to_strings, @@ -77,7 +77,6 @@ STATE_TOPIC = "vacuum/state" DEFAULT_CONFIG = { mqtt.DOMAIN: { vacuum.DOMAIN: { - CONF_SCHEMA: "state", CONF_NAME: "mqtttest", CONF_COMMAND_TOPIC: COMMAND_TOPIC, mqttvacuum.CONF_SEND_COMMAND_TOPIC: SEND_COMMAND_TOPIC, @@ -88,7 +87,7 @@ DEFAULT_CONFIG = { } } -DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"schema": "state", "name": "test"}}} +DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}} CONFIG_ALL_SERVICES = help_custom_config( vacuum.DOMAIN, @@ -103,6 +102,35 @@ CONFIG_ALL_SERVICES = help_custom_config( ) +async def test_warning_schema_option( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the warning on use of deprecated schema option.""" + await mqtt_mock_entry() + # Send discovery message with deprecated schema option + async_fire_mqtt_message( + hass, + f"homeassistant/{vacuum.DOMAIN}/bla/config", + '{"name": "test", "schema": "state", "o": {"name": "Bla2MQTT", "sw": "0.99", "url":"https://example.com/support"}}', + ) + await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("vacuum.test") + assert state is not None + with caplog.at_level(logging.WARNING): + assert ( + "The `schema` option is deprecated for MQTT vacuum, but it was used in a " + "discovery payload. Please contact the maintainer of the integration or " + "service that supplies the config, and suggest to remove the option." + in caplog.text + ) + assert "https://example.com/support" in caplog.text + assert "at discovery topic homeassistant/vacuum/bla/config" in caplog.text + + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -261,7 +289,6 @@ async def test_commands_without_supported_features( "mqtt": { "vacuum": { "name": "test", - "schema": "state", mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( ALL_SERVICES, SERVICE_TO_STRING ), @@ -525,13 +552,11 @@ async def test_discovery_update_attr( mqtt.DOMAIN: { vacuum.DOMAIN: [ { - "schema": "state", "name": "Test 1", "command_topic": "command-topic", "unique_id": "TOTALLY_UNIQUE", }, { - "schema": "state", "name": "Test 2", "command_topic": "command-topic", "unique_id": "TOTALLY_UNIQUE", @@ -554,7 +579,7 @@ async def test_discovery_removal_vacuum( caplog: pytest.LogCaptureFixture, ) -> None: """Test removal of discovered vacuum.""" - data = '{ "schema": "state", "name": "test", "command_topic": "test_topic"}' + data = '{"name": "test", "command_topic": "test_topic"}' await help_test_discovery_removal( hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data ) @@ -566,8 +591,8 @@ async def test_discovery_update_vacuum( caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered vacuum.""" - config1 = {"schema": "state", "name": "Beer", "command_topic": "test_topic"} - config2 = {"schema": "state", "name": "Milk", "command_topic": "test_topic"} + config1 = {"name": "Beer", "command_topic": "test_topic"} + config2 = {"name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, config1, config2 ) @@ -579,7 +604,7 @@ async def test_discovery_update_unchanged_vacuum( caplog: pytest.LogCaptureFixture, ) -> None: """Test update of discovered vacuum.""" - data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' + data1 = '{"name": "Beer", "command_topic": "test_topic"}' with patch( "homeassistant.components.mqtt.vacuum.MqttStateVacuum.discovery_update" ) as discovery_update: @@ -600,8 +625,8 @@ async def test_discovery_broken( caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of bad discovery message.""" - data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic#"}' - data2 = '{ "schema": "state", "name": "Milk", "command_topic": "test_topic"}' + data1 = '{"name": "Beer", "command_topic": "test_topic#"}' + data2 = '{"name": "Milk", "command_topic": "test_topic"}' await help_test_discovery_broken( hass, mqtt_mock_entry, caplog, vacuum.DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index 7fd9b10c005..2efa30d096a 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -131,6 +131,11 @@ async def test_state_via_state_topic_no_position( state = hass.states.get("valve.test") assert state.state == asserted_state + async_fire_mqtt_message(hass, "state-topic", "None") + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize( "hass_config", @@ -197,6 +202,7 @@ async def test_state_via_state_topic_with_template( ('{"position":100}', STATE_OPEN), ('{"position":50.0}', STATE_OPEN), ('{"position":0}', STATE_CLOSED), + ('{"position":null}', STATE_UNKNOWN), ('{"position":"non_numeric"}', STATE_UNKNOWN), ('{"ignored":12}', STATE_UNKNOWN), ], @@ -477,7 +483,7 @@ async def test_state_via_state_trough_position_with_alt_range( (SERVICE_STOP_VALVE, "SToP"), ], ) -async def tests_controling_valve_by_state( +async def test_controlling_valve_by_state( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, service: str, @@ -553,7 +559,7 @@ async def tests_controling_valve_by_state( ), ], ) -async def tests_supported_features( +async def test_supported_features( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, supported_features: ValveEntityFeature, @@ -583,7 +589,7 @@ async def tests_supported_features( ), ], ) -async def tests_open_close_payload_config_not_allowed( +async def test_open_close_payload_config_not_allowed( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, @@ -631,7 +637,7 @@ async def tests_open_close_payload_config_not_allowed( (SERVICE_OPEN_VALVE, "OPEN", STATE_OPEN), ], ) -async def tests_controling_valve_by_state_optimistic( +async def test_controlling_valve_by_state_optimistic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, service: str, @@ -683,7 +689,7 @@ async def tests_controling_valve_by_state_optimistic( (SERVICE_STOP_VALVE, "-1"), ], ) -async def tests_controling_valve_by_position( +async def test_controlling_valve_by_position( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, service: str, @@ -734,7 +740,7 @@ async def tests_controling_valve_by_position( (100, "100"), ], ) -async def tests_controling_valve_by_set_valve_position( +async def test_controlling_valve_by_set_valve_position( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, position: int, @@ -786,7 +792,7 @@ async def tests_controling_valve_by_set_valve_position( (100, "100", 100, STATE_OPEN), ], ) -async def tests_controling_valve_optimistic_by_set_valve_position( +async def test_controlling_valve_optimistic_by_set_valve_position( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, position: int, @@ -843,7 +849,7 @@ async def tests_controling_valve_optimistic_by_set_valve_position( (100, "127"), ], ) -async def tests_controling_valve_with_alt_range_by_set_valve_position( +async def test_controlling_valve_with_alt_range_by_set_valve_position( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, position: int, @@ -894,7 +900,7 @@ async def tests_controling_valve_with_alt_range_by_set_valve_position( (SERVICE_OPEN_VALVE, "127"), ], ) -async def tests_controling_valve_with_alt_range_by_position( +async def test_controlling_valve_with_alt_range_by_position( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, service: str, @@ -955,7 +961,7 @@ async def tests_controling_valve_with_alt_range_by_position( (SERVICE_OPEN_VALVE, "100", STATE_OPEN, 100), ], ) -async def tests_controling_valve_by_position_optimistic( +async def test_controlling_valve_by_position_optimistic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, service: str, @@ -1014,7 +1020,7 @@ async def tests_controling_valve_by_position_optimistic( (100, "127", 100, STATE_OPEN), ], ) -async def tests_controling_valve_optimistic_alt_trange_by_set_valve_position( +async def test_controlling_valve_optimistic_alt_range_by_set_valve_position( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, position: int, diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index ee0aa1c0949..a80ab59657f 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -25,7 +25,12 @@ from homeassistant.components.water_heater import ( STATE_PERFORMANCE, WaterHeaterEntityFeature, ) -from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature +from homeassistant.const import ( + ATTR_TEMPERATURE, + STATE_OFF, + STATE_UNKNOWN, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.util.unit_conversion import TemperatureConverter @@ -200,7 +205,7 @@ async def test_set_operation_pessimistic( await mqtt_mock_entry() state = hass.states.get(ENTITY_WATER_HEATER) - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN await common.async_set_operation_mode(hass, "eco", ENTITY_WATER_HEATER) state = hass.states.get(ENTITY_WATER_HEATER) @@ -214,6 +219,16 @@ async def test_set_operation_pessimistic( state = hass.states.get(ENTITY_WATER_HEATER) assert state.state == "eco" + # Empty state ignored + async_fire_mqtt_message(hass, "mode-state", "") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "eco" + + # Test None payload + async_fire_mqtt_message(hass, "mode-state", "None") + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == STATE_UNKNOWN + @pytest.mark.parametrize( "hass_config", @@ -1271,7 +1286,7 @@ async def test_skipped_async_ha_write_state( }, ), ) - for value_template in ["value_template", "mode_state_template"] + for value_template in ("value_template", "mode_state_template") ], ids=["value_template", "mode_state_template"], ) diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index 90034382fc8..82def7ef145 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -66,7 +66,7 @@ async def test_subscribe(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> No await hass.async_block_till_done() # Verify that the this entity was subscribed to the topic - mqtt_mock.async_subscribe.assert_called_with(sub_topic, ANY, 0, ANY) + mqtt_mock.async_subscribe.assert_called_with(sub_topic, ANY, 0, ANY, ANY) async def test_state_changed_event_sends_message( diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index f150f5c86c9..a992c985057 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -1,12 +1,12 @@ """The tests for the JSON MQTT device tracker platform.""" -from collections.abc import Generator import json import logging import os from unittest.mock import patch import pytest +from typing_extensions import AsyncGenerator from homeassistant.components.device_tracker.legacy import ( DOMAIN as DT_DOMAIN, @@ -34,7 +34,7 @@ LOCATION_MESSAGE_INCOMPLETE = {"longitude": 2.0} @pytest.fixture(autouse=True) async def setup_comp( hass: HomeAssistant, mqtt_mock: MqttMockHAClient -) -> Generator[None, None, None]: +) -> AsyncGenerator[None]: """Initialize components.""" yaml_devices = hass.config.path(YAML_DEVICES) yield @@ -43,7 +43,7 @@ async def setup_comp( async def test_setup_fails_without_mqtt_being_setup( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, caplog: pytest.LogCaptureFixture ) -> None: """Ensure mqtt is started when we setup the component.""" # Simulate MQTT is was removed @@ -52,6 +52,8 @@ async def test_setup_fails_without_mqtt_being_setup( await hass.config_entries.async_set_disabled_by( mqtt_entry.entry_id, ConfigEntryDisabler.USER ) + # mqtt is mocked so we need to simulate it is not connected + mqtt_mock.connected = False dev_id = "zanzito" topic = "location/zanzito" diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 01d6f5d9620..f1b86c9ce5b 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable, Generator +from collections.abc import Callable from copy import deepcopy import json from typing import Any @@ -12,6 +12,7 @@ from mysensors import BaseSyncGateway from mysensors.persistence import MySensorsJSONDecoder from mysensors.sensor import Sensor import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.mysensors.config_flow import DEFAULT_BAUD_RATE @@ -36,7 +37,7 @@ def mock_mqtt_fixture(hass: HomeAssistant) -> None: @pytest.fixture(name="is_serial_port") -def is_serial_port_fixture() -> Generator[MagicMock, None, None]: +def is_serial_port_fixture() -> Generator[MagicMock]: """Patch the serial port check.""" with patch("homeassistant.components.mysensors.gateway.cv.isdevice") as is_device: is_device.side_effect = lambda device: device @@ -53,7 +54,7 @@ def gateway_nodes_fixture() -> dict[int, Sensor]: async def serial_transport_fixture( gateway_nodes: dict[int, Sensor], is_serial_port: MagicMock, -) -> AsyncGenerator[dict[int, Sensor], None]: +) -> AsyncGenerator[dict[int, Sensor]]: """Mock a serial transport.""" with ( patch( @@ -136,7 +137,7 @@ def config_entry_fixture(serial_entry: MockConfigEntry) -> MockConfigEntry: @pytest.fixture(name="integration") async def integration_fixture( hass: HomeAssistant, transport: MagicMock, config_entry: MockConfigEntry -) -> AsyncGenerator[MockConfigEntry, None]: +) -> AsyncGenerator[MockConfigEntry]: """Set up the mysensors integration with a config entry.""" config: dict[str, Any] = {} config_entry.add_to_hass(hass) diff --git a/tests/components/mystrom/conftest.py b/tests/components/mystrom/conftest.py index 04b8fc221ed..f5405055805 100644 --- a/tests/components/mystrom/conftest.py +++ b/tests/components/mystrom/conftest.py @@ -1,9 +1,9 @@ """Provide common mystrom fixtures and mocks.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.mystrom.const import DOMAIN from homeassistant.const import CONF_HOST @@ -16,7 +16,7 @@ DEVICE_MAC = "6001940376EB" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.mystrom.async_setup_entry", return_value=True diff --git a/tests/components/myuplink/conftest.py b/tests/components/myuplink/conftest.py index 3ecb7e08356..dd05bedcaf4 100644 --- a/tests/components/myuplink/conftest.py +++ b/tests/components/myuplink/conftest.py @@ -1,6 +1,5 @@ """Test helpers for myuplink.""" -from collections.abc import AsyncGenerator, Generator import time from typing import Any from unittest.mock import MagicMock, patch @@ -8,6 +7,7 @@ from unittest.mock import MagicMock, patch from myuplink import Device, DevicePoint, System import orjson import pytest +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.application_credentials import ( ClientCredential, @@ -135,7 +135,7 @@ def mock_myuplink_client( device_points_fixture, system_fixture, load_systems_jv_file, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock a myuplink client.""" with patch( @@ -182,7 +182,7 @@ async def setup_platform( hass: HomeAssistant, mock_config_entry: MockConfigEntry, platforms, -) -> AsyncGenerator[None, None]: +) -> AsyncGenerator[None]: """Set up one or all platforms.""" with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): diff --git a/tests/components/myuplink/test_binary_sensor.py b/tests/components/myuplink/test_binary_sensor.py index 19eb4a4f292..128a4ebdde9 100644 --- a/tests/components/myuplink/test_binary_sensor.py +++ b/tests/components/myuplink/test_binary_sensor.py @@ -2,6 +2,9 @@ from unittest.mock import MagicMock +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from . import setup_integration @@ -9,17 +12,46 @@ from . import setup_integration from tests.common import MockConfigEntry +# Test one entity from each of binary_sensor classes. +@pytest.mark.parametrize( + ("entity_id", "friendly_name", "test_attributes", "expected_state"), + [ + ( + "binary_sensor.gotham_city_pump_heating_medium_gp1", + "Gotham City Pump: Heating medium (GP1)", + True, + STATE_ON, + ), + ( + "binary_sensor.gotham_city_connectivity", + "Gotham City Connectivity", + False, + STATE_ON, + ), + ( + "binary_sensor.gotham_city_alarm", + "Gotham City Pump: Alarm", + False, + STATE_OFF, + ), + ], +) async def test_sensor_states( hass: HomeAssistant, mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, + entity_id: str, + friendly_name: str, + test_attributes: bool, + expected_state: str, ) -> None: """Test sensor state.""" await setup_integration(hass, mock_config_entry) - state = hass.states.get("binary_sensor.gotham_city_pump_heating_medium_gp1") + state = hass.states.get(entity_id) assert state is not None - assert state.state == "on" - assert state.attributes == { - "friendly_name": "Gotham City Pump: Heating medium (GP1)", - } + assert state.state == expected_state + if test_attributes: + assert state.attributes == { + "friendly_name": friendly_name, + } diff --git a/tests/components/myuplink/test_config_flow.py b/tests/components/myuplink/test_config_flow.py index 7c5ae2c8657..3ae32575257 100644 --- a/tests/components/myuplink/test_config_flow.py +++ b/tests/components/myuplink/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.myuplink.const import ( DOMAIN, @@ -22,11 +24,11 @@ REDIRECT_URL = "https://example.com/auth/external/callback" CURRENT_SCOPE = "WRITESYSTEM READSYSTEM offline_access" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, setup_credentials, ) -> None: """Check full flow.""" @@ -72,11 +74,11 @@ async def test_full_flow( assert len(mock_setup.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_flow_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, setup_credentials: None, mock_config_entry: MockConfigEntry, expires_at: float, diff --git a/tests/components/myuplink/test_init.py b/tests/components/myuplink/test_init.py index 421eb9b59c2..b474db731d1 100644 --- a/tests/components/myuplink/test_init.py +++ b/tests/components/myuplink/test_init.py @@ -76,25 +76,23 @@ async def test_expired_token_refresh_failure( ) async def test_devices_created_count( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test that one device is created.""" await setup_integration(hass, mock_config_entry) - device_registry = dr.async_get(hass) - assert len(device_registry.devices) == 1 async def test_devices_multiple_created_count( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_myuplink_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test that multiple device are created.""" await setup_integration(hass, mock_config_entry) - device_registry = dr.async_get(hass) - assert len(device_registry.devices) == 2 diff --git a/tests/components/myuplink/test_number.py b/tests/components/myuplink/test_number.py index 899b2302b3c..273c35ab749 100644 --- a/tests/components/myuplink/test_number.py +++ b/tests/components/myuplink/test_number.py @@ -74,16 +74,15 @@ async def test_api_failure( ) -> None: """Test handling of exception from API.""" + mock_myuplink_client.async_set_device_points.side_effect = ClientError with pytest.raises(HomeAssistantError): - mock_myuplink_client.async_set_device_points.side_effect = ClientError await hass.services.async_call( TEST_PLATFORM, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: ENTITY_ID, "value": -125}, blocking=True, ) - await hass.async_block_till_done() - mock_myuplink_client.async_set_device_points.assert_called_once() + mock_myuplink_client.async_set_device_points.assert_called_once() @pytest.mark.parametrize( diff --git a/tests/components/myuplink/test_switch.py b/tests/components/myuplink/test_switch.py index efbc2c88371..5e309e7152e 100644 --- a/tests/components/myuplink/test_switch.py +++ b/tests/components/myuplink/test_switch.py @@ -86,14 +86,13 @@ async def test_api_failure( service: str, ) -> None: """Test handling of exception from API.""" + mock_myuplink_client.async_set_device_points.side_effect = ClientError with pytest.raises(HomeAssistantError): - mock_myuplink_client.async_set_device_points.side_effect = ClientError await hass.services.async_call( TEST_PLATFORM, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True ) - await hass.async_block_till_done() - mock_myuplink_client.async_set_device_points.assert_called_once() + mock_myuplink_client.async_set_device_points.assert_called_once() @pytest.mark.parametrize( diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py index 9b254de452c..e7560f8f7ce 100644 --- a/tests/components/nam/__init__.py +++ b/tests/components/nam/__init__.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch from homeassistant.components.nam.const import DOMAIN +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_json_object_fixture @@ -12,7 +13,9 @@ INCOMPLETE_NAM_DATA = { } -async def init_integration(hass, co2_sensor=True) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, co2_sensor: bool = True +) -> MockConfigEntry: """Set up the Nettigo Air Monitor integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/nam/fixtures/nam_data.json b/tests/components/nam/fixtures/nam_data.json index 93a33d4a552..82dacbefb34 100644 --- a/tests/components/nam/fixtures/nam_data.json +++ b/tests/components/nam/fixtures/nam_data.json @@ -15,6 +15,7 @@ { "value_type": "BME280_temperature", "value": "7.56" }, { "value_type": "BME280_humidity", "value": "45.69" }, { "value_type": "BME280_pressure", "value": "101101.17" }, + { "value_type": "DS18B20_temperature", "value": "12.56" }, { "value_type": "BMP_temperature", "value": "7.56" }, { "value_type": "BMP_pressure", "value": "103201.18" }, { "value_type": "BMP280_temperature", "value": "5.56" }, diff --git a/tests/components/nam/snapshots/test_diagnostics.ambr b/tests/components/nam/snapshots/test_diagnostics.ambr index 2ebc0246090..c187dec2866 100644 --- a/tests/components/nam/snapshots/test_diagnostics.ambr +++ b/tests/components/nam/snapshots/test_diagnostics.ambr @@ -11,6 +11,7 @@ 'bmp280_temperature': 5.6, 'dht22_humidity': 46.2, 'dht22_temperature': 6.3, + 'ds18b20_temperature': 12.6, 'heca_humidity': 50.0, 'heca_temperature': 8.0, 'mhz14a_carbon_dioxide': 865.0, diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index bbc655ecbb6..ea47998f3de 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -532,6 +532,60 @@ 'state': '6.3', }) # --- +# name: test_sensor[sensor.nettigo_air_monitor_ds18b20_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nettigo_air_monitor_ds18b20_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DS18B20 temperature', + 'platform': 'nam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ds18b20_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff-ds18b20_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.nettigo_air_monitor_ds18b20_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Nettigo Air Monitor DS18B20 temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nettigo_air_monitor_ds18b20_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.6', + }) +# --- # name: test_sensor[sensor.nettigo_air_monitor_heca_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 5dff9855988..b96eddfd18b 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -8,7 +8,13 @@ import pytest from homeassistant.components import zeroconf from homeassistant.components.nam.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, + SOURCE_ZEROCONF, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -437,3 +443,161 @@ async def test_zeroconf_errors(hass: HomeAssistant, error) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == reason + + +async def test_reconfigure_successful(hass: HomeAssistant) -> None: + """Test starting a reconfigure flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={ + CONF_HOST: "10.10.2.3", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with ( + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_HOST: "10.10.10.10", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + + +async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: + """Test starting a reconfigure flow but no connection found.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={ + CONF_HOST: "10.10.2.3", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + side_effect=ApiError("API Error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + with ( + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_HOST: "10.10.10.10", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + + +async def test_reconfigure_not_the_same_device(hass: HomeAssistant) -> None: + """Test starting the reconfiguration process, but with a different printer.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="11:22:33:44:55:66", + data={ + CONF_HOST: "10.10.2.3", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with ( + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "10.10.10.10"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "another_device" diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 2b307b4b02a..53945e1c8a2 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -5,9 +5,11 @@ from unittest.mock import AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory from nettigo_air_monitor import ApiError +import pytest from syrupy import SnapshotAssertion +from tenacity import RetryError -from homeassistant.components.nam.const import DOMAIN +from homeassistant.components.nam.const import DEFAULT_UPDATE_INTERVAL, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -31,15 +33,15 @@ from tests.common import ( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, ) -> None: """Test states of the air_quality.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2024-04-20 12:00:00+00:00") with patch("homeassistant.components.nam.PLATFORMS", [Platform.SENSOR]): @@ -96,7 +98,10 @@ async def test_incompleta_data_after_device_restart(hass: HomeAssistant) -> None assert state.state == STATE_UNAVAILABLE -async def test_availability(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("exc", [ApiError("API Error"), RetryError]) +async def test_availability( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, exc: Exception +) -> None: """Ensure that we mark the entities unavailable correctly when device causes an error.""" nam_data = load_json_object_fixture("nam/nam_data.json") @@ -107,22 +112,21 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state != STATE_UNAVAILABLE assert state.state == "7.6" - future = utcnow() + timedelta(minutes=6) with ( patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( "homeassistant.components.nam.NettigoAirMonitor._async_http_request", - side_effect=ApiError("API Error"), + side_effect=exc, ), ): - async_fire_time_changed(hass, future) + freezer.tick(DEFAULT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nettigo_air_monitor_bme280_temperature") assert state assert state.state == STATE_UNAVAILABLE - future = utcnow() + timedelta(minutes=12) update_response = Mock(json=AsyncMock(return_value=nam_data)) with ( patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), @@ -131,7 +135,8 @@ async def test_availability(hass: HomeAssistant) -> None: return_value=update_response, ), ): - async_fire_time_changed(hass, future) + freezer.tick(DEFAULT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nettigo_air_monitor_bme280_temperature") diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py index fdd9081331f..1d5b4ca5949 100644 --- a/tests/components/namecheapdns/test_init.py +++ b/tests/components/namecheapdns/test_init.py @@ -18,7 +18,9 @@ PASSWORD = "abcdefgh" @pytest.fixture -def setup_namecheapdns(hass, aioclient_mock): +def setup_namecheapdns( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Fixture that sets up NamecheapDNS.""" aioclient_mock.get( namecheapdns.UPDATE_URL, diff --git a/tests/components/neato/test_config_flow.py b/tests/components/neato/test_config_flow.py index 132b23ef157..1b86c4e9980 100644 --- a/tests/components/neato/test_config_flow.py +++ b/tests/components/neato/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from pybotvac.neato import Neato +import pytest from homeassistant import config_entries, setup from homeassistant.components.application_credentials import ( @@ -27,11 +28,11 @@ OAUTH2_AUTHORIZE = VENDOR.auth_endpoint OAUTH2_TOKEN = VENDOR.token_endpoint +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" assert await setup.async_setup_component(hass, "neato", {}) @@ -98,11 +99,11 @@ async def test_abort_if_already_setup(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test initialization of the reauth flow.""" assert await setup.async_setup_component(hass, "neato", {}) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 70bc88b003f..bbaa92b7b28 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable import copy from dataclasses import dataclass, field import time -from typing import Any, TypeVar +from typing import Any from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.device import Device @@ -14,14 +14,14 @@ from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.event import EventMessage from google_nest_sdm.event_media import CachePolicy from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber +from typing_extensions import Generator from homeassistant.components.application_credentials import ClientCredential from homeassistant.components.nest import DOMAIN # Typing helpers -PlatformSetup = Callable[[], Awaitable[None]] -_T = TypeVar("_T") -YieldFixture = Generator[_T, None, None] +type PlatformSetup = Callable[[], Awaitable[None]] +type YieldFixture[_T] = Generator[_T] WEB_AUTH_DOMAIN = DOMAIN APP_AUTH_DOMAIN = f"{DOMAIN}.installed" @@ -91,13 +91,15 @@ TEST_CONFIG_ENTRY_LEGACY = NestTestConfig( class FakeSubscriber(GoogleNestSubscriber): """Fake subscriber that supplies a FakeDeviceManager.""" - def __init__(self): + stop_calls = 0 + + def __init__(self): # pylint: disable=super-init-not-called """Initialize Fake Subscriber.""" self._device_manager = DeviceManager() - def set_update_callback(self, callback: Callable[[EventMessage], Awaitable[None]]): + def set_update_callback(self, target: Callable[[EventMessage], Awaitable[None]]): """Capture the callback set by Home Assistant.""" - self._device_manager.set_update_callback(callback) + self._device_manager.set_update_callback(target) async def create_subscription(self): """Create the subscription.""" @@ -122,7 +124,7 @@ class FakeSubscriber(GoogleNestSubscriber): def stop_async(self): """No-op to stop the subscriber.""" - return None + self.stop_calls += 1 async def async_receive_event(self, event_message: EventMessage): """Simulate a received pubsub message, invoked by tests.""" diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 68c77cb7635..de0fc2079fa 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Generator +from asyncio import AbstractEventLoop import copy import shutil import time @@ -15,6 +15,7 @@ from google_nest_sdm import diagnostics from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.device_manager import DeviceManager import pytest +from typing_extensions import Generator from homeassistant.components.application_credentials import ( async_import_client_credential, @@ -37,6 +38,7 @@ from .common import ( ) from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator FAKE_TOKEN = "some-token" FAKE_REFRESH_TOKEN = "some-refresh-token" @@ -86,13 +88,17 @@ class FakeAuth(AbstractAuth): @pytest.fixture -def aiohttp_client(event_loop, aiohttp_client, socket_enabled): +def aiohttp_client( + event_loop: AbstractEventLoop, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, +) -> ClientSessionGenerator: """Return aiohttp_client and allow opening sockets.""" return aiohttp_client @pytest.fixture -async def auth(aiohttp_client): +async def auth(aiohttp_client: ClientSessionGenerator) -> FakeAuth: """Fixture for an AbstractAuth.""" auth = FakeAuth() app = aiohttp.web.Application() @@ -164,7 +170,7 @@ async def create_device( device_id: str, device_type: str, device_traits: dict[str, Any], -) -> None: +) -> CreateDevice: """Fixture for creating devices.""" factory = CreateDevice(device_manager, auth) factory.data.update( @@ -190,7 +196,7 @@ def subscriber_id() -> str: @pytest.fixture -def nest_test_config(request) -> NestTestConfig: +def nest_test_config() -> NestTestConfig: """Fixture that sets up the configuration used for the test.""" return TEST_CONFIG_APP_CREDS @@ -292,7 +298,7 @@ async def setup_platform( @pytest.fixture(autouse=True) -def reset_diagnostics() -> Generator[None, None, None]: +def reset_diagnostics() -> Generator[None]: """Fixture to reset client library diagnostic counters.""" yield diagnostics.reset() diff --git a/tests/components/nest/snapshots/test_diagnostics.ambr b/tests/components/nest/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..aa679b8821c --- /dev/null +++ b/tests/components/nest/snapshots/test_diagnostics.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_camera_diagnostics + dict({ + 'camera': dict({ + 'camera.camera': dict({ + }), + }), + 'devices': list([ + dict({ + 'data': dict({ + 'name': '**REDACTED**', + 'parentRelations': list([ + ]), + 'traits': dict({ + 'sdm.devices.traits.CameraLiveStream': dict({ + 'audioCodecs': list([ + ]), + 'maxVideoResolution': dict({ + 'height': None, + 'width': None, + }), + 'supportedProtocols': list([ + 'RTSP', + ]), + 'videoCodecs': list([ + 'H264', + ]), + }), + }), + 'type': 'sdm.devices.types.CAMERA', + }), + }), + ]), + }) +# --- +# name: test_device_diagnostics + dict({ + 'data': dict({ + 'name': '**REDACTED**', + 'parentRelations': list([ + dict({ + 'displayName': '**REDACTED**', + 'parent': '**REDACTED**', + }), + ]), + 'traits': dict({ + 'sdm.devices.traits.Humidity': dict({ + 'ambient_humidity_percent': 35.0, + }), + 'sdm.devices.traits.Info': dict({ + 'custom_name': '**REDACTED**', + }), + 'sdm.devices.traits.Temperature': dict({ + 'ambient_temperature_celsius': 25.1, + }), + }), + 'type': 'sdm.devices.types.THERMOSTAT', + }), + }) +# --- +# name: test_entry_diagnostics + dict({ + 'devices': list([ + dict({ + 'data': dict({ + 'name': '**REDACTED**', + 'parentRelations': list([ + dict({ + 'displayName': '**REDACTED**', + 'parent': '**REDACTED**', + }), + ]), + 'traits': dict({ + 'sdm.devices.traits.Humidity': dict({ + 'ambient_humidity_percent': 35.0, + }), + 'sdm.devices.traits.Info': dict({ + 'custom_name': '**REDACTED**', + }), + 'sdm.devices.traits.Temperature': dict({ + 'ambient_temperature_celsius': 25.1, + }), + }), + 'type': 'sdm.devices.types.THERMOSTAT', + }), + }), + ]), + }) +# --- diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 33c611c9cfc..1838c18b6d4 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -12,11 +12,12 @@ import aiohttp from freezegun import freeze_time from google_nest_sdm.event import EventMessage import pytest +from typing_extensions import Generator from homeassistant.components import camera from homeassistant.components.camera import STATE_IDLE, STATE_STREAMING, StreamType from homeassistant.components.nest.const import DOMAIN -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -109,7 +110,7 @@ def make_motion_event( """Create an EventMessage for a motion event.""" if not timestamp: timestamp = utcnow() - return EventMessage( + return EventMessage.create_event( { "eventId": "some-event-id", # Ignored; we use the resource updated event id below "timestamp": timestamp.isoformat(timespec="seconds"), @@ -149,7 +150,7 @@ def make_stream_url_response( @pytest.fixture -async def mock_create_stream(hass) -> Mock: +async def mock_create_stream(hass: HomeAssistant) -> Generator[AsyncMock]: """Fixture to mock out the create stream call.""" assert await async_setup_component(hass, "stream", {}) with patch( @@ -203,7 +204,11 @@ async def test_ineligible_device( async def test_camera_device( - hass: HomeAssistant, setup_platform: PlatformSetup, camera_device: None + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + setup_platform: PlatformSetup, + camera_device: None, ) -> None: """Test a basic camera with a live stream.""" await setup_platform() @@ -214,12 +219,10 @@ async def test_camera_device( assert camera.state == STATE_STREAMING assert camera.attributes.get(ATTR_FRIENDLY_NAME) == "My Camera" - registry = er.async_get(hass) - entry = registry.async_get("camera.my_camera") + entry = entity_registry.async_get("camera.my_camera") assert entry.unique_id == f"{DEVICE_ID}-camera" assert entry.domain == "camera" - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My Camera" assert device.model == "Camera" diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index a3698cf0e82..88847759a16 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -52,7 +52,7 @@ from .conftest import FakeAuth from tests.components.climate import common -CreateEvent = Callable[[dict[str, Any]], Awaitable[None]] +type CreateEvent = Callable[[dict[str, Any]], Awaitable[None]] EVENT_ID = "some-event-id" @@ -79,7 +79,7 @@ async def create_event( async def create_event(traits: dict[str, Any]) -> None: await subscriber.async_receive_event( - EventMessage( + EventMessage.create_event( { "eventId": EVENT_ID, "timestamp": "2019-01-01T00:00:01Z", @@ -516,7 +516,6 @@ async def test_thermostat_invalid_hvac_mode( with pytest.raises(ValueError): await common.async_set_hvac_mode(hass, HVACMode.DRY) - await hass.async_block_till_done() assert thermostat.state == HVACMode.OFF assert auth.method is None # No communication with API @@ -1206,7 +1205,6 @@ async def test_thermostat_invalid_fan_mode( with pytest.raises(ServiceValidationError): await common.async_set_fan_mode(hass, FAN_LOW) - await hass.async_block_till_done() async def test_thermostat_target_temp( @@ -1378,7 +1376,6 @@ async def test_thermostat_unexpected_hvac_status( with pytest.raises(ValueError): await common.async_set_hvac_mode(hass, HVACMode.DRY) - await hass.async_block_till_done() assert thermostat.state == HVACMode.OFF @@ -1488,7 +1485,6 @@ async def test_thermostat_invalid_set_preset_mode( # Set preset mode that is invalid with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, PRESET_SLEEP) - await hass.async_block_till_done() # No RPC sent assert auth.method is None @@ -1538,7 +1534,6 @@ async def test_thermostat_hvac_mode_failure( auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] with pytest.raises(HomeAssistantError) as e_info: await common.async_set_hvac_mode(hass, HVACMode.HEAT) - await hass.async_block_till_done() assert "HVAC mode" in str(e_info) assert "climate.my_thermostat" in str(e_info) assert HVACMode.HEAT in str(e_info) @@ -1546,7 +1541,6 @@ async def test_thermostat_hvac_mode_failure( auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] with pytest.raises(HomeAssistantError) as e_info: await common.async_set_temperature(hass, temperature=25.0) - await hass.async_block_till_done() assert "temperature" in str(e_info) assert "climate.my_thermostat" in str(e_info) assert "25.0" in str(e_info) @@ -1554,7 +1548,6 @@ async def test_thermostat_hvac_mode_failure( auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] with pytest.raises(HomeAssistantError) as e_info: await common.async_set_fan_mode(hass, FAN_ON) - await hass.async_block_till_done() assert "fan mode" in str(e_info) assert "climate.my_thermostat" in str(e_info) assert FAN_ON in str(e_info) @@ -1562,7 +1555,6 @@ async def test_thermostat_hvac_mode_failure( auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] with pytest.raises(HomeAssistantError) as e_info: await common.async_set_preset_mode(hass, PRESET_ECO) - await hass.async_block_till_done() assert "preset mode" in str(e_info) assert "climate.my_thermostat" in str(e_info) assert PRESET_ECO in str(e_info) diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index cef1f5e9a86..5c8f01c8e39 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -35,6 +35,8 @@ from .common import ( ) from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" @@ -46,7 +48,7 @@ FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( @pytest.fixture -def nest_test_config(request) -> NestTestConfig: +def nest_test_config() -> NestTestConfig: """Fixture with empty configuration and no existing config entry.""" return TEST_CONFIGFLOW_APP_CREDS @@ -189,7 +191,12 @@ class OAuthFixture: @pytest.fixture -async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_host): +async def oauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, +) -> OAuthFixture: """Create the simulated oauth flow.""" return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index 44fb6bcf701..1820096d2a6 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -1,5 +1,7 @@ """The tests for Nest device triggers.""" +from typing import Any + from google_nest_sdm.event import EventMessage import pytest from pytest_unordered import unordered @@ -11,7 +13,7 @@ from homeassistant.components.device_automation.exceptions import ( ) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.events import NEST_EVENT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -30,7 +32,9 @@ def platforms() -> list[str]: return ["camera"] -def make_camera(device_id, name=DEVICE_NAME, traits={}): +def make_camera( + device_id, name: str = DEVICE_NAME, *, traits: dict[str, Any] +) -> dict[str, Any]: """Create a nest camera.""" traits = traits.copy() traits.update( @@ -80,13 +84,16 @@ async def setup_automation(hass, device_id, trigger_type): @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") async def test_get_triggers( - hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_device: CreateDevice, + setup_platform: PlatformSetup, ) -> None: """Test we get the expected triggers from a nest.""" create_device.create( @@ -100,7 +107,6 @@ async def test_get_triggers( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) expected_triggers = [ @@ -126,7 +132,10 @@ async def test_get_triggers( async def test_multiple_devices( - hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_device: CreateDevice, + setup_platform: PlatformSetup, ) -> None: """Test we get the expected triggers from a nest.""" create_device.create( @@ -149,10 +158,9 @@ async def test_multiple_devices( ) await setup_platform() - registry = er.async_get(hass) - entry1 = registry.async_get("camera.camera_1") + entry1 = entity_registry.async_get("camera.camera_1") assert entry1.unique_id == "device-id-1-camera" - entry2 = registry.async_get("camera.camera_2") + entry2 = entity_registry.async_get("camera.camera_2") assert entry2.unique_id == "device-id-2-camera" triggers = await async_get_device_automations( @@ -181,7 +189,10 @@ async def test_multiple_devices( async def test_triggers_for_invalid_device_id( - hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + create_device: CreateDevice, + setup_platform: PlatformSetup, ) -> None: """Get triggers for a device not found in the API.""" create_device.create( @@ -195,7 +206,6 @@ async def test_triggers_for_invalid_device_id( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert device_entry is not None @@ -215,14 +225,16 @@ async def test_triggers_for_invalid_device_id( async def test_no_triggers( - hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_device: CreateDevice, + setup_platform: PlatformSetup, ) -> None: """Test we get the expected triggers from a nest.""" create_device.create(raw_data=make_camera(device_id=DEVICE_ID, traits={})) await setup_platform() - registry = er.async_get(hass) - entry = registry.async_get("camera.my_camera") + entry = entity_registry.async_get("camera.my_camera") assert entry.unique_id == f"{DEVICE_ID}-camera" triggers = await async_get_device_automations( @@ -233,9 +245,10 @@ async def test_no_triggers( async def test_fires_on_camera_motion( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, create_device: CreateDevice, setup_platform: PlatformSetup, - calls, + calls: list[ServiceCall], ) -> None: """Test camera_motion triggers firing.""" create_device.create( @@ -249,7 +262,6 @@ async def test_fires_on_camera_motion( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "camera_motion") @@ -267,9 +279,10 @@ async def test_fires_on_camera_motion( async def test_fires_on_camera_person( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, create_device: CreateDevice, setup_platform: PlatformSetup, - calls, + calls: list[ServiceCall], ) -> None: """Test camera_person triggers firing.""" create_device.create( @@ -283,7 +296,6 @@ async def test_fires_on_camera_person( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "camera_person") @@ -301,9 +313,10 @@ async def test_fires_on_camera_person( async def test_fires_on_camera_sound( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, create_device: CreateDevice, setup_platform: PlatformSetup, - calls, + calls: list[ServiceCall], ) -> None: """Test camera_sound triggers firing.""" create_device.create( @@ -317,7 +330,6 @@ async def test_fires_on_camera_sound( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "camera_sound") @@ -335,9 +347,10 @@ async def test_fires_on_camera_sound( async def test_fires_on_doorbell_chime( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, create_device: CreateDevice, setup_platform: PlatformSetup, - calls, + calls: list[ServiceCall], ) -> None: """Test doorbell_chime triggers firing.""" create_device.create( @@ -351,7 +364,6 @@ async def test_fires_on_doorbell_chime( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "doorbell_chime") @@ -369,9 +381,10 @@ async def test_fires_on_doorbell_chime( async def test_trigger_for_wrong_device_id( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, create_device: CreateDevice, setup_platform: PlatformSetup, - calls, + calls: list[ServiceCall], ) -> None: """Test messages for the wrong device are ignored.""" create_device.create( @@ -385,7 +398,6 @@ async def test_trigger_for_wrong_device_id( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "camera_motion") @@ -402,9 +414,10 @@ async def test_trigger_for_wrong_device_id( async def test_trigger_for_wrong_event_type( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, create_device: CreateDevice, setup_platform: PlatformSetup, - calls, + calls: list[ServiceCall], ) -> None: """Test that messages for the wrong event type are ignored.""" create_device.create( @@ -418,7 +431,6 @@ async def test_trigger_for_wrong_event_type( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "camera_motion") @@ -435,7 +447,8 @@ async def test_trigger_for_wrong_event_type( async def test_subscriber_automation( hass: HomeAssistant, - calls: list, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], create_device: CreateDevice, setup_platform: PlatformSetup, subscriber: FakeSubscriber, @@ -451,13 +464,12 @@ async def test_subscriber_automation( ) await setup_platform() - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "camera_motion") # Simulate a pubsub message received by the subscriber with a motion event - event = EventMessage( + event = EventMessage.create_event( { "eventId": "some-event-id", "timestamp": "2019-01-01T00:00:01Z", diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index 5fb33ff4a47..a072394a43d 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -4,12 +4,16 @@ from unittest.mock import patch from google_nest_sdm.exceptions import SubscriberException import pytest +from syrupy import SnapshotAssertion from homeassistant.components.nest.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 CreateDevice, PlatformSetup + +from tests.common import MockConfigEntry from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -41,21 +45,6 @@ DEVICE_API_DATA = { ], } -DEVICE_DIAGNOSTIC_DATA = { - "data": { - "assignee": "**REDACTED**", - "name": "**REDACTED**", - "parentRelations": [{"displayName": "**REDACTED**", "parent": "**REDACTED**"}], - "traits": { - "sdm.devices.traits.Info": {"customName": "**REDACTED**"}, - "sdm.devices.traits.Humidity": {"ambientHumidityPercent": 35.0}, - "sdm.devices.traits.Temperature": {"ambientTemperatureCelsius": 25.1}, - }, - "type": "sdm.devices.types.THERMOSTAT", - } -} - - CAMERA_API_DATA = { "name": NEST_DEVICE_ID, "type": "sdm.devices.types.CAMERA", @@ -67,19 +56,6 @@ CAMERA_API_DATA = { }, } -CAMERA_DIAGNOSTIC_DATA = { - "data": { - "name": "**REDACTED**", - "traits": { - "sdm.devices.traits.CameraLiveStream": { - "videoCodecs": ["H264"], - "supportedProtocols": ["RTSP"], - }, - }, - "type": "sdm.devices.types.CAMERA", - }, -} - @pytest.fixture def platforms() -> list[str]: @@ -90,9 +66,10 @@ def platforms() -> list[str]: async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - create_device, - setup_platform, - config_entry, + create_device: CreateDevice, + setup_platform: PlatformSetup, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" create_device.create(raw_data=DEVICE_API_DATA) @@ -100,38 +77,40 @@ async def test_entry_diagnostics( assert config_entry.state is ConfigEntryState.LOADED # Test that only non identifiable device information is returned - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "devices": [DEVICE_DIAGNOSTIC_DATA] - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - create_device, - setup_platform, - config_entry, + device_registry: dr.DeviceRegistry, + create_device: CreateDevice, + setup_platform: PlatformSetup, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" create_device.create(raw_data=DEVICE_API_DATA) await setup_platform() assert config_entry.state is ConfigEntryState.LOADED - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, NEST_DEVICE_ID)}) assert device is not None assert ( await get_diagnostics_for_device(hass, hass_client, config_entry, device) - == DEVICE_DIAGNOSTIC_DATA + == snapshot ) async def test_setup_susbcriber_failure( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry, - setup_base_platform, + config_entry: MockConfigEntry, + setup_base_platform: PlatformSetup, ) -> None: """Test configuration error.""" with patch( @@ -148,9 +127,10 @@ async def test_setup_susbcriber_failure( async def test_camera_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - create_device, - setup_platform, - config_entry, + create_device: CreateDevice, + setup_platform: PlatformSetup, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" create_device.create(raw_data=CAMERA_API_DATA) @@ -158,7 +138,7 @@ async def test_camera_diagnostics( assert config_entry.state is ConfigEntryState.LOADED # Test that only non identifiable device information is returned - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "devices": [CAMERA_DIAGNOSTIC_DATA], - "camera": {"camera.camera": {}}, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index caa86a3d93b..08cf9f775b7 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -53,7 +53,7 @@ def device_traits() -> list[str]: @pytest.fixture(autouse=True) def device( - device_type: str, device_traits: dict[str, Any], create_device: CreateDevice + device_type: str, device_traits: list[str], create_device: CreateDevice ) -> None: """Fixture to create a device under test.""" return create_device.create( @@ -70,7 +70,7 @@ def event_view(d: Mapping[str, Any]) -> Mapping[str, Any]: return {key: value for key, value in d.items() if key in EVENT_KEYS} -def create_device_traits(event_traits=[]): +def create_device_traits(event_traits: list[str]) -> dict[str, Any]: """Create fake traits for a device.""" result = { "sdm.devices.traits.Info": { @@ -104,7 +104,7 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): """Create an EventMessage for events.""" if not timestamp: timestamp = utcnow() - return EventMessage( + return EventMessage.create_event( { "eventId": "some-event-id", "timestamp": timestamp.isoformat(timespec="seconds"), @@ -152,6 +152,8 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): ) async def test_event( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, auth, setup_platform, subscriber, @@ -163,13 +165,11 @@ async def test_event( events = async_capture_events(hass, NEST_EVENT) await setup_platform() - registry = er.async_get(hass) - entry = registry.async_get("camera.front") + entry = entity_registry.async_get("camera.front") assert entry is not None assert entry.unique_id == "some-device-id-camera" assert entry.domain == "camera" - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "Front" assert device.model == expected_model @@ -195,13 +195,12 @@ async def test_event( ], ) async def test_camera_multiple_event( - hass: HomeAssistant, subscriber, setup_platform + hass: HomeAssistant, entity_registry: er.EntityRegistry, subscriber, setup_platform ) -> None: """Test a pubsub message for a camera person event.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() - registry = er.async_get(hass) - entry = registry.async_get("camera.front") + entry = entity_registry.async_get("camera.front") assert entry is not None event_map = { @@ -264,7 +263,7 @@ async def test_event_message_without_device_event( events = async_capture_events(hass, NEST_EVENT) await setup_platform() timestamp = utcnow() - event = EventMessage( + event = EventMessage.create_event( { "eventId": "some-event-id", "timestamp": timestamp.isoformat(timespec="seconds"), @@ -284,13 +283,12 @@ async def test_event_message_without_device_event( ], ) async def test_doorbell_event_thread( - hass: HomeAssistant, subscriber, setup_platform + hass: HomeAssistant, entity_registry: er.EntityRegistry, subscriber, setup_platform ) -> None: """Test a series of pubsub messages in the same thread.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() - registry = er.async_get(hass) - entry = registry.async_get("camera.front") + entry = entity_registry.async_get("camera.front") assert entry is not None event_message_data = { @@ -321,7 +319,9 @@ async def test_doorbell_event_thread( "eventThreadState": "STARTED", } ) - await subscriber.async_receive_event(EventMessage(message_data_1, auth=None)) + await subscriber.async_receive_event( + EventMessage.create_event(message_data_1, auth=None) + ) # Publish message #2 that sends a no-op update to end the event thread timestamp2 = timestamp1 + datetime.timedelta(seconds=1) @@ -332,7 +332,9 @@ async def test_doorbell_event_thread( "eventThreadState": "ENDED", } ) - await subscriber.async_receive_event(EventMessage(message_data_2, auth=None)) + await subscriber.async_receive_event( + EventMessage.create_event(message_data_2, auth=None) + ) await hass.async_block_till_done() # The event is only published once @@ -355,13 +357,12 @@ async def test_doorbell_event_thread( ], ) async def test_doorbell_event_session_update( - hass: HomeAssistant, subscriber, setup_platform + hass: HomeAssistant, entity_registry: er.EntityRegistry, subscriber, setup_platform ) -> None: """Test a pubsub message with updates to an existing session.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() - registry = er.async_get(hass) - entry = registry.async_get("camera.front") + entry = entity_registry.async_get("camera.front") assert entry is not None # Message #1 has a motion event @@ -419,15 +420,14 @@ async def test_doorbell_event_session_update( async def test_structure_update_event( - hass: HomeAssistant, subscriber, setup_platform + hass: HomeAssistant, entity_registry: er.EntityRegistry, subscriber, setup_platform ) -> None: """Test a pubsub message for a new device being added.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() # Entity for first device is registered - registry = er.async_get(hass) - assert registry.async_get("camera.front") + assert entity_registry.async_get("camera.front") new_device = Device.MakeDevice( { @@ -446,10 +446,10 @@ async def test_structure_update_event( device_manager.add_device(new_device) # Entity for new devie has not yet been loaded - assert not registry.async_get("camera.back") + assert not entity_registry.async_get("camera.back") # Send a message that triggers the device to be loaded - message = EventMessage( + message = EventMessage.create_event( { "eventId": "some-event-id", "timestamp": utcnow().isoformat(timespec="seconds"), @@ -474,9 +474,9 @@ async def test_structure_update_event( # No home assistant events published assert not events - assert registry.async_get("camera.front") + assert entity_registry.async_get("camera.front") # Currently need a manual reload to detect the new entity - assert not registry.async_get("camera.back") + assert not entity_registry.async_get("camera.back") @pytest.mark.parametrize( @@ -485,12 +485,13 @@ async def test_structure_update_event( ["sdm.devices.traits.CameraMotion"], ], ) -async def test_event_zones(hass: HomeAssistant, subscriber, setup_platform) -> None: +async def test_event_zones( + hass: HomeAssistant, entity_registry: er.EntityRegistry, subscriber, setup_platform +) -> None: """Test events published with zone information.""" events = async_capture_events(hass, NEST_EVENT) await setup_platform() - registry = er.async_get(hass) - entry = registry.async_get("camera.front") + entry = entity_registry.async_get("camera.front") assert entry is not None event_map = { diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index e77ba3bb7e1..f9813ca63ee 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -19,6 +19,7 @@ from google_nest_sdm.exceptions import ( SubscriberException, ) import pytest +from typing_extensions import Generator from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -32,6 +33,7 @@ from .common import ( TEST_CONFIG_LEGACY, TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, + PlatformSetup, YieldFixture, ) @@ -47,14 +49,18 @@ def platforms() -> list[str]: @pytest.fixture -def error_caplog(caplog): +def error_caplog( + caplog: pytest.LogCaptureFixture, +) -> Generator[pytest.LogCaptureFixture]: """Fixture to capture nest init error messages.""" with caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): yield caplog @pytest.fixture -def warning_caplog(caplog): +def warning_caplog( + caplog: pytest.LogCaptureFixture, +) -> Generator[pytest.LogCaptureFixture]: """Fixture to capture nest init warning messages.""" with caplog.at_level(logging.WARNING, logger="homeassistant.components.nest"): yield caplog @@ -77,7 +83,9 @@ def failing_subscriber(subscriber_side_effect: Any) -> YieldFixture[FakeSubscrib yield subscriber -async def test_setup_success(hass: HomeAssistant, error_caplog, setup_platform) -> None: +async def test_setup_success( + hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, setup_platform +) -> None: """Test successful setup.""" await setup_platform() assert not error_caplog.records @@ -108,7 +116,10 @@ async def test_setup_configuration_failure( @pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()]) async def test_setup_susbcriber_failure( - hass: HomeAssistant, caplog, failing_subscriber, setup_base_platform + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + failing_subscriber, + setup_base_platform, ) -> None: """Test configuration error.""" await setup_base_platform() @@ -120,7 +131,7 @@ async def test_setup_susbcriber_failure( async def test_setup_device_manager_failure( - hass: HomeAssistant, caplog, setup_base_platform + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, setup_base_platform ) -> None: """Test device manager api failure.""" with ( @@ -160,7 +171,7 @@ async def test_subscriber_auth_failure( @pytest.mark.parametrize("subscriber_id", [(None)]) async def test_setup_missing_subscriber_id( - hass: HomeAssistant, warning_caplog, setup_base_platform + hass: HomeAssistant, warning_caplog: pytest.LogCaptureFixture, setup_base_platform ) -> None: """Test missing subscriber id from configuration.""" await setup_base_platform() @@ -173,7 +184,10 @@ async def test_setup_missing_subscriber_id( @pytest.mark.parametrize("subscriber_side_effect", [(ConfigurationException())]) async def test_subscriber_configuration_failure( - hass: HomeAssistant, error_caplog, setup_base_platform, failing_subscriber + hass: HomeAssistant, + error_caplog: pytest.LogCaptureFixture, + setup_base_platform, + failing_subscriber, ) -> None: """Test configuration error.""" await setup_base_platform() @@ -186,7 +200,7 @@ async def test_subscriber_configuration_failure( @pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_empty_config( - hass: HomeAssistant, error_caplog, config, setup_platform + hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, config, setup_platform ) -> None: """Test setup is a no-op with not config.""" await setup_platform() @@ -241,6 +255,23 @@ async def test_remove_entry( assert not entries +async def test_home_assistant_stop( + hass: HomeAssistant, + setup_platform: PlatformSetup, + subscriber: FakeSubscriber, +) -> None: + """Test successful subscriber shutdown when HomeAssistant stops.""" + await setup_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.state is ConfigEntryState.LOADED + + await hass.async_stop() + assert subscriber.stop_calls == 1 + + async def test_remove_entry_delete_subscriber_failure( hass: HomeAssistant, setup_base_platform ) -> None: diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 419b3648124..f4fb8bdb623 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -4,7 +4,6 @@ These tests simulate recent camera events received by the subscriber exposed as media in the media source. """ -from collections.abc import Generator import datetime from http import HTTPStatus import io @@ -16,6 +15,7 @@ import av from google_nest_sdm.event import EventMessage import numpy as np import pytest +from typing_extensions import Generator from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_source import ( @@ -95,7 +95,7 @@ def platforms() -> list[str]: @pytest.fixture(autouse=True) -async def setup_components(hass) -> None: +async def setup_components(hass: HomeAssistant) -> None: """Fixture to initialize the integration.""" await async_setup_component(hass, "media_source", {}) @@ -196,7 +196,7 @@ def create_event_message(event_data, timestamp, device_id=None): """Create an EventMessage for a single event type.""" if device_id is None: device_id = DEVICE_ID - return EventMessage( + return EventMessage.create_event( { "eventId": f"{EVENT_ID}-{timestamp}", "timestamp": timestamp.isoformat(timespec="seconds"), @@ -249,7 +249,9 @@ async def test_no_eligible_devices(hass: HomeAssistant, setup_platform) -> None: @pytest.mark.parametrize("device_traits", [CAMERA_TRAITS, BATTERY_CAMERA_TRAITS]) -async def test_supported_device(hass: HomeAssistant, setup_platform) -> None: +async def test_supported_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, setup_platform +) -> None: """Test a media source with a supported camera.""" await setup_platform() @@ -257,7 +259,6 @@ async def test_supported_device(hass: HomeAssistant, setup_platform) -> None: camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -308,6 +309,7 @@ async def test_integration_unloaded(hass: HomeAssistant, auth, setup_platform) - async def test_camera_event( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, subscriber, auth, setup_platform, @@ -319,7 +321,6 @@ async def test_camera_event( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -410,7 +411,11 @@ async def test_camera_event( async def test_event_order( - hass: HomeAssistant, auth, subscriber, setup_platform + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + auth, + subscriber, + setup_platform, ) -> None: """Test multiple events are in descending timestamp order.""" await setup_platform() @@ -449,7 +454,6 @@ async def test_event_order( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -478,6 +482,7 @@ async def test_event_order( async def test_multiple_image_events_in_session( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, subscriber, @@ -494,7 +499,6 @@ async def test_multiple_image_events_in_session( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -593,6 +597,7 @@ async def test_multiple_image_events_in_session( @pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) async def test_multiple_clip_preview_events_in_session( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, subscriber, @@ -608,7 +613,6 @@ async def test_multiple_clip_preview_events_in_session( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -691,12 +695,11 @@ async def test_multiple_clip_preview_events_in_session( async def test_browse_invalid_device_id( - hass: HomeAssistant, auth, setup_platform + hass: HomeAssistant, device_registry: dr.DeviceRegistry, auth, setup_platform ) -> None: """Test a media source request for an invalid device id.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -712,12 +715,11 @@ async def test_browse_invalid_device_id( async def test_browse_invalid_event_id( - hass: HomeAssistant, auth, setup_platform + hass: HomeAssistant, device_registry: dr.DeviceRegistry, auth, setup_platform ) -> None: """Test a media source browsing for an invalid event id.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -735,12 +737,11 @@ async def test_browse_invalid_event_id( async def test_resolve_missing_event_id( - hass: HomeAssistant, auth, setup_platform + hass: HomeAssistant, device_registry: dr.DeviceRegistry, auth, setup_platform ) -> None: """Test a media source request missing an event id.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -767,12 +768,11 @@ async def test_resolve_invalid_device_id( async def test_resolve_invalid_event_id( - hass: HomeAssistant, auth, setup_platform + hass: HomeAssistant, device_registry: dr.DeviceRegistry, auth, setup_platform ) -> None: """Test resolving media for an invalid event id.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -793,6 +793,7 @@ async def test_resolve_invalid_event_id( @pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) async def test_camera_event_clip_preview( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, mp4, @@ -820,7 +821,6 @@ async def test_camera_event_clip_preview( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -911,11 +911,14 @@ async def test_event_media_render_invalid_device_id( async def test_event_media_render_invalid_event_id( - hass: HomeAssistant, auth, hass_client: ClientSessionGenerator, setup_platform + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + auth, + hass_client: ClientSessionGenerator, + setup_platform, ) -> None: """Test event media API called with an invalid device id.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -927,6 +930,7 @@ async def test_event_media_render_invalid_event_id( async def test_event_media_failure( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, subscriber, @@ -955,7 +959,6 @@ async def test_event_media_failure( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -982,6 +985,7 @@ async def test_event_media_failure( async def test_media_permission_unauthorized( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, @@ -993,7 +997,6 @@ async def test_media_permission_unauthorized( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1012,6 +1015,7 @@ async def test_media_permission_unauthorized( async def test_multiple_devices( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, create_device, @@ -1029,7 +1033,6 @@ async def test_multiple_devices( ) await setup_platform() - device_registry = dr.async_get(hass) device1 = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device1 device2 = device_registry.async_get_device(identifiers={(DOMAIN, device_id2)}) @@ -1094,7 +1097,7 @@ async def test_multiple_devices( @pytest.fixture -def event_store() -> Generator[None, None, None]: +def event_store() -> Generator[None]: """Persist changes to event store immediately.""" with patch( "homeassistant.components.nest.media_source.STORAGE_SAVE_DELAY_SECONDS", @@ -1106,6 +1109,7 @@ def event_store() -> Generator[None, None, None]: @pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) async def test_media_store_persistence( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, event_store, @@ -1116,7 +1120,6 @@ async def test_media_store_persistence( """Test the disk backed media store persistence.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1169,7 +1172,6 @@ async def test_media_store_persistence( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1200,6 +1202,7 @@ async def test_media_store_persistence( @pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) async def test_media_store_save_filesystem_error( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, subscriber, @@ -1228,7 +1231,6 @@ async def test_media_store_save_filesystem_error( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1253,6 +1255,7 @@ async def test_media_store_save_filesystem_error( async def test_media_store_load_filesystem_error( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, subscriber, @@ -1265,7 +1268,6 @@ async def test_media_store_load_filesystem_error( camera = hass.states.get("camera.front") assert camera is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1307,6 +1309,7 @@ async def test_media_store_load_filesystem_error( @pytest.mark.parametrize(("device_traits", "cache_size"), [(BATTERY_CAMERA_TRAITS, 5)]) async def test_camera_event_media_eviction( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, subscriber, @@ -1315,7 +1318,6 @@ async def test_camera_event_media_eviction( """Test media files getting evicted from the cache.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1384,6 +1386,7 @@ async def test_camera_event_media_eviction( async def test_camera_image_resize( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, auth, hass_client: ClientSessionGenerator, subscriber, @@ -1392,7 +1395,6 @@ async def test_camera_image_resize( """Test scaling a thumbnail for an event image.""" await setup_platform() - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME diff --git a/tests/components/nest/test_sensor.py b/tests/components/nest/test_sensor.py index 65a74eb93e0..2339d72ebc7 100644 --- a/tests/components/nest/test_sensor.py +++ b/tests/components/nest/test_sensor.py @@ -41,7 +41,11 @@ def device_traits() -> dict[str, Any]: async def test_thermostat_device( - hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + create_device: CreateDevice, + setup_platform: PlatformSetup, ) -> None: """Test a thermostat with temperature and humidity sensors.""" create_device.create( @@ -77,16 +81,14 @@ async def test_thermostat_device( assert humidity.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert humidity.attributes.get(ATTR_FRIENDLY_NAME) == "My Sensor Humidity" - registry = er.async_get(hass) - entry = registry.async_get("sensor.my_sensor_temperature") + entry = entity_registry.async_get("sensor.my_sensor_temperature") assert entry.unique_id == f"{DEVICE_ID}-temperature" assert entry.domain == "sensor" - entry = registry.async_get("sensor.my_sensor_humidity") + entry = entity_registry.async_get("sensor.my_sensor_humidity") assert entry.unique_id == f"{DEVICE_ID}-humidity" assert entry.domain == "sensor" - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My Sensor" assert device.model == "Thermostat" @@ -215,7 +217,7 @@ async def test_event_updates_sensor( assert temperature.state == "25.1" # Simulate a pubsub message received by the subscriber with a trait update - event = EventMessage( + event = EventMessage.create_event( { "eventId": "some-event-id", "timestamp": "2019-01-01T00:00:01Z", @@ -240,7 +242,11 @@ async def test_event_updates_sensor( @pytest.mark.parametrize("device_type", ["some-unknown-type"]) async def test_device_with_unknown_type( - hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + create_device: CreateDevice, + setup_platform: PlatformSetup, ) -> None: """Test a device without a custom name, inferring name from structure.""" create_device.create( @@ -257,12 +263,10 @@ async def test_device_with_unknown_type( assert temperature.state == "25.1" assert temperature.attributes.get(ATTR_FRIENDLY_NAME) == "My Sensor Temperature" - registry = er.async_get(hass) - entry = registry.async_get("sensor.my_sensor_temperature") + entry = entity_registry.async_get("sensor.my_sensor_temperature") assert entry.unique_id == f"{DEVICE_ID}-temperature" assert entry.domain == "sensor" - device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My Sensor" assert device.model is None diff --git a/tests/components/netatmo/test_binary_sensor.py b/tests/components/netatmo/test_binary_sensor.py index 53aea461fde..7b841ba204e 100644 --- a/tests/components/netatmo/test_binary_sensor.py +++ b/tests/components/netatmo/test_binary_sensor.py @@ -9,8 +9,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .common import snapshot_platform_entities + from tests.common import MockConfigEntry -from tests.components.netatmo.common import snapshot_platform_entities @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index b25f78b5e2f..4b908580346 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -750,7 +750,6 @@ async def test_service_preset_mode_with_end_time_thermostats( }, blocking=True, ) - await hass.async_block_till_done() # Test setting a valid preset mode (that allow an end datetime in Netatmo == THERM_MODES) without an end datetime with pytest.raises(MultipleInvalid): @@ -763,7 +762,6 @@ async def test_service_preset_mode_with_end_time_thermostats( }, blocking=True, ) - await hass.async_block_till_done() async def test_service_preset_mode_already_boost_valves( @@ -914,7 +912,6 @@ async def test_service_preset_mode_invalid( {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/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 933f782c9d9..29a065c3be3 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -4,6 +4,7 @@ from ipaddress import ip_address from unittest.mock import patch from pyatmo.const import ALL_SCOPES +import pytest from homeassistant import config_entries from homeassistant.components import zeroconf @@ -59,11 +60,11 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" @@ -226,11 +227,11 @@ async def test_option_flow_wrong_coordinates(hass: HomeAssistant) -> None: assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test initialization of the reauth flow.""" diff --git a/tests/components/netatmo/test_device_trigger.py b/tests/components/netatmo/test_device_trigger.py index 566bc72426b..ad1e9bd8cb9 100644 --- a/tests/components/netatmo/test_device_trigger.py +++ b/tests/components/netatmo/test_device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.components.netatmo.const import ( ) from homeassistant.components.netatmo.device_trigger import SUBTYPES from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -27,7 +27,7 @@ from tests.common import ( @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -113,7 +113,7 @@ async def test_get_triggers( ) async def test_if_fires_on_event( hass: HomeAssistant, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, platform, @@ -196,7 +196,7 @@ async def test_if_fires_on_event( ) async def test_if_fires_on_event_legacy( hass: HomeAssistant, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, platform, @@ -266,18 +266,16 @@ async def test_if_fires_on_event_legacy( ("platform", "camera_type", "event_type", "sub_type"), [ ("climate", "Smart Valve", trigger, subtype) - for trigger in SUBTYPES - for subtype in SUBTYPES[trigger] + for trigger, subtype in SUBTYPES.items() ] + [ ("climate", "Smart Thermostat", trigger, subtype) - for trigger in SUBTYPES - for subtype in SUBTYPES[trigger] + for trigger, subtype in SUBTYPES.items() ], ) async def test_if_fires_on_event_with_subtype( hass: HomeAssistant, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, platform, diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 8d8dfae9eeb..5fdf4f8ea35 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -87,8 +87,8 @@ async def test_setup_component( assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) > 0 - for config_entry in hass.config_entries.async_entries("netatmo"): - await hass.config_entries.async_remove(config_entry.entry_id) + for entry in hass.config_entries.async_entries("netatmo"): + await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 @@ -160,8 +160,8 @@ async def test_setup_component_with_webhook( await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK) assert hass.states.get(climate_entity_livingroom).state == "heat" - for config_entry in hass.config_entries.async_entries("netatmo"): - await hass.config_entries.async_remove(config_entry.entry_id) + for entry in hass.config_entries.async_entries("netatmo"): + await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 @@ -246,8 +246,8 @@ async def test_setup_with_cloud( await hass.async_block_till_done() assert hass.config_entries.async_entries(DOMAIN) - for config_entry in hass.config_entries.async_entries("netatmo"): - await hass.config_entries.async_remove(config_entry.entry_id) + for entry in hass.config_entries.async_entries("netatmo"): + await hass.config_entries.async_remove(entry.entry_id) fake_delete_cloudhook.assert_called_once() await hass.async_block_till_done() @@ -479,8 +479,8 @@ async def test_setup_component_invalid_token( notifications = async_get_persistent_notifications(hass) assert len(notifications) > 0 - for config_entry in hass.config_entries.async_entries("netatmo"): - await hass.config_entries.async_remove(config_entry.entry_id) + for entry in hass.config_entries.async_entries("netatmo"): + await hass.config_entries.async_remove(entry.entry_id) async def test_devices( diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 4fa64e59b11..3c16e6e60f9 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -210,6 +210,7 @@ async def test_process_health(health: int, expected: str) -> None: ) async def test_weather_sensor_enabling( hass: HomeAssistant, + entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, uid: str, name: str, @@ -221,8 +222,7 @@ async def test_weather_sensor_enabling( states_before = len(hass.states.async_all()) assert hass.states.get(f"sensor.{name}") is None - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "netatmo", uid, diff --git a/tests/components/netgear_lte/test_binary_sensor.py b/tests/components/netgear_lte/test_binary_sensor.py index 5fbbcfe06f6..e44b7de5da0 100644 --- a/tests/components/netgear_lte/test_binary_sensor.py +++ b/tests/components/netgear_lte/test_binary_sensor.py @@ -1,5 +1,6 @@ """The tests for Netgear LTE binary sensor platform.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -8,9 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: None, setup_integration: None, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, diff --git a/tests/components/netgear_lte/test_config_flow.py b/tests/components/netgear_lte/test_config_flow.py index 6b969e33475..ec649f4def0 100644 --- a/tests/components/netgear_lte/test_config_flow.py +++ b/tests/components/netgear_lte/test_config_flow.py @@ -2,11 +2,8 @@ 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.config_entries import SOURCE_USER from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,7 +24,7 @@ async def test_flow_user_form(hass: HomeAssistant, connection: None) -> None: context={CONF_SOURCE: SOURCE_USER}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" with _patch_setup(): @@ -35,20 +32,19 @@ async def test_flow_user_form(hass: HomeAssistant, connection: None) -> None: result["flow_id"], user_input=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] is FlowResultType.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 + hass: HomeAssistant, setup_integration: None ) -> None: """Test config flow aborts when already configured.""" result = await hass.config_entries.flow.async_init( DOMAIN, - context={CONF_SOURCE: source}, + context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA, ) @@ -66,7 +62,7 @@ async def test_flow_user_cannot_connect( data=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" @@ -81,32 +77,6 @@ async def test_flow_user_unknown_error(hass: HomeAssistant, unknown: None) -> No result["flow_id"], user_input=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] is FlowResultType.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 index ef3109123fa..1bd3dff1eff 100644 --- a/tests/components/netgear_lte/test_init.py +++ b/tests/components/netgear_lte/test_init.py @@ -1,16 +1,26 @@ """Test Netgear LTE integration.""" +from datetime import timedelta +from unittest.mock import patch + +from eternalegypt.eternalegypt import Error +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.netgear_lte.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +import homeassistant.util.dt as dt_util from .conftest import CONF_DATA +from tests.common import async_fire_time_changed -async def test_setup_unload(hass: HomeAssistant, setup_integration: None) -> None: + +@pytest.mark.usefixtures("setup_integration") +async def test_setup_unload(hass: HomeAssistant) -> None: """Test setup and unload.""" entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.state is ConfigEntryState.LOADED @@ -23,19 +33,18 @@ async def test_setup_unload(hass: HomeAssistant, setup_integration: None) -> Non assert not hass.data.get(DOMAIN) -async def test_async_setup_entry_not_ready( - hass: HomeAssistant, setup_cannot_connect: None -) -> None: +@pytest.mark.usefixtures("setup_cannot_connect") +async def test_async_setup_entry_not_ready(hass: HomeAssistant) -> 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 is ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("setup_integration") async def test_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - setup_integration: None, snapshot: SnapshotAssertion, ) -> None: """Test device info.""" @@ -43,3 +52,18 @@ async def test_device( await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) assert device == snapshot + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") +async def test_update_failed(hass: HomeAssistant) -> None: + """Test coordinator throws UpdateFailed after failed update.""" + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.information", + side_effect=Error, + ) as updater: + next_update = dt_util.utcnow() + timedelta(seconds=10) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + updater.assert_called_once() + state = hass.states.get("sensor.netgear_lm1200_radio_quality") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/netgear_lte/test_sensor.py b/tests/components/netgear_lte/test_sensor.py index 075c3db3b08..14533d7216c 100644 --- a/tests/components/netgear_lte/test_sensor.py +++ b/tests/components/netgear_lte/test_sensor.py @@ -1,5 +1,6 @@ """The tests for Netgear LTE sensor platform.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.netgear_lte.const import DOMAIN @@ -8,9 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: None, setup_integration: None, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, diff --git a/tests/components/network/conftest.py b/tests/components/network/conftest.py index 8b1b383ae42..36d9c449d27 100644 --- a/tests/components/network/conftest.py +++ b/tests/components/network/conftest.py @@ -1,13 +1,21 @@ """Tests for the Network Configuration integration.""" +from unittest.mock import _patch + import pytest - - -@pytest.fixture(autouse=True) -def mock_get_source_ip(): - """Override mock of network util's async_get_source_ip.""" +from typing_extensions import Generator @pytest.fixture(autouse=True) def mock_network(): """Override mock of network util's async_get_adapters.""" + + +@pytest.fixture(autouse=True) +def override_mock_get_source_ip( + mock_get_source_ip: _patch, +) -> Generator[None]: + """Override mock of network util's async_get_source_ip.""" + mock_get_source_ip.stop() + yield + mock_get_source_ip.start() diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index b02692e5086..57a12868d0a 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -20,6 +20,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from tests.typing import WebSocketGenerator + _NO_LOOPBACK_IPADDR = "192.168.1.5" _LOOPBACK_IPADDR = "127.0.0.1" @@ -34,6 +36,7 @@ def _mock_cond_socket(sockname): class CondMockSock(MagicMock): def connect(self, addr): """Mock connect that stores addr.""" + # pylint: disable-next=attribute-defined-outside-init self._addr = addr[0] def getsockname(self): @@ -409,7 +412,9 @@ async def test_interfaces_configured_from_storage( async def test_interfaces_configured_from_storage_websocket_update( - hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], ) -> None: """Test settings from storage can be updated via websocket api.""" hass_storage[STORAGE_KEY] = { diff --git a/tests/components/nexia/test_binary_sensor.py b/tests/components/nexia/test_binary_sensor.py index e175afe6214..0abb709f6aa 100644 --- a/tests/components/nexia/test_binary_sensor.py +++ b/tests/components/nexia/test_binary_sensor.py @@ -20,7 +20,7 @@ async def test_create_binary_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("binary_sensor.downstairs_east_wing_blower_active") @@ -32,5 +32,5 @@ async def test_create_binary_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) diff --git a/tests/components/nexia/test_climate.py b/tests/components/nexia/test_climate.py index 900838547f2..1d248e5ec5f 100644 --- a/tests/components/nexia/test_climate.py +++ b/tests/components/nexia/test_climate.py @@ -39,7 +39,7 @@ async def test_climate_zones(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("climate.kitchen") @@ -72,5 +72,5 @@ async def test_climate_zones(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py index 58ad74c859d..5984a0af721 100644 --- a/tests/components/nexia/test_init.py +++ b/tests/components/nexia/test_init.py @@ -6,7 +6,6 @@ from homeassistant.components.nexia.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .util import async_init_integration @@ -21,23 +20,24 @@ async def test_setup_retry_client_os_error(hass: HomeAssistant) -> None: async def test_device_remove_devices( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test we can only remove a device that no longer exists.""" await async_setup_component(hass, "config", {}) config_entry = await async_init_integration(hass) entry_id = config_entry.entry_id - device_registry = dr.async_get(hass) - registry: EntityRegistry = er.async_get(hass) - entity = registry.entities["sensor.nick_office_temperature"] + entity = entity_registry.entities["sensor.nick_office_temperature"] live_zone_device_entry = device_registry.async_get(entity.device_id) client = await hass_ws_client(hass) response = await client.remove_device(live_zone_device_entry.id, entry_id) assert not response["success"] - entity = registry.entities["sensor.master_suite_humidity"] + entity = entity_registry.entities["sensor.master_suite_humidity"] live_thermostat_device_entry = device_registry.async_get(entity.device_id) response = await client.remove_device(live_thermostat_device_entry.id, entry_id) assert not response["success"] diff --git a/tests/components/nexia/test_number.py b/tests/components/nexia/test_number.py index 7f4c5f92ab6..ee621912807 100644 --- a/tests/components/nexia/test_number.py +++ b/tests/components/nexia/test_number.py @@ -26,7 +26,7 @@ async def test_create_fan_speed_number_entities(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("number.downstairs_east_wing_fan_speed") @@ -40,7 +40,7 @@ async def test_create_fan_speed_number_entities(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) diff --git a/tests/components/nexia/test_scene.py b/tests/components/nexia/test_scene.py index 20f214fff27..5d9ae30c7e1 100644 --- a/tests/components/nexia/test_scene.py +++ b/tests/components/nexia/test_scene.py @@ -35,7 +35,7 @@ async def test_automation_scenes(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("scene.power_outage") @@ -55,7 +55,7 @@ async def test_automation_scenes(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("scene.power_restored") @@ -73,5 +73,5 @@ async def test_automation_scenes(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) diff --git a/tests/components/nexia/test_sensor.py b/tests/components/nexia/test_sensor.py index 1f595da43d1..ec9ed256617 100644 --- a/tests/components/nexia/test_sensor.py +++ b/tests/components/nexia/test_sensor.py @@ -23,7 +23,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.nick_office_zone_setpoint_status") @@ -35,7 +35,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.nick_office_zone_status") @@ -48,7 +48,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.master_suite_air_cleaner_mode") @@ -61,7 +61,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.master_suite_current_compressor_speed") @@ -75,7 +75,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.master_suite_outdoor_temperature") @@ -90,7 +90,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.master_suite_humidity") @@ -105,7 +105,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.master_suite_requested_compressor_speed") @@ -119,7 +119,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) state = hass.states.get("sensor.master_suite_system_status") @@ -132,5 +132,5 @@ async def test_create_sensors(hass: HomeAssistant) -> None: # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all( - state.attributes[key] == expected_attributes[key] for key in expected_attributes + state.attributes[key] == value for key, value in expected_attributes.items() ) diff --git a/tests/components/nextbus/test_config_flow.py b/tests/components/nextbus/test_config_flow.py index 1af2cff0897..0a64bc97d9a 100644 --- a/tests/components/nextbus/test_config_flow.py +++ b/tests/components/nextbus/test_config_flow.py @@ -1,9 +1,9 @@ """Test the NextBus config flow.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant import config_entries, setup from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN @@ -13,7 +13,7 @@ from homeassistant.data_entry_flow import FlowResultType @pytest.fixture -def mock_setup_entry() -> Generator[MagicMock, None, None]: +def mock_setup_entry() -> Generator[MagicMock]: """Create a mock for the nextbus component setup.""" with patch( "homeassistant.components.nextbus.async_setup_entry", @@ -23,7 +23,7 @@ def mock_setup_entry() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_nextbus() -> Generator[MagicMock, None, None]: +def mock_nextbus() -> Generator[MagicMock]: """Create a mock py_nextbus module.""" with patch("homeassistant.components.nextbus.config_flow.NextBusClient") as client: yield client diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 5e4f322e1eb..3630ff88855 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -1,12 +1,12 @@ """The tests for the nexbus sensor component.""" -from collections.abc import Generator from copy import deepcopy from unittest.mock import MagicMock, patch from urllib.error import HTTPError from py_nextbus.client import NextBusFormatError, NextBusHTTPError import pytest +from typing_extensions import Generator from homeassistant.components import sensor from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN @@ -66,7 +66,7 @@ BASIC_RESULTS = { @pytest.fixture -def mock_nextbus() -> Generator[MagicMock, None, None]: +def mock_nextbus() -> Generator[MagicMock]: """Create a mock py_nextbus module.""" with patch("homeassistant.components.nextbus.coordinator.NextBusClient") as client: yield client @@ -75,7 +75,7 @@ def mock_nextbus() -> Generator[MagicMock, None, None]: @pytest.fixture def mock_nextbus_predictions( mock_nextbus: MagicMock, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Create a mock of NextBusClient predictions.""" instance = mock_nextbus.return_value instance.get_predictions_for_multi_stops.return_value = BASIC_RESULTS diff --git a/tests/components/nextcloud/conftest.py b/tests/components/nextcloud/conftest.py index 58b37359d42..d6cd39e7fc8 100644 --- a/tests/components/nextcloud/conftest.py +++ b/tests/components/nextcloud/conftest.py @@ -1,9 +1,9 @@ """Fixtrues for the Nextcloud integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator @pytest.fixture @@ -15,7 +15,7 @@ def mock_nextcloud_monitor() -> Mock: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.nextcloud.async_setup_entry", return_value=True diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index e7ea7a3f56b..eddf5a1cc5a 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta from unittest.mock import patch from nextdns import ApiError +import pytest from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -16,9 +17,9 @@ from . import init_integration, mock_nextdns from tests.common import async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: @@ -29,9 +30,9 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_availability( hass: HomeAssistant, - entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, ) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 2936bad1c67..059585e9ffe 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -29,9 +29,9 @@ from . import init_integration, mock_nextdns from tests.common import async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch( hass: HomeAssistant, - entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index 00d4c92c68b..c44875414e2 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -1,12 +1,12 @@ """Test configuration for Nibe Heat Pump.""" -from collections.abc import Generator from contextlib import ExitStack from unittest.mock import AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory from nibe.exceptions import CoilNotFoundException import pytest +from typing_extensions import Generator from homeassistant.core import HomeAssistant @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Make sure we never actually run setup.""" with patch( "homeassistant.components.nibe_heatpump.async_setup_entry", return_value=True diff --git a/tests/components/nibe_heatpump/snapshots/test_climate.ambr b/tests/components/nibe_heatpump/snapshots/test_climate.ambr index 0c5cd46f5db..fb3e2d1003b 100644 --- a/tests/components/nibe_heatpump/snapshots/test_climate.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_climate.ambr @@ -319,6 +319,214 @@ 'state': 'auto', }) # --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][cooling] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][heating (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][heating (only)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][heating] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][idle (mixing valve)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][initial] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': 21.0, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][off (auto)] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_basic[Model.F730-s1-climate.climate_system_s1][unavailable] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.5, + 'friendly_name': 'Climate System S1', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'target_temp_step': 0.5, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate_system_s1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- # name: test_basic[Model.S320-s1-climate.climate_system_s1][cooling] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index e660340c549..5015bba4092 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -41,7 +41,7 @@ async def test_reset_button( entity_id: str, coils: dict[int, Any], freezer_ticker: Any, -): +) -> None: """Test reset button.""" unit = UNIT_COILGROUPS[model.series]["main"] diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 3a468e51e83..073e142f7ff 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from . import MockConnection, async_add_model @@ -62,8 +63,10 @@ def _setup_climate_group( [ (Model.S320, "s1", "climate.climate_system_s1"), (Model.F1155, "s2", "climate.climate_system_s2"), + (Model.F730, "s1", "climate.climate_system_s1"), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_basic( hass: HomeAssistant, mock_connection: MockConnection, @@ -71,7 +74,6 @@ async def test_basic( climate_id: str, entity_id: str, coils: dict[int, Any], - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test setting of value.""" @@ -111,6 +113,7 @@ async def test_basic( (Model.F1155, "s3", "climate.climate_system_s3"), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_active_accessory( hass: HomeAssistant, mock_connection: MockConnection, @@ -118,7 +121,6 @@ async def test_active_accessory( climate_id: str, entity_id: str, coils: dict[int, Any], - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test climate groups that can be deactivated by configuration.""" @@ -139,17 +141,17 @@ async def test_active_accessory( (Model.F1155, "s2", "climate.climate_system_s2"), ], ) -async def test_set_temperature( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_temperature_supported_cooling( hass: HomeAssistant, mock_connection: MockConnection, model: Model, climate_id: str, entity_id: str, coils: dict[int, Any], - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: - """Test setting temperature.""" + """Test setting temperature for models with cooling support.""" climate, _ = _setup_climate_group(coils, model, climate_id) await async_add_model(hass, model) @@ -195,7 +197,7 @@ async def test_set_temperature( ] mock_connection.write_coil.reset_mock() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( PLATFORM_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -226,6 +228,62 @@ async def test_set_temperature( mock_connection.write_coil.reset_mock() +@pytest.mark.parametrize( + ("model", "climate_id", "entity_id"), + [ + (Model.F730, "s1", "climate.climate_system_s1"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_temperature_unsupported_cooling( + hass: HomeAssistant, + mock_connection: MockConnection, + model: Model, + climate_id: str, + entity_id: str, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting temperature for models that do not support cooling.""" + climate, _ = _setup_climate_group(coils, model, climate_id) + + await async_add_model(hass, model) + + coil_setpoint_heat = mock_connection.heatpump.get_coil_by_address( + climate.setpoint_heat + ) + + # Set temperature to heat + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_connection.write_coil.mock_calls == [ + call(CoilData(coil_setpoint_heat, 22)) + ] + + # Attempt to set temperature to cool should raise ServiceValidationError + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TEMPERATURE: 22, + ATTR_HVAC_MODE: HVACMode.COOL, + }, + blocking=True, + ) + mock_connection.write_coil.reset_mock() + + @pytest.mark.parametrize( ("hvac_mode", "cooling_with_room_sensor", "use_room_sensor"), [ @@ -239,8 +297,10 @@ async def test_set_temperature( [ (Model.S320, "s1", "climate.climate_system_s1"), (Model.F1155, "s2", "climate.climate_system_s2"), + (Model.F730, "s1", "climate.climate_system_s1"), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_set_hvac_mode( hass: HomeAssistant, mock_connection: MockConnection, @@ -251,7 +311,6 @@ async def test_set_hvac_mode( use_room_sensor: str, hvac_mode: HVACMode, coils: dict[int, Any], - entity_registry_enabled_by_default: None, ) -> None: """Test setting a hvac mode.""" climate, unit = _setup_climate_group(coils, model, climate_id) @@ -283,36 +342,36 @@ async def test_set_hvac_mode( @pytest.mark.parametrize( - ("model", "climate_id", "entity_id"), + ("model", "climate_id", "entity_id", "unsupported_mode"), [ - (Model.S320, "s1", "climate.climate_system_s1"), - (Model.F1155, "s2", "climate.climate_system_s2"), + (Model.S320, "s1", "climate.climate_system_s1", HVACMode.DRY), + (Model.F1155, "s2", "climate.climate_system_s2", HVACMode.DRY), + (Model.F730, "s1", "climate.climate_system_s1", HVACMode.COOL), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_set_invalid_hvac_mode( hass: HomeAssistant, mock_connection: MockConnection, model: Model, climate_id: str, entity_id: str, + unsupported_mode: str, coils: dict[int, Any], - entity_registry_enabled_by_default: None, ) -> None: """Test setting an invalid hvac mode.""" _setup_climate_group(coils, model, climate_id) await async_add_model(hass, model) - - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( PLATFORM_DOMAIN, SERVICE_SET_HVAC_MODE, { ATTR_ENTITY_ID: entity_id, - ATTR_HVAC_MODE: HVACMode.DRY, + ATTR_HVAC_MODE: unsupported_mode, }, blocking=True, ) - await hass.async_block_till_done() assert mock_connection.write_coil.mock_calls == [] diff --git a/tests/components/nibe_heatpump/test_coordinator.py b/tests/components/nibe_heatpump/test_coordinator.py index ffd5c545645..2fade8e34d7 100644 --- a/tests/components/nibe_heatpump/test_coordinator.py +++ b/tests/components/nibe_heatpump/test_coordinator.py @@ -22,10 +22,10 @@ async def fixture_single_platform(): yield +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_partial_refresh( hass: HomeAssistant, coils: dict[int, Any], - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test that coordinator can handle partial fields.""" @@ -45,10 +45,10 @@ async def test_partial_refresh( assert data == snapshot(name="3. Sensor is available") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_invalid_coil( hass: HomeAssistant, coils: dict[int, Any], - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, freezer_ticker: Any, ) -> None: @@ -67,10 +67,10 @@ async def test_invalid_coil( assert hass.states.get(entity_id) == snapshot(name="Sensor is not available") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_pushed_update( hass: HomeAssistant, coils: dict[int, Any], - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, mock_connection: MockConnection, freezer_ticker: Any, @@ -97,10 +97,10 @@ async def test_pushed_update( assert hass.states.get(entity_id) == snapshot(name="4. final values") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_shutdown( hass: HomeAssistant, coils: dict[int, Any], - entity_registry_enabled_by_default: None, mock_connection: MockConnection, freezer_ticker: Any, ) -> None: diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index 99f8ab22b6c..73fed9ee08a 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -43,6 +43,7 @@ async def fixture_single_platform(): (Model.F750, 47062, "number.hw_charge_offset_47062", None), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update( hass: HomeAssistant, model: Model, @@ -50,7 +51,6 @@ async def test_update( address: int, value: Any, coils: dict[int, Any], - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test setting of value.""" @@ -73,6 +73,7 @@ async def test_update( (Model.F750, 47062, "number.hw_charge_offset_47062", 10), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_set_value( hass: HomeAssistant, mock_connection: AsyncMock, @@ -81,7 +82,6 @@ async def test_set_value( address: int, value: Any, coils: dict[int, Any], - entity_registry_enabled_by_default: None, ) -> None: """Test setting of value.""" coils[address] = 0 diff --git a/tests/components/nightscout/__init__.py b/tests/components/nightscout/__init__.py index da421d5bba9..551ecffbed1 100644 --- a/tests/components/nightscout/__init__.py +++ b/tests/components/nightscout/__init__.py @@ -8,6 +8,7 @@ from py_nightscout.models import SGV, ServerStatus from homeassistant.components.nightscout.const import DOMAIN from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -30,7 +31,7 @@ SERVER_STATUS_STATUS_ONLY = ServerStatus.new_from_json_dict( ) -async def init_integration(hass) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Nightscout integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, @@ -53,7 +54,7 @@ async def init_integration(hass) -> MockConfigEntry: return entry -async def init_integration_unavailable(hass) -> MockConfigEntry: +async def init_integration_unavailable(hass: HomeAssistant) -> MockConfigEntry: """Set up the Nightscout integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, @@ -76,7 +77,7 @@ async def init_integration_unavailable(hass) -> MockConfigEntry: return entry -async def init_integration_empty_response(hass) -> MockConfigEntry: +async def init_integration_empty_response(hass: HomeAssistant) -> MockConfigEntry: """Set up the Nightscout integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/nina/__init__.py b/tests/components/nina/__init__.py index 923df6b6337..702bd78715b 100644 --- a/tests/components/nina/__init__.py +++ b/tests/components/nina/__init__.py @@ -24,20 +24,24 @@ def mocked_request_function(url: str) -> dict[str, Any]: load_fixture("sample_labels.json", "nina") ) - if "https://warnung.bund.de/api31/dashboard/" in url: + if "https://warnung.bund.de/api31/dashboard/" in url: # codespell:ignore bund return dummy_response - if "https://warnung.bund.de/api/appdata/gsb/labels/de_labels.json" in url: + if ( + "https://warnung.bund.de/api/appdata/gsb/labels/de_labels.json" # codespell:ignore bund + in url + ): return dummy_response_labels if ( url - == "https://www.xrepository.de/api/xrepository/urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31/download/Regionalschl_ssel_2021-07-31.json" + == "https://www.xrepository.de/api/xrepository/urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31/download/Regionalschl_ssel_2021-07-31.json" # codespell:ignore bund ): return dummy_response_regions - warning_id = url.replace("https://warnung.bund.de/api31/warnings/", "").replace( - ".json", "" - ) + warning_id = url.replace( + "https://warnung.bund.de/api31/warnings/", # codespell:ignore bund + "", + ).replace(".json", "") return dummy_response_details[warning_id] diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index 7f4f000cf3a..a7f9a980960 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -48,7 +48,7 @@ ENTRY_DATA_NO_AREA: dict[str, Any] = { } -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the creation and values of the NINA sensors.""" with patch( @@ -58,8 +58,6 @@ async def test_sensors(hass: HomeAssistant) -> None: conf_entry: MockConfigEntry = MockConfigEntry( domain=DOMAIN, title="NINA", data=ENTRY_DATA ) - - entity_registry: er = er.async_get(hass) conf_entry.add_to_hass(hass) await hass.config_entries.async_setup(conf_entry.entry_id) @@ -164,7 +162,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY -async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: +async def test_sensors_without_corona_filter( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the NINA sensors without the corona filter.""" with patch( @@ -174,8 +174,6 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: conf_entry: MockConfigEntry = MockConfigEntry( domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_CORONA ) - - entity_registry: er = er.async_get(hass) conf_entry.add_to_hass(hass) await hass.config_entries.async_setup(conf_entry.entry_id) @@ -292,7 +290,9 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY -async def test_sensors_with_area_filter(hass: HomeAssistant) -> None: +async def test_sensors_with_area_filter( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the NINA sensors with an area filter.""" with patch( @@ -302,8 +302,6 @@ async def test_sensors_with_area_filter(hass: HomeAssistant) -> None: conf_entry: MockConfigEntry = MockConfigEntry( domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_AREA ) - - entity_registry: er = er.async_get(hass) conf_entry.add_to_hass(hass) await hass.config_entries.async_setup(conf_entry.entry_id) diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 804b614fe92..23ee8cbf797 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -303,7 +303,9 @@ async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT -async def test_options_flow_entity_removal(hass: HomeAssistant) -> None: +async def test_options_flow_entity_removal( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test if old entities are removed.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -341,7 +343,6 @@ async def test_options_flow_entity_removal(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY - entity_registry: er = er.async_get(hass) entries = er.async_entries_for_config_entry( entity_registry, config_entry.entry_id ) diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py index 5a6b9ab07dd..620b01fdeb8 100644 --- a/tests/components/nina/test_init.py +++ b/tests/components/nina/test_init.py @@ -22,7 +22,7 @@ ENTRY_DATA: dict[str, Any] = { } -async def init_integration(hass) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the NINA integration in Home Assistant.""" with patch( diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 2e12c53a759..5c0548c4158 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -25,7 +25,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( "hosts", ["1.1.1.1", "192.168.1.0/24", "192.168.1.0/24,192.168.2.0/24"] ) -async def test_form(hass: HomeAssistant, hosts: str, mock_get_source_ip) -> None: +async def test_form(hass: HomeAssistant, hosts: str) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -64,7 +64,7 @@ async def test_form(hass: HomeAssistant, hosts: str, mock_get_source_ip) -> None assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_range(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_form_range(hass: HomeAssistant) -> None: """Test we get the form and can take an ip range.""" result = await hass.config_entries.flow.async_init( @@ -100,7 +100,7 @@ async def test_form_range(hass: HomeAssistant, mock_get_source_ip) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_hosts(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_form_invalid_hosts(hass: HomeAssistant) -> None: """Test invalid hosts passed in.""" result = await hass.config_entries.flow.async_init( @@ -124,7 +124,7 @@ async def test_form_invalid_hosts(hass: HomeAssistant, mock_get_source_ip) -> No assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"} -async def test_form_already_configured(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_form_already_configured(hass: HomeAssistant) -> None: """Test duplicate host list.""" config_entry = MockConfigEntry( @@ -159,7 +159,7 @@ async def test_form_already_configured(hass: HomeAssistant, mock_get_source_ip) assert result2["reason"] == "already_configured" -async def test_form_invalid_excludes(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_form_invalid_excludes(hass: HomeAssistant) -> None: """Test invalid excludes passed in.""" result = await hass.config_entries.flow.async_init( @@ -183,7 +183,7 @@ async def test_form_invalid_excludes(hass: HomeAssistant, mock_get_source_ip) -> assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"} -async def test_options_flow(hass: HomeAssistant, mock_get_source_ip) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test we can edit options.""" config_entry = MockConfigEntry( diff --git a/tests/components/no_ip/test_init.py b/tests/components/no_ip/test_init.py index 576a04c28a0..e344b984e7d 100644 --- a/tests/components/no_ip/test_init.py +++ b/tests/components/no_ip/test_init.py @@ -22,7 +22,7 @@ USERNAME = "abc@123.com" @pytest.fixture -def setup_no_ip(hass, aioclient_mock): +def setup_no_ip(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Fixture that sets up NO-IP.""" aioclient_mock.get(UPDATE_URL, params={"hostname": DOMAIN}, text="good 0.0.0.0") diff --git a/tests/components/notify/conftest.py b/tests/components/notify/conftest.py index 23930132f7b..0efb3a4689d 100644 --- a/tests/components/notify/conftest.py +++ b/tests/components/notify/conftest.py @@ -1,8 +1,7 @@ """Fixtures for Notify platform tests.""" -from collections.abc import Generator - import pytest +from typing_extensions import Generator from homeassistant.config_entries import ConfigFlow from homeassistant.core import HomeAssistant @@ -15,7 +14,7 @@ class MockFlow(ConfigFlow): @pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, "test.config_flow") diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index cfafae28b6e..0c559ad779f 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -56,7 +56,7 @@ async def help_async_setup_entry_init( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True diff --git a/tests/components/notify/test_legacy.py b/tests/components/notify/test_legacy.py index 71424beeda9..d6478c358bf 100644 --- a/tests/components/notify/test_legacy.py +++ b/tests/components/notify/test_legacy.py @@ -261,7 +261,7 @@ async def test_platform_setup_with_error( async def async_get_service(hass, config, discovery_info=None): """Return None for an invalid notify service.""" - raise Exception("Setup error") + raise Exception("Setup error") # pylint: disable=broad-exception-raised mock_notify_platform( hass, tmp_path, "testnotify", async_get_service=async_get_service @@ -507,7 +507,6 @@ async def test_sending_none_message(hass: HomeAssistant, tmp_path: Path) -> None await hass.services.async_call( notify.DOMAIN, notify.SERVICE_NOTIFY, {notify.ATTR_MESSAGE: None} ) - await hass.async_block_till_done() assert ( str(exc.value) == "template value is None for dictionary value @ data['message']" diff --git a/tests/components/notify/test_persistent_notification.py b/tests/components/notify/test_persistent_notification.py index bbf571b69ae..d46b97e5bc2 100644 --- a/tests/components/notify/test_persistent_notification.py +++ b/tests/components/notify/test_persistent_notification.py @@ -1,7 +1,7 @@ """The tests for the notify.persistent_notification service.""" from homeassistant.components import notify -import homeassistant.components.persistent_notification as pn +from homeassistant.components.persistent_notification import DOMAIN as PN_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -10,7 +10,7 @@ from tests.common import async_get_persistent_notifications async def test_async_send_message(hass: HomeAssistant) -> None: """Test sending a message to notify.persistent_notification service.""" - await async_setup_component(hass, pn.DOMAIN, {"core": {}}) + await async_setup_component(hass, PN_DOMAIN, {"core": {}}) await async_setup_component(hass, notify.DOMAIN, {}) await hass.async_block_till_done() @@ -30,7 +30,7 @@ async def test_async_send_message(hass: HomeAssistant) -> None: async def test_async_supports_notification_id(hass: HomeAssistant) -> None: """Test that notify.persistent_notification supports notification_id.""" - await async_setup_component(hass, pn.DOMAIN, {"core": {}}) + await async_setup_component(hass, PN_DOMAIN, {"core": {}}) await async_setup_component(hass, notify.DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/notify/test_repairs.py b/tests/components/notify/test_repairs.py new file mode 100644 index 00000000000..fef5818e1e6 --- /dev/null +++ b/tests/components/notify/test_repairs.py @@ -0,0 +1,92 @@ +"""Test repairs for notify entity component.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + migrate_notify_issue, +) +from homeassistant.components.repairs.issue_handler import ( + async_process_repairs_platforms, +) +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.typing import ClientSessionGenerator + +THERMOSTAT_ID = 0 + + +@pytest.mark.usefixtures("config_flow_fixture") +@pytest.mark.parametrize( + ("service_name", "translation_key"), + [(None, "migrate_notify_test"), ("bla", "migrate_notify_test_bla")], +) +async def test_notify_migration_repair_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + service_name: str | None, + translation_key: str, +) -> None: + """Test the notify service repair flow is triggered.""" + await async_setup_component(hass, NOTIFY_DOMAIN, {}) + await hass.async_block_till_done() + await async_process_repairs_platforms(hass) + + http_client = await hass_client() + await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=AsyncMock(return_value=True), + ), + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Simulate legacy service being used and issue being registered + migrate_notify_issue(hass, "test", "Test", "2024.12.0", service_name=service_name) + await hass.async_block_till_done() + # Assert the issue is present + assert issue_registry.async_get_issue( + domain=NOTIFY_DOMAIN, + issue_id=translation_key, + ) + assert len(issue_registry.issues) == 1 + + url = RepairsFlowIndexView.url + resp = await http_client.post( + url, json={"handler": NOTIFY_DOMAIN, "issue_id": translation_key} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + # Test confirm step in repair flow + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue( + domain=NOTIFY_DOMAIN, + issue_id=translation_key, + ) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index e69905ed72c..17bea306ad8 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -1,6 +1,5 @@ """Define fixtures for Notion tests.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -9,6 +8,7 @@ from aionotion.listener.models import Listener from aionotion.sensor.models import Sensor from aionotion.user.models import UserPreferences import pytest +from typing_extensions import Generator from homeassistant.components.notion import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN from homeassistant.const import CONF_USERNAME @@ -23,7 +23,7 @@ TEST_USER_UUID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.notion.async_setup_entry", return_value=True diff --git a/tests/components/nuki/__init__.py b/tests/components/nuki/__init__.py index a774935b9db..d100e4b628e 100644 --- a/tests/components/nuki/__init__.py +++ b/tests/components/nuki/__init__.py @@ -1 +1,37 @@ """The tests for nuki integration.""" + +import requests_mock + +from homeassistant.components.nuki.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .mock import MOCK_INFO, setup_nuki_integration + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock integration setup.""" + with requests_mock.Mocker() as mock: + # Mocking authentication endpoint + mock.get("http://1.1.1.1:8080/info", json=MOCK_INFO) + mock.get( + "http://1.1.1.1:8080/list", + json=load_json_array_fixture("list.json", DOMAIN), + ) + mock.get( + "http://1.1.1.1:8080/callback/list", + json=load_json_object_fixture("callback_list.json", DOMAIN), + ) + mock.get( + "http://1.1.1.1:8080/callback/add", + json=load_json_object_fixture("callback_add.json", DOMAIN), + ) + entry = await setup_nuki_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/nuki/fixtures/callback_add.json b/tests/components/nuki/fixtures/callback_add.json new file mode 100644 index 00000000000..5550c6db40a --- /dev/null +++ b/tests/components/nuki/fixtures/callback_add.json @@ -0,0 +1,3 @@ +{ + "success": true +} diff --git a/tests/components/nuki/fixtures/callback_list.json b/tests/components/nuki/fixtures/callback_list.json new file mode 100644 index 00000000000..87da7f43884 --- /dev/null +++ b/tests/components/nuki/fixtures/callback_list.json @@ -0,0 +1,12 @@ +{ + "callbacks": [ + { + "id": 0, + "url": "http://192.168.0.20:8000/nuki" + }, + { + "id": 1, + "url": "http://192.168.0.21/test" + } + ] +} diff --git a/tests/components/nuki/fixtures/info.json b/tests/components/nuki/fixtures/info.json new file mode 100644 index 00000000000..2a81bdf6e52 --- /dev/null +++ b/tests/components/nuki/fixtures/info.json @@ -0,0 +1,27 @@ +{ + "bridgeType": 1, + "ids": { "hardwareId": 12345678, "serverId": 12345678 }, + "versions": { + "firmwareVersion": "0.1.0", + "wifiFirmwareVersion": "0.2.0" + }, + "uptime": 120, + "currentTime": "2018-04-01T12:10:11Z", + "serverConnected": true, + "scanResults": [ + { + "nukiId": 10, + "type": 0, + "name": "Nuki_00000010", + "rssi": -87, + "paired": true + }, + { + "nukiId": 2, + "deviceType": 11, + "name": "Nuki_00000011", + "rssi": -93, + "paired": false + } + ] +} diff --git a/tests/components/nuki/fixtures/list.json b/tests/components/nuki/fixtures/list.json new file mode 100644 index 00000000000..f92a32f3215 --- /dev/null +++ b/tests/components/nuki/fixtures/list.json @@ -0,0 +1,30 @@ +[ + { + "nukiId": 1, + "deviceType": 0, + "name": "Home", + "lastKnownState": { + "mode": 2, + "state": 1, + "stateName": "unlocked", + "batteryCritical": false, + "batteryCharging": false, + "batteryChargeState": 85, + "doorsensorState": 2, + "doorsensorStateName": "door closed", + "timestamp": "2018-10-03T06:49:00+00:00" + } + }, + { + "nukiId": 2, + "deviceType": 2, + "name": "Community door", + "lastKnownState": { + "mode": 3, + "state": 3, + "stateName": "rto active", + "batteryCritical": false, + "timestamp": "2018-10-03T06:49:00+00:00" + } + } +] diff --git a/tests/components/nuki/mock.py b/tests/components/nuki/mock.py index 56297240331..a6bb643b932 100644 --- a/tests/components/nuki/mock.py +++ b/tests/components/nuki/mock.py @@ -1,25 +1,29 @@ """Mockup Nuki device.""" -from tests.common import MockConfigEntry +from homeassistant.components.nuki.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN +from homeassistant.core import HomeAssistant -NAME = "Nuki_Bridge_75BCD15" +from tests.common import MockConfigEntry, load_json_object_fixture + +NAME = "Nuki_Bridge_BC614E" HOST = "1.1.1.1" MAC = "01:23:45:67:89:ab" DHCP_FORMATTED_MAC = "0123456789ab" -HW_ID = 123456789 -ID_HEX = "75BCD15" +HW_ID = 12345678 +ID_HEX = "BC614E" -MOCK_INFO = {"ids": {"hardwareId": HW_ID}} +MOCK_INFO = load_json_object_fixture("info.json", DOMAIN) -async def setup_nuki_integration(hass): +async def setup_nuki_integration(hass: HomeAssistant) -> MockConfigEntry: """Create the Nuki device.""" entry = MockConfigEntry( - domain="nuki", + domain=DOMAIN, unique_id=ID_HEX, - data={"host": HOST, "port": 8080, "token": "test-token"}, + data={CONF_HOST: HOST, CONF_PORT: 8080, CONF_TOKEN: "test-token"}, ) entry.add_to_hass(hass) diff --git a/tests/components/nuki/snapshots/test_binary_sensor.ambr b/tests/components/nuki/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..4a122fa78f2 --- /dev/null +++ b/tests/components/nuki/snapshots/test_binary_sensor.ambr @@ -0,0 +1,237 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.community_door_battery-entry] + 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.community_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2_battery_critical', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.community_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Community door Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.community_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.community_door_ring_action-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.community_door_ring_action', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ring Action', + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ring_action', + 'unique_id': '2_ringaction', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.community_door_ring_action-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Community door Ring Action', + 'nuki_id': 2, + }), + 'context': , + 'entity_id': 'binary_sensor.community_door_ring_action', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensors[binary_sensor.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_doorsensor', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Home', + 'nuki_id': 1, + }), + 'context': , + 'entity_id': 'binary_sensor.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.home_battery-entry] + 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.home_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_battery_critical', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.home_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Home Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.home_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.home_charging-entry] + 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.home_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_battery_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.home_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Home Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.home_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/nuki/snapshots/test_lock.ambr b/tests/components/nuki/snapshots/test_lock.ambr new file mode 100644 index 00000000000..a0013fc37c1 --- /dev/null +++ b/tests/components/nuki/snapshots/test_lock.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_locks[lock.community_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.community_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'nuki_lock', + 'unique_id': 2, + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.community_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_critical': False, + 'friendly_name': 'Community door', + 'nuki_id': 2, + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.community_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_locks[lock.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'nuki_lock', + 'unique_id': 1, + 'unit_of_measurement': None, + }) +# --- +# name: test_locks[lock.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_critical': False, + 'friendly_name': 'Home', + 'nuki_id': 1, + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- diff --git a/tests/components/nuki/snapshots/test_sensor.ambr b/tests/components/nuki/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3c1159aecba --- /dev/null +++ b/tests/components/nuki/snapshots/test_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_sensors[sensor.home_battery-entry] + 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.home_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'nuki', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.home_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Home Battery', + 'nuki_id': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85', + }) +# --- diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py new file mode 100644 index 00000000000..54fbc93c144 --- /dev/null +++ b/tests/components/nuki/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the nuki binary sensors.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensors.""" + with patch("homeassistant.components.nuki.PLATFORMS", [Platform.BINARY_SENSOR]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_config_flow.py b/tests/components/nuki/test_config_flow.py index 58cbfde3d92..cdd429c40c5 100644 --- a/tests/components/nuki/test_config_flow.py +++ b/tests/components/nuki/test_config_flow.py @@ -8,7 +8,7 @@ from requests.exceptions import RequestException from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.nuki.const import DOMAIN -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -37,19 +37,19 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", }, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "75BCD15" + assert result2["title"] == "BC614E" assert result2["data"] == { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -67,9 +67,9 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", }, ) @@ -90,9 +90,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", }, ) @@ -113,9 +113,9 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", }, ) @@ -137,9 +137,9 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", }, ) @@ -173,18 +173,18 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", }, ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "75BCD15" + assert result2["title"] == "BC614E" assert result2["data"] == { - "host": "1.1.1.1", - "port": 8080, - "token": "test-token", + CONF_HOST: "1.1.1.1", + CONF_PORT: 8080, + CONF_TOKEN: "test-token", } await hass.async_block_till_done() diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py new file mode 100644 index 00000000000..824d508f3dc --- /dev/null +++ b/tests/components/nuki/test_lock.py @@ -0,0 +1,25 @@ +"""Tests for the nuki locks.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_locks( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test locks.""" + with patch("homeassistant.components.nuki.PLATFORMS", [Platform.LOCK]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py new file mode 100644 index 00000000000..dde803d573f --- /dev/null +++ b/tests/components/nuki/test_sensor.py @@ -0,0 +1,25 @@ +"""Tests for the nuki sensors.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors.""" + with patch("homeassistant.components.nuki.PLATFORMS", [Platform.SENSOR]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/numato/test_binary_sensor.py b/tests/components/numato/test_binary_sensor.py index e353de5e7df..524589af198 100644 --- a/tests/components/numato/test_binary_sensor.py +++ b/tests/components/numato/test_binary_sensor.py @@ -92,9 +92,7 @@ async def test_binary_sensor_setup_no_notify( caplog.set_level(logging.INFO) def raise_notification_error(self, port, callback, direction): - raise NumatoGpioError( - f"{repr(self)} Mockup device doesn't support notifications." - ) + raise NumatoGpioError(f"{self!r} Mockup device doesn't support notifications.") with patch.object( NumatoModuleMock.NumatoDeviceMock, diff --git a/tests/components/numato/test_init.py b/tests/components/numato/test_init.py index 1e84813df94..35dd102ec9e 100644 --- a/tests/components/numato/test_init.py +++ b/tests/components/numato/test_init.py @@ -47,10 +47,12 @@ async def test_hass_numato_api_wrong_port_directions( api = numato.NumatoAPI() api.setup_output(0, 5) api.setup_input(0, 2) - api.setup_input(0, 6) + api.setup_output(0, 6) with pytest.raises(NumatoGpioError): api.read_adc_input(0, 5) # adc_read from output + with pytest.raises(NumatoGpioError): api.read_input(0, 6) # read from output + with pytest.raises(NumatoGpioError): api.write_output(0, 2, 1) # write to input @@ -66,8 +68,11 @@ async def test_hass_numato_api_errors( api = numato.NumatoAPI() with pytest.raises(NumatoGpioError): api.setup_input(0, 5) + with pytest.raises(NumatoGpioError): api.read_adc_input(0, 1) + with pytest.raises(NumatoGpioError): api.read_input(0, 2) + with pytest.raises(NumatoGpioError): api.write_output(0, 2, 1) diff --git a/tests/components/number/conftest.py b/tests/components/number/conftest.py index a84ab03611b..49b492821ab 100644 --- a/tests/components/number/conftest.py +++ b/tests/components/number/conftest.py @@ -2,7 +2,7 @@ import pytest -from tests.components.number.common import MockNumberEntity +from .common import MockNumberEntity UNIQUE_NUMBER = "unique_number" diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 92a7cefd467..ffebd62fcbf 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -100,7 +100,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["set_value"] + for action in ("set_value",) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 96ad4b4d2d4..6f74a3126c0 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -1,10 +1,10 @@ """The tests for the Number component.""" -from collections.abc import Generator from typing import Any from unittest.mock import MagicMock import pytest +from typing_extensions import Generator from homeassistant.components.number import ( ATTR_MAX, @@ -43,6 +43,8 @@ from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM +from . import common + from tests.common import ( MockConfigEntry, MockModule, @@ -54,7 +56,6 @@ from tests.common import ( mock_restore_cache_with_extra_data, setup_test_component_platform, ) -from tests.components.number import common TEST_DOMAIN = "test" @@ -704,6 +705,7 @@ async def test_restore_number_restore_state( ) async def test_custom_unit( hass: HomeAssistant, + entity_registry: er.EntityRegistry, device_class, native_unit, custom_unit, @@ -712,8 +714,6 @@ async def test_custom_unit( custom_value, ) -> None: """Test custom unit.""" - entity_registry = er.async_get(hass) - entry = entity_registry.async_get_or_create("number", "test", "very_unique") entity_registry.async_update_entity_options( entry.entity_id, "number", {"unit_of_measurement": custom_unit} @@ -780,6 +780,7 @@ async def test_custom_unit( ) async def test_custom_unit_change( hass: HomeAssistant, + entity_registry: er.EntityRegistry, native_unit, custom_unit, used_custom_unit, @@ -789,7 +790,6 @@ async def test_custom_unit_change( default_value, ) -> None: """Test custom unit changes are picked up.""" - entity_registry = er.async_get(hass) entity0 = common.MockNumberEntity( name="Test", native_value=native_value, @@ -846,13 +846,10 @@ def test_device_classes_aligned() -> None: assert hasattr(NumberDeviceClass, device_class.name) assert getattr(NumberDeviceClass, device_class.name).value == device_class.value - for device_class in SENSOR_DEVICE_CLASS_UNITS: + for device_class, unit in SENSOR_DEVICE_CLASS_UNITS.items(): if device_class in NON_NUMERIC_DEVICE_CLASSES: continue - assert ( - SENSOR_DEVICE_CLASS_UNITS[device_class] - == NUMBER_DEVICE_CLASS_UNITS[device_class] - ) + assert unit == NUMBER_DEVICE_CLASS_UNITS[device_class] class MockFlow(ConfigFlow): @@ -860,7 +857,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -875,7 +872,7 @@ async def test_name(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True mock_platform(hass, f"{TEST_DOMAIN}.config_flow") diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index c4a8159b8cc..afe57631910 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -142,7 +142,9 @@ async def test_unknown_state_sensors(hass: HomeAssistant) -> None: assert state2.state == "OQ" -async def test_stale_options(hass: HomeAssistant) -> None: +async def test_stale_options( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test creation of sensors with stale options to remove.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -166,8 +168,7 @@ async def test_stale_options(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - entry = registry.async_get("sensor.ups1_battery_charge") + entry = entity_registry.async_get("sensor.ups1_battery_charge") assert entry assert entry.unique_id == f"{config_entry.entry_id}_battery.charge" assert config_entry.data[CONF_RESOURCES] == ["battery.charge"] diff --git a/tests/components/nws/conftest.py b/tests/components/nws/conftest.py index 48401fe87ba..65276a1a115 100644 --- a/tests/components/nws/conftest.py +++ b/tests/components/nws/conftest.py @@ -11,8 +11,12 @@ from .const import DEFAULT_FORECAST, DEFAULT_OBSERVATION @pytest.fixture def mock_simple_nws(): """Mock pynws SimpleNWS with default values.""" - - with patch("homeassistant.components.nws.SimpleNWS") as mock_nws: + # set RETRY_STOP and RETRY_INTERVAL to avoid retries inside pynws in tests + with ( + patch("homeassistant.components.nws.SimpleNWS") as mock_nws, + patch("homeassistant.components.nws.coordinator.RETRY_STOP", 0), + patch("homeassistant.components.nws.coordinator.RETRY_INTERVAL", 0), + ): instance = mock_nws.return_value instance.set_station = AsyncMock(return_value=None) instance.update_observation = AsyncMock(return_value=None) @@ -29,7 +33,12 @@ def mock_simple_nws(): @pytest.fixture def mock_simple_nws_times_out(): """Mock pynws SimpleNWS that times out.""" - with patch("homeassistant.components.nws.SimpleNWS") as mock_nws: + # set RETRY_STOP and RETRY_INTERVAL to avoid retries inside pynws in tests + with ( + patch("homeassistant.components.nws.SimpleNWS") as mock_nws, + patch("homeassistant.components.nws.coordinator.RETRY_STOP", 0), + patch("homeassistant.components.nws.coordinator.RETRY_INTERVAL", 0), + ): instance = mock_nws.return_value instance.set_station = AsyncMock(side_effect=asyncio.TimeoutError) instance.update_observation = AsyncMock(side_effect=asyncio.TimeoutError) diff --git a/tests/components/nws/snapshots/test_diagnostics.ambr b/tests/components/nws/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..2db73f90054 --- /dev/null +++ b/tests/components/nws/snapshots/test_diagnostics.ambr @@ -0,0 +1,88 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'forecast': list([ + dict({ + 'detailedForecast': 'A detailed forecast.', + 'dewpoint': 4, + 'iconTime': 'night', + 'iconWeather': list([ + list([ + 'lightning-rainy', + 40, + ]), + list([ + 'lightning-rainy', + 90, + ]), + ]), + 'isDaytime': False, + 'name': 'Tonight', + 'number': 1, + 'probabilityOfPrecipitation': 89, + 'relativeHumidity': 75, + 'startTime': '2019-08-12T20:00:00-04:00', + 'temperature': 10, + 'timestamp': '2019-08-12T23:53:00+00:00', + 'windBearing': 180, + 'windSpeedAvg': 10, + }), + ]), + 'forecast_hourly': list([ + dict({ + 'detailedForecast': 'A detailed forecast.', + 'dewpoint': 4, + 'iconTime': 'night', + 'iconWeather': list([ + list([ + 'lightning-rainy', + 40, + ]), + list([ + 'lightning-rainy', + 90, + ]), + ]), + 'isDaytime': False, + 'name': 'Tonight', + 'number': 1, + 'probabilityOfPrecipitation': 89, + 'relativeHumidity': 75, + 'startTime': '2019-08-12T20:00:00-04:00', + 'temperature': 10, + 'timestamp': '2019-08-12T23:53:00+00:00', + 'windBearing': 180, + 'windSpeedAvg': 10, + }), + ]), + 'info': dict({ + 'api_key': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'station': '**REDACTED**', + }), + 'observation': dict({ + 'barometricPressure': 100000, + 'dewpoint': 5, + 'heatIndex': 15, + 'iconTime': 'day', + 'iconWeather': list([ + list([ + 'Fair/clear', + None, + ]), + ]), + 'relativeHumidity': 10, + 'seaLevelPressure': 100000, + 'station': '**REDACTED**', + 'temperature': 10, + 'textDescription': 'A long description', + 'timestamp': '2019-08-12T23:53:00+00:00', + 'visibility': 10000, + 'windChill': 5, + 'windDirection': 180, + 'windGust': 20, + 'windSpeed': 10, + }), + }) +# --- diff --git a/tests/components/nws/test_diagnostics.py b/tests/components/nws/test_diagnostics.py new file mode 100644 index 00000000000..55f7f3100a0 --- /dev/null +++ b/tests/components/nws/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Test NWS diagnostics.""" + +from syrupy import SnapshotAssertion + +from homeassistant.components import nws +from homeassistant.core import HomeAssistant + +from .const import NWS_CONFIG + +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_simple_nws, +) -> None: + """Test config entry diagnostics.""" + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot diff --git a/tests/components/nws/test_init.py b/tests/components/nws/test_init.py index 121da07a9ce..9926e530d36 100644 --- a/tests/components/nws/test_init.py +++ b/tests/components/nws/test_init.py @@ -1,8 +1,7 @@ """Tests for init module.""" from homeassistant.components.nws.const import DOMAIN -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import NWS_CONFIG @@ -21,20 +20,10 @@ async def test_unload_entry(hass: HomeAssistant, mock_simple_nws) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 - assert DOMAIN in hass.data + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED - assert len(hass.data[DOMAIN]) == 1 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert await hass.config_entries.async_unload(entries[0].entry_id) - entities = hass.states.async_entity_ids(WEATHER_DOMAIN) - assert len(entities) == 1 - for entity in entities: - assert hass.states.get(entity).state == STATE_UNAVAILABLE - assert DOMAIN not in hass.data - - assert await hass.config_entries.async_remove(entries[0].entry_id) + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py index 4d29e48ae0b..dd69d5ac775 100644 --- a/tests/components/nws/test_sensor.py +++ b/tests/components/nws/test_sensor.py @@ -36,6 +36,7 @@ from tests.common import MockConfigEntry ) async def test_imperial_metric( hass: HomeAssistant, + entity_registry: er.EntityRegistry, units, result_observation, result_forecast, @@ -43,10 +44,8 @@ async def test_imperial_metric( no_weather, ) -> None: """Test with imperial and metric units.""" - registry = er.async_get(hass) - for description in SENSOR_TYPES: - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, f"35_-75_{description.key}", @@ -73,16 +72,18 @@ async def test_imperial_metric( @pytest.mark.parametrize("values", [NONE_OBSERVATION, None]) async def test_none_values( - hass: HomeAssistant, mock_simple_nws, no_weather, values + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_simple_nws, + no_weather, + values, ) -> None: """Test with no values.""" instance = mock_simple_nws.return_value instance.observation = values - registry = er.async_get(hass) - for description in SENSOR_TYPES: - registry.async_get_or_create( + entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, f"35_-75_{description.key}", diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 87aae18be60..b4f4b5155a1 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -1,14 +1,18 @@ """Tests for the NWS weather component.""" from datetime import timedelta -from unittest.mock import patch import aiohttp from freezegun.api import FrozenDateTimeFactory +from pynws import NwsNoDataError import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import nws +from homeassistant.components.nws.const import ( + DEFAULT_SCAN_INTERVAL, + OBSERVATION_VALID_TIME, +) from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, @@ -19,7 +23,6 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import ( @@ -114,6 +117,112 @@ async def test_none_values(hass: HomeAssistant, mock_simple_nws, no_sensor) -> N assert data.get(key) is None +async def test_data_caching_error_observation( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_simple_nws, + no_sensor, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test caching of data with errors.""" + instance = mock_simple_nws.return_value + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("weather.abc") + assert state.state == "sunny" + + # data is still valid even when update fails + instance.update_observation.side_effect = NwsNoDataError("Test") + + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=100)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.abc") + assert state.state == "sunny" + + assert ( + "NWS observation update failed, but data still valid. Last success: " + in caplog.text + ) + + # data is no longer valid after OBSERVATION_VALID_TIME + freezer.tick(OBSERVATION_VALID_TIME + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.abc") + assert state.state == STATE_UNAVAILABLE + + assert "Error fetching NWS observation station ABC data: Test" in caplog.text + + +async def test_no_data_error_observation( + hass: HomeAssistant, mock_simple_nws, no_sensor, caplog: pytest.LogCaptureFixture +) -> None: + """Test catching NwsNoDataDrror.""" + instance = mock_simple_nws.return_value + instance.update_observation.side_effect = NwsNoDataError("Test") + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "Error fetching NWS observation station ABC data: Test" in caplog.text + + +async def test_no_data_error_forecast( + hass: HomeAssistant, mock_simple_nws, no_sensor, caplog: pytest.LogCaptureFixture +) -> None: + """Test catching NwsNoDataDrror.""" + instance = mock_simple_nws.return_value + instance.update_forecast.side_effect = NwsNoDataError("Test") + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + "Error fetching NWS forecast station ABC data: No data returned" in caplog.text + ) + + +async def test_no_data_error_forecast_hourly( + hass: HomeAssistant, mock_simple_nws, no_sensor, caplog: pytest.LogCaptureFixture +) -> None: + """Test catching NwsNoDataDrror.""" + instance = mock_simple_nws.return_value + instance.update_forecast_hourly.side_effect = NwsNoDataError("Test") + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + "Error fetching NWS forecast hourly station ABC data: No data returned" + in caplog.text + ) + + async def test_none(hass: HomeAssistant, mock_simple_nws, no_sensor) -> None: """Test with None as observation and forecast.""" instance = mock_simple_nws.return_value @@ -187,32 +296,29 @@ async def test_error_observation( hass: HomeAssistant, mock_simple_nws, no_sensor ) -> None: """Test error during update observation.""" - utc_time = dt_util.utcnow() - with patch("homeassistant.components.nws.utcnow") as mock_utc: - mock_utc.return_value = utc_time - instance = mock_simple_nws.return_value - # first update fails - instance.update_observation.side_effect = aiohttp.ClientError + instance = mock_simple_nws.return_value + # first update fails + instance.update_observation.side_effect = aiohttp.ClientError - entry = MockConfigEntry( - domain=nws.DOMAIN, - data=NWS_CONFIG, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - instance.update_observation.assert_called_once() + instance.update_observation.assert_called_once() - state = hass.states.get("weather.abc") - assert state - assert state.state == STATE_UNAVAILABLE + state = hass.states.get("weather.abc") + assert state + assert state.state == STATE_UNAVAILABLE -async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, no_sensor +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) - entry = MockConfigEntry( domain=nws.DOMAIN, data=NWS_CONFIG, @@ -224,7 +330,7 @@ async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: assert len(hass.states.async_entity_ids("weather")) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 @pytest.mark.parametrize( @@ -344,6 +450,7 @@ async def test_forecast_service( async def test_forecast_subscription( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, mock_simple_nws, @@ -354,9 +461,8 @@ async def test_forecast_subscription( """Test multiple forecast.""" client = await hass_ws_client(hass) - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, nws.DOMAIN, "35_-75_hourly", @@ -411,6 +517,7 @@ async def test_forecast_subscription( async def test_forecast_subscription_with_failing_coordinator( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, mock_simple_nws_times_out, @@ -421,9 +528,8 @@ async def test_forecast_subscription_with_failing_coordinator( """Test a forecast subscription when the coordinator is failing to update.""" client = await hass_ws_client(hass) - registry = er.async_get(hass) # Pre-create the hourly entity - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, nws.DOMAIN, "35_-75_hourly", diff --git a/tests/components/nzbget/__init__.py b/tests/components/nzbget/__init__.py index d3216b62ef3..e91f6e35e08 100644 --- a/tests/components/nzbget/__init__.py +++ b/tests/components/nzbget/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -59,14 +60,9 @@ MOCK_HISTORY = [ ] -async def init_integration( - hass, - *, - data: dict = ENTRY_CONFIG, - options: dict = ENTRY_OPTIONS, -) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the NZBGet integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=data, options=options) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -82,17 +78,17 @@ def _patch_async_setup_entry(return_value=True): ) -def _patch_history(return_value=MOCK_HISTORY): +def _patch_history(): return patch( "homeassistant.components.nzbget.coordinator.NZBGetAPI.history", - return_value=return_value, + return_value=MOCK_HISTORY, ) -def _patch_status(return_value=MOCK_STATUS): +def _patch_status(): return patch( "homeassistant.components.nzbget.coordinator.NZBGetAPI.status", - return_value=return_value, + return_value=MOCK_STATUS, ) diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py index 350401ed9a2..30a7f262b0b 100644 --- a/tests/components/nzbget/test_sensor.py +++ b/tests/components/nzbget/test_sensor.py @@ -16,14 +16,14 @@ from homeassistant.util import dt as dt_util from . import init_integration -async def test_sensors(hass: HomeAssistant, nzbget_api) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, nzbget_api +) -> None: """Test the creation and values of the sensors.""" now = dt_util.utcnow().replace(microsecond=0) with patch("homeassistant.components.nzbget.sensor.utcnow", return_value=now): entry = await init_integration(hass) - registry = er.async_get(hass) - uptime = now - timedelta(seconds=600) sensors = { @@ -76,7 +76,7 @@ async def test_sensors(hass: HomeAssistant, nzbget_api) -> None: } for sensor_id, data in sensors.items(): - entity_entry = registry.async_get(f"sensor.nzbgettest_{sensor_id}") + entity_entry = entity_registry.async_get(f"sensor.nzbgettest_{sensor_id}") assert entity_entry assert entity_entry.original_device_class == data[3] assert entity_entry.unique_id == f"{entry.entry_id}_{data[0]}" diff --git a/tests/components/nzbget/test_switch.py b/tests/components/nzbget/test_switch.py index 61343710254..1c518486b9f 100644 --- a/tests/components/nzbget/test_switch.py +++ b/tests/components/nzbget/test_switch.py @@ -15,16 +15,17 @@ from homeassistant.helpers.entity_component import async_update_entity from . import init_integration -async def test_download_switch(hass: HomeAssistant, nzbget_api) -> None: +async def test_download_switch( + hass: HomeAssistant, entity_registry: er.EntityRegistry, nzbget_api +) -> None: """Test the creation and values of the download switch.""" instance = nzbget_api.return_value entry = await init_integration(hass) assert entry - registry = er.async_get(hass) entity_id = "switch.nzbgettest_download" - entity_entry = registry.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.unique_id == f"{entry.entry_id}_download" diff --git a/tests/components/obihai/conftest.py b/tests/components/obihai/conftest.py index 751f41f315a..c4edfdedf65 100644 --- a/tests/components/obihai/conftest.py +++ b/tests/components/obihai/conftest.py @@ -1,14 +1,14 @@ """Define test fixtures for Obihai.""" -from collections.abc import Generator from socket import gaierror from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( @@ -18,7 +18,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_gaierror() -> Generator[AsyncMock, None, None]: +def mock_gaierror() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index 0a35d0a2267..dd3eda0e81f 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -14,6 +14,8 @@ from pyoctoprintapi import ( from homeassistant.components.octoprint import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry @@ -33,11 +35,11 @@ DEFAULT_PRINTER = { async def init_integration( - hass, - platform, + hass: HomeAssistant, + platform: Platform, printer: dict[str, Any] | UndefinedType | None = UNDEFINED, job: dict[str, Any] | None = None, -): +) -> None: """Set up the octoprint integration in Home Assistant.""" printer_info: OctoprintPrinterInfo | None = None if printer is UNDEFINED: diff --git a/tests/components/octoprint/test_binary_sensor.py b/tests/components/octoprint/test_binary_sensor.py index 50572682e7d..ab055934a0c 100644 --- a/tests/components/octoprint/test_binary_sensor.py +++ b/tests/components/octoprint/test_binary_sensor.py @@ -7,7 +7,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, entity_registry: er.EntityRegistry) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -18,8 +18,6 @@ async def test_sensors(hass: HomeAssistant) -> None: } await init_integration(hass, "binary_sensor", printer=printer) - entity_registry = er.async_get(hass) - state = hass.states.get("binary_sensor.octoprint_printing") assert state is not None assert state.state == STATE_ON @@ -35,12 +33,12 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry.unique_id == "Printing Error-uuid" -async def test_sensors_printer_offline(hass: HomeAssistant) -> None: +async def test_sensors_printer_offline( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the underlying sensors when the printer is offline.""" await init_integration(hass, "binary_sensor", printer=None) - entity_registry = er.async_get(hass) - state = hass.states.get("binary_sensor.octoprint_printing") assert state is not None assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/octoprint/test_button.py b/tests/components/octoprint/test_button.py index 7f272f9927e..cf9008d8b58 100644 --- a/tests/components/octoprint/test_button.py +++ b/tests/components/octoprint/test_button.py @@ -57,24 +57,22 @@ async def test_pause_job(hass: HomeAssistant) -> None: assert len(pause_command.mock_calls) == 0 # Test pausing the printer when it is stopped - with ( - patch("pyoctoprintapi.OctoprintClient.pause_job") as pause_command, - pytest.raises(InvalidPrinterState), - ): + with patch("pyoctoprintapi.OctoprintClient.pause_job") as pause_command: coordinator.data["printer"] = OctoprintPrinterInfo( { "state": {"flags": {"printing": False, "paused": False}}, "temperature": [], } ) - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - { - ATTR_ENTITY_ID: "button.octoprint_pause_job", - }, - blocking=True, - ) + with pytest.raises(InvalidPrinterState): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.octoprint_pause_job", + }, + blocking=True, + ) async def test_resume_job(hass: HomeAssistant) -> None: @@ -118,24 +116,22 @@ async def test_resume_job(hass: HomeAssistant) -> None: assert len(resume_command.mock_calls) == 0 # Test resuming the printer when it is stopped - with ( - patch("pyoctoprintapi.OctoprintClient.resume_job") as resume_command, - pytest.raises(InvalidPrinterState), - ): + with patch("pyoctoprintapi.OctoprintClient.resume_job") as resume_command: coordinator.data["printer"] = OctoprintPrinterInfo( { "state": {"flags": {"printing": False, "paused": False}}, "temperature": [], } ) - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - { - ATTR_ENTITY_ID: "button.octoprint_resume_job", - }, - blocking=True, - ) + with pytest.raises(InvalidPrinterState): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.octoprint_resume_job", + }, + blocking=True, + ) async def test_stop_job(hass: HomeAssistant) -> None: diff --git a/tests/components/octoprint/test_camera.py b/tests/components/octoprint/test_camera.py index b1d843f7d39..31ccb85eb88 100644 --- a/tests/components/octoprint/test_camera.py +++ b/tests/components/octoprint/test_camera.py @@ -11,7 +11,7 @@ from homeassistant.helpers import entity_registry as er from . import init_integration -async def test_camera(hass: HomeAssistant) -> None: +async def test_camera(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the underlying camera.""" with patch( "pyoctoprintapi.OctoprintClient.get_webcam_info", @@ -26,14 +26,14 @@ async def test_camera(hass: HomeAssistant) -> None: ): await init_integration(hass, CAMERA_DOMAIN) - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("camera.octoprint_camera") assert entry is not None assert entry.unique_id == "uuid" -async def test_camera_disabled(hass: HomeAssistant) -> None: +async def test_camera_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that the camera does not load if there is not one configured.""" with patch( "pyoctoprintapi.OctoprintClient.get_webcam_info", @@ -48,13 +48,13 @@ async def test_camera_disabled(hass: HomeAssistant) -> None: ): await init_integration(hass, CAMERA_DOMAIN) - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("camera.octoprint_camera") assert entry is None -async def test_no_supported_camera(hass: HomeAssistant) -> None: +async def test_no_supported_camera( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that the camera does not load if there is not one configured.""" with patch( "pyoctoprintapi.OctoprintClient.get_webcam_info", @@ -62,7 +62,5 @@ async def test_no_supported_camera(hass: HomeAssistant) -> None: ): await init_integration(hass, CAMERA_DOMAIN) - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("camera.octoprint_camera") assert entry is None diff --git a/tests/components/octoprint/test_servics.py b/tests/components/octoprint/test_servics.py index 2b5a89970e8..21a4ede8845 100644 --- a/tests/components/octoprint/test_servics.py +++ b/tests/components/octoprint/test_servics.py @@ -8,20 +8,19 @@ from homeassistant.components.octoprint.const import ( SERVICE_CONNECT, ) from homeassistant.const import ATTR_DEVICE_ID, CONF_PORT, CONF_PROFILE_NAME -from homeassistant.helpers.device_registry import ( - async_entries_for_config_entry, - async_get as async_get_dev_reg, -) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration -async def test_connect_default(hass) -> None: +async def test_connect_default( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test the connect to printer service.""" await init_integration(hass, "sensor") - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, "uuid")[0] + device = dr.async_entries_for_config_entry(device_registry, "uuid")[0] # Test pausing the printer when it is printing with patch("pyoctoprintapi.OctoprintClient.connect") as connect_command: @@ -40,12 +39,13 @@ async def test_connect_default(hass) -> None: ) -async def test_connect_all_arguments(hass) -> None: +async def test_connect_all_arguments( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test the connect to printer service.""" await init_integration(hass, "sensor") - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, "uuid")[0] + device = dr.async_entries_for_config_entry(device_registry, "uuid")[0] # Test pausing the printer when it is printing with patch("pyoctoprintapi.OctoprintClient.connect") as connect_command: diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 080d0d34f2d..b6f0be3c414 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -6,6 +6,7 @@ from ollama import Message, ResponseError import pytest from homeassistant.components import conversation, ollama +from homeassistant.components.conversation import trace from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL from homeassistant.core import Context, HomeAssistant @@ -110,6 +111,19 @@ async def test_chat( ), result assert result.response.speech["plain"]["speech"] == "test response" + # Test Conversation tracing + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + trace_events = last_trace.get("events", []) + assert [event["event_type"] for event in trace_events] == [ + trace.ConversationTraceEventType.ASYNC_PROCESS, + trace.ConversationTraceEventType.AGENT_DETAIL, + ] + # AGENT_DETAIL event contains the raw prompt passed to the model + detail_event = trace_events[1] + assert "The current time is" in detail_event["data"]["messages"][0]["content"] + async def test_message_history_trimming( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index c296d6de700..d1074226837 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -20,7 +20,11 @@ from tests.common import MockConfigEntry ], ) async def test_init_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, caplog, side_effect, error + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + side_effect, + error, ) -> None: """Test initialization errors.""" with patch( diff --git a/tests/components/omnilogic/__init__.py b/tests/components/omnilogic/__init__.py index b7b8008abaa..6882ed8830a 100644 --- a/tests/components/omnilogic/__init__.py +++ b/tests/components/omnilogic/__init__.py @@ -1 +1,38 @@ """Tests for the Omnilogic integration.""" + +from unittest.mock import patch + +from homeassistant.components.omnilogic.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import TELEMETRY + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock integration setup.""" + with ( + patch( + "homeassistant.components.omnilogic.OmniLogic.connect", + return_value=True, + ), + patch( + "homeassistant.components.omnilogic.OmniLogic.get_telemetry_data", + return_value={}, + ), + patch( + "homeassistant.components.omnilogic.coordinator.OmniLogicUpdateCoordinator._async_update_data", + return_value=TELEMETRY, + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + entry_id="6fa019921cf8e7a3f57a3c2ed001a10d", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/omnilogic/const.py b/tests/components/omnilogic/const.py new file mode 100644 index 00000000000..e434cfef00a --- /dev/null +++ b/tests/components/omnilogic/const.py @@ -0,0 +1,266 @@ +"""Constants for the Omnilogic integration tests.""" + +TELEMETRY = { + ("Backyard", "SCRUBBED"): { + "systemId": "SCRUBBED", + "statusVersion": "3", + "airTemp": "70", + "status": "1", + "state": "1", + "configUpdatedTime": "2020-10-08T09:04:42.0556413Z", + "datetime": "2020-10-11T16:36:53.4128627", + "Relays": [], + "BOWS": [ + { + "systemId": "1", + "flow": "255", + "waterTemp": "71", + "Name": "Spa", + "Supports-Spillover": "no", + "Filter": { + "systemId": "2", + "valvePosition": "1", + "filterSpeed": "100", + "filterState": "1", + "lastSpeed": "0", + "Name": "Filter Pump", + "Shared-Type": "BOW_NO_EQUIPMENT_SHARED", + "Filter-Type": "FMT_SINGLE_SPEED", + "Max-Pump-Speed": "100", + "Min-Pump-Speed": "100", + "Max-Pump-RPM": "3450", + "Min-Pump-RPM": "600", + "Priming-Enabled": "no", + "Alarms": [], + }, + "VirtualHeater": { + "systemId": "3", + "Current-Set-Point": "103", + "enable": "no", + }, + "Heater": { + "systemId": "4", + "heaterState": "0", + "enable": "yes", + "Shared-Type": "BOW_NO_EQUIPMENT_SHARED", + "Operation": { + "VirtualHeater": { + "System-Id": "4", + "Name": "Heater", + "Type": "PET_HEATER", + "Heater-Type": "HTR_GAS", + "Enabled": "yes", + "Priority": "HTR_PRIORITY_1", + "Run-For-Priority": "HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID", + "Shared-Equipment-System-ID": "-1", + "Current-Set-Point": "103", + "Max-Water-Temp": "104", + "Min-Settable-Water-Temp": "65", + "Max-Settable-Water-Temp": "104", + "enable": "yes", + "systemId": "3", + } + }, + "Alarms": [], + }, + "Group": {"systemId": "13", "groupState": "0"}, + "Lights": [ + { + "systemId": "6", + "lightState": "0", + "currentShow": "0", + "Name": "Lights", + "Type": "COLOR_LOGIC_UCL", + "Alarms": [], + } + ], + "Relays": [ + { + "systemId": "10", + "relayState": "0", + "Name": "Overflow", + "Type": "RLY_VALVE_ACTUATOR", + "Function": "RLY_WATER_FEATURE", + "Alarms": [], + } + ], + "Pumps": [ + { + "systemId": "5", + "pumpState": "0", + "pumpSpeed": "0", + "lastSpeed": "0", + "Name": "Spa Jets", + "Type": "PMP_SINGLE_SPEED", + "Function": "PMP_WATER_FEATURE", + "Min-Pump-Speed": "18", + "Max-Pump-Speed": "100", + "Alarms": [], + } + ], + } + ], + "BackyardName": "SCRUBBED", + "Msp-Vsp-Speed-Format": "Percent", + "Msp-Time-Format": "12 Hour Format", + "Units": "Standard", + "Msp-Chlor-Display": "Salt", + "Msp-Language": "English", + "Unit-of-Measurement": "Standard", + "Alarms": [], + "Unit-of-Temperature": "UNITS_FAHRENHEIT", + }, + ("Backyard", "SCRUBBED", "BOWS", "1"): { + "systemId": "1", + "flow": "255", + "waterTemp": "71", + "Name": "Spa", + "Supports-Spillover": "no", + "Filter": { + "systemId": "2", + "valvePosition": "1", + "filterSpeed": "100", + "filterState": "1", + "lastSpeed": "0", + "Name": "Filter Pump", + "Shared-Type": "BOW_NO_EQUIPMENT_SHARED", + "Filter-Type": "FMT_SINGLE_SPEED", + "Max-Pump-Speed": "100", + "Min-Pump-Speed": "100", + "Max-Pump-RPM": "3450", + "Min-Pump-RPM": "600", + "Priming-Enabled": "no", + "Alarms": [], + }, + "VirtualHeater": {"systemId": "3", "Current-Set-Point": "103", "enable": "no"}, + "Heater": { + "systemId": "4", + "heaterState": "0", + "enable": "yes", + "Shared-Type": "BOW_NO_EQUIPMENT_SHARED", + "Operation": { + "VirtualHeater": { + "System-Id": "4", + "Name": "Heater", + "Type": "PET_HEATER", + "Heater-Type": "HTR_GAS", + "Enabled": "yes", + "Priority": "HTR_PRIORITY_1", + "Run-For-Priority": "HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID", + "Shared-Equipment-System-ID": "-1", + "Current-Set-Point": "103", + "Max-Water-Temp": "104", + "Min-Settable-Water-Temp": "65", + "Max-Settable-Water-Temp": "104", + "enable": "yes", + "systemId": "3", + } + }, + "Alarms": [], + }, + "Group": {"systemId": "13", "groupState": "0"}, + "Lights": [ + { + "systemId": "6", + "lightState": "0", + "currentShow": "0", + "Name": "Lights", + "Type": "COLOR_LOGIC_UCL", + "Alarms": [], + } + ], + "Relays": [ + { + "systemId": "10", + "relayState": "0", + "Name": "Overflow", + "Type": "RLY_VALVE_ACTUATOR", + "Function": "RLY_WATER_FEATURE", + "Alarms": [], + } + ], + "Pumps": [ + { + "systemId": "5", + "pumpState": "0", + "pumpSpeed": "0", + "lastSpeed": "0", + "Name": "Spa Jets", + "Type": "PMP_SINGLE_SPEED", + "Function": "PMP_WATER_FEATURE", + "Min-Pump-Speed": "18", + "Max-Pump-Speed": "100", + "Alarms": [], + } + ], + }, + ("Backyard", "SCRUBBED", "BOWS", "1", "Pumps", "5"): { + "systemId": "5", + "pumpState": "0", + "pumpSpeed": "0", + "lastSpeed": "0", + "Name": "Spa Jets", + "Type": "PMP_SINGLE_SPEED", + "Function": "PMP_WATER_FEATURE", + "Min-Pump-Speed": "18", + "Max-Pump-Speed": "100", + "Alarms": [], + }, + ("Backyard", "SCRUBBED", "BOWS", "1", "Relays", "10"): { + "systemId": "10", + "relayState": "0", + "Name": "Overflow", + "Type": "RLY_VALVE_ACTUATOR", + "Function": "RLY_WATER_FEATURE", + "Alarms": [], + }, + ("Backyard", "SCRUBBED", "BOWS", "1", "Lights", "6"): { + "systemId": "6", + "lightState": "0", + "currentShow": "0", + "Name": "Lights", + "Type": "COLOR_LOGIC_UCL", + "Alarms": [], + }, + ("Backyard", "SCRUBBED", "BOWS", "1", "Heater", "4"): { + "systemId": "4", + "heaterState": "0", + "enable": "yes", + "Shared-Type": "BOW_NO_EQUIPMENT_SHARED", + "Operation": { + "VirtualHeater": { + "System-Id": "4", + "Name": "Heater", + "Type": "PET_HEATER", + "Heater-Type": "HTR_GAS", + "Enabled": "yes", + "Priority": "HTR_PRIORITY_1", + "Run-For-Priority": "HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID", + "Shared-Equipment-System-ID": "-1", + "Current-Set-Point": "103", + "Max-Water-Temp": "104", + "Min-Settable-Water-Temp": "65", + "Max-Settable-Water-Temp": "104", + "enable": "yes", + "systemId": "3", + } + }, + "Alarms": [], + }, + ("Backyard", "SCRUBBED", "BOWS", "1", "Filter", "2"): { + "systemId": "2", + "valvePosition": "1", + "filterSpeed": "100", + "filterState": "1", + "lastSpeed": "0", + "Name": "Filter Pump", + "Shared-Type": "BOW_NO_EQUIPMENT_SHARED", + "Filter-Type": "FMT_SINGLE_SPEED", + "Max-Pump-Speed": "100", + "Min-Pump-Speed": "100", + "Max-Pump-RPM": "3450", + "Min-Pump-RPM": "600", + "Priming-Enabled": "no", + "Alarms": [], + }, +} diff --git a/tests/components/omnilogic/snapshots/test_sensor.ambr b/tests/components/omnilogic/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a4ea7f02a03 --- /dev/null +++ b/tests/components/omnilogic/snapshots/test_sensor.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_sensors[sensor.scrubbed_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.scrubbed_air_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SCRUBBED Air Temperature', + 'platform': 'omnilogic', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SCRUBBED_SCRUBBED_air_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.scrubbed_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SCRUBBED Air Temperature', + 'hayward_temperature': '70', + 'hayward_unit_of_measure': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.scrubbed_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21', + }) +# --- +# name: test_sensors[sensor.scrubbed_spa_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.scrubbed_spa_water_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'SCRUBBED Spa Water Temperature', + 'platform': 'omnilogic', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SCRUBBED_1_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.scrubbed_spa_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SCRUBBED Spa Water Temperature', + 'hayward_temperature': '71', + 'hayward_unit_of_measure': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.scrubbed_spa_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- diff --git a/tests/components/omnilogic/snapshots/test_switch.ambr b/tests/components/omnilogic/snapshots/test_switch.ambr new file mode 100644 index 00000000000..a5d77f1adcf --- /dev/null +++ b/tests/components/omnilogic/snapshots/test_switch.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_switches[switch.scrubbed_spa_filter_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.scrubbed_spa_filter_pump', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SCRUBBED Spa Filter Pump ', + 'platform': 'omnilogic', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SCRUBBED_1_2_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.scrubbed_spa_filter_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SCRUBBED Spa Filter Pump ', + }), + 'context': , + 'entity_id': 'switch.scrubbed_spa_filter_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.scrubbed_spa_spa_jets-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.scrubbed_spa_spa_jets', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SCRUBBED Spa Spa Jets ', + 'platform': 'omnilogic', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SCRUBBED_1_5_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.scrubbed_spa_spa_jets-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SCRUBBED Spa Spa Jets ', + }), + 'context': , + 'entity_id': 'switch.scrubbed_spa_spa_jets', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/omnilogic/test_sensor.py b/tests/components/omnilogic/test_sensor.py new file mode 100644 index 00000000000..166eb7f87f2 --- /dev/null +++ b/tests/components/omnilogic/test_sensor.py @@ -0,0 +1,28 @@ +"""Tests for the omnilogic sensors.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors.""" + with patch( + "homeassistant.components.omnilogic.PLATFORMS", + [Platform.SENSOR], + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/omnilogic/test_switch.py b/tests/components/omnilogic/test_switch.py new file mode 100644 index 00000000000..1f9506380a2 --- /dev/null +++ b/tests/components/omnilogic/test_switch.py @@ -0,0 +1,28 @@ +"""Tests for the omnilogic switches.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test switches.""" + with patch( + "homeassistant.components.omnilogic.PLATFORMS", + [Platform.SWITCH], + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 556b590e746..e9ba720adb3 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import Mock, patch import pytest +from typing_extensions import AsyncGenerator from homeassistant.components import onboarding from homeassistant.components.onboarding import const, views @@ -35,7 +36,9 @@ def auth_active(hass): @pytest.fixture(name="rpi") -async def rpi_fixture(hass, aioclient_mock, mock_supervisor): +async def rpi_fixture( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_supervisor +) -> None: """Mock core info with rpi.""" aioclient_mock.get( "http://127.0.0.1/core/info", @@ -49,7 +52,9 @@ async def rpi_fixture(hass, aioclient_mock, mock_supervisor): @pytest.fixture(name="no_rpi") -async def no_rpi_fixture(hass, aioclient_mock, mock_supervisor): +async def no_rpi_fixture( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_supervisor +) -> None: """Mock core info with rpi.""" aioclient_mock.get( "http://127.0.0.1/core/info", @@ -63,7 +68,9 @@ async def no_rpi_fixture(hass, aioclient_mock, mock_supervisor): @pytest.fixture(name="mock_supervisor") -async def mock_supervisor_fixture(hass, aioclient_mock): +async def mock_supervisor_fixture( + aioclient_mock: AiohttpClientMocker, +) -> AsyncGenerator[None]: """Mock supervisor.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) @@ -80,6 +87,16 @@ async def mock_supervisor_fixture(hass, aioclient_mock): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/network/info", + json={ + "result": "ok", + "data": { + "host_internet": True, + "supervisor_internet": True, + }, + }, + ) with ( patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( @@ -192,10 +209,9 @@ async def test_onboarding_user( hass: HomeAssistant, hass_storage: dict[str, Any], hass_client_no_auth: ClientSessionGenerator, + area_registry: ar.AreaRegistry, ) -> None: """Test creating a new user.""" - area_registry = ar.async_get(hass) - # Create an existing area to mimic an integration creating an area # before onboarding is done. area_registry.async_create("Living Room") diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py index c124bab3c48..e5f55d54062 100644 --- a/tests/components/oncue/test_sensor.py +++ b/tests/components/oncue/test_sensor.py @@ -29,7 +29,13 @@ from tests.common import MockConfigEntry (_patch_login_and_data_offline_device, set()), ], ) -async def test_sensors(hass: HomeAssistant, patcher, connections) -> None: +async def test_sensors( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + patcher, + connections, +) -> None: """Test that the sensors are setup with the expected values.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -42,9 +48,7 @@ async def test_sensors(hass: HomeAssistant, patcher, connections) -> None: await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_registry = er.async_get(hass) ent = entity_registry.async_get("sensor.my_generator_latest_firmware") - device_registry = dr.async_get(hass) dev = device_registry.async_get(ent.device_id) assert dev.connections == connections diff --git a/tests/components/ondilo_ico/__init__.py b/tests/components/ondilo_ico/__init__.py index 12d8d3e2b9f..7637137631a 100644 --- a/tests/components/ondilo_ico/__init__.py +++ b/tests/components/ondilo_ico/__init__.py @@ -1 +1,17 @@ """Tests for the Ondilo ICO integration.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, mock_ondilo_client: MagicMock +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ondilo_ico/conftest.py b/tests/components/ondilo_ico/conftest.py new file mode 100644 index 00000000000..6a03d6961c2 --- /dev/null +++ b/tests/components/ondilo_ico/conftest.py @@ -0,0 +1,84 @@ +"""Provide basic Ondilo fixture.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.ondilo_ico.const import DOMAIN + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Ondilo ICO", + data={"auth_implementation": DOMAIN, "token": {"access_token": "fake_token"}}, + ) + + +@pytest.fixture +def mock_ondilo_client( + two_pools: list[dict[str, Any]], + ico_details1: dict[str, Any], + ico_details2: dict[str, Any], + last_measures: list[dict[str, Any]], +) -> Generator[MagicMock]: + """Mock a Homeassistant Ondilo client.""" + with ( + patch( + "homeassistant.components.ondilo_ico.OndiloClient", + autospec=True, + ) as mock_ondilo, + ): + client = mock_ondilo.return_value + client.get_pools.return_value = two_pools + client.get_ICO_details.side_effect = [ico_details1, ico_details2] + client.get_last_pool_measures.return_value = last_measures + yield client + + +@pytest.fixture(scope="session") +def pool1() -> list[dict[str, Any]]: + """First pool description.""" + return [load_json_object_fixture("pool1.json", DOMAIN)] + + +@pytest.fixture(scope="session") +def pool2() -> list[dict[str, Any]]: + """Second pool description.""" + return [load_json_object_fixture("pool2.json", DOMAIN)] + + +@pytest.fixture(scope="session") +def ico_details1() -> dict[str, Any]: + """ICO details of first pool.""" + return load_json_object_fixture("ico_details1.json", DOMAIN) + + +@pytest.fixture(scope="session") +def ico_details2() -> dict[str, Any]: + """ICO details of second pool.""" + return load_json_object_fixture("ico_details2.json", DOMAIN) + + +@pytest.fixture(scope="session") +def last_measures() -> list[dict[str, Any]]: + """Pool measurements.""" + return load_json_array_fixture("last_measures.json", DOMAIN) + + +@pytest.fixture(scope="session") +def two_pools( + pool1: list[dict[str, Any]], pool2: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """Two pools description.""" + return [*pool1, *pool2] diff --git a/tests/components/ondilo_ico/fixtures/ico_details1.json b/tests/components/ondilo_ico/fixtures/ico_details1.json new file mode 100644 index 00000000000..1712e660241 --- /dev/null +++ b/tests/components/ondilo_ico/fixtures/ico_details1.json @@ -0,0 +1,5 @@ +{ + "uuid": "111112222233333444445555", + "serial_number": "W1122333044455", + "sw_version": "1.7.1-stable" +} diff --git a/tests/components/ondilo_ico/fixtures/ico_details2.json b/tests/components/ondilo_ico/fixtures/ico_details2.json new file mode 100644 index 00000000000..55b838543bd --- /dev/null +++ b/tests/components/ondilo_ico/fixtures/ico_details2.json @@ -0,0 +1,5 @@ +{ + "uuid": "222223333344444555566666", + "serial_number": "W2233304445566", + "sw_version": "1.7.1-stable" +} diff --git a/tests/components/ondilo_ico/fixtures/last_measures.json b/tests/components/ondilo_ico/fixtures/last_measures.json new file mode 100644 index 00000000000..6961d3eea52 --- /dev/null +++ b/tests/components/ondilo_ico/fixtures/last_measures.json @@ -0,0 +1,51 @@ +[ + { + "data_type": "temperature", + "value": 19, + "value_time": "2024-01-01 01:00:00", + "is_valid": true, + "exclusion_reason": null + }, + { + "data_type": "ph", + "value": 9.29, + "value_time": "2024-01-01 01:00:00", + "is_valid": true, + "exclusion_reason": null + }, + { + "data_type": "orp", + "value": 647, + "value_time": "2024-01-01 01:00:00", + "is_valid": true, + "exclusion_reason": null + }, + { + "data_type": "salt", + "value": null, + "value_time": "2024-01-01 01:00:00", + "is_valid": true, + "exclusion_reason": null + }, + { + "data_type": "battery", + "value": 50, + "value_time": "2024-01-01 01:00:00", + "is_valid": true, + "exclusion_reason": null + }, + { + "data_type": "tds", + "value": 845, + "value_time": "2024-01-01 01:00:00", + "is_valid": true, + "exclusion_reason": null + }, + { + "data_type": "rssi", + "value": 60, + "value_time": "2024-01-01 01:00:00", + "is_valid": true, + "exclusion_reason": null + } +] diff --git a/tests/components/ondilo_ico/fixtures/pool1.json b/tests/components/ondilo_ico/fixtures/pool1.json new file mode 100644 index 00000000000..9b67a6450d9 --- /dev/null +++ b/tests/components/ondilo_ico/fixtures/pool1.json @@ -0,0 +1,19 @@ +{ + "id": 1, + "name": "Pool 1", + "type": "outdoor_inground_pool", + "volume": 100, + "disinfection": { + "primary": "chlorine", + "secondary": { "uv_sanitizer": false, "ozonator": false } + }, + "address": { + "street": "1 Rue de Paris", + "zipcode": "75000", + "city": "Paris", + "country": "France", + "latitude": 48.861783, + "longitude": 2.337421 + }, + "updated_at": "2024-01-01T01:00:00+0000" +} diff --git a/tests/components/ondilo_ico/fixtures/pool2.json b/tests/components/ondilo_ico/fixtures/pool2.json new file mode 100644 index 00000000000..da0cb62d484 --- /dev/null +++ b/tests/components/ondilo_ico/fixtures/pool2.json @@ -0,0 +1,19 @@ +{ + "id": 2, + "name": "Pool 2", + "type": "outdoor_inground_pool", + "volume": 120, + "disinfection": { + "primary": "chlorine", + "secondary": { "uv_sanitizer": false, "ozonator": false } + }, + "address": { + "street": "1 Rue de Paris", + "zipcode": "75000", + "city": "Paris", + "country": "France", + "latitude": 48.861783, + "longitude": 2.337421 + }, + "updated_at": "2024-01-01T01:00:00+0000" +} diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr new file mode 100644 index 00000000000..c488b1e3c15 --- /dev/null +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_devices[ondilo_ico-W1122333044455] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ondilo_ico', + 'W1122333044455', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Ondilo', + 'model': 'ICO', + 'name': 'Pool 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.7.1-stable', + 'via_device_id': None, + }) +# --- +# name: test_devices[ondilo_ico-W2233304445566] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'ondilo_ico', + 'W2233304445566', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Ondilo', + 'model': 'ICO', + 'name': 'Pool 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.7.1-stable', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/ondilo_ico/snapshots/test_sensor.ambr b/tests/components/ondilo_ico/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..56e30cd904a --- /dev/null +++ b/tests/components/ondilo_ico/snapshots/test_sensor.ambr @@ -0,0 +1,705 @@ +# serializer version: 1 +# name: test_sensors[sensor.pool_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'W1122333044455-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.pool_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pool 1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pool_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.pool_1_oxydo_reduction_potential-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_1_oxydo_reduction_potential', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Oxydo reduction potential', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oxydo_reduction_potential', + 'unique_id': 'W1122333044455-orp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.pool_1_oxydo_reduction_potential-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 1 Oxydo reduction potential', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_1_oxydo_reduction_potential', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '647', + }) +# --- +# name: test_sensors[sensor.pool_1_ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_1_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'W1122333044455-ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.pool_1_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ph', + 'friendly_name': 'Pool 1 pH', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.pool_1_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.29', + }) +# --- +# name: test_sensors[sensor.pool_1_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_1_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'W1122333044455-rssi', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.pool_1_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 1 RSSI', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pool_1_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[sensor.pool_1_salt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_1_salt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt', + 'unique_id': 'W1122333044455-salt', + 'unit_of_measurement': 'mg/L', + }) +# --- +# name: test_sensors[sensor.pool_1_salt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 1 Salt', + 'state_class': , + 'unit_of_measurement': 'mg/L', + }), + 'context': , + 'entity_id': 'sensor.pool_1_salt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.pool_1_tds-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_1_tds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TDS', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tds', + 'unique_id': 'W1122333044455-tds', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[sensor.pool_1_tds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 1 TDS', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.pool_1_tds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '845', + }) +# --- +# name: test_sensors[sensor.pool_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'W1122333044455-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.pool_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pool 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19', + }) +# --- +# name: test_sensors[sensor.pool_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'W2233304445566-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.pool_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pool 2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pool_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.pool_2_oxydo_reduction_potential-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_2_oxydo_reduction_potential', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Oxydo reduction potential', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oxydo_reduction_potential', + 'unique_id': 'W2233304445566-orp', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.pool_2_oxydo_reduction_potential-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 2 Oxydo reduction potential', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_2_oxydo_reduction_potential', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '647', + }) +# --- +# name: test_sensors[sensor.pool_2_ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_2_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'W2233304445566-ph', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.pool_2_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ph', + 'friendly_name': 'Pool 2 pH', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.pool_2_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.29', + }) +# --- +# name: test_sensors[sensor.pool_2_rssi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_2_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rssi', + 'unique_id': 'W2233304445566-rssi', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.pool_2_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 2 RSSI', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pool_2_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[sensor.pool_2_salt-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_2_salt', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Salt', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'salt', + 'unique_id': 'W2233304445566-salt', + 'unit_of_measurement': 'mg/L', + }) +# --- +# name: test_sensors[sensor.pool_2_salt-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 2 Salt', + 'state_class': , + 'unit_of_measurement': 'mg/L', + }), + 'context': , + 'entity_id': 'sensor.pool_2_salt', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.pool_2_tds-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_2_tds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TDS', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tds', + 'unique_id': 'W2233304445566-tds', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[sensor.pool_2_tds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool 2 TDS', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.pool_2_tds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '845', + }) +# --- +# name: test_sensors[sensor.pool_2_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_2_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ondilo_ico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'W2233304445566-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.pool_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pool 2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19', + }) +# --- diff --git a/tests/components/ondilo_ico/test_config_flow.py b/tests/components/ondilo_ico/test_config_flow.py index 6b8fcbeefea..deab2a8e0b9 100644 --- a/tests/components/ondilo_ico/test_config_flow.py +++ b/tests/components/ondilo_ico/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries, setup from homeassistant.components.ondilo_ico.const import ( DOMAIN, @@ -34,11 +36,11 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: assert result["reason"] == "single_instance_allowed" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" assert await setup.async_setup_component( diff --git a/tests/components/ondilo_ico/test_init.py b/tests/components/ondilo_ico/test_init.py new file mode 100644 index 00000000000..707022e9145 --- /dev/null +++ b/tests/components/ondilo_ico/test_init.py @@ -0,0 +1,55 @@ +"""Test Ondilo ICO initialization.""" + +from typing import Any +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_devices( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test devices are registered.""" + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 2 + + for device_entry in device_entries: + identifier = list(device_entry.identifiers)[0] + assert device_entry == snapshot(name=f"{identifier[0]}-{identifier[1]}") + + +async def test_init_with_no_ico_attached( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + config_entry: MockConfigEntry, + pool1: dict[str, Any], +) -> None: + """Test if an ICO is not attached to a pool, then no sensor is created.""" + # Only one pool, but no ICO attached + mock_ondilo_client.get_pools.return_value = pool1 + mock_ondilo_client.get_ICO_details.side_effect = None + mock_ondilo_client.get_ICO_details.return_value = None + await setup_integration(hass, config_entry, mock_ondilo_client) + + # No sensor should be created + assert len(hass.states.async_all()) == 0 + # We should not have tried to retrieve pool measures + mock_ondilo_client.get_last_pool_measures.assert_not_called() + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/ondilo_ico/test_sensor.py b/tests/components/ondilo_ico/test_sensor.py new file mode 100644 index 00000000000..0043d22f6c0 --- /dev/null +++ b/tests/components/ondilo_ico/test_sensor.py @@ -0,0 +1,84 @@ +"""Test Ondilo ICO integration sensors.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +from ondilo import OndiloError +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that I can get all pools data when no error.""" + with patch("homeassistant.components.ondilo_ico.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry, mock_ondilo_client) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_no_ico_for_one_pool( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + config_entry: MockConfigEntry, + two_pools: list[dict[str, Any]], + ico_details2: dict[str, Any], + last_measures: list[dict[str, Any]], +) -> None: + """Test if an ICO is not attached to a pool, then no sensor for that pool is created.""" + mock_ondilo_client.get_pools.return_value = two_pools + mock_ondilo_client.get_ICO_details.side_effect = [None, ico_details2] + + await setup_integration(hass, config_entry, mock_ondilo_client) + # Only the second pool is created + assert len(hass.states.async_all()) == 7 + assert hass.states.get("sensor.pool_1_temperature") is None + assert hass.states.get("sensor.pool_2_rssi").state == next( + str(item["value"]) for item in last_measures if item["data_type"] == "rssi" + ) + + +async def test_error_retrieving_ico( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + config_entry: MockConfigEntry, + pool1: dict[str, Any], +) -> None: + """Test if there's an error retrieving ICO data, then no sensor is created.""" + mock_ondilo_client.get_pools.return_value = pool1 + mock_ondilo_client.get_ICO_details.side_effect = OndiloError(400, "error") + + await setup_integration(hass, config_entry, mock_ondilo_client) + + # No sensor should be created + assert len(hass.states.async_all()) == 0 + + +async def test_error_retrieving_measures( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + config_entry: MockConfigEntry, + pool1: dict[str, Any], + ico_details1: dict[str, Any], +) -> None: + """Test if there's an error retrieving measures of ICO, then no sensor is created.""" + mock_ondilo_client.get_pools.return_value = pool1 + mock_ondilo_client.get_ICO_details.return_value = ico_details1 + mock_ondilo_client.get_last_pool_measures.side_effect = OndiloError(400, "error") + + await setup_integration(hass, config_entry, mock_ondilo_client) + + # No sensor should be created + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 03a8443049e..47b50ab10e0 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -1,10 +1,10 @@ """Provide common 1-Wire fixtures.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from pyownet.protocol import ConnError import pytest +from typing_extensions import Generator from homeassistant.components.onewire.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntry @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.onewire.async_setup_entry", return_value=True diff --git a/tests/components/onewire/test_binary_sensor.py b/tests/components/onewire/test_binary_sensor.py index 26b1ed5aed7..8b1129529d5 100644 --- a/tests/components/onewire/test_binary_sensor.py +++ b/tests/components/onewire/test_binary_sensor.py @@ -1,10 +1,10 @@ """Tests for 1-Wire binary sensors.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -15,7 +15,7 @@ from . import setup_owproxy_mock_devices @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.onewire.PLATFORMS", [Platform.BINARY_SENSOR]): yield diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index dd08e825221..62b045c4516 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -1,10 +1,10 @@ """Test 1-Wire diagnostics.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -17,7 +17,7 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): yield diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index b8ab2fa9ccf..82ff75628c2 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -80,6 +80,7 @@ async def test_update_options( @patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, config_entry: ConfigEntry, owproxy: MagicMock, hass_ws_client: WebSocketGenerator, @@ -88,7 +89,6 @@ async def test_registry_cleanup( assert await async_setup_component(hass, "config", {}) entry_id = config_entry.entry_id - device_registry = dr.async_get(hass) live_id = "10.111111111111" dead_id = "28.111111111111" diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 848489c837f..df0a81920c9 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -1,6 +1,5 @@ """Tests for 1-Wire sensors.""" -from collections.abc import Generator from copy import deepcopy import logging from unittest.mock import MagicMock, _patch_dict, patch @@ -8,6 +7,7 @@ from unittest.mock import MagicMock, _patch_dict, patch from pyownet.protocol import OwnetError import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -19,7 +19,7 @@ from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]): yield diff --git a/tests/components/onewire/test_switch.py b/tests/components/onewire/test_switch.py index c6d84d38848..b1b8e5ddbd0 100644 --- a/tests/components/onewire/test_switch.py +++ b/tests/components/onewire/test_switch.py @@ -1,10 +1,10 @@ """Tests for 1-Wire switches.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -22,7 +22,7 @@ from . import setup_owproxy_mock_devices @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.onewire.PLATFORMS", [Platform.SWITCH]): yield diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 1e7c3273ced..0857dfef798 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -18,6 +18,7 @@ from homeassistant.components.onvif.models import ( WebHookManagerState, ) from homeassistant.const import HTTP_DIGEST_AUTHENTICATION +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -158,7 +159,7 @@ def setup_mock_device(mock_device, capabilities=None): async def setup_onvif_integration( - hass, + hass: HomeAssistant, config=None, options=None, unique_id=MAC, diff --git a/tests/components/onvif/test_button.py b/tests/components/onvif/test_button.py index f8d51ae31a0..209733a0f78 100644 --- a/tests/components/onvif/test_button.py +++ b/tests/components/onvif/test_button.py @@ -10,7 +10,9 @@ from homeassistant.helpers import entity_registry as er from . import MAC, setup_onvif_integration -async def test_reboot_button(hass: HomeAssistant) -> None: +async def test_reboot_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the Reboot button.""" await setup_onvif_integration(hass) @@ -19,8 +21,7 @@ async def test_reboot_button(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - registry = er.async_get(hass) - entry = registry.async_get("button.testcamera_reboot") + entry = entity_registry.async_get("button.testcamera_reboot") assert entry assert entry.unique_id == f"{MAC}_reboot" @@ -42,7 +43,9 @@ async def test_reboot_button_press(hass: HomeAssistant) -> None: devicemgmt.SystemReboot.assert_called_once() -async def test_set_dateandtime_button(hass: HomeAssistant) -> None: +async def test_set_dateandtime_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the SetDateAndTime button.""" await setup_onvif_integration(hass) @@ -50,8 +53,7 @@ async def test_set_dateandtime_button(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNKNOWN - registry = er.async_get(hass) - entry = registry.async_get("button.testcamera_set_system_date_and_time") + entry = entity_registry.async_get("button.testcamera_set_system_date_and_time") assert entry assert entry.unique_id == f"{MAC}_setsystemdatetime" diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index b08615add0e..c0e5a6fe545 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -673,12 +673,13 @@ async def test_option_flow(hass: HomeAssistant, option_value: bool) -> None: } -async def test_discovered_by_dhcp_updates_host(hass: HomeAssistant) -> None: +async def test_discovered_by_dhcp_updates_host( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test dhcp updates existing host.""" config_entry, _camera, device = await setup_onvif_integration(hass) device.profiles = device.async_get_profiles() - registry = dr.async_get(hass) - devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) assert len(devices) == 1 device = devices[0] assert device.model == "TestModel" @@ -697,13 +698,12 @@ async def test_discovered_by_dhcp_updates_host(hass: HomeAssistant) -> None: async def test_discovered_by_dhcp_does_nothing_if_host_is_the_same( - hass: HomeAssistant, + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test dhcp update does nothing if host is the same.""" config_entry, _camera, device = await setup_onvif_integration(hass) device.profiles = device.async_get_profiles() - registry = dr.async_get(hass) - devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) assert len(devices) == 1 device = devices[0] assert device.model == "TestModel" @@ -722,13 +722,12 @@ async def test_discovered_by_dhcp_does_nothing_if_host_is_the_same( async def test_discovered_by_dhcp_does_not_update_if_already_loaded( - hass: HomeAssistant, + hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test dhcp does not update existing host if its already loaded.""" config_entry, _camera, device = await setup_onvif_integration(hass) device.profiles = device.async_get_profiles() - registry = dr.async_get(hass) - devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + devices = dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) assert len(devices) == 1 device = devices[0] assert device.model == "TestModel" diff --git a/tests/components/onvif/test_switch.py b/tests/components/onvif/test_switch.py index 0afa4ff4042..8e23345bae5 100644 --- a/tests/components/onvif/test_switch.py +++ b/tests/components/onvif/test_switch.py @@ -10,7 +10,9 @@ from homeassistant.helpers import entity_registry as er from . import MAC, Capabilities, setup_onvif_integration -async def test_wiper_switch(hass: HomeAssistant) -> None: +async def test_wiper_switch( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the Wiper switch.""" _config, _camera, device = await setup_onvif_integration(hass) device.profiles = device.async_get_profiles() @@ -19,8 +21,7 @@ async def test_wiper_switch(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNKNOWN - registry = er.async_get(hass) - entry = registry.async_get("switch.testcamera_wiper") + entry = entity_registry.async_get("switch.testcamera_wiper") assert entry assert entry.unique_id == f"{MAC}_wiper" @@ -71,7 +72,9 @@ async def test_turn_wiper_switch_off(hass: HomeAssistant) -> None: assert state.state == STATE_OFF -async def test_autofocus_switch(hass: HomeAssistant) -> None: +async def test_autofocus_switch( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the autofocus switch.""" _config, _camera, device = await setup_onvif_integration(hass) device.profiles = device.async_get_profiles() @@ -80,8 +83,7 @@ async def test_autofocus_switch(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNKNOWN - registry = er.async_get(hass) - entry = registry.async_get("switch.testcamera_autofocus") + entry = entity_registry.async_get("switch.testcamera_autofocus") assert entry assert entry.unique_id == f"{MAC}_autofocus" @@ -132,7 +134,9 @@ async def test_turn_autofocus_switch_off(hass: HomeAssistant) -> None: assert state.state == STATE_OFF -async def test_infrared_switch(hass: HomeAssistant) -> None: +async def test_infrared_switch( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test states of the autofocus switch.""" _config, _camera, device = await setup_onvif_integration(hass) device.profiles = device.async_get_profiles() @@ -141,8 +145,7 @@ async def test_infrared_switch(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNKNOWN - registry = er.async_get(hass) - entry = registry.async_get("switch.testcamera_ir_lamp") + entry = entity_registry.async_get("switch.testcamera_ir_lamp") assert entry assert entry.unique_id == f"{MAC}_ir_lamp" diff --git a/tests/components/open_meteo/conftest.py b/tests/components/open_meteo/conftest.py index 466d593cd73..0d3e1274693 100644 --- a/tests/components/open_meteo/conftest.py +++ b/tests/components/open_meteo/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import MagicMock, patch from open_meteo import Forecast import pytest +from typing_extensions import Generator from homeassistant.components.open_meteo.const import DOMAIN from homeassistant.const import CONF_ZONE @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.open_meteo.async_setup_entry", return_value=True @@ -35,7 +35,7 @@ def mock_setup_entry() -> Generator[None, None, None]: @pytest.fixture -def mock_open_meteo(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_open_meteo(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return a mocked Open-Meteo client.""" fixture: str = "forecast.json" if hasattr(request, "param") and request.param: diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 272c23a9510..6d770b51ce9 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -4,7 +4,9 @@ from unittest.mock import patch import pytest +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -24,6 +26,15 @@ def mock_config_entry(hass): return entry +@pytest.fixture +def mock_config_entry_with_assist(hass, mock_config_entry): + """Mock a config entry with assist.""" + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component(hass, mock_config_entry): """Initialize integration.""" diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 1a488bb948c..e4dd7cd00bb 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,67 +1,34 @@ # serializer version: 1 -# name: test_default_prompt[None] - list([ - dict({ - 'content': ''' - This smart home is controlled by Home Assistant. - - An overview of the areas and the devices in this smart home: - - Test Area: - - Test Device (Test Model) - - Test Area 2: - - Test Device 2 - - Test Device 3 (Test Model 3A) - - Test Device 4 - - 1 (3) - - Answer the user's questions about the world truthfully. - - If the user wants to control a device, reject the request and suggest using the Home Assistant app. - ''', - 'role': 'system', - }), - dict({ - 'content': 'hello', - 'role': 'user', - }), - dict({ - 'content': 'Hello, how can I help you?', - 'role': 'assistant', - }), - ]) -# --- -# name: test_default_prompt[conversation.openai] - list([ - dict({ - 'content': ''' - This smart home is controlled by Home Assistant. - - An overview of the areas and the devices in this smart home: - - Test Area: - - Test Device (Test Model) - - Test Area 2: - - Test Device 2 - - Test Device 3 (Test Model 3A) - - Test Device 4 - - 1 (3) - - Answer the user's questions about the world truthfully. - - If the user wants to control a device, reject the request and suggest using the Home Assistant app. - ''', - 'role': 'system', - }), - dict({ - 'content': 'hello', - 'role': 'user', - }), - dict({ - 'content': 'Hello, how can I help you?', - 'role': 'assistant', - }), - ]) +# name: test_unknown_hass_api + dict({ + 'conversation_id': None, + 'response': IntentResponse( + card=dict({ + }), + error_code=, + failed_results=list([ + ]), + intent=None, + intent_targets=list([ + ]), + language='en', + matched_states=list([ + ]), + reprompt=dict({ + }), + response_type=, + speech=dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': 'Error preparing LLM API: API non-existing not found', + }), + }), + speech_slots=dict({ + }), + success_results=list([ + ]), + unmatched_states=list([ + ]), + ), + }) # --- diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 57f03d0c0bf..f5017c124b1 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -7,11 +7,20 @@ from openai import APIConnectionError, AuthenticationError, BadRequestError import pytest from homeassistant import config_entries +from homeassistant.components.openai_conversation.config_flow import RECOMMENDED_OPTIONS from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, - DEFAULT_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_PROMPT, + CONF_RECOMMENDED, + CONF_TEMPERATURE, + CONF_TOP_P, DOMAIN, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TOP_P, ) +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -54,6 +63,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } + assert result2["options"] == RECOMMENDED_OPTIONS assert len(mock_setup_entry.mock_calls) == 1 @@ -75,7 +85,7 @@ async def test_options( assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"]["prompt"] == "Speak like a pirate" assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL + assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL @pytest.mark.parametrize( @@ -115,3 +125,78 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error} + + +@pytest.mark.parametrize( + ("current_options", "new_options", "expected_options"), + [ + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "none", + CONF_PROMPT: "bla", + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + }, + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ), + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "assist", + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: "assist", + CONF_PROMPT: "", + }, + ), + ], +) +async def test_options_switching( + hass: HomeAssistant, + mock_config_entry, + mock_init_component, + current_options, + new_options, + expected_options, +) -> None: + """Test the options form.""" + hass.config_entries.async_update_entry(mock_config_entry, options=current_options) + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + **current_options, + CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], + }, + ) + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + new_options, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.CREATE_ENTRY + assert options["data"] == expected_options diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 9e50204cdde..1008482847c 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,141 +1,32 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from freezegun import freeze_time from httpx import Response from openai import RateLimitError from openai.types.chat.chat_completion import ChatCompletion, Choice from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.chat.chat_completion_message_tool_call import ( + ChatCompletionMessageToolCall, + Function, +) from openai.types.completion_usage import CompletionUsage -import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant.components import conversation +from homeassistant.components.conversation import trace +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import area_registry as ar, device_registry as dr, intent +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent, llm +from homeassistant.setup import async_setup_component +from homeassistant.util import ulid from tests.common import MockConfigEntry -@pytest.mark.parametrize("agent_id", [None, "conversation.openai"]) -async def test_default_prompt( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - snapshot: SnapshotAssertion, - agent_id: str, -) -> None: - """Test that the default prompt works.""" - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - for i in range(3): - area_registry.async_create(f"{i}Empty Area") - - if agent_id is None: - agent_id = mock_config_entry.entry_id - - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "1234")}, - name="Test Device", - manufacturer="Test Manufacturer", - model="Test Model", - suggested_area="Test Area", - ) - for i in range(3): - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", f"{i}abcd")}, - name="Test Service", - manufacturer="Test Manufacturer", - model="Test Model", - suggested_area="Test Area", - entry_type=dr.DeviceEntryType.SERVICE, - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "5678")}, - name="Test Device 2", - manufacturer="Test Manufacturer 2", - model="Device 2", - suggested_area="Test Area 2", - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876")}, - name="Test Device 3", - manufacturer="Test Manufacturer 3", - model="Test Model 3A", - suggested_area="Test Area 2", - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "qwer")}, - name="Test Device 4", - suggested_area="Test Area 2", - ) - device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-disabled")}, - name="Test Device 3", - manufacturer="Test Manufacturer 3", - model="Test Model 3A", - suggested_area="Test Area 2", - ) - device_registry.async_update_device( - device.id, disabled_by=dr.DeviceEntryDisabler.USER - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-no-name")}, - manufacturer="Test Manufacturer NoName", - model="Test Model NoName", - suggested_area="Test Area 2", - ) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={("test", "9876-integer-values")}, - name=1, - manufacturer=2, - model=3, - suggested_area="Test Area 2", - ) - with patch( - "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=agent_id - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_create.mock_calls[0][2]["messages"] == snapshot - - async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: @@ -184,6 +75,53 @@ async def test_template_error( assert result.response.error_code == "unknown", result +async def test_template_variables( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template variables work.""" + context = Context(user_id="12345") + mock_user = Mock() + mock_user.id = "12345" + mock_user.name = "Test User" + + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": ( + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + ), + }, + ) + with ( + patch( + "openai.resources.models.AsyncModels.list", + ), + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create, + patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + result = await conversation.async_converse( + hass, "hello", None, context, agent_id=mock_config_entry.entry_id + ) + + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + assert ( + "The user name is Test User." + in mock_create.mock_calls[0][2]["messages"][0]["content"] + ) + assert ( + "The user id is 12345." + in mock_create.mock_calls[0][2]["messages"][0]["content"] + ) + + async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -194,3 +132,407 @@ async def test_conversation_agent( mock_config_entry.entry_id ) assert agent.supported_languages == "*" + + +@patch( + "homeassistant.components.openai_conversation.conversation.llm.AssistAPI._async_get_tools" +) +async def test_function_call( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test function call from the assistant.""" + agent_id = mock_config_entry_with_assist.entry_id + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + mock_get_tools.return_value = [mock_tool] + + def completion_result(*args, messages, **kwargs): + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", + function=Function( + arguments='{"param1":"test_value"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create, + freeze_time("2024-06-03 23:00:00"), + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + ) + + assert ( + "Today's date is 2024-06-03." + in mock_create.mock_calls[1][2]["messages"][0]["content"] + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_create.mock_calls[1][2]["messages"][3] == { + "role": "tool", + "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", + "content": '"Test response"', + } + mock_tool.async_call.assert_awaited_once_with( + hass, + llm.ToolInput( + tool_name="test_tool", + tool_args={"param1": "test_value"}, + ), + llm.LLMContext( + platform="openai_conversation", + context=context, + user_prompt="Please call the test function", + language="en", + assistant="conversation", + device_id=None, + ), + ) + + # Test Conversation tracing + traces = trace.async_get_traces() + assert traces + last_trace = traces[-1].as_dict() + trace_events = last_trace.get("events", []) + assert [event["event_type"] for event in trace_events] == [ + trace.ConversationTraceEventType.ASYNC_PROCESS, + trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.LLM_TOOL_CALL, + ] + # AGENT_DETAIL event contains the raw prompt passed to the model + detail_event = trace_events[1] + assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] + assert ( + "Today's date is 2024-06-03." + in trace_events[1]["data"]["messages"][0]["content"] + ) + + # Call it again, make sure we have updated prompt + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create, + freeze_time("2024-06-04 23:00:00"), + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + ) + + assert ( + "Today's date is 2024-06-04." + in mock_create.mock_calls[1][2]["messages"][0]["content"] + ) + # Test old assert message not updated + assert ( + "Today's date is 2024-06-03." + in trace_events[1]["data"]["messages"][0]["content"] + ) + + +@patch( + "homeassistant.components.openai_conversation.conversation.llm.AssistAPI._async_get_tools" +) +async def test_function_exception( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test function call with exception.""" + agent_id = mock_config_entry_with_assist.entry_id + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") + + mock_get_tools.return_value = [mock_tool] + + def completion_result(*args, messages, **kwargs): + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="There was an error calling the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", + function=Function( + arguments='{"param1":"test_value"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create: + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_create.mock_calls[1][2]["messages"][3] == { + "role": "tool", + "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", + "content": '{"error": "HomeAssistantError", "error_text": "Test tool exception"}', + } + mock_tool.async_call.assert_awaited_once_with( + hass, + llm.ToolInput( + tool_name="test_tool", + tool_args={"param1": "test_value"}, + ), + llm.LLMContext( + platform="openai_conversation", + context=context, + user_prompt="Please call the test function", + language="en", + assistant="conversation", + device_id=None, + ), + ) + + +async def test_assist_api_tools_conversion( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, +) -> None: + """Test that we are able to convert actual tools from Assist API.""" + for component in ( + "intent", + "todo", + "light", + "shopping_list", + "humidifier", + "climate", + "media_player", + "vacuum", + "cover", + "weather", + ): + assert await async_setup_component(hass, component, {}) + + agent_id = mock_config_entry_with_assist.entry_id + with patch( + "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: + await conversation.async_converse( + hass, "hello", None, Context(), agent_id=agent_id + ) + + tools = mock_create.mock_calls[0][2]["tools"] + assert tools + + +async def test_unknown_hass_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_init_component, +) -> None: + """Test when we reference an API that no longer exists.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_LLM_HASS_API: "non-existing", + }, + ) + + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result == snapshot + + +@patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, +) +async def test_conversation_id( + mock_create, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test conversation ID is honored.""" + result = await conversation.async_converse( + hass, "hello", None, None, agent_id=mock_config_entry.entry_id + ) + + conversation_id = result.conversation_id + + result = await conversation.async_converse( + hass, "hello", conversation_id, None, agent_id=mock_config_entry.entry_id + ) + + assert result.conversation_id == conversation_id + + unknown_id = ulid.ulid() + + result = await conversation.async_converse( + hass, "hello", unknown_id, None, agent_id=mock_config_entry.entry_id + ) + + assert result.conversation_id != unknown_id + + result = await conversation.async_converse( + hass, "hello", "koala", None, agent_id=mock_config_entry.entry_id + ) + + assert result.conversation_id == "koala" diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 773ba3bca06..c9431aa1083 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -14,7 +14,7 @@ from openai.types.images_response import ImagesResponse import pytest from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -160,6 +160,28 @@ async def test_generate_image_service_error( ) +async def test_invalid_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Assert exception when invalid config entry is provided.""" + service_data = { + "prompt": "Picture of a dog", + "config_entry": "invalid_entry", + } + with pytest.raises( + ServiceValidationError, match="Invalid config entry provided. Got invalid_entry" + ): + await hass.services.async_call( + "openai_conversation", + "generate_image", + service_data, + blocking=True, + return_response=True, + ) + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -179,7 +201,11 @@ async def test_generate_image_service_error( ], ) async def test_init_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, caplog, side_effect, error + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + side_effect, + error, ) -> None: """Test initialization errors.""" with patch( diff --git a/tests/components/openexchangerates/conftest.py b/tests/components/openexchangerates/conftest.py index 5cb97e0cc53..6bd7da2c7af 100644 --- a/tests/components/openexchangerates/conftest.py +++ b/tests/components/openexchangerates/conftest.py @@ -1,9 +1,9 @@ """Provide common fixtures for tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.openexchangerates.const import DOMAIN @@ -19,7 +19,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.openexchangerates.async_setup_entry", @@ -29,9 +29,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_latest_rates_config_flow( - request: pytest.FixtureRequest, -) -> Generator[AsyncMock, None, None]: +def mock_latest_rates_config_flow() -> Generator[AsyncMock]: """Return a mocked WLED client.""" with patch( "homeassistant.components.openexchangerates.config_flow.Client.get_latest", diff --git a/tests/components/openexchangerates/test_config_flow.py b/tests/components/openexchangerates/test_config_flow.py index 2bc24e6852b..30ea619d646 100644 --- a/tests/components/openexchangerates/test_config_flow.py +++ b/tests/components/openexchangerates/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Open Exchange Rates config flow.""" import asyncio -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch @@ -10,6 +9,7 @@ from aioopenexchangerates import ( OpenExchangeRatesClientError, ) import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.openexchangerates.const import DOMAIN @@ -20,7 +20,7 @@ from tests.common import MockConfigEntry @pytest.fixture(name="currencies", autouse=True) -def currencies_fixture(hass: HomeAssistant) -> Generator[AsyncMock, None, None]: +def currencies_fixture(hass: HomeAssistant) -> Generator[AsyncMock]: """Mock currencies.""" with patch( "homeassistant.components.openexchangerates.config_flow.Client.get_currencies", diff --git a/tests/components/opengarage/conftest.py b/tests/components/opengarage/conftest.py index 24dc8134e4b..c960e723289 100644 --- a/tests/components/opengarage/conftest.py +++ b/tests/components/opengarage/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.opengarage.const import CONF_DEVICE_KEY, DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL @@ -31,7 +31,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_opengarage() -> Generator[MagicMock, None, None]: +def mock_opengarage() -> Generator[MagicMock]: """Return a mocked OpenGarage client.""" with patch( "homeassistant.components.opengarage.opengarage.OpenGarage", diff --git a/tests/components/openhome/test_update.py b/tests/components/openhome/test_update.py index d3a328b9f9e..354ed26af64 100644 --- a/tests/components/openhome/test_update.py +++ b/tests/components/openhome/test_update.py @@ -89,7 +89,7 @@ async def setup_integration( await hass.async_block_till_done() -async def test_not_supported(hass: HomeAssistant): +async def test_not_supported(hass: HomeAssistant) -> None: """Ensure update entity works if service not supported.""" update_firmware = AsyncMock() @@ -107,7 +107,7 @@ async def test_not_supported(hass: HomeAssistant): update_firmware.assert_not_called() -async def test_on_latest_firmware(hass: HomeAssistant): +async def test_on_latest_firmware(hass: HomeAssistant) -> None: """Test device on latest firmware.""" update_firmware = AsyncMock() @@ -125,7 +125,7 @@ async def test_on_latest_firmware(hass: HomeAssistant): update_firmware.assert_not_called() -async def test_update_available(hass: HomeAssistant): +async def test_update_available(hass: HomeAssistant) -> None: """Test device has firmware update available.""" update_firmware = AsyncMock() @@ -158,7 +158,7 @@ async def test_update_available(hass: HomeAssistant): update_firmware.assert_called_once() -async def test_firmware_update_not_required(hass: HomeAssistant): +async def test_firmware_update_not_required(hass: HomeAssistant) -> None: """Ensure firmware install does nothing if up to date.""" update_firmware = AsyncMock() diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py index 665fdd90e69..c48f3bec8d8 100644 --- a/tests/components/opensky/conftest.py +++ b/tests/components/opensky/conftest.py @@ -1,10 +1,10 @@ """Configure tests for the OpenSky integration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from python_opensky import StatesResponse +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.opensky.const import ( CONF_ALTITUDE, @@ -23,7 +23,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.opensky.async_setup_entry", @@ -87,7 +87,7 @@ def mock_config_entry_authenticated() -> MockConfigEntry: @pytest.fixture -async def opensky_client() -> Generator[AsyncMock, None, None]: +async def opensky_client() -> AsyncGenerator[AsyncMock]: """Mock the OpenSky client.""" with ( patch( diff --git a/tests/components/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py index e30d5ad8475..b99c264f205 100644 --- a/tests/components/opensky/test_config_flow.py +++ b/tests/components/opensky/test_config_flow.py @@ -22,8 +22,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry -from tests.components.opensky import setup_integration async def test_full_user_flow(hass: HomeAssistant, mock_setup_entry) -> None: diff --git a/tests/components/opensky/test_init.py b/tests/components/opensky/test_init.py index f5acf7479a2..cc53bc1de14 100644 --- a/tests/components/opensky/test_init.py +++ b/tests/components/opensky/test_init.py @@ -10,8 +10,9 @@ from python_opensky.exceptions import OpenSkyUnauthenticatedError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry -from tests.components.opensky import setup_integration async def test_load_unload_entry( diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index 801980ec5b9..937540a42c1 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -14,12 +14,13 @@ from homeassistant.components.opensky.const import ( ) from homeassistant.core import Event, HomeAssistant +from . import setup_integration + from tests.common import ( MockConfigEntry, async_fire_time_changed, load_json_object_fixture, ) -from tests.components.opensky import setup_integration async def test_sensor( @@ -27,7 +28,7 @@ async def test_sensor( config_entry: MockConfigEntry, snapshot: SnapshotAssertion, opensky_client: AsyncMock, -): +) -> None: """Test setup sensor.""" await setup_integration(hass, config_entry) @@ -48,7 +49,7 @@ async def test_sensor_altitude( config_entry_altitude: MockConfigEntry, opensky_client: AsyncMock, snapshot: SnapshotAssertion, -): +) -> None: """Test setup sensor with a set altitude.""" await setup_integration(hass, config_entry_altitude) @@ -62,7 +63,7 @@ async def test_sensor_updating( opensky_client: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, -): +) -> None: """Test updating sensor.""" await setup_integration(hass, config_entry) diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 77d43039c2b..a1ff5b75f47 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -32,7 +32,9 @@ MOCK_CONFIG_ENTRY = MockConfigEntry( # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_device_registry_insert(hass: HomeAssistant) -> None: +async def test_device_registry_insert( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that the device registry is initialized correctly.""" MOCK_CONFIG_ENTRY.add_to_hass(hass) @@ -47,8 +49,6 @@ async def test_device_registry_insert(hass: HomeAssistant) -> None: await hass.async_block_till_done() - device_registry = dr.async_get(hass) - gw_dev = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) assert gw_dev.sw_version == VERSION_OLD diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py index 5aad7d5b1a6..69563c94c64 100644 --- a/tests/components/openuv/conftest.py +++ b/tests/components/openuv/conftest.py @@ -1,10 +1,10 @@ """Define test fixtures for OpenUV.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.openuv import CONF_FROM_WINDOW, CONF_TO_WINDOW, DOMAIN from homeassistant.const import ( @@ -23,7 +23,7 @@ TEST_LONGITUDE = -0.3817765 @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.openuv.async_setup_entry", return_value=True diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 2715d83f4f0..be02a6b01a9 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -1,13 +1,23 @@ """Define tests for the OpenWeatherMap config flow.""" -from unittest.mock import MagicMock, patch +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch -from pyowm.commons.exceptions import APIRequestError, UnauthorizedError +from pyopenweathermap import ( + CurrentWeather, + DailyTemperature, + DailyWeatherForecast, + RequestError, + WeatherCondition, + WeatherReport, +) +import pytest from homeassistant.components.openweathermap.const import ( - DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, + DEFAULT_OWM_MODE, DOMAIN, + OWM_MODE_V25, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -28,190 +38,262 @@ CONFIG = { CONF_API_KEY: "foo", CONF_LATITUDE: 50, CONF_LONGITUDE: 40, - CONF_MODE: DEFAULT_FORECAST_MODE, CONF_LANGUAGE: DEFAULT_LANGUAGE, + CONF_MODE: OWM_MODE_V25, } VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} -async def test_form(hass: HomeAssistant) -> None: +def _create_mocked_owm_client(is_valid: bool): + current_weather = CurrentWeather( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + temperature=6.84, + feels_like=2.07, + pressure=1000, + humidity=82, + dew_point=3.99, + uv_index=0.13, + cloud_coverage=75, + visibility=10000, + wind_speed=9.83, + wind_bearing=199, + wind_gust=None, + rain={}, + snow={}, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + ) + daily_weather_forecast = DailyWeatherForecast( + date_time=datetime.fromtimestamp(1714063536, tz=UTC), + summary="There will be clear sky until morning, then partly cloudy", + temperature=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + feels_like=DailyTemperature( + day=18.76, + min=8.11, + max=21.26, + night=13.06, + evening=20.51, + morning=8.47, + ), + pressure=1015, + humidity=62, + dew_point=11.34, + wind_speed=8.14, + wind_bearing=168, + wind_gust=11.81, + condition=WeatherCondition( + id=803, + main="Clouds", + description="broken clouds", + icon="04d", + ), + cloud_coverage=84, + precipitation_probability=0, + uv_index=4.06, + rain=0, + snow=0, + ) + weather_report = WeatherReport(current_weather, [], [daily_weather_forecast]) + + mocked_owm_client = MagicMock() + mocked_owm_client.validate_key = AsyncMock(return_value=is_valid) + mocked_owm_client.get_weather = AsyncMock(return_value=weather_report) + + return mocked_owm_client + + +@pytest.fixture(name="owm_client_mock") +def mock_owm_client(): + """Mock config_flow OWMClient.""" + with patch( + "homeassistant.components.openweathermap.OWMClient", + ) as owm_client_mock: + yield owm_client_mock + + +@pytest.fixture(name="config_flow_owm_client_mock") +def mock_config_flow_owm_client(): + """Mock config_flow OWMClient.""" + with patch( + "homeassistant.components.openweathermap.utils.OWMClient", + ) as config_flow_owm_client_mock: + yield config_flow_owm_client_mock + + +async def test_successful_config_flow( + hass: HomeAssistant, + owm_client_mock, + config_flow_owm_client_mock, +) -> None: """Test that the form is served with valid input.""" - mocked_owm = _create_mocked_owm(True) + mock = _create_mocked_owm_client(True) + owm_client_mock.return_value = mock + config_flow_owm_client_mock.return_value = mock - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - return_value=mocked_owm, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - - await hass.async_block_till_done() - - conf_entries = hass.config_entries.async_entries(DOMAIN) - entry = conf_entries[0] - assert entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(conf_entries[0].entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == CONFIG[CONF_NAME] - assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] - assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] - assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] - - -async def test_form_options(hass: HomeAssistant) -> None: - """Test that the options form.""" - mocked_owm = _create_mocked_owm(True) - - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - return_value=mocked_owm, - ): - config_entry = MockConfigEntry( - domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG - ) - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_MODE: "daily"} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_MODE: "daily", - CONF_LANGUAGE: DEFAULT_LANGUAGE, - } - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_MODE: "onecall_daily"} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == { - CONF_MODE: "onecall_daily", - CONF_LANGUAGE: DEFAULT_LANGUAGE, - } - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - -async def test_form_invalid_api_key(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - mocked_owm = _create_mocked_owm(True) - - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - return_value=mocked_owm, - side_effect=UnauthorizedError(""), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - - assert result["errors"] == {"base": "invalid_api_key"} - - -async def test_form_api_call_error(hass: HomeAssistant) -> None: - """Test setting up with api call error.""" - mocked_owm = _create_mocked_owm(True) - - with patch( - "pyowm.weatherapi25.weather_manager.WeatherManager", - return_value=mocked_owm, - side_effect=APIRequestError(""), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_form_api_offline(hass: HomeAssistant) -> None: - """Test setting up with api call error.""" - mocked_owm = _create_mocked_owm(False) - - with patch( - "homeassistant.components.openweathermap.config_flow.OWM", - return_value=mocked_owm, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) - - assert result["errors"] == {"base": "invalid_api_key"} - - -def _create_mocked_owm(is_api_online: bool): - mocked_owm = MagicMock() - - weather = MagicMock() - weather.temperature.return_value.get.return_value = 10 - weather.pressure.get.return_value = 10 - weather.humidity.return_value = 10 - weather.wind.return_value.get.return_value = 0 - weather.clouds.return_value = "clouds" - weather.rain.return_value = [] - weather.snow.return_value = [] - weather.detailed_status.return_value = "status" - weather.weather_code = 803 - weather.dewpoint = 10 - - mocked_owm.weather_at_coords.return_value.weather = weather - - one_day_forecast = MagicMock() - one_day_forecast.reference_time.return_value = 10 - one_day_forecast.temperature.return_value.get.return_value = 10 - one_day_forecast.rain.return_value.get.return_value = 0 - one_day_forecast.snow.return_value.get.return_value = 0 - one_day_forecast.wind.return_value.get.return_value = 0 - one_day_forecast.weather_code = 803 - - mocked_owm.forecast_at_coords.return_value.forecast.weathers = [one_day_forecast] - - one_call = MagicMock() - one_call.current = weather - one_call.forecast_hourly = [one_day_forecast] - one_call.forecast_daily = [one_day_forecast] - - mocked_owm.one_call.return_value = one_call - - mocked_owm.weather_manager.return_value.weather_at_coords.return_value = ( - is_api_online + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - return mocked_owm + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + await hass.async_block_till_done() + + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(conf_entries[0].entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONFIG[CONF_NAME] + assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] + assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] + assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] + + +async def test_abort_config_flow( + hass: HomeAssistant, + owm_client_mock, + config_flow_owm_client_mock, +) -> None: + """Test that the form is served with same data.""" + mock = _create_mocked_owm_client(True) + owm_client_mock.return_value = mock + config_flow_owm_client_mock.return_value = mock + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + + +async def test_config_flow_options_change( + hass: HomeAssistant, + owm_client_mock, + config_flow_owm_client_mock, +) -> None: + """Test that the options form.""" + mock = _create_mocked_owm_client(True) + owm_client_mock.return_value = mock + config_flow_owm_client_mock.return_value = mock + + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + new_language = "es" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_MODE: DEFAULT_OWM_MODE, CONF_LANGUAGE: new_language}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_LANGUAGE: new_language, + CONF_MODE: DEFAULT_OWM_MODE, + } + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + updated_language = "es" + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_LANGUAGE: updated_language} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_LANGUAGE: updated_language, + CONF_MODE: DEFAULT_OWM_MODE, + } + + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_form_invalid_api_key( + hass: HomeAssistant, + config_flow_owm_client_mock, +) -> None: + """Test that the form is served with no input.""" + config_flow_owm_client_mock.return_value = _create_mocked_owm_client(False) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_api_key"} + + config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_api_call_error( + hass: HomeAssistant, + config_flow_owm_client_mock, +) -> None: + """Test setting up with api call error.""" + config_flow_owm_client_mock.return_value = _create_mocked_owm_client(True) + config_flow_owm_client_mock.side_effect = RequestError("oops") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + config_flow_owm_client_mock.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 18a7caf23df..a236494f2c9 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Opower config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from opower import CannotConnect, InvalidAuth import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.opower.const import DOMAIN @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: +def override_async_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.opower.async_setup_entry", return_value=True @@ -26,7 +26,7 @@ def override_async_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_unload_entry() -> Generator[AsyncMock, None, None]: +def mock_unload_entry() -> Generator[AsyncMock]: """Mock unloading a config entry.""" with patch( "homeassistant.components.opower.async_unload_entry", diff --git a/tests/components/oralb/conftest.py b/tests/components/oralb/conftest.py index 690444d3fb1..fa4ba463357 100644 --- a/tests/components/oralb/conftest.py +++ b/tests/components/oralb/conftest.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +from typing_extensions import Generator class MockServices: @@ -44,7 +45,7 @@ class MockBleakClientBattery49(MockBleakClient): @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> Generator[None]: """Auto mock bluetooth.""" with mock.patch( diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 82f9b86b352..147f20733d6 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -3,6 +3,8 @@ from datetime import timedelta import time +import pytest + from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, async_address_present, @@ -27,9 +29,8 @@ from tests.components.bluetooth import ( ) -async def test_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" start_monotonic = time.monotonic() entry = MockConfigEntry( @@ -79,9 +80,8 @@ async def test_sensors( assert toothbrush_sensor.state == "running" -async def test_sensors_io_series_4( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors_io_series_4(hass: HomeAssistant) -> None: """Test setting up creates the sensors with an io series 4.""" start_monotonic = time.monotonic() diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 82f167cdd23..ba0f43c4a71 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for the Open Thread Border Router integration.""" -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest @@ -73,7 +73,7 @@ async def otbr_config_entry_thread_fixture(hass): @pytest.fixture(autouse=True) -def use_mocked_zeroconf(mock_async_zeroconf): +def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Mock zeroconf in all tests.""" diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 7fd4ef6b016..0c56e9ac8da 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -40,7 +40,11 @@ DATASET_NO_CHANNEL = bytes.fromhex( ) -async def test_import_dataset(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +async def test_import_dataset( + hass: HomeAssistant, + mock_async_zeroconf: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: """Test the active dataset is imported at setup.""" add_service_listener_called = asyncio.Event() @@ -53,7 +57,6 @@ async def test_import_dataset(hass: HomeAssistant, mock_async_zeroconf: None) -> mock_async_zeroconf.async_remove_service_listener = AsyncMock() mock_async_zeroconf.async_get_service_info = AsyncMock() - issue_registry = ir.async_get(hass) assert await thread.async_get_preferred_dataset(hass) is None config_entry = MockConfigEntry( @@ -123,15 +126,15 @@ async def test_import_dataset(hass: HomeAssistant, mock_async_zeroconf: None) -> async def test_import_share_radio_channel_collision( - hass: HomeAssistant, multiprotocol_addon_manager_mock + hass: HomeAssistant, + multiprotocol_addon_manager_mock, + issue_registry: ir.IssueRegistry, ) -> None: """Test the active dataset is imported at setup. This imports a dataset with different channel than ZHA when ZHA and OTBR share the radio. """ - issue_registry = ir.async_get(hass) - multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( @@ -173,14 +176,15 @@ async def test_import_share_radio_channel_collision( @pytest.mark.parametrize("dataset", [DATASET_CH15, DATASET_NO_CHANNEL]) async def test_import_share_radio_no_channel_collision( - hass: HomeAssistant, multiprotocol_addon_manager_mock, dataset: bytes + hass: HomeAssistant, + multiprotocol_addon_manager_mock, + dataset: bytes, + issue_registry: ir.IssueRegistry, ) -> None: """Test the active dataset is imported at setup. This imports a dataset when ZHA and OTBR share the radio. """ - issue_registry = ir.async_get(hass) - multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( @@ -221,13 +225,13 @@ async def test_import_share_radio_no_channel_collision( @pytest.mark.parametrize( "dataset", [DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE] ) -async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> None: +async def test_import_insecure_dataset( + hass: HomeAssistant, dataset: bytes, issue_registry: ir.IssueRegistry +) -> None: """Test the active dataset is imported at setup. This imports a dataset with insecure settings. """ - issue_registry = ir.async_get(hass) - config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index c8ac839f629..df55d38d3b7 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -18,11 +18,13 @@ from . import ( ) from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator @pytest.fixture -async def websocket_client(hass, hass_ws_client): +async def websocket_client( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> MockHAClientWebSocket: """Create a websocket client.""" return await hass_ws_client(hass) diff --git a/tests/components/otp/__init__.py b/tests/components/otp/__init__.py new file mode 100644 index 00000000000..91a7412323b --- /dev/null +++ b/tests/components/otp/__init__.py @@ -0,0 +1 @@ +"""Test the One-Time Password (OTP).""" diff --git a/tests/components/otp/conftest.py b/tests/components/otp/conftest.py new file mode 100644 index 00000000000..7443d772c69 --- /dev/null +++ b/tests/components/otp/conftest.py @@ -0,0 +1,65 @@ +"""Common fixtures for the One-Time Password (OTP) tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.otp.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_NAME, CONF_PLATFORM, CONF_TOKEN +from homeassistant.helpers.typing import ConfigType + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.otp.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_pyotp() -> Generator[MagicMock, None, None]: + """Mock a pyotp.""" + with ( + patch( + "homeassistant.components.otp.config_flow.pyotp", + ) as mock_client, + patch("homeassistant.components.otp.sensor.pyotp", new=mock_client), + ): + mock_totp = MagicMock() + mock_totp.now.return_value = 123456 + mock_totp.verify.return_value = True + mock_totp.provisioning_uri.return_value = "otpauth://totp/Home%20Assistant:OTP%20Sensor?secret=2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52&issuer=Home%20Assistant" + mock_client.TOTP.return_value = mock_totp + mock_client.random_base32.return_value = "2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52" + yield mock_client + + +@pytest.fixture(name="otp_config_entry") +def mock_otp_config_entry() -> MockConfigEntry: + """Mock otp configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: "OTP Sensor", + CONF_TOKEN: "2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", + }, + unique_id="2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", + ) + + +@pytest.fixture(name="otp_yaml_config") +def mock_otp_yaml_config() -> ConfigType: + """Mock otp configuration entry.""" + return { + SENSOR_DOMAIN: { + CONF_PLATFORM: "otp", + CONF_TOKEN: "2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", + CONF_NAME: "OTP Sensor", + } + } diff --git a/tests/components/otp/snapshots/test_sensor.ambr b/tests/components/otp/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5329b03ad9e --- /dev/null +++ b/tests/components/otp/snapshots/test_sensor.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'OTP Sensor', + }), + 'context': , + 'entity_id': 'sensor.otp_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123456', + }) +# --- diff --git a/tests/components/otp/test_config_flow.py b/tests/components/otp/test_config_flow.py new file mode 100644 index 00000000000..eefb1a6f4e0 --- /dev/null +++ b/tests/components/otp/test_config_flow.py @@ -0,0 +1,185 @@ +"""Test the One-Time Password (OTP) config flow.""" + +import binascii +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.otp.const import CONF_NEW_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_CODE, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +TEST_DATA = { + CONF_NAME: "OTP Sensor", + CONF_TOKEN: "2FX5FBSYRE6VEC2FSHBQCRKO2GNDVZ52", +} + +TEST_DATA_2 = { + CONF_NAME: "OTP Sensor", + CONF_NEW_TOKEN: True, +} + +TEST_DATA_3 = { + CONF_NAME: "OTP Sensor", + CONF_TOKEN: "", +} + + +@pytest.mark.usefixtures("mock_pyotp") +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": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (binascii.Error, "invalid_token"), + (IndexError, "unknown"), + ], +) +async def test_errors_and_recover( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyotp: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test errors and recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_pyotp.TOTP().now.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_pyotp.TOTP().now.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OTP Sensor" + assert result["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyotp", "mock_setup_entry") +async def test_flow_import(hass: HomeAssistant) -> None: + """Test that we can import a YAML config.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=TEST_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OTP Sensor" + assert result["data"] == TEST_DATA + + +@pytest.mark.usefixtures("mock_pyotp") +async def test_generate_new_token( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test form generate new token.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA_2, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: "123456"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OTP Sensor" + assert result["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_generate_new_token_errors( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_pyotp +) -> None: + """Test input validation errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA_3, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_token"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA_2, + ) + mock_pyotp.TOTP().verify.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: "123456"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_code"} + + mock_pyotp.TOTP().verify.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: "123456"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OTP Sensor" + assert result["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/otp/test_init.py b/tests/components/otp/test_init.py new file mode 100644 index 00000000000..0ce8f44523e --- /dev/null +++ b/tests/components/otp/test_init.py @@ -0,0 +1,23 @@ +"""Test the One-Time Password (OTP) init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_entry_setup_unload( + hass: HomeAssistant, otp_config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + otp_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(otp_config_entry.entry_id) + await hass.async_block_till_done() + + assert otp_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(otp_config_entry.entry_id) + await hass.async_block_till_done() + + assert otp_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/otp/test_sensor.py b/tests/components/otp/test_sensor.py new file mode 100644 index 00000000000..e75ce6707d4 --- /dev/null +++ b/tests/components/otp/test_sensor.py @@ -0,0 +1,41 @@ +"""Tests for the One-Time Password (OTP) Sensors.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.otp.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pyotp") +async def test_setup( + hass: HomeAssistant, + otp_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of ista EcoTrend sensor platform.""" + + otp_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(otp_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.otp_sensor") == snapshot + + +async def test_deprecated_yaml_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, otp_yaml_config: ConfigType +) -> None: + """Test an issue is created when attempting setup from yaml config.""" + + assert await async_setup_component(hass, SENSOR_DOMAIN, otp_yaml_config) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}" + ) diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py index 00aab0df834..bc8c632b511 100644 --- a/tests/components/ourgroceries/conftest.py +++ b/tests/components/ourgroceries/conftest.py @@ -1,9 +1,9 @@ """Common fixtures for the OurGroceries tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.ourgroceries import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -19,7 +19,7 @@ PASSWORD = "test-password" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.ourgroceries.async_setup_entry", return_value=True diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py index d1da5d89134..8ab26e3587b 100644 --- a/tests/components/overkiz/conftest.py +++ b/tests/components/overkiz/conftest.py @@ -1,21 +1,17 @@ """Configuration for overkiz tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.overkiz.const import DOMAIN from homeassistant.core import HomeAssistant +from . import load_setup_fixture +from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_PASSWORD, TEST_SERVER + from tests.common import MockConfigEntry -from tests.components.overkiz import load_setup_fixture -from tests.components.overkiz.test_config_flow import ( - TEST_EMAIL, - TEST_GATEWAY_ID, - TEST_PASSWORD, - TEST_SERVER, -) MOCK_SETUP_RESPONSE = Mock(devices=[], gateways=[]) @@ -32,7 +28,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.overkiz.async_setup_entry", return_value=True diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index a36d03e973c..8246a7f51ac 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -1,17 +1,22 @@ """The tests for the Owntracks device tracker.""" +import base64 import json +import pickle from unittest.mock import patch +from nacl.encoding import Base64Encoder +from nacl.secret import SecretBox import pytest from homeassistant.components import owntracks +from homeassistant.components.device_tracker.legacy import Device from homeassistant.const import STATE_NOT_HOME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, MqttMockHAClient USER = "greg" DEVICE = "phone" @@ -284,7 +289,11 @@ BAD_JSON_SUFFIX = "** and it ends here ^^" @pytest.fixture -def setup_comp(hass, mock_device_tracker_conf, mqtt_mock): +def setup_comp( + hass: HomeAssistant, + mock_device_tracker_conf: list[Device], + mqtt_mock: MqttMockHAClient, +): """Initialize components.""" hass.loop.run_until_complete(async_setup_component(hass, "device_tracker", {})) @@ -1327,23 +1336,14 @@ def generate_ciphers(secret): # PyNaCl ciphertext generation will fail if the module # cannot be imported. However, the test for decryption # also relies on this library and won't be run without it. - import base64 - import pickle + keylen = SecretBox.KEY_SIZE + key = secret.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b"\0") - try: - from nacl.encoding import Base64Encoder - from nacl.secret import SecretBox + msg = json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8") - keylen = SecretBox.KEY_SIZE - key = secret.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b"\0") - - msg = json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8") - - ctxt = SecretBox(key).encrypt(msg, encoder=Base64Encoder).decode("utf-8") - except (ImportError, OSError): - ctxt = "" + ctxt = SecretBox(key).encrypt(msg, encoder=Base64Encoder).decode("utf-8") mctxt = base64.b64encode( pickle.dumps( @@ -1378,9 +1378,6 @@ def mock_cipher(): def mock_decrypt(ciphertext, key): """Decrypt/unpickle.""" - import base64 - import pickle - (mkey, plaintext) = pickle.loads(base64.b64decode(ciphertext)) if key != mkey: raise ValueError @@ -1501,12 +1498,6 @@ async def test_encrypted_payload_no_topic_key(hass: HomeAssistant, setup_comp) - async def test_encrypted_payload_libsodium(hass: HomeAssistant, setup_comp) -> None: """Test sending encrypted message payload.""" - try: - import nacl # noqa: F401 - except (ImportError, OSError): - pytest.skip("PyNaCl/libsodium is not installed") - return - await setup_owntracks(hass, {CONF_SECRET: TEST_SECRET_KEY}) await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index 7e85b67f9de..5ef0efb0ab9 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -1,11 +1,15 @@ """Test the owntracks_http platform.""" +from aiohttp.test_utils import TestClient import pytest from homeassistant.components import owntracks +from homeassistant.components.device_tracker.legacy import Device +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, mock_component +from tests.typing import ClientSessionGenerator MINIMAL_LOCATION_MESSAGE = { "_type": "location", @@ -34,12 +38,14 @@ LOCATION_MESSAGE = { @pytest.fixture(autouse=True) -def mock_dev_track(mock_device_tracker_conf): +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: """Mock device tracker config loading.""" @pytest.fixture -def mock_client(hass, hass_client_no_auth): +def mock_client( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> TestClient: """Start the Home Assistant HTTP component.""" mock_component(hass, "group") mock_component(hass, "zone") diff --git a/tests/components/p1_monitor/conftest.py b/tests/components/p1_monitor/conftest.py index e95cb245f5e..1d5f349f858 100644 --- a/tests/components/p1_monitor/conftest.py +++ b/tests/components/p1_monitor/conftest.py @@ -27,7 +27,9 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture def mock_p1monitor(): """Return a mocked P1 Monitor client.""" - with patch("homeassistant.components.p1_monitor.P1Monitor") as p1monitor_mock: + with patch( + "homeassistant.components.p1_monitor.coordinator.P1Monitor" + ) as p1monitor_mock: client = p1monitor_mock.return_value client.smartmeter = AsyncMock( return_value=SmartMeter.from_dict( diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py index 6f6c2c8f7ec..12a6a6f5d11 100644 --- a/tests/components/p1_monitor/test_config_flow.py +++ b/tests/components/p1_monitor/test_config_flow.py @@ -44,7 +44,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: async def test_api_error(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" with patch( - "homeassistant.components.p1_monitor.P1Monitor.smartmeter", + "homeassistant.components.p1_monitor.coordinator.P1Monitor.smartmeter", side_effect=P1MonitorError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/p1_monitor/test_init.py b/tests/components/p1_monitor/test_init.py index f8de8767a09..02888b5ae97 100644 --- a/tests/components/p1_monitor/test_init.py +++ b/tests/components/p1_monitor/test_init.py @@ -29,7 +29,7 @@ async def test_load_unload_config_entry( @patch( - "homeassistant.components.p1_monitor.P1Monitor._request", + "homeassistant.components.p1_monitor.coordinator.P1Monitor._request", side_effect=P1MonitorConnectionError, ) async def test_config_entry_not_ready( diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py index e1ea53ba6cc..4267b7b7e2b 100644 --- a/tests/components/p1_monitor/test_sensor.py +++ b/tests/components/p1_monitor/test_sensor.py @@ -30,12 +30,12 @@ from tests.common import MockConfigEntry async def test_smartmeter( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the P1 Monitor - SmartMeter sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) state = hass.states.get("sensor.smartmeter_power_consumption") entry = entity_registry.async_get("sensor.smartmeter_power_consumption") @@ -87,12 +87,12 @@ async def test_smartmeter( async def test_phases( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the P1 Monitor - Phases sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) state = hass.states.get("sensor.phases_voltage_phase_l1") entry = entity_registry.async_get("sensor.phases_voltage_phase_l1") @@ -144,12 +144,12 @@ async def test_phases( async def test_settings( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the P1 Monitor - Settings sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) state = hass.states.get("sensor.settings_energy_consumption_price_low") entry = entity_registry.async_get("sensor.settings_energy_consumption_price_low") @@ -196,12 +196,12 @@ async def test_settings( async def test_watermeter( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the P1 Monitor - WaterMeter sensors.""" entry_id = init_integration.entry_id - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) state = hass.states.get("sensor.watermeter_consumption_day") entry = entity_registry.async_get("sensor.watermeter_consumption_day") assert entry @@ -242,11 +242,12 @@ async def test_no_watermeter( ["sensor.smartmeter_gas_consumption"], ) async def test_smartmeter_disabled_by_default( - hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + entity_id: str, ) -> None: """Test the P1 Monitor - SmartMeter sensors that are disabled by default.""" - entity_registry = er.async_get(hass) - state = hass.states.get(entity_id) assert state is None diff --git a/tests/components/panasonic_viera/conftest.py b/tests/components/panasonic_viera/conftest.py index e30c0f41e92..8871da106e3 100644 --- a/tests/components/panasonic_viera/conftest.py +++ b/tests/components/panasonic_viera/conftest.py @@ -21,6 +21,7 @@ from homeassistant.components.panasonic_viera.const import ( ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry @@ -54,7 +55,7 @@ def get_mock_remote( encrypted=False, app_id=None, encryption_key=None, - device_info=MOCK_DEVICE_INFO, + device_info: UndefinedType | None = UNDEFINED, ): """Return a mock remote.""" mock_remote = Mock() @@ -78,7 +79,9 @@ def get_mock_remote( mock_remote.authorize_pin_code = authorize_pin_code - mock_remote.get_device_info = Mock(return_value=device_info) + mock_remote.get_device_info = Mock( + return_value=MOCK_DEVICE_INFO if device_info is UNDEFINED else device_info + ) mock_remote.send_key = Mock() diff --git a/tests/components/panel_iframe/test_init.py b/tests/components/panel_iframe/test_init.py index 0e898fd6266..74e1b642df5 100644 --- a/tests/components/panel_iframe/test_init.py +++ b/tests/components/panel_iframe/test_init.py @@ -104,7 +104,7 @@ async def test_import_config( }, ] - for url_path in ["api", "ftp", "router", "weather"]: + for url_path in ("api", "ftp", "router", "weather"): await client.send_json_auto_id( {"type": "lovelace/config", "url_path": url_path} ) @@ -145,9 +145,10 @@ async def test_import_config_once( assert response["result"] == [] -async def test_create_issue_when_manually_configured(hass: HomeAssistant) -> None: +async def test_create_issue_when_manually_configured( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test creating issue registry issues.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - issue_registry = ir.async_get(hass) assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml") diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index 112d160fa81..16a193139b4 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -62,7 +62,6 @@ async def test_invalid_county(hass: HomeAssistant) -> None: "county": "INVALID_COUNTY_THAT_SHOULDNT_EXIST", }, ) - await hass.async_block_till_done() async def test_meter_value_error(hass: HomeAssistant) -> None: diff --git a/tests/components/pegel_online/test_sensor.py b/tests/components/pegel_online/test_sensor.py index e911ec571cd..038a320c549 100644 --- a/tests/components/pegel_online/test_sensor.py +++ b/tests/components/pegel_online/test_sensor.py @@ -106,13 +106,13 @@ from tests.common import MockConfigEntry ), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, mock_config_entry_data: dict, mock_station_details: Station, mock_station_measurement: StationMeasurements, expected_states: dict, - entity_registry_enabled_by_default: None, ) -> None: """Tests sensor entity.""" entry = MockConfigEntry( diff --git a/tests/components/permobil/conftest.py b/tests/components/permobil/conftest.py index 74d17616af7..ed6a843b206 100644 --- a/tests/components/permobil/conftest.py +++ b/tests/components/permobil/conftest.py @@ -1,16 +1,16 @@ """Common fixtures for the MyPermobil tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch from mypermobil import MyPermobil import pytest +from typing_extensions import Generator from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.permobil.async_setup_entry", return_value=True diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index 3e99e268231..956183d8420 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -1,7 +1,7 @@ """The tests for the persistent notification component.""" import homeassistant.components.persistent_notification as pn -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py index 7f06b854c5c..ecec42b003d 100644 --- a/tests/components/person/conftest.py +++ b/tests/components/person/conftest.py @@ -1,14 +1,18 @@ """The tests for the person component.""" import logging +from typing import Any import pytest from homeassistant.components import person from homeassistant.components.person import DOMAIN +from homeassistant.core import HomeAssistant from homeassistant.helpers import collection from homeassistant.setup import async_setup_component +from tests.common import MockUser + DEVICE_TRACKER = "device_tracker.test_tracker" DEVICE_TRACKER_2 = "device_tracker.test_tracker_2" @@ -27,7 +31,9 @@ def storage_collection(hass): @pytest.fixture -def storage_setup(hass, hass_storage, hass_admin_user): +def storage_setup( + hass: HomeAssistant, hass_storage: dict[str, Any], hass_admin_user: MockUser +) -> None: """Storage setup.""" hass_storage[DOMAIN] = { "key": DOMAIN, diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index b00a0ff1a6b..1d6c398c444 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -571,7 +571,10 @@ async def test_ws_update_require_admin( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test deleting via WS.""" manager = hass.data[DOMAIN][1] @@ -589,8 +592,7 @@ async def test_ws_delete( assert resp["success"] assert len(hass.states.async_entity_ids("person")) == 0 - ent_reg = er.async_get(hass) - assert not ent_reg.async_is_registered("person.tracked_person") + assert not entity_registry.async_is_registered("person.tracked_person") async def test_ws_delete_require_admin( @@ -685,11 +687,12 @@ async def test_update_person_when_user_removed( assert storage_collection.data[person["id"]]["user_id"] is None -async def test_removing_device_tracker(hass: HomeAssistant, storage_setup) -> None: +async def test_removing_device_tracker( + hass: HomeAssistant, entity_registry: er.EntityRegistry, storage_setup +) -> None: """Test we automatically remove removed device trackers.""" storage_collection = hass.data[DOMAIN][1] - reg = er.async_get(hass) - entry = reg.async_get_or_create( + entry = entity_registry.async_get_or_create( "device_tracker", "mobile_app", "bla", suggested_object_id="pixel" ) @@ -697,7 +700,7 @@ async def test_removing_device_tracker(hass: HomeAssistant, storage_setup) -> No {"name": "Hello", "device_trackers": [entry.entity_id]} ) - reg.async_remove(entry.entity_id) + entity_registry.async_remove(entry.entity_id) await hass.async_block_till_done() assert storage_collection.data[person["id"]]["device_trackers"] == [] diff --git a/tests/components/person/test_recorder.py b/tests/components/person/test_recorder.py index 4d25ce7add4..5551a051df0 100644 --- a/tests/components/person/test_recorder.py +++ b/tests/components/person/test_recorder.py @@ -4,8 +4,9 @@ from __future__ import annotations from datetime import timedelta +import pytest + from homeassistant.components.person import ATTR_DEVICE_TRACKERS, DOMAIN -from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -15,10 +16,9 @@ from tests.common import MockUser, async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, - enable_custom_integrations: None, hass_admin_user: MockUser, storage_setup, ) -> None: diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py index 3591546dfe9..b6c78fe9e5e 100644 --- a/tests/components/philips_js/conftest.py +++ b/tests/components/philips_js/conftest.py @@ -1,10 +1,10 @@ """Standard setup for tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, create_autospec, patch from haphilipsjs import PhilipsTV import pytest +from typing_extensions import Generator from homeassistant.components.philips_js.const import DOMAIN @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry, mock_device_registry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Disable component setup.""" with ( patch( diff --git a/tests/components/philips_js/snapshots/test_diagnostics.ambr b/tests/components/philips_js/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5cff47c7d62 --- /dev/null +++ b/tests/components/philips_js/snapshots/test_diagnostics.ambr @@ -0,0 +1,100 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'ambilight_cached': dict({ + }), + 'ambilight_current_configuration': None, + 'ambilight_measured': None, + 'ambilight_mode_raw': 'internal', + 'ambilight_modes': list([ + 'internal', + 'manual', + 'expert', + 'lounge', + ]), + 'ambilight_power': 'On', + 'ambilight_power_raw': dict({ + 'power': 'On', + }), + 'ambilight_processed': None, + 'ambilight_styles': dict({ + }), + 'ambilight_topology': None, + 'application': None, + 'applications': dict({ + }), + 'channel': None, + 'channel_lists': dict({ + 'all': dict({ + 'Channel': list([ + ]), + 'id': 'all', + 'installCountry': 'Poland', + 'listType': 'MixedSources', + 'medium': 'mixed', + 'operator': 'None', + 'version': 2, + }), + }), + 'channels': dict({ + }), + 'context': dict({ + 'data': 'NA', + 'level1': 'NA', + 'level2': 'NA', + 'level3': 'NA', + }), + 'favorite_lists': dict({ + '1': dict({ + 'channels': list([ + ]), + 'id': '1', + 'medium': 'mixed', + 'name': 'Favourites 1', + 'type': 'MixedSources', + 'version': '60', + }), + }), + 'on': True, + 'powerstate': None, + 'screenstate': 'On', + 'source_id': None, + 'sources': dict({ + }), + 'system': dict({ + 'country': 'Sweden', + 'menulanguage': 'English', + 'model': 'modelname', + 'name': 'Philips TV', + 'serialnumber': '**REDACTED**', + 'softwareversion': 'abcd', + }), + }), + 'entry': dict({ + 'data': dict({ + 'api_version': 1, + 'host': '1.1.1.1', + 'system': dict({ + 'country': 'Sweden', + 'menulanguage': 'English', + 'model': 'modelname', + 'name': 'Philips TV', + 'serialnumber': '**REDACTED**', + 'softwareversion': 'abcd', + }), + }), + 'disabled_by': None, + 'domain': 'philips_js', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py index 3fbac81acbf..b9b7439d2fa 100644 --- a/tests/components/philips_js/test_device_trigger.py +++ b/tests/components/philips_js/test_device_trigger.py @@ -6,7 +6,7 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.philips_js.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations, async_mock_service @@ -18,7 +18,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -42,7 +42,7 @@ async def test_get_triggers(hass: HomeAssistant, mock_device) -> None: async def test_if_fires_on_turn_on_request( - hass: HomeAssistant, calls, mock_tv, mock_entity, mock_device + hass: HomeAssistant, calls: list[ServiceCall], mock_tv, mock_entity, mock_device ) -> None: """Test for turn_on and turn_off triggers firing.""" diff --git a/tests/components/philips_js/test_diagnostics.py b/tests/components/philips_js/test_diagnostics.py new file mode 100644 index 00000000000..cb3235b9780 --- /dev/null +++ b/tests/components/philips_js/test_diagnostics.py @@ -0,0 +1,66 @@ +"""Test the Philips TV diagnostics platform.""" + +from unittest.mock import AsyncMock + +from haphilipsjs.typing import ChannelListType, ContextType, FavoriteListType +from syrupy import SnapshotAssertion +from syrupy.filters import props + +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 + +TV_CONTEXT = ContextType(level1="NA", level2="NA", level3="NA", data="NA") +TV_CHANNEL_LISTS = { + "all": ChannelListType( + version=2, + id="all", + listType="MixedSources", + medium="mixed", + operator="None", + installCountry="Poland", + Channel=[], + ) +} +TV_FAVORITE_LISTS = { + "1": FavoriteListType( + version="60", + id="1", + type="MixedSources", + medium="mixed", + name="Favourites 1", + channels=[], + ) +} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_tv: AsyncMock, +) -> None: + """Test config entry diagnostics.""" + mock_tv.context = TV_CONTEXT + mock_tv.ambilight_topology = None + mock_tv.ambilight_mode_raw = "internal" + mock_tv.ambilight_modes = ["internal", "manual", "expert", "lounge"] + mock_tv.ambilight_power_raw = {"power": "On"} + mock_tv.ambilight_power = "On" + mock_tv.ambilight_measured = None + mock_tv.ambilight_processed = None + mock_tv.screenstate = "On" + mock_tv.channel = None + mock_tv.channel_lists = TV_CHANNEL_LISTS + mock_tv.favorite_lists = TV_FAVORITE_LISTS + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("entry_id")) diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index 3b56305e0fc..326b01b9a7a 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -128,7 +128,6 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: user_input={CONF_API_KEY: "newkey"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert entry.data[CONF_API_KEY] == "newkey" diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index 3c8f66a82d0..72b48e3d572 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -1,7 +1,7 @@ """Test pi_hole component.""" import logging -from unittest.mock import AsyncMock +from unittest.mock import ANY, AsyncMock from hole.exceptions import HoleError import pytest @@ -14,12 +14,20 @@ from homeassistant.components.pi_hole.const import ( SERVICE_DISABLE_ATTR_DURATION, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_LOCATION, + CONF_NAME, + CONF_SSL, +) from homeassistant.core import HomeAssistant from . import ( + API_KEY, CONFIG_DATA, CONFIG_DATA_DEFAULTS, + CONFIG_ENTRY_WITHOUT_API_KEY, SWITCH_ENTITY_ID, _create_mocked_hole, _patch_init_hole, @@ -28,6 +36,29 @@ from . import ( from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("config_entry_data", "expected_api_token"), + [(CONFIG_DATA_DEFAULTS, API_KEY), (CONFIG_ENTRY_WITHOUT_API_KEY, "")], +) +async def test_setup_api( + hass: HomeAssistant, config_entry_data: dict, expected_api_token: str +) -> None: + """Tests the API object is created with the expected parameters.""" + mocked_hole = _create_mocked_hole() + config_entry_data = {**config_entry_data, CONF_STATISTICS_ONLY: True} + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config_entry_data) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole) as patched_init_hole: + assert await hass.config_entries.async_setup(entry.entry_id) + patched_init_hole.assert_called_once_with( + config_entry_data[CONF_HOST], + ANY, + api_token=expected_api_token, + location=config_entry_data[CONF_LOCATION], + tls=config_entry_data[CONF_SSL], + ) + + async def test_setup_with_defaults(hass: HomeAssistant) -> None: """Tests component setup with default config.""" mocked_hole = _create_mocked_hole() @@ -168,8 +199,6 @@ async def test_disable_service_call(hass: HomeAssistant) -> None: blocking=True, ) - await hass.async_block_till_done() - mocked_hole.disable.assert_called_with(1) @@ -188,8 +217,6 @@ async def test_unload(hass: HomeAssistant) -> None: assert isinstance(entry.runtime_data, PiHoleData) assert 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/pilight/test_init.py b/tests/components/pilight/test_init.py index 621d002bb62..c48135f59eb 100644 --- a/tests/components/pilight/test_init.py +++ b/tests/components/pilight/test_init.py @@ -114,7 +114,6 @@ async def test_send_code_no_protocol(hass: HomeAssistant) -> None: service_data={"noprotocol": "test", "value": 42}, blocking=True, ) - await hass.async_block_till_done() assert "required key not provided @ data['protocol']" in str(excinfo.value) @@ -363,6 +362,7 @@ async def test_call_rate_delay_throttle_enabled(hass: HomeAssistant) -> None: delay = 5.0 limit = pilight.CallRateDelayThrottle(hass, delay) + # pylint: disable-next=unnecessary-lambda action = limit.limited(lambda x: runs.append(x)) for i in range(3): @@ -386,6 +386,7 @@ def test_call_rate_delay_throttle_disabled(hass: HomeAssistant) -> None: runs = [] limit = pilight.CallRateDelayThrottle(hass, 0.0) + # pylint: disable-next=unnecessary-lambda action = limit.limited(lambda x: runs.append(x)) for i in range(3): diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py index 9bbbc9e6e32..fced110f1c5 100644 --- a/tests/components/ping/conftest.py +++ b/tests/components/ping/conftest.py @@ -5,9 +5,8 @@ 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.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.components.ping import CONF_PING_COUNT, DOMAIN from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index a8346b9a634..660b5ca31f1 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -10,8 +10,8 @@ from syrupy import SnapshotAssertion from syrupy.filters import props from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -49,7 +49,7 @@ async def test_disabled_after_import( hass: HomeAssistant, config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, -): +) -> None: """Test if binary sensor is disabled after import.""" config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( @@ -64,29 +64,3 @@ async def test_disabled_after_import( assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - -async def test_import_issue_creation( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -): - """Test if import issue is raised.""" - - await async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "ping", - "name": "test", - "host": "127.0.0.1", - "count": 1, - } - }, - ) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - assert issue diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py index 1f55957410d..8204a000f29 100644 --- a/tests/components/ping/test_config_flow.py +++ b/tests/components/ping/test_config_flow.py @@ -6,12 +6,9 @@ import pytest from homeassistant import config_entries from homeassistant.components.ping import DOMAIN -from homeassistant.components.ping.const import CONF_IMPORTED_BY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import BINARY_SENSOR_IMPORT_DATA - from tests.common import MockConfigEntry @@ -87,41 +84,3 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None "host": "10.10.10.1", "consider_home": 180, } - - -@pytest.mark.usefixtures("patch_setup") -async def test_step_import(hass: HomeAssistant) -> None: - """Test for import step.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_IMPORTED_BY: "binary_sensor", **BINARY_SENSOR_IMPORT_DATA}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test2" - assert result["data"] == {CONF_IMPORTED_BY: "binary_sensor"} - assert result["options"] == { - "host": "127.0.0.1", - "count": 1, - "consider_home": 240, - } - - # test import without name - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_IMPORTED_BY: "binary_sensor", "host": "10.10.10.10", "count": 5}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "10.10.10.10" - assert result["data"] == {CONF_IMPORTED_BY: "binary_sensor"} - 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 a01bd0fa1bf..5aa425226b3 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,26 +1,21 @@ """Test the binary sensor platform of ping.""" -from collections.abc import Generator from datetime import timedelta from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory from icmplib import Host import pytest +from typing_extensions import Generator -from homeassistant.components.device_tracker import legacy -from homeassistant.components.ping.const import DOMAIN -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -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 homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, async_fire_time_changed, patch_yaml_files +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture -def entity_registry_enabled_by_default() -> Generator[None, None, None]: +def entity_registry_enabled_by_default() -> Generator[None]: """Test fixture that ensures ping device_tracker entities are enabled in the registry.""" with patch( "homeassistant.components.ping.device_tracker.PingDeviceTracker.entity_registry_enabled_default", @@ -85,69 +80,12 @@ async def test_setup_and_update( assert state.state == "home" -async def test_import_issue_creation( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -): - """Test if import issue is raised.""" - - await async_setup_component( - hass, - "device_tracker", - {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, - ) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - assert issue - - -async def test_import_delete_known_devices( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, -): - """Test if import deletes known devices.""" - yaml_devices = { - "test": { - "hide_if_away": True, - "mac": "00:11:22:33:44:55", - "name": "Test name", - "picture": "/local/test.png", - "track": True, - }, - } - files = {legacy.YAML_DEVICES: dump(yaml_devices)} - - with ( - patch_yaml_files(files, True), - patch( - "homeassistant.components.ping.device_tracker.remove_device_from_config" - ) as remove_device_from_config, - ): - await async_setup_component( - hass, - "device_tracker", - {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, - ) - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - assert len(remove_device_from_config.mock_calls) == 1 - - @pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_integration") async def test_reload_not_triggering_home( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, -): +) -> None: """Test if reload/restart does not trigger home when device is unavailable.""" assert hass.states.get("device_tracker.10_10_10_10").state == "home" diff --git a/tests/components/plaato/__init__.py b/tests/components/plaato/__init__.py index dac4d341790..5a2b2a68d44 100644 --- a/tests/components/plaato/__init__.py +++ b/tests/components/plaato/__init__.py @@ -1 +1,55 @@ """Tests for the Plaato integration.""" + +from unittest.mock import patch + +from freezegun import freeze_time +from pyplaato.models.airlock import PlaatoAirlock +from pyplaato.models.device import PlaatoDeviceType +from pyplaato.models.keg import PlaatoKeg + +from homeassistant.components.plaato.const import ( + CONF_DEVICE_NAME, + CONF_DEVICE_TYPE, + CONF_USE_WEBHOOK, + DOMAIN, +) +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +# Note: It would be good to replace this test data +# with actual data from the API +AIRLOCK_DATA = {} +KEG_DATA = {} + + +@freeze_time("2024-05-24 12:00:00", tz_offset=0) +async def init_integration( + hass: HomeAssistant, device_type: PlaatoDeviceType +) -> MockConfigEntry: + """Mock integration setup.""" + with ( + patch( + "homeassistant.components.plaato.coordinator.Plaato.get_airlock_data", + return_value=PlaatoAirlock(AIRLOCK_DATA), + ), + patch( + "homeassistant.components.plaato.coordinator.Plaato.get_keg_data", + return_value=PlaatoKeg(KEG_DATA), + ), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USE_WEBHOOK: False, + CONF_TOKEN: "valid_token", + CONF_DEVICE_TYPE: device_type, + CONF_DEVICE_NAME: "device_name", + }, + entry_id="123456", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/plaato/snapshots/test_binary_sensor.ambr b/tests/components/plaato/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..e8db3bf32d8 --- /dev/null +++ b/tests/components/plaato/snapshots/test_binary_sensor.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_binary_sensors[Keg][binary_sensor.plaato_plaatodevicetype_keg_device_name_leaking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.plaato_plaatodevicetype_keg_device_name_leaking', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Leaking', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.LEAK_DETECTION', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[Keg][binary_sensor.plaato_plaatodevicetype_keg_device_name_leaking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beer_name': 'Beer', + 'device_class': 'problem', + 'friendly_name': 'Plaato Plaatodevicetype.Keg Device_Name Leaking', + 'keg_date': '05/24/24', + 'mode': 'Co2', + }), + 'context': , + 'entity_id': 'binary_sensor.plaato_plaatodevicetype_keg_device_name_leaking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[Keg][binary_sensor.plaato_plaatodevicetype_keg_device_name_pouring-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.plaato_plaatodevicetype_keg_device_name_pouring', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Pouring', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.POURING', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[Keg][binary_sensor.plaato_plaatodevicetype_keg_device_name_pouring-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beer_name': 'Beer', + 'device_class': 'opening', + 'friendly_name': 'Plaato Plaatodevicetype.Keg Device_Name Pouring', + 'keg_date': '05/24/24', + 'mode': 'Co2', + }), + 'context': , + 'entity_id': 'binary_sensor.plaato_plaatodevicetype_keg_device_name_pouring', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/plaato/snapshots/test_sensor.ambr b/tests/components/plaato/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..110ffb04ba9 --- /dev/null +++ b/tests/components/plaato/snapshots/test_sensor.ambr @@ -0,0 +1,574 @@ +# serializer version: 1 +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_alcohol_by_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_alcohol_by_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Alcohol By Volume', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.ABV', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_alcohol_by_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Alcohol By Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_alcohol_by_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_batch_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_batch_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Batch Volume', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.BATCH_VOLUME', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_batch_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Batch Volume', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_batch_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_bubbles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_bubbles', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Bubbles', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.BUBBLES', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_bubbles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Bubbles', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_bubbles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_bubbles_per_minute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_bubbles_per_minute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Bubbles Per Minute', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.BPM', + 'unit_of_measurement': 'bpm', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_bubbles_per_minute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Bubbles Per Minute', + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_bubbles_per_minute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_co2_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_co2_volume', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Co2 Volume', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.CO2_VOLUME', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_co2_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Co2 Volume', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_co2_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_original_gravity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_original_gravity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Original Gravity', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.OG', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_original_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Original Gravity', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_original_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_specific_gravity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_specific_gravity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Specific Gravity', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.SG', + 'unit_of_measurement': '', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_specific_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Specific Gravity', + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_specific_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Airlock Device_Name Temperature', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.TEMPERATURE', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[Airlock][sensor.plaato_plaatodevicetype_airlock_device_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Plaato Plaatodevicetype.Airlock Device_Name Temperature', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_airlock_device_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_beer_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_beer_left', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Beer Left', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.BEER_LEFT', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_beer_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beer_name': 'Beer', + 'friendly_name': 'Plaato Plaatodevicetype.Keg Device_Name Beer Left', + 'keg_date': '05/24/24', + 'mode': 'Co2', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_beer_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_last_pour_amount-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_last_pour_amount', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Last Pour Amount', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.LAST_POUR', + 'unit_of_measurement': 'oz', + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_last_pour_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beer_name': 'Beer', + 'friendly_name': 'Plaato Plaatodevicetype.Keg Device_Name Last Pour Amount', + 'keg_date': '05/24/24', + 'mode': 'Co2', + 'unit_of_measurement': 'oz', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_last_pour_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_percent_beer_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_percent_beer_left', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Percent Beer Left', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.PERCENT_BEER_LEFT', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_percent_beer_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beer_name': 'Beer', + 'friendly_name': 'Plaato Plaatodevicetype.Keg Device_Name Percent Beer Left', + 'keg_date': '05/24/24', + 'mode': 'Co2', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_percent_beer_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plaato Plaatodevicetype.Keg Device_Name Temperature', + 'platform': 'plaato', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'valid_token_Pins.TEMPERATURE', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[Keg][sensor.plaato_plaatodevicetype_keg_device_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beer_name': 'Beer', + 'device_class': 'temperature', + 'friendly_name': 'Plaato Plaatodevicetype.Keg Device_Name Temperature', + 'keg_date': '05/24/24', + 'mode': 'Co2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.plaato_plaatodevicetype_keg_device_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/plaato/test_binary_sensor.py b/tests/components/plaato/test_binary_sensor.py new file mode 100644 index 00000000000..73d378dd531 --- /dev/null +++ b/tests/components/plaato/test_binary_sensor.py @@ -0,0 +1,33 @@ +"""Tests for the plaato binary sensors.""" + +from unittest.mock import patch + +from pyplaato.models.device import PlaatoDeviceType +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +# note: PlaatoDeviceType.Airlock does not provide binary sensors +@pytest.mark.parametrize("device_type", [PlaatoDeviceType.Keg]) +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + device_type: PlaatoDeviceType, +) -> None: + """Test binary sensors.""" + with patch( + "homeassistant.components.plaato.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + entry = await init_integration(hass, device_type) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/plaato/test_sensor.py b/tests/components/plaato/test_sensor.py new file mode 100644 index 00000000000..e4574634c4b --- /dev/null +++ b/tests/components/plaato/test_sensor.py @@ -0,0 +1,34 @@ +"""Tests for the plaato sensors.""" + +from unittest.mock import patch + +from pyplaato.models.device import PlaatoDeviceType +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +@pytest.mark.parametrize( + "device_type", [PlaatoDeviceType.Airlock, PlaatoDeviceType.Keg] +) +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + device_type: PlaatoDeviceType, +) -> None: + """Test sensors.""" + with patch( + "homeassistant.components.plaato.PLATFORMS", + [Platform.SENSOR], + ): + entry = await init_integration(hass, device_type) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py index 0f79ade2df5..122ac3b75d1 100644 --- a/tests/components/plant/test_init.py +++ b/tests/components/plant/test_init.py @@ -6,11 +6,11 @@ from homeassistant.components import plant from homeassistant.components.recorder import Recorder from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, - CONDUCTIVITY, LIGHT_LUX, STATE_OK, STATE_PROBLEM, STATE_UNAVAILABLE, + UnitOfConductivity, ) from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component @@ -79,7 +79,9 @@ async def test_low_battery(hass: HomeAssistant) -> None: async def test_initial_states(hass: HomeAssistant) -> None: """Test plant initialises attributes if sensor already exists.""" - hass.states.async_set(MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: CONDUCTIVITY}) + hass.states.async_set( + MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + ) plant_name = "some_plant" assert await async_setup_component( hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} @@ -98,7 +100,9 @@ async def test_update_states(hass: HomeAssistant) -> None: assert await async_setup_component( hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} ) - hass.states.async_set(MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: CONDUCTIVITY}) + hass.states.async_set( + MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") assert state.state == STATE_PROBLEM @@ -115,7 +119,9 @@ async def test_unavailable_state(hass: HomeAssistant) -> None: hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} ) hass.states.async_set( - MOISTURE_ENTITY, STATE_UNAVAILABLE, {ATTR_UNIT_OF_MEASUREMENT: CONDUCTIVITY} + MOISTURE_ENTITY, + STATE_UNAVAILABLE, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -132,13 +138,17 @@ async def test_state_problem_if_unavailable(hass: HomeAssistant) -> None: assert await async_setup_component( hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} ) - hass.states.async_set(MOISTURE_ENTITY, 42, {ATTR_UNIT_OF_MEASUREMENT: CONDUCTIVITY}) + hass.states.async_set( + MOISTURE_ENTITY, 42, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") assert state.state == STATE_OK assert state.attributes[plant.READING_MOISTURE] == 42 hass.states.async_set( - MOISTURE_ENTITY, STATE_UNAVAILABLE, {ATTR_UNIT_OF_MEASUREMENT: CONDUCTIVITY} + MOISTURE_ENTITY, + STATE_UNAVAILABLE, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -153,7 +163,7 @@ async def test_load_from_db(recorder_mock: Recorder, hass: HomeAssistant) -> Non is enabled via plant.ENABLE_LOAD_HISTORY. """ plant_name = "wise_plant" - for value in [20, 30, 10]: + for value in (20, 30, 10): hass.states.async_set( BRIGHTNESS_ENTITY, value, {ATTR_UNIT_OF_MEASUREMENT: "Lux"} ) @@ -204,8 +214,8 @@ def test_daily_history_one_day(hass: HomeAssistant) -> None: """Test storing data for the same day.""" dh = plant.DailyHistory(3) values = [-2, 10, 0, 5, 20] - for i in range(len(values)): - dh.add_measurement(values[i]) + for i, value in enumerate(values): + dh.add_measurement(value) max_value = max(values[0 : i + 1]) assert len(dh._days) == 1 assert dh.max == max_value @@ -222,6 +232,6 @@ def test_daily_history_multiple_days(hass: HomeAssistant) -> None: values = [10, 1, 7, 3] max_values = [10, 10, 10, 7] - for i in range(len(days)): - dh.add_measurement(values[i], days[i]) + for i, value in enumerate(days): + dh.add_measurement(values[i], value) assert max_values[i] == dh.max diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index d00b8eb944b..a061d9c1105 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -1,12 +1,14 @@ """Fixtures for Plex tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +import requests_mock +from typing_extensions import Generator from homeassistant.components.plex.const import DOMAIN, PLEX_SERVER_CONFIG, SERVERS from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant from .const import DEFAULT_DATA, DEFAULT_OPTIONS, PLEX_DIRECT_URL from .helpers import websocket_connected @@ -21,7 +23,7 @@ def plex_server_url(entry): @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.plex.async_setup_entry", return_value=True @@ -436,7 +438,7 @@ def mock_websocket(): @pytest.fixture def mock_plex_calls( entry, - requests_mock, + requests_mock: requests_mock.Mocker, children_20, children_30, children_200, @@ -481,7 +483,7 @@ def mock_plex_calls( url = plex_server_url(entry) - for server in [url, PLEX_DIRECT_URL]: + for server in (url, PLEX_DIRECT_URL): requests_mock.get(server, text=plex_server_default) requests_mock.get(f"{server}/accounts", text=plex_server_accounts) @@ -545,12 +547,12 @@ def mock_plex_calls( @pytest.fixture def setup_plex_server( - hass, + hass: HomeAssistant, entry, livetv_sessions, mock_websocket, mock_plex_calls, - requests_mock, + requests_mock: requests_mock.Mocker, empty_payload, session_default, session_live_tv, diff --git a/tests/components/plex/helpers.py b/tests/components/plex/helpers.py index 00d0a4539c1..4828b972d9d 100644 --- a/tests/components/plex/helpers.py +++ b/tests/components/plex/helpers.py @@ -1,9 +1,11 @@ """Helper methods for Plex tests.""" from datetime import timedelta +from typing import Any from plexwebsocket import SIGNAL_CONNECTION_STATE, STATE_CONNECTED +from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed @@ -27,10 +29,14 @@ def websocket_connected(mock_websocket): callback(SIGNAL_CONNECTION_STATE, STATE_CONNECTED, None) -def trigger_plex_update(mock_websocket, msgtype="playing", payload=UPDATE_PAYLOAD): +def trigger_plex_update( + mock_websocket, + msgtype="playing", + payload: dict[str, Any] | UndefinedType = UNDEFINED, +): """Call the websocket callback method with a Plex update.""" callback = mock_websocket.call_args[0][1] - callback(msgtype, payload, None) + callback(msgtype, UPDATE_PAYLOAD if payload is UNDEFINED else payload, None) async def wait_for_debouncer(hass): diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index 11eb73ad608..470caead14c 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_TYPE, ) from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER, PLEX_URI_SCHEME -from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT +from homeassistant.components.websocket_api import ERR_UNKNOWN_ERROR, TYPE_RESULT from homeassistant.core import HomeAssistant from .const import DEFAULT_DATA diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 5f2531992d4..08733a7dd17 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -49,9 +49,8 @@ from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator -async def test_bad_credentials( - hass: HomeAssistant, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_bad_credentials(hass: HomeAssistant) -> None: """Test when provided credentials are rejected.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -81,9 +80,8 @@ async def test_bad_credentials( assert result["errors"][CONF_TOKEN] == "faulty_credentials" -async def test_bad_hostname( - hass: HomeAssistant, mock_plex_calls, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_bad_hostname(hass: HomeAssistant, mock_plex_calls) -> None: """Test when an invalid address is provided.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -114,9 +112,8 @@ async def test_bad_hostname( assert result["errors"][CONF_HOST] == "not_found" -async def test_unknown_exception( - hass: HomeAssistant, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_unknown_exception(hass: HomeAssistant) -> None: """Test when an unknown exception is encountered.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -142,12 +139,12 @@ async def test_unknown_exception( assert result["reason"] == "unknown" +@pytest.mark.usefixtures("current_request_with_host") async def test_no_servers_found( hass: HomeAssistant, mock_plex_calls, requests_mock: requests_mock.Mocker, empty_payload, - current_request_with_host: None, ) -> None: """Test when no servers are on an account.""" requests_mock.get("https://plex.tv/api/v2/resources", text=empty_payload) @@ -176,10 +173,10 @@ async def test_no_servers_found( assert result["errors"]["base"] == "no_servers" +@pytest.mark.usefixtures("current_request_with_host") async def test_single_available_server( hass: HomeAssistant, mock_plex_calls, - current_request_with_host: None, mock_setup_entry: AsyncMock, ) -> None: """Test creating an entry with one server available.""" @@ -218,12 +215,12 @@ async def test_single_available_server( mock_setup_entry.assert_called_once() +@pytest.mark.usefixtures("current_request_with_host") async def test_multiple_servers_with_selection( hass: HomeAssistant, mock_plex_calls, requests_mock: requests_mock.Mocker, plextv_resources_two_servers, - current_request_with_host: None, mock_setup_entry: AsyncMock, ) -> None: """Test creating an entry with multiple servers available.""" @@ -275,12 +272,12 @@ async def test_multiple_servers_with_selection( mock_setup_entry.assert_called_once() +@pytest.mark.usefixtures("current_request_with_host") async def test_adding_last_unconfigured_server( hass: HomeAssistant, mock_plex_calls, requests_mock: requests_mock.Mocker, plextv_resources_two_servers, - current_request_with_host: None, mock_setup_entry: AsyncMock, ) -> None: """Test automatically adding last unconfigured server when multiple servers on account.""" @@ -332,13 +329,13 @@ async def test_adding_last_unconfigured_server( assert mock_setup_entry.call_count == 2 +@pytest.mark.usefixtures("current_request_with_host") async def test_all_available_servers_configured( hass: HomeAssistant, entry, requests_mock: requests_mock.Mocker, plextv_account, plextv_resources_two_servers, - current_request_with_host: None, ) -> None: """Test when all available servers are already configured.""" entry.add_to_hass(hass) @@ -479,9 +476,8 @@ async def test_option_flow_new_users_available( assert "[New]" in multiselect_defaults[user] -async def test_external_timed_out( - hass: HomeAssistant, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_external_timed_out(hass: HomeAssistant) -> None: """Test when external flow times out.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -506,10 +502,10 @@ async def test_external_timed_out( assert result["reason"] == "token_request_timeout" +@pytest.mark.usefixtures("current_request_with_host") async def test_callback_view( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, ) -> None: """Test callback view.""" result = await hass.config_entries.flow.async_init( @@ -534,15 +530,14 @@ async def test_callback_view( assert resp.status == HTTPStatus.OK -async def test_manual_config( - hass: HomeAssistant, mock_plex_calls, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_manual_config(hass: HomeAssistant, mock_plex_calls) -> None: """Test creating via manual configuration.""" class WrongCertValidaitionException(requests.exceptions.SSLError): """Mock the exception showing an unmatched error.""" - def __init__(self): + def __init__(self): # pylint: disable=super-init-not-called self.__context__ = ssl.SSLCertVerificationError( "some random message that doesn't match" ) @@ -739,11 +734,11 @@ async def test_integration_discovery(hass: HomeAssistant) -> None: assert flow["step_id"] == "user" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, entry: MockConfigEntry, mock_plex_calls: None, - current_request_with_host: None, mock_setup_entry: AsyncMock, ) -> None: """Test setup and reauthorization of a Plex token.""" @@ -783,11 +778,11 @@ async def test_reauth( mock_setup_entry.assert_called_once() +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_multiple_servers_available( hass: HomeAssistant, entry: MockConfigEntry, mock_plex_calls: None, - current_request_with_host: None, requests_mock: requests_mock.Mocker, plextv_resources_two_servers: str, mock_setup_entry: AsyncMock, @@ -853,9 +848,8 @@ async def test_client_request_missing(hass: HomeAssistant) -> None: ) -async def test_client_header_issues( - hass: HomeAssistant, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_client_header_issues(hass: HomeAssistant) -> None: """Test when client headers are not set properly.""" class MockRequest: diff --git a/tests/components/plex/test_device_handling.py b/tests/components/plex/test_device_handling.py index c3c26ec0bdd..f49cd4e7ccc 100644 --- a/tests/components/plex/test_device_handling.py +++ b/tests/components/plex/test_device_handling.py @@ -9,13 +9,15 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er async def test_cleanup_orphaned_devices( - hass: HomeAssistant, entry, setup_plex_server + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entry, + setup_plex_server, ) -> None: """Test cleaning up orphaned devices on startup.""" test_device_id = {(DOMAIN, "temporary_device_123")} - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) entry.add_to_hass(hass) test_device = device_registry.async_get_or_create( @@ -45,6 +47,8 @@ async def test_cleanup_orphaned_devices( async def test_migrate_transient_devices( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, entry, setup_plex_server, requests_mock: requests_mock.Mocker, @@ -55,8 +59,6 @@ async def test_migrate_transient_devices( non_plexweb_device_id = {(DOMAIN, "1234567890123456-com-plexapp-android")} plex_client_service_device_id = {(DOMAIN, "plex.tv-clients")} - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) entry.add_to_hass(hass) # Pre-create devices and entities to test device migration diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index a14c65daa43..15af78faf65 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -209,7 +209,7 @@ async def test_setup_when_certificate_changed( class WrongCertHostnameException(requests.exceptions.SSLError): """Mock the exception showing a mismatched hostname.""" - def __init__(self): + def __init__(self): # pylint: disable=super-init-not-called self.__context__ = ssl.SSLCertVerificationError( f"hostname '{old_domain}' doesn't match" ) @@ -255,7 +255,7 @@ async def test_setup_when_certificate_changed( # Test with success new_url = PLEX_DIRECT_URL requests_mock.get("https://plex.tv/api/v2/resources", text=plextv_resources) - for resource_url in [new_url, "http://1.2.3.4:32400"]: + for resource_url in (new_url, "http://1.2.3.4:32400"): requests_mock.get(resource_url, text=plex_server_default) requests_mock.get(f"{new_url}/accounts", text=plex_server_accounts) requests_mock.get(f"{new_url}/library", text=empty_library) @@ -270,7 +270,7 @@ async def test_setup_when_certificate_changed( assert old_entry.data[const.PLEX_SERVER_CONFIG][CONF_URL] == new_url -async def test_tokenless_server(hass, entry, setup_plex_server) -> None: +async def test_tokenless_server(hass: HomeAssistant, entry, setup_plex_server) -> None: """Test setup with a server with token auth disabled.""" TOKENLESS_DATA = copy.deepcopy(DEFAULT_DATA) TOKENLESS_DATA[const.PLEX_SERVER_CONFIG].pop(CONF_TOKEN, None) diff --git a/tests/components/plex/test_media_search.py b/tests/components/plex/test_media_search.py index 5578ecd2550..8219cbe27b6 100644 --- a/tests/components/plex/test_media_search.py +++ b/tests/components/plex/test_media_search.py @@ -59,14 +59,13 @@ async def test_media_lookups( # TV show searches with pytest.raises(MediaNotFound) as excinfo: - payload = '{"library_name": "Not a Library", "show_name": "TV Show"}' await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, ATTR_MEDIA_CONTENT_TYPE: MediaType.EPISODE, - ATTR_MEDIA_CONTENT_ID: payload, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Not a Library", "show_name": "TV Show"}', }, True, ) @@ -251,36 +250,36 @@ async def test_media_lookups( search.assert_called_with(title="Movie 1", libtype=None) with pytest.raises(MediaNotFound) as excinfo: - payload = '{"title": "Movie 1"}' await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, ATTR_MEDIA_CONTENT_TYPE: MediaType.VIDEO, - ATTR_MEDIA_CONTENT_ID: payload, + ATTR_MEDIA_CONTENT_ID: '{"title": "Movie 1"}', }, True, ) assert "Must specify 'library_name' for this search" in str(excinfo.value) - with pytest.raises(MediaNotFound) as excinfo: - payload = '{"library_name": "Movies", "title": "Not a Movie"}' - with patch( + with ( + pytest.raises(MediaNotFound) as excinfo, + patch( "plexapi.library.LibrarySection.search", side_effect=BadRequest, __qualname__="search", - ): - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: media_player_id, - ATTR_MEDIA_CONTENT_TYPE: MediaType.VIDEO, - ATTR_MEDIA_CONTENT_ID: payload, - }, - True, - ) + ), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_id, + ATTR_MEDIA_CONTENT_TYPE: MediaType.VIDEO, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Not a Movie"}', + }, + True, + ) assert "Problem in query" in str(excinfo.value) # Playlist searches @@ -296,28 +295,26 @@ async def test_media_lookups( ) with pytest.raises(MediaNotFound) as excinfo: - payload = '{"playlist_name": "Not a Playlist"}' await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: payload, + ATTR_MEDIA_CONTENT_ID: '{"playlist_name": "Not a Playlist"}', }, True, ) assert "Playlist 'Not a Playlist' not found" in str(excinfo.value) with pytest.raises(MediaNotFound) as excinfo: - payload = "{}" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: media_player_id, ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, - ATTR_MEDIA_CONTENT_ID: payload, + ATTR_MEDIA_CONTENT_ID: "{}", }, True, ) diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index 33c8b130749..183a779c940 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -83,7 +83,7 @@ async def test_media_player_playback( }, True, ) - assert not playmedia_mock.called + assert not playmedia_mock.called assert f"No {MediaType.MOVIE} results in 'Movies' for" in str(excinfo.value) movie1 = MockPlexMedia("Movie", "movie") @@ -197,24 +197,25 @@ async def test_media_player_playback( # Test multiple choices without exact match playmedia_mock.reset() movies = [movie2, movie3] - with pytest.raises(HomeAssistantError) as excinfo: - payload = '{"library_name": "Movies", "title": "Movie" }' - with patch( + with ( + pytest.raises(HomeAssistantError) as excinfo, + patch( "plexapi.library.LibrarySection.search", return_value=movies, __qualname__="search", - ): - await hass.services.async_call( - MP_DOMAIN, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: media_player, - ATTR_MEDIA_CONTENT_TYPE: MediaType.MOVIE, - ATTR_MEDIA_CONTENT_ID: payload, - }, - True, - ) - assert not playmedia_mock.called + ), + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MediaType.MOVIE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Movie" }', + }, + True, + ) + assert not playmedia_mock.called assert "Multiple matches, make content_id more specific" in str(excinfo.value) # Test multiple choices with allow_multiple diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 6002429e84d..02cbaac4db3 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -74,6 +74,7 @@ class MockPlexTVEpisode(MockPlexMedia): async def test_library_sensor_values( hass: HomeAssistant, + entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, setup_plex_server, mock_websocket, @@ -118,7 +119,6 @@ async def test_library_sensor_values( assert hass.states.get("sensor.plex_server_1_library_tv_shows") is None # Enable sensor and validate values - entity_registry = er.async_get(hass) entity_registry.async_update_entity( entity_id="sensor.plex_server_1_library_tv_shows", disabled_by=None ) diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index c211cd0a741..83826a0a543 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -2,13 +2,13 @@ from __future__ import annotations -from collections.abc import Generator import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from plugwise import PlugwiseData import pytest +from typing_extensions import Generator from homeassistant.components.plugwise.const import DOMAIN from homeassistant.const import ( @@ -47,7 +47,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.plugwise.async_setup_entry", return_value=True @@ -56,7 +56,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_smile_config_flow() -> Generator[None, MagicMock, None]: +def mock_smile_config_flow() -> Generator[MagicMock]: """Return a mocked Smile client.""" with patch( "homeassistant.components.plugwise.config_flow.Smile", @@ -71,7 +71,7 @@ def mock_smile_config_flow() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_adam() -> Generator[None, MagicMock, None]: +def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" chosen_env = "adam_multiple_devices_per_zone" @@ -97,7 +97,7 @@ def mock_smile_adam() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_adam_2() -> Generator[None, MagicMock, None]: +def mock_smile_adam_2() -> Generator[MagicMock]: """Create a 2nd Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_heating" @@ -123,7 +123,7 @@ def mock_smile_adam_2() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_adam_3() -> Generator[None, MagicMock, None]: +def mock_smile_adam_3() -> Generator[MagicMock]: """Create a 3rd Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_cooling" @@ -149,7 +149,7 @@ def mock_smile_adam_3() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_adam_4() -> Generator[None, MagicMock, None]: +def mock_smile_adam_4() -> Generator[MagicMock]: """Create a 4th Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_jip" @@ -175,7 +175,7 @@ def mock_smile_adam_4() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_anna() -> Generator[None, MagicMock, None]: +def mock_smile_anna() -> Generator[MagicMock]: """Create a Mock Anna environment for testing exceptions.""" chosen_env = "anna_heatpump_heating" with patch( @@ -200,7 +200,7 @@ def mock_smile_anna() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_anna_2() -> Generator[None, MagicMock, None]: +def mock_smile_anna_2() -> Generator[MagicMock]: """Create a 2nd Mock Anna environment for testing exceptions.""" chosen_env = "m_anna_heatpump_cooling" with patch( @@ -225,7 +225,7 @@ def mock_smile_anna_2() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_anna_3() -> Generator[None, MagicMock, None]: +def mock_smile_anna_3() -> Generator[MagicMock]: """Create a 3rd Mock Anna environment for testing exceptions.""" chosen_env = "m_anna_heatpump_idle" with patch( @@ -250,7 +250,7 @@ def mock_smile_anna_3() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_p1() -> Generator[None, MagicMock, None]: +def mock_smile_p1() -> Generator[MagicMock]: """Create a Mock P1 DSMR environment for testing exceptions.""" chosen_env = "p1v4_442_single" with patch( @@ -275,7 +275,7 @@ def mock_smile_p1() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_smile_p1_2() -> Generator[None, MagicMock, None]: +def mock_smile_p1_2() -> Generator[MagicMock]: """Create a Mock P1 3-phase DSMR environment for testing exceptions.""" chosen_env = "p1v4_442_triple" with patch( @@ -300,7 +300,7 @@ def mock_smile_p1_2() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_stretch() -> Generator[None, MagicMock, None]: +def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" chosen_env = "stretch_v31" with patch( diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index d9bf85b4701..6cd3241a637 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -66,8 +66,7 @@ "model": "ThermoTouch", "name": "Anna", "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Weekschema", - "selected_schedule": "None", + "select_schedule": "None", "sensors": { "setpoint": 23.5, "temperature": 25.8 diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index 37fc73009d3..0e9df1a5079 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -71,8 +71,7 @@ "model": "ThermoTouch", "name": "Anna", "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Weekschema", - "selected_schedule": "None", + "select_schedule": "None", "sensors": { "setpoint": 20.0, "temperature": 19.1 diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 8041d2778ef..5cdc468a957 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from plugwise.exceptions import PlugwiseError import pytest -from homeassistant.components.climate.const import HVACMode +from homeassistant.components.climate import HVACMode from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.util.dt import utcnow diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index b206b36be89..9c709f1c4f6 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -1,6 +1,7 @@ """Tests for the Plugwise Climate integration.""" -from unittest.mock import MagicMock +from datetime import timedelta +from unittest.mock import MagicMock, patch from plugwise.exceptions import ( ConnectionFailedError, @@ -15,15 +16,45 @@ from homeassistant.components.plugwise.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +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 tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +HA_PLUGWISE_SMILE_ASYNC_UPDATE = ( + "homeassistant.components.plugwise.coordinator.Smile.async_update" +) HEATER_ID = "1cbf783bb11e4a7c8a6843dee3a86927" # Opentherm device_id for migration PLUG_ID = "cd0ddb54ef694e11ac18ed1cbce5dbbd" # VCR device_id for migration SECONDARY_ID = ( "1cbf783bb11e4a7c8a6843dee3a86927" # Heater_central device_id for migration ) +TOM = { + "01234567890abcdefghijklmnopqrstu": { + "available": True, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "temperature": 18.6, + "temperature_difference": 2.3, + "valve_position": 0.0, + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01", + }, +} async def test_load_unload_config_entry( @@ -92,6 +123,7 @@ async def test_gateway_config_entry_not_ready( ) async def test_migrate_unique_id_temperature( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_smile_anna: MagicMock, entitydata: dict, @@ -101,7 +133,6 @@ async def test_migrate_unique_id_temperature( """Test migration of unique_id.""" mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: entity_registry.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, @@ -144,6 +175,7 @@ async def test_migrate_unique_id_temperature( ) async def test_migrate_unique_id_relay( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_smile_adam: MagicMock, entitydata: dict, @@ -153,8 +185,7 @@ async def test_migrate_unique_id_relay( """Test migration of unique_id.""" mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - entity: entity_registry.RegistryEntry = entity_registry.async_get_or_create( + entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, ) @@ -165,3 +196,63 @@ async def test_migrate_unique_id_relay( entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == new_unique_id + + +async def test_update_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smile_adam_2: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test a clean-up of the device_registry.""" + utcnow = dt_util.utcnow() + data = mock_smile_adam_2.async_update.return_value + + mock_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 28 + ) + assert ( + len( + dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + ) + == 6 + ) + + # Add a 2nd Tom/Floor + data.devices.update(TOM) + with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): + async_fire_time_changed(hass, utcnow + timedelta(minutes=1)) + await hass.async_block_till_done() + + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 33 + ) + assert ( + len( + dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + ) + == 7 + ) + item_list: list[str] = [] + for device_entry in list(device_registry.devices.values()): + item_list.extend(x[1] for x in device_entry.identifiers) + assert "01234567890abcdefghijklmnopqrstu" in item_list diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index d1df8454f4e..9a20a37824d 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -3,10 +3,10 @@ from unittest.mock import MagicMock from homeassistant.components.plugwise.const import DOMAIN -from homeassistant.components.plugwise.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.entity_registry import async_get from tests.common import MockConfigEntry @@ -49,16 +49,16 @@ async def test_adam_climate_sensor_entity_2( async def test_unique_id_migration_humidity( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_smile_adam_4: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -relative_humidity to -humidity.""" mock_config_entry.add_to_hass(hass) - entity_registry = async_get(hass) # Entry to migrate entity_registry.async_get_or_create( - SENSOR_DOMAIN, + Platform.SENSOR, DOMAIN, "f61f1a2535f54f52ad006a3d18e459ca-relative_humidity", config_entry=mock_config_entry, @@ -67,7 +67,7 @@ async def test_unique_id_migration_humidity( ) # Entry not needing migration entity_registry.async_get_or_create( - SENSOR_DOMAIN, + Platform.SENSOR, DOMAIN, "f61f1a2535f54f52ad006a3d18e459ca-battery", config_entry=mock_config_entry, @@ -136,7 +136,10 @@ async def test_p1_dsmr_sensor_entities( async def test_p1_3ph_dsmr_sensor_entities( - hass: HomeAssistant, mock_smile_p1_2: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_smile_p1_2: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test creation of power related sensor entities.""" state = hass.states.get("sensor.p1_electricity_phase_one_consumed") @@ -155,7 +158,6 @@ async def test_p1_3ph_dsmr_sensor_entities( state = hass.states.get(entity_id) assert not state - entity_registry = async_get(hass) entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index fa58bd4c8eb..6b2393476ae 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -153,14 +153,16 @@ async def test_stretch_switch_changes( async def test_unique_id_migration_plug_relay( - hass: HomeAssistant, mock_smile_adam: MagicMock, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_smile_adam: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test unique ID migration of -plugs to -relay.""" mock_config_entry.add_to_hass(hass) - registry = er.async_get(hass) # Entry to migrate - registry.async_get_or_create( + entity_registry.async_get_or_create( SWITCH_DOMAIN, DOMAIN, "21f2b542c49845e6bb416884c55778d6-plug", @@ -169,7 +171,7 @@ async def test_unique_id_migration_plug_relay( disabled_by=None, ) # Entry not needing migration - registry.async_get_or_create( + entity_registry.async_get_or_create( SWITCH_DOMAIN, DOMAIN, "675416a629f343c495449970e2ca37b5-relay", @@ -184,10 +186,10 @@ async def test_unique_id_migration_plug_relay( assert hass.states.get("switch.playstation_smart_plug") is not None assert hass.states.get("switch.ziggo_modem") is not None - entity_entry = registry.async_get("switch.playstation_smart_plug") + entity_entry = entity_registry.async_get("switch.playstation_smart_plug") assert entity_entry assert entity_entry.unique_id == "21f2b542c49845e6bb416884c55778d6-relay" - entity_entry = registry.async_get("switch.ziggo_modem") + entity_entry = entity_registry.async_get("switch.ziggo_modem") assert entity_entry assert entity_entry.unique_id == "675416a629f343c495449970e2ca37b5-relay" diff --git a/tests/components/poolsense/__init__.py b/tests/components/poolsense/__init__.py index ace3a6997fb..9d7ecb5eb47 100644 --- a/tests/components/poolsense/__init__.py +++ b/tests/components/poolsense/__init__.py @@ -1 +1,12 @@ """Tests for the PoolSense integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/poolsense/conftest.py b/tests/components/poolsense/conftest.py new file mode 100644 index 00000000000..ac16ef23ff3 --- /dev/null +++ b/tests/components/poolsense/conftest.py @@ -0,0 +1,67 @@ +"""Common fixtures for the Poolsense tests.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components.poolsense.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.poolsense.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_poolsense_client() -> Generator[AsyncMock]: + """Mock a PoolSense client.""" + with ( + patch( + "homeassistant.components.poolsense.PoolSense", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.poolsense.config_flow.PoolSense", + new=mock_client, + ), + ): + client = mock_client.return_value + client.test_poolsense_credentials.return_value = True + client.get_poolsense_data.return_value = { + "Chlorine": 20, + "pH": 5, + "Water Temp": 6, + "Battery": 80, + "Last Seen": datetime(2021, 1, 1, 0, 0, 0, tzinfo=UTC), + "Chlorine High": 30, + "Chlorine Low": 20, + "pH High": 7, + "pH Low": 4, + "pH Status": "red", + "Chlorine Status": "red", + } + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="test@test.com", + unique_id="test@test.com", + data={ + CONF_EMAIL: "test@test.com", + CONF_PASSWORD: "test", + }, + ) diff --git a/tests/components/poolsense/snapshots/test_binary_sensor.ambr b/tests/components/poolsense/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..8a6d39332d4 --- /dev/null +++ b/tests/components/poolsense/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.test_test_com_chlorine_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_test_com_chlorine_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Chlorine status', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_status', + 'unique_id': 'test@test.com-Chlorine Status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_test_com_chlorine_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'device_class': 'problem', + 'friendly_name': 'test@test.com Chlorine status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_test_com_chlorine_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.test_test_com_ph_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_test_com_ph_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH status', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ph_status', + 'unique_id': 'test@test.com-pH Status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.test_test_com_ph_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'device_class': 'problem', + 'friendly_name': 'test@test.com pH status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_test_com_ph_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/poolsense/snapshots/test_sensor.ambr b/tests/components/poolsense/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9029f1f24aa --- /dev/null +++ b/tests/components/poolsense/snapshots/test_sensor.ambr @@ -0,0 +1,433 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_test_com_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test@test.com-Battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_test_com_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'device_class': 'battery', + 'friendly_name': 'test@test.com Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_test_com_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_all_entities[sensor.test_test_com_chlorine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_chlorine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine', + 'unique_id': 'test@test.com-Chlorine', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_test_com_chlorine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'friendly_name': 'test@test.com Chlorine', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_test_com_chlorine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sensor.test_test_com_chlorine_high-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_chlorine_high', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine high', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_high', + 'unique_id': 'test@test.com-Chlorine High', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_test_com_chlorine_high-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'friendly_name': 'test@test.com Chlorine high', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_test_com_chlorine_high', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[sensor.test_test_com_chlorine_low-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_chlorine_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine low', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'chlorine_low', + 'unique_id': 'test@test.com-Chlorine Low', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_test_com_chlorine_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'friendly_name': 'test@test.com Chlorine low', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_test_com_chlorine_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sensor.test_test_com_last_seen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_last_seen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last seen', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_seen', + 'unique_id': 'test@test.com-Last Seen', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_test_com_last_seen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'device_class': 'timestamp', + 'friendly_name': 'test@test.com Last seen', + }), + 'context': , + 'entity_id': 'sensor.test_test_com_last_seen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T00:00:00+00:00', + }) +# --- +# name: test_all_entities[sensor.test_test_com_ph-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_ph', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test@test.com-pH', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_test_com_ph-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'device_class': 'ph', + 'friendly_name': 'test@test.com pH', + }), + 'context': , + 'entity_id': 'sensor.test_test_com_ph', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[sensor.test_test_com_ph_high-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_ph_high', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH high', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ph_high', + 'unique_id': 'test@test.com-pH High', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_test_com_ph_high-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'friendly_name': 'test@test.com pH high', + }), + 'context': , + 'entity_id': 'sensor.test_test_com_ph_high', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.test_test_com_ph_low-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_ph_low', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'pH low', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ph_low', + 'unique_id': 'test@test.com-pH Low', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_test_com_ph_low-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'friendly_name': 'test@test.com pH low', + }), + 'context': , + 'entity_id': 'sensor.test_test_com_ph_low', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_all_entities[sensor.test_test_com_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_test_com_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'poolsense', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_temp', + 'unique_id': 'test@test.com-Water Temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_test_com_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'PoolSense Data', + 'device_class': 'temperature', + 'friendly_name': 'test@test.com Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_test_com_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- diff --git a/tests/components/poolsense/test_binary_sensor.py b/tests/components/poolsense/test_binary_sensor.py new file mode 100644 index 00000000000..4d10413c124 --- /dev/null +++ b/tests/components/poolsense/test_binary_sensor.py @@ -0,0 +1,31 @@ +"""Test the PoolSense binary sensor module.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_poolsense_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.poolsense.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) diff --git a/tests/components/poolsense/test_config_flow.py b/tests/components/poolsense/test_config_flow.py index 49f790b5075..5c8b824bfaa 100644 --- a/tests/components/poolsense/test_config_flow.py +++ b/tests/components/poolsense/test_config_flow.py @@ -1,6 +1,6 @@ """Test the PoolSense config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from homeassistant.components.poolsense.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -8,9 +8,13 @@ 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 def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" + +async def test_full_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_poolsense_client: AsyncMock +) -> None: + """Test full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -18,39 +22,59 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, + ) -async def test_invalid_credentials(hass: HomeAssistant) -> None: + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@test.com" + assert result["data"] == { + CONF_EMAIL: "test@test.com", + CONF_PASSWORD: "test", + } + assert result["result"].unique_id == "test@test.com" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_invalid_credentials( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_poolsense_client: AsyncMock +) -> None: """Test we handle invalid credentials.""" - with patch( - "poolsense.PoolSense.test_poolsense_credentials", - return_value=False, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"}, - ) + mock_poolsense_client.test_poolsense_credentials.return_value = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} + mock_poolsense_client.test_poolsense_credentials.return_value = True -async def test_valid_credentials(hass: HomeAssistant) -> None: - """Test we handle invalid credentials.""" - with ( - patch("poolsense.PoolSense.test_poolsense_credentials", return_value=True), - patch( - "homeassistant.components.poolsense.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_EMAIL: "test-email", CONF_PASSWORD: "test-password"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "test-email" - assert len(mock_setup_entry.mock_calls) == 1 + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_poolsense_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can't add the same entry twice.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_EMAIL: "test@test.com", CONF_PASSWORD: "test"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/poolsense/test_sensor.py b/tests/components/poolsense/test_sensor.py new file mode 100644 index 00000000000..7f088eee6a3 --- /dev/null +++ b/tests/components/poolsense/test_sensor.py @@ -0,0 +1,31 @@ +"""Test the PoolSense sensor module.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_poolsense_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.poolsense.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) diff --git a/tests/components/powerwall/fixtures/batteries.json b/tests/components/powerwall/fixtures/batteries.json index fb8d4a97ee4..084a9fd1e47 100644 --- a/tests/components/powerwall/fixtures/batteries.json +++ b/tests/components/powerwall/fixtures/batteries.json @@ -12,7 +12,8 @@ "v_out": 245.70000000000002, "f_out": 50.037, "i_out": 0.30000000000000004, - "pinv_grid_state": "Grid_Compliant" + "pinv_grid_state": "Grid_Compliant", + "disabled_reasons": [] }, { "PackagePartNumber": "3012170-05-C", @@ -27,6 +28,7 @@ "v_out": 245.60000000000002, "f_out": 50.037, "i_out": 0.1, - "pinv_grid_state": "Grid_Compliant" + "pinv_grid_state": "Grid_Compliant", + "disabled_reasons": [] } ] diff --git a/tests/components/powerwall/mocks.py b/tests/components/powerwall/mocks.py index 10b070a0db7..e43ccee16f1 100644 --- a/tests/components/powerwall/mocks.py +++ b/tests/components/powerwall/mocks.py @@ -16,12 +16,16 @@ from tesla_powerwall import ( SiteMasterResponse, ) +from homeassistant.core import HomeAssistant + from tests.common import load_fixture MOCK_GATEWAY_DIN = "111-0----2-000000000FFA" -async def _mock_powerwall_with_fixtures(hass, empty_meters: bool = False) -> MagicMock: +async def _mock_powerwall_with_fixtures( + hass: HomeAssistant, empty_meters: bool = False +) -> MagicMock: """Mock data used to build powerwall state.""" async with asyncio.TaskGroup() as tg: meters_file = "meters_empty.json" if empty_meters else "meters.json" diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 2ec9f44bd0e..fa2d986d12a 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +import pytest from tesla_powerwall import MetersAggregatesResponse from tesla_powerwall.error import MissingAttributeError @@ -25,9 +26,8 @@ from .mocks import MOCK_GATEWAY_DIN, _mock_powerwall_with_fixtures from tests.common import MockConfigEntry, async_fire_time_changed -async def test_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors(hass: HomeAssistant, device_registry: dr.DeviceRegistry) -> None: """Test creation of the sensors.""" mock_powerwall = await _mock_powerwall_with_fixtures(hass) @@ -46,7 +46,6 @@ async def test_sensors( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={("powerwall", MOCK_GATEWAY_DIN)}, ) @@ -244,12 +243,13 @@ async def test_sensors_with_empty_meters(hass: HomeAssistant) -> None: assert hass.states.get("sensor.mysite_solar_power") is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_unique_id_migrate( - hass: HomeAssistant, entity_registry_enabled_by_default: None + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test we can migrate unique ids of the sensors.""" - device_registry = dr.async_get(hass) - ent_reg = er.async_get(hass) config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4"}) config_entry.add_to_hass(hass) @@ -261,7 +261,7 @@ async def test_unique_id_migrate( identifiers={("powerwall", old_unique_id)}, manufacturer="Tesla", ) - old_mysite_load_power_entity = ent_reg.async_get_or_create( + old_mysite_load_power_entity = entity_registry.async_get_or_create( "sensor", DOMAIN, unique_id=f"{old_unique_id}_load_instant_power", @@ -292,13 +292,13 @@ async def test_unique_id_migrate( assert reg_device is not None assert ( - ent_reg.async_get_entity_id( + entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{old_unique_id}_load_instant_power" ) is None ) assert ( - ent_reg.async_get_entity_id( + entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{new_unique_id}_load_instant_power" ) is not None diff --git a/tests/components/powerwall/test_switch.py b/tests/components/powerwall/test_switch.py index fdcdd5150ed..b01f60210a6 100644 --- a/tests/components/powerwall/test_switch.py +++ b/tests/components/powerwall/test_switch.py @@ -95,9 +95,8 @@ async def test_exception_on_powerwall_error( ) -> None: """Ensure that an exception in the tesla_powerwall library causes a HomeAssistantError.""" + mock_powerwall.set_island_mode.side_effect = PowerwallError("Mock exception") with pytest.raises(HomeAssistantError, match="Setting off-grid operation to"): - mock_powerwall.set_island_mode.side_effect = PowerwallError("Mock exception") - await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, diff --git a/tests/components/private_ble_device/test_config_flow.py b/tests/components/private_ble_device/test_config_flow.py index a8821dddace..0d4ebdfd99d 100644 --- a/tests/components/private_ble_device/test_config_flow.py +++ b/tests/components/private_ble_device/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.private_ble_device import const from homeassistant.core import HomeAssistant @@ -18,9 +20,8 @@ def assert_form_error(result: FlowResult, key: str, value: str) -> None: assert result["errors"][key] == value -async def test_setup_user_no_bluetooth( - hass: HomeAssistant, mock_bluetooth_adapters: None -) -> None: +@pytest.mark.usefixtures("mock_bluetooth_adapters") +async def test_setup_user_no_bluetooth(hass: HomeAssistant) -> None: """Test setting up via user interaction when bluetooth is not enabled.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, @@ -30,7 +31,8 @@ async def test_setup_user_no_bluetooth( assert result["reason"] == "bluetooth_not_available" -async def test_invalid_irk(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_invalid_irk(hass: HomeAssistant) -> None: """Test invalid irk.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,7 +45,8 @@ async def test_invalid_irk(hass: HomeAssistant, enable_bluetooth: None) -> None: assert_form_error(result, "irk", "irk_not_valid") -async def test_invalid_irk_base64(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_invalid_irk_base64(hass: HomeAssistant) -> None: """Test invalid irk.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -56,7 +59,8 @@ async def test_invalid_irk_base64(hass: HomeAssistant, enable_bluetooth: None) - assert_form_error(result, "irk", "irk_not_valid") -async def test_invalid_irk_hex(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_invalid_irk_hex(hass: HomeAssistant) -> None: """Test invalid irk.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -69,7 +73,8 @@ async def test_invalid_irk_hex(hass: HomeAssistant, enable_bluetooth: None) -> N assert_form_error(result, "irk", "irk_not_valid") -async def test_irk_not_found(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_irk_not_found(hass: HomeAssistant) -> None: """Test irk not found.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -83,7 +88,8 @@ async def test_irk_not_found(hass: HomeAssistant, enable_bluetooth: None) -> Non assert_form_error(result, "irk", "irk_not_found") -async def test_flow_works(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_flow_works(hass: HomeAssistant) -> None: """Test config flow works.""" inject_bluetooth_service_info( @@ -120,9 +126,8 @@ async def test_flow_works(hass: HomeAssistant, enable_bluetooth: None) -> None: assert result["result"].unique_id == "00000000000000000000000000000000" -async def test_flow_works_by_base64( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_flow_works_by_base64(hass: HomeAssistant) -> None: """Test config flow works.""" inject_bluetooth_service_info( diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index 9d784ecdfa7..02b0dd14df8 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -2,7 +2,9 @@ import time +# pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED +import pytest from homeassistant.components.bluetooth.api import ( async_get_fallback_availability_interval, @@ -21,7 +23,8 @@ from . import ( from tests.components.bluetooth.test_advertisement_tracker import ONE_HOUR_SECONDS -async def test_tracker_created(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_created(hass: HomeAssistant) -> None: """Test creating a tracker entity when no devices have been seen.""" await async_mock_config_entry(hass) @@ -30,9 +33,8 @@ async def test_tracker_created(hass: HomeAssistant, enable_bluetooth: None) -> N assert state.state == "not_home" -async def test_tracker_ignore_other_rpa( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_ignore_other_rpa(hass: HomeAssistant) -> None: """Test that tracker ignores RPA's that don't match us.""" await async_mock_config_entry(hass) await async_inject_broadcast(hass, MAC_STATIC) @@ -42,9 +44,8 @@ async def test_tracker_ignore_other_rpa( assert state.state == "not_home" -async def test_tracker_already_home( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_already_home(hass: HomeAssistant) -> None: """Test creating a tracker and the device was already discovered by HA.""" await async_inject_broadcast(hass, MAC_RPA_VALID_1) await async_mock_config_entry(hass) @@ -54,7 +55,8 @@ async def test_tracker_already_home( assert state.state == "home" -async def test_tracker_arrive_home(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_arrive_home(hass: HomeAssistant) -> None: """Test transition from not_home to home.""" await async_mock_config_entry(hass) await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"1") @@ -84,7 +86,8 @@ async def test_tracker_arrive_home(hass: HomeAssistant, enable_bluetooth: None) assert state.attributes["current_address"] == "40:01:02:0a:c4:a6" -async def test_tracker_isolation(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_isolation(hass: HomeAssistant) -> None: """Test creating 2 tracker entities doesn't confuse anything.""" await async_mock_config_entry(hass) await async_mock_config_entry(hass, irk="1" * 32) @@ -101,7 +104,8 @@ async def test_tracker_isolation(hass: HomeAssistant, enable_bluetooth: None) -> assert state.state == "not_home" -async def test_tracker_mac_rotate(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_mac_rotate(hass: HomeAssistant) -> None: """Test MAC address rotation.""" await async_inject_broadcast(hass, MAC_RPA_VALID_1) await async_mock_config_entry(hass) @@ -118,7 +122,8 @@ async def test_tracker_mac_rotate(hass: HomeAssistant, enable_bluetooth: None) - assert state.attributes["current_address"] == MAC_RPA_VALID_2 -async def test_tracker_start_stale(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_start_stale(hass: HomeAssistant) -> None: """Test edge case where we find an existing stale record, and it expires before we see any more.""" time.monotonic() @@ -137,7 +142,8 @@ async def test_tracker_start_stale(hass: HomeAssistant, enable_bluetooth: None) assert state.state == "not_home" -async def test_tracker_leave_home(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_tracker_leave_home(hass: HomeAssistant) -> None: """Test tracker notices we have left.""" time.monotonic() @@ -156,9 +162,8 @@ async def test_tracker_leave_home(hass: HomeAssistant, enable_bluetooth: None) - assert state.state == "not_home" -async def test_old_tracker_leave_home( - hass: HomeAssistant, enable_bluetooth: None -) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_old_tracker_leave_home(hass: HomeAssistant) -> None: """Test tracker ignores an old stale mac address timing out.""" start_time = time.monotonic() @@ -184,11 +189,8 @@ async def test_old_tracker_leave_home( assert state.state == "not_home" -async def test_mac_rotation( - hass: HomeAssistant, - enable_bluetooth: None, - entity_registry_enabled_by_default: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +async def test_mac_rotation(hass: HomeAssistant) -> None: """Test sensors get value when we receive a broadcast.""" await async_mock_config_entry(hass) diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 43667a0e9d2..cb40fc4f0c2 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,6 +1,8 @@ """Tests for sensors.""" +# pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED +import pytest from homeassistant.components.bluetooth import async_set_fallback_availability_interval from homeassistant.core import HomeAssistant @@ -13,11 +15,8 @@ from . import ( ) -async def test_sensor_unavailable( - hass: HomeAssistant, - enable_bluetooth: None, - entity_registry_enabled_by_default: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +async def test_sensor_unavailable(hass: HomeAssistant) -> None: """Test sensors are unavailable.""" await async_mock_config_entry(hass) @@ -26,11 +25,8 @@ async def test_sensor_unavailable( assert state.state == "unavailable" -async def test_sensors_already_home( - hass: HomeAssistant, - enable_bluetooth: None, - entity_registry_enabled_by_default: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +async def test_sensors_already_home(hass: HomeAssistant) -> None: """Test sensors get value when we start at home.""" await async_inject_broadcast(hass, MAC_RPA_VALID_1) await async_mock_config_entry(hass) @@ -40,11 +36,8 @@ async def test_sensors_already_home( assert state.state == "-63" -async def test_sensors_come_home( - hass: HomeAssistant, - enable_bluetooth: None, - entity_registry_enabled_by_default: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +async def test_sensors_come_home(hass: HomeAssistant) -> None: """Test sensors get value when we receive a broadcast.""" await async_mock_config_entry(hass) await async_inject_broadcast(hass, MAC_RPA_VALID_1) @@ -54,11 +47,8 @@ async def test_sensors_come_home( assert state.state == "-63" -async def test_estimated_broadcast_interval( - hass: HomeAssistant, - enable_bluetooth: None, - entity_registry_enabled_by_default: None, -) -> None: +@pytest.mark.usefixtures("enable_bluetooth", "entity_registry_enabled_by_default") +async def test_estimated_broadcast_interval(hass: HomeAssistant) -> None: """Test sensors get value when we receive a broadcast.""" await async_mock_config_entry(hass) await async_inject_broadcast(hass, MAC_RPA_VALID_1) diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index ba605049e72..2eca84b43fe 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -181,7 +181,7 @@ async def test_dump_log_object( def __repr__(self): if self.fail: - raise Exception("failed") + raise Exception("failed") # pylint: disable=broad-exception-raised return "" obj1 = DumpLogDummy(False) diff --git a/tests/components/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py index 534c852c616..b65b86b3049 100644 --- a/tests/components/prosegur/test_alarm_control_panel.py +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from pyprosegur.installation import Status import pytest +from typing_extensions import Generator from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.const import ( @@ -35,7 +36,7 @@ def mock_auth(): @pytest.fixture(params=list(Status)) -def mock_status(request): +def mock_status(request: pytest.FixtureRequest) -> Generator[None]: """Mock the status of the alarm.""" install = AsyncMock() @@ -47,11 +48,13 @@ def mock_status(request): async def test_entity_registry( - hass: HomeAssistant, init_integration, mock_auth, mock_status + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration, + mock_auth, + mock_status, ) -> None: """Tests that the devices are registered in the entity registry.""" - entity_registry = er.async_get(hass) - entry = entity_registry.async_get(PROSEGUR_ALARM_ENTITY) # Prosegur alarm device unique_id is the contract id associated to the alarm account assert entry.unique_id == CONTRACT diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py index ed503d676ff..9cce5d484d4 100644 --- a/tests/components/prosegur/test_camera.py +++ b/tests/components/prosegur/test_camera.py @@ -40,9 +40,9 @@ async def test_camera_fail( ): await camera.async_get_image(hass, "camera.contract_1234abcd_test_cam") - assert "Unable to get image" in str(exc.value) + assert "Unable to get image" in str(exc.value) - assert "Image test_cam doesn't exist" in caplog.text + assert "Image test_cam doesn't exist" in caplog.text async def test_request_image( diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 8fa9e4a1ce1..6c2b54cae29 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -483,7 +483,7 @@ async def test_device_trackers_in_zone(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_nearest_device") assert state.state == "test1, test2" - for device in ["test1", "test2"]: + for device in ("test1", "test2"): entity_base_name = f"sensor.home_{device}" state = hass.states.get(f"{entity_base_name}_distance") assert state.state == "0" diff --git a/tests/components/ps4/conftest.py b/tests/components/ps4/conftest.py index d95acc7e92f..bc84ea3b4db 100644 --- a/tests/components/ps4/conftest.py +++ b/tests/components/ps4/conftest.py @@ -1,14 +1,14 @@ """Test configuration for PS4.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch from pyps4_2ndscreen.ddp import DEFAULT_UDP_PORT, DDPProtocol import pytest +from typing_extensions import Generator @pytest.fixture -def patch_load_json_object() -> Generator[MagicMock, None, None]: +def patch_load_json_object() -> Generator[MagicMock]: """Prevent load JSON being used.""" with patch( "homeassistant.components.ps4.load_json_object", return_value={} @@ -17,21 +17,21 @@ def patch_load_json_object() -> Generator[MagicMock, None, None]: @pytest.fixture -def patch_save_json() -> Generator[MagicMock, None, None]: +def patch_save_json() -> Generator[MagicMock]: """Prevent save JSON being used.""" with patch("homeassistant.components.ps4.save_json") as mock_save: yield mock_save @pytest.fixture -def patch_get_status() -> Generator[MagicMock, None, None]: +def patch_get_status() -> Generator[MagicMock]: """Prevent save JSON being used.""" with patch("pyps4_2ndscreen.ps4.get_status", return_value=None) as mock_get_status: yield mock_get_status @pytest.fixture -def mock_ddp_endpoint() -> Generator[None, None, None]: +def mock_ddp_endpoint() -> Generator[None]: """Mock pyps4_2ndscreen.ddp.async_create_ddp_endpoint.""" protocol = DDPProtocol() protocol._local_port = DEFAULT_UDP_PORT diff --git a/tests/components/pure_energie/conftest.py b/tests/components/pure_energie/conftest.py index 40e6f803e83..7174befbf5b 100644 --- a/tests/components/pure_energie/conftest.py +++ b/tests/components/pure_energie/conftest.py @@ -1,11 +1,11 @@ """Fixtures for Pure Energie integration tests.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch from gridnet import Device as GridNetDevice, SmartBridge import pytest +from typing_extensions import Generator from homeassistant.components.pure_energie.const import DOMAIN from homeassistant.const import CONF_HOST @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.pure_energie.async_setup_entry", return_value=True @@ -35,9 +35,7 @@ def mock_setup_entry() -> Generator[None, None, None]: @pytest.fixture -def mock_pure_energie_config_flow( - request: pytest.FixtureRequest, -) -> Generator[None, MagicMock, None]: +def mock_pure_energie_config_flow() -> Generator[MagicMock]: """Return a mocked Pure Energie client.""" with patch( "homeassistant.components.pure_energie.config_flow.GridNet", autospec=True @@ -53,7 +51,7 @@ def mock_pure_energie_config_flow( def mock_pure_energie(): """Return a mocked Pure Energie client.""" with patch( - "homeassistant.components.pure_energie.GridNet", autospec=True + "homeassistant.components.pure_energie.coordinator.GridNet", autospec=True ) as pure_energie_mock: pure_energie = pure_energie_mock.return_value pure_energie.smartbridge = AsyncMock( diff --git a/tests/components/pure_energie/test_init.py b/tests/components/pure_energie/test_init.py index 0a56240aaad..0dbd8a753e6 100644 --- a/tests/components/pure_energie/test_init.py +++ b/tests/components/pure_energie/test_init.py @@ -37,7 +37,7 @@ async def test_load_unload_config_entry( @patch( - "homeassistant.components.pure_energie.GridNet._request", + "homeassistant.components.pure_energie.coordinator.GridNet._request", side_effect=GridNetConnectionError, ) async def test_config_entry_not_ready( diff --git a/tests/components/pure_energie/test_sensor.py b/tests/components/pure_energie/test_sensor.py index eb0b9634e83..ba557363fa4 100644 --- a/tests/components/pure_energie/test_sensor.py +++ b/tests/components/pure_energie/test_sensor.py @@ -22,12 +22,11 @@ from tests.common import MockConfigEntry async def test_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Pure Energie - SmartBridge sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.pem_energy_consumption_total") entry = entity_registry.async_get("sensor.pem_energy_consumption_total") assert entry diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py index fbfc20fc632..2345d98b5e1 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -275,7 +275,10 @@ async def test_options_add_sensor_duplicate( async def test_options_remove_sensor( - hass: HomeAssistant, config_entry, setup_config_entry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry, + setup_config_entry, ) -> None: """Test removing a sensor via the options flow.""" result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -288,7 +291,6 @@ async def test_options_remove_sensor( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "remove_sensor" - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, str(TEST_SENSOR_INDEX1))} ) diff --git a/tests/components/pvoutput/conftest.py b/tests/components/pvoutput/conftest.py index 122b55ca4c2..d19f09d9e6c 100644 --- a/tests/components/pvoutput/conftest.py +++ b/tests/components/pvoutput/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from pvo import Status, System import pytest +from typing_extensions import Generator from homeassistant.components.pvoutput.const import CONF_SYSTEM_ID, DOMAIN from homeassistant.const import CONF_API_KEY @@ -27,7 +27,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.pvoutput.async_setup_entry", return_value=True @@ -36,7 +36,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_pvoutput() -> Generator[None, MagicMock, None]: +def mock_pvoutput() -> Generator[MagicMock]: """Return a mocked PVOutput client.""" with ( patch( diff --git a/tests/components/pvoutput/test_sensor.py b/tests/components/pvoutput/test_sensor.py index 6d1e239f0f3..fbcff94be60 100644 --- a/tests/components/pvoutput/test_sensor.py +++ b/tests/components/pvoutput/test_sensor.py @@ -24,11 +24,11 @@ from tests.common import MockConfigEntry async def test_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the PVOutput sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) state = hass.states.get("sensor.frenck_s_solar_farm_energy_consumed") entry = entity_registry.async_get("sensor.frenck_s_solar_farm_energy_consumed") diff --git a/tests/components/pvpc_hourly_pricing/conftest.py b/tests/components/pvpc_hourly_pricing/conftest.py index 5a09d1f3487..f0bf71e2d5a 100644 --- a/tests/components/pvpc_hourly_pricing/conftest.py +++ b/tests/components/pvpc_hourly_pricing/conftest.py @@ -4,7 +4,7 @@ from http import HTTPStatus import pytest -from homeassistant.components.pvpc_hourly_pricing import ATTR_TARIFF, DOMAIN +from homeassistant.components.pvpc_hourly_pricing.const import ATTR_TARIFF, DOMAIN from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, CURRENCY_EURO, UnitOfEnergy from tests.common import load_fixture diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 70e25392bb6..fbaeb8aa5a3 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -30,6 +30,7 @@ _MOCK_TIME_BAD_AUTH_RESPONSES = datetime(2023, 1, 8, 12, 0, tzinfo=dt_util.UTC) async def test_config_flow( hass: HomeAssistant, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, pvpc_aioclient_mock: AiohttpClientMocker, ) -> None: @@ -42,7 +43,7 @@ async def test_config_flow( - Configure options to introduce API Token, with bad auth and good one """ freezer.move_to(_MOCK_TIME_VALID_RESPONSES) - hass.config.set_time_zone("Europe/Madrid") + await hass.config.async_set_time_zone("Europe/Madrid") tst_config = { CONF_NAME: "test", ATTR_TARIFF: TARIFFS[1], @@ -82,8 +83,7 @@ async def test_config_flow( assert pvpc_aioclient_mock.call_count == 1 # Check removal - registry = er.async_get(hass) - registry_entity = registry.async_get("sensor.esios_pvpc") + registry_entity = entity_registry.async_get("sensor.esios_pvpc") assert await hass.config_entries.async_remove(registry_entity.config_entry_id) # and add it again with UI @@ -184,7 +184,7 @@ async def test_reauth( ) -> None: """Test reauth flow for API-token mode.""" freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES) - hass.config.set_time_zone("Europe/Madrid") + await hass.config.async_set_time_zone("Europe/Madrid") tst_config = { CONF_NAME: "test", ATTR_TARIFF: TARIFFS[1], diff --git a/tests/components/pyload/__init__.py b/tests/components/pyload/__init__.py new file mode 100644 index 00000000000..5ba1e4f9337 --- /dev/null +++ b/tests/components/pyload/__init__.py @@ -0,0 +1 @@ +"""Tests for the pyLoad component.""" diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py new file mode 100644 index 00000000000..67694bcb4b9 --- /dev/null +++ b/tests/components/pyload/conftest.py @@ -0,0 +1,75 @@ +"""Fixtures for pyLoad integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyloadapi.types import LoginResponse, StatusServerResponse +import pytest + +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.helpers.typing import ConfigType + + +@pytest.fixture +def pyload_config() -> ConfigType: + """Mock pyload configuration entry.""" + return { + "sensor": { + CONF_PLATFORM: "pyload", + CONF_HOST: "localhost", + CONF_PORT: 8000, + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_SSL: True, + CONF_MONITORED_VARIABLES: ["speed"], + CONF_NAME: "pyload", + } + } + + +@pytest.fixture +def mock_pyloadapi() -> Generator[AsyncMock, None, None]: + """Mock PyLoadAPI.""" + with ( + patch( + "homeassistant.components.pyload.sensor.PyLoadAPI", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + client.username = "username" + client.login.return_value = LoginResponse.from_dict( + { + "_permanent": True, + "authenticated": True, + "id": 2, + "name": "username", + "role": 0, + "perms": 0, + "template": "default", + "_flashes": [["message", "Logged in successfully"]], + } + ) + client.get_status.return_value = StatusServerResponse.from_dict( + { + "pause": False, + "active": 1, + "queue": 6, + "total": 37, + "speed": 5405963.0, + "download": True, + "reconnect": False, + "captcha": False, + } + ) + client.free_space.return_value = 99999999999 + yield client diff --git a/tests/components/pyload/snapshots/test_sensor.ambr b/tests/components/pyload/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..226221240d2 --- /dev/null +++ b/tests/components/pyload/snapshots/test_sensor.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'pyload Speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pyload_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.405963', + }) +# --- diff --git a/tests/components/pyload/test_sensor.py b/tests/components/pyload/test_sensor.py new file mode 100644 index 00000000000..e2b392b06f9 --- /dev/null +++ b/tests/components/pyload/test_sensor.py @@ -0,0 +1,123 @@ +"""Tests for the pyLoad Sensors.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.pyload.sensor import SCAN_INTERVAL +from homeassistant.components.sensor import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from tests.common import async_fire_time_changed + +SENSORS = ["sensor.pyload_speed"] + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_setup( + hass: HomeAssistant, + pyload_config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of the pyload sensor platform.""" + + assert await async_setup_component(hass, DOMAIN, pyload_config) + await hass.async_block_till_done() + + for sensor in SENSORS: + result = hass.states.get(sensor) + assert result == snapshot + + +@pytest.mark.parametrize( + ("exception", "expected_exception"), + [ + (CannotConnect, "Unable to connect and retrieve data from pyLoad API"), + (ParserError, "Unable to parse data from pyLoad API"), + ( + InvalidAuth, + "Authentication failed for username, check your login credentials", + ), + ], +) +async def test_setup_exceptions( + hass: HomeAssistant, + pyload_config: ConfigType, + mock_pyloadapi: AsyncMock, + exception: Exception, + expected_exception: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exceptions during setup up pyLoad platform.""" + + mock_pyloadapi.login.side_effect = exception + + assert await async_setup_component(hass, DOMAIN, pyload_config) + await hass.async_block_till_done() + + assert len(hass.states.async_all(DOMAIN)) == 0 + assert expected_exception in caplog.text + + +@pytest.mark.parametrize( + ("exception", "expected_exception"), + [ + (CannotConnect, "Unable to connect and retrieve data from pyLoad API"), + (ParserError, "Unable to parse data from pyLoad API"), + (InvalidAuth, "Authentication failed, trying to reauthenticate"), + ], +) +async def test_sensor_update_exceptions( + hass: HomeAssistant, + pyload_config: ConfigType, + mock_pyloadapi: AsyncMock, + exception: Exception, + expected_exception: str, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test exceptions during update of pyLoad sensor.""" + + mock_pyloadapi.get_status.side_effect = exception + + assert await async_setup_component(hass, DOMAIN, pyload_config) + await hass.async_block_till_done() + + assert len(hass.states.async_all(DOMAIN)) == 1 + assert expected_exception in caplog.text + + for sensor in SENSORS: + assert hass.states.get(sensor).state == STATE_UNAVAILABLE + + +async def test_sensor_invalid_auth( + hass: HomeAssistant, + pyload_config: ConfigType, + mock_pyloadapi: AsyncMock, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test invalid auth during sensor update.""" + + assert await async_setup_component(hass, DOMAIN, pyload_config) + await hass.async_block_till_done() + assert len(hass.states.async_all(DOMAIN)) == 1 + + mock_pyloadapi.get_status.side_effect = InvalidAuth + mock_pyloadapi.login.side_effect = InvalidAuth + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + "Authentication failed for username, check your login credentials" + in caplog.text + ) diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 463d69975b4..03fa73f076e 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -682,7 +682,7 @@ hass.states.set('hello.c', c) ], ) async def test_prohibited_augmented_assignment_operations( - hass: HomeAssistant, case: str, error: str, caplog + hass: HomeAssistant, case: str, error: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that prohibited augmented assignment operations raise an error.""" hass.async_add_executor_job(execute, hass, "aug_assign_prohibited.py", case, {}) diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py index 9a5ead35a05..b15e2a6865b 100644 --- a/tests/components/qbittorrent/conftest.py +++ b/tests/components/qbittorrent/conftest.py @@ -1,14 +1,14 @@ """Fixtures for testing qBittorrent component.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest import requests_mock +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock qbittorrent entry setup.""" with patch( "homeassistant.components.qbittorrent.async_setup_entry", return_value=True @@ -17,7 +17,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_api() -> Generator[requests_mock.Mocker, None, None]: +def mock_api() -> Generator[requests_mock.Mocker]: """Mock the qbittorrent API.""" with requests_mock.Mocker() as mocker: mocker.get("http://localhost:8080/api/v2/app/preferences", status_code=403) diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py index c52762f24d3..abf64713f50 100644 --- a/tests/components/qbittorrent/test_config_flow.py +++ b/tests/components/qbittorrent/test_config_flow.py @@ -59,8 +59,7 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> # Test flow with wrong creds, fail with invalid_auth with requests_mock.Mocker() as mock: - mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode") - mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403) + mock.head(USER_INPUT[CONF_URL]) mock.post( f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", text="Wrong username/password", @@ -74,11 +73,18 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> assert result["errors"] == {"base": "invalid_auth"} # Test flow with proper input, succeed - result = await hass.config_entries.flow.async_configure( - result["flow_id"], USER_INPUT - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY + with requests_mock.Mocker() as mock: + mock.head(USER_INPUT[CONF_URL]) + mock.post( + f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", + text="Ok.", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { CONF_URL: "http://localhost:8080", CONF_USERNAME: "user", diff --git a/tests/components/qingping/conftest.py b/tests/components/qingping/conftest.py index e74bf38b26d..21667684562 100644 --- a/tests/components/qingping/conftest.py +++ b/tests/components/qingping/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/qnap/conftest.py b/tests/components/qnap/conftest.py index 5c6d5eb65fc..c0947318f60 100644 --- a/tests/components/qnap/conftest.py +++ b/tests/components/qnap/conftest.py @@ -1,9 +1,9 @@ """Setup the QNAP tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator TEST_HOST = "1.2.3.4" TEST_USERNAME = "admin" @@ -15,7 +15,7 @@ TEST_SYSTEM_STATS = {"system": {"serial_number": TEST_SERIAL, "name": TEST_NAS_N @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.qnap.async_setup_entry", return_value=True @@ -24,7 +24,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def qnap_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None]: +def qnap_connect() -> Generator[MagicMock]: """Mock qnap connection.""" with patch( "homeassistant.components.qnap.config_flow.QNAPStats", autospec=True diff --git a/tests/components/qnap_qsw/test_binary_sensor.py b/tests/components/qnap_qsw/test_binary_sensor.py index 3540eb6ba4a..535ffdfb693 100644 --- a/tests/components/qnap_qsw/test_binary_sensor.py +++ b/tests/components/qnap_qsw/test_binary_sensor.py @@ -1,5 +1,7 @@ """The binary sensor tests for the QNAP QSW platform.""" +import pytest + from homeassistant.components.qnap_qsw.const import ATTR_MESSAGE from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -8,9 +10,9 @@ from homeassistant.helpers import entity_registry as er from .util import async_init_integration +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_qnap_qsw_create_binary_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, ) -> None: """Test creation of binary sensors.""" diff --git a/tests/components/qnap_qsw/test_sensor.py b/tests/components/qnap_qsw/test_sensor.py index 673a607acdf..646058add62 100644 --- a/tests/components/qnap_qsw/test_sensor.py +++ b/tests/components/qnap_qsw/test_sensor.py @@ -1,14 +1,16 @@ """The sensor tests for the QNAP QSW platform.""" +import pytest + from homeassistant.components.qnap_qsw.const import ATTR_MAX from homeassistant.core import HomeAssistant from .util import async_init_integration +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_qnap_qsw_create_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: None, ) -> None: """Test creation of sensors.""" diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py index 7ec411d6a48..2e0cfba38c0 100644 --- a/tests/components/rabbitair/test_config_flow.py +++ b/tests/components/rabbitair/test_config_flow.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Generator from ipaddress import ip_address -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from rabbitair import Mode, Model, Speed +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components import zeroconf @@ -38,12 +38,12 @@ ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( @pytest.fixture(autouse=True) -def use_mocked_zeroconf(mock_async_zeroconf): +def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Mock zeroconf in all tests.""" @pytest.fixture -def rabbitair_connect() -> Generator[None, None, None]: +def rabbitair_connect() -> Generator[None]: """Mock connection.""" with ( patch("rabbitair.UdpClient.get_info", return_value=get_mock_info()), diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py index e82760cadba..ecf8433a445 100644 --- a/tests/components/radarr/test_calendar.py +++ b/tests/components/radarr/test_calendar.py @@ -4,13 +4,12 @@ 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.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -21,8 +20,7 @@ async def test_calendar( ) -> 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"] + await setup_integration(hass, aioclient_mock) state = hass.states.get("calendar.mock_title") assert state.state == STATE_ON @@ -33,8 +31,9 @@ async def test_calendar( 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() + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() state = hass.states.get("calendar.mock_title") assert state.state == STATE_OFF diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index 10ff196bf17..5401b42759c 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -49,11 +49,12 @@ async def test_async_setup_entry_auth_failed( @pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test device info.""" entry = await setup_integration(hass, aioclient_mock) - device_registry = dr.async_get(hass) await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index b75034acc8f..563ac504057 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,5 +1,9 @@ """The tests for Radarr sensor platform.""" +from datetime import timedelta +from unittest.mock import patch + +from aiopyarr.exceptions import ArrConnectionException import pytest from homeassistant.components.sensor import ( @@ -7,11 +11,18 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util from . import setup_integration +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -41,10 +52,10 @@ from tests.test_util.aiohttp import AiohttpClientMocker ), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: None, windows: bool, single: bool, root_folder: str, @@ -76,3 +87,28 @@ async def test_windows( state = hass.states.get("sensor.mock_title_disk_space_tv") assert state.state == "263.10" + + +async def test_update_failed( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test coordinator updates handle failures.""" + entry = await setup_integration(hass, aioclient_mock) + assert entry.state is ConfigEntryState.LOADED + entity = "sensor.mock_title_disk_space_downloads" + assert hass.states.get(entity).state == "263.10" + + with patch( + "homeassistant.components.radarr.RadarrClient._async_request", + side_effect=ArrConnectionException, + ) as updater: + next_update = dt_util.utcnow() + timedelta(minutes=1) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + assert updater.call_count == 2 + assert hass.states.get(entity).state == STATE_UNAVAILABLE + + next_update = dt_util.utcnow() + timedelta(minutes=1) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + assert hass.states.get(entity).state == "263.10" diff --git a/tests/components/radio_browser/conftest.py b/tests/components/radio_browser/conftest.py index fa732912dc0..95fda545a6c 100644 --- a/tests/components/radio_browser/conftest.py +++ b/tests/components/radio_browser/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.radio_browser.const import DOMAIN @@ -23,7 +23,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.radio_browser.async_setup_entry", return_value=True diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 59471f5eed4..a2c26c71231 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator from http import HTTPStatus import json from typing import Any @@ -10,6 +9,7 @@ from unittest.mock import patch from pyrainbird import encryption import pytest +from typing_extensions import Generator from homeassistant.components.rainbird import DOMAIN from homeassistant.components.rainbird.const import ( @@ -157,7 +157,7 @@ def setup_platforms( @pytest.fixture(autouse=True) -def aioclient_mock(hass: HomeAssistant) -> Generator[AiohttpClientMocker, None, None]: +def aioclient_mock(hass: HomeAssistant) -> Generator[AiohttpClientMocker]: """Context manager to mock aiohttp client.""" mocker = AiohttpClientMocker() diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 1af6ca7ba7f..3f5776c7b37 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -7,7 +7,6 @@ from typing import Any import urllib from zoneinfo import ZoneInfo -from aiohttp import ClientSession from freezegun.api import FrozenDateTimeFactory import pytest @@ -20,9 +19,10 @@ from .conftest import CONFIG_ENTRY_DATA_OLD_FORMAT, mock_response, mock_response from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMockResponse +from tests.typing import ClientSessionGenerator TEST_ENTITY = "calendar.rain_bird_controller" -GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] +type GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] SCHEDULE_RESPONSES = [ # Current controller status @@ -91,9 +91,9 @@ async def setup_config_entry( @pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant): +async def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" - hass.config.set_time_zone("America/Regina") + await hass.config.async_set_time_zone("America/Regina") @pytest.fixture(autouse=True) @@ -114,7 +114,7 @@ def mock_insert_schedule_response( @pytest.fixture(name="get_events") def get_events_fixture( - hass_client: Callable[..., Awaitable[ClientSession]], + hass_client: ClientSessionGenerator, ) -> GetEventsFn: """Fetch calendar events from the HTTP API.""" @@ -237,7 +237,7 @@ async def test_no_schedule( hass: HomeAssistant, get_events: GetEventsFn, responses: list[AiohttpClientMockResponse], - hass_client: Callable[..., Awaitable[ClientSession]], + hass_client: ClientSessionGenerator, ) -> None: """Test calendar error when fetching the calendar.""" responses.extend([mock_response_error(HTTPStatus.BAD_GATEWAY)]) # Arbitrary error diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index b4cd51d6b3e..cdcef95f458 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -1,11 +1,11 @@ """Tests for the Rain Bird config flow.""" -from collections.abc import Generator from http import HTTPStatus from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import AsyncGenerator from homeassistant import config_entries from homeassistant.components.rainbird import DOMAIN @@ -46,7 +46,7 @@ async def config_entry_data() -> None: @pytest.fixture(autouse=True) -async def mock_setup() -> Generator[Mock, None, None]: +async def mock_setup() -> AsyncGenerator[AsyncMock]: """Fixture for patching out integration setup.""" with patch( diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index b3a1860baab..2515fc071d2 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -71,6 +71,7 @@ async def test_number_values( async def test_set_value( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, responses: list[str], ) -> None: @@ -79,7 +80,6 @@ async def test_set_value( raindelay = hass.states.get("number.rain_bird_controller_rain_delay") assert raindelay is not None - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DOMAIN, MAC_ADDRESS.lower())} ) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 1352a4a633d..c2f8fa29ca3 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -270,13 +270,11 @@ async def test_switch_error( with pytest.raises(HomeAssistantError, match=expected_msg): await switch_common.async_turn_on(hass, "switch.rain_bird_sprinkler_3") - await hass.async_block_till_done() responses.append(mock_response_error(status=status)) with pytest.raises(HomeAssistantError, match=expected_msg): await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") - await hass.async_block_till_done() @pytest.mark.parametrize( diff --git a/tests/components/rainforest_eagle/conftest.py b/tests/components/rainforest_eagle/conftest.py index 9ea607b1db4..1aff693e61f 100644 --- a/tests/components/rainforest_eagle/conftest.py +++ b/tests/components/rainforest_eagle/conftest.py @@ -66,7 +66,7 @@ async def setup_rainforest_100(hass): }, ).add_to_hass(hass) with patch( - "homeassistant.components.rainforest_eagle.data.Eagle100Reader", + "homeassistant.components.rainforest_eagle.coordinator.Eagle100Reader", return_value=Mock( get_instantaneous_demand=Mock( return_value={"InstantaneousDemand": {"Demand": "1.152000"}} diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py index d3df44fb4fe..0d3b477b3d5 100644 --- a/tests/components/rainforest_eagle/test_config_flow.py +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -27,7 +27,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.rainforest_eagle.data.async_get_type", + "homeassistant.components.rainforest_eagle.config_flow.async_get_type", return_value=(TYPE_EAGLE_200, "mock-hw"), ), patch( diff --git a/tests/components/rainforest_raven/__init__.py b/tests/components/rainforest_raven/__init__.py index 0269e4cf0f4..9d40652b42d 100644 --- a/tests/components/rainforest_raven/__init__.py +++ b/tests/components/rainforest_raven/__init__.py @@ -17,7 +17,7 @@ from .const import ( from tests.common import AsyncMock, MockConfigEntry -def create_mock_device(): +def create_mock_device() -> AsyncMock: """Create a mock instance of RAVEnStreamDevice.""" device = AsyncMock() @@ -27,13 +27,14 @@ def create_mock_device(): device.get_device_info.return_value = DEVICE_INFO device.get_instantaneous_demand.return_value = DEMAND device.get_meter_list.return_value = METER_LIST + # pylint: disable-next=unnecessary-lambda device.get_meter_info.side_effect = lambda meter: METER_INFO.get(meter) device.get_network_info.return_value = NETWORK_INFO return device -def create_mock_entry(no_meters=False): +def create_mock_entry(no_meters: bool = False) -> MockConfigEntry: """Create a mock config entry for a RAVEn device.""" return MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/rainforest_raven/conftest.py b/tests/components/rainforest_raven/conftest.py new file mode 100644 index 00000000000..0a809c6430a --- /dev/null +++ b/tests/components/rainforest_raven/conftest.py @@ -0,0 +1,33 @@ +"""Fixtures for the Rainforest RAVEn tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from typing_extensions import Generator + +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_device() -> Generator[AsyncMock]: + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device: AsyncMock) -> MockConfigEntry: + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry diff --git a/tests/components/rainforest_raven/test_config_flow.py b/tests/components/rainforest_raven/test_config_flow.py index d86dee6e0f6..7f7041cbcd8 100644 --- a/tests/components/rainforest_raven/test_config_flow.py +++ b/tests/components/rainforest_raven/test_config_flow.py @@ -1,10 +1,11 @@ """Test Rainforest RAVEn config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aioraven.device import RAVEnConnectionError import pytest -import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +from typing_extensions import Generator from homeassistant.components.rainforest_raven.const import DOMAIN from homeassistant.config_entries import SOURCE_USB, SOURCE_USER @@ -19,7 +20,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_device(): +def mock_device() -> Generator[AsyncMock]: """Mock a functioning RAVEn device.""" device = create_mock_device() with patch( @@ -30,7 +31,7 @@ def mock_device(): @pytest.fixture -def mock_device_no_open(mock_device): +def mock_device_no_open(mock_device: AsyncMock) -> AsyncMock: """Mock a device which fails to open.""" mock_device.__aenter__.side_effect = RAVEnConnectionError mock_device.open.side_effect = RAVEnConnectionError @@ -38,7 +39,7 @@ def mock_device_no_open(mock_device): @pytest.fixture -def mock_device_comm_error(mock_device): +def mock_device_comm_error(mock_device: AsyncMock) -> AsyncMock: """Mock a device which fails to read or parse raw data.""" mock_device.get_meter_list.side_effect = RAVEnConnectionError mock_device.get_meter_info.side_effect = RAVEnConnectionError @@ -46,7 +47,7 @@ def mock_device_comm_error(mock_device): @pytest.fixture -def mock_device_timeout(mock_device): +def mock_device_timeout(mock_device: AsyncMock) -> AsyncMock: """Mock a device which times out when queried.""" mock_device.get_meter_list.side_effect = TimeoutError mock_device.get_meter_info.side_effect = TimeoutError @@ -54,9 +55,9 @@ def mock_device_timeout(mock_device): @pytest.fixture -def mock_comports(): +def mock_comports() -> Generator[list[ListPortInfo]]: """Mock serial port list.""" - port = serial.tools.list_ports_common.ListPortInfo(DISCOVERY_INFO.device) + port = ListPortInfo(DISCOVERY_INFO.device) port.serial_number = DISCOVERY_INFO.serial_number port.manufacturer = DISCOVERY_INFO.manufacturer port.device = DISCOVERY_INFO.device @@ -68,7 +69,8 @@ def mock_comports(): yield comports -async def test_flow_usb(hass: HomeAssistant, mock_comports, mock_device): +@pytest.mark.usefixtures("mock_comports", "mock_device") +async def test_flow_usb(hass: HomeAssistant) -> None: """Test usb flow connection.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO @@ -86,9 +88,8 @@ async def test_flow_usb(hass: HomeAssistant, mock_comports, mock_device): assert result.get("type") is FlowResultType.CREATE_ENTRY -async def test_flow_usb_cannot_connect( - hass: HomeAssistant, mock_comports, mock_device_no_open -): +@pytest.mark.usefixtures("mock_comports", "mock_device_no_open") +async def test_flow_usb_cannot_connect(hass: HomeAssistant) -> None: """Test usb flow connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO @@ -98,9 +99,8 @@ async def test_flow_usb_cannot_connect( assert result.get("reason") == "cannot_connect" -async def test_flow_usb_timeout_connect( - hass: HomeAssistant, mock_comports, mock_device_timeout -): +@pytest.mark.usefixtures("mock_comports", "mock_device_timeout") +async def test_flow_usb_timeout_connect(hass: HomeAssistant) -> None: """Test usb flow connection timeout.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO @@ -110,9 +110,8 @@ async def test_flow_usb_timeout_connect( assert result.get("reason") == "timeout_connect" -async def test_flow_usb_comm_error( - hass: HomeAssistant, mock_comports, mock_device_comm_error -): +@pytest.mark.usefixtures("mock_comports", "mock_device_comm_error") +async def test_flow_usb_comm_error(hass: HomeAssistant) -> None: """Test usb flow connection failure to communicate.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO @@ -122,7 +121,8 @@ async def test_flow_usb_comm_error( assert result.get("reason") == "cannot_connect" -async def test_flow_user(hass: HomeAssistant, mock_comports, mock_device): +@pytest.mark.usefixtures("mock_comports", "mock_device") +async def test_flow_user(hass: HomeAssistant) -> None: """Test user flow connection.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -153,7 +153,8 @@ async def test_flow_user(hass: HomeAssistant, mock_comports, mock_device): assert result.get("type") is FlowResultType.CREATE_ENTRY -async def test_flow_user_no_available_devices(hass: HomeAssistant, mock_comports): +@pytest.mark.usefixtures("mock_comports") +async def test_flow_user_no_available_devices(hass: HomeAssistant) -> None: """Test user flow with no available devices.""" entry = MockConfigEntry( domain=DOMAIN, @@ -169,7 +170,8 @@ async def test_flow_user_no_available_devices(hass: HomeAssistant, mock_comports assert result.get("reason") == "no_devices_found" -async def test_flow_user_in_progress(hass: HomeAssistant, mock_comports): +@pytest.mark.usefixtures("mock_comports") +async def test_flow_user_in_progress(hass: HomeAssistant) -> None: """Test user flow with no available devices.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -190,9 +192,8 @@ async def test_flow_user_in_progress(hass: HomeAssistant, mock_comports): assert result.get("reason") == "already_in_progress" -async def test_flow_user_cannot_connect( - hass: HomeAssistant, mock_comports, mock_device_no_open -): +@pytest.mark.usefixtures("mock_comports", "mock_device_no_open") +async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: """Test user flow connection failure to communicate.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -206,9 +207,8 @@ async def test_flow_user_cannot_connect( assert result.get("errors") == {CONF_DEVICE: "cannot_connect"} -async def test_flow_user_timeout_connect( - hass: HomeAssistant, mock_comports, mock_device_timeout -): +@pytest.mark.usefixtures("mock_comports", "mock_device_timeout") +async def test_flow_user_timeout_connect(hass: HomeAssistant) -> None: """Test user flow connection failure to communicate.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -222,9 +222,8 @@ async def test_flow_user_timeout_connect( assert result.get("errors") == {CONF_DEVICE: "timeout_connect"} -async def test_flow_user_comm_error( - hass: HomeAssistant, mock_comports, mock_device_comm_error -): +@pytest.mark.usefixtures("mock_comports", "mock_device_comm_error") +async def test_flow_user_comm_error(hass: HomeAssistant) -> None: """Test user flow connection failure to communicate.""" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/rainforest_raven/test_coordinator.py b/tests/components/rainforest_raven/test_coordinator.py index 1a5f4d3d3f7..db70118f7b9 100644 --- a/tests/components/rainforest_raven/test_coordinator.py +++ b/tests/components/rainforest_raven/test_coordinator.py @@ -2,6 +2,7 @@ import asyncio import functools +from unittest.mock import AsyncMock from aioraven.device import RAVEnConnectionError import pytest @@ -10,23 +11,11 @@ from homeassistant.components.rainforest_raven.coordinator import RAVEnDataCoord from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from . import create_mock_device, create_mock_entry - -from tests.common import patch +from . import create_mock_entry -@pytest.fixture -def mock_device(): - """Mock a functioning RAVEn device.""" - mock_device = create_mock_device() - with patch( - "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", - return_value=mock_device, - ): - yield mock_device - - -async def test_coordinator_device_info(hass: HomeAssistant, mock_device): +@pytest.mark.usefixtures("mock_device") +async def test_coordinator_device_info(hass: HomeAssistant) -> None: """Test reporting device information from the coordinator.""" entry = create_mock_entry() coordinator = RAVEnDataCoordinator(hass, entry) @@ -50,7 +39,9 @@ async def test_coordinator_device_info(hass: HomeAssistant, mock_device): assert coordinator.device_name == "RAVEn Device" -async def test_coordinator_cache_device(hass: HomeAssistant, mock_device): +async def test_coordinator_cache_device( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: """Test that the device isn't re-opened for subsequent refreshes.""" entry = create_mock_entry() coordinator = RAVEnDataCoordinator(hass, entry) @@ -64,7 +55,9 @@ async def test_coordinator_cache_device(hass: HomeAssistant, mock_device): assert mock_device.open.call_count == 1 -async def test_coordinator_device_error_setup(hass: HomeAssistant, mock_device): +async def test_coordinator_device_error_setup( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: """Test handling of a device error during initialization.""" entry = create_mock_entry() coordinator = RAVEnDataCoordinator(hass, entry) @@ -74,7 +67,9 @@ async def test_coordinator_device_error_setup(hass: HomeAssistant, mock_device): await coordinator.async_config_entry_first_refresh() -async def test_coordinator_device_error_update(hass: HomeAssistant, mock_device): +async def test_coordinator_device_error_update( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: """Test handling of a device error during an update.""" entry = create_mock_entry() coordinator = RAVEnDataCoordinator(hass, entry) @@ -87,7 +82,9 @@ async def test_coordinator_device_error_update(hass: HomeAssistant, mock_device) assert coordinator.last_update_success is False -async def test_coordinator_device_timeout_update(hass: HomeAssistant, mock_device): +async def test_coordinator_device_timeout_update( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: """Test handling of a device timeout during an update.""" entry = create_mock_entry() coordinator = RAVEnDataCoordinator(hass, entry) @@ -100,7 +97,9 @@ async def test_coordinator_device_timeout_update(hass: HomeAssistant, mock_devic assert coordinator.last_update_success is False -async def test_coordinator_comm_error(hass: HomeAssistant, mock_device): +async def test_coordinator_comm_error( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: """Test handling of an error parsing or reading raw device data.""" entry = create_mock_entry() coordinator = RAVEnDataCoordinator(hass, entry) diff --git a/tests/components/rainforest_raven/test_diagnostics.py b/tests/components/rainforest_raven/test_diagnostics.py index fe01dc1d0f9..86a86032ac6 100644 --- a/tests/components/rainforest_raven/test_diagnostics.py +++ b/tests/components/rainforest_raven/test_diagnostics.py @@ -8,32 +8,11 @@ from homeassistant.components.diagnostics import REDACTED from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant -from . import create_mock_device, create_mock_entry +from . import create_mock_entry from .const import DEMAND, NETWORK_INFO, PRICE_CLUSTER, SUMMATION -from tests.common import patch from tests.components.diagnostics import get_diagnostics_for_config_entry - - -@pytest.fixture -def mock_device(): - """Mock a functioning RAVEn device.""" - mock_device = create_mock_device() - with patch( - "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", - return_value=mock_device, - ): - yield mock_device - - -@pytest.fixture -async def mock_entry(hass: HomeAssistant, mock_device): - """Mock a functioning RAVEn config entry.""" - mock_entry = create_mock_entry() - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - return mock_entry +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -47,8 +26,11 @@ async def mock_entry_no_meters(hass: HomeAssistant, mock_device): async def test_entry_diagnostics_no_meters( - hass, hass_client, mock_device, mock_entry_no_meters -): + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_device, + mock_entry_no_meters, +) -> None: """Test RAVEn diagnostics before the coordinator has updated.""" result = await get_diagnostics_for_config_entry( hass, hass_client, mock_entry_no_meters @@ -66,7 +48,9 @@ async def test_entry_diagnostics_no_meters( } -async def test_entry_diagnostics(hass, hass_client, mock_device, mock_entry): +async def test_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_device, mock_entry +) -> None: """Test RAVEn diagnostics.""" result = await get_diagnostics_for_config_entry(hass, hass_client, mock_entry) diff --git a/tests/components/rainforest_raven/test_init.py b/tests/components/rainforest_raven/test_init.py index 1cc50971e09..974c45150a6 100644 --- a/tests/components/rainforest_raven/test_init.py +++ b/tests/components/rainforest_raven/test_init.py @@ -1,38 +1,15 @@ """Tests for the Rainforest RAVEn component initialisation.""" -import pytest - from homeassistant.components.rainforest_raven.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import create_mock_device, create_mock_entry - -from tests.common import patch +from tests.common import MockConfigEntry -@pytest.fixture -def mock_device(): - """Mock a functioning RAVEn device.""" - mock_device = create_mock_device() - with patch( - "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", - return_value=mock_device, - ): - yield mock_device - - -@pytest.fixture -async def mock_entry(hass: HomeAssistant, mock_device): - """Mock a functioning RAVEn config entry.""" - mock_entry = create_mock_entry() - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - return mock_entry - - -async def test_load_unload_entry(hass: HomeAssistant, mock_entry): +async def test_load_unload_entry( + hass: HomeAssistant, mock_entry: MockConfigEntry +) -> None: """Test load and unload.""" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert mock_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/rainforest_raven/test_sensor.py b/tests/components/rainforest_raven/test_sensor.py index 36e6572149f..3b859621cb4 100644 --- a/tests/components/rainforest_raven/test_sensor.py +++ b/tests/components/rainforest_raven/test_sensor.py @@ -4,33 +4,9 @@ import pytest from homeassistant.core import HomeAssistant -from . import create_mock_device, create_mock_entry -from tests.common import patch - - -@pytest.fixture -def mock_device(): - """Mock a functioning RAVEn device.""" - mock_device = create_mock_device() - with patch( - "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", - return_value=mock_device, - ): - yield mock_device - - -@pytest.fixture -async def mock_entry(hass: HomeAssistant, mock_device): - """Mock a functioning RAVEn config entry.""" - mock_entry = create_mock_entry() - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - return mock_entry - - -async def test_sensors(hass: HomeAssistant, mock_device, mock_entry): +@pytest.mark.usefixtures("mock_entry") +async def test_sensors(hass: HomeAssistant) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 5 diff --git a/tests/components/rainmachine/conftest.py b/tests/components/rainmachine/conftest.py index 9b0f8f0442a..717d74b421b 100644 --- a/tests/components/rainmachine/conftest.py +++ b/tests/components/rainmachine/conftest.py @@ -1,6 +1,7 @@ """Define test fixtures for RainMachine.""" import json +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -32,7 +33,12 @@ def config_fixture(hass): @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config, controller_mac): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=controller_mac, data=config) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=controller_mac, + data=config, + entry_id="81bd010ed0a63b705f6da8407cb26d4b", + ) entry.add_to_hass(hass) return entry @@ -100,7 +106,9 @@ def data_machine_firmare_update_status_fixture(): @pytest.fixture(name="data_programs", scope="package") def data_programs_fixture(): """Define program data.""" - return json.loads(load_fixture("programs_data.json", "rainmachine")) + raw_data = json.loads(load_fixture("programs_data.json", "rainmachine")) + # This replicate the process from `regenmaschine` to convert list to dict + return {program["uid"]: program for program in raw_data} @pytest.fixture(name="data_provision_settings", scope="package") @@ -124,7 +132,16 @@ def data_restrictions_universal_fixture(): @pytest.fixture(name="data_zones", scope="package") def data_zones_fixture(): """Define zone data.""" - return json.loads(load_fixture("zones_data.json", "rainmachine")) + raw_data = json.loads(load_fixture("zones_data.json", "rainmachine")) + # This replicate the process from `regenmaschine` to convert list to dict + zone_details = json.loads(load_fixture("zones_details.json", "rainmachine")) + + zones: dict[int, dict[str, Any]] = {} + for zone in raw_data: + [extra] = [z for z in zone_details if z["uid"] == zone["uid"]] + zones[zone["uid"]] = {**zone, **extra} + + return zones @pytest.fixture(name="setup_rainmachine") diff --git a/tests/components/rainmachine/fixtures/zones_details.json b/tests/components/rainmachine/fixtures/zones_details.json new file mode 100644 index 00000000000..cb5fec45879 --- /dev/null +++ b/tests/components/rainmachine/fixtures/zones_details.json @@ -0,0 +1,482 @@ +[ + { + "uid": 1, + "name": "Landscaping", + "valveid": 1, + "ETcoef": 0.80000000000000004, + "active": true, + "type": 4, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 5, + "group_id": 4, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.17000000000000001, + "rootDepth": 229, + "minRuntime": 0, + "appEfficiency": 0.75, + "isTallPlant": true, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 8.3800000000000008, + "maxAllowedDepletion": 0.5, + "precipitationRate": 25.399999999999999, + "currentFieldCapacity": 16.030000000000001, + "area": 92.900001525878906, + "referenceTime": 1243, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": null, + "soilIntakeRate": 10.16 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 2, + "name": "Flower Box", + "valveid": 2, + "ETcoef": 0.80000000000000004, + "active": true, + "type": 5, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 5, + "group_id": 3, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.17000000000000001, + "rootDepth": 457, + "minRuntime": 5, + "appEfficiency": 0.80000000000000004, + "isTallPlant": true, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 8.3800000000000008, + "maxAllowedDepletion": 0.34999999999999998, + "precipitationRate": 12.699999999999999, + "currentFieldCapacity": 22.390000000000001, + "area": 92.900000000000006, + "referenceTime": 2680, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": null, + "soilIntakeRate": 10.16 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 3, + "name": "TEST", + "valveid": 3, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 9, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 700, + "minRuntime": 0, + "appEfficiency": 0.69999999999999996, + "isTallPlant": true, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.59999999999999998, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 113.40000000000001, + "area": 92.900000000000006, + "referenceTime": 380, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": null, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 4, + "name": "Zone 4", + "valveid": 4, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 5, + "name": "Zone 5", + "valveid": 5, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 6, + "name": "Zone 6", + "valveid": 6, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 7, + "name": "Zone 7", + "valveid": 7, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 8, + "name": "Zone 8", + "valveid": 8, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 9, + "name": "Zone 9", + "valveid": 9, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 10, + "name": "Zone 10", + "valveid": 10, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 11, + "name": "Zone 11", + "valveid": 11, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + }, + { + "uid": 12, + "name": "Zone 12", + "valveid": 12, + "ETcoef": 0.80000000000000004, + "active": false, + "type": 2, + "internet": true, + "savings": 100, + "slope": 1, + "sun": 1, + "soil": 1, + "group_id": 1, + "history": true, + "master": false, + "before": 0, + "after": 0, + "waterSense": { + "fieldCapacity": 0.29999999999999999, + "rootDepth": 203, + "minRuntime": -1, + "appEfficiency": 0.69999999999999996, + "isTallPlant": false, + "permWilting": 0.029999999999999999, + "allowedSurfaceAcc": 6.5999999999999996, + "maxAllowedDepletion": 0.40000000000000002, + "precipitationRate": 35.560000000000002, + "currentFieldCapacity": 21.920000000000002, + "area": 92.900000000000006, + "referenceTime": 761, + "detailedMonthsKc": [ + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0 + ], + "flowrate": 0.0, + "soilIntakeRate": 5.0800000000000001 + }, + "customSoilPreset": null, + "customVegetationPreset": null, + "customSprinklerPreset": null + } +] diff --git a/tests/components/rainmachine/snapshots/test_binary_sensor.ambr b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..9c930736fe3 --- /dev/null +++ b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.12345_freeze_restrictions-entry] + 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.12345_freeze_restrictions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Freeze restrictions', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freeze', + 'unique_id': 'aa:bb:cc:dd:ee:ff_freeze', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_freeze_restrictions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Freeze restrictions', + }), + 'context': , + 'entity_id': 'binary_sensor.12345_freeze_restrictions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_hourly_restrictions-entry] + 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.12345_hourly_restrictions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hourly restrictions', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hourly', + 'unique_id': 'aa:bb:cc:dd:ee:ff_hourly', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_hourly_restrictions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Hourly restrictions', + }), + 'context': , + 'entity_id': 'binary_sensor.12345_hourly_restrictions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_month_restrictions-entry] + 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.12345_month_restrictions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Month restrictions', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'month', + 'unique_id': 'aa:bb:cc:dd:ee:ff_month', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_month_restrictions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Month restrictions', + }), + 'context': , + 'entity_id': 'binary_sensor.12345_month_restrictions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_rain_delay_restrictions-entry] + 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.12345_rain_delay_restrictions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rain delay restrictions', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raindelay', + 'unique_id': 'aa:bb:cc:dd:ee:ff_raindelay', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_rain_delay_restrictions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Rain delay restrictions', + }), + 'context': , + 'entity_id': 'binary_sensor.12345_rain_delay_restrictions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_rain_sensor_restrictions-entry] + 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.12345_rain_sensor_restrictions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rain sensor restrictions', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rainsensor', + 'unique_id': 'aa:bb:cc:dd:ee:ff_rainsensor', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_rain_sensor_restrictions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Rain sensor restrictions', + }), + 'context': , + 'entity_id': 'binary_sensor.12345_rain_sensor_restrictions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_weekday_restrictions-entry] + 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.12345_weekday_restrictions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Weekday restrictions', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weekday', + 'unique_id': 'aa:bb:cc:dd:ee:ff_weekday', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.12345_weekday_restrictions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Weekday restrictions', + }), + 'context': , + 'entity_id': 'binary_sensor.12345_weekday_restrictions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/rainmachine/snapshots/test_button.ambr b/tests/components/rainmachine/snapshots/test_button.ambr new file mode 100644 index 00000000000..609079bb0d8 --- /dev/null +++ b/tests/components/rainmachine/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_buttons[button.12345_restart-entry] + 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.12345_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[button.12345_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': '12345 Restart', + }), + 'context': , + 'entity_id': 'button.12345_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/rainmachine/snapshots/test_diagnostics.ambr b/tests/components/rainmachine/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..9b5b5edc0c4 --- /dev/null +++ b/tests/components/rainmachine/snapshots/test_diagnostics.ambr @@ -0,0 +1,2279 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'controller_diagnostics': dict({ + 'bootCompleted': True, + 'cloudStatus': 0, + 'cpuUsage': 1, + 'gatewayAddress': '172.16.20.1', + 'hasWifi': True, + 'internetStatus': True, + 'lastCheck': '2022-08-07 11:59:35', + 'lastCheckTimestamp': 1659895175, + 'locationStatus': True, + 'memUsage': 16196, + 'networkStatus': True, + 'softwareVersion': '4.0.1144', + 'standaloneMode': False, + 'timeStatus': True, + 'uptime': '3 days, 18:14:14', + 'uptimeSeconds': 324854, + 'weatherStatus': True, + 'wifiMode': None, + 'wizardHasRun': True, + }), + 'coordinator': dict({ + 'api.versions': dict({ + 'apiVer': '4.6.1', + 'hwVer': '3', + 'swVer': '4.0.1144', + }), + 'machine.firmware_update_status': dict({ + 'lastUpdateCheck': '2022-07-14 13:01:28', + 'lastUpdateCheckTimestamp': 1657825288, + 'packageDetails': list([ + ]), + 'update': False, + 'updateStatus': 1, + }), + 'programs': dict({ + '1': dict({ + 'active': True, + 'coef': 0, + 'cs_on': False, + 'cycles': 0, + 'delay': 0, + 'delay_on': False, + 'endDate': None, + 'freq_modified': 0, + 'frequency': dict({ + 'param': '0', + 'type': 0, + }), + 'futureField1': 0, + 'ignoreInternetWeather': False, + 'name': 'Morning', + 'nextRun': '2018-06-04', + 'simulationExpired': False, + 'soak': 0, + 'startDate': '2018-04-28', + 'startTime': '06:00', + 'startTimeParams': dict({ + 'offsetMinutes': 0, + 'offsetSign': 0, + 'type': 0, + }), + 'status': 0, + 'uid': 1, + 'useWaterSense': False, + 'wateringTimes': list([ + dict({ + 'active': True, + 'duration': 0, + 'id': 1, + 'minRuntimeCoef': 1, + 'name': 'Landscaping', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': True, + 'duration': 0, + 'id': 2, + 'minRuntimeCoef': 1, + 'name': 'Flower Box', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 3, + 'minRuntimeCoef': 1, + 'name': 'TEST', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 4, + 'minRuntimeCoef': 1, + 'name': 'Zone 4', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 5, + 'minRuntimeCoef': 1, + 'name': 'Zone 5', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 6, + 'minRuntimeCoef': 1, + 'name': 'Zone 6', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 7, + 'minRuntimeCoef': 1, + 'name': 'Zone 7', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 8, + 'minRuntimeCoef': 1, + 'name': 'Zone 8', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 9, + 'minRuntimeCoef': 1, + 'name': 'Zone 9', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 10, + 'minRuntimeCoef': 1, + 'name': 'Zone 10', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 11, + 'minRuntimeCoef': 1, + 'name': 'Zone 11', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 12, + 'minRuntimeCoef': 1, + 'name': 'Zone 12', + 'order': -1, + 'userPercentage': 1, + }), + ]), + 'yearlyRecurring': True, + }), + '2': dict({ + 'active': False, + 'coef': 0, + 'cs_on': False, + 'cycles': 0, + 'delay': 0, + 'delay_on': False, + 'endDate': None, + 'freq_modified': 0, + 'frequency': dict({ + 'param': '0', + 'type': 0, + }), + 'futureField1': 0, + 'ignoreInternetWeather': False, + 'name': 'Evening', + 'nextRun': '2018-06-04', + 'simulationExpired': False, + 'soak': 0, + 'startDate': '2018-04-28', + 'startTime': '06:00', + 'startTimeParams': dict({ + 'offsetMinutes': 0, + 'offsetSign': 0, + 'type': 0, + }), + 'status': 0, + 'uid': 2, + 'useWaterSense': False, + 'wateringTimes': list([ + dict({ + 'active': True, + 'duration': 0, + 'id': 1, + 'minRuntimeCoef': 1, + 'name': 'Landscaping', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': True, + 'duration': 0, + 'id': 2, + 'minRuntimeCoef': 1, + 'name': 'Flower Box', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 3, + 'minRuntimeCoef': 1, + 'name': 'TEST', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 4, + 'minRuntimeCoef': 1, + 'name': 'Zone 4', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 5, + 'minRuntimeCoef': 1, + 'name': 'Zone 5', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 6, + 'minRuntimeCoef': 1, + 'name': 'Zone 6', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 7, + 'minRuntimeCoef': 1, + 'name': 'Zone 7', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 8, + 'minRuntimeCoef': 1, + 'name': 'Zone 8', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 9, + 'minRuntimeCoef': 1, + 'name': 'Zone 9', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 10, + 'minRuntimeCoef': 1, + 'name': 'Zone 10', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 11, + 'minRuntimeCoef': 1, + 'name': 'Zone 11', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 12, + 'minRuntimeCoef': 1, + 'name': 'Zone 12', + 'order': -1, + 'userPercentage': 1, + }), + ]), + 'yearlyRecurring': True, + }), + }), + 'provision.settings': dict({ + 'location': dict({ + 'address': 'Default', + 'doyDownloaded': True, + 'elevation': '**REDACTED**', + 'et0Average': 6.578, + 'krs': 0.16, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'Home', + 'rainSensitivity': 0.8, + 'state': 'Default', + 'stationDownloaded': True, + 'stationID': '**REDACTED**', + 'stationName': '**REDACTED**', + 'stationSource': '**REDACTED**', + 'timezone': '**REDACTED**', + 'windSensitivity': 0.5, + 'wsDays': 2, + 'zip': None, + }), + 'system': dict({ + 'allowAlexaDiscovery': False, + 'automaticUpdates': True, + 'databasePath': '/rainmachine-app/DB/Default', + 'defaultZoneWateringDuration': 300, + 'hardwareVersion': 3, + 'httpEnabled': True, + 'localValveCount': 12, + 'masterValveAfter': 0, + 'masterValveBefore': 0, + 'maxLEDBrightness': 40, + 'maxWateringCoef': 2, + 'minLEDBrightness': 0, + 'minWateringDurationThreshold': 0, + 'mixerHistorySize': 365, + 'netName': 'Home', + 'parserDataSizeInDays': 6, + 'parserHistorySize': 365, + 'programListShowInactive': True, + 'programSingleSchedule': False, + 'programZonesShowInactive': False, + 'rainSensorIsNormallyClosed': True, + 'rainSensorRainStart': None, + 'rainSensorSnoozeDuration': 0, + 'runParsersBeforePrograms': True, + 'selfTest': False, + 'showRestrictionsOnLed': False, + 'simulatorHistorySize': 0, + 'softwareRainSensorMinQPF': 5, + 'standaloneMode': False, + 'touchAdvanced': False, + 'touchAuthAPSeconds': 60, + 'touchCyclePrograms': True, + 'touchLongPressTimeout': 3, + 'touchProgramToRun': None, + 'touchSleepTimeout': 10, + 'uiUnitsMetric': False, + 'useBonjourService': True, + 'useCommandLineArguments': False, + 'useCorrectionForPast': True, + 'useMasterValve': False, + 'useRainSensor': False, + 'useSoftwareRainSensor': False, + 'vibration': False, + 'waterLogHistorySize': 365, + 'wizardHasRun': True, + 'zoneDuration': list([ + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + ]), + 'zoneListShowInactive': True, + }), + }), + 'restrictions.current': dict({ + 'freeze': False, + 'hourly': False, + 'month': False, + 'rainDelay': False, + 'rainDelayCounter': -1, + 'rainSensor': False, + 'weekDay': False, + }), + 'restrictions.universal': dict({ + 'freezeProtectEnabled': True, + 'freezeProtectTemp': 2, + 'hotDaysExtraWatering': False, + 'noWaterInMonths': '000000000000', + 'noWaterInWeekDays': '0000000', + 'rainDelayDuration': 0, + 'rainDelayStartTime': 1524854551, + }), + 'zones': dict({ + '1': dict({ + 'ETcoef': 0.8, + 'active': True, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 4, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Landscaping', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 5, + 'state': 0, + 'sun': 1, + 'type': 4, + 'uid': 1, + 'userDuration': 0, + 'valveid': 1, + 'waterSense': dict({ + 'allowedSurfaceAcc': 8.38, + 'appEfficiency': 0.75, + 'area': 92.9000015258789, + 'currentFieldCapacity': 16.03, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.17, + 'flowrate': None, + 'isTallPlant': True, + 'maxAllowedDepletion': 0.5, + 'minRuntime': 0, + 'permWilting': 0.03, + 'precipitationRate': 25.4, + 'referenceTime': 1243, + 'rootDepth': 229, + 'soilIntakeRate': 10.16, + }), + }), + '10': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 10', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 10, + 'userDuration': 0, + 'valveid': 10, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '11': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 11', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 11, + 'userDuration': 0, + 'valveid': 11, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '12': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 12', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 12, + 'userDuration': 0, + 'valveid': 12, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '2': dict({ + 'ETcoef': 0.8, + 'active': True, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 3, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Flower Box', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 5, + 'state': 0, + 'sun': 1, + 'type': 5, + 'uid': 2, + 'userDuration': 0, + 'valveid': 2, + 'waterSense': dict({ + 'allowedSurfaceAcc': 8.38, + 'appEfficiency': 0.8, + 'area': 92.9, + 'currentFieldCapacity': 22.39, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.17, + 'flowrate': None, + 'isTallPlant': True, + 'maxAllowedDepletion': 0.35, + 'minRuntime': 5, + 'permWilting': 0.03, + 'precipitationRate': 12.7, + 'referenceTime': 2680, + 'rootDepth': 457, + 'soilIntakeRate': 10.16, + }), + }), + '3': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'TEST', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 9, + 'uid': 3, + 'userDuration': 0, + 'valveid': 3, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 113.4, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': None, + 'isTallPlant': True, + 'maxAllowedDepletion': 0.6, + 'minRuntime': 0, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 380, + 'rootDepth': 700, + 'soilIntakeRate': 5.08, + }), + }), + '4': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 4', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 4, + 'userDuration': 0, + 'valveid': 4, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '5': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 5', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 5, + 'userDuration': 0, + 'valveid': 5, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '6': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 6', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 6, + 'userDuration': 0, + 'valveid': 6, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '7': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 7', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 7, + 'userDuration': 0, + 'valveid': 7, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '8': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 8', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 8, + 'userDuration': 0, + 'valveid': 8, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '9': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 9', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 9, + 'userDuration': 0, + 'valveid': 9, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'ip_address': '192.168.1.100', + 'password': '**REDACTED**', + 'port': 8080, + 'ssl': True, + }), + 'disabled_by': None, + 'domain': 'rainmachine', + 'entry_id': '81bd010ed0a63b705f6da8407cb26d4b', + 'minor_version': 1, + 'options': dict({ + 'allow_inactive_zones_to_run': False, + 'use_app_run_times': False, + 'zone_run_time': 600, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 2, + }), + }) +# --- +# name: test_entry_diagnostics_failed_controller_diagnostics + dict({ + 'data': dict({ + 'controller_diagnostics': None, + 'coordinator': dict({ + 'api.versions': dict({ + 'apiVer': '4.6.1', + 'hwVer': '3', + 'swVer': '4.0.1144', + }), + 'machine.firmware_update_status': dict({ + 'lastUpdateCheck': '2022-07-14 13:01:28', + 'lastUpdateCheckTimestamp': 1657825288, + 'packageDetails': list([ + ]), + 'update': False, + 'updateStatus': 1, + }), + 'programs': dict({ + '1': dict({ + 'active': True, + 'coef': 0, + 'cs_on': False, + 'cycles': 0, + 'delay': 0, + 'delay_on': False, + 'endDate': None, + 'freq_modified': 0, + 'frequency': dict({ + 'param': '0', + 'type': 0, + }), + 'futureField1': 0, + 'ignoreInternetWeather': False, + 'name': 'Morning', + 'nextRun': '2018-06-04', + 'simulationExpired': False, + 'soak': 0, + 'startDate': '2018-04-28', + 'startTime': '06:00', + 'startTimeParams': dict({ + 'offsetMinutes': 0, + 'offsetSign': 0, + 'type': 0, + }), + 'status': 0, + 'uid': 1, + 'useWaterSense': False, + 'wateringTimes': list([ + dict({ + 'active': True, + 'duration': 0, + 'id': 1, + 'minRuntimeCoef': 1, + 'name': 'Landscaping', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': True, + 'duration': 0, + 'id': 2, + 'minRuntimeCoef': 1, + 'name': 'Flower Box', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 3, + 'minRuntimeCoef': 1, + 'name': 'TEST', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 4, + 'minRuntimeCoef': 1, + 'name': 'Zone 4', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 5, + 'minRuntimeCoef': 1, + 'name': 'Zone 5', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 6, + 'minRuntimeCoef': 1, + 'name': 'Zone 6', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 7, + 'minRuntimeCoef': 1, + 'name': 'Zone 7', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 8, + 'minRuntimeCoef': 1, + 'name': 'Zone 8', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 9, + 'minRuntimeCoef': 1, + 'name': 'Zone 9', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 10, + 'minRuntimeCoef': 1, + 'name': 'Zone 10', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 11, + 'minRuntimeCoef': 1, + 'name': 'Zone 11', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 12, + 'minRuntimeCoef': 1, + 'name': 'Zone 12', + 'order': -1, + 'userPercentage': 1, + }), + ]), + 'yearlyRecurring': True, + }), + '2': dict({ + 'active': False, + 'coef': 0, + 'cs_on': False, + 'cycles': 0, + 'delay': 0, + 'delay_on': False, + 'endDate': None, + 'freq_modified': 0, + 'frequency': dict({ + 'param': '0', + 'type': 0, + }), + 'futureField1': 0, + 'ignoreInternetWeather': False, + 'name': 'Evening', + 'nextRun': '2018-06-04', + 'simulationExpired': False, + 'soak': 0, + 'startDate': '2018-04-28', + 'startTime': '06:00', + 'startTimeParams': dict({ + 'offsetMinutes': 0, + 'offsetSign': 0, + 'type': 0, + }), + 'status': 0, + 'uid': 2, + 'useWaterSense': False, + 'wateringTimes': list([ + dict({ + 'active': True, + 'duration': 0, + 'id': 1, + 'minRuntimeCoef': 1, + 'name': 'Landscaping', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': True, + 'duration': 0, + 'id': 2, + 'minRuntimeCoef': 1, + 'name': 'Flower Box', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 3, + 'minRuntimeCoef': 1, + 'name': 'TEST', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 4, + 'minRuntimeCoef': 1, + 'name': 'Zone 4', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 5, + 'minRuntimeCoef': 1, + 'name': 'Zone 5', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 6, + 'minRuntimeCoef': 1, + 'name': 'Zone 6', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 7, + 'minRuntimeCoef': 1, + 'name': 'Zone 7', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 8, + 'minRuntimeCoef': 1, + 'name': 'Zone 8', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 9, + 'minRuntimeCoef': 1, + 'name': 'Zone 9', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 10, + 'minRuntimeCoef': 1, + 'name': 'Zone 10', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 11, + 'minRuntimeCoef': 1, + 'name': 'Zone 11', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': False, + 'duration': 0, + 'id': 12, + 'minRuntimeCoef': 1, + 'name': 'Zone 12', + 'order': -1, + 'userPercentage': 1, + }), + ]), + 'yearlyRecurring': True, + }), + }), + 'provision.settings': dict({ + 'location': dict({ + 'address': 'Default', + 'doyDownloaded': True, + 'elevation': '**REDACTED**', + 'et0Average': 6.578, + 'krs': 0.16, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'Home', + 'rainSensitivity': 0.8, + 'state': 'Default', + 'stationDownloaded': True, + 'stationID': '**REDACTED**', + 'stationName': '**REDACTED**', + 'stationSource': '**REDACTED**', + 'timezone': '**REDACTED**', + 'windSensitivity': 0.5, + 'wsDays': 2, + 'zip': None, + }), + 'system': dict({ + 'allowAlexaDiscovery': False, + 'automaticUpdates': True, + 'databasePath': '/rainmachine-app/DB/Default', + 'defaultZoneWateringDuration': 300, + 'hardwareVersion': 3, + 'httpEnabled': True, + 'localValveCount': 12, + 'masterValveAfter': 0, + 'masterValveBefore': 0, + 'maxLEDBrightness': 40, + 'maxWateringCoef': 2, + 'minLEDBrightness': 0, + 'minWateringDurationThreshold': 0, + 'mixerHistorySize': 365, + 'netName': 'Home', + 'parserDataSizeInDays': 6, + 'parserHistorySize': 365, + 'programListShowInactive': True, + 'programSingleSchedule': False, + 'programZonesShowInactive': False, + 'rainSensorIsNormallyClosed': True, + 'rainSensorRainStart': None, + 'rainSensorSnoozeDuration': 0, + 'runParsersBeforePrograms': True, + 'selfTest': False, + 'showRestrictionsOnLed': False, + 'simulatorHistorySize': 0, + 'softwareRainSensorMinQPF': 5, + 'standaloneMode': False, + 'touchAdvanced': False, + 'touchAuthAPSeconds': 60, + 'touchCyclePrograms': True, + 'touchLongPressTimeout': 3, + 'touchProgramToRun': None, + 'touchSleepTimeout': 10, + 'uiUnitsMetric': False, + 'useBonjourService': True, + 'useCommandLineArguments': False, + 'useCorrectionForPast': True, + 'useMasterValve': False, + 'useRainSensor': False, + 'useSoftwareRainSensor': False, + 'vibration': False, + 'waterLogHistorySize': 365, + 'wizardHasRun': True, + 'zoneDuration': list([ + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + 300, + ]), + 'zoneListShowInactive': True, + }), + }), + 'restrictions.current': dict({ + 'freeze': False, + 'hourly': False, + 'month': False, + 'rainDelay': False, + 'rainDelayCounter': -1, + 'rainSensor': False, + 'weekDay': False, + }), + 'restrictions.universal': dict({ + 'freezeProtectEnabled': True, + 'freezeProtectTemp': 2, + 'hotDaysExtraWatering': False, + 'noWaterInMonths': '000000000000', + 'noWaterInWeekDays': '0000000', + 'rainDelayDuration': 0, + 'rainDelayStartTime': 1524854551, + }), + 'zones': dict({ + '1': dict({ + 'ETcoef': 0.8, + 'active': True, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 4, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Landscaping', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 5, + 'state': 0, + 'sun': 1, + 'type': 4, + 'uid': 1, + 'userDuration': 0, + 'valveid': 1, + 'waterSense': dict({ + 'allowedSurfaceAcc': 8.38, + 'appEfficiency': 0.75, + 'area': 92.9000015258789, + 'currentFieldCapacity': 16.03, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.17, + 'flowrate': None, + 'isTallPlant': True, + 'maxAllowedDepletion': 0.5, + 'minRuntime': 0, + 'permWilting': 0.03, + 'precipitationRate': 25.4, + 'referenceTime': 1243, + 'rootDepth': 229, + 'soilIntakeRate': 10.16, + }), + }), + '10': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 10', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 10, + 'userDuration': 0, + 'valveid': 10, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '11': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 11', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 11, + 'userDuration': 0, + 'valveid': 11, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '12': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 12', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 12, + 'userDuration': 0, + 'valveid': 12, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '2': dict({ + 'ETcoef': 0.8, + 'active': True, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 3, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Flower Box', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 5, + 'state': 0, + 'sun': 1, + 'type': 5, + 'uid': 2, + 'userDuration': 0, + 'valveid': 2, + 'waterSense': dict({ + 'allowedSurfaceAcc': 8.38, + 'appEfficiency': 0.8, + 'area': 92.9, + 'currentFieldCapacity': 22.39, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.17, + 'flowrate': None, + 'isTallPlant': True, + 'maxAllowedDepletion': 0.35, + 'minRuntime': 5, + 'permWilting': 0.03, + 'precipitationRate': 12.7, + 'referenceTime': 2680, + 'rootDepth': 457, + 'soilIntakeRate': 10.16, + }), + }), + '3': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'TEST', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 9, + 'uid': 3, + 'userDuration': 0, + 'valveid': 3, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 113.4, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': None, + 'isTallPlant': True, + 'maxAllowedDepletion': 0.6, + 'minRuntime': 0, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 380, + 'rootDepth': 700, + 'soilIntakeRate': 5.08, + }), + }), + '4': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 4', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 4, + 'userDuration': 0, + 'valveid': 4, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '5': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 5', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 5, + 'userDuration': 0, + 'valveid': 5, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '6': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 6', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 6, + 'userDuration': 0, + 'valveid': 6, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '7': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 7', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 7, + 'userDuration': 0, + 'valveid': 7, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '8': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 8', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 8, + 'userDuration': 0, + 'valveid': 8, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + '9': dict({ + 'ETcoef': 0.8, + 'active': False, + 'after': 0, + 'before': 0, + 'customSoilPreset': None, + 'customSprinklerPreset': None, + 'customVegetationPreset': None, + 'cycle': 0, + 'group_id': 1, + 'history': True, + 'internet': True, + 'machineDuration': 0, + 'master': False, + 'name': 'Zone 9', + 'noOfCycles': 0, + 'remaining': 0, + 'restriction': False, + 'savings': 100, + 'slope': 1, + 'soil': 1, + 'state': 0, + 'sun': 1, + 'type': 2, + 'uid': 9, + 'userDuration': 0, + 'valveid': 9, + 'waterSense': dict({ + 'allowedSurfaceAcc': 6.6, + 'appEfficiency': 0.7, + 'area': 92.9, + 'currentFieldCapacity': 21.92, + 'detailedMonthsKc': list([ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + ]), + 'fieldCapacity': 0.3, + 'flowrate': 0.0, + 'isTallPlant': False, + 'maxAllowedDepletion': 0.4, + 'minRuntime': -1, + 'permWilting': 0.03, + 'precipitationRate': 35.56, + 'referenceTime': 761, + 'rootDepth': 203, + 'soilIntakeRate': 5.08, + }), + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'ip_address': '192.168.1.100', + 'password': '**REDACTED**', + 'port': 8080, + 'ssl': True, + }), + 'disabled_by': None, + 'domain': 'rainmachine', + 'entry_id': '81bd010ed0a63b705f6da8407cb26d4b', + 'minor_version': 1, + 'options': dict({ + 'allow_inactive_zones_to_run': False, + 'use_app_run_times': False, + 'zone_run_time': 600, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 2, + }), + }) +# --- diff --git a/tests/components/rainmachine/snapshots/test_select.ambr b/tests/components/rainmachine/snapshots/test_select.ambr new file mode 100644 index 00000000000..651a709d2fa --- /dev/null +++ b/tests/components/rainmachine/snapshots/test_select.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_select_entities[select.12345_freeze_protection_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0°C', + '2°C', + '5°C', + '10°C', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.12345_freeze_protection_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Freeze protection temperature', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freeze_protection_temperature', + 'unique_id': 'aa:bb:cc:dd:ee:ff_freeze_protection_temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.12345_freeze_protection_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Freeze protection temperature', + 'options': list([ + '0°C', + '2°C', + '5°C', + '10°C', + ]), + }), + 'context': , + 'entity_id': 'select.12345_freeze_protection_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2°C', + }) +# --- diff --git a/tests/components/rainmachine/snapshots/test_sensor.ambr b/tests/components/rainmachine/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e93d0645030 --- /dev/null +++ b/tests/components/rainmachine/snapshots/test_sensor.ambr @@ -0,0 +1,707 @@ +# serializer version: 1 +# name: test_sensors[sensor.12345_evening_run_completion_time-entry] + 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.12345_evening_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Evening Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_program_run_completion_time_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_evening_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Evening Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_evening_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_flower_box_run_completion_time-entry] + 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.12345_flower_box_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flower Box Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_flower_box_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Flower Box Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_flower_box_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_landscaping_run_completion_time-entry] + 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.12345_landscaping_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Landscaping Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_landscaping_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Landscaping Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_landscaping_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_morning_run_completion_time-entry] + 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.12345_morning_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Morning Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_program_run_completion_time_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_morning_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Morning Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_morning_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_rain_sensor_rain_start-entry] + 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.12345_rain_sensor_rain_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:weather-pouring', + 'original_name': 'Rain sensor rain start', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rain_sensor_rain_start', + 'unique_id': 'aa:bb:cc:dd:ee:ff_rain_sensor_rain_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_rain_sensor_rain_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Rain sensor rain start', + 'icon': 'mdi:weather-pouring', + }), + 'context': , + 'entity_id': 'sensor.12345_rain_sensor_rain_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_test_run_completion_time-entry] + 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.12345_test_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'TEST Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_test_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 TEST Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_test_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_10_run_completion_time-entry] + 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.12345_zone_10_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 10 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_10_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 10 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_10_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_11_run_completion_time-entry] + 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.12345_zone_11_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 11 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_11', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_11_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 11 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_11_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_12_run_completion_time-entry] + 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.12345_zone_12_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 12 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_12', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_12_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 12 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_12_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_4_run_completion_time-entry] + 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.12345_zone_4_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 4 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_4_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 4 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_4_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_5_run_completion_time-entry] + 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.12345_zone_5_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 5 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_5_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 5 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_5_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_6_run_completion_time-entry] + 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.12345_zone_6_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 6 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_6_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 6 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_6_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_7_run_completion_time-entry] + 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.12345_zone_7_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 7 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_7_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 7 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_7_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_8_run_completion_time-entry] + 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.12345_zone_8_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 8 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_8', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_8_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 8 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_8_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.12345_zone_9_run_completion_time-entry] + 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.12345_zone_9_run_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 9 Run Completion Time', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_run_completion_time_9', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.12345_zone_9_run_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': '12345 Zone 9 Run Completion Time', + }), + 'context': , + 'entity_id': 'sensor.12345_zone_9_run_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/rainmachine/snapshots/test_switch.ambr b/tests/components/rainmachine/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b803ff994d4 --- /dev/null +++ b/tests/components/rainmachine/snapshots/test_switch.ambr @@ -0,0 +1,1643 @@ +# serializer version: 1 +# name: test_switches[switch.12345_evening-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_evening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Evening', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_program_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_evening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'program', + 'friendly_name': '12345 Evening', + 'icon': 'mdi:water', + 'id': 2, + 'next_run': '2018-06-04T06:00:00', + 'soak': 0, + 'status': , + 'zones': list([ + dict({ + 'active': True, + 'duration': 0, + 'id': 1, + 'minRuntimeCoef': 1, + 'name': 'Landscaping', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': True, + 'duration': 0, + 'id': 2, + 'minRuntimeCoef': 1, + 'name': 'Flower Box', + 'order': -1, + 'userPercentage': 1, + }), + ]), + }), + 'context': , + 'entity_id': 'switch.12345_evening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_evening_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_evening_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Evening enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_program_2_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_evening_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'program', + 'friendly_name': '12345 Evening enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_evening_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_extra_water_on_hot_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_extra_water_on_hot_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:heat-wave', + 'original_name': 'Extra water on hot days', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hot_days_extra_watering', + 'unique_id': 'aa:bb:cc:dd:ee:ff_hot_days_extra_watering', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_extra_water_on_hot_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Extra water on hot days', + 'icon': 'mdi:heat-wave', + }), + 'context': , + 'entity_id': 'switch.12345_extra_water_on_hot_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_flower_box-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_flower_box', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Flower box', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_flower_box-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.17, + 'friendly_name': '12345 Flower box', + 'icon': 'mdi:water', + 'id': 2, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Sandy Loam', + 'sprinkler_head_precipitation_rate': 12.7, + 'sprinkler_head_type': 'Surface Drip', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Vegetables', + }), + 'context': , + 'entity_id': 'switch.12345_flower_box', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_flower_box_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_flower_box_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Flower box enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_2_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_flower_box_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Flower box enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_flower_box_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12345_freeze_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_freeze_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:snowflake-alert', + 'original_name': 'Freeze protection', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freeze_protect_enabled', + 'unique_id': 'aa:bb:cc:dd:ee:ff_freeze_protect_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_freeze_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '12345 Freeze protection', + 'icon': 'mdi:snowflake-alert', + }), + 'context': , + 'entity_id': 'switch.12345_freeze_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12345_landscaping-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_landscaping', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Landscaping', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_landscaping-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.17, + 'friendly_name': '12345 Landscaping', + 'icon': 'mdi:water', + 'id': 1, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Sandy Loam', + 'sprinkler_head_precipitation_rate': 25.4, + 'sprinkler_head_type': 'Bubblers Drip', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Flowers', + }), + 'context': , + 'entity_id': 'switch.12345_landscaping', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_landscaping_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_landscaping_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Landscaping enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_1_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_landscaping_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Landscaping enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_landscaping_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12345_morning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_morning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Morning', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_program_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_morning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'program', + 'friendly_name': '12345 Morning', + 'icon': 'mdi:water', + 'id': 1, + 'next_run': '2018-06-04T06:00:00', + 'soak': 0, + 'status': , + 'zones': list([ + dict({ + 'active': True, + 'duration': 0, + 'id': 1, + 'minRuntimeCoef': 1, + 'name': 'Landscaping', + 'order': -1, + 'userPercentage': 1, + }), + dict({ + 'active': True, + 'duration': 0, + 'id': 2, + 'minRuntimeCoef': 1, + 'name': 'Flower Box', + 'order': -1, + 'userPercentage': 1, + }), + ]), + }), + 'context': , + 'entity_id': 'switch.12345_morning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_morning_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_morning_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Morning enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_program_1_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_morning_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'program', + 'friendly_name': '12345 Morning enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_morning_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[switch.12345_test-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Test', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_test-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Test', + 'icon': 'mdi:water', + 'id': 3, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Drought Tolerant Plants', + }), + 'context': , + 'entity_id': 'switch.12345_test', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_test_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_test_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Test enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_3_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_test_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Test enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_test_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 10', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_10', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 10', + 'icon': 'mdi:water', + 'id': 10, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_10_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_10_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 10 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_10_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_10_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 10 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_10_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_11-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_11', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 11', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_11', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_11-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 11', + 'icon': 'mdi:water', + 'id': 11, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_11', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_11_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_11_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 11 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_11_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_11_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 11 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_11_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_12-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_12', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 12', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_12', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_12-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 12', + 'icon': 'mdi:water', + 'id': 12, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_12', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_12_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_12_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 12 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_12_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_12_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 12 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_12_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 4', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 4', + 'icon': 'mdi:water', + 'id': 4, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_4_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_4_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 4 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_4_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_4_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 4 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_4_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 5', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 5', + 'icon': 'mdi:water', + 'id': 5, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_5_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_5_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 5 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_5_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_5_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 5 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_5_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 6', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_6', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 6', + 'icon': 'mdi:water', + 'id': 6, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_6_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_6_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 6 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_6_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_6_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 6 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_6_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 7', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_7', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 7', + 'icon': 'mdi:water', + 'id': 7, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_7_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_7_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 7 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_7_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_7_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 7 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_7_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 8', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_8', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 8', + 'icon': 'mdi:water', + 'id': 8, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_8_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_8_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 8 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_8_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_8_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 8 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_8_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.12345_zone_9', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water', + 'original_name': 'Zone 9', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_9', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'area': 92.9, + 'current_cycle': 0, + 'field_capacity': 0.3, + 'friendly_name': '12345 Zone 9', + 'icon': 'mdi:water', + 'id': 9, + 'number_of_cycles': 0, + 'restrictions': False, + 'slope': 'Flat', + 'soil_type': 'Clay Loam', + 'sprinkler_head_precipitation_rate': 35.56, + 'sprinkler_head_type': 'Popup Spray', + 'status': , + 'sun_exposure': 'Full Sun', + 'vegetation_type': 'Cool Season Grass', + }), + 'context': , + 'entity_id': 'switch.12345_zone_9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.12345_zone_9_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.12345_zone_9_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:cog', + 'original_name': 'Zone 9 enabled', + 'platform': 'rainmachine', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_zone_9_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.12345_zone_9_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'activity_type': 'zone', + 'friendly_name': '12345 Zone 9 enabled', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'switch.12345_zone_9_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/rainmachine/test_binary_sensor.py b/tests/components/rainmachine/test_binary_sensor.py new file mode 100644 index 00000000000..d428993da51 --- /dev/null +++ b/tests/components/rainmachine/test_binary_sensor.py @@ -0,0 +1,36 @@ +"""Test RainMachine binary sensors.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.rainmachine import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config: dict[str, Any], + config_entry: MockConfigEntry, + client: AsyncMock, +) -> None: + """Test binary sensors.""" + with ( + patch("homeassistant.components.rainmachine.Client", return_value=client), + patch( + "homeassistant.components.rainmachine.PLATFORMS", [Platform.BINARY_SENSOR] + ), + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/rainmachine/test_button.py b/tests/components/rainmachine/test_button.py new file mode 100644 index 00000000000..629c325c79e --- /dev/null +++ b/tests/components/rainmachine/test_button.py @@ -0,0 +1,32 @@ +"""Test RainMachine buttons.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.rainmachine import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config: dict[str, Any], + config_entry: MockConfigEntry, + client: AsyncMock, +) -> None: + """Test buttons.""" + with ( + patch("homeassistant.components.rainmachine.Client", return_value=client), + patch("homeassistant.components.rainmachine.PLATFORMS", [Platform.BUTTON]), + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 808c2f184a7..5838dcc35c8 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -59,6 +59,7 @@ async def test_invalid_password(hass: HomeAssistant, config) -> None: ) async def test_migrate_1_2( hass: HomeAssistant, + entity_registry: er.EntityRegistry, client, config, config_entry, @@ -69,10 +70,8 @@ async def test_migrate_1_2( platform, ) -> None: """Test migration from version 1 to 2 (consistent unique IDs).""" - ent_reg = er.async_get(hass) - # Create entity RegistryEntry using old unique ID format: - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( platform, DOMAIN, old_unique_id, @@ -96,9 +95,9 @@ async def test_migrate_1_2( await hass.async_block_till_done() # Check that new RegistryEntry is using new unique ID format - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry.unique_id == new_unique_id - assert ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id) is None + assert entity_registry.async_get_entity_id(platform, DOMAIN, old_unique_id) is None async def test_options_flow(hass: HomeAssistant, config, config_entry) -> None: diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index 6ea50e5b102..1fc03ab357a 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -1,9 +1,8 @@ """Test RainMachine diagnostics.""" from regenmaschine.errors import RainMachineError +from syrupy import SnapshotAssertion -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 @@ -15,628 +14,13 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_rainmachine, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 2, - "minor_version": 1, - "domain": "rainmachine", - "title": "Mock Title", - "data": { - "ip_address": "192.168.1.100", - "password": REDACTED, - "port": 8080, - "ssl": True, - }, - "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", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "coordinator": { - "api.versions": {"apiVer": "4.6.1", "hwVer": "3", "swVer": "4.0.1144"}, - "machine.firmware_update_status": { - "lastUpdateCheckTimestamp": 1657825288, - "packageDetails": [], - "update": False, - "lastUpdateCheck": "2022-07-14 13:01:28", - "updateStatus": 1, - }, - "programs": [ - { - "uid": 1, - "name": "Morning", - "active": True, - "startTime": "06:00", - "cycles": 0, - "soak": 0, - "cs_on": False, - "delay": 0, - "delay_on": False, - "status": 0, - "startTimeParams": { - "offsetSign": 0, - "type": 0, - "offsetMinutes": 0, - }, - "frequency": {"type": 0, "param": "0"}, - "coef": 0, - "ignoreInternetWeather": False, - "futureField1": 0, - "freq_modified": 0, - "useWaterSense": False, - "nextRun": "2018-06-04", - "startDate": "2018-04-28", - "endDate": None, - "yearlyRecurring": True, - "simulationExpired": False, - "wateringTimes": [ - { - "id": 1, - "order": -1, - "name": "Landscaping", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 2, - "order": -1, - "name": "Flower Box", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 3, - "order": -1, - "name": "TEST", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 4, - "order": -1, - "name": "Zone 4", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 5, - "order": -1, - "name": "Zone 5", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 6, - "order": -1, - "name": "Zone 6", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 7, - "order": -1, - "name": "Zone 7", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 8, - "order": -1, - "name": "Zone 8", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 9, - "order": -1, - "name": "Zone 9", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 10, - "order": -1, - "name": "Zone 10", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 11, - "order": -1, - "name": "Zone 11", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 12, - "order": -1, - "name": "Zone 12", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - ], - }, - { - "uid": 2, - "name": "Evening", - "active": False, - "startTime": "06:00", - "cycles": 0, - "soak": 0, - "cs_on": False, - "delay": 0, - "delay_on": False, - "status": 0, - "startTimeParams": { - "offsetSign": 0, - "type": 0, - "offsetMinutes": 0, - }, - "frequency": {"type": 0, "param": "0"}, - "coef": 0, - "ignoreInternetWeather": False, - "futureField1": 0, - "freq_modified": 0, - "useWaterSense": False, - "nextRun": "2018-06-04", - "startDate": "2018-04-28", - "endDate": None, - "yearlyRecurring": True, - "simulationExpired": False, - "wateringTimes": [ - { - "id": 1, - "order": -1, - "name": "Landscaping", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 2, - "order": -1, - "name": "Flower Box", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 3, - "order": -1, - "name": "TEST", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 4, - "order": -1, - "name": "Zone 4", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 5, - "order": -1, - "name": "Zone 5", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 6, - "order": -1, - "name": "Zone 6", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 7, - "order": -1, - "name": "Zone 7", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 8, - "order": -1, - "name": "Zone 8", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 9, - "order": -1, - "name": "Zone 9", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 10, - "order": -1, - "name": "Zone 10", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 11, - "order": -1, - "name": "Zone 11", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 12, - "order": -1, - "name": "Zone 12", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - ], - }, - ], - "provision.settings": { - "system": { - "httpEnabled": True, - "rainSensorSnoozeDuration": 0, - "uiUnitsMetric": False, - "programZonesShowInactive": False, - "programSingleSchedule": False, - "standaloneMode": False, - "masterValveAfter": 0, - "touchSleepTimeout": 10, - "selfTest": False, - "useSoftwareRainSensor": False, - "defaultZoneWateringDuration": 300, - "maxLEDBrightness": 40, - "simulatorHistorySize": 0, - "vibration": False, - "masterValveBefore": 0, - "touchProgramToRun": None, - "useRainSensor": False, - "wizardHasRun": True, - "waterLogHistorySize": 365, - "netName": "Home", - "softwareRainSensorMinQPF": 5, - "touchAdvanced": False, - "useBonjourService": True, - "hardwareVersion": 3, - "touchLongPressTimeout": 3, - "showRestrictionsOnLed": False, - "parserDataSizeInDays": 6, - "programListShowInactive": True, - "parserHistorySize": 365, - "allowAlexaDiscovery": False, - "automaticUpdates": True, - "minLEDBrightness": 0, - "minWateringDurationThreshold": 0, - "localValveCount": 12, - "touchAuthAPSeconds": 60, - "useCommandLineArguments": False, - "databasePath": "/rainmachine-app/DB/Default", - "touchCyclePrograms": True, - "zoneListShowInactive": True, - "rainSensorRainStart": None, - "zoneDuration": [ - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - ], - "rainSensorIsNormallyClosed": True, - "useCorrectionForPast": True, - "useMasterValve": False, - "runParsersBeforePrograms": True, - "maxWateringCoef": 2, - "mixerHistorySize": 365, - }, - "location": { - "elevation": REDACTED, - "doyDownloaded": True, - "zip": None, - "windSensitivity": 0.5, - "krs": 0.16, - "stationID": REDACTED, - "stationSource": REDACTED, - "et0Average": 6.578, - "latitude": REDACTED, - "state": "Default", - "stationName": REDACTED, - "wsDays": 2, - "stationDownloaded": True, - "address": "Default", - "rainSensitivity": 0.8, - "timezone": REDACTED, - "longitude": REDACTED, - "name": "Home", - }, - }, - "restrictions.current": { - "hourly": False, - "freeze": False, - "month": False, - "weekDay": False, - "rainDelay": False, - "rainDelayCounter": -1, - "rainSensor": False, - }, - "restrictions.universal": { - "hotDaysExtraWatering": False, - "freezeProtectEnabled": True, - "freezeProtectTemp": 2, - "noWaterInWeekDays": "0000000", - "noWaterInMonths": "000000000000", - "rainDelayStartTime": 1524854551, - "rainDelayDuration": 0, - }, - "zones": [ - { - "uid": 1, - "name": "Landscaping", - "state": 0, - "active": True, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 4, - "master": False, - "waterSense": False, - }, - { - "uid": 2, - "name": "Flower Box", - "state": 0, - "active": True, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 5, - "master": False, - "waterSense": False, - }, - { - "uid": 3, - "name": "TEST", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 9, - "master": False, - "waterSense": False, - }, - { - "uid": 4, - "name": "Zone 4", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 5, - "name": "Zone 5", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 6, - "name": "Zone 6", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 7, - "name": "Zone 7", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 8, - "name": "Zone 8", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 9, - "name": "Zone 9", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 10, - "name": "Zone 10", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 11, - "name": "Zone 11", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 12, - "name": "Zone 12", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - ], - }, - "controller_diagnostics": { - "hasWifi": True, - "uptime": "3 days, 18:14:14", - "uptimeSeconds": 324854, - "memUsage": 16196, - "networkStatus": True, - "bootCompleted": True, - "lastCheckTimestamp": 1659895175, - "wizardHasRun": True, - "standaloneMode": False, - "cpuUsage": 1, - "lastCheck": "2022-08-07 11:59:35", - "softwareVersion": "4.0.1144", - "internetStatus": True, - "locationStatus": True, - "timeStatus": True, - "wifiMode": None, - "gatewayAddress": "172.16.20.1", - "cloudStatus": 0, - "weatherStatus": True, - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) async def test_entry_diagnostics_failed_controller_diagnostics( @@ -645,606 +29,11 @@ async def test_entry_diagnostics_failed_controller_diagnostics( controller, hass_client: ClientSessionGenerator, setup_rainmachine, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics when the controller diagnostics API call fails.""" controller.diagnostics.current.side_effect = RainMachineError - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 2, - "minor_version": 1, - "domain": "rainmachine", - "title": "Mock Title", - "data": { - "ip_address": "192.168.1.100", - "password": REDACTED, - "port": 8080, - "ssl": True, - }, - "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", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "coordinator": { - "api.versions": {"apiVer": "4.6.1", "hwVer": "3", "swVer": "4.0.1144"}, - "machine.firmware_update_status": { - "lastUpdateCheckTimestamp": 1657825288, - "packageDetails": [], - "update": False, - "lastUpdateCheck": "2022-07-14 13:01:28", - "updateStatus": 1, - }, - "programs": [ - { - "uid": 1, - "name": "Morning", - "active": True, - "startTime": "06:00", - "cycles": 0, - "soak": 0, - "cs_on": False, - "delay": 0, - "delay_on": False, - "status": 0, - "startTimeParams": { - "offsetSign": 0, - "type": 0, - "offsetMinutes": 0, - }, - "frequency": {"type": 0, "param": "0"}, - "coef": 0, - "ignoreInternetWeather": False, - "futureField1": 0, - "freq_modified": 0, - "useWaterSense": False, - "nextRun": "2018-06-04", - "startDate": "2018-04-28", - "endDate": None, - "yearlyRecurring": True, - "simulationExpired": False, - "wateringTimes": [ - { - "id": 1, - "order": -1, - "name": "Landscaping", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 2, - "order": -1, - "name": "Flower Box", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 3, - "order": -1, - "name": "TEST", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 4, - "order": -1, - "name": "Zone 4", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 5, - "order": -1, - "name": "Zone 5", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 6, - "order": -1, - "name": "Zone 6", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 7, - "order": -1, - "name": "Zone 7", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 8, - "order": -1, - "name": "Zone 8", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 9, - "order": -1, - "name": "Zone 9", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 10, - "order": -1, - "name": "Zone 10", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 11, - "order": -1, - "name": "Zone 11", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 12, - "order": -1, - "name": "Zone 12", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - ], - }, - { - "uid": 2, - "name": "Evening", - "active": False, - "startTime": "06:00", - "cycles": 0, - "soak": 0, - "cs_on": False, - "delay": 0, - "delay_on": False, - "status": 0, - "startTimeParams": { - "offsetSign": 0, - "type": 0, - "offsetMinutes": 0, - }, - "frequency": {"type": 0, "param": "0"}, - "coef": 0, - "ignoreInternetWeather": False, - "futureField1": 0, - "freq_modified": 0, - "useWaterSense": False, - "nextRun": "2018-06-04", - "startDate": "2018-04-28", - "endDate": None, - "yearlyRecurring": True, - "simulationExpired": False, - "wateringTimes": [ - { - "id": 1, - "order": -1, - "name": "Landscaping", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 2, - "order": -1, - "name": "Flower Box", - "duration": 0, - "active": True, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 3, - "order": -1, - "name": "TEST", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 4, - "order": -1, - "name": "Zone 4", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 5, - "order": -1, - "name": "Zone 5", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 6, - "order": -1, - "name": "Zone 6", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 7, - "order": -1, - "name": "Zone 7", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 8, - "order": -1, - "name": "Zone 8", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 9, - "order": -1, - "name": "Zone 9", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 10, - "order": -1, - "name": "Zone 10", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 11, - "order": -1, - "name": "Zone 11", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - { - "id": 12, - "order": -1, - "name": "Zone 12", - "duration": 0, - "active": False, - "userPercentage": 1, - "minRuntimeCoef": 1, - }, - ], - }, - ], - "provision.settings": { - "system": { - "httpEnabled": True, - "rainSensorSnoozeDuration": 0, - "uiUnitsMetric": False, - "programZonesShowInactive": False, - "programSingleSchedule": False, - "standaloneMode": False, - "masterValveAfter": 0, - "touchSleepTimeout": 10, - "selfTest": False, - "useSoftwareRainSensor": False, - "defaultZoneWateringDuration": 300, - "maxLEDBrightness": 40, - "simulatorHistorySize": 0, - "vibration": False, - "masterValveBefore": 0, - "touchProgramToRun": None, - "useRainSensor": False, - "wizardHasRun": True, - "waterLogHistorySize": 365, - "netName": "Home", - "softwareRainSensorMinQPF": 5, - "touchAdvanced": False, - "useBonjourService": True, - "hardwareVersion": 3, - "touchLongPressTimeout": 3, - "showRestrictionsOnLed": False, - "parserDataSizeInDays": 6, - "programListShowInactive": True, - "parserHistorySize": 365, - "allowAlexaDiscovery": False, - "automaticUpdates": True, - "minLEDBrightness": 0, - "minWateringDurationThreshold": 0, - "localValveCount": 12, - "touchAuthAPSeconds": 60, - "useCommandLineArguments": False, - "databasePath": "/rainmachine-app/DB/Default", - "touchCyclePrograms": True, - "zoneListShowInactive": True, - "rainSensorRainStart": None, - "zoneDuration": [ - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - 300, - ], - "rainSensorIsNormallyClosed": True, - "useCorrectionForPast": True, - "useMasterValve": False, - "runParsersBeforePrograms": True, - "maxWateringCoef": 2, - "mixerHistorySize": 365, - }, - "location": { - "elevation": REDACTED, - "doyDownloaded": True, - "zip": None, - "windSensitivity": 0.5, - "krs": 0.16, - "stationID": REDACTED, - "stationSource": REDACTED, - "et0Average": 6.578, - "latitude": REDACTED, - "state": "Default", - "stationName": REDACTED, - "wsDays": 2, - "stationDownloaded": True, - "address": "Default", - "rainSensitivity": 0.8, - "timezone": REDACTED, - "longitude": REDACTED, - "name": "Home", - }, - }, - "restrictions.current": { - "hourly": False, - "freeze": False, - "month": False, - "weekDay": False, - "rainDelay": False, - "rainDelayCounter": -1, - "rainSensor": False, - }, - "restrictions.universal": { - "hotDaysExtraWatering": False, - "freezeProtectEnabled": True, - "freezeProtectTemp": 2, - "noWaterInWeekDays": "0000000", - "noWaterInMonths": "000000000000", - "rainDelayStartTime": 1524854551, - "rainDelayDuration": 0, - }, - "zones": [ - { - "uid": 1, - "name": "Landscaping", - "state": 0, - "active": True, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 4, - "master": False, - "waterSense": False, - }, - { - "uid": 2, - "name": "Flower Box", - "state": 0, - "active": True, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 5, - "master": False, - "waterSense": False, - }, - { - "uid": 3, - "name": "TEST", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 9, - "master": False, - "waterSense": False, - }, - { - "uid": 4, - "name": "Zone 4", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 5, - "name": "Zone 5", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 6, - "name": "Zone 6", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 7, - "name": "Zone 7", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 8, - "name": "Zone 8", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 9, - "name": "Zone 9", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 10, - "name": "Zone 10", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 11, - "name": "Zone 11", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - { - "uid": 12, - "name": "Zone 12", - "state": 0, - "active": False, - "userDuration": 0, - "machineDuration": 0, - "remaining": 0, - "cycle": 0, - "noOfCycles": 0, - "restriction": False, - "type": 2, - "master": False, - "waterSense": False, - }, - ], - }, - "controller_diagnostics": None, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/rainmachine/test_select.py b/tests/components/rainmachine/test_select.py new file mode 100644 index 00000000000..ca9ce2e644d --- /dev/null +++ b/tests/components/rainmachine/test_select.py @@ -0,0 +1,32 @@ +"""Test RainMachine select entities.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.rainmachine import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_select_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config: dict[str, Any], + config_entry: MockConfigEntry, + client: AsyncMock, +) -> None: + """Test select entities.""" + with ( + patch("homeassistant.components.rainmachine.Client", return_value=client), + patch("homeassistant.components.rainmachine.PLATFORMS", [Platform.SELECT]), + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/rainmachine/test_sensor.py b/tests/components/rainmachine/test_sensor.py new file mode 100644 index 00000000000..3ff533b6da0 --- /dev/null +++ b/tests/components/rainmachine/test_sensor.py @@ -0,0 +1,34 @@ +"""Test RainMachine sensors.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.rainmachine import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config: dict[str, Any], + config_entry: MockConfigEntry, + client: AsyncMock, +) -> None: + """Test sensors.""" + with ( + patch("homeassistant.components.rainmachine.Client", return_value=client), + patch("homeassistant.components.rainmachine.PLATFORMS", [Platform.SENSOR]), + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/rainmachine/test_switch.py b/tests/components/rainmachine/test_switch.py new file mode 100644 index 00000000000..50e73a78efe --- /dev/null +++ b/tests/components/rainmachine/test_switch.py @@ -0,0 +1,34 @@ +"""Test RainMachine switches.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.rainmachine import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switches( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config: dict[str, Any], + config_entry: MockConfigEntry, + client: AsyncMock, +) -> None: + """Test switches.""" + with ( + patch("homeassistant.components.rainmachine.Client", return_value=client), + patch("homeassistant.components.rainmachine.PLATFORMS", [Platform.SWITCH]), + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/rapt_ble/conftest.py b/tests/components/rapt_ble/conftest.py index 4a890eb60f1..9b62f212584 100644 --- a/tests/components/rapt_ble/conftest.py +++ b/tests/components/rapt_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/rdw/conftest.py b/tests/components/rdw/conftest.py index 7e9f485eaef..3f45f44e3d8 100644 --- a/tests/components/rdw/conftest.py +++ b/tests/components/rdw/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from vehicle import Vehicle from homeassistant.components.rdw.const import CONF_LICENSE_PLATE, DOMAIN @@ -26,14 +26,14 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.rdw.async_setup_entry", return_value=True): yield @pytest.fixture -def mock_rdw_config_flow() -> Generator[None, MagicMock, None]: +def mock_rdw_config_flow() -> Generator[MagicMock]: """Return a mocked RDW client.""" with patch( "homeassistant.components.rdw.config_flow.RDW", autospec=True @@ -44,7 +44,7 @@ def mock_rdw_config_flow() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_rdw(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_rdw(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return a mocked WLED client.""" fixture: str = "rdw/11ZKZ3.json" if hasattr(request, "param") and request.param: diff --git a/tests/components/rdw/test_binary_sensor.py b/tests/components/rdw/test_binary_sensor.py index 4c21f5f881f..a0b8f37357c 100644 --- a/tests/components/rdw/test_binary_sensor.py +++ b/tests/components/rdw/test_binary_sensor.py @@ -11,12 +11,11 @@ from tests.common import MockConfigEntry async def test_vehicle_binary_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the RDW vehicle binary sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("binary_sensor.skoda_11zkz3_liability_insured") entry = entity_registry.async_get("binary_sensor.skoda_11zkz3_liability_insured") assert entry diff --git a/tests/components/rdw/test_sensor.py b/tests/components/rdw/test_sensor.py index ef8ce48e7ce..59384868c5a 100644 --- a/tests/components/rdw/test_sensor.py +++ b/tests/components/rdw/test_sensor.py @@ -16,12 +16,11 @@ from tests.common import MockConfigEntry async def test_vehicle_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the RDW vehicle sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.skoda_11zkz3_apk_expiration") entry = entity_registry.async_get("sensor.skoda_11zkz3_apk_expiration") assert entry diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index 5713e287222..e3b2638eded 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -17,16 +17,14 @@ async def test_validate_db_schema_fix_float_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - db_engine, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with postgresql and mysql. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", return_value={"events.double precision"}, @@ -50,17 +48,19 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_event_data( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with MySQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch("homeassistant.components.recorder.core.Recorder.dialect_name", "mysql"), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", return_value={"event_data.4-byte UTF-8"}, @@ -81,17 +81,19 @@ async def test_validate_db_schema_fix_utf8_issue_event_data( @pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with MySQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch("homeassistant.components.recorder.core.Recorder.dialect_name", "mysql"), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", return_value={"events.utf8mb4_unicode_ci"}, diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 7d14a873bfe..58910a4441a 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -17,16 +17,14 @@ async def test_validate_db_schema_fix_float_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - db_engine, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with postgresql and mysql. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", return_value={"states.double precision"}, @@ -52,17 +50,19 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_states( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with MySQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch("homeassistant.components.recorder.core.Recorder.dialect_name", "mysql"), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", return_value={"states.4-byte UTF-8"}, @@ -82,17 +82,19 @@ async def test_validate_db_schema_fix_utf8_issue_states( @pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_utf8_issue_state_attributes( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with MySQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch("homeassistant.components.recorder.core.Recorder.dialect_name", "mysql"), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", return_value={"state_attributes.4-byte UTF-8"}, @@ -113,17 +115,19 @@ async def test_validate_db_schema_fix_utf8_issue_state_attributes( @pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with MySQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch("homeassistant.components.recorder.core.Recorder.dialect_name", "mysql"), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", return_value={"states.utf8mb4_unicode_ci"}, diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 2a1c3c5d209..175cb6ecd1a 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -1,6 +1,5 @@ """Test removing statistics duplicates.""" -from collections.abc import Callable import importlib from pathlib import Path import sys @@ -11,7 +10,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session from homeassistant.components import recorder -from homeassistant.components.recorder import statistics +from homeassistant.components.recorder import Recorder, statistics from homeassistant.components.recorder.auto_repairs.statistics.duplicates import ( delete_statistics_duplicates, delete_statistics_meta_duplicates, @@ -21,20 +20,34 @@ from homeassistant.components.recorder.statistics import async_add_external_stat from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from ...common import wait_recording_done +from ...common import async_wait_recording_done -from tests.common import get_test_home_assistant +from tests.common import async_test_home_assistant +from tests.typing import RecorderInstanceGenerator -def test_delete_duplicates_no_duplicates( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +@pytest.fixture +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + + +@pytest.fixture +def setup_recorder(recorder_mock: Recorder) -> None: + """Set up recorder.""" + + +async def test_delete_duplicates_no_duplicates( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test removal of duplicated statistics.""" - hass = hass_recorder() - wait_recording_done(hass) + await async_wait_recording_done(hass) instance = recorder.get_instance(hass) with session_scope(hass=hass) as session: delete_statistics_duplicates(instance, hass, session) @@ -43,12 +56,13 @@ def test_delete_duplicates_no_duplicates( assert "Found duplicated" not in caplog.text -def test_duplicate_statistics_handle_integrity_error( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_duplicate_statistics_handle_integrity_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test the recorder does not blow up if statistics is duplicated.""" - hass = hass_recorder() - wait_recording_done(hass) + await async_wait_recording_done(hass) period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) @@ -93,7 +107,7 @@ def test_duplicate_statistics_handle_integrity_error( async_add_external_statistics( hass, external_energy_metadata_1, external_energy_statistics_2 ) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert insert_statistics_mock.call_count == 3 with session_scope(hass=hass) as session: @@ -126,7 +140,7 @@ def _create_engine_28(*args, **kwargs): return engine -def test_delete_metadata_duplicates( +async def test_delete_metadata_duplicates( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test removal of duplicated statistics.""" @@ -164,23 +178,7 @@ def test_delete_metadata_duplicates( "unit_of_measurement": "%", } - # Create some duplicated statistics_meta with schema version 28 - with ( - patch.object(recorder, "db_schema", old_db_schema), - patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), - patch( - "homeassistant.components.recorder.core.create_engine", - new=_create_engine_28, - ), - get_test_home_assistant() as hass, - ): - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) - + def add_statistics_meta(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: session.add( recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) @@ -192,8 +190,33 @@ def test_delete_metadata_duplicates( recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() + def get_statistics_meta(hass: HomeAssistant) -> list: + with session_scope(hass=hass, read_only=True) as session: + return list(session.query(recorder.db_schema.StatisticsMeta).all()) + + # Create some duplicated statistics_meta with schema version 28 + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION + ), + patch( + "homeassistant.components.recorder.core.create_engine", + new=_create_engine_28, + ), + ): + async with async_test_home_assistant() as hass: + recorder_helper.async_initialize_recorder(hass) + await async_setup_component( + hass, "recorder", {"recorder": {"db_url": dburl}} + ) + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + instance = recorder.get_instance(hass) + await instance.async_add_executor_job(add_statistics_meta, hass) + + tmp = await instance.async_add_executor_job(get_statistics_meta, hass) assert len(tmp) == 3 assert tmp[0].id == 1 assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" @@ -202,29 +225,29 @@ def test_delete_metadata_duplicates( assert tmp[2].id == 3 assert tmp[2].statistic_id == "test:fossil_percentage" - hass.stop() + await hass.async_stop() # Test that the duplicates are removed during migration from schema 28 - with get_test_home_assistant() as hass: + async with async_test_home_assistant() as hass: recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) + await async_setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + await hass.async_start() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) assert "Deleted 1 duplicated statistics_meta rows" in caplog.text - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 2 - assert tmp[0].id == 2 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 3 - assert tmp[1].statistic_id == "test:fossil_percentage" + instance = recorder.get_instance(hass) + tmp = await instance.async_add_executor_job(get_statistics_meta, hass) + assert len(tmp) == 2 + assert tmp[0].id == 2 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 3 + assert tmp[1].statistic_id == "test:fossil_percentage" - hass.stop() + await hass.async_stop() -def test_delete_metadata_duplicates_many( +async def test_delete_metadata_duplicates_many( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: """Test removal of duplicated statistics.""" @@ -262,23 +285,7 @@ def test_delete_metadata_duplicates_many( "unit_of_measurement": "%", } - # Create some duplicated statistics with schema version 28 - with ( - patch.object(recorder, "db_schema", old_db_schema), - patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), - patch( - "homeassistant.components.recorder.core.create_engine", - new=_create_engine_28, - ), - get_test_home_assistant() as hass, - ): - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) - + def add_statistics_meta(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: session.add( recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) @@ -302,36 +309,61 @@ def test_delete_metadata_duplicates_many( recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - hass.stop() + def get_statistics_meta(hass: HomeAssistant) -> list: + with session_scope(hass=hass, read_only=True) as session: + return list(session.query(recorder.db_schema.StatisticsMeta).all()) + + # Create some duplicated statistics with schema version 28 + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION + ), + patch( + "homeassistant.components.recorder.core.create_engine", + new=_create_engine_28, + ), + ): + async with async_test_home_assistant() as hass: + recorder_helper.async_initialize_recorder(hass) + await async_setup_component( + hass, "recorder", {"recorder": {"db_url": dburl}} + ) + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + instance = recorder.get_instance(hass) + await instance.async_add_executor_job(add_statistics_meta, hass) + + await hass.async_stop() # Test that the duplicates are removed during migration from schema 28 - with get_test_home_assistant() as hass: + async with async_test_home_assistant() as hass: recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) + await async_setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + await hass.async_start() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) assert "Deleted 1102 duplicated statistics_meta rows" in caplog.text - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 3 - assert tmp[0].id == 1101 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 1103 - assert tmp[1].statistic_id == "test:total_energy_import_tariff_2" - assert tmp[2].id == 1105 - assert tmp[2].statistic_id == "test:fossil_percentage" + instance = recorder.get_instance(hass) + tmp = await instance.async_add_executor_job(get_statistics_meta, hass) + assert len(tmp) == 3 + assert tmp[0].id == 1101 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 1103 + assert tmp[1].statistic_id == "test:total_energy_import_tariff_2" + assert tmp[2].id == 1105 + assert tmp[2].statistic_id == "test:fossil_percentage" - hass.stop() + await hass.async_stop() -def test_delete_metadata_duplicates_no_duplicates( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_delete_metadata_duplicates_no_duplicates( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, setup_recorder: None ) -> None: """Test removal of duplicated statistics.""" - hass = hass_recorder() - wait_recording_done(hass) + await async_wait_recording_done(hass) with session_scope(hass=hass) as session: instance = recorder.get_instance(hass) delete_statistics_meta_duplicates(instance, session) diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 0badceee0d2..f4e1d74aadf 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -11,18 +11,20 @@ from ...common import async_wait_recording_done from tests.typing import RecorderInstanceGenerator +@pytest.mark.parametrize("db_engine", ["mysql"]) @pytest.mark.parametrize("enable_schema_validation", [True]) async def test_validate_db_schema_fix_utf8_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with MySQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch("homeassistant.components.recorder.core.Recorder.dialect_name", "mysql"), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", return_value={"statistics_meta.4-byte UTF-8"}, @@ -51,15 +53,13 @@ async def test_validate_db_schema_fix_float_issue( caplog: pytest.LogCaptureFixture, table: str, db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with postgresql and mysql. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", return_value={f"{table}.double precision"}, @@ -90,17 +90,19 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ["mysql"]) async def test_validate_db_schema_fix_collation_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + recorder_dialect_name: None, + db_engine: str, ) -> None: """Test validating DB schema with MySQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ with ( - patch("homeassistant.components.recorder.core.Recorder.dialect_name", "mysql"), patch( "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_has_correct_collation", return_value={"statistics.utf8mb4_unicode_ci"}, diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index 14c74e2614e..d921c0cdbf8 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -1,7 +1,5 @@ """The test validating and repairing schema.""" -from unittest.mock import patch - import pytest from sqlalchemy import text @@ -28,17 +26,15 @@ async def test_validate_db_schema( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - db_engine, + db_engine: str, + recorder_dialect_name: None, ) -> None: """Test validating DB schema with MySQL and PostgreSQL. Note: The test uses SQLite, the purpose is only to exercise the code. """ - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) assert "Schema validation failed" not in caplog.text assert "Detected statistics schema errors" not in caplog.text assert "Database is about to correct DB schema errors" not in caplog.text diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 2ded3513a7e..c72b1ac830b 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -138,7 +138,7 @@ async def async_recorder_block_till_done(hass: HomeAssistant) -> None: def corrupt_db_file(test_db_file): """Corrupt an sqlite3 database file.""" - with open(test_db_file, "w+") as fhandle: + with open(test_db_file, "w+", encoding="utf8") as fhandle: fhandle.seek(200) fhandle.write("I am a corrupt db" * 100) diff --git a/tests/components/recorder/conftest.py b/tests/components/recorder/conftest.py new file mode 100644 index 00000000000..4db573fa65f --- /dev/null +++ b/tests/components/recorder/conftest.py @@ -0,0 +1,24 @@ +"""Fixtures for the recorder component tests.""" + +from unittest.mock import patch + +import pytest +from typing_extensions import Generator + +from homeassistant.components import recorder +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def recorder_dialect_name(hass: HomeAssistant, db_engine: str) -> Generator[None]: + """Patch the recorder dialect.""" + if instance := hass.data.get(recorder.DATA_INSTANCE): + instance.__dict__.pop("dialect_name", None) + with patch.object(instance, "_dialect_name", db_engine): + yield + instance.__dict__.pop("dialect_name", None) + else: + with patch( + "homeassistant.components.recorder.Recorder.dialect_name", db_engine + ): + yield diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 9062de01b59..12336dcc96a 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -19,6 +19,7 @@ from sqlalchemy import ( distinct, ) from sqlalchemy.orm import declarative_base +from sqlalchemy.orm.session import Session from homeassistant.core import Event, EventOrigin, State, split_entity_id from homeassistant.helpers.json import JSONEncoder @@ -141,8 +142,6 @@ class RecorderRuns(Base): # type: ignore[valid-type,misc] Specify point_in_time if you want to know which existed at that point in time inside the run. """ - from sqlalchemy.orm.session import Session - session = Session.object_session(self) assert session is not None, "RecorderRuns need to be persisted" diff --git a/tests/components/recorder/db_schema_42.py b/tests/components/recorder/db_schema_42.py index b8e49aef592..c0dfc70571d 100644 --- a/tests/components/recorder/db_schema_42.py +++ b/tests/components/recorder/db_schema_42.py @@ -54,7 +54,12 @@ from homeassistant.components.recorder.models import ( ulid_to_bytes_or_none, uuid_hex_to_bytes_or_none, ) +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + MATCH_ALL, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, @@ -577,10 +582,27 @@ class StateAttributes(Base): if state is None: return b"{}" if state_info := state.state_info: + unrecorded_attributes = state_info["unrecorded_attributes"] exclude_attrs = { *ALL_DOMAIN_EXCLUDE_ATTRS, - *state_info["unrecorded_attributes"], + *unrecorded_attributes, } + if MATCH_ALL in unrecorded_attributes: + # Don't exclude device class, state class, unit of measurement + # or friendly name when using the MATCH_ALL exclude constant + _exclude_attributes = { + k: v + for k, v in state.attributes.items() + if k + not in ( + ATTR_DEVICE_CLASS, + ATTR_STATE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + ATTR_FRIENDLY_NAME, + ) + } + exclude_attrs.update(_exclude_attributes) + else: exclude_attrs = ALL_DOMAIN_EXCLUDE_ATTRS encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index d181c449bbf..08fbef01bdd 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -31,7 +31,7 @@ async def test_async_pre_backup_with_timeout( pytest.raises(TimeoutError), ): await async_pre_backup(hass) - assert lock_mock.called + assert lock_mock.called async def test_async_pre_backup_with_migration( @@ -69,4 +69,4 @@ async def test_async_post_backup_failure( pytest.raises(HomeAssistantError), ): await async_post_backup(hass) - assert unlock_mock.called + assert unlock_mock.called diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index f5eec10f805..9c66d2ee169 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from sqlalchemy import select from sqlalchemy.engine.row import Row +from typing_extensions import AsyncGenerator from homeassistant.components.recorder import Recorder, get_instance from homeassistant.components.recorder.db_schema import EventData, Events, States @@ -38,7 +39,9 @@ def db_schema_32(): @pytest.fixture(name="legacy_recorder_mock") -async def legacy_recorder_mock_fixture(recorder_mock): +async def legacy_recorder_mock_fixture( + recorder_mock: Recorder, +) -> AsyncGenerator[Recorder]: """Fixture for legacy recorder mock.""" with patch.object(recorder_mock.states_meta_manager, "active", False): yield recorder_mock diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index ebcb0522e72..af846353467 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from copy import copy from datetime import datetime, timedelta import json @@ -41,12 +40,23 @@ from .common import ( assert_states_equal_without_context, async_recorder_block_till_done, async_wait_recording_done, - wait_recording_done, ) from tests.typing import RecorderInstanceGenerator +@pytest.fixture +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + + +@pytest.fixture(autouse=True) +def setup_recorder(recorder_mock: Recorder) -> recorder.Recorder: + """Set up recorder.""" + + async def _async_get_states( hass: HomeAssistant, utc_point_in_time: datetime, @@ -118,11 +128,10 @@ def _add_db_entries( ) -def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_full_significant_states_with_session_entity_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) with session_scope(hass=hass, read_only=True) as session: @@ -144,11 +153,10 @@ def test_get_full_significant_states_with_session_entity_no_matches( ) -def test_significant_states_with_session_entity_minimal_response_no_matches( - hass_recorder: Callable[..., HomeAssistant], +async def test_significant_states_with_session_entity_minimal_response_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) with session_scope(hass=hass, read_only=True) as session: @@ -176,14 +184,13 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ) -def test_significant_states_with_session_single_entity( - hass_recorder: Callable[..., HomeAssistant], +async def test_significant_states_with_session_single_entity( + hass: HomeAssistant, ) -> None: """Test get_significant_states_with_session with a single entity.""" - hass = hass_recorder() - hass.states.set("demo.id", "any", {"attr": True}) - hass.states.set("demo.id", "any2", {"attr": True}) - wait_recording_done(hass) + hass.states.async_set("demo.id", "any", {"attr": True}) + hass.states.async_set("demo.id", "any2", {"attr": True}) + await async_wait_recording_done(hass) now = dt_util.utcnow() with session_scope(hass=hass, read_only=True) as session: states = history.get_significant_states_with_session( @@ -206,17 +213,15 @@ def test_significant_states_with_session_single_entity( ({}, True, 3), ], ) -def test_state_changes_during_period( - hass_recorder: Callable[..., HomeAssistant], attributes, no_attributes, limit +async def test_state_changes_during_period( + hass: HomeAssistant, attributes, no_attributes, limit ) -> None: """Test state change during period.""" - hass = hass_recorder() entity_id = "media_player.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, attributes) return hass.states.get(entity_id) start = dt_util.utcnow() @@ -238,6 +243,7 @@ def test_state_changes_during_period( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes, limit=limit @@ -246,17 +252,15 @@ def test_state_changes_during_period( assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) -def test_state_changes_during_period_last_reported( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_last_reported( + hass: HomeAssistant, ) -> None: """Test state change during period.""" - hass = hass_recorder() entity_id = "media_player.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() @@ -275,23 +279,22 @@ def test_state_changes_during_period_last_reported( freezer.move_to(end) set_state("Netflix") + await async_wait_recording_done(hass) hist = history.state_changes_during_period(hass, start, end, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_descending( + hass: HomeAssistant, ) -> None: """Test state change during period descending.""" - hass = hass_recorder() entity_id = "media_player.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, {"any": 1}) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, {"any": 1}) return hass.states.get(entity_id) start = dt_util.utcnow().replace(microsecond=0) @@ -320,6 +323,7 @@ def test_state_changes_during_period_descending( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes=False, descending=False @@ -385,15 +389,13 @@ def test_state_changes_during_period_descending( ) -def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_last_state_changes(hass: HomeAssistant) -> None: """Test number of state changes.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -409,23 +411,22 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> freezer.move_to(point2) states.append(set_state("3")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_get_last_state_changes_last_reported( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_last_state_changes_last_reported( + hass: HomeAssistant, ) -> None: """Test number of state changes.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -441,21 +442,20 @@ def test_get_last_state_changes_last_reported( freezer.move_to(point2) states.append(set_state("2")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_last_state_change(hass: HomeAssistant) -> None: """Test getting the last state change for an entity.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -471,27 +471,26 @@ def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> N freezer.move_to(point2) states.append(set_state("3")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 1, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant], +async def test_ensure_state_can_be_copied( + hass: HomeAssistant, ) -> None: """Ensure a state can pass though copy(). The filter integration uses copy() on states from history. """ - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -502,6 +501,7 @@ def test_ensure_state_can_be_copied( freezer.move_to(point) set_state("2") + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -509,21 +509,22 @@ def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) -def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_minimal_response( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned. @@ -534,8 +535,9 @@ def test_get_significant_states_minimal_response( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states( hass, zero, four, minimal_response=True, entity_ids=list(states) ) @@ -591,8 +593,8 @@ def test_get_significant_states_minimal_response( @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) -def test_get_significant_states_with_initial( - time_zone, hass_recorder: Callable[..., HomeAssistant] +async def test_get_significant_states_with_initial( + time_zone, hass: HomeAssistant ) -> None: """Test that only significant states are returned. @@ -600,9 +602,10 @@ def test_get_significant_states_with_initial( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one_and_half = zero + timedelta(seconds=1.5) for entity_id in states: if entity_id == "media_player.test": @@ -621,8 +624,8 @@ def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_without_initial( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned. @@ -630,8 +633,9 @@ def test_get_significant_states_without_initial( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) one_and_half = zero + timedelta(seconds=1.5) @@ -654,12 +658,13 @@ def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_entity_id( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test"] @@ -671,12 +676,12 @@ def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_multiple_entity_ids( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -693,16 +698,17 @@ def test_get_significant_states_multiple_entity_ids( ) -def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_are_ordered( + hass: HomeAssistant, ) -> None: """Test order of results from get_significant_states. When entity ids are given, the results should be returned with the data in the same order. """ - hass = hass_recorder() zero, four, _states = record_states(hass) + await async_wait_recording_done(hass) + entity_ids = ["media_player.test", "media_player.test2"] hist = history.get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids @@ -711,17 +717,15 @@ def test_get_significant_states_are_ordered( assert list(hist.keys()) == entity_ids -def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_only( + hass: HomeAssistant, ) -> None: """Test significant states when significant_states_only is set.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=4) @@ -742,6 +746,7 @@ def test_get_significant_states_only( freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) + await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -775,7 +780,7 @@ def test_get_significant_states_only( async def test_get_significant_states_only_minimal_response( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test significant states when significant_states_only is True.""" now = dt_util.utcnow() @@ -801,7 +806,9 @@ async def test_get_significant_states_only_minimal_response( assert len(hist["sensor.test"]) == 3 -def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: +def record_states( + hass: HomeAssistant, +) -> tuple[datetime, datetime, dict[str, list[State]]]: """Record some test states. We inject a bunch of state updates from media player, zone and @@ -818,8 +825,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def set_state(entity_id, state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) zero = dt_util.utcnow() @@ -886,7 +892,6 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: async def test_state_changes_during_period_query_during_migration_to_schema_25( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, recorder_db_url: str, ) -> None: @@ -895,7 +900,7 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( # This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes return - instance = await async_setup_recorder_instance(hass, {}) + instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): start = dt_util.utcnow() @@ -953,7 +958,6 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, recorder_db_url: str, ) -> None: @@ -962,7 +966,7 @@ async def test_get_states_query_during_migration_to_schema_25( # This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes return - instance = await async_setup_recorder_instance(hass, {}) + instance = recorder.get_instance(hass) start = dt_util.utcnow() point = start + timedelta(seconds=1) @@ -1004,7 +1008,6 @@ async def test_get_states_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25_multiple_entities( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, recorder_db_url: str, ) -> None: @@ -1013,7 +1016,7 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( # This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes return - instance = await async_setup_recorder_instance(hass, {}) + instance = recorder.get_instance(hass) start = dt_util.utcnow() point = start + timedelta(seconds=1) @@ -1058,12 +1061,9 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( async def test_get_full_significant_states_handles_empty_last_changed( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, ) -> None: """Test getting states when last_changed is null.""" - await async_setup_recorder_instance(hass, {}) - now = dt_util.utcnow() hass.states.async_set("sensor.one", "on", {"attr": "original"}) state0 = hass.states.get("sensor.one") @@ -1155,21 +1155,20 @@ 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], +async def test_state_changes_during_period_multiple_entities_single_test( + hass: HomeAssistant, ) -> None: """Test state change during period with multiple entities in the same test. This test ensures the sqlalchemy query cache does not generate incorrect results. """ - hass = hass_recorder() start = dt_util.utcnow() test_entites = {f"sensor.{i}": str(i) for i in range(30)} for entity_id, value in test_entites.items(): - hass.states.set(entity_id, value) + hass.states.async_set(entity_id, value) + await async_wait_recording_done(hass) - wait_recording_done(hass) end = dt_util.utcnow() for entity_id, value in test_entites.items(): @@ -1180,11 +1179,9 @@ def test_state_changes_during_period_multiple_entities_single_test( @pytest.mark.freeze_time("2039-01-19 03:14:07.555555-00:00") async def test_get_full_significant_states_past_year_2038( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, ) -> None: """Test we can store times past year 2038.""" - await async_setup_recorder_instance(hass, {}) past_2038_time = dt_util.parse_datetime("2039-01-19 03:14:07.555555-00:00") hass.states.async_set("sensor.one", "on", {"attr": "original"}) state0 = hass.states.get("sensor.one") @@ -1214,31 +1211,28 @@ async def test_get_full_significant_states_past_year_2038( assert sensor_one_states[0].last_updated == past_2038_time -def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_without_entity_ids_raises( + hass: HomeAssistant, ) -> None: """Test at least one entity id is required for get_significant_states.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_ids must be provided"): history.get_significant_states(hass, now, None) -def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_without_entity_ids_raises( + hass: HomeAssistant, ) -> None: """Test at least one entity id is required for state_changes_during_period.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_id must be provided"): history.state_changes_during_period(hass, now, None) -def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_with_filters_raises( + hass: HomeAssistant, ) -> None: """Test passing filters is no longer supported.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(NotImplementedError, match="Filters are no longer supported"): history.get_significant_states( @@ -1246,29 +1240,26 @@ def test_get_significant_states_with_filters_raises( ) -def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} -def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert ( history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} ) -def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" - hass = hass_recorder() assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index 2d0b3398a87..e5e80b0cdb9 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from copy import copy from datetime import datetime, timedelta import json @@ -12,7 +11,7 @@ from freezegun import freeze_time import pytest from homeassistant.components import recorder -from homeassistant.components.recorder import history +from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope @@ -25,10 +24,19 @@ from .common import ( assert_multiple_states_equal_without_context, assert_multiple_states_equal_without_context_and_last_changed, assert_states_equal_without_context, + async_wait_recording_done, old_db_schema, - wait_recording_done, ) +from tests.typing import RecorderInstanceGenerator + + +@pytest.fixture +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + @pytest.fixture(autouse=True) def db_schema_30(): @@ -37,11 +45,15 @@ def db_schema_30(): yield -def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant], +@pytest.fixture(autouse=True) +def setup_recorder(db_schema_30, recorder_mock: Recorder) -> recorder.Recorder: + """Set up recorder.""" + + +async def test_get_full_significant_states_with_session_entity_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) instance = recorder.get_instance(hass) @@ -67,11 +79,10 @@ def test_get_full_significant_states_with_session_entity_no_matches( ) -def test_significant_states_with_session_entity_minimal_response_no_matches( - hass_recorder: Callable[..., HomeAssistant], +async def test_significant_states_with_session_entity_minimal_response_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) instance = recorder.get_instance(hass) @@ -112,19 +123,17 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ({}, True, 3), ], ) -def test_state_changes_during_period( - hass_recorder: Callable[..., HomeAssistant], attributes, no_attributes, limit +async def test_state_changes_during_period( + hass: HomeAssistant, attributes, no_attributes, limit ) -> None: """Test state change during period.""" - hass = hass_recorder() entity_id = "media_player.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, attributes) return hass.states.get(entity_id) start = dt_util.utcnow() @@ -146,6 +155,7 @@ def test_state_changes_during_period( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes, limit=limit @@ -154,19 +164,17 @@ def test_state_changes_during_period( assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) -def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_descending( + hass: HomeAssistant, ) -> None: """Test state change during period descending.""" - hass = hass_recorder() entity_id = "media_player.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, {"any": 1}) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, {"any": 1}) return hass.states.get(entity_id) start = dt_util.utcnow() @@ -196,6 +204,7 @@ def test_state_changes_during_period_descending( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes=False, descending=False @@ -210,17 +219,15 @@ def test_state_changes_during_period_descending( ) -def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_last_state_changes(hass: HomeAssistant) -> None: """Test number of state changes.""" - hass = hass_recorder() entity_id = "sensor.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -236,29 +243,28 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> freezer.move_to(point2) states.append(set_state("3")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant], +async def test_ensure_state_can_be_copied( + hass: HomeAssistant, ) -> None: """Ensure a state can pass though copy(). The filter integration uses copy() on states from history. """ - hass = hass_recorder() entity_id = "sensor.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -269,6 +275,7 @@ def test_ensure_state_can_be_copied( freezer.move_to(point) set_state("2") + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -280,24 +287,23 @@ def test_ensure_state_can_be_copied( ) -def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +async def test_get_significant_states_minimal_response(hass: HomeAssistant) -> None: """Test that only significant states are returned. When minimal responses is set only the first and @@ -306,10 +312,11 @@ def test_get_significant_states_minimal_response( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states( hass, zero, four, minimal_response=True, entity_ids=list(states) ) @@ -364,19 +371,18 @@ def test_get_significant_states_minimal_response( ) -def test_get_significant_states_with_initial( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +async def test_get_significant_states_with_initial(hass: HomeAssistant) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) one_and_half = zero + timedelta(seconds=1.5) @@ -398,19 +404,18 @@ def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +async def test_get_significant_states_without_initial(hass: HomeAssistant) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) one_and_half = zero + timedelta(seconds=1.5) @@ -432,14 +437,13 @@ def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +async def test_get_significant_states_entity_id(hass: HomeAssistant) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test"] @@ -450,14 +454,13 @@ def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +async def test_get_significant_states_multiple_entity_ids(hass: HomeAssistant) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test2"] @@ -477,19 +480,18 @@ def test_get_significant_states_multiple_entity_ids( ) -def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +async def test_get_significant_states_are_ordered(hass: HomeAssistant) -> None: """Test order of results from get_significant_states. When entity ids are given, the results should be returned with the data in the same order. """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, _states = record_states(hass) + await async_wait_recording_done(hass) + entity_ids = ["media_player.test", "media_player.test2"] hist = history.get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids @@ -498,19 +500,15 @@ def test_get_significant_states_are_ordered( assert list(hist.keys()) == entity_ids -def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +async def test_get_significant_states_only(hass: HomeAssistant) -> None: """Test significant states when significant_states_only is set.""" - hass = hass_recorder() entity_id = "sensor.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=4) @@ -531,6 +529,7 @@ def test_get_significant_states_only( freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) + await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -563,7 +562,9 @@ def test_get_significant_states_only( ) -def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: +def record_states( + hass: HomeAssistant, +) -> tuple[datetime, datetime, dict[str, list[State]]]: """Record some test states. We inject a bunch of state updates from media player, zone and @@ -579,8 +580,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def set_state(entity_id, state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) zero = dt_util.utcnow() @@ -639,23 +639,22 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: return zero, four, states -def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_multiple_entities_single_test( + hass: HomeAssistant, ) -> None: """Test state change during period with multiple entities in the same test. This test ensures the sqlalchemy query cache does not generate incorrect results. """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): start = dt_util.utcnow() test_entites = {f"sensor.{i}": str(i) for i in range(30)} for entity_id, value in test_entites.items(): - hass.states.set(entity_id, value) + hass.states.async_set(entity_id, value) + await async_wait_recording_done(hass) - wait_recording_done(hass) end = dt_util.utcnow() for entity_id, value in test_entites.items(): @@ -664,31 +663,24 @@ def test_state_changes_during_period_multiple_entities_single_test( assert hist[entity_id][0].state == value -def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +def test_get_significant_states_without_entity_ids_raises(hass: HomeAssistant) -> None: """Test at least one entity id is required for get_significant_states.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_ids must be provided"): history.get_significant_states(hass, now, None) def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], + hass: HomeAssistant, ) -> None: """Test at least one entity id is required for state_changes_during_period.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_id must be provided"): history.state_changes_during_period(hass, now, None) -def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant], -) -> None: +def test_get_significant_states_with_filters_raises(hass: HomeAssistant) -> None: """Test passing filters is no longer supported.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(NotImplementedError, match="Filters are no longer supported"): history.get_significant_states( @@ -697,19 +689,17 @@ 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: HomeAssistant, ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], + hass: HomeAssistant, ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert ( history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} @@ -717,8 +707,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: HomeAssistant, ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" - hass = hass_recorder() assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 5acf07b0604..8a3e6a58ab3 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from copy import copy from datetime import datetime, timedelta import json @@ -12,7 +11,7 @@ from freezegun import freeze_time import pytest from homeassistant.components import recorder -from homeassistant.components.recorder import history +from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.util import session_scope @@ -25,10 +24,19 @@ from .common import ( assert_multiple_states_equal_without_context, assert_multiple_states_equal_without_context_and_last_changed, assert_states_equal_without_context, + async_wait_recording_done, old_db_schema, - wait_recording_done, ) +from tests.typing import RecorderInstanceGenerator + + +@pytest.fixture +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + @pytest.fixture(autouse=True) def db_schema_32(): @@ -37,11 +45,15 @@ def db_schema_32(): yield -def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant], +@pytest.fixture(autouse=True) +def setup_recorder(db_schema_32, recorder_mock: Recorder) -> recorder.Recorder: + """Set up recorder.""" + + +async def test_get_full_significant_states_with_session_entity_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) instance = recorder.get_instance(hass) @@ -67,11 +79,10 @@ def test_get_full_significant_states_with_session_entity_no_matches( ) -def test_significant_states_with_session_entity_minimal_response_no_matches( - hass_recorder: Callable[..., HomeAssistant], +async def test_significant_states_with_session_entity_minimal_response_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) instance = recorder.get_instance(hass) @@ -112,19 +123,17 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ({}, True, 3), ], ) -def test_state_changes_during_period( - hass_recorder: Callable[..., HomeAssistant], attributes, no_attributes, limit +async def test_state_changes_during_period( + hass: HomeAssistant, attributes, no_attributes, limit ) -> None: """Test state change during period.""" - hass = hass_recorder() entity_id = "media_player.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, attributes) return hass.states.get(entity_id) start = dt_util.utcnow() @@ -146,6 +155,7 @@ def test_state_changes_during_period( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes, limit=limit @@ -154,19 +164,17 @@ def test_state_changes_during_period( assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) -def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_descending( + hass: HomeAssistant, ) -> None: """Test state change during period descending.""" - hass = hass_recorder() entity_id = "media_player.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, {"any": 1}) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, {"any": 1}) return hass.states.get(entity_id) start = dt_util.utcnow() @@ -195,6 +203,7 @@ def test_state_changes_during_period_descending( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes=False, descending=False @@ -209,17 +218,15 @@ def test_state_changes_during_period_descending( ) -def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_last_state_changes(hass: HomeAssistant) -> None: """Test number of state changes.""" - hass = hass_recorder() entity_id = "sensor.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -235,29 +242,28 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> freezer.move_to(point2) states.append(set_state("3")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant], +async def test_ensure_state_can_be_copied( + hass: HomeAssistant, ) -> None: """Ensure a state can pass though copy(). The filter integration uses copy() on states from history. """ - hass = hass_recorder() entity_id = "sensor.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -268,6 +274,7 @@ def test_ensure_state_can_be_copied( freezer.move_to(point) set_state("2") + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -279,23 +286,24 @@ def test_ensure_state_can_be_copied( ) -def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_minimal_response( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned. @@ -305,10 +313,11 @@ def test_get_significant_states_minimal_response( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states( hass, zero, four, minimal_response=True, entity_ids=list(states) ) @@ -364,8 +373,8 @@ def test_get_significant_states_minimal_response( @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) -def test_get_significant_states_with_initial( - time_zone, hass_recorder: Callable[..., HomeAssistant] +async def test_get_significant_states_with_initial( + time_zone, hass: HomeAssistant ) -> None: """Test that only significant states are returned. @@ -373,9 +382,10 @@ def test_get_significant_states_with_initial( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one = zero + timedelta(seconds=1) one_and_half = zero + timedelta(seconds=1.5) for entity_id in states: @@ -391,8 +401,8 @@ def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_without_initial( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned. @@ -400,10 +410,11 @@ def test_get_significant_states_without_initial( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) one_and_half = zero + timedelta(seconds=1.5) @@ -425,14 +436,15 @@ def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_entity_id( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test"] @@ -443,14 +455,15 @@ def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_multiple_entity_ids( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test2"] @@ -470,19 +483,19 @@ def test_get_significant_states_multiple_entity_ids( ) -def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_are_ordered( + hass: HomeAssistant, ) -> None: """Test order of results from get_significant_states. When entity ids are given, the results should be returned with the data in the same order. """ - hass = hass_recorder() - instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): zero, four, _states = record_states(hass) + await async_wait_recording_done(hass) + entity_ids = ["media_player.test", "media_player.test2"] hist = history.get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids @@ -491,19 +504,17 @@ def test_get_significant_states_are_ordered( assert list(hist.keys()) == entity_ids -def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_only( + hass: HomeAssistant, ) -> None: """Test significant states when significant_states_only is set.""" - hass = hass_recorder() entity_id = "sensor.test" instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): def set_state(state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=4) @@ -524,6 +535,7 @@ def test_get_significant_states_only( freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) + await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -556,7 +568,9 @@ def test_get_significant_states_only( ) -def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: +def record_states( + hass: HomeAssistant, +) -> tuple[datetime, datetime, dict[str, list[State]]]: """Record some test states. We inject a bunch of state updates from media player, zone and @@ -572,8 +586,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def set_state(entity_id, state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) zero = dt_util.utcnow() @@ -632,23 +645,22 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: return zero, four, states -def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_multiple_entities_single_test( + hass: HomeAssistant, ) -> None: """Test state change during period with multiple entities in the same test. This test ensures the sqlalchemy query cache does not generate incorrect results. """ - hass = hass_recorder() instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): start = dt_util.utcnow() test_entites = {f"sensor.{i}": str(i) for i in range(30)} for entity_id, value in test_entites.items(): - hass.states.set(entity_id, value) + hass.states.async_set(entity_id, value) - wait_recording_done(hass) + await async_wait_recording_done(hass) end = dt_util.utcnow() for entity_id, value in test_entites.items(): @@ -657,31 +669,28 @@ def test_state_changes_during_period_multiple_entities_single_test( assert hist[entity_id][0].state == value -def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_without_entity_ids_raises( + hass: HomeAssistant, ) -> None: """Test at least one entity id is required for get_significant_states.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_ids must be provided"): history.get_significant_states(hass, now, None) -def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_without_entity_ids_raises( + hass: HomeAssistant, ) -> None: """Test at least one entity id is required for state_changes_during_period.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_id must be provided"): history.state_changes_during_period(hass, now, None) -def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_with_filters_raises( + hass: HomeAssistant, ) -> None: """Test passing filters is no longer supported.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(NotImplementedError, match="Filters are no longer supported"): history.get_significant_states( @@ -689,29 +698,26 @@ def test_get_significant_states_with_filters_raises( ) -def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} -def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert ( history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} ) -def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" - hass = hass_recorder() assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index e342799c3a8..083d4c0930e 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from copy import copy from datetime import datetime, timedelta import json @@ -35,13 +34,19 @@ from .common import ( async_recorder_block_till_done, async_wait_recording_done, old_db_schema, - wait_recording_done, ) from .db_schema_42 import Events, RecorderRuns, StateAttributes, States, StatesMeta from tests.typing import RecorderInstanceGenerator +@pytest.fixture +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + + @pytest.fixture(autouse=True) def db_schema_42(): """Fixture to initialize the db with the old schema 42.""" @@ -49,6 +54,11 @@ def db_schema_42(): yield +@pytest.fixture(autouse=True) +def setup_recorder(db_schema_42, recorder_mock: Recorder) -> recorder.Recorder: + """Set up recorder.""" + + async def _async_get_states( hass: HomeAssistant, utc_point_in_time: datetime, @@ -120,11 +130,10 @@ def _add_db_entries( ) -def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_full_significant_states_with_session_entity_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) with session_scope(hass=hass, read_only=True) as session: @@ -146,11 +155,10 @@ def test_get_full_significant_states_with_session_entity_no_matches( ) -def test_significant_states_with_session_entity_minimal_response_no_matches( - hass_recorder: Callable[..., HomeAssistant], +async def test_significant_states_with_session_entity_minimal_response_no_matches( + hass: HomeAssistant, ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" - hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) with session_scope(hass=hass, read_only=True) as session: @@ -178,14 +186,13 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ) -def test_significant_states_with_session_single_entity( - hass_recorder: Callable[..., HomeAssistant], +async def test_significant_states_with_session_single_entity( + hass: HomeAssistant, ) -> None: """Test get_significant_states_with_session with a single entity.""" - hass = hass_recorder() - hass.states.set("demo.id", "any", {"attr": True}) - hass.states.set("demo.id", "any2", {"attr": True}) - wait_recording_done(hass) + hass.states.async_set("demo.id", "any", {"attr": True}) + hass.states.async_set("demo.id", "any2", {"attr": True}) + await async_wait_recording_done(hass) now = dt_util.utcnow() with session_scope(hass=hass, read_only=True) as session: states = history.get_significant_states_with_session( @@ -208,17 +215,15 @@ def test_significant_states_with_session_single_entity( ({}, True, 3), ], ) -def test_state_changes_during_period( - hass_recorder: Callable[..., HomeAssistant], attributes, no_attributes, limit +async def test_state_changes_during_period( + hass: HomeAssistant, attributes, no_attributes, limit ) -> None: """Test state change during period.""" - hass = hass_recorder() entity_id = "media_player.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, attributes) return hass.states.get(entity_id) start = dt_util.utcnow() @@ -240,6 +245,7 @@ def test_state_changes_during_period( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes, limit=limit @@ -248,17 +254,15 @@ def test_state_changes_during_period( assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) -def test_state_changes_during_period_last_reported( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_last_reported( + hass: HomeAssistant, ) -> None: """Test state change during period.""" - hass = hass_recorder() entity_id = "media_player.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return ha.State.from_dict(hass.states.get(entity_id).as_dict()) start = dt_util.utcnow() @@ -277,23 +281,22 @@ def test_state_changes_during_period_last_reported( freezer.move_to(end) set_state("Netflix") + await async_wait_recording_done(hass) hist = history.state_changes_during_period(hass, start, end, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_descending( + hass: HomeAssistant, ) -> None: """Test state change during period descending.""" - hass = hass_recorder() entity_id = "media_player.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state, {"any": 1}) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, {"any": 1}) return hass.states.get(entity_id) start = dt_util.utcnow().replace(microsecond=0) @@ -322,6 +325,7 @@ def test_state_changes_during_period_descending( freezer.move_to(end) set_state("Netflix") set_state("Plex") + await async_wait_recording_done(hass) hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes=False, descending=False @@ -387,15 +391,13 @@ def test_state_changes_during_period_descending( ) -def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_last_state_changes(hass: HomeAssistant) -> None: """Test number of state changes.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -411,23 +413,22 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> freezer.move_to(point2) states.append(set_state("3")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_get_last_state_changes_last_reported( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_last_state_changes_last_reported( + hass: HomeAssistant, ) -> None: """Test number of state changes.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return ha.State.from_dict(hass.states.get(entity_id).as_dict()) start = dt_util.utcnow() - timedelta(minutes=2) @@ -443,21 +444,20 @@ def test_get_last_state_changes_last_reported( freezer.move_to(point2) states.append(set_state("2")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_last_state_change(hass: HomeAssistant) -> None: """Test getting the last state change for an entity.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -473,27 +473,26 @@ def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> N freezer.move_to(point2) states.append(set_state("3")) + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 1, entity_id) assert_multiple_states_equal_without_context(states, hist[entity_id]) -def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant], +async def test_ensure_state_can_be_copied( + hass: HomeAssistant, ) -> None: """Ensure a state can pass though copy(). The filter integration uses copy() on states from history. """ - hass = hass_recorder() entity_id = "sensor.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) + hass.states.async_set(entity_id, state) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -504,6 +503,7 @@ def test_ensure_state_can_be_copied( freezer.move_to(point) set_state("2") + await async_wait_recording_done(hass) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -511,21 +511,22 @@ def test_ensure_state_can_be_copied( assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) -def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_get_significant_states(hass: HomeAssistant) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_minimal_response( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned. @@ -536,8 +537,9 @@ def test_get_significant_states_minimal_response( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + hist = history.get_significant_states( hass, zero, four, minimal_response=True, entity_ids=list(states) ) @@ -593,8 +595,8 @@ def test_get_significant_states_minimal_response( @pytest.mark.parametrize("time_zone", ["Europe/Berlin", "US/Hawaii", "UTC"]) -def test_get_significant_states_with_initial( - time_zone, hass_recorder: Callable[..., HomeAssistant] +async def test_get_significant_states_with_initial( + time_zone, hass: HomeAssistant ) -> None: """Test that only significant states are returned. @@ -602,9 +604,10 @@ def test_get_significant_states_with_initial( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one_and_half = zero + timedelta(seconds=1.5) for entity_id in states: if entity_id == "media_player.test": @@ -623,8 +626,8 @@ def test_get_significant_states_with_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_without_initial( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned. @@ -632,8 +635,9 @@ def test_get_significant_states_without_initial( includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) one_and_half = zero + timedelta(seconds=1.5) @@ -656,12 +660,13 @@ def test_get_significant_states_without_initial( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_entity_id( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) + del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test"] @@ -673,12 +678,12 @@ def test_get_significant_states_entity_id( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_multiple_entity_ids( + hass: HomeAssistant, ) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_recorder() zero, four, states = record_states(hass) + await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -695,16 +700,17 @@ def test_get_significant_states_multiple_entity_ids( ) -def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_are_ordered( + hass: HomeAssistant, ) -> None: """Test order of results from get_significant_states. When entity ids are given, the results should be returned with the data in the same order. """ - hass = hass_recorder() zero, four, _states = record_states(hass) + await async_wait_recording_done(hass) + entity_ids = ["media_player.test", "media_player.test2"] hist = history.get_significant_states(hass, zero, four, entity_ids) assert list(hist.keys()) == entity_ids @@ -713,17 +719,15 @@ def test_get_significant_states_are_ordered( assert list(hist.keys()) == entity_ids -def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_only( + hass: HomeAssistant, ) -> None: """Test significant states when significant_states_only is set.""" - hass = hass_recorder() entity_id = "sensor.test" def set_state(state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=4) @@ -744,6 +748,7 @@ def test_get_significant_states_only( freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) + await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -777,7 +782,7 @@ def test_get_significant_states_only( async def test_get_significant_states_only_minimal_response( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, ) -> None: """Test significant states when significant_states_only is True.""" now = dt_util.utcnow() @@ -803,7 +808,9 @@ async def test_get_significant_states_only_minimal_response( assert len(hist["sensor.test"]) == 3 -def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: +def record_states( + hass: HomeAssistant, +) -> tuple[datetime, datetime, dict[str, list[State]]]: """Record some test states. We inject a bunch of state updates from media player, zone and @@ -820,8 +827,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def set_state(entity_id, state, **kwargs): """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, **kwargs) return hass.states.get(entity_id) zero = dt_util.utcnow() @@ -888,7 +894,6 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: async def test_state_changes_during_period_query_during_migration_to_schema_25( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, recorder_db_url: str, ) -> None: @@ -897,7 +902,7 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( # This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes return - instance = await async_setup_recorder_instance(hass, {}) + instance = recorder.get_instance(hass) with patch.object(instance.states_meta_manager, "active", False): start = dt_util.utcnow() @@ -955,7 +960,6 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, recorder_db_url: str, ) -> None: @@ -964,7 +968,7 @@ async def test_get_states_query_during_migration_to_schema_25( # This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes return - instance = await async_setup_recorder_instance(hass, {}) + instance = recorder.get_instance(hass) start = dt_util.utcnow() point = start + timedelta(seconds=1) @@ -1006,7 +1010,6 @@ async def test_get_states_query_during_migration_to_schema_25( async def test_get_states_query_during_migration_to_schema_25_multiple_entities( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, recorder_db_url: str, ) -> None: @@ -1015,7 +1018,7 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( # This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop table state_attributes return - instance = await async_setup_recorder_instance(hass, {}) + instance = recorder.get_instance(hass) start = dt_util.utcnow() point = start + timedelta(seconds=1) @@ -1060,12 +1063,9 @@ async def test_get_states_query_during_migration_to_schema_25_multiple_entities( async def test_get_full_significant_states_handles_empty_last_changed( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, ) -> None: """Test getting states when last_changed is null.""" - await async_setup_recorder_instance(hass, {}) - now = dt_util.utcnow() hass.states.async_set("sensor.one", "on", {"attr": "original"}) state0 = hass.states.get("sensor.one") @@ -1157,21 +1157,20 @@ 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], +async def test_state_changes_during_period_multiple_entities_single_test( + hass: HomeAssistant, ) -> None: """Test state change during period with multiple entities in the same test. This test ensures the sqlalchemy query cache does not generate incorrect results. """ - hass = hass_recorder() start = dt_util.utcnow() test_entites = {f"sensor.{i}": str(i) for i in range(30)} for entity_id, value in test_entites.items(): - hass.states.set(entity_id, value) + hass.states.async_set(entity_id, value) - wait_recording_done(hass) + await async_wait_recording_done(hass) end = dt_util.utcnow() for entity_id, value in test_entites.items(): @@ -1182,11 +1181,9 @@ def test_state_changes_during_period_multiple_entities_single_test( @pytest.mark.freeze_time("2039-01-19 03:14:07.555555-00:00") async def test_get_full_significant_states_past_year_2038( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, ) -> None: """Test we can store times past year 2038.""" - await async_setup_recorder_instance(hass, {}) past_2038_time = dt_util.parse_datetime("2039-01-19 03:14:07.555555-00:00") hass.states.async_set("sensor.one", "on", {"attr": "original"}) state0 = hass.states.get("sensor.one") @@ -1216,31 +1213,28 @@ async def test_get_full_significant_states_past_year_2038( assert sensor_one_states[0].last_updated == past_2038_time -def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_without_entity_ids_raises( + hass: HomeAssistant, ) -> None: """Test at least one entity id is required for get_significant_states.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_ids must be provided"): history.get_significant_states(hass, now, None) -def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_without_entity_ids_raises( + hass: HomeAssistant, ) -> None: """Test at least one entity id is required for state_changes_during_period.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(ValueError, match="entity_id must be provided"): history.state_changes_during_period(hass, now, None) -def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_with_filters_raises( + hass: HomeAssistant, ) -> None: """Test passing filters is no longer supported.""" - hass = hass_recorder() now = dt_util.utcnow() with pytest.raises(NotImplementedError, match="Filters are no longer supported"): history.get_significant_states( @@ -1248,29 +1242,26 @@ def test_get_significant_states_with_filters_raises( ) -def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_significant_states_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} -def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" - hass = hass_recorder() now = dt_util.utcnow() assert ( history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} ) -def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant], +async def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( + hass: HomeAssistant, ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" - hass = hass_recorder() assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index d9f0e7d296f..52947ce0c19 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -3,17 +3,18 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from datetime import datetime, timedelta from pathlib import Path import sqlite3 import threading -from typing import cast +from typing import Any, cast from unittest.mock import MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError +from sqlalchemy.pool import QueuePool +from typing_extensions import Generator from homeassistant.components import recorder from homeassistant.components.recorder import ( @@ -30,7 +31,6 @@ from homeassistant.components.recorder import ( db_schema, get_instance, migration, - pool, statistics, ) from homeassistant.components.recorder.const import ( @@ -74,34 +74,48 @@ from homeassistant.const import ( STATE_UNLOCKED, ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, recorder as recorder_helper -from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry -from homeassistant.setup import async_setup_component, setup_component +from homeassistant.helpers import ( + entity_registry as er, + issue_registry as ir, + recorder as recorder_helper, +) +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads from .common import ( async_block_recorder, + async_recorder_block_till_done, async_wait_recording_done, convert_pending_states_to_meta, corrupt_db_file, run_information_with_session, - wait_recording_done, ) from tests.common import ( MockEntity, MockEntityPlatform, async_fire_time_changed, - fire_time_changed, - get_test_home_assistant, + async_test_home_assistant, mock_platform, ) from tests.typing import RecorderInstanceGenerator @pytest.fixture -def small_cache_size() -> None: +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + + +@pytest.fixture +def setup_recorder(recorder_mock: Recorder) -> None: + """Set up recorder.""" + + +@pytest.fixture +def small_cache_size() -> Generator[None]: """Patch the default cache size to 8.""" with ( patch.object(state_attributes_table_manager, "CACHE_SIZE", 8), @@ -127,8 +141,8 @@ def _default_recorder(hass): async def test_shutdown_before_startup_finishes( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, recorder_db_url: str, tmp_path: Path, ) -> None: @@ -148,14 +162,18 @@ async def test_shutdown_before_startup_finishes( await recorder_helper.async_wait_recorder(hass) instance = get_instance(hass) - session = await hass.async_add_executor_job(instance.get_session) + session = await instance.async_add_executor_job(instance.get_session) with patch.object(instance, "engine"): hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) await hass.async_block_till_done() await hass.async_stop() - run_info = await hass.async_add_executor_job(run_information_with_session, session) + def _run_information_with_session(): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + return run_information_with_session(session) + + run_info = await instance.async_add_executor_job(_run_information_with_session) assert run_info.run_id == 1 assert run_info.start is not None @@ -167,8 +185,8 @@ async def test_shutdown_before_startup_finishes( async def test_canceled_before_startup_finishes( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test recorder shuts down when its startup future is canceled out from under it.""" @@ -192,7 +210,7 @@ async def test_canceled_before_startup_finishes( async def test_shutdown_closes_connections( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, setup_recorder: None ) -> None: """Test shutdown closes connections.""" @@ -219,7 +237,7 @@ async def test_shutdown_closes_connections( async def test_state_gets_saved_when_set_before_start_event( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant + hass: HomeAssistant, async_setup_recorder_instance: RecorderInstanceGenerator ) -> None: """Test we can record an event when starting with not running.""" @@ -245,7 +263,7 @@ async def test_state_gets_saved_when_set_before_start_event( assert db_states[0].event_id is None -async def test_saving_state(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_saving_state(hass: HomeAssistant, setup_recorder: None) -> None: """Test saving and restoring a state.""" entity_id = "test.recorder" state = "restoring_from_db" @@ -275,7 +293,7 @@ async def test_saving_state(recorder_mock: Recorder, hass: HomeAssistant) -> Non @pytest.mark.parametrize( - ("dialect_name", "expected_attributes"), + ("db_engine", "expected_attributes"), [ (SupportedDialect.MYSQL, {"test_attr": 5, "test_attr_10": "silly\0stuff"}), (SupportedDialect.POSTGRESQL, {"test_attr": 5, "test_attr_10": "silly"}), @@ -283,18 +301,19 @@ async def test_saving_state(recorder_mock: Recorder, hass: HomeAssistant) -> Non ], ) async def test_saving_state_with_nul( - recorder_mock: Recorder, hass: HomeAssistant, dialect_name, expected_attributes + hass: HomeAssistant, + db_engine: str, + recorder_dialect_name: None, + setup_recorder: None, + expected_attributes: dict[str, Any], ) -> None: """Test saving and restoring a state with nul in attributes.""" entity_id = "test.recorder" state = "restoring_from_db" attributes = {"test_attr": 5, "test_attr_10": "silly\0stuff"} - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", dialect_name - ): - hass.states.async_set(entity_id, state, attributes) - await async_wait_recording_done(hass) + hass.states.async_set(entity_id, state, attributes) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: db_states = [] @@ -318,7 +337,7 @@ async def test_saving_state_with_nul( async def test_saving_many_states( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant + hass: HomeAssistant, async_setup_recorder_instance: RecorderInstanceGenerator ) -> None: """Test we expire after many commits.""" instance = await async_setup_recorder_instance( @@ -347,7 +366,7 @@ async def test_saving_many_states( async def test_saving_state_with_intermixed_time_changes( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, setup_recorder: None ) -> None: """Test saving states with intermixed time changes.""" entity_id = "test.recorder" @@ -370,14 +389,12 @@ async def test_saving_state_with_intermixed_time_changes( assert db_states[0].event_id is None -def test_saving_state_with_exception( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_with_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder() - entity_id = "test.recorder" state = "restoring_from_db" attributes = {"test_attr": 5, "test_attr_10": "nice"} @@ -397,15 +414,15 @@ def test_saving_state_with_exception( side_effect=_throw_if_state_in_session, ), ): - hass.states.set(entity_id, "fail", attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, "fail", attributes) + await async_wait_recording_done(hass) assert "Error executing query" in caplog.text assert "Error saving events" not in caplog.text caplog.clear() - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, attributes) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) @@ -415,14 +432,12 @@ def test_saving_state_with_exception( assert "Error saving events" not in caplog.text -def test_saving_state_with_sqlalchemy_exception( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_with_sqlalchemy_exception( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test saving state when there is an SQLAlchemyError.""" - hass = hass_recorder() - entity_id = "test.recorder" state = "restoring_from_db" attributes = {"test_attr": 5, "test_attr_10": "nice"} @@ -442,14 +457,14 @@ def test_saving_state_with_sqlalchemy_exception( side_effect=_throw_if_state_in_session, ), ): - hass.states.set(entity_id, "fail", attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, "fail", attributes) + await async_wait_recording_done(hass) assert "SQLAlchemyError error processing task" in caplog.text caplog.clear() - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, state, attributes) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) @@ -461,8 +476,8 @@ def test_saving_state_with_sqlalchemy_exception( async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test forcing shutdown.""" @@ -495,10 +510,8 @@ async def test_force_shutdown_with_queue_of_writes_that_generate_exceptions( assert "Error saving events" not in caplog.text -def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_saving_event(hass: HomeAssistant, setup_recorder: None) -> None: """Test saving and restoring an event.""" - hass = hass_recorder() - event_type = "EVENT_TEST" event_data = {"test_attr": 5, "test_attr_10": "nice"} @@ -510,16 +523,16 @@ def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: if event.event_type == event_type: events.append(event) - hass.bus.listen(MATCH_ALL, event_listener) + hass.bus.async_listen(MATCH_ALL, event_listener) - hass.bus.fire(event_type, event_data) + hass.bus.async_fire(event_type, event_data) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert len(events) == 1 event: Event = events[0] - get_instance(hass).block_till_done() + await async_recorder_block_till_done(hass) events: list[Event] = [] with session_scope(hass=hass, read_only=True) as session: @@ -550,20 +563,21 @@ def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: ) -def test_saving_state_with_commit_interval_zero( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_with_commit_interval_zero( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving a state with a commit interval of zero.""" - hass = hass_recorder(config={"commit_interval": 0}) + await async_setup_recorder_instance(hass, {"commit_interval": 0}) assert get_instance(hass).commit_interval == 0 entity_id = "test.recorder" state = "restoring_from_db" attributes = {"test_attr": 5, "test_attr_10": "nice"} - hass.states.set(entity_id, state, attributes) + hass.states.async_set(entity_id, state, attributes) - wait_recording_done(hass) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) @@ -571,12 +585,12 @@ def test_saving_state_with_commit_interval_zero( assert db_states[0].event_id is None -def _add_entities(hass, entity_ids): +async def _add_entities(hass, entity_ids): """Add entities.""" attributes = {"test_attr": 5, "test_attr_10": "nice"} for idx, entity_id in enumerate(entity_ids): - hass.states.set(entity_id, f"state{idx}", attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, f"state{idx}", attributes) + await async_wait_recording_done(hass) with session_scope(hass=hass) as session: states = [] @@ -601,30 +615,33 @@ def _state_with_context(hass, entity_id): return hass.states.get(entity_id) -def test_setup_without_migration(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_setup_without_migration( + hass: HomeAssistant, setup_recorder: None +) -> None: """Verify the schema version without a migration.""" - hass = hass_recorder() assert recorder.get_instance(hass).schema_version == SCHEMA_VERSION -def test_saving_state_include_domains( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_include_domains( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder(config={"include": {"domains": "test2"}}) - states = _add_entities(hass, ["test.recorder", "test2.recorder"]) + await async_setup_recorder_instance(hass, {"include": {"domains": "test2"}}) + states = await _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() -def test_saving_state_include_domains_globs( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_include_domains_globs( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder( - config={"include": {"domains": "test2", "entity_globs": "*.included_*"}} + await async_setup_recorder_instance( + hass, {"include": {"domains": "test2", "entity_globs": "*.included_*"}} ) - states = _add_entities( + states = await _add_entities( hass, ["test.recorder", "test2.recorder", "test3.included_entity"] ) assert len(states) == 2 @@ -640,19 +657,22 @@ def test_saving_state_include_domains_globs( ) -def test_saving_state_incl_entities( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_incl_entities( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder(config={"include": {"entities": "test2.recorder"}}) - states = _add_entities(hass, ["test.recorder", "test2.recorder"]) + await async_setup_recorder_instance( + hass, {"include": {"entities": "test2.recorder"}} + ) + states = await _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() async def test_saving_event_exclude_event_type( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring an event.""" config = { @@ -674,7 +694,7 @@ async def test_saving_event_exclude_event_type( await async_wait_recording_done(hass) - def _get_events(hass: HomeAssistant, event_types: list[str]) -> list[Event]: + def _get_events(hass: HomeAssistant, event_type_list: list[str]) -> list[Event]: with session_scope(hass=hass, read_only=True) as session: events = [] for event, event_data, event_types in ( @@ -683,7 +703,7 @@ async def test_saving_event_exclude_event_type( EventTypes, (Events.event_type_id == EventTypes.event_type_id) ) .outerjoin(EventData, Events.data_id == EventData.data_id) - .where(EventTypes.event_type.in_(event_types)) + .where(EventTypes.event_type.in_(event_type_list)) ): event = cast(Events, event) event_data = cast(EventData, event_data) @@ -701,97 +721,110 @@ async def test_saving_event_exclude_event_type( assert events[0].event_type == "test2" -def test_saving_state_exclude_domains( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_exclude_domains( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder(config={"exclude": {"domains": "test"}}) - states = _add_entities(hass, ["test.recorder", "test2.recorder"]) + await async_setup_recorder_instance(hass, {"exclude": {"domains": "test"}}) + states = await _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() -def test_saving_state_exclude_domains_globs( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_exclude_domains_globs( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder( - config={"exclude": {"domains": "test", "entity_globs": "*.excluded_*"}} + await async_setup_recorder_instance( + hass, {"exclude": {"domains": "test", "entity_globs": "*.excluded_*"}} ) - states = _add_entities( + states = await _add_entities( hass, ["test.recorder", "test2.recorder", "test2.excluded_entity"] ) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() -def test_saving_state_exclude_entities( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_exclude_entities( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder(config={"exclude": {"entities": "test.recorder"}}) - states = _add_entities(hass, ["test.recorder", "test2.recorder"]) + await async_setup_recorder_instance( + hass, {"exclude": {"entities": "test.recorder"}} + ) + states = await _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() -def test_saving_state_exclude_domain_include_entity( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_exclude_domain_include_entity( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder( - config={ + await async_setup_recorder_instance( + hass, + { "include": {"entities": "test.recorder"}, "exclude": {"domains": "test"}, - } + }, ) - states = _add_entities(hass, ["test.recorder", "test2.recorder"]) + states = await _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 2 -def test_saving_state_exclude_domain_glob_include_entity( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_exclude_domain_glob_include_entity( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder( - config={ + await async_setup_recorder_instance( + hass, + { "include": {"entities": ["test.recorder", "test.excluded_entity"]}, "exclude": {"domains": "test", "entity_globs": "*._excluded_*"}, - } + }, ) - states = _add_entities( + states = await _add_entities( hass, ["test.recorder", "test2.recorder", "test.excluded_entity"] ) assert len(states) == 3 -def test_saving_state_include_domain_exclude_entity( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_include_domain_exclude_entity( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder( - config={ + await async_setup_recorder_instance( + hass, + { "exclude": {"entities": "test.recorder"}, "include": {"domains": "test"}, - } + }, ) - states = _add_entities(hass, ["test.recorder", "test2.recorder", "test.ok"]) + states = await _add_entities(hass, ["test.recorder", "test2.recorder", "test.ok"]) assert len(states) == 1 assert _state_with_context(hass, "test.ok").as_dict() == states[0].as_dict() assert _state_with_context(hass, "test.ok").state == "state2" -def test_saving_state_include_domain_glob_exclude_entity( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_include_domain_glob_exclude_entity( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder( - config={ + await async_setup_recorder_instance( + hass, + { "exclude": {"entities": ["test.recorder", "test2.included_entity"]}, "include": {"domains": "test", "entity_globs": "*._included_*"}, - } + }, ) - states = _add_entities( + states = await _add_entities( hass, ["test.recorder", "test2.recorder", "test.ok", "test2.included_entity"] ) assert len(states) == 1 @@ -799,17 +832,17 @@ def test_saving_state_include_domain_glob_exclude_entity( assert _state_with_context(hass, "test.ok").state == "state2" -def test_saving_state_and_removing_entity( - hass_recorder: Callable[..., HomeAssistant], +async def test_saving_state_and_removing_entity( + hass: HomeAssistant, + setup_recorder: None, ) -> None: """Test saving the state of a removed entity.""" - hass = hass_recorder() entity_id = "lock.mine" - hass.states.set(entity_id, STATE_LOCKED) - hass.states.set(entity_id, STATE_UNLOCKED) - hass.states.remove(entity_id) + hass.states.async_set(entity_id, STATE_LOCKED) + hass.states.async_set(entity_id, STATE_UNLOCKED) + hass.states.async_remove(entity_id) - wait_recording_done(hass) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: states = list( @@ -826,16 +859,17 @@ def test_saving_state_and_removing_entity( assert states[2].state is None -def test_saving_state_with_oversized_attributes( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_saving_state_with_oversized_attributes( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test saving states is limited to 16KiB of JSON encoded attributes.""" - hass = hass_recorder() massive_dict = {"a": "b" * 16384} attributes = {"test_attr": 5, "test_attr_10": "nice"} - hass.states.set("switch.sane", "on", attributes) - hass.states.set("switch.too_big", "on", massive_dict) - wait_recording_done(hass) + hass.states.async_set("switch.sane", "on", attributes) + hass.states.async_set("switch.too_big", "on", massive_dict) + await async_wait_recording_done(hass) states = [] with session_scope(hass=hass, read_only=True) as session: @@ -860,16 +894,17 @@ def test_saving_state_with_oversized_attributes( assert states[1].attributes == {} -def test_saving_event_with_oversized_data( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_saving_event_with_oversized_data( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test saving events is limited to 32KiB of JSON encoded data.""" - hass = hass_recorder() massive_dict = {"a": "b" * 32768} event_data = {"test_attr": 5, "test_attr_10": "nice"} - hass.bus.fire("test_event", event_data) - hass.bus.fire("test_event_too_big", massive_dict) - wait_recording_done(hass) + hass.bus.async_fire("test_event", event_data) + hass.bus.async_fire("test_event_too_big", massive_dict) + await async_wait_recording_done(hass) events = {} with session_scope(hass=hass, read_only=True) as session: @@ -888,14 +923,15 @@ def test_saving_event_with_oversized_data( assert json_loads(events["test_event_too_big"]) == {} -def test_saving_event_invalid_context_ulid( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_saving_event_invalid_context_ulid( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test we handle invalid manually injected context ids.""" - hass = hass_recorder() event_data = {"test_attr": 5, "test_attr_10": "nice"} - hass.bus.fire("test_event", event_data, context=Context(id="invalid")) - wait_recording_done(hass) + hass.bus.async_fire("test_event", event_data, context=Context(id="invalid")) + await async_wait_recording_done(hass) events = {} with session_scope(hass=hass, read_only=True) as session: @@ -913,7 +949,7 @@ def test_saving_event_invalid_context_ulid( assert json_loads(events["test_event"]) == event_data -def test_recorder_setup_failure(hass: HomeAssistant) -> None: +async def test_recorder_setup_failure(hass: HomeAssistant) -> None: """Test some exceptions.""" recorder_helper.async_initialize_recorder(hass) with ( @@ -929,7 +965,7 @@ def test_recorder_setup_failure(hass: HomeAssistant) -> None: hass.stop() -def test_recorder_validate_schema_failure(hass: HomeAssistant) -> None: +async def test_recorder_validate_schema_failure(hass: HomeAssistant) -> None: """Test some exceptions.""" recorder_helper.async_initialize_recorder(hass) with ( @@ -947,7 +983,9 @@ def test_recorder_validate_schema_failure(hass: HomeAssistant) -> None: hass.stop() -def test_recorder_setup_failure_without_event_listener(hass: HomeAssistant) -> None: +async def test_recorder_setup_failure_without_event_listener( + hass: HomeAssistant, +) -> None: """Test recorder setup failure when the event listener is not setup.""" recorder_helper.async_initialize_recorder(hass) with ( @@ -981,19 +1019,19 @@ async def test_defaults_set(hass: HomeAssistant) -> None: assert recorder_config["purge_keep_days"] == 10 -def run_tasks_at_time(hass: HomeAssistant, test_time: datetime) -> None: +async def run_tasks_at_time(hass: HomeAssistant, test_time: datetime) -> None: """Advance the clock and wait for any callbacks to finish.""" - fire_time_changed(hass, test_time) - hass.block_till_done(wait_background_tasks=True) - get_instance(hass).block_till_done() - hass.block_till_done(wait_background_tasks=True) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done(wait_background_tasks=True) + await async_recorder_block_till_done(hass) + await hass.async_block_till_done(wait_background_tasks=True) @pytest.mark.parametrize("enable_nightly_purge", [True]) -def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_auto_purge(hass: HomeAssistant, setup_recorder: None) -> None: """Test periodic purge scheduling.""" timezone = "Europe/Copenhagen" - hass = hass_recorder(timezone=timezone) + await hass.config.async_set_time_zone(timezone) tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by @@ -1004,7 +1042,7 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: # The clock is started at 4:15am then advanced forward below now = dt_util.utcnow() test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) with ( patch( @@ -1014,9 +1052,12 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: "homeassistant.components.recorder.tasks.periodic_db_cleanups" ) as periodic_db_cleanups, ): + assert len(purge_old_data.mock_calls) == 0 + assert len(periodic_db_cleanups.mock_calls) == 0 + # Advance one day, and the purge task should run test_time = test_time + timedelta(days=1) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 1 assert len(periodic_db_cleanups.mock_calls) == 1 @@ -1025,7 +1066,7 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: # Advance one day, and the purge task should run again test_time = test_time + timedelta(days=1) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 1 assert len(periodic_db_cleanups.mock_calls) == 1 @@ -1034,24 +1075,25 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: # Advance less than one full day. The alarm should not yet fire. test_time = test_time + timedelta(hours=23) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 0 assert len(periodic_db_cleanups.mock_calls) == 0 # Advance to the next day and fire the alarm again test_time = test_time + timedelta(hours=1) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 1 assert len(periodic_db_cleanups.mock_calls) == 1 @pytest.mark.parametrize("enable_nightly_purge", [True]) -def test_auto_purge_auto_repack_on_second_sunday( - hass_recorder: Callable[..., HomeAssistant], +async def test_auto_purge_auto_repack_on_second_sunday( + hass: HomeAssistant, + setup_recorder: None, ) -> None: """Test periodic purge scheduling does a repack on the 2nd sunday.""" timezone = "Europe/Copenhagen" - hass = hass_recorder(timezone=timezone) + await hass.config.async_set_time_zone(timezone) tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by @@ -1062,7 +1104,7 @@ def test_auto_purge_auto_repack_on_second_sunday( # The clock is started at 4:15am then advanced forward below now = dt_util.utcnow() test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) with ( patch( @@ -1075,9 +1117,12 @@ def test_auto_purge_auto_repack_on_second_sunday( "homeassistant.components.recorder.tasks.periodic_db_cleanups" ) as periodic_db_cleanups, ): + assert len(purge_old_data.mock_calls) == 0 + assert len(periodic_db_cleanups.mock_calls) == 0 + # Advance one day, and the purge task should run test_time = test_time + timedelta(days=1) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 1 args, _ = purge_old_data.call_args_list[0] assert args[2] is True # repack @@ -1085,12 +1130,14 @@ 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], +async def test_auto_purge_auto_repack_disabled_on_second_sunday( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test periodic purge scheduling does not auto repack on the 2nd sunday if disabled.""" timezone = "Europe/Copenhagen" - hass = hass_recorder(config={CONF_AUTO_REPACK: False}, timezone=timezone) + await hass.config.async_set_time_zone(timezone) + await async_setup_recorder_instance(hass, {CONF_AUTO_REPACK: False}) tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by @@ -1101,7 +1148,7 @@ def test_auto_purge_auto_repack_disabled_on_second_sunday( # The clock is started at 4:15am then advanced forward below now = dt_util.utcnow() test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) with ( patch( @@ -1114,9 +1161,12 @@ def test_auto_purge_auto_repack_disabled_on_second_sunday( "homeassistant.components.recorder.tasks.periodic_db_cleanups" ) as periodic_db_cleanups, ): + assert len(purge_old_data.mock_calls) == 0 + assert len(periodic_db_cleanups.mock_calls) == 0 + # Advance one day, and the purge task should run test_time = test_time + timedelta(days=1) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 1 args, _ = purge_old_data.call_args_list[0] assert args[2] is False # repack @@ -1124,12 +1174,13 @@ 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], +async def test_auto_purge_no_auto_repack_on_not_second_sunday( + hass: HomeAssistant, + setup_recorder: None, ) -> None: """Test periodic purge scheduling does not do a repack unless its the 2nd sunday.""" timezone = "Europe/Copenhagen" - hass = hass_recorder(timezone=timezone) + await hass.config.async_set_time_zone(timezone) tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by @@ -1140,7 +1191,7 @@ def test_auto_purge_no_auto_repack_on_not_second_sunday( # The clock is started at 4:15am then advanced forward below now = dt_util.utcnow() test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) with ( patch( @@ -1154,9 +1205,12 @@ def test_auto_purge_no_auto_repack_on_not_second_sunday( "homeassistant.components.recorder.tasks.periodic_db_cleanups" ) as periodic_db_cleanups, ): + assert len(purge_old_data.mock_calls) == 0 + assert len(periodic_db_cleanups.mock_calls) == 0 + # Advance one day, and the purge task should run test_time = test_time + timedelta(days=1) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 1 args, _ = purge_old_data.call_args_list[0] assert args[2] is False # repack @@ -1164,10 +1218,14 @@ def test_auto_purge_no_auto_repack_on_not_second_sunday( @pytest.mark.parametrize("enable_nightly_purge", [True]) -def test_auto_purge_disabled(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_auto_purge_disabled( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: """Test periodic db cleanup still run when auto purge is disabled.""" timezone = "Europe/Copenhagen" - hass = hass_recorder(config={CONF_AUTO_PURGE: False}, timezone=timezone) + await hass.config.async_set_time_zone(timezone) + await async_setup_recorder_instance(hass, {CONF_AUTO_PURGE: False}) tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. We want @@ -1177,7 +1235,7 @@ def test_auto_purge_disabled(hass_recorder: Callable[..., HomeAssistant]) -> Non # The clock is started at 4:15am then advanced forward below now = dt_util.utcnow() test_time = datetime(now.year + 2, 1, 1, 4, 15, 0, tzinfo=tz) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) with ( patch( @@ -1187,9 +1245,12 @@ def test_auto_purge_disabled(hass_recorder: Callable[..., HomeAssistant]) -> Non "homeassistant.components.recorder.tasks.periodic_db_cleanups" ) as periodic_db_cleanups, ): + assert len(purge_old_data.mock_calls) == 0 + assert len(periodic_db_cleanups.mock_calls) == 0 + # Advance one day, and the purge task should run test_time = test_time + timedelta(days=1) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(purge_old_data.mock_calls) == 0 assert len(periodic_db_cleanups.mock_calls) == 1 @@ -1198,10 +1259,14 @@ def test_auto_purge_disabled(hass_recorder: Callable[..., HomeAssistant]) -> Non @pytest.mark.parametrize("enable_statistics", [True]) -def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) -> None: +async def test_auto_statistics( + hass: HomeAssistant, + setup_recorder: None, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic statistics scheduling.""" timezone = "Europe/Copenhagen" - hass = hass_recorder(timezone=timezone) + await hass.config.async_set_time_zone(timezone) tz = dt_util.get_time_zone(timezone) stats_5min = [] @@ -1212,6 +1277,7 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - """Handle recorder 5 min stat updated.""" stats_5min.append(event) + @callback def async_hourly_stats_updated_listener(event: Event) -> None: """Handle recorder 5 min stat updated.""" stats_hourly.append(event) @@ -1225,12 +1291,12 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - now = dt_util.utcnow() test_time = datetime(now.year + 2, 1, 1, 4, 51, 0, tzinfo=tz) freezer.move_to(test_time.isoformat()) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) - hass.bus.listen( + hass.bus.async_listen( EVENT_RECORDER_5MIN_STATISTICS_GENERATED, async_5min_stats_updated_listener ) - hass.bus.listen( + hass.bus.async_listen( EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, async_hourly_stats_updated_listener ) @@ -1243,7 +1309,7 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - # Advance 5 minutes, and the statistics task should run test_time = test_time + timedelta(minutes=5) freezer.move_to(test_time.isoformat()) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 assert len(stats_5min) == 1 assert len(stats_hourly) == 0 @@ -1251,9 +1317,9 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - compile_statistics.reset_mock() # Advance 5 minutes, and the statistics task should run again - test_time = test_time + timedelta(minutes=5) + test_time = test_time + timedelta(minutes=5, seconds=1) freezer.move_to(test_time.isoformat()) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 assert len(stats_5min) == 2 assert len(stats_hourly) == 1 @@ -1263,29 +1329,31 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - # Advance less than 5 minutes. The task should not run. test_time = test_time + timedelta(minutes=3) freezer.move_to(test_time.isoformat()) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 0 assert len(stats_5min) == 2 assert len(stats_hourly) == 1 # Advance 5 minutes, and the statistics task should run again - test_time = test_time + timedelta(minutes=5) + test_time = test_time + timedelta(minutes=5, seconds=1) freezer.move_to(test_time.isoformat()) - run_tasks_at_time(hass, test_time) + await run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 assert len(stats_5min) == 3 assert len(stats_hourly) == 1 -def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_statistics_runs_initiated( + hass: HomeAssistant, async_setup_recorder_instance: RecorderInstanceGenerator +) -> None: """Test statistics_runs is initiated when DB is created.""" now = dt_util.utcnow() with patch( "homeassistant.components.recorder.core.dt_util.utcnow", return_value=now ): - hass = hass_recorder() + await async_setup_recorder_instance(hass) - wait_recording_done(hass) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: statistics_runs = list(session.query(StatisticsRuns)) @@ -1297,7 +1365,7 @@ def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) @pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") -def test_compile_missing_statistics( +async def test_compile_missing_statistics( tmp_path: Path, freezer: FrozenDateTimeFactory ) -> None: """Test missing statistics are compiled on startup.""" @@ -1307,22 +1375,28 @@ def test_compile_missing_statistics( test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - with get_test_home_assistant() as hass: - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - + def get_statistic_runs(hass: HomeAssistant) -> list: with session_scope(hass=hass, read_only=True) as session: - statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 1 - last_run = process_timestamp(statistics_runs[0].start) - assert last_run == now - timedelta(minutes=5) + return list(session.query(StatisticsRuns)) - wait_recording_done(hass) - wait_recording_done(hass) - hass.stop() + async with async_test_home_assistant() as hass: + recorder_helper.async_initialize_recorder(hass) + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + await hass.async_start() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + instance = recorder.get_instance(hass) + statistics_runs = await instance.async_add_executor_job( + get_statistic_runs, hass + ) + assert len(statistics_runs) == 1 + last_run = process_timestamp(statistics_runs[0].start) + assert last_run == now - timedelta(minutes=5) + + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + await hass.async_stop() # Start Home Assistant one hour later stats_5min = [] @@ -1338,45 +1412,44 @@ def test_compile_missing_statistics( stats_hourly.append(event) freezer.tick(timedelta(hours=1)) - with get_test_home_assistant() as hass: - hass.bus.listen( + async with async_test_home_assistant() as hass: + hass.bus.async_listen( EVENT_RECORDER_5MIN_STATISTICS_GENERATED, async_5min_stats_updated_listener ) - hass.bus.listen( + hass.bus.async_listen( EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, async_hourly_stats_updated_listener, ) recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + await hass.async_start() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - with session_scope(hass=hass, read_only=True) as session: - statistics_runs = list(session.query(StatisticsRuns)) - assert len(statistics_runs) == 13 # 12 5-minute runs - last_run = process_timestamp(statistics_runs[1].start) - assert last_run == now + instance = recorder.get_instance(hass) + statistics_runs = await instance.async_add_executor_job( + get_statistic_runs, hass + ) + assert len(statistics_runs) == 13 # 12 5-minute runs + last_run = process_timestamp(statistics_runs[1].start) + assert last_run == now assert len(stats_5min) == 1 assert len(stats_hourly) == 1 - wait_recording_done(hass) - wait_recording_done(hass) - hass.stop() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + await hass.async_stop() -def test_saving_sets_old_state(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_saving_sets_old_state(hass: HomeAssistant, setup_recorder: None) -> None: """Test saving sets old state.""" - hass = hass_recorder() - - hass.states.set("test.one", "s1", {}) - hass.states.set("test.two", "s2", {}) - wait_recording_done(hass) - hass.states.set("test.one", "s3", {}) - hass.states.set("test.two", "s4", {}) - wait_recording_done(hass) + hass.states.async_set("test.one", "s1", {}) + hass.states.async_set("test.two", "s2", {}) + hass.states.async_set("test.one", "s3", {}) + hass.states.async_set("test.two", "s4", {}) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: states = list( @@ -1398,19 +1471,15 @@ def test_saving_sets_old_state(hass_recorder: Callable[..., HomeAssistant]) -> N assert states_by_state["s4"].old_state_id == states_by_state["s2"].state_id -def test_saving_state_with_serializable_data( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_saving_state_with_serializable_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, setup_recorder: None ) -> None: """Test saving data that cannot be serialized does not crash.""" - hass = hass_recorder() - - hass.bus.fire("bad_event", {"fail": CannotSerializeMe()}) - hass.states.set("test.one", "s1", {"fail": CannotSerializeMe()}) - wait_recording_done(hass) - hass.states.set("test.two", "s2", {}) - wait_recording_done(hass) - hass.states.set("test.two", "s3", {}) - wait_recording_done(hass) + hass.bus.async_fire("bad_event", {"fail": CannotSerializeMe()}) + hass.states.async_set("test.one", "s1", {"fail": CannotSerializeMe()}) + hass.states.async_set("test.two", "s2", {}) + hass.states.async_set("test.two", "s3", {}) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: states = list( @@ -1428,23 +1497,20 @@ def test_saving_state_with_serializable_data( assert "State is not JSON serializable" in caplog.text -def test_has_services(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_has_services(hass: HomeAssistant, setup_recorder: None) -> None: """Test the services exist.""" - hass = hass_recorder() - assert hass.services.has_service(DOMAIN, SERVICE_DISABLE) assert hass.services.has_service(DOMAIN, SERVICE_ENABLE) assert hass.services.has_service(DOMAIN, SERVICE_PURGE) assert hass.services.has_service(DOMAIN, SERVICE_PURGE_ENTITIES) -def test_service_disable_events_not_recording( - hass_recorder: Callable[..., HomeAssistant], +async def test_service_disable_events_not_recording( + hass: HomeAssistant, + setup_recorder: None, ) -> None: """Test that events are not recorded when recorder is disabled using service.""" - hass = hass_recorder() - - hass.services.call( + await hass.services.async_call( DOMAIN, SERVICE_DISABLE, {}, @@ -1461,11 +1527,11 @@ def test_service_disable_events_not_recording( if event.event_type == event_type: events.append(event) - hass.bus.listen(MATCH_ALL, event_listener) + hass.bus.async_listen(MATCH_ALL, event_listener) event_data1 = {"test_attr": 5, "test_attr_10": "nice"} - hass.bus.fire(event_type, event_data1) - wait_recording_done(hass) + hass.bus.async_fire(event_type, event_data1) + await async_wait_recording_done(hass) assert len(events) == 1 event = events[0] @@ -1478,7 +1544,7 @@ def test_service_disable_events_not_recording( ) assert len(db_events) == 0 - hass.services.call( + await hass.services.async_call( DOMAIN, SERVICE_ENABLE, {}, @@ -1486,8 +1552,8 @@ def test_service_disable_events_not_recording( ) event_data2 = {"attr_one": 5, "attr_two": "nice"} - hass.bus.fire(event_type, event_data2) - wait_recording_done(hass) + hass.bus.async_fire(event_type, event_data2) + await async_wait_recording_done(hass) assert len(events) == 2 assert events[0] != events[1] @@ -1522,34 +1588,33 @@ def test_service_disable_events_not_recording( ) -def test_service_disable_states_not_recording( - hass_recorder: Callable[..., HomeAssistant], +async def test_service_disable_states_not_recording( + hass: HomeAssistant, + setup_recorder: None, ) -> None: """Test that state changes are not recorded when recorder is disabled using service.""" - hass = hass_recorder() - - hass.services.call( + await hass.services.async_call( DOMAIN, SERVICE_DISABLE, {}, blocking=True, ) - hass.states.set("test.one", "on", {}) - wait_recording_done(hass) + hass.states.async_set("test.one", "on", {}) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: assert len(list(session.query(States))) == 0 - hass.services.call( + await hass.services.async_call( DOMAIN, SERVICE_ENABLE, {}, blocking=True, ) - hass.states.set("test.two", "off", {}) - wait_recording_done(hass) + hass.states.async_set("test.two", "off", {}) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(States)) @@ -1562,50 +1627,54 @@ def test_service_disable_states_not_recording( ) -def test_service_disable_run_information_recorded(tmp_path: Path) -> None: +async def test_service_disable_run_information_recorded(tmp_path: Path) -> None: """Test that runs are still recorded when recorder is disabled.""" test_dir = tmp_path.joinpath("sqlite") test_dir.mkdir() test_db_file = test_dir.joinpath("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - with get_test_home_assistant() as hass: - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) - + def get_recorder_runs(hass: HomeAssistant) -> list: with session_scope(hass=hass, read_only=True) as session: - db_run_info = list(session.query(RecorderRuns)) - assert len(db_run_info) == 1 - assert db_run_info[0].start is not None - assert db_run_info[0].end is None + return list(session.query(RecorderRuns)) - hass.services.call( + async with async_test_home_assistant() as hass: + recorder_helper.async_initialize_recorder(hass) + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + await hass.async_start() + await async_wait_recording_done(hass) + + instance = recorder.get_instance(hass) + db_run_info = await instance.async_add_executor_job(get_recorder_runs, hass) + assert len(db_run_info) == 1 + assert db_run_info[0].start is not None + assert db_run_info[0].end is None + + await hass.services.async_call( DOMAIN, SERVICE_DISABLE, {}, blocking=True, ) - wait_recording_done(hass) - hass.stop() + await async_wait_recording_done(hass) + await hass.async_stop() - with get_test_home_assistant() as hass: + async with async_test_home_assistant() as hass: recorder_helper.async_initialize_recorder(hass) - setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) - hass.start() - wait_recording_done(hass) + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DB_URL: dburl}}) + await hass.async_start() + await async_wait_recording_done(hass) - with session_scope(hass=hass, read_only=True) as session: - db_run_info = list(session.query(RecorderRuns)) - assert len(db_run_info) == 2 - assert db_run_info[0].start is not None - assert db_run_info[0].end is not None - assert db_run_info[1].start is not None - assert db_run_info[1].end is None + instance = recorder.get_instance(hass) + db_run_info = await instance.async_add_executor_job(get_recorder_runs, hass) + assert len(db_run_info) == 2 + assert db_run_info[0].start is not None + assert db_run_info[0].end is not None + assert db_run_info[1].start is not None + assert db_run_info[1].end is None - hass.stop() + await hass.async_stop() class CannotSerializeMe: @@ -1632,7 +1701,8 @@ async def test_database_corruption_while_running( await hass.async_block_till_done() caplog.clear() - original_start_time = get_instance(hass).recorder_runs_manager.recording_start + instance = get_instance(hass) + original_start_time = instance.recorder_runs_manager.recording_start hass.states.async_set("test.lost", "on", {}) @@ -1676,11 +1746,11 @@ async def test_database_corruption_while_running( assert db_states[0].event_id is None return db_states[0].to_native() - state = await hass.async_add_executor_job(_get_last_state) + state = await instance.async_add_executor_job(_get_last_state) assert state.entity_id == "test.two" assert state.state == "on" - new_start_time = get_instance(hass).recorder_runs_manager.recording_start + new_start_time = instance.recorder_runs_manager.recording_start assert original_start_time < new_start_time hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -1688,13 +1758,17 @@ async def test_database_corruption_while_running( hass.stop() -def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_entity_id_filter( + hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: """Test that entity ID filtering filters string and list.""" - hass = hass_recorder( - config={ + await async_setup_recorder_instance( + hass, + { "include": {"domains": "hello"}, "exclude": {"domains": "hidden_domain"}, - } + }, ) event_types = ("hello",) @@ -1707,8 +1781,8 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: {"entity_id": {"unexpected": "data"}}, ) ): - hass.bus.fire("hello", data) - wait_recording_done(hass) + hass.bus.async_fire("hello", data) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: db_events = list( @@ -1722,8 +1796,8 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: {"entity_id": "hidden_domain.person"}, {"entity_id": ["hidden_domain.person"]}, ): - hass.bus.fire("hello", data) - wait_recording_done(hass) + hass.bus.async_fire("hello", data) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: db_events = list( @@ -1736,8 +1810,8 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: async def test_database_lock_and_unlock( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, recorder_db_url: str, tmp_path: Path, ) -> None: @@ -1779,22 +1853,23 @@ async def test_database_lock_and_unlock( # Recording can't be finished while lock is held with pytest.raises(TimeoutError): await asyncio.wait_for(asyncio.shield(task), timeout=0.25) - db_events = await hass.async_add_executor_job(_get_db_events) - assert len(db_events) == 0 + db_events = await hass.async_add_executor_job(_get_db_events) + assert len(db_events) == 0 assert instance.unlock_database() await task - db_events = await hass.async_add_executor_job(_get_db_events) + db_events = await instance.async_add_executor_job(_get_db_events) assert len(db_events) == 1 async def test_database_lock_and_overflow( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, recorder_db_url: str, tmp_path: Path, caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test writing events during lock leading to overflow the queue causes the database to unlock.""" if recorder_db_url.startswith(("mysql://", "postgresql://")): @@ -1845,8 +1920,7 @@ async def test_database_lock_and_overflow( assert "Database queue backlog reached more than" in caplog.text assert not instance.unlock_database() - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") + issue = issue_registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") assert issue is not None assert "start_time" in issue.translation_placeholders start_time = issue.translation_placeholders["start_time"] @@ -1856,11 +1930,12 @@ async def test_database_lock_and_overflow( async def test_database_lock_and_overflow_checks_available_memory( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, recorder_db_url: str, tmp_path: Path, caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test writing events during lock leading to overflow the queue causes the database to unlock.""" if recorder_db_url.startswith(("mysql://", "postgresql://")): @@ -1935,8 +2010,7 @@ async def test_database_lock_and_overflow_checks_available_memory( db_events = await instance.async_add_executor_job(_get_db_events) assert len(db_events) >= 2 - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") + issue = issue_registry.async_get_issue(DOMAIN, "backup_failed_out_of_resources") assert issue is not None assert "start_time" in issue.translation_placeholders start_time = issue.translation_placeholders["start_time"] @@ -1946,7 +2020,7 @@ async def test_database_lock_and_overflow_checks_available_memory( async def test_database_lock_timeout( - recorder_mock: Recorder, hass: HomeAssistant, recorder_db_url: str + hass: HomeAssistant, setup_recorder: None, recorder_db_url: str ) -> None: """Test locking database timeout when recorder stopped.""" if recorder_db_url.startswith(("mysql://", "postgresql://")): @@ -1975,7 +2049,7 @@ async def test_database_lock_timeout( async def test_database_lock_without_instance( - recorder_mock: Recorder, hass: HomeAssistant + hass: HomeAssistant, setup_recorder: None ) -> None: """Test database lock doesn't fail if instance is not initialized.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) @@ -1998,18 +2072,19 @@ async def test_in_memory_database( assert "In-memory SQLite database is not supported" in caplog.text +@pytest.mark.parametrize("db_engine", ["mysql"]) async def test_database_connection_keep_alive( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + recorder_dialect_name: None, + async_setup_recorder_instance: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test we keep alive socket based dialects.""" - with patch("homeassistant.components.recorder.Recorder.dialect_name"): - instance = await async_setup_recorder_instance(hass) - # We have to mock this since we don't have a mock - # MySQL server available in tests. - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await instance.async_recorder_ready.wait() + instance = await async_setup_recorder_instance(hass) + # We have to mock this since we don't have a mock + # MySQL server available in tests. + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await instance.async_recorder_ready.wait() async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=recorder.core.KEEPALIVE_TIME) @@ -2019,8 +2094,8 @@ async def test_database_connection_keep_alive( async def test_database_connection_keep_alive_disabled_on_sqlite( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture, recorder_db_url: str, ) -> None: @@ -2040,18 +2115,15 @@ async def test_database_connection_keep_alive_disabled_on_sqlite( assert "Sending keepalive" not in caplog.text -def test_deduplication_event_data_inside_commit_interval( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_deduplication_event_data_inside_commit_interval( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, setup_recorder: None ) -> None: """Test deduplication of event data inside the commit interval.""" - hass = hass_recorder() - for _ in range(10): - hass.bus.fire("this_event", {"de": "dupe"}) - wait_recording_done(hass) + hass.bus.async_fire("this_event", {"de": "dupe"}) for _ in range(10): - hass.bus.fire("this_event", {"de": "dupe"}) - wait_recording_done(hass) + hass.bus.async_fire("this_event", {"de": "dupe"}) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: event_types = ("this_event",) @@ -2066,30 +2138,27 @@ def test_deduplication_event_data_inside_commit_interval( assert all(event.data_id == first_data_id for event in events) -def test_deduplication_state_attributes_inside_commit_interval( +async def test_deduplication_state_attributes_inside_commit_interval( small_cache_size: None, - hass_recorder: Callable[..., HomeAssistant], + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + setup_recorder: None, ) -> None: """Test deduplication of state attributes inside the commit interval.""" - hass = hass_recorder() - entity_id = "test.recorder" attributes = {"test_attr": 5, "test_attr_10": "nice"} - hass.states.set(entity_id, "on", attributes) - hass.states.set(entity_id, "off", attributes) + hass.states.async_set(entity_id, "on", attributes) + hass.states.async_set(entity_id, "off", attributes) # Now exhaust the cache to ensure we go back to the db for attr_id in range(5): - hass.states.set(entity_id, "on", {"test_attr": attr_id}) - hass.states.set(entity_id, "off", {"test_attr": attr_id}) - - wait_recording_done(hass) + hass.states.async_set(entity_id, "on", {"test_attr": attr_id}) + hass.states.async_set(entity_id, "off", {"test_attr": attr_id}) for _ in range(5): - hass.states.set(entity_id, "on", attributes) - hass.states.set(entity_id, "off", attributes) - wait_recording_done(hass) + hass.states.async_set(entity_id, "on", attributes) + hass.states.async_set(entity_id, "off", attributes) + await async_wait_recording_done(hass) with session_scope(hass=hass, read_only=True) as session: states = list( @@ -2104,7 +2173,7 @@ def test_deduplication_state_attributes_inside_commit_interval( async def test_async_block_till_done( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant + hass: HomeAssistant, async_setup_recorder_instance: RecorderInstanceGenerator ) -> None: """Test we can block until recordering is done.""" instance = await async_setup_recorder_instance(hass) @@ -2265,7 +2334,7 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: def engine_created(*args): ... def get_dialect_pool_class(self, *args): - return pool.RecorderPool + return QueuePool def initialize(*args): ... @@ -2299,9 +2368,9 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: async def test_excluding_attributes_by_integration( - recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry, + setup_recorder: None, ) -> None: """Test that an entity can exclude attributes from being recorded.""" state = "restoring_from_db" @@ -2351,8 +2420,73 @@ async def test_excluding_attributes_by_integration( assert state.as_dict() == expected.as_dict() +async def test_excluding_all_attributes_by_integration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_recorder: None, +) -> None: + """Test that an entity can exclude all attributes from being recorded using MATCH_ALL.""" + state = "restoring_from_db" + attributes = { + "test_attr": 5, + "excluded_component": 10, + "excluded_integration": 20, + "device_class": "test", + "state_class": "test", + "friendly_name": "Test entity", + "unit_of_measurement": "mm", + } + mock_platform( + hass, + "fake_integration.recorder", + Mock(exclude_attributes=lambda hass: {"excluded"}), + ) + hass.config.components.add("fake_integration") + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {"component": "fake_integration"}) + await hass.async_block_till_done() + + class EntityWithExcludedAttributes(MockEntity): + _unrecorded_attributes = frozenset({MATCH_ALL}) + + entity_id = "test.fake_integration_recorder" + entity_platform = MockEntityPlatform(hass, platform_name="fake_integration") + entity = EntityWithExcludedAttributes( + entity_id=entity_id, + extra_state_attributes=attributes, + ) + await entity_platform.async_add_entities([entity]) + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + + with session_scope(hass=hass, read_only=True) as session: + db_states = [] + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + ): + db_state.entity_id = states_meta.entity_id + db_states.append(db_state) + state = db_state.to_native() + state.attributes = db_state_attributes.to_native() + assert len(db_states) == 1 + assert db_states[0].event_id is None + + expected = _state_with_context(hass, entity_id) + expected.attributes = { + "device_class": "test", + "state_class": "test", + "friendly_name": "Test entity", + "unit_of_measurement": "mm", + } + assert state.as_dict() == expected.as_dict() + + async def test_lru_increases_with_many_entities( - small_cache_size: None, recorder_mock: Recorder, hass: HomeAssistant + small_cache_size: None, hass: HomeAssistant, setup_recorder: None ) -> None: """Test that the recorder's internal LRU cache increases with many entities.""" mock_entity_count = 16 @@ -2362,11 +2496,9 @@ async def test_lru_increases_with_many_entities( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await async_wait_recording_done(hass) - assert ( - recorder_mock.state_attributes_manager._id_map.get_size() - == mock_entity_count * 2 - ) - assert recorder_mock.states_meta_manager._id_map.get_size() == mock_entity_count * 2 + instance = get_instance(hass) + assert instance.state_attributes_manager._id_map.get_size() == mock_entity_count * 2 + assert instance.states_meta_manager._id_map.get_size() == mock_entity_count * 2 async def test_clean_shutdown_when_recorder_thread_raises_during_initialize_database( @@ -2461,8 +2593,8 @@ async def test_clean_shutdown_when_schema_migration_fails(hass: HomeAssistant) - async def test_events_are_recorded_until_final_write( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, ) -> None: """Test that events are recorded until the final write.""" instance = await async_setup_recorder_instance(hass, {}) @@ -2507,8 +2639,8 @@ async def test_events_are_recorded_until_final_write( async def test_commit_before_commits_pending_writes( - async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, + async_setup_recorder_instance: RecorderInstanceGenerator, recorder_db_url: str, tmp_path: Path, ) -> None: @@ -2576,7 +2708,7 @@ async def test_commit_before_commits_pending_writes( await verify_session_commit_future -def test_all_tables_use_default_table_args(hass: HomeAssistant) -> None: +async def test_all_tables_use_default_table_args(hass: HomeAssistant) -> None: """Test that all tables use the default table args.""" for table in db_schema.Base.metadata.tables.values(): assert table.kwargs.items() >= db_schema._DEFAULT_TABLE_ARGS.items() diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 01d5912a683..a21f4771616 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -350,7 +350,7 @@ async def test_schema_migrate( This simulates an existing db with the old schema. """ - module = f"tests.components.recorder.db_schema_{str(start_version)}" + module = f"tests.components.recorder.db_schema_{start_version!s}" importlib.import_module(module) old_models = sys.modules[module] engine = create_engine(*args, **kwargs) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 646cd338949..8fda495cf60 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -12,9 +12,16 @@ import pytest from sqlalchemy import create_engine, inspect from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from typing_extensions import AsyncGenerator from homeassistant.components import recorder -from homeassistant.components.recorder import core, db_schema, migration, statistics +from homeassistant.components.recorder import ( + Recorder, + core, + db_schema, + migration, + statistics, +) from homeassistant.components.recorder.db_schema import ( Events, EventTypes, @@ -110,7 +117,9 @@ def db_schema_32(): @pytest.fixture(name="legacy_recorder_mock") -async def legacy_recorder_mock_fixture(recorder_mock): +async def legacy_recorder_mock_fixture( + recorder_mock: Recorder, +) -> AsyncGenerator[Recorder]: """Fixture for legacy recorder mock.""" with patch.object(recorder_mock.states_meta_manager, "active", False): yield recorder_mock diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 262fb48af4d..d06c4a629d7 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -361,9 +361,9 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( @pytest.mark.parametrize( "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] ) -def test_process_datetime_to_timestamp(time_zone, hass: HomeAssistant) -> None: +async def test_process_datetime_to_timestamp(time_zone, hass: HomeAssistant) -> None: """Test we can handle processing database datatimes to timestamps.""" - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) utc_now = dt_util.utcnow() assert process_datetime_to_timestamp(utc_now) == utc_now.timestamp() now = dt_util.now() @@ -373,14 +373,14 @@ def test_process_datetime_to_timestamp(time_zone, hass: HomeAssistant) -> None: @pytest.mark.parametrize( "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] ) -def test_process_datetime_to_timestamp_freeze_time( +async def test_process_datetime_to_timestamp_freeze_time( time_zone, hass: HomeAssistant ) -> None: """Test we can handle processing database datatimes to timestamps. This test freezes time to make sure everything matches. """ - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) utc_now = dt_util.utcnow() with freeze_time(utc_now): epoch = utc_now.timestamp() @@ -396,7 +396,7 @@ async def test_process_datetime_to_timestamp_mirrors_utc_isoformat_behavior( time_zone, hass: HomeAssistant ) -> None: """Test process_datetime_to_timestamp mirrors process_timestamp_to_utc_isoformat.""" - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) est = dt_util.get_time_zone("US/Eastern") diff --git a/tests/components/recorder/test_pool.py b/tests/components/recorder/test_pool.py index 541fc8d714b..3cca095399b 100644 --- a/tests/components/recorder/test_pool.py +++ b/tests/components/recorder/test_pool.py @@ -12,20 +12,32 @@ from homeassistant.components.recorder.pool import RecorderPool async def test_recorder_pool_called_from_event_loop() -> None: """Test we raise an exception when calling from the event loop.""" - engine = create_engine("sqlite://", poolclass=RecorderPool) + recorder_and_worker_thread_ids: set[int] = set() + engine = create_engine( + "sqlite://", + poolclass=RecorderPool, + recorder_and_worker_thread_ids=recorder_and_worker_thread_ids, + ) with pytest.raises(RuntimeError): sessionmaker(bind=engine)().connection() def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: """Test RecorderPool gives the same connection in the creating thread.""" - - engine = create_engine("sqlite://", poolclass=RecorderPool) + recorder_and_worker_thread_ids: set[int] = set() + engine = create_engine( + "sqlite://", + poolclass=RecorderPool, + recorder_and_worker_thread_ids=recorder_and_worker_thread_ids, + ) get_session = sessionmaker(bind=engine) shutdown = False connections = [] + add_thread = False def _get_connection_twice(): + if add_thread: + recorder_and_worker_thread_ids.add(threading.get_ident()) session = get_session() connections.append(session.connection().connection.driver_connection) session.close() @@ -44,6 +56,7 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: assert "accesses the database without the database executor" in caplog.text assert connections[0] != connections[1] + add_thread = True caplog.clear() new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread.start() diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index e80bc7ca7d1..1ccbaada265 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -9,6 +9,7 @@ from freezegun import freeze_time import pytest from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session +from typing_extensions import Generator from voluptuous.error import MultipleInvalid from homeassistant.components import recorder @@ -58,7 +59,7 @@ TEST_EVENT_TYPES = ( @pytest.fixture(name="use_sqlite") -def mock_use_sqlite(request): +def mock_use_sqlite(request: pytest.FixtureRequest) -> Generator[None]: """Pytest fixture to switch purge method.""" with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 3946d8896f7..fb636cfa9dc 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -10,6 +10,7 @@ import pytest from sqlalchemy import text, update from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session +from typing_extensions import Generator from homeassistant.components import recorder from homeassistant.components.recorder import migration @@ -33,8 +34,7 @@ from .common import ( async_wait_recording_done, old_db_schema, ) - -from tests.components.recorder.db_schema_32 import ( +from .db_schema_32 import ( EventData, Events, RecorderRuns, @@ -43,6 +43,7 @@ from tests.components.recorder.db_schema_32 import ( StatisticsRuns, StatisticsShortTerm, ) + from tests.typing import RecorderInstanceGenerator @@ -54,7 +55,7 @@ def db_schema_32(): @pytest.fixture(name="use_sqlite") -def mock_use_sqlite(request): +def mock_use_sqlite(request: pytest.FixtureRequest) -> Generator[None]: """Pytest fixture to switch purge method.""" with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 19a0fe98953..7d8bc6e3415 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,6 +1,5 @@ """The tests for sensor recorder platform.""" -from collections.abc import Callable from datetime import timedelta from unittest.mock import patch @@ -33,22 +32,33 @@ from homeassistant.components.recorder.table_managers.statistics_meta import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import setup_component +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, + async_record_states, async_wait_recording_done, do_adhoc_statistics, - record_states, statistics_during_period, - wait_recording_done, ) -from tests.common import mock_registry -from tests.typing import WebSocketGenerator +from tests.typing import RecorderInstanceGenerator, WebSocketGenerator + + +@pytest.fixture +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + + +@pytest.fixture +def setup_recorder(recorder_mock: Recorder) -> None: + """Set up recorder.""" def test_converters_align_with_sensor() -> None: @@ -60,12 +70,14 @@ def test_converters_align_with_sensor() -> None: assert converter in UNIT_CONVERTERS.values() -def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_compile_hourly_statistics( + hass: HomeAssistant, + setup_recorder: None, +) -> None: """Test compiling hourly statistics.""" - hass = hass_recorder() instance = recorder.get_instance(hass) - setup_component(hass, "sensor", {}) - zero, four, states = record_states(hass) + await async_setup_component(hass, "sensor", {}) + zero, four, states = await async_record_states(hass) hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -93,7 +105,7 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=four) - wait_recording_done(hass) + await async_wait_recording_done(hass) metadata = get_metadata(hass, statistic_ids={"sensor.test1", "sensor.test2"}) assert metadata["sensor.test1"][1]["has_mean"] is True @@ -320,18 +332,16 @@ def mock_from_stats(): yield -def test_compile_periodic_statistics_exception( - hass_recorder: Callable[..., HomeAssistant], mock_sensor_statistics, mock_from_stats +async def test_compile_periodic_statistics_exception( + hass: HomeAssistant, setup_recorder: None, mock_sensor_statistics, mock_from_stats ) -> None: """Test exception handling when compiling periodic statistics.""" - - hass = hass_recorder() - setup_component(hass, "sensor", {}) + await async_setup_component(hass, "sensor", {}) now = dt_util.utcnow() do_adhoc_statistics(hass, start=now) do_adhoc_statistics(hass, start=now + timedelta(minutes=5)) - wait_recording_done(hass) + await async_wait_recording_done(hass) expected_1 = { "start": process_timestamp(now).timestamp(), "end": process_timestamp(now + timedelta(minutes=5)).timestamp(), @@ -364,27 +374,22 @@ def test_compile_periodic_statistics_exception( } -def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_rename_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_recorder: None +) -> None: """Test statistics is migrated when entity_id is changed.""" - hass = hass_recorder() - setup_component(hass, "sensor", {}) + await async_setup_component(hass, "sensor", {}) - entity_reg = mock_registry(hass) + reg_entry = entity_registry.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + await hass.async_block_till_done() - @callback - def add_entry(): - reg_entry = entity_reg.async_get_or_create( - "sensor", - "test", - "unique_0000", - suggested_object_id="test1", - ) - assert reg_entry.entity_id == "sensor.test1" - - hass.add_job(add_entry) - hass.block_till_done() - - zero, four, states = record_states(hass) + zero, four, states = await async_record_states(hass) hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -401,7 +406,7 @@ def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: assert stats == {} do_adhoc_statistics(hass, start=zero) - wait_recording_done(hass) + await async_wait_recording_done(hass) expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -419,23 +424,19 @@ def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} - @callback - def rename_entry(): - entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") - - hass.add_job(rename_entry) - wait_recording_done(hass) + entity_registry.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + await async_wait_recording_done(hass) stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} -def test_statistics_during_period_set_back_compat( - hass_recorder: Callable[..., HomeAssistant], +async def test_statistics_during_period_set_back_compat( + hass: HomeAssistant, + setup_recorder: None, ) -> None: """Test statistics_during_period can handle a list instead of a set.""" - hass = hass_recorder() - setup_component(hass, "sensor", {}) + await async_setup_component(hass, "sensor", {}) # This should not throw an exception when passed a list instead of a set assert ( statistics.statistics_during_period( @@ -451,33 +452,29 @@ def test_statistics_during_period_set_back_compat( ) -def test_rename_entity_collision( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_rename_entity_collision( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, ) -> None: """Test statistics is migrated when entity_id is changed. This test relies on the safeguard in the statistics_meta_manager and should not hit the filter_unique_constraint_integrity_error safeguard. """ - hass = hass_recorder() - setup_component(hass, "sensor", {}) + await async_setup_component(hass, "sensor", {}) - entity_reg = mock_registry(hass) + reg_entry = entity_registry.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + await hass.async_block_till_done() - @callback - def add_entry(): - reg_entry = entity_reg.async_get_or_create( - "sensor", - "test", - "unique_0000", - suggested_object_id="test1", - ) - assert reg_entry.entity_id == "sensor.test1" - - hass.add_job(add_entry) - hass.block_till_done() - - zero, four, states = record_states(hass) + zero, four, states = await async_record_states(hass) hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -494,7 +491,7 @@ def test_rename_entity_collision( assert stats == {} do_adhoc_statistics(hass, start=zero) - wait_recording_done(hass) + await async_wait_recording_done(hass) expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -525,12 +522,8 @@ def test_rename_entity_collision( session.add(recorder.db_schema.StatisticsMeta.from_meta(metadata_1)) # Rename entity sensor.test1 to sensor.test99 - @callback - def rename_entry(): - entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") - - hass.add_job(rename_entry) - wait_recording_done(hass) + entity_registry.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + await async_wait_recording_done(hass) # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") @@ -546,33 +539,29 @@ def test_rename_entity_collision( assert "Blocked attempt to insert duplicated statistic rows" not in caplog.text -def test_rename_entity_collision_states_meta_check_disabled( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_rename_entity_collision_states_meta_check_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_recorder: None, + caplog: pytest.LogCaptureFixture, ) -> None: """Test statistics is migrated when entity_id is changed. This test disables the safeguard in the statistics_meta_manager and relies on the filter_unique_constraint_integrity_error safeguard. """ - hass = hass_recorder() - setup_component(hass, "sensor", {}) + await async_setup_component(hass, "sensor", {}) - entity_reg = mock_registry(hass) + reg_entry = entity_registry.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + await hass.async_block_till_done() - @callback - def add_entry(): - reg_entry = entity_reg.async_get_or_create( - "sensor", - "test", - "unique_0000", - suggested_object_id="test1", - ) - assert reg_entry.entity_id == "sensor.test1" - - hass.add_job(add_entry) - hass.block_till_done() - - zero, four, states = record_states(hass) + zero, four, states = await async_record_states(hass) hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) @@ -589,7 +578,7 @@ def test_rename_entity_collision_states_meta_check_disabled( assert stats == {} do_adhoc_statistics(hass, start=zero) - wait_recording_done(hass) + await async_wait_recording_done(hass) expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -624,14 +613,10 @@ def test_rename_entity_collision_states_meta_check_disabled( # so that we hit the filter_unique_constraint_integrity_error safeguard in the statistics with patch.object(instance.statistics_meta_manager, "get", return_value=None): # Rename entity sensor.test1 to sensor.test99 - @callback - def rename_entry(): - entity_reg.async_update_entity( - "sensor.test1", new_entity_id="sensor.test99" - ) - - hass.add_job(rename_entry) - wait_recording_done(hass) + entity_registry.async_update_entity( + "sensor.test1", new_entity_id="sensor.test99" + ) + await async_wait_recording_done(hass) # Statistics failed to migrate due to the collision stats = statistics_during_period(hass, zero, period="5minute") @@ -647,17 +632,16 @@ def test_rename_entity_collision_states_meta_check_disabled( ) not in caplog.text -def test_statistics_duplicated( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_statistics_duplicated( + hass: HomeAssistant, setup_recorder: None, caplog: pytest.LogCaptureFixture ) -> None: """Test statistics with same start time is not compiled.""" - hass = hass_recorder() - setup_component(hass, "sensor", {}) - zero, four, states = record_states(hass) + await async_setup_component(hass, "sensor", {}) + zero, four, states = await async_record_states(hass) hist = history.get_significant_states(hass, zero, four, list(states)) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -666,7 +650,7 @@ def test_statistics_duplicated( return_value=statistics.PlatformCompiledStatistics([], {}), ) as compile_statistics: do_adhoc_statistics(hass, start=zero) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert compile_statistics.called compile_statistics.reset_mock() assert "Compiling statistics for" in caplog.text @@ -674,7 +658,7 @@ def test_statistics_duplicated( caplog.clear() do_adhoc_statistics(hass, start=zero) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert not compile_statistics.called compile_statistics.reset_mock() assert "Compiling statistics for" not in caplog.text @@ -933,12 +917,11 @@ async def test_import_statistics( } -def test_external_statistics_errors( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_external_statistics_errors( + hass: HomeAssistant, setup_recorder: None, caplog: pytest.LogCaptureFixture ) -> None: """Test validation of external statistics.""" - hass = hass_recorder() - wait_recording_done(hass) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -970,7 +953,7 @@ def test_external_statistics_errors( external_statistics = {**_external_statistics} with pytest.raises(HomeAssistantError): async_add_external_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} @@ -980,7 +963,7 @@ def test_external_statistics_errors( external_statistics = {**_external_statistics} with pytest.raises(HomeAssistantError): async_add_external_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} @@ -993,7 +976,7 @@ def test_external_statistics_errors( } with pytest.raises(HomeAssistantError): async_add_external_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} @@ -1003,7 +986,7 @@ def test_external_statistics_errors( external_statistics = {**_external_statistics, "start": period1.replace(minute=1)} with pytest.raises(HomeAssistantError): async_add_external_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} @@ -1016,18 +999,17 @@ def test_external_statistics_errors( } with pytest.raises(HomeAssistantError): async_add_external_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} -def test_import_statistics_errors( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_import_statistics_errors( + hass: HomeAssistant, setup_recorder: None, caplog: pytest.LogCaptureFixture ) -> None: """Test validation of imported statistics.""" - hass = hass_recorder() - wait_recording_done(hass) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1059,7 +1041,7 @@ def test_import_statistics_errors( external_statistics = {**_external_statistics} with pytest.raises(HomeAssistantError): async_import_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} @@ -1069,7 +1051,7 @@ def test_import_statistics_errors( external_statistics = {**_external_statistics} with pytest.raises(HomeAssistantError): async_import_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} @@ -1082,7 +1064,7 @@ def test_import_statistics_errors( } with pytest.raises(HomeAssistantError): async_import_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} @@ -1092,7 +1074,7 @@ def test_import_statistics_errors( external_statistics = {**_external_statistics, "start": period1.replace(minute=1)} with pytest.raises(HomeAssistantError): async_import_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} @@ -1105,7 +1087,7 @@ def test_import_statistics_errors( } with pytest.raises(HomeAssistantError): async_import_statistics(hass, external_metadata, (external_statistics,)) - wait_recording_done(hass) + await async_wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} @@ -1113,14 +1095,15 @@ def test_import_statistics_errors( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_daily_statistics_sum( - hass_recorder: Callable[..., HomeAssistant], +async def test_daily_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, caplog: pytest.LogCaptureFixture, timezone, ) -> None: """Test daily statistics.""" - hass = hass_recorder(timezone=timezone) - wait_recording_done(hass) + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1180,7 +1163,7 @@ def test_daily_statistics_sum( } async_add_external_statistics(hass, external_metadata, external_statistics) - wait_recording_done(hass) + await async_wait_recording_done(hass) stats = statistics_during_period( hass, zero, period="day", statistic_ids={"test:total_energy_import"} ) @@ -1292,14 +1275,15 @@ def test_daily_statistics_sum( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_weekly_statistics_mean( - hass_recorder: Callable[..., HomeAssistant], +async def test_weekly_statistics_mean( + hass: HomeAssistant, + setup_recorder: None, caplog: pytest.LogCaptureFixture, timezone, ) -> None: """Test weekly statistics.""" - hass = hass_recorder(timezone=timezone) - wait_recording_done(hass) + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1349,7 +1333,7 @@ def test_weekly_statistics_mean( } async_add_external_statistics(hass, external_metadata, external_statistics) - wait_recording_done(hass) + await async_wait_recording_done(hass) # Get all data stats = statistics_during_period( hass, zero, period="week", statistic_ids={"test:total_energy_import"} @@ -1426,14 +1410,15 @@ def test_weekly_statistics_mean( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_weekly_statistics_sum( - hass_recorder: Callable[..., HomeAssistant], +async def test_weekly_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, caplog: pytest.LogCaptureFixture, timezone, ) -> None: """Test weekly statistics.""" - hass = hass_recorder(timezone=timezone) - wait_recording_done(hass) + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1493,7 +1478,7 @@ def test_weekly_statistics_sum( } async_add_external_statistics(hass, external_metadata, external_statistics) - wait_recording_done(hass) + await async_wait_recording_done(hass) stats = statistics_during_period( hass, zero, period="week", statistic_ids={"test:total_energy_import"} ) @@ -1605,14 +1590,15 @@ def test_weekly_statistics_sum( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -def test_monthly_statistics_sum( - hass_recorder: Callable[..., HomeAssistant], +async def test_monthly_statistics_sum( + hass: HomeAssistant, + setup_recorder: None, caplog: pytest.LogCaptureFixture, timezone, ) -> None: """Test monthly statistics.""" - hass = hass_recorder(timezone=timezone) - wait_recording_done(hass) + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1672,7 +1658,7 @@ def test_monthly_statistics_sum( } async_add_external_statistics(hass, external_metadata, external_statistics) - wait_recording_done(hass) + await async_wait_recording_done(hass) stats = statistics_during_period( hass, zero, period="month", statistic_ids={"test:total_energy_import"} ) @@ -1924,14 +1910,15 @@ def test_cache_key_for_generate_statistics_at_time_stmt() -> None: @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_change( - hass_recorder: Callable[..., HomeAssistant], +async def test_change( + hass: HomeAssistant, + setup_recorder: None, caplog: pytest.LogCaptureFixture, timezone, ) -> None: """Test deriving change from sum statistic.""" - hass = hass_recorder(timezone=timezone) - wait_recording_done(hass) + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1977,7 +1964,7 @@ def test_change( } async_import_statistics(hass, external_metadata, external_statistics) - wait_recording_done(hass) + await async_wait_recording_done(hass) # Get change from far in the past stats = statistics_during_period( hass, @@ -2258,8 +2245,9 @@ def test_change( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_change_with_none( - hass_recorder: Callable[..., HomeAssistant], +async def test_change_with_none( + hass: HomeAssistant, + setup_recorder: None, caplog: pytest.LogCaptureFixture, timezone, ) -> None: @@ -2268,8 +2256,8 @@ def test_change_with_none( This tests the behavior when some record has None sum. The calculated change is not expected to be correct, but we should not raise on this error. """ - hass = hass_recorder(timezone=timezone) - wait_recording_done(hass) + await hass.config.async_set_time_zone(timezone) + await async_wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -2315,7 +2303,7 @@ def test_change_with_none( } async_add_external_statistics(hass, external_metadata, external_statistics) - wait_recording_done(hass) + await async_wait_recording_done(hass) # Get change from far in the past stats = statistics_during_period( hass, diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index 28c7613e761..af784692612 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -9,12 +9,13 @@ import importlib import json from pathlib import Path import sys +import threading from unittest.mock import patch import pytest from homeassistant.components import recorder -from homeassistant.components.recorder import SQLITE_URL_PREFIX +from homeassistant.components.recorder import SQLITE_URL_PREFIX, get_instance from homeassistant.components.recorder.util import session_scope from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import setup_component @@ -176,6 +177,7 @@ def test_delete_duplicates(caplog: pytest.LogCaptureFixture, tmp_path: Path) -> ): recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + get_instance(hass).recorder_and_worker_thread_ids.add(threading.get_ident()) wait_recording_done(hass) wait_recording_done(hass) @@ -358,6 +360,7 @@ def test_delete_duplicates_many( ): recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + get_instance(hass).recorder_and_worker_thread_ids.add(threading.get_ident()) wait_recording_done(hass) wait_recording_done(hass) @@ -517,6 +520,7 @@ def test_delete_duplicates_non_identical( ): recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + get_instance(hass).recorder_and_worker_thread_ids.add(threading.get_ident()) wait_recording_done(hass) wait_recording_done(hass) @@ -552,7 +556,7 @@ def test_delete_duplicates_non_identical( isotime = dt_util.utcnow().isoformat() backup_file_name = f".storage/deleted_statistics.{isotime}.json" - with open(hass.config.path(backup_file_name)) as backup_file: + with open(hass.config.path(backup_file_name), encoding="utf8") as backup_file: backup = json.load(backup_file) assert backup == [ @@ -631,6 +635,7 @@ def test_delete_duplicates_short_term( ): recorder_helper.async_initialize_recorder(hass) setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + get_instance(hass).recorder_and_worker_thread_ids.add(threading.get_ident()) wait_recording_done(hass) wait_recording_done(hass) diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index ee4217dab69..fbcefa0b13e 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -37,18 +37,18 @@ async def test_recorder_system_health( @pytest.mark.parametrize( - "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] + "db_engine", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) async def test_recorder_system_health_alternate_dbms( - recorder_mock: Recorder, hass: HomeAssistant, dialect_name + recorder_mock: Recorder, + hass: HomeAssistant, + db_engine: SupportedDialect, + recorder_dialect_name: None, ) -> None: """Test recorder system health.""" assert await async_setup_component(hass, "system_health", {}) await async_wait_recording_done(hass) with ( - patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", dialect_name - ), patch( "sqlalchemy.orm.session.Session.execute", return_value=Mock(scalar=Mock(return_value=("1048576"))), @@ -60,16 +60,19 @@ async def test_recorder_system_health_alternate_dbms( "current_recorder_run": instance.recorder_runs_manager.current.start, "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": "1.00 MiB", - "database_engine": dialect_name.value, + "database_engine": db_engine.value, "database_version": ANY, } @pytest.mark.parametrize( - "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] + "db_engine", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] ) async def test_recorder_system_health_db_url_missing_host( - recorder_mock: Recorder, hass: HomeAssistant, dialect_name + recorder_mock: Recorder, + hass: HomeAssistant, + db_engine: SupportedDialect, + recorder_dialect_name: None, ) -> None: """Test recorder system health with a db_url without a hostname.""" assert await async_setup_component(hass, "system_health", {}) @@ -77,9 +80,6 @@ async def test_recorder_system_health_db_url_missing_host( instance = get_instance(hass) with ( - patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", dialect_name - ), patch.object( instance, "db_url", @@ -95,7 +95,7 @@ async def test_recorder_system_health_db_url_missing_host( "current_recorder_run": instance.recorder_runs_manager.current.start, "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": "1.00 MiB", - "database_engine": dialect_name.value, + "database_engine": db_engine.value, "database_version": ANY, } diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 9e32fa2c500..d72978c57bb 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -1,21 +1,21 @@ """Test util methods.""" -from collections.abc import Callable from datetime import UTC, datetime, timedelta import os from pathlib import Path import sqlite3 +import threading from unittest.mock import MagicMock, Mock, patch import pytest from sqlalchemy import lambda_stmt, text from sqlalchemy.engine.result import ChunkedIteratorResult -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder -from homeassistant.components.recorder import util +from homeassistant.components.recorder import Recorder, util from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.history.modern import ( @@ -26,7 +26,6 @@ from homeassistant.components.recorder.models import ( process_timestamp, ) from homeassistant.components.recorder.util import ( - chunked_or_all, end_incomplete_runs, is_second_sunday, resolve_period, @@ -34,18 +33,36 @@ from homeassistant.components.recorder.util import ( ) from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry +from homeassistant.helpers import issue_registry as ir from homeassistant.util import dt as dt_util -from .common import corrupt_db_file, run_information_with_session, wait_recording_done +from .common import ( + async_wait_recording_done, + corrupt_db_file, + run_information_with_session, +) from tests.common import async_test_home_assistant from tests.typing import RecorderInstanceGenerator -def test_session_scope_not_setup(hass_recorder: Callable[..., HomeAssistant]) -> None: +@pytest.fixture +async def mock_recorder_before_hass( + async_setup_recorder_instance: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + + +@pytest.fixture +def setup_recorder(recorder_mock: Recorder) -> None: + """Set up recorder.""" + + +async def test_session_scope_not_setup( + hass: HomeAssistant, + setup_recorder: None, +) -> None: """Try to create a session scope when not setup.""" - hass = hass_recorder() with ( patch.object(util.get_instance(hass), "get_session", return_value=None), pytest.raises(RuntimeError), @@ -54,11 +71,8 @@ def test_session_scope_not_setup(hass_recorder: Callable[..., HomeAssistant]) -> pass -def test_recorder_bad_execute(hass_recorder: Callable[..., HomeAssistant]) -> None: +async def test_recorder_bad_execute(hass: HomeAssistant, setup_recorder: None) -> None: """Bad execute, retry 3 times.""" - from sqlalchemy.exc import SQLAlchemyError - - hass_recorder() def to_native(validate_entity_id=True): """Raise exception.""" @@ -602,7 +616,11 @@ def test_warn_unsupported_dialect( ], ) async def test_issue_for_mariadb_with_MDEV_25020( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mysql_version, min_version + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mysql_version, + min_version, + issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue for MariaDB versions affected. @@ -637,8 +655,7 @@ async def test_issue_for_mariadb_with_MDEV_25020( ) await hass.async_block_till_done() - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "maria_db_range_index_regression") + issue = issue_registry.async_get_issue(DOMAIN, "maria_db_range_index_regression") assert issue is not None assert issue.translation_placeholders == {"min_version": min_version} @@ -657,7 +674,10 @@ async def test_issue_for_mariadb_with_MDEV_25020( ], ) async def test_no_issue_for_mariadb_with_MDEV_25020( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mysql_version + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mysql_version, + issue_registry: ir.IssueRegistry, ) -> None: """Test we do not create an issue for MariaDB versions not affected. @@ -692,24 +712,21 @@ async def test_no_issue_for_mariadb_with_MDEV_25020( ) await hass.async_block_till_done() - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "maria_db_range_index_regression") + issue = issue_registry.async_get_issue(DOMAIN, "maria_db_range_index_regression") assert issue is None assert database_engine is not None assert database_engine.optimizer.slow_range_in_select is False -def test_basic_sanity_check( - hass_recorder: Callable[..., HomeAssistant], recorder_db_url +async def test_basic_sanity_check( + hass: HomeAssistant, setup_recorder: None, recorder_db_url: str ) -> None: """Test the basic sanity checks with a missing table.""" if recorder_db_url.startswith(("mysql://", "postgresql://")): # This test is specific for SQLite return - hass = hass_recorder() - cursor = util.get_instance(hass).engine.raw_connection().cursor() assert util.basic_sanity_check(cursor) is True @@ -720,17 +737,17 @@ def test_basic_sanity_check( util.basic_sanity_check(cursor) -def test_combined_checks( - hass_recorder: Callable[..., HomeAssistant], +async def test_combined_checks( + hass: HomeAssistant, + setup_recorder: None, caplog: pytest.LogCaptureFixture, - recorder_db_url, + recorder_db_url: str, ) -> None: """Run Checks on the open database.""" if recorder_db_url.startswith(("mysql://", "postgresql://")): # This test is specific for SQLite return - hass = hass_recorder() instance = util.get_instance(hass) instance.db_retry_wait = 0 @@ -788,12 +805,10 @@ def test_combined_checks( util.run_checks_on_open_db("fake_db_path", cursor) -def test_end_incomplete_runs( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +async def test_end_incomplete_runs( + hass: HomeAssistant, setup_recorder: None, caplog: pytest.LogCaptureFixture ) -> None: """Ensure we can end incomplete runs.""" - hass = hass_recorder() - with session_scope(hass=hass) as session: run_info = run_information_with_session(session) assert isinstance(run_info, RecorderRuns) @@ -814,15 +829,14 @@ def test_end_incomplete_runs( assert "Ended unfinished session" in caplog.text -def test_periodic_db_cleanups( - hass_recorder: Callable[..., HomeAssistant], recorder_db_url +async def test_periodic_db_cleanups( + hass: HomeAssistant, setup_recorder: None, recorder_db_url: str ) -> None: """Test periodic db cleanups.""" if recorder_db_url.startswith(("mysql://", "postgresql://")): # This test is specific for SQLite return - hass = hass_recorder() with patch.object(util.get_instance(hass).engine, "connect") as connect_mock: util.periodic_db_cleanups(util.get_instance(hass)) @@ -833,15 +847,12 @@ def test_periodic_db_cleanups( assert str(text_obj) == "PRAGMA wal_checkpoint(TRUNCATE);" -@patch("homeassistant.components.recorder.pool.check_loop") async def test_write_lock_db( - skip_check_loop, async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, tmp_path: Path, ) -> None: """Test database write lock.""" - from sqlalchemy.exc import OperationalError # Use file DB, in memory DB cannot do write locks. config = { @@ -854,6 +865,7 @@ async def test_write_lock_db( with instance.engine.connect() as connection: connection.execute(text("DROP TABLE events;")) + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) with util.write_lock_db_sqlite(instance), pytest.raises(OperationalError): # Database should be locked now, try writing SQL command # This needs to be called in another thread since @@ -862,7 +874,7 @@ async def test_write_lock_db( # in the same thread as the one holding the lock since it # would be allowed to proceed as the goal is to prevent # all the other threads from accessing the database - await hass.async_add_executor_job(_drop_table) + await instance.async_add_executor_job(_drop_table) def test_is_second_sunday() -> None: @@ -894,15 +906,15 @@ 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], +async def test_execute_stmt_lambda_element( + hass: HomeAssistant, + setup_recorder: None, ) -> None: """Test executing with execute_stmt_lambda_element.""" - hass = hass_recorder() instance = recorder.get_instance(hass) - hass.states.set("sensor.on", "on") + hass.states.async_set("sensor.on", "on") new_state = hass.states.get("sensor.on") - wait_recording_done(hass) + await async_wait_recording_done(hass) now = dt_util.utcnow() tomorrow = now + timedelta(days=1) one_week_from_now = now + timedelta(days=7) @@ -1036,24 +1048,3 @@ async def test_resolve_period(hass: HomeAssistant) -> None: } } ) == (now - timedelta(hours=1, minutes=25), now - timedelta(minutes=25)) - - -def test_chunked_or_all(): - """Test chunked_or_all can iterate chunk sizes larger than the passed in collection.""" - all_items = [] - incoming = (1, 2, 3, 4) - for chunk in chunked_or_all(incoming, 2): - assert len(chunk) == 2 - all_items.extend(chunk) - assert all_items == [1, 2, 3, 4] - - all_items = [] - incoming = (1, 2, 3, 4) - for chunk in chunked_or_all(incoming, 5): - assert len(chunk) == 4 - # Verify the chunk is the same object as the incoming - # collection since we want to avoid copying the collection - # if we don't need to - assert chunk is incoming - all_items.extend(chunk) - assert all_items == [1, 2, 3, 4] diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 4a1410d45a4..cc187a1e6ad 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -7,6 +7,7 @@ import threading from unittest.mock import ANY, patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import recorder @@ -794,6 +795,347 @@ async def test_statistic_during_period_hole( } +@pytest.mark.parametrize( + "frozen_time", + [ + # This is the normal case, all statistics runs are available + datetime.datetime(2022, 10, 21, 6, 31, tzinfo=datetime.UTC), + # Statistic only available up until 6:25, this can happen if + # core has been shut down for an hour + datetime.datetime(2022, 10, 21, 7, 31, tzinfo=datetime.UTC), + ], +) +async def test_statistic_during_period_partial_overlap( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + frozen_time: datetime, +) -> None: + """Test statistic_during_period.""" + client = await hass_ws_client() + + freezer.move_to(frozen_time) + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + + zero = now + start = zero.replace(hour=0, minute=0, second=0, microsecond=0) + + # Sum shall be tracking a hypothetical sensor that is 0 at midnight, and grows by 1 per minute. + # The test will have 4 hours of LTS-only data (0:00-3:59:59), followed by 2 hours of overlapping STS/LTS (4:00-5:59:59), followed by 30 minutes of STS only (6:00-6:29:59) + # similar to how a real recorder might look after purging STS. + + # The datapoint at i=0 (start = 0:00) will be 60 as that is the growth during the hour starting at the start period + imported_stats_hours = [ + { + "start": (start + timedelta(hours=i)), + "min": i * 60, + "max": i * 60 + 60, + "mean": i * 60 + 30, + "sum": (i + 1) * 60, + } + for i in range(6) + ] + + # The datapoint at i=0 (start = 4:00) would be the sensor's value at t=4:05, or 245 + imported_stats_5min = [ + { + "start": (start + timedelta(hours=4, minutes=5 * i)), + "min": 4 * 60 + i * 5, + "max": 4 * 60 + i * 5 + 5, + "mean": 4 * 60 + i * 5 + 2.5, + "sum": 4 * 60 + (i + 1) * 5, + } + for i in range(30) + ] + + assert imported_stats_hours[-1]["sum"] == 360 + assert imported_stats_hours[-1]["start"] == start.replace( + hour=5, minute=0, second=0, microsecond=0 + ) + assert imported_stats_5min[-1]["sum"] == 390 + assert imported_stats_5min[-1]["start"] == start.replace( + hour=6, minute=25, second=0, microsecond=0 + ) + + statId = "sensor.test_overlapping" + imported_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy overlapping", + "source": "recorder", + "statistic_id": statId, + "unit_of_measurement": "kWh", + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_hours, + Statistics, + ) + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_5min, + StatisticsShortTerm, + ) + await async_wait_recording_done(hass) + + metadata = get_metadata(hass, statistic_ids={statId}) + metadata_id = metadata[statId][0] + run_cache = get_short_term_statistics_run_cache(hass) + # Verify the import of the short term statistics + # also updates the run cache + assert run_cache.get_latest_ids({metadata_id}) is not None + + # Get all the stats, should consider all hours and 5mins + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": statId, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "change": 390, + "max": 390, + "min": 0, + "mean": 195, + } + + async def assert_stat_during_fixed(client, start_time, end_time, expect): + json = { + "type": "recorder/statistic_during_period", + "types": list(expect.keys()), + "statistic_id": statId, + "fixed_period": {}, + } + if start_time: + json["fixed_period"]["start_time"] = start_time.isoformat() + if end_time: + json["fixed_period"]["end_time"] = end_time.isoformat() + + await client.send_json_auto_id(json) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expect + + # One hours worth of growth in LTS-only + start_time = start.replace(hour=1) + end_time = start.replace(hour=2) + await assert_stat_during_fixed( + client, start_time, end_time, {"change": 60, "min": 60, "max": 120, "mean": 90} + ) + + # Five minutes of growth in STS-only + start_time = start.replace(hour=6, minute=15) + end_time = start.replace(hour=6, minute=20) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 6 * 60 + 15, + "max": 6 * 60 + 20, + "mean": 6 * 60 + (15 + 20) / 2, + }, + ) + + # Six minutes of growth in STS-only + start_time = start.replace(hour=6, minute=14) + end_time = start.replace(hour=6, minute=20) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 6 * 60 + 15, + "max": 6 * 60 + 20, + "mean": 6 * 60 + (15 + 20) / 2, + }, + ) + + # Six minutes of growth in STS-only + # 5-minute Change includes start times exactly on or before a statistics start, but end times are not counted unless they are greater than start. + start_time = start.replace(hour=6, minute=15) + end_time = start.replace(hour=6, minute=21) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 10, + "min": 6 * 60 + 15, + "max": 6 * 60 + 25, + "mean": 6 * 60 + (15 + 25) / 2, + }, + ) + + # Five minutes of growth in overlapping LTS+STS + start_time = start.replace(hour=5, minute=15) + end_time = start.replace(hour=5, minute=20) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 5 * 60 + 15, + "max": 5 * 60 + 20, + "mean": 5 * 60 + (15 + 20) / 2, + }, + ) + + # Five minutes of growth in overlapping LTS+STS (start of hour) + start_time = start.replace(hour=5, minute=0) + end_time = start.replace(hour=5, minute=5) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 5, "min": 5 * 60, "max": 5 * 60 + 5, "mean": 5 * 60 + (5) / 2}, + ) + + # Five minutes of growth in overlapping LTS+STS (end of hour) + start_time = start.replace(hour=4, minute=55) + end_time = start.replace(hour=5, minute=0) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 4 * 60 + 55, + "max": 5 * 60, + "mean": 4 * 60 + (55 + 60) / 2, + }, + ) + + # Five minutes of growth in STS-only, with a minute offset. Despite that this does not cover the full period, result is still 5 + start_time = start.replace(hour=6, minute=16) + end_time = start.replace(hour=6, minute=21) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 6 * 60 + 20, + "max": 6 * 60 + 25, + "mean": 6 * 60 + (20 + 25) / 2, + }, + ) + + # 7 minutes of growth in STS-only, spanning two intervals + start_time = start.replace(hour=6, minute=14) + end_time = start.replace(hour=6, minute=21) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 10, + "min": 6 * 60 + 15, + "max": 6 * 60 + 25, + "mean": 6 * 60 + (15 + 25) / 2, + }, + ) + + # One hours worth of growth in LTS-only, with arbitrary minute offsets + # Since this does not fully cover the hour, result is None? + start_time = start.replace(hour=1, minute=40) + end_time = start.replace(hour=2, minute=12) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": None, "min": None, "max": None, "mean": None}, + ) + + # One hours worth of growth in LTS-only, with arbitrary minute offsets, covering a whole 1-hour period + start_time = start.replace(hour=1, minute=40) + end_time = start.replace(hour=3, minute=12) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 60, "min": 120, "max": 180, "mean": 150}, + ) + + # 90 minutes of growth in window overlapping LTS+STS/STS-only (4:41 - 6:11) + start_time = start.replace(hour=4, minute=41) + end_time = start_time + timedelta(minutes=90) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 90, + "min": 4 * 60 + 45, + "max": 4 * 60 + 45 + 90, + "mean": 4 * 60 + 45 + 45, + }, + ) + + # 4 hours of growth in overlapping LTS-only/LTS+STS (2:01-6:01) + start_time = start.replace(hour=2, minute=1) + end_time = start_time + timedelta(minutes=240) + # 60 from LTS (3:00-3:59), 125 from STS (25 intervals) (4:00-6:01) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 185, "min": 3 * 60, "max": 3 * 60 + 185, "mean": 3 * 60 + 185 / 2}, + ) + + # 4 hours of growth in overlapping LTS-only/LTS+STS (1:31-5:31) + start_time = start.replace(hour=1, minute=31) + end_time = start_time + timedelta(minutes=240) + # 120 from LTS (2:00-3:59), 95 from STS (19 intervals) 4:00-5:31 + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 215, "min": 2 * 60, "max": 2 * 60 + 215, "mean": 2 * 60 + 215 / 2}, + ) + + # 5 hours of growth, start time only (1:31-end) + start_time = start.replace(hour=1, minute=31) + end_time = None + # will be actually 2:00 - end + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 4 * 60 + 30, "min": 120, "max": 390, "mean": (390 + 120) / 2}, + ) + + # 5 hours of growth, end_time_only (0:00-5:00) + start_time = None + end_time = start.replace(hour=5) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 5 * 60, "min": 0, "max": 5 * 60, "mean": (5 * 60) / 2}, + ) + + # 5 hours 1 minute of growth, end_time_only (0:00-5:01) + start_time = None + end_time = start.replace(hour=5, minute=1) + # 4 hours LTS, 1 hour and 5 minutes STS (4:00-5:01) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 5 * 60 + 5, "min": 0, "max": 5 * 60 + 5, "mean": (5 * 60 + 5) / 2}, + ) + + @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) @pytest.mark.parametrize( ("calendar_period", "start_time", "end_time"), @@ -1119,7 +1461,7 @@ async def test_statistics_during_period_in_the_past( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test statistics_during_period in the past.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") now = dt_util.utcnow().replace() hass.config.units = US_CUSTOMARY_SYSTEM @@ -2474,7 +2816,7 @@ async def test_import_statistics( }, ] } - statistic_ids = list_statistic_ids(hass) # TODO + statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ { "display_unit_of_measurement": "kWh", @@ -2692,7 +3034,7 @@ async def test_adjust_sum_statistics_energy( }, ] } - statistic_ids = list_statistic_ids(hass) # TODO + statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ { "display_unit_of_measurement": "kWh", @@ -2885,7 +3227,7 @@ async def test_adjust_sum_statistics_gas( }, ] } - statistic_ids = list_statistic_ids(hass) # TODO + statistic_ids = list_statistic_ids(hass) assert statistic_ids == [ { "display_unit_of_measurement": "m³", @@ -3177,3 +3519,81 @@ async def test_adjust_sum_statistics_errors( stats = statistics_during_period(hass, zero, period="hour") assert stats != previous_stats previous_stats = stats + + +async def test_import_statistics_with_last_reset( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test importing external statistics with last_reset can be fetched via websocket api.""" + client = await hass_ws_client() + + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + last_reset = dt_util.parse_datetime("2022-01-01T00:00:00+02:00") + period1 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + period2 = zero.replace(minute=0, second=0, microsecond=0) + timedelta(hours=2) + + external_statistics1 = { + "start": period1, + "last_reset": last_reset, + "state": 0, + "sum": 2, + } + external_statistics2 = { + "start": period2, + "last_reset": last_reset, + "state": 1, + "sum": 3, + } + + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics( + hass, external_metadata, (external_statistics1, external_statistics2) + ) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "recorder/statistics_during_period", + "start_time": zero.isoformat(), + "end_time": (zero + timedelta(hours=48)).isoformat(), + "statistic_ids": ["test:total_energy_import"], + "period": "hour", + "types": ["change", "last_reset", "max", "mean", "min", "state", "sum"], + } + ) + response = await client.receive_json() + assert response["result"] == { + "test:total_energy_import": [ + { + "change": 2.0, + "end": (period1.timestamp() * 1000) + (3600 * 1000), + "last_reset": last_reset.timestamp() * 1000, + "start": period1.timestamp() * 1000, + "state": 0.0, + "sum": 2.0, + }, + { + "change": 1.0, + "end": (period2.timestamp() * 1000 + (3600 * 1000)), + "last_reset": last_reset.timestamp() * 1000, + "start": period2.timestamp() * 1000, + "state": 1.0, + "sum": 3.0, + }, + ] + } diff --git a/tests/components/reddit/test_sensor.py b/tests/components/reddit/test_sensor.py index 92ee282e9c8..52dac07d621 100644 --- a/tests/components/reddit/test_sensor.py +++ b/tests/components/reddit/test_sensor.py @@ -111,7 +111,7 @@ class MockPraw: username: str, password: str, user_agent: str, - ): + ) -> None: """Add mock data for API return.""" self._data = MOCK_RESULTS @@ -123,7 +123,7 @@ class MockPraw: class MockSubreddit: """Mock class for a subreddit instance.""" - def __init__(self, subreddit: str, data): + def __init__(self, subreddit: str, data) -> None: """Add mock data for API return.""" self._subreddit = subreddit self._data = data diff --git a/tests/components/refoss/__init__.py b/tests/components/refoss/__init__.py index 51c4261b954..1a3e02dac62 100644 --- a/tests/components/refoss/__init__.py +++ b/tests/components/refoss/__init__.py @@ -22,6 +22,7 @@ class FakeDiscovery: self.mock_devices = {"abc": build_device_mock()} self.last_mock_infos = {} self._listeners = [] + self.sock = None def add_listener(self, listener: Listener) -> None: """Add an event listener.""" diff --git a/tests/components/refoss/conftest.py b/tests/components/refoss/conftest.py index d627af5b5ab..80b3f4d8b75 100644 --- a/tests/components/refoss/conftest.py +++ b/tests/components/refoss/conftest.py @@ -1,13 +1,13 @@ """Pytest module configuration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.refoss.async_setup_entry", return_value=True diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 50a859af446..a6e890937b5 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -7,7 +7,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -25,7 +25,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -53,7 +53,7 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in ["turn_off", "turn_on", "toggle"] + for action in ("turn_off", "turn_on", "toggle") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -101,7 +101,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["turn_off", "turn_on", "toggle"] + for action in ("turn_off", "turn_on", "toggle") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -109,12 +109,12 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_action( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off actions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -184,12 +184,12 @@ async def test_action( assert turn_on_calls[-1].data == {"entity_id": entry.entity_id} +@pytest.mark.usefixtures("enable_custom_integrations") async def test_action_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off actions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index 4fd14e82990..d13a0480355 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -10,7 +10,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -59,7 +59,7 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -107,7 +107,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -178,12 +178,12 @@ async def test_get_condition_capabilities_legacy( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -265,12 +265,12 @@ async def test_if_state( assert calls[1].data["some"] == "is_off event - test_event2" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -324,12 +324,12 @@ async def test_if_state_legacy( assert calls[0].data["some"] == "is_on event - test_event1" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_for_condition( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for firing if condition is on with delay.""" point1 = dt_util.utcnow() diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index 68f7215186f..8a1a0c318d7 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -9,7 +9,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.remote import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -59,7 +59,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -107,7 +107,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -176,12 +176,12 @@ async def test_get_trigger_capabilities_legacy( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -286,12 +286,12 @@ async def test_if_fires_on_state_change( } +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -346,12 +346,12 @@ async def test_if_fires_on_state_change_legacy( ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index c06abc8efd0..a5af01b504a 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,6 +1,5 @@ """Provide common Renault fixtures.""" -from collections.abc import Generator import contextlib from types import MappingProxyType from typing import Any @@ -9,6 +8,7 @@ from unittest.mock import AsyncMock, patch import pytest from renault_api.kamereon import exceptions, schemas from renault_api.renault_account import RenaultAccount +from typing_extensions import Generator from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntry @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.renault.async_setup_entry", return_value=True diff --git a/tests/components/renault/fixtures/hvac_status.1.json b/tests/components/renault/fixtures/hvac_status.1.json index f48cbae68ae..7cbd7a9fe37 100644 --- a/tests/components/renault/fixtures/hvac_status.1.json +++ b/tests/components/renault/fixtures/hvac_status.1.json @@ -2,6 +2,6 @@ "data": { "type": "Car", "id": "VF1AAAAA555777999", - "attributes": { "externalTemperature": 8.0, "hvacStatus": "off" } + "attributes": { "externalTemperature": 8.0, "hvacStatus": 1 } } } diff --git a/tests/components/renault/fixtures/hvac_status.2.json b/tests/components/renault/fixtures/hvac_status.2.json index a2ca08a71e9..8bb4f941e06 100644 --- a/tests/components/renault/fixtures/hvac_status.2.json +++ b/tests/components/renault/fixtures/hvac_status.2.json @@ -4,7 +4,7 @@ "id": "VF1AAAAA555777999", "attributes": { "socThreshold": 30.0, - "hvacStatus": "off", + "hvacStatus": 1, "lastUpdateTime": "2020-12-03T00:00:00Z" } } diff --git a/tests/components/renault/snapshots/test_diagnostics.ambr b/tests/components/renault/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ae90115fcb6 --- /dev/null +++ b/tests/components/renault/snapshots/test_diagnostics.ambr @@ -0,0 +1,402 @@ +# serializer version: 1 +# name: test_device_diagnostics[zoe_40] + dict({ + 'data': dict({ + 'battery': dict({ + 'batteryAutonomy': 141, + 'batteryAvailableEnergy': 31, + 'batteryCapacity': 0, + 'batteryLevel': 60, + 'batteryTemperature': 20, + 'chargingInstantaneousPower': 27, + 'chargingRemainingTime': 145, + 'chargingStatus': 1.0, + 'plugStatus': 1, + 'timestamp': '2020-01-12T21:40:16Z', + }), + 'charge_mode': dict({ + 'chargeMode': 'always', + }), + 'cockpit': dict({ + 'totalMileage': 49114.27, + }), + 'hvac_status': dict({ + 'externalTemperature': 8.0, + 'hvacStatus': 1, + }), + 'res_state': dict({ + }), + }), + 'details': dict({ + 'assets': list([ + dict({ + 'assetType': 'PICTURE', + 'renditions': list([ + dict({ + 'resolutionType': 'ONE_MYRENAULT_LARGE', + 'url': 'https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE', + }), + dict({ + 'resolutionType': 'ONE_MYRENAULT_SMALL', + 'url': 'https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2', + }), + ]), + }), + dict({ + 'assetRole': 'GUIDE', + 'assetType': 'PDF', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf', + }), + ]), + 'title': 'PDF Guide', + }), + dict({ + 'assetRole': 'GUIDE', + 'assetType': 'URL', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'http://gb.e-guide.renault.com/eng/Zoe', + }), + ]), + 'title': 'e-guide', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': '39r6QEKcOM4', + }), + ]), + 'title': '10 Fundamentals about getting the best out of your electric vehicle', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'Va2FnZFo_GE', + }), + ]), + 'title': 'Automatic Climate Control', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'URL', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'https://www.youtube.com/watch?v=wfpCMkK1rKI', + }), + ]), + 'title': 'More videos', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'RaEad8DjUJs', + }), + ]), + 'title': 'Charging the battery', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'zJfd7fJWtr0', + }), + ]), + 'title': 'Charging the battery at a station with a flap', + }), + ]), + 'battery': dict({ + 'code': 'BT4AR1', + 'group': '968', + 'label': 'BATTERIE BT4AR1', + }), + 'brand': dict({ + 'label': 'RENAULT', + }), + 'connectivityTechnology': 'RLINK1', + 'deliveryCountry': dict({ + 'code': 'FR', + 'label': 'FRANCE', + }), + 'deliveryDate': '2017-08-11', + 'easyConnectStore': False, + 'electrical': True, + 'energy': dict({ + 'code': 'ELEC', + 'group': '019', + 'label': 'ELECTRIQUE', + }), + 'engineEnergyType': 'ELEC', + 'engineRatio': '601', + 'engineType': '5AQ', + 'family': dict({ + 'code': 'X10', + 'group': '007', + 'label': 'FAMILLE X10', + }), + 'firstRegistrationDate': '2017-08-01', + 'gearbox': dict({ + 'code': 'BVEL', + 'group': '427', + 'label': 'BOITE A VARIATEUR ELECTRIQUE', + }), + 'model': dict({ + 'code': 'X101VE', + 'group': '971', + 'label': 'ZOE', + }), + 'modelSCR': 'ZOE', + 'navigationAssistanceLevel': dict({ + 'code': 'NAV3G5', + 'group': '408', + 'label': 'LEVEL 3 TYPE 5 NAVIGATION', + }), + 'radioCode': '**REDACTED**', + 'radioType': dict({ + 'code': 'RAD37A', + 'group': '425', + 'label': 'RADIO 37A', + }), + 'registrationCountry': dict({ + 'code': 'FR', + }), + 'registrationDate': '2017-08-01', + 'registrationNumber': '**REDACTED**', + 'retrievedFromDhs': False, + 'rlinkStore': False, + 'tcu': dict({ + 'code': 'TCU0G2', + 'group': 'E70', + 'label': 'TCU VER 0 GEN 2', + }), + 'vcd': 'SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ', + 'version': dict({ + 'code': 'INT MB 10R', + }), + 'vin': '**REDACTED**', + 'yearsOfMaintenance': 12, + }), + }) +# --- +# name: test_entry_diagnostics[zoe_40] + dict({ + 'entry': dict({ + 'data': dict({ + 'kamereon_account_id': '**REDACTED**', + 'locale': 'fr_FR', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'title': 'Mock Title', + }), + 'vehicles': list([ + dict({ + 'data': dict({ + 'battery': dict({ + 'batteryAutonomy': 141, + 'batteryAvailableEnergy': 31, + 'batteryCapacity': 0, + 'batteryLevel': 60, + 'batteryTemperature': 20, + 'chargingInstantaneousPower': 27, + 'chargingRemainingTime': 145, + 'chargingStatus': 1.0, + 'plugStatus': 1, + 'timestamp': '2020-01-12T21:40:16Z', + }), + 'charge_mode': dict({ + 'chargeMode': 'always', + }), + 'cockpit': dict({ + 'totalMileage': 49114.27, + }), + 'hvac_status': dict({ + 'externalTemperature': 8.0, + 'hvacStatus': 1, + }), + 'res_state': dict({ + }), + }), + 'details': dict({ + 'assets': list([ + dict({ + 'assetType': 'PICTURE', + 'renditions': list([ + dict({ + 'resolutionType': 'ONE_MYRENAULT_LARGE', + 'url': 'https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE', + }), + dict({ + 'resolutionType': 'ONE_MYRENAULT_SMALL', + 'url': 'https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2', + }), + ]), + }), + dict({ + 'assetRole': 'GUIDE', + 'assetType': 'PDF', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf', + }), + ]), + 'title': 'PDF Guide', + }), + dict({ + 'assetRole': 'GUIDE', + 'assetType': 'URL', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'http://gb.e-guide.renault.com/eng/Zoe', + }), + ]), + 'title': 'e-guide', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': '39r6QEKcOM4', + }), + ]), + 'title': '10 Fundamentals about getting the best out of your electric vehicle', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'Va2FnZFo_GE', + }), + ]), + 'title': 'Automatic Climate Control', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'URL', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'https://www.youtube.com/watch?v=wfpCMkK1rKI', + }), + ]), + 'title': 'More videos', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'RaEad8DjUJs', + }), + ]), + 'title': 'Charging the battery', + }), + dict({ + 'assetRole': 'CAR', + 'assetType': 'VIDEO', + 'description': '', + 'renditions': list([ + dict({ + 'url': 'zJfd7fJWtr0', + }), + ]), + 'title': 'Charging the battery at a station with a flap', + }), + ]), + 'battery': dict({ + 'code': 'BT4AR1', + 'group': '968', + 'label': 'BATTERIE BT4AR1', + }), + 'brand': dict({ + 'label': 'RENAULT', + }), + 'connectivityTechnology': 'RLINK1', + 'deliveryCountry': dict({ + 'code': 'FR', + 'label': 'FRANCE', + }), + 'deliveryDate': '2017-08-11', + 'easyConnectStore': False, + 'electrical': True, + 'energy': dict({ + 'code': 'ELEC', + 'group': '019', + 'label': 'ELECTRIQUE', + }), + 'engineEnergyType': 'ELEC', + 'engineRatio': '601', + 'engineType': '5AQ', + 'family': dict({ + 'code': 'X10', + 'group': '007', + 'label': 'FAMILLE X10', + }), + 'firstRegistrationDate': '2017-08-01', + 'gearbox': dict({ + 'code': 'BVEL', + 'group': '427', + 'label': 'BOITE A VARIATEUR ELECTRIQUE', + }), + 'model': dict({ + 'code': 'X101VE', + 'group': '971', + 'label': 'ZOE', + }), + 'modelSCR': 'ZOE', + 'navigationAssistanceLevel': dict({ + 'code': 'NAV3G5', + 'group': '408', + 'label': 'LEVEL 3 TYPE 5 NAVIGATION', + }), + 'radioCode': '**REDACTED**', + 'radioType': dict({ + 'code': 'RAD37A', + 'group': '425', + 'label': 'RADIO 37A', + }), + 'registrationCountry': dict({ + 'code': 'FR', + }), + 'registrationDate': '2017-08-01', + 'registrationNumber': '**REDACTED**', + 'retrievedFromDhs': False, + 'rlinkStore': False, + 'tcu': dict({ + 'code': 'TCU0G2', + 'group': 'E70', + 'label': 'TCU VER 0 GEN 2', + }), + 'vcd': 'SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ', + 'version': dict({ + 'code': 'INT MB 10R', + }), + 'vin': '**REDACTED**', + 'yearsOfMaintenance': 12, + }), + }), + ]), + }) +# --- diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 7a0d593a4c4..a0264493544 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -1,10 +1,10 @@ """Tests for Renault binary sensors.""" -from collections.abc import Generator from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -18,7 +18,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.renault.PLATFORMS", [Platform.BINARY_SENSOR]): yield diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index d592f040c97..bed188d8881 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -1,11 +1,11 @@ """Tests for Renault sensors.""" -from collections.abc import Generator from unittest.mock import patch import pytest from renault_api.kamereon import schemas from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry @@ -22,7 +22,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.renault.PLATFORMS", [Platform.BUTTON]): yield diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py index a809ce82e6e..d8bee097eda 100644 --- a/tests/components/renault/test_device_tracker.py +++ b/tests/components/renault/test_device_tracker.py @@ -1,10 +1,10 @@ """Tests for Renault sensors.""" -from collections.abc import Generator from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -18,7 +18,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.renault.PLATFORMS", [Platform.DEVICE_TRACKER]): yield diff --git a/tests/components/renault/test_diagnostics.py b/tests/components/renault/test_diagnostics.py index 3c8c1c7449e..7159de26b11 100644 --- a/tests/components/renault/test_diagnostics.py +++ b/tests/components/renault/test_diagnostics.py @@ -1,8 +1,8 @@ """Test Renault diagnostics.""" import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.components.renault import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -16,174 +16,23 @@ from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") -VEHICLE_DETAILS = { - "vin": REDACTED, - "registrationDate": "2017-08-01", - "firstRegistrationDate": "2017-08-01", - "engineType": "5AQ", - "engineRatio": "601", - "modelSCR": "ZOE", - "deliveryCountry": {"code": "FR", "label": "FRANCE"}, - "family": {"code": "X10", "label": "FAMILLE X10", "group": "007"}, - "tcu": { - "code": "TCU0G2", - "label": "TCU VER 0 GEN 2", - "group": "E70", - }, - "navigationAssistanceLevel": { - "code": "NAV3G5", - "label": "LEVEL 3 TYPE 5 NAVIGATION", - "group": "408", - }, - "battery": { - "code": "BT4AR1", - "label": "BATTERIE BT4AR1", - "group": "968", - }, - "radioType": { - "code": "RAD37A", - "label": "RADIO 37A", - "group": "425", - }, - "registrationCountry": {"code": "FR"}, - "brand": {"label": "RENAULT"}, - "model": {"code": "X101VE", "label": "ZOE", "group": "971"}, - "gearbox": { - "code": "BVEL", - "label": "BOITE A VARIATEUR ELECTRIQUE", - "group": "427", - }, - "version": {"code": "INT MB 10R"}, - "energy": {"code": "ELEC", "label": "ELECTRIQUE", "group": "019"}, - "registrationNumber": REDACTED, - "vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ", - "assets": [ - { - "assetType": "PICTURE", - "renditions": [ - { - "resolutionType": "ONE_MYRENAULT_LARGE", - "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE", - }, - { - "resolutionType": "ONE_MYRENAULT_SMALL", - "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2", - }, - ], - }, - { - "assetType": "PDF", - "assetRole": "GUIDE", - "title": "PDF Guide", - "description": "", - "renditions": [ - { - "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf" - } - ], - }, - { - "assetType": "URL", - "assetRole": "GUIDE", - "title": "e-guide", - "description": "", - "renditions": [{"url": "http://gb.e-guide.renault.com/eng/Zoe"}], - }, - { - "assetType": "VIDEO", - "assetRole": "CAR", - "title": "10 Fundamentals about getting the best out of your electric vehicle", - "description": "", - "renditions": [{"url": "39r6QEKcOM4"}], - }, - { - "assetType": "VIDEO", - "assetRole": "CAR", - "title": "Automatic Climate Control", - "description": "", - "renditions": [{"url": "Va2FnZFo_GE"}], - }, - { - "assetType": "URL", - "assetRole": "CAR", - "title": "More videos", - "description": "", - "renditions": [{"url": "https://www.youtube.com/watch?v=wfpCMkK1rKI"}], - }, - { - "assetType": "VIDEO", - "assetRole": "CAR", - "title": "Charging the battery", - "description": "", - "renditions": [{"url": "RaEad8DjUJs"}], - }, - { - "assetType": "VIDEO", - "assetRole": "CAR", - "title": "Charging the battery at a station with a flap", - "description": "", - "renditions": [{"url": "zJfd7fJWtr0"}], - }, - ], - "yearsOfMaintenance": 12, - "connectivityTechnology": "RLINK1", - "easyConnectStore": False, - "electrical": True, - "rlinkStore": False, - "deliveryDate": "2017-08-11", - "retrievedFromDhs": False, - "engineEnergyType": "ELEC", - "radioCode": REDACTED, -} - -VEHICLE_DATA = { - "battery": { - "batteryAutonomy": 141, - "batteryAvailableEnergy": 31, - "batteryCapacity": 0, - "batteryLevel": 60, - "batteryTemperature": 20, - "chargingInstantaneousPower": 27, - "chargingRemainingTime": 145, - "chargingStatus": 1.0, - "plugStatus": 1, - "timestamp": "2020-01-12T21:40:16Z", - }, - "charge_mode": { - "chargeMode": "always", - }, - "cockpit": { - "totalMileage": 49114.27, - }, - "hvac_status": { - "externalTemperature": 8.0, - "hvacStatus": "off", - }, - "res_state": {}, -} - @pytest.mark.usefixtures("fixtures_with_data") @pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, hass_client: ClientSessionGenerator + hass: HomeAssistant, + config_entry: ConfigEntry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "data": { - "kamereon_account_id": REDACTED, - "locale": "fr_FR", - "password": REDACTED, - "username": REDACTED, - }, - "title": "Mock Title", - }, - "vehicles": [{"details": VEHICLE_DETAILS, "data": VEHICLE_DATA}], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) @pytest.mark.usefixtures("fixtures_with_data") @@ -193,6 +42,7 @@ async def test_device_diagnostics( config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -203,6 +53,7 @@ async def test_device_diagnostics( ) assert device is not None - assert await get_diagnostics_for_device( - hass, hass_client, config_entry, device - ) == {"details": VEHICLE_DETAILS, "data": VEHICLE_DATA} + assert ( + await get_diagnostics_for_device(hass, hass_client, config_entry, device) + == snapshot + ) diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index e6c55f99810..90963fd3521 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -1,27 +1,31 @@ """Tests for Renault setup process.""" -from collections.abc import Generator from typing import Any from unittest.mock import Mock, patch import aiohttp import pytest from renault_api.gigya.exceptions import GigyaException, InvalidCredentialsException +from typing_extensions import Generator from homeassistant.components.renault.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.renault.PLATFORMS", []): yield @pytest.fixture(autouse=True, name="vehicle_type", params=["zoe_40"]) -def override_vehicle_type(request) -> str: +def override_vehicle_type(request: pytest.FixtureRequest) -> str: """Parametrize vehicle type.""" return request.param @@ -108,3 +112,48 @@ async def test_setup_entry_missing_vehicle_details( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_registry_cleanup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: ConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test being able to remove a disconnected device.""" + assert await async_setup_component(hass, "config", {}) + entry_id = config_entry.entry_id + live_id = "VF1AAAAA555777999" + dead_id = "VF1AAAAA555777888" + + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 0 + device_registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, dead_id)}, + manufacturer="Renault", + model="Zoe", + name="REGISTRATION-NUMBER", + sw_version="X101VE", + ) + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 1 + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + + # Try to remove "VF1AAAAA555777999" - fails as it is live + device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) + client = await hass_ws_client(hass) + response = await client.remove_device(device.id, entry_id) + assert not response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + assert device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) is not None + + # Try to remove "VF1AAAAA555777888" - succeeds as it is dead + device = device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) + response = await client.remove_device(device.id, entry_id) + assert response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 1 + assert device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) is None diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 5dcd798def2..0577966d514 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -1,11 +1,11 @@ """Tests for Renault selects.""" -from collections.abc import Generator from unittest.mock import patch import pytest from renault_api.kamereon import schemas from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.components.select import ( ATTR_OPTION, @@ -26,7 +26,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.renault.PLATFORMS", [Platform.SELECT]): yield diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index bd94aa8d8e1..7e8e4f24c77 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,10 +1,10 @@ """Tests for Renault sensors.""" -from collections.abc import Generator from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -18,7 +18,7 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.renault.PLATFORMS", [Platform.SENSOR]): yield diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index a1715a479f2..d30626e4117 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -1,6 +1,5 @@ """Tests for Renault sensors.""" -from collections.abc import Generator from datetime import datetime from unittest.mock import patch @@ -8,6 +7,7 @@ import pytest from renault_api.exceptions import RenaultException from renault_api.kamereon import schemas from renault_api.kamereon.models import ChargeSchedule +from typing_extensions import Generator from homeassistant.components.renault.const import DOMAIN from homeassistant.components.renault.services import ( @@ -39,14 +39,14 @@ pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicle @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.renault.PLATFORMS", []): yield @pytest.fixture(autouse=True, name="vehicle_type", params=["zoe_40"]) -def override_vehicle_type(request) -> str: +def override_vehicle_type(request: pytest.FixtureRequest) -> str: """Parametrize vehicle type.""" return request.param @@ -253,7 +253,7 @@ async def test_service_invalid_device_id( async def test_service_invalid_device_id2( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, device_registry: dr.DeviceRegistry, config_entry: ConfigEntry ) -> None: """Test that service fails with ValueError if device_id not found in vehicles.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -261,7 +261,6 @@ async def test_service_invalid_device_id2( extra_vehicle = MOCK_VEHICLES["captur_phev"]["expected_device"] - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers=extra_vehicle[ATTR_IDENTIFIERS], diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 5fd52b97b6b..3541aa1f856 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -1,9 +1,9 @@ """Setup the Reolink tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -29,6 +29,7 @@ TEST_MAC = "aa:bb:cc:dd:ee:ff" TEST_MAC2 = "ff:ee:dd:cc:bb:aa" DHCP_FORMATTED_MAC = "aabbccddeeff" TEST_UID = "ABC1234567D89EFG" +TEST_UID_CAM = "DEF7654321D89GHT" TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" @@ -38,7 +39,7 @@ TEST_CAM_MODEL = "RLC-123" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.reolink.async_setup_entry", return_value=True @@ -47,9 +48,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def reolink_connect_class( - mock_get_source_ip: None, -) -> Generator[MagicMock, None, None]: +def reolink_connect_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" with ( patch( @@ -86,7 +85,12 @@ def reolink_connect_class( 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_hardware_version.return_value = "IPC_00001" host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" + host_mock.camera_uid.return_value = TEST_UID_CAM + host_mock.channel_for_uid.return_value = 0 + host_mock.get_encoding.return_value = "h264" + host_mock.firmware_update_available.return_value = False host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 @@ -106,13 +110,13 @@ def reolink_connect_class( @pytest.fixture def reolink_connect( reolink_connect_class: MagicMock, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock reolink connection.""" return reolink_connect_class.return_value @pytest.fixture -def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: +def reolink_platforms() -> Generator[None]: """Mock reolink entry setup.""" with patch("homeassistant.components.reolink.PLATFORMS", return_value=[]): yield diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 9f70673695c..00363023d14 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -5,7 +5,9 @@ 'HTTPS': True, 'IPC cams': dict({ '0': dict({ + 'encoding main': 'h264', 'firmware version': 'v1.1.0.0.0.0000', + 'hardware version': 'IPC_00001', 'model': 'RLC-123', }), }), @@ -38,7 +40,113 @@ 'channels': list([ 0, ]), + 'cmd list': dict({ + 'GetAiAlarm': dict({ + '0': 5, + 'null': 5, + }), + 'GetAiCfg': dict({ + '0': 4, + 'null': 4, + }), + 'GetAudioAlarm': dict({ + '0': 1, + 'null': 1, + }), + 'GetAudioCfg': dict({ + '0': 2, + 'null': 2, + }), + 'GetAutoFocus': dict({ + '0': 1, + 'null': 1, + }), + 'GetAutoReply': dict({ + '0': 2, + 'null': 2, + }), + 'GetBatteryInfo': dict({ + '0': 1, + 'null': 1, + }), + 'GetBuzzerAlarmV20': dict({ + '0': 1, + 'null': 2, + }), + 'GetChannelstatus': dict({ + '0': 1, + 'null': 1, + }), + 'GetEmail': dict({ + '0': 1, + 'null': 2, + }), + 'GetEnc': dict({ + '0': 1, + 'null': 1, + }), + 'GetFtp': dict({ + '0': 1, + 'null': 2, + }), + 'GetIrLights': dict({ + '0': 1, + 'null': 1, + }), + 'GetIsp': dict({ + '0': 1, + 'null': 1, + }), + 'GetManualRec': dict({ + '0': 1, + 'null': 1, + }), + 'GetMdAlarm': dict({ + '0': 1, + 'null': 1, + }), + 'GetPirInfo': dict({ + '0': 1, + 'null': 1, + }), + 'GetPowerLed': dict({ + '0': 2, + 'null': 2, + }), + 'GetPtzCurPos': dict({ + '0': 1, + 'null': 1, + }), + 'GetPtzGuard': dict({ + '0': 2, + 'null': 2, + }), + 'GetPtzTraceSection': dict({ + '0': 2, + 'null': 2, + }), + 'GetPush': dict({ + '0': 1, + 'null': 2, + }), + 'GetRec': dict({ + '0': 1, + 'null': 2, + }), + 'GetWhiteLed': dict({ + '0': 3, + 'null': 3, + }), + 'GetZoomFocus': dict({ + '0': 2, + 'null': 2, + }), + }), 'event connection': 'Fast polling', + 'firmware ch list': list([ + 0, + None, + ]), 'firmware version': 'v1.0.0.0.0.0000', 'hardware version': 'IPC_00000', 'model': 'RLN8-410', diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 4ec02244c91..466836e52ef 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from reolink_aio.exceptions import ReolinkError +from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.components.reolink import FIRMWARE_UPDATE_INTERVAL, const from homeassistant.config import async_process_ha_core_config @@ -20,7 +20,14 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME +from .conftest import ( + TEST_CAM_MODEL, + TEST_HOST_MODEL, + TEST_MAC, + TEST_NVR_NAME, + TEST_UID, + TEST_UID_CAM, +) from tests.common import MockConfigEntry, async_fire_time_changed @@ -50,6 +57,11 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") AsyncMock(side_effect=ReolinkError("Test error")), ConfigEntryState.SETUP_RETRY, ), + ( + "get_states", + AsyncMock(side_effect=CredentialsInvalidError("Test error")), + ConfigEntryState.SETUP_ERROR, + ), ( "supported", Mock(return_value=False), @@ -173,44 +185,149 @@ async def test_cleanup_disconnected_cams( assert sorted(device_models) == sorted(expected_models) -async def test_cleanup_deprecated_entities( +@pytest.mark.parametrize( + ( + "original_id", + "new_id", + "original_dev_id", + "new_dev_id", + "domain", + "support_uid", + "support_ch_uid", + ), + [ + ( + TEST_MAC, + f"{TEST_MAC}_firmware", + f"{TEST_MAC}", + f"{TEST_MAC}", + Platform.UPDATE, + False, + False, + ), + ( + TEST_MAC, + f"{TEST_UID}_firmware", + f"{TEST_MAC}", + f"{TEST_UID}", + Platform.UPDATE, + True, + False, + ), + ( + f"{TEST_MAC}_0_record_audio", + f"{TEST_UID}_0_record_audio", + f"{TEST_MAC}_ch0", + f"{TEST_UID}_ch0", + Platform.SWITCH, + True, + False, + ), + ( + f"{TEST_MAC}_0_record_audio", + f"{TEST_MAC}_{TEST_UID_CAM}_record_audio", + f"{TEST_MAC}_ch0", + f"{TEST_MAC}_{TEST_UID_CAM}", + Platform.SWITCH, + False, + True, + ), + ( + f"{TEST_MAC}_0_record_audio", + f"{TEST_UID}_{TEST_UID_CAM}_record_audio", + f"{TEST_MAC}_ch0", + f"{TEST_UID}_{TEST_UID_CAM}", + Platform.SWITCH, + True, + True, + ), + ( + f"{TEST_UID}_0_record_audio", + f"{TEST_UID}_{TEST_UID_CAM}_record_audio", + f"{TEST_UID}_ch0", + f"{TEST_UID}_{TEST_UID_CAM}", + Platform.SWITCH, + True, + True, + ), + ], +) +async def test_migrate_entity_ids( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + original_id: str, + new_id: str, + original_dev_id: str, + new_dev_id: str, + domain: Platform, + support_uid: bool, + support_ch_uid: bool, ) -> None: - """Test deprecated ir_lights light entity is cleaned.""" - reolink_connect.channels = [0] - ir_id = f"{TEST_MAC}_0_ir_lights" + """Test entity ids that need to be migrated.""" - 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, + def mock_supported(ch, capability): + if capability == "UID" and ch is None: + return support_uid + if capability == "UID": + return support_ch_uid + return True + + reolink_connect.channels = [0] + reolink_connect.supported = mock_supported + + dev_entry = device_registry.async_get_or_create( + identifiers={(const.DOMAIN, original_dev_id)}, + config_entry_id=config_entry.entry_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 + entity_registry.async_get_or_create( + domain=domain, + platform=const.DOMAIN, + unique_id=original_id, + config_entry=config_entry, + suggested_object_id=original_id, + disabled_by=None, + device_id=dev_entry.id, ) - # setup CH 0 and NVR switch entities/device - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id) + assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None + + assert device_registry.async_get_device( + identifiers={(const.DOMAIN, original_dev_id)} + ) + if new_dev_id != original_dev_id: + assert ( + device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)}) + is None + ) + + # setup CH 0 and host entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [domain]): 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 + entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id) is None ) - assert entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id) + assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) + + if new_dev_id != original_dev_id: + assert ( + device_registry.async_get_device( + identifiers={(const.DOMAIN, original_dev_id)} + ) + is None + ) + assert device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)}) async def test_no_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: """Test no repairs issue is raised when http local url is used.""" await async_process_ha_core_config( @@ -220,7 +337,6 @@ async def test_no_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues assert (const.DOMAIN, "webhook_url") not in issue_registry.issues assert (const.DOMAIN, "enable_port") not in issue_registry.issues @@ -229,7 +345,7 @@ async def test_no_repair_issue( async def test_https_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: """Test repairs issue is raised when https local url is used.""" await async_process_ha_core_config( @@ -248,12 +364,11 @@ async def test_https_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") in issue_registry.issues async def test_ssl_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: """Test repairs issue is raised when global ssl certificate is used.""" assert await async_setup_component(hass, "webhook", {}) @@ -275,7 +390,6 @@ async def test_ssl_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) assert (const.DOMAIN, "ssl") in issue_registry.issues @@ -285,6 +399,7 @@ async def test_port_repair_issue( config_entry: MockConfigEntry, reolink_connect: MagicMock, protocol: str, + issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" reolink_connect.set_net_port = AsyncMock(side_effect=ReolinkError("Test error")) @@ -295,12 +410,11 @@ async def test_port_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) assert (const.DOMAIN, "enable_port") in issue_registry.issues async def test_webhook_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" with ( @@ -315,7 +429,6 @@ async def test_webhook_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) assert (const.DOMAIN, "webhook_url") in issue_registry.issues @@ -323,11 +436,11 @@ async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, + issue_registry: ir.IssueRegistry, ) -> None: """Test firmware issue is raised when too old firmware is used.""" reolink_connect.sw_version_update_required = True assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - issue_registry = ir.async_get(hass) - assert (const.DOMAIN, "firmware_update") in issue_registry.issues + assert (const.DOMAIN, "firmware_update_host") in issue_registry.issues diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 1eb45945eee..0d86106e8e5 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -51,11 +51,14 @@ TEST_DAY2 = 15 TEST_HOUR = 13 TEST_MINUTE = 12 TEST_FILE_NAME = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00" +TEST_FILE_NAME_MP4 = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00.mp4" TEST_STREAM = "main" TEST_CHANNEL = "0" TEST_MIME_TYPE = "application/x-mpegURL" -TEST_URL = "http:test_url" +TEST_MIME_TYPE_MP4 = "video/mp4" +TEST_URL = "http:test_url&user=admin&password=test" +TEST_URL2 = "http:test_url&token=test" @pytest.fixture(autouse=True) @@ -85,18 +88,35 @@ async def test_resolve( """Test resolving Reolink media items.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - - reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) caplog.set_level(logging.DEBUG) file_id = ( f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" ) + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) play_media = await async_resolve_media( hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None ) + assert play_media.mime_type == TEST_MIME_TYPE + file_id = f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME_MP4}" + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL2) + + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None + ) + assert play_media.mime_type == TEST_MIME_TYPE_MP4 + + file_id = ( + f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + ) + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + reolink_connect.is_nvr = False + + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/{file_id}", None + ) assert play_media.mime_type == TEST_MIME_TYPE @@ -136,11 +156,15 @@ async def test_browsing( browse_resolution_id = f"RESs|{entry_id}|{TEST_CHANNEL}" browse_res_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|sub" browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" + browse_res_AT_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_sub" + browse_res_AT_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_main" assert browse.domain == DOMAIN assert browse.title == TEST_NVR_NAME assert browse.identifier == browse_resolution_id assert browse.children[0].identifier == browse_res_sub_id assert browse.children[1].identifier == browse_res_main_id + assert browse.children[2].identifier == browse_res_AT_sub_id + assert browse.children[3].identifier == browse_res_AT_main_id # browse camera recording days mock_status = MagicMock() @@ -149,6 +173,22 @@ async def test_browsing( mock_status.days = (TEST_DAY, TEST_DAY2) reolink_connect.request_vod_files.return_value = ([mock_status], []) + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} Low res." + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_sub_id}" + ) + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} Autotrack low res." + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_main_id}" + ) + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} Autotrack high res." + browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" ) @@ -205,6 +245,7 @@ async def test_browsing_unsupported_encoding( reolink_connect.request_vod_files.return_value = ([mock_status], []) reolink_connect.time.return_value = None reolink_connect.get_encoding.return_value = "h265" + reolink_connect.supported.return_value = False browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index 75088f6c370..edb6e509841 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -14,14 +14,7 @@ from homeassistant.components.repairs.issue_handler import ( ) from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, - async_ignore_issue, - create_issue, - delete_issue, -) +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import mock_platform @@ -67,7 +60,7 @@ async def test_create_update_issue( ] for issue in issues: - async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -98,7 +91,7 @@ async def test_create_update_issue( } # Update an issue - async_create_issue( + ir.async_create_issue( hass, issues[0]["domain"], issues[0]["issue_id"], @@ -147,7 +140,7 @@ async def test_create_issue_invalid_version( } with pytest.raises(AwesomeVersionStrategyException): - async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -196,7 +189,7 @@ async def test_ignore_issue( ] for issue in issues: - async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -228,7 +221,7 @@ async def test_ignore_issue( # Ignore a non-existing issue with pytest.raises(KeyError): - async_ignore_issue(hass, issues[0]["domain"], "no_such_issue", True) + ir.async_ignore_issue(hass, issues[0]["domain"], "no_such_issue", True) await client.send_json({"id": 3, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -248,7 +241,7 @@ async def test_ignore_issue( } # Ignore an existing issue - async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], True) + ir.async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], True) await client.send_json({"id": 4, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -268,7 +261,7 @@ async def test_ignore_issue( } # Ignore the same issue again - async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], True) + ir.async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], True) await client.send_json({"id": 5, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -288,7 +281,7 @@ async def test_ignore_issue( } # Update an ignored issue - async_create_issue( + ir.async_create_issue( hass, issues[0]["domain"], issues[0]["issue_id"], @@ -315,7 +308,7 @@ async def test_ignore_issue( ) # Unignore the same issue - async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], False) + ir.async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], False) await client.send_json({"id": 7, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -362,7 +355,7 @@ async def test_delete_issue( ] for issue in issues: - async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -393,7 +386,7 @@ async def test_delete_issue( } # Delete a non-existing issue - async_delete_issue(hass, issues[0]["domain"], "no_such_issue") + ir.async_delete_issue(hass, issues[0]["domain"], "no_such_issue") await client.send_json({"id": 2, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -413,7 +406,7 @@ async def test_delete_issue( } # Delete an existing issue - async_delete_issue(hass, issues[0]["domain"], issues[0]["issue_id"]) + ir.async_delete_issue(hass, issues[0]["domain"], issues[0]["issue_id"]) await client.send_json({"id": 3, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -422,7 +415,7 @@ async def test_delete_issue( assert msg["result"] == {"issues": []} # Delete the same issue again - async_delete_issue(hass, issues[0]["domain"], issues[0]["issue_id"]) + ir.async_delete_issue(hass, issues[0]["domain"], issues[0]["issue_id"]) await client.send_json({"id": 4, "type": "repairs/list_issues"}) msg = await client.receive_json() @@ -434,7 +427,7 @@ async def test_delete_issue( freezer.move_to("2022-07-19 08:53:05") for issue in issues: - async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -508,7 +501,7 @@ async def test_sync_methods( assert msg["result"] == {"issues": []} def _create_issue() -> None: - create_issue( + ir.create_issue( hass, "fake_integration", "sync_issue", @@ -516,7 +509,7 @@ async def test_sync_methods( is_fixable=True, is_persistent=False, learn_more_url="https://theuselessweb.com", - severity=IssueSeverity.ERROR, + severity=ir.IssueSeverity.ERROR, translation_key="abc_123", translation_placeholders={"abc": "123"}, ) @@ -546,7 +539,7 @@ async def test_sync_methods( } await hass.async_add_executor_job( - delete_issue, hass, "fake_integration", "sync_issue" + ir.delete_issue, hass, "fake_integration", "sync_issue" ) await client.send_json({"id": 3, "type": "repairs/list_issues"}) msg = await client.receive_json() diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 846b25ae8c2..60d0364b985 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -432,7 +432,9 @@ async def test_step_unauth( @pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( - hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can list issues.""" @@ -581,7 +583,9 @@ async def test_fix_issue_aborted( @pytest.mark.freeze_time("2022-07-19 07:53:05") -async def test_get_issue_data(hass: HomeAssistant, hass_ws_client) -> None: +async def test_get_issue_data( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Test we can get issue data.""" assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 08e385b50c8..65ec6bf5c05 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -362,6 +362,77 @@ async def test_setup_get_on(hass: HomeAssistant) -> None: assert state.state == STATE_ON +@respx.mock +async def test_setup_get_xml(hass: HomeAssistant) -> None: + """Test setup with valid xml configuration.""" + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + headers={"content-type": "text/xml"}, + content="1", + ) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.dog }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_ON + + +@respx.mock +@pytest.mark.parametrize( + ("content"), + [ + (""), + (""), + ], +) +async def test_setup_get_bad_xml( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, content: str +) -> None: + """Test attributes get extracted from a XML result with bad xml.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + headers={"content-type": "text/xml"}, + content=content, + ) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.toplevel.master_value }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 + state = hass.states.get("binary_sensor.foo") + + assert state.state == STATE_OFF + assert "REST xml result could not be parsed" in caplog.text + + @respx.mock async def test_setup_with_exception(hass: HomeAssistant) -> None: """Test setup with exception.""" @@ -465,7 +536,9 @@ async def test_setup_query_params(hass: HomeAssistant) -> None: @respx.mock -async def test_entity_config(hass: HomeAssistant) -> None: +async def test_entity_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity configuration.""" config = { @@ -486,7 +559,6 @@ async def test_entity_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert ( entity_registry.async_get("binary_sensor.rest_binary_sensor").unique_id == "very_unique" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 3de386be214..2e02063b215 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -868,15 +868,25 @@ async def test_update_with_application_xml_convert_json_attrs_with_jsonattr_temp @respx.mock +@pytest.mark.parametrize( + ("content", "error_message"), + [ + ("", "Empty reply"), + ("", "Erroneous JSON"), + ], +) async def test_update_with_xml_convert_bad_xml( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + content: str, + error_message: str, ) -> None: """Test attributes get extracted from a XML result with bad xml.""" respx.get("http://localhost").respond( status_code=HTTPStatus.OK, headers={"content-type": "text/xml"}, - content="", + content=content, ) assert await async_setup_component( hass, @@ -901,7 +911,7 @@ async def test_update_with_xml_convert_bad_xml( assert state.state == STATE_UNKNOWN assert "REST xml result could not be parsed" in caplog.text - assert "Empty reply" in caplog.text + assert error_message in caplog.text @respx.mock @@ -982,7 +992,9 @@ async def test_reload(hass: HomeAssistant) -> None: @respx.mock -async def test_entity_config(hass: HomeAssistant) -> None: +async def test_entity_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity configuration.""" config = { @@ -1006,7 +1018,6 @@ async def test_entity_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get("sensor.rest_sensor").unique_id == "very_unique" state = hass.states.get("sensor.rest_sensor") diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 551994312d4..e0fc36d053e 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -450,7 +450,9 @@ async def test_update_timeout(hass: HomeAssistant) -> None: @respx.mock -async def test_entity_config(hass: HomeAssistant) -> None: +async def test_entity_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity configuration.""" respx.get(RESOURCE) % HTTPStatus.OK @@ -471,7 +473,6 @@ async def test_entity_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get("switch.rest_switch").unique_id == "very_unique" state = hass.states.get("switch.rest_switch") diff --git a/tests/components/rest_command/conftest.py b/tests/components/rest_command/conftest.py index ec1cfb16ee6..68d14844ea7 100644 --- a/tests/components/rest_command/conftest.py +++ b/tests/components/rest_command/conftest.py @@ -11,7 +11,7 @@ from homeassistant.setup import async_setup_component from tests.common import assert_setup_component -ComponentSetup = Callable[[dict[str, Any] | None], Awaitable[None]] +type ComponentSetup = Callable[[dict[str, Any] | None], Awaitable[None]] TEST_URL = "https://example.com/" TEST_CONFIG = { diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 4f88e1b9d34..97ef29dfaca 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -154,7 +154,7 @@ async def test_rest_command_methods( setup_component: ComponentSetup, aioclient_mock: AiohttpClientMocker, method: str, -): +) -> None: """Test various http methods.""" await setup_component() @@ -215,7 +215,7 @@ async def test_rest_command_headers( # provide post request data aioclient_mock.post(TEST_URL, content=b"success") - for test_service in [ + for test_service in ( "no_headers_test", "content_type_test", "headers_test", @@ -223,7 +223,7 @@ async def test_rest_command_headers( "headers_and_content_type_override_test", "headers_template_test", "headers_and_content_type_override_template_test", - ]: + ): await hass.services.async_call(DOMAIN, test_service, {}, blocking=True) await hass.async_block_till_done() diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 8f09c4a2e54..f901e46aea1 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -417,7 +417,9 @@ async def test_keepalive( ) -async def test2_keepalive(hass, monkeypatch, caplog): +async def test_keepalive_2( + hass: HomeAssistant, monkeypatch, caplog: pytest.LogCaptureFixture +) -> None: """Validate very short keepalive values.""" keepalive_value = 30 domain = RFLINK_DOMAIN @@ -443,7 +445,9 @@ async def test2_keepalive(hass, monkeypatch, caplog): ) -async def test3_keepalive(hass, monkeypatch, caplog): +async def test_keepalive_3( + hass: HomeAssistant, monkeypatch, caplog: pytest.LogCaptureFixture +) -> None: """Validate keepalive=0 value.""" domain = RFLINK_DOMAIN config = { @@ -480,7 +484,9 @@ async def test_default_keepalive( assert "TCP Keepalive IDLE timer was provided" not in caplog.text -async def test_unique_id(hass: HomeAssistant, monkeypatch) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, monkeypatch +) -> None: """Validate the device unique_id.""" DOMAIN = "sensor" @@ -503,15 +509,13 @@ async def test_unique_id(hass: HomeAssistant, monkeypatch) -> None: }, } - registry = er.async_get(hass) - # setup mocking rflink module event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) - humidity_entry = registry.async_get("sensor.humidity_device") + humidity_entry = entity_registry.async_get("sensor.humidity_device") assert humidity_entry assert humidity_entry.unique_id == "my_humidity_device_unique_id" - temperature_entry = registry.async_get("sensor.temperature_device") + temperature_entry = entity_registry.async_get("sensor.temperature_device") assert temperature_entry assert temperature_entry.unique_id == "my_temperature_device_unique_id" diff --git a/tests/components/rfxtrx/conftest.py b/tests/components/rfxtrx/conftest.py index 5e0223173f9..88450638d6c 100644 --- a/tests/components/rfxtrx/conftest.py +++ b/tests/components/rfxtrx/conftest.py @@ -10,6 +10,7 @@ from RFXtrx import Connect, RFXtrxTransport from homeassistant.components import rfxtrx from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -37,7 +38,7 @@ def create_rfx_test_cfg( async def setup_rfx_test_cfg( - hass, + hass: HomeAssistant, device="abcd", automatic_add=False, devices: dict[str, dict] | None = None, diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 3e97b4cfc30..b61440c31b6 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -426,7 +426,11 @@ async def test_options_add_duplicate_device(hass: HomeAssistant) -> None: assert result["errors"]["event_code"] == "already_configured_device" -async def test_options_replace_sensor_device(hass: HomeAssistant) -> None: +async def test_options_replace_sensor_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test we can replace a sensor device.""" entry = MockConfigEntry( @@ -486,7 +490,6 @@ async def test_options_replace_sensor_device(hass: HomeAssistant) -> None: ) assert state - device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) old_device = next( @@ -533,8 +536,6 @@ async def test_options_replace_sensor_device(hass: HomeAssistant) -> None: await hass.async_block_till_done() - entity_registry = er.async_get(hass) - entry = entity_registry.async_get( "sensor.thgn122_123_thgn132_thgr122_228_238_268_f0_04_signal_strength" ) @@ -583,7 +584,11 @@ async def test_options_replace_sensor_device(hass: HomeAssistant) -> None: assert not state -async def test_options_replace_control_device(hass: HomeAssistant) -> None: +async def test_options_replace_control_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test we can replace a control device.""" entry = MockConfigEntry( @@ -619,7 +624,6 @@ async def test_options_replace_control_device(hass: HomeAssistant) -> None: state = hass.states.get("switch.ac_1118cdea_2") assert state - device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) old_device = next( @@ -666,8 +670,6 @@ async def test_options_replace_control_device(hass: HomeAssistant) -> None: await hass.async_block_till_done() - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("binary_sensor.ac_118cdea_2") assert entry assert entry.device_id == new_device @@ -686,7 +688,9 @@ async def test_options_replace_control_device(hass: HomeAssistant) -> None: assert not state -async def test_options_add_and_configure_device(hass: HomeAssistant) -> None: +async def test_options_add_and_configure_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we can add a device.""" entry = MockConfigEntry( @@ -757,7 +761,6 @@ async def test_options_add_and_configure_device(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN assert state.attributes.get("friendly_name") == "PT2262 22670e" - device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) assert device_entries[0].id @@ -795,7 +798,9 @@ async def test_options_add_and_configure_device(hass: HomeAssistant) -> None: assert "delay_off" not in entry.data["devices"]["0913000022670e013970"] -async def test_options_configure_rfy_cover_device(hass: HomeAssistant) -> None: +async def test_options_configure_rfy_cover_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we can configure the venetion blind mode of an Rfy cover.""" entry = MockConfigEntry( @@ -842,7 +847,6 @@ async def test_options_configure_rfy_cover_device(hass: HomeAssistant) -> None: entry.data["devices"]["0C1a0000010203010000000000"]["device_id"], list ) - device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) assert device_entries[0].id @@ -896,16 +900,17 @@ def test_get_serial_by_id_no_dir() -> None: def test_get_serial_by_id() -> None: """Test serial by id conversion.""" - p1 = patch("os.path.isdir", MagicMock(return_value=True)) - p2 = patch("os.scandir") def _realpath(path): if path is sentinel.matched_link: return sentinel.path return sentinel.serial_link_path - p3 = patch("os.path.realpath", side_effect=_realpath) - with p1 as is_dir_mock, p2 as scan_mock, p3: + with ( + patch("os.path.isdir", MagicMock(return_value=True)) as is_dir_mock, + patch("os.scandir") as scan_mock, + patch("os.path.realpath", side_effect=_realpath), + ): res = config_flow.get_serial_by_id(sentinel.path) assert res is sentinel.path assert is_dir_mock.call_count == 1 diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py index 629ff897eb7..38f7cccc072 100644 --- a/tests/components/rfxtrx/test_device_trigger.py +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -65,7 +65,7 @@ async def setup_entry(hass, devices): EVENT_LIGHTING_1, [ {"type": "command", "subtype": subtype} - for subtype in [ + for subtype in ( "Off", "On", "Dim", @@ -74,7 +74,7 @@ async def setup_entry(hass, devices): "All/group On", "Chime", "Illegal command", - ] + ) ], ) ], diff --git a/tests/components/rfxtrx/test_event.py b/tests/components/rfxtrx/test_event.py index 035949efe3b..52daeffd10c 100644 --- a/tests/components/rfxtrx/test_event.py +++ b/tests/components/rfxtrx/test_event.py @@ -32,7 +32,7 @@ async def test_control_event( snapshot: SnapshotAssertion, ) -> None: """Test event update updates correct event object.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await setup_rfx_test_cfg( @@ -60,7 +60,7 @@ async def test_status_event( snapshot: SnapshotAssertion, ) -> None: """Test event update updates correct event object.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") await setup_rfx_test_cfg( @@ -104,7 +104,9 @@ async def test_invalid_event_type( assert hass.states.get("event.arc_c1") == state -async def test_ignoring_lighting4(hass: HomeAssistant, rfxtrx) -> None: +async def test_ignoring_lighting4( + hass: HomeAssistant, entity_registry: er.EntityRegistry, rfxtrx +) -> None: """Test with 1 sensor.""" entry = await setup_rfx_test_cfg( hass, @@ -117,10 +119,11 @@ async def test_ignoring_lighting4(hass: HomeAssistant, rfxtrx) -> None: }, ) - registry = er.async_get(hass) entries = [ entry - for entry in registry.entities.get_entries_for_config_entry_id(entry.entry_id) + for entry in entity_registry.entities.get_entries_for_config_entry_id( + entry.entry_id + ) if entry.domain == Platform.EVENT ] assert entries == [] diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py index 43a2a2cdddc..9641aec3edf 100644 --- a/tests/components/rfxtrx/test_init.py +++ b/tests/components/rfxtrx/test_init.py @@ -19,7 +19,9 @@ from tests.typing import WebSocketGenerator SOME_PROTOCOLS = ["ac", "arc"] -async def test_fire_event(hass: HomeAssistant, rfxtrx) -> None: +async def test_fire_event( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, rfxtrx +) -> None: """Test fire event.""" await setup_rfx_test_cfg( hass, @@ -31,8 +33,6 @@ async def test_fire_event(hass: HomeAssistant, rfxtrx) -> None: }, ) - device_registry: dr.DeviceRegistry = dr.async_get(hass) - calls = [] @callback @@ -92,7 +92,9 @@ async def test_send(hass: HomeAssistant, rfxtrx) -> None: async def test_ws_device_remove( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test removing a device through device registry.""" assert await async_setup_component(hass, "config", {}) @@ -105,9 +107,9 @@ async def test_ws_device_remove( }, ) - device_reg = dr.async_get(hass) - - device_entry = device_reg.async_get_device(identifiers={("rfxtrx", *device_id)}) + device_entry = device_registry.async_get_device( + identifiers={("rfxtrx", *device_id)} + ) assert device_entry # Ask to remove existing device @@ -116,7 +118,9 @@ async def test_ws_device_remove( assert response["success"] # Verify device entry is removed - assert device_reg.async_get_device(identifiers={("rfxtrx", *device_id)}) is None + assert ( + device_registry.async_get_device(identifiers={("rfxtrx", *device_id)}) is None + ) # Verify that the config entry has removed the device assert mock_entry.data["devices"] == {} diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index 70c067af887..58e77184f55 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -1,22 +1,24 @@ """Configuration for Ring tests.""" -from collections.abc import Generator -import re -from unittest.mock import AsyncMock, Mock, patch +from itertools import chain +from unittest.mock import AsyncMock, Mock, create_autospec, patch import pytest -import requests_mock +import ring_doorbell +from typing_extensions import Generator from homeassistant.components.ring import DOMAIN from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from .device_mocks import get_active_alerts, get_devices_data, get_mock_devices + +from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.ring.async_setup_entry", return_value=True @@ -36,6 +38,67 @@ def mock_ring_auth(): yield mock_ring_auth.return_value +@pytest.fixture +def mock_ring_devices(): + """Mock Ring devices.""" + + devices = get_mock_devices() + device_list = list(chain.from_iterable(devices.values())) + + def filter_devices(device_api_ai: int, device_family: set | None = None): + return next( + iter( + [ + device + for device in device_list + if device.id == device_api_ai + and (not device_family or device.family in device_family) + ] + ) + ) + + class FakeRingDevices: + """Class fakes the RingDevices class.""" + + all_devices = device_list + video_devices = ( + devices["stickup_cams"] + + devices["doorbots"] + + devices["authorized_doorbots"] + ) + stickup_cams = devices["stickup_cams"] + other = devices["other"] + chimes = devices["chimes"] + + def get_device(self, id): + return filter_devices(id) + + def get_video_device(self, id): + return filter_devices( + id, {"stickup_cams", "doorbots", "authorized_doorbots"} + ) + + def get_stickup_cam(self, id): + return filter_devices(id, {"stickup_cams"}) + + def get_other(self, id): + return filter_devices(id, {"other"}) + + return FakeRingDevices() + + +@pytest.fixture +def mock_ring_client(mock_ring_auth, mock_ring_devices): + """Mock ring client api.""" + mock_client = create_autospec(ring_doorbell.Ring) + mock_client.return_value.devices_data = get_devices_data() + mock_client.return_value.devices.return_value = mock_ring_devices + mock_client.return_value.active_alerts.side_effect = get_active_alerts + + with patch("homeassistant.components.ring.Ring", new=mock_client): + yield mock_client.return_value + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock ConfigEntry.""" @@ -55,91 +118,10 @@ async def mock_added_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_ring_auth: Mock, + mock_ring_client: Mock, ) -> MockConfigEntry: """Mock ConfigEntry that's been added to HA.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert DOMAIN in hass.config_entries.async_domains() return mock_config_entry - - -@pytest.fixture(name="requests_mock") -def requests_mock_fixture(): - """Fixture to provide a requests mocker.""" - with requests_mock.mock() as mock: - # Note all devices have an id of 987652, but a different device_id. - # the device_id is used as our unique_id, but the id is what is sent - # to the APIs, which is why every mock uses that id. - - # Mocks the response for authenticating - mock.post( - "https://oauth.ring.com/oauth/token", - text=load_fixture("oauth.json", "ring"), - ) - # Mocks the response for getting the login session - mock.post( - "https://api.ring.com/clients_api/session", - text=load_fixture("session.json", "ring"), - ) - # Mocks the response for getting all the devices - mock.get( - "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("devices.json", "ring"), - ) - mock.get( - "https://api.ring.com/clients_api/dings/active", - text=load_fixture("ding_active.json", "ring"), - ) - # Mocks the response for getting the history of a device - mock.get( - re.compile( - r"https:\/\/api\.ring\.com\/clients_api\/doorbots\/\d+\/history" - ), - text=load_fixture("doorbot_history.json", "ring"), - ) - # Mocks the response for getting the health of a device - mock.get( - re.compile(r"https:\/\/api\.ring\.com\/clients_api\/doorbots\/\d+\/health"), - text=load_fixture("doorboot_health_attrs.json", "ring"), - ) - # Mocks the response for getting a chimes health - mock.get( - re.compile(r"https:\/\/api\.ring\.com\/clients_api\/chimes\/\d+\/health"), - text=load_fixture("chime_health_attrs.json", "ring"), - ) - mock.get( - re.compile( - r"https:\/\/api\.ring\.com\/clients_api\/dings\/\d+\/share/play" - ), - status_code=200, - json={"url": "http://127.0.0.1/foo"}, - ) - mock.get( - "https://api.ring.com/groups/v1/locations/mock-location-id/groups", - text=load_fixture("groups.json", "ring"), - ) - # Mocks the response for getting the history of the intercom - mock.get( - "https://api.ring.com/clients_api/doorbots/185036587/history", - text=load_fixture("intercom_history.json", "ring"), - ) - # Mocks the response for setting properties in settings (i.e. motion_detection) - mock.patch( - re.compile( - r"https:\/\/api\.ring\.com\/devices\/v1\/devices\/\d+\/settings" - ), - text="ok", - ) - # Mocks the open door command for intercom devices - mock.put( - "https://api.ring.com/commands/v1/devices/185036587/device_rpc", - status_code=200, - text="{}", - ) - # Mocks the response for getting the history of the intercom - mock.get( - "https://api.ring.com/clients_api/doorbots/185036587/history", - text=load_fixture("intercom_history.json", "ring"), - ) - yield mock diff --git a/tests/components/ring/device_mocks.py b/tests/components/ring/device_mocks.py new file mode 100644 index 00000000000..f43370c918d --- /dev/null +++ b/tests/components/ring/device_mocks.py @@ -0,0 +1,179 @@ +"""Module for ring device mocks. + +Creates a MagicMock for all device families, i.e. chimes, doorbells, stickup_cams and other. + +Each device entry in the devices.json will have a MagicMock instead of the RingObject. + +Mocks the api calls on the devices such as history() and health(). +""" + +from copy import deepcopy +from datetime import datetime +from time import time +from unittest.mock import MagicMock + +from ring_doorbell import ( + RingCapability, + RingChime, + RingDoorBell, + RingOther, + RingStickUpCam, +) + +from homeassistant.components.ring.const import DOMAIN +from homeassistant.util import dt as dt_util + +from tests.common import load_json_value_fixture + +DEVICES_FIXTURE = load_json_value_fixture("devices.json", DOMAIN) +DOORBOT_HISTORY = load_json_value_fixture("doorbot_history.json", DOMAIN) +INTERCOM_HISTORY = load_json_value_fixture("intercom_history.json", DOMAIN) +DOORBOT_HEALTH = load_json_value_fixture("doorbot_health_attrs.json", DOMAIN) +CHIME_HEALTH = load_json_value_fixture("chime_health_attrs.json", DOMAIN) +DEVICE_ALERTS = load_json_value_fixture("ding_active.json", DOMAIN) + + +def get_mock_devices(): + """Return list of mock devices keyed by device_type.""" + devices = {} + for device_family, device_class in DEVICE_TYPES.items(): + devices[device_family] = [ + _mocked_ring_device( + device, device_family, device_class, DEVICE_CAPABILITIES[device_class] + ) + for device in DEVICES_FIXTURE[device_family] + ] + return devices + + +def get_devices_data(): + """Return devices raw json used by the diagnostics module.""" + return { + device_type: {obj["id"]: obj for obj in devices} + for device_type, devices in DEVICES_FIXTURE.items() + } + + +def get_active_alerts(): + """Return active alerts set to now.""" + dings_fixture = deepcopy(DEVICE_ALERTS) + for ding in dings_fixture: + ding["now"] = time() + return dings_fixture + + +DEVICE_TYPES = { + "doorbots": RingDoorBell, + "authorized_doorbots": RingDoorBell, + "stickup_cams": RingStickUpCam, + "chimes": RingChime, + "other": RingOther, +} + +DEVICE_CAPABILITIES = { + RingDoorBell: [ + RingCapability.BATTERY, + RingCapability.VOLUME, + RingCapability.MOTION_DETECTION, + RingCapability.VIDEO, + RingCapability.HISTORY, + ], + RingStickUpCam: [ + RingCapability.BATTERY, + RingCapability.VOLUME, + RingCapability.MOTION_DETECTION, + RingCapability.VIDEO, + RingCapability.HISTORY, + RingCapability.SIREN, + RingCapability.LIGHT, + ], + RingChime: [RingCapability.VOLUME], + RingOther: [RingCapability.OPEN, RingCapability.HISTORY], +} + + +def _mocked_ring_device(device_dict, device_family, device_class, capabilities): + """Return a mocked device.""" + mock_device = MagicMock(spec=device_class, name=f"Mocked {device_family!s}") + + def has_capability(capability): + return ( + capability in capabilities + if isinstance(capability, RingCapability) + else RingCapability.from_name(capability) in capabilities + ) + + def update_health_data(fixture): + mock_device.configure_mock( + wifi_signal_category=fixture["device_health"].get("latest_signal_category"), + wifi_signal_strength=fixture["device_health"].get("latest_signal_strength"), + ) + + def update_history_data(fixture): + for entry in fixture: # Mimic the api date parsing + if isinstance(entry["created_at"], str): + dt_at = datetime.strptime(entry["created_at"], "%Y-%m-%dT%H:%M:%S.%f%z") + entry["created_at"] = dt_util.as_utc(dt_at) + mock_device.configure_mock(last_history=fixture) # Set last_history + return fixture + + # Configure the device attributes + mock_device.configure_mock(**device_dict) + + # Configure the Properties on the device + mock_device.configure_mock( + model=device_family, + device_api_id=device_dict["id"], + name=device_dict["description"], + wifi_signal_category=None, + wifi_signal_strength=None, + family=device_family, + ) + + # Configure common methods + mock_device.has_capability.side_effect = has_capability + mock_device.update_health_data.side_effect = lambda: update_health_data( + DOORBOT_HEALTH if device_family != "chimes" else CHIME_HEALTH + ) + # Configure methods based on capability + if has_capability(RingCapability.HISTORY): + mock_device.configure_mock(last_history=[]) + mock_device.history.side_effect = lambda *_, **__: update_history_data( + DOORBOT_HISTORY if device_family != "other" else INTERCOM_HISTORY + ) + + if has_capability(RingCapability.MOTION_DETECTION): + mock_device.configure_mock( + motion_detection=device_dict["settings"].get("motion_detection_enabled"), + ) + + if has_capability(RingCapability.LIGHT): + mock_device.configure_mock(lights=device_dict.get("led_status")) + + if has_capability(RingCapability.VOLUME): + mock_device.configure_mock( + volume=device_dict["settings"].get( + "doorbell_volume", device_dict["settings"].get("volume") + ) + ) + + if has_capability(RingCapability.SIREN): + mock_device.configure_mock( + siren=device_dict["siren_status"].get("seconds_remaining") + ) + + if has_capability(RingCapability.BATTERY): + mock_device.configure_mock( + battery_life=min( + 100, device_dict.get("battery_life", device_dict.get("battery_life2")) + ) + ) + + if device_family == "other": + mock_device.configure_mock( + doorbell_volume=device_dict["settings"].get("doorbell_volume"), + mic_volume=device_dict["settings"].get("mic_volume"), + voice_volume=device_dict["settings"].get("voice_volume"), + ) + + return mock_device diff --git a/tests/components/ring/fixtures/chime_devices.json b/tests/components/ring/fixtures/chime_devices.json deleted file mode 100644 index 5c3e60ec655..00000000000 --- a/tests/components/ring/fixtures/chime_devices.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "authorized_doorbots": [], - "chimes": [ - { - "address": "123 Main St", - "alerts": { "connection": "online" }, - "description": "Downstairs", - "device_id": "abcdef123", - "do_not_disturb": { "seconds_left": 0 }, - "features": { "ringtones_enabled": true }, - "firmware_version": "1.2.3", - "id": 123456, - "kind": "chime", - "latitude": 12.0, - "longitude": -70.12345, - "owned": true, - "owner": { - "email": "foo@bar.org", - "first_name": "Marcelo", - "id": 999999, - "last_name": "Assistant" - }, - "settings": { - "ding_audio_id": null, - "ding_audio_user_id": null, - "motion_audio_id": null, - "motion_audio_user_id": null, - "volume": 2 - }, - "time_zone": "America/New_York" - } - ], - "doorbots": [], - "stickup_cams": [] -} diff --git a/tests/components/ring/fixtures/devices.json b/tests/components/ring/fixtures/devices.json index 8deee7ec413..fc708115500 100644 --- a/tests/components/ring/fixtures/devices.json +++ b/tests/components/ring/fixtures/devices.json @@ -5,7 +5,7 @@ "address": "123 Main St", "alerts": { "connection": "online" }, "description": "Downstairs", - "device_id": "abcdef123", + "device_id": "abcdef123456", "do_not_disturb": { "seconds_left": 0 }, "features": { "ringtones_enabled": true }, "firmware_version": "1.2.3", @@ -36,7 +36,7 @@ "alerts": { "connection": "online" }, "battery_life": 4081, "description": "Front Door", - "device_id": "aacdef123", + "device_id": "aacdef987654", "external_connection": false, "features": { "advanced_motion_enabled": false, @@ -85,7 +85,7 @@ "alerts": { "connection": "online" }, "battery_life": 80, "description": "Front", - "device_id": "aacdef123", + "device_id": "aacdef765432", "external_connection": false, "features": { "advanced_motion_enabled": false, @@ -234,7 +234,7 @@ "alerts": { "connection": "online" }, "battery_life": 80, "description": "Internal", - "device_id": "aacdef124", + "device_id": "aacdef345678", "external_connection": false, "features": { "advanced_motion_enabled": false, @@ -395,7 +395,7 @@ "last_name": "", "email": "" }, - "device_id": "124ba1b3fe1a", + "device_id": "abcdef185036587", "time_zone": "Europe/Rome", "firmware_version": "Up to Date", "owned": true, diff --git a/tests/components/ring/fixtures/devices_updated.json b/tests/components/ring/fixtures/devices_updated.json deleted file mode 100644 index 01ea2ca25f5..00000000000 --- a/tests/components/ring/fixtures/devices_updated.json +++ /dev/null @@ -1,382 +0,0 @@ -{ - "authorized_doorbots": [], - "chimes": [ - { - "address": "123 Main St", - "alerts": { "connection": "online" }, - "description": "Downstairs", - "device_id": "abcdef123", - "do_not_disturb": { "seconds_left": 0 }, - "features": { "ringtones_enabled": true }, - "firmware_version": "1.2.3", - "id": 123456, - "kind": "chime", - "latitude": 12.0, - "longitude": -70.12345, - "owned": true, - "owner": { - "email": "foo@bar.org", - "first_name": "Marcelo", - "id": 999999, - "last_name": "Assistant" - }, - "settings": { - "ding_audio_id": null, - "ding_audio_user_id": null, - "motion_audio_id": null, - "motion_audio_user_id": null, - "volume": 2 - }, - "time_zone": "America/New_York" - } - ], - "doorbots": [ - { - "address": "123 Main St", - "alerts": { "connection": "online" }, - "battery_life": 4081, - "description": "Front Door", - "device_id": "aacdef123", - "external_connection": false, - "features": { - "advanced_motion_enabled": false, - "motion_message_enabled": false, - "motions_enabled": true, - "people_only_enabled": false, - "shadow_correction_enabled": false, - "show_recordings": true - }, - "firmware_version": "1.4.26", - "id": 987654, - "kind": "lpd_v1", - "latitude": 12.0, - "longitude": -70.12345, - "motion_snooze": null, - "owned": true, - "owner": { - "email": "foo@bar.org", - "first_name": "Home", - "id": 999999, - "last_name": "Assistant" - }, - "settings": { - "chime_settings": { - "duration": 3, - "enable": true, - "type": 0 - }, - "doorbell_volume": 1, - "enable_vod": true, - "live_view_preset_profile": "highest", - "live_view_presets": ["low", "middle", "high", "highest"], - "motion_detection_enabled": true, - "motion_announcement": false, - "motion_snooze_preset_profile": "low", - "motion_snooze_presets": ["null", "low", "medium", "high"] - }, - "subscribed": true, - "subscribed_motions": true, - "time_zone": "America/New_York" - } - ], - "stickup_cams": [ - { - "address": "123 Main St", - "alerts": { "connection": "online" }, - "battery_life": 80, - "description": "Front", - "device_id": "aacdef123", - "external_connection": false, - "features": { - "advanced_motion_enabled": false, - "motion_message_enabled": false, - "motions_enabled": true, - "night_vision_enabled": false, - "people_only_enabled": false, - "shadow_correction_enabled": false, - "show_recordings": true - }, - "firmware_version": "1.9.3", - "id": 765432, - "kind": "hp_cam_v1", - "latitude": 12.0, - "led_status": "on", - "location_id": null, - "longitude": -70.12345, - "motion_snooze": { "scheduled": true }, - "night_mode_status": "false", - "owned": true, - "owner": { - "email": "foo@bar.org", - "first_name": "Foo", - "id": 999999, - "last_name": "Bar" - }, - "ring_cam_light_installed": "false", - "ring_id": null, - "settings": { - "chime_settings": { - "duration": 10, - "enable": true, - "type": 0 - }, - "doorbell_volume": 11, - "enable_vod": true, - "floodlight_settings": { - "duration": 30, - "priority": 0 - }, - "light_schedule_settings": { - "end_hour": 0, - "end_minute": 0, - "start_hour": 0, - "start_minute": 0 - }, - "live_view_preset_profile": "highest", - "live_view_presets": ["low", "middle", "high", "highest"], - "motion_detection_enabled": true, - "motion_announcement": false, - "motion_snooze_preset_profile": "low", - "motion_snooze_presets": ["none", "low", "medium", "high"], - "motion_zones": { - "active_motion_filter": 1, - "advanced_object_settings": { - "human_detection_confidence": { - "day": 0.7, - "night": 0.7 - }, - "motion_zone_overlap": { - "day": 0.1, - "night": 0.2 - }, - "object_size_maximum": { - "day": 0.8, - "night": 0.8 - }, - "object_size_minimum": { - "day": 0.03, - "night": 0.05 - }, - "object_time_overlap": { - "day": 0.1, - "night": 0.6 - } - }, - "enable_audio": false, - "pir_settings": { - "sensitivity1": 1, - "sensitivity2": 1, - "sensitivity3": 1, - "zone_mask": 6 - }, - "sensitivity": 5, - "zone1": { - "name": "Zone 1", - "state": 2, - "vertex1": { "x": 0.0, "y": 0.0 }, - "vertex2": { "x": 0.0, "y": 0.0 }, - "vertex3": { "x": 0.0, "y": 0.0 }, - "vertex4": { "x": 0.0, "y": 0.0 }, - "vertex5": { "x": 0.0, "y": 0.0 }, - "vertex6": { "x": 0.0, "y": 0.0 }, - "vertex7": { "x": 0.0, "y": 0.0 }, - "vertex8": { "x": 0.0, "y": 0.0 } - }, - "zone2": { - "name": "Zone 2", - "state": 2, - "vertex1": { "x": 0.0, "y": 0.0 }, - "vertex2": { "x": 0.0, "y": 0.0 }, - "vertex3": { "x": 0.0, "y": 0.0 }, - "vertex4": { "x": 0.0, "y": 0.0 }, - "vertex5": { "x": 0.0, "y": 0.0 }, - "vertex6": { "x": 0.0, "y": 0.0 }, - "vertex7": { "x": 0.0, "y": 0.0 }, - "vertex8": { "x": 0.0, "y": 0.0 } - }, - "zone3": { - "name": "Zone 3", - "state": 2, - "vertex1": { "x": 0.0, "y": 0.0 }, - "vertex2": { "x": 0.0, "y": 0.0 }, - "vertex3": { "x": 0.0, "y": 0.0 }, - "vertex4": { "x": 0.0, "y": 0.0 }, - "vertex5": { "x": 0.0, "y": 0.0 }, - "vertex6": { "x": 0.0, "y": 0.0 }, - "vertex7": { "x": 0.0, "y": 0.0 }, - "vertex8": { "x": 0.0, "y": 0.0 } - } - }, - "pir_motion_zones": [0, 1, 1], - "pir_settings": { - "sensitivity1": 1, - "sensitivity2": 1, - "sensitivity3": 1, - "zone_mask": 6 - }, - "stream_setting": 0, - "video_settings": { - "ae_level": 0, - "birton": null, - "brightness": 0, - "contrast": 64, - "saturation": 80 - } - }, - "siren_status": { "seconds_remaining": 30 }, - "stolen": false, - "subscribed": true, - "subscribed_motions": true, - "time_zone": "America/New_York" - }, - { - "address": "123 Main St", - "alerts": { "connection": "online" }, - "battery_life": 80, - "description": "Internal", - "device_id": "aacdef124", - "external_connection": false, - "features": { - "advanced_motion_enabled": false, - "motion_message_enabled": false, - "motions_enabled": true, - "night_vision_enabled": false, - "people_only_enabled": false, - "shadow_correction_enabled": false, - "show_recordings": true - }, - "firmware_version": "1.9.3", - "id": 345678, - "kind": "hp_cam_v1", - "latitude": 12.0, - "led_status": "off", - "location_id": null, - "longitude": -70.12345, - "motion_snooze": { "scheduled": true }, - "night_mode_status": "false", - "owned": true, - "owner": { - "email": "foo@bar.org", - "first_name": "Foo", - "id": 999999, - "last_name": "Bar" - }, - "ring_cam_light_installed": "false", - "ring_id": null, - "settings": { - "chime_settings": { - "duration": 10, - "enable": true, - "type": 0 - }, - "doorbell_volume": 11, - "enable_vod": true, - "floodlight_settings": { - "duration": 30, - "priority": 0 - }, - "light_schedule_settings": { - "end_hour": 0, - "end_minute": 0, - "start_hour": 0, - "start_minute": 0 - }, - "live_view_preset_profile": "highest", - "live_view_presets": ["low", "middle", "high", "highest"], - "motion_detection_enabled": false, - "motion_announcement": false, - "motion_snooze_preset_profile": "low", - "motion_snooze_presets": ["none", "low", "medium", "high"], - "motion_zones": { - "active_motion_filter": 1, - "advanced_object_settings": { - "human_detection_confidence": { - "day": 0.7, - "night": 0.7 - }, - "motion_zone_overlap": { - "day": 0.1, - "night": 0.2 - }, - "object_size_maximum": { - "day": 0.8, - "night": 0.8 - }, - "object_size_minimum": { - "day": 0.03, - "night": 0.05 - }, - "object_time_overlap": { - "day": 0.1, - "night": 0.6 - } - }, - "enable_audio": false, - "pir_settings": { - "sensitivity1": 1, - "sensitivity2": 1, - "sensitivity3": 1, - "zone_mask": 6 - }, - "sensitivity": 5, - "zone1": { - "name": "Zone 1", - "state": 2, - "vertex1": { "x": 0.0, "y": 0.0 }, - "vertex2": { "x": 0.0, "y": 0.0 }, - "vertex3": { "x": 0.0, "y": 0.0 }, - "vertex4": { "x": 0.0, "y": 0.0 }, - "vertex5": { "x": 0.0, "y": 0.0 }, - "vertex6": { "x": 0.0, "y": 0.0 }, - "vertex7": { "x": 0.0, "y": 0.0 }, - "vertex8": { "x": 0.0, "y": 0.0 } - }, - "zone2": { - "name": "Zone 2", - "state": 2, - "vertex1": { "x": 0.0, "y": 0.0 }, - "vertex2": { "x": 0.0, "y": 0.0 }, - "vertex3": { "x": 0.0, "y": 0.0 }, - "vertex4": { "x": 0.0, "y": 0.0 }, - "vertex5": { "x": 0.0, "y": 0.0 }, - "vertex6": { "x": 0.0, "y": 0.0 }, - "vertex7": { "x": 0.0, "y": 0.0 }, - "vertex8": { "x": 0.0, "y": 0.0 } - }, - "zone3": { - "name": "Zone 3", - "state": 2, - "vertex1": { "x": 0.0, "y": 0.0 }, - "vertex2": { "x": 0.0, "y": 0.0 }, - "vertex3": { "x": 0.0, "y": 0.0 }, - "vertex4": { "x": 0.0, "y": 0.0 }, - "vertex5": { "x": 0.0, "y": 0.0 }, - "vertex6": { "x": 0.0, "y": 0.0 }, - "vertex7": { "x": 0.0, "y": 0.0 }, - "vertex8": { "x": 0.0, "y": 0.0 } - } - }, - "pir_motion_zones": [0, 1, 1], - "pir_settings": { - "sensitivity1": 1, - "sensitivity2": 1, - "sensitivity3": 1, - "zone_mask": 6 - }, - "stream_setting": 0, - "video_settings": { - "ae_level": 0, - "birton": null, - "brightness": 0, - "contrast": 64, - "saturation": 80 - } - }, - "siren_status": { "seconds_remaining": 30 }, - "stolen": false, - "subscribed": true, - "subscribed_motions": true, - "time_zone": "America/New_York" - } - ] -} diff --git a/tests/components/ring/fixtures/ding_active.json b/tests/components/ring/fixtures/ding_active.json index b367369fcff..1d089ab454e 100644 --- a/tests/components/ring/fixtures/ding_active.json +++ b/tests/components/ring/fixtures/ding_active.json @@ -24,5 +24,12 @@ "snapshot_url": "", "state": "ringing", "video_jitter_buffer_ms": 0 + }, + { + "kind": "motion", + "doorbot_id": 987654, + "state": "ringing", + "now": 1490949469.5498993, + "expires_in": 180 } ] diff --git a/tests/components/ring/fixtures/doorboot_health_attrs.json b/tests/components/ring/fixtures/doorbot_health_attrs.json similarity index 100% rename from tests/components/ring/fixtures/doorboot_health_attrs.json rename to tests/components/ring/fixtures/doorbot_health_attrs.json diff --git a/tests/components/ring/fixtures/doorbot_history.json b/tests/components/ring/fixtures/doorbot_history.json index 2f6b44318bb..1c4c97e51c7 100644 --- a/tests/components/ring/fixtures/doorbot_history.json +++ b/tests/components/ring/fixtures/doorbot_history.json @@ -1,4 +1,14 @@ [ + { + "answered": false, + "created_at": "2018-03-05T15:03:40.000Z", + "events": [], + "favorite": false, + "id": 987654321, + "kind": "ding", + "recording": { "status": "ready" }, + "snapshot_url": "" + }, { "answered": false, "created_at": "2017-03-05T15:03:40.000Z", diff --git a/tests/components/ring/fixtures/doorbot_siren_on_response.json b/tests/components/ring/fixtures/doorbot_siren_on_response.json deleted file mode 100644 index 288800ed5fa..00000000000 --- a/tests/components/ring/fixtures/doorbot_siren_on_response.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "started_at": "2019-07-28T16:58:27.593+00:00", - "duration": 30, - "ends_at": "2019-07-28T16:58:57.593+00:00", - "seconds_remaining": 30 -} diff --git a/tests/components/ring/fixtures/groups.json b/tests/components/ring/fixtures/groups.json deleted file mode 100644 index 399aaac1641..00000000000 --- a/tests/components/ring/fixtures/groups.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "device_groups": [ - { - "device_group_id": "mock-group-id", - "location_id": "mock-location-id", - "name": "Landscape", - "devices": [ - { - "doorbot_id": 12345678, - "location_id": "mock-location-id", - "type": "beams_ct200_transformer", - "mac_address": null, - "hardware_id": "1234567890", - "name": "Mock Transformer", - "deleted_at": null - } - ], - "created_at": "2020-11-03T22:07:05Z", - "updated_at": "2020-11-19T03:52:59Z", - "deleted_at": null, - "external_id": "12345678-1234-5678-90ab-1234567890ab" - } - ] -} diff --git a/tests/components/ring/fixtures/oauth.json b/tests/components/ring/fixtures/oauth.json deleted file mode 100644 index 902e40a4110..00000000000 --- a/tests/components/ring/fixtures/oauth.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "access_token": "eyJ0eWfvEQwqfJNKyQ9999", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "67695a26bdefc1ac8999", - "scope": "client", - "created_at": 1529099870 -} diff --git a/tests/components/ring/fixtures/session.json b/tests/components/ring/fixtures/session.json deleted file mode 100644 index 62c8efa1d8f..00000000000 --- a/tests/components/ring/fixtures/session.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "profile": { - "authentication_token": "12345678910", - "email": "foo@bar.org", - "features": { - "chime_dnd_enabled": false, - "chime_pro_enabled": true, - "delete_all_enabled": true, - "delete_all_settings_enabled": false, - "device_health_alerts_enabled": true, - "floodlight_cam_enabled": true, - "live_view_settings_enabled": true, - "lpd_enabled": true, - "lpd_motion_announcement_enabled": false, - "multiple_calls_enabled": true, - "multiple_delete_enabled": true, - "nw_enabled": true, - "nw_larger_area_enabled": false, - "nw_user_activated": false, - "owner_proactive_snoozing_enabled": true, - "power_cable_enabled": false, - "proactive_snoozing_enabled": false, - "reactive_snoozing_enabled": false, - "remote_logging_format_storing": false, - "remote_logging_level": 1, - "ringplus_enabled": true, - "starred_events_enabled": true, - "stickupcam_setup_enabled": true, - "subscriptions_enabled": true, - "ujet_enabled": false, - "video_search_enabled": false, - "vod_enabled": false - }, - "first_name": "Home", - "id": 999999, - "last_name": "Assistant" - } -} diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index ba73de05c9b..16bc6e872c1 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -1,32 +1,14 @@ """The tests for the Ring binary sensor platform.""" -from time import time -from unittest.mock import patch - -import requests_mock - +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .common import setup_platform -async def test_binary_sensor( - hass: HomeAssistant, requests_mock: requests_mock.Mocker -) -> None: +async def test_binary_sensor(hass: HomeAssistant, mock_ring_client) -> None: """Test the Ring binary sensors.""" - with patch( - "ring_doorbell.Ring.active_alerts", - return_value=[ - { - "kind": "motion", - "doorbot_id": 987654, - "state": "ringing", - "now": time(), - "expires_in": 180, - } - ], - ): - await setup_platform(hass, "binary_sensor") + await setup_platform(hass, Platform.BINARY_SENSOR) motion_state = hass.states.get("binary_sensor.front_door_motion") assert motion_state is not None diff --git a/tests/components/ring/test_button.py b/tests/components/ring/test_button.py index 6b2200b2bf3..6fef3295159 100644 --- a/tests/components/ring/test_button.py +++ b/tests/components/ring/test_button.py @@ -1,7 +1,5 @@ """The tests for the Ring button platform.""" -import requests_mock - from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -11,7 +9,7 @@ from .common import setup_platform async def test_entity_registry( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, entity_registry: er.EntityRegistry, ) -> None: """Tests that the devices are registered in the entity registry.""" @@ -22,21 +20,19 @@ async def test_entity_registry( async def test_button_opens_door( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + mock_ring_client, + mock_ring_devices, ) -> None: """Tests the door open button works correctly.""" await setup_platform(hass, Platform.BUTTON) - # Mocks the response for opening door - mock = requests_mock.put( - "https://api.ring.com/commands/v1/devices/185036587/device_rpc", - status_code=200, - text="{}", - ) + mock_intercom = mock_ring_devices.get_device(185036587) + mock_intercom.open_door.assert_not_called() await hass.services.async_call( "button", "press", {"entity_id": "button.ingress_open_door"}, blocking=True ) - await hass.async_block_till_done() - assert mock.call_count == 1 + await hass.async_block_till_done(wait_background_tasks=True) + mock_intercom.open_door.assert_called_once() diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index dde1252d5b8..20a9ed5f0c9 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -1,9 +1,8 @@ """The tests for the Ring switch platform.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import PropertyMock import pytest -import requests_mock import ring_doorbell from homeassistant.config_entries import SOURCE_REAUTH @@ -14,15 +13,14 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -from tests.common import load_fixture - async def test_entity_registry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ring_client, ) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, Platform.CAMERA) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("camera.front") assert entry.unique_id == "765432" @@ -41,7 +39,7 @@ async def test_entity_registry( ) async def test_camera_motion_detection_state_reports_correctly( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, entity_name, expected_state, friendly_name, @@ -55,7 +53,7 @@ async def test_camera_motion_detection_state_reports_correctly( async def test_camera_motion_detection_can_be_turned_on( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client ) -> None: """Tests the siren turns on correctly.""" await setup_platform(hass, Platform.CAMERA) @@ -77,17 +75,15 @@ async def test_camera_motion_detection_can_be_turned_on( async def test_updates_work( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client, mock_ring_devices ) -> None: """Tests the update service works correctly.""" await setup_platform(hass, Platform.CAMERA) state = hass.states.get("camera.internal") assert state.attributes.get("motion_detection") is True - # Changes the return to indicate that the switch is now on. - requests_mock.get( - "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("devices_updated.json", "ring"), - ) + + internal_camera_mock = mock_ring_devices.get_device(345678) + internal_camera_mock.motion_detection = False await hass.services.async_call("ring", "update", {}, blocking=True) @@ -108,7 +104,8 @@ async def test_updates_work( ) async def test_motion_detection_errors_when_turned_on( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, + mock_ring_devices, exception_type, reauth_expected, ) -> None: @@ -118,19 +115,19 @@ async def test_motion_detection_errors_when_turned_on( assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - with patch.object( - ring_doorbell.RingDoorBell, "motion_detection", new_callable=PropertyMock - ) as mock_motion_detection: - mock_motion_detection.side_effect = exception_type - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "camera", - "enable_motion_detection", - {"entity_id": "camera.front"}, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_motion_detection.call_count == 1 + front_camera_mock = mock_ring_devices.get_device(765432) + p = PropertyMock(side_effect=exception_type) + type(front_camera_mock).motion_detection = p + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "camera", + "enable_motion_detection", + {"entity_id": "camera.front"}, + blocking=True, + ) + await hass.async_block_till_done() + p.assert_called_once() assert ( any( flow diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index bedb4604814..2420bb9cc50 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_ring_auth: Mock, + mock_ring_client: Mock, ) -> None: """Test we get the form.""" diff --git a/tests/components/ring/test_diagnostics.py b/tests/components/ring/test_diagnostics.py index 269446c3ad5..7d6eb8a7f76 100644 --- a/tests/components/ring/test_diagnostics.py +++ b/tests/components/ring/test_diagnostics.py @@ -1,6 +1,5 @@ """Test Ring diagnostics.""" -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -14,7 +13,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, - requests_mock: requests_mock.Mocker, + mock_ring_client, snapshot: SnapshotAssertion, ) -> None: """Test Ring diagnostics.""" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 664f8ff1973..d8529e874b9 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -1,68 +1,68 @@ """The tests for the Ring component.""" -from datetime import timedelta -from unittest.mock import patch - +from freezegun.api import FrozenDateTimeFactory import pytest -import requests_mock from ring_doorbell import AuthenticationError, RingError, RingTimeout from homeassistant.components import ring from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.ring import DOMAIN +from homeassistant.components.ring.const import SCAN_INTERVAL from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.issue_registry import IssueRegistry +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import MockConfigEntry, async_fire_time_changed -async def test_setup(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: +async def test_setup(hass: HomeAssistant, mock_ring_client) -> None: """Test the setup.""" await async_setup_component(hass, ring.DOMAIN, {}) - requests_mock.post( - "https://oauth.ring.com/oauth/token", text=load_fixture("oauth.json", "ring") - ) - requests_mock.post( - "https://api.ring.com/clients_api/session", - text=load_fixture("session.json", "ring"), - ) - requests_mock.get( - "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("devices.json", "ring"), - ) - requests_mock.get( - "https://api.ring.com/clients_api/chimes/999999/health", - text=load_fixture("chime_health_attrs.json", "ring"), - ) - requests_mock.get( - "https://api.ring.com/clients_api/doorbots/987652/health", - text=load_fixture("doorboot_health_attrs.json", "ring"), - ) + +async def test_setup_entry( + hass: HomeAssistant, + mock_ring_client, + mock_added_config_entry: MockConfigEntry, +) -> None: + """Test setup entry.""" + assert mock_added_config_entry.state is ConfigEntryState.LOADED + + +async def test_setup_entry_device_update( + hass: HomeAssistant, + mock_ring_client, + mock_ring_devices, + freezer: FrozenDateTimeFactory, + mock_added_config_entry: MockConfigEntry, +) -> None: + """Test devices are updating after setup entry.""" + + front_door_doorbell = mock_ring_devices.get_device(987654) + front_door_doorbell.history.assert_not_called() + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + front_door_doorbell.history.assert_called_once() async def test_auth_failed_on_setup( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, mock_config_entry: MockConfigEntry, ) -> None: """Test auth failure on setup entry.""" mock_config_entry.add_to_hass(hass) - with patch( - "ring_doorbell.Ring.update_data", - side_effect=AuthenticationError, - ): - assert not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_ring_client.update_data.side_effect = AuthenticationError + + assert not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR @pytest.mark.parametrize( @@ -81,87 +81,73 @@ async def test_auth_failed_on_setup( ) async def test_error_on_setup( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, mock_config_entry: MockConfigEntry, - caplog, + caplog: pytest.LogCaptureFixture, error_type, log_msg, ) -> None: - """Test auth failure on setup entry.""" + """Test non-auth errors on setup entry.""" mock_config_entry.add_to_hass(hass) - with patch( - "ring_doorbell.Ring.update_data", - side_effect=error_type, - ): - 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 + mock_ring_client.update_data.side_effect = error_type - assert [ - record.message - for record in caplog.records - if record.levelname == "DEBUG" - and record.name == "homeassistant.config_entries" - and log_msg in record.message - and DOMAIN in record.message - ] + 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 + + assert log_msg in caplog.text async def test_auth_failure_on_global_update( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, mock_config_entry: MockConfigEntry, - caplog, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test authentication failure on global data update.""" 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 not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - with patch( - "ring_doorbell.Ring.update_devices", - side_effect=AuthenticationError, - ): - async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) - await hass.async_block_till_done() - assert "Authentication failed while fetching devices data: " in [ - record.message - for record in caplog.records - if record.levelname == "ERROR" - and record.name == "homeassistant.components.ring.coordinator" - ] + mock_ring_client.update_devices.side_effect = AuthenticationError - assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert "Authentication failed while fetching devices data: " in caplog.text + + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) async def test_auth_failure_on_device_update( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, + mock_ring_devices, mock_config_entry: MockConfigEntry, - caplog, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test authentication failure on device data update.""" 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 not any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - with patch( - "ring_doorbell.RingDoorBell.history", - side_effect=AuthenticationError, - ): - async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) - await hass.async_block_till_done(wait_background_tasks=True) - assert "Authentication failed while fetching devices data: " in [ - record.message - for record in caplog.records - if record.levelname == "ERROR" - and record.name == "homeassistant.components.ring.coordinator" - ] + front_door_doorbell = mock_ring_devices.get_device(987654) + front_door_doorbell.history.side_effect = AuthenticationError - assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Authentication failed while fetching devices data: " in caplog.text + + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) @pytest.mark.parametrize( @@ -180,29 +166,27 @@ async def test_auth_failure_on_device_update( ) async def test_error_on_global_update( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, mock_config_entry: MockConfigEntry, - caplog, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, error_type, log_msg, ) -> None: - """Test error on global data update.""" + """Test non-auth errors on global data update.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - with patch( - "ring_doorbell.Ring.update_devices", - side_effect=error_type, - ): - async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) - await hass.async_block_till_done(wait_background_tasks=True) + mock_ring_client.update_devices.side_effect = error_type - assert log_msg in [ - record.message for record in caplog.records if record.levelname == "ERROR" - ] + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) - assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert log_msg in caplog.text + + assert mock_config_entry.entry_id in hass.data[DOMAIN] @pytest.mark.parametrize( @@ -221,35 +205,35 @@ async def test_error_on_global_update( ) async def test_error_on_device_update( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, + mock_ring_devices, mock_config_entry: MockConfigEntry, - caplog, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, error_type, log_msg, ) -> None: - """Test auth failure on data update.""" + """Test non-auth errors on device update.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - with patch( - "ring_doorbell.RingDoorBell.history", - side_effect=error_type, - ): - async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) - await hass.async_block_till_done(wait_background_tasks=True) + front_door_doorbell = mock_ring_devices.get_device(765432) + front_door_doorbell.history.side_effect = error_type - assert log_msg in [ - record.message for record in caplog.records if record.levelname == "ERROR" - ] - assert mock_config_entry.entry_id in hass.data[DOMAIN] + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert log_msg in caplog.text + assert mock_config_entry.entry_id in hass.data[DOMAIN] async def test_issue_deprecated_service_ring_update( hass: HomeAssistant, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, caplog: pytest.LogCaptureFixture, - requests_mock: requests_mock.Mocker, + mock_ring_client, mock_config_entry: MockConfigEntry, ) -> None: """Test the issue is raised on deprecated service ring.update.""" @@ -289,7 +273,7 @@ async def test_update_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, - requests_mock: requests_mock.Mocker, + mock_ring_client, domain: str, old_unique_id: int | str, ) -> None: @@ -325,7 +309,7 @@ async def test_update_unique_id_existing( hass: HomeAssistant, entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, - requests_mock: requests_mock.Mocker, + mock_ring_client, ) -> None: """Test unique_id update of integration.""" old_unique_id = 123456 @@ -373,7 +357,7 @@ async def test_update_unique_id_no_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, - requests_mock: requests_mock.Mocker, + mock_ring_client, ) -> None: """Test unique_id update of integration.""" correct_unique_id = "123456" diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py index ac0f3b70d27..c2d21a22951 100644 --- a/tests/components/ring/test_light.py +++ b/tests/components/ring/test_light.py @@ -1,9 +1,8 @@ """The tests for the Ring light platform.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import PropertyMock import pytest -import requests_mock import ring_doorbell from homeassistant.config_entries import SOURCE_REAUTH @@ -14,15 +13,14 @@ from homeassistant.helpers import entity_registry as er from .common import setup_platform -from tests.common import load_fixture - async def test_entity_registry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ring_client, ) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, Platform.LIGHT) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("light.front_light") assert entry.unique_id == "765432" @@ -32,7 +30,7 @@ async def test_entity_registry( async def test_light_off_reports_correctly( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client ) -> None: """Tests that the initial state of a device that should be off is correct.""" await setup_platform(hass, Platform.LIGHT) @@ -43,7 +41,7 @@ async def test_light_off_reports_correctly( async def test_light_on_reports_correctly( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client ) -> None: """Tests that the initial state of a device that should be on is correct.""" await setup_platform(hass, Platform.LIGHT) @@ -53,18 +51,10 @@ async def test_light_on_reports_correctly( assert state.attributes.get("friendly_name") == "Internal Light" -async def test_light_can_be_turned_on( - hass: HomeAssistant, requests_mock: requests_mock.Mocker -) -> None: +async def test_light_can_be_turned_on(hass: HomeAssistant, mock_ring_client) -> None: """Tests the light turns on correctly.""" await setup_platform(hass, Platform.LIGHT) - # Mocks the response for turning a light on - requests_mock.put( - "https://api.ring.com/clients_api/doorbots/765432/floodlight_light_on", - text=load_fixture("doorbot_siren_on_response.json", "ring"), - ) - state = hass.states.get("light.front_light") assert state.state == "off" @@ -78,17 +68,15 @@ async def test_light_can_be_turned_on( async def test_updates_work( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client, mock_ring_devices ) -> None: """Tests the update service works correctly.""" await setup_platform(hass, Platform.LIGHT) state = hass.states.get("light.front_light") assert state.state == "off" - # Changes the return to indicate that the light is now on. - requests_mock.get( - "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("devices_updated.json", "ring"), - ) + + front_light_mock = mock_ring_devices.get_device(765432) + front_light_mock.lights = "on" await hass.services.async_call("ring", "update", {}, blocking=True) @@ -109,7 +97,8 @@ async def test_updates_work( ) async def test_light_errors_when_turned_on( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, + mock_ring_devices, exception_type, reauth_expected, ) -> None: @@ -119,16 +108,17 @@ async def test_light_errors_when_turned_on( assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - with patch.object( - ring_doorbell.RingStickUpCam, "lights", new_callable=PropertyMock - ) as mock_lights: - mock_lights.side_effect = exception_type - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "light", "turn_on", {"entity_id": "light.front_light"}, blocking=True - ) - await hass.async_block_till_done() - assert mock_lights.call_count == 1 + front_light_mock = mock_ring_devices.get_device(765432) + p = PropertyMock(side_effect=exception_type) + type(front_light_mock).lights = p + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "light", "turn_on", {"entity_id": "light.front_light"}, blocking=True + ) + await hass.async_block_till_done() + p.assert_called_once() + assert ( any( flow diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 2c866586c6c..1f05c120251 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -3,21 +3,20 @@ import logging from freezegun.api import FrozenDateTimeFactory -import requests_mock +import pytest from homeassistant.components.ring.const import SCAN_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .common import setup_platform -from tests.common import async_fire_time_changed, load_fixture - -WIFI_ENABLED = False +from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: +async def test_sensor(hass: HomeAssistant, mock_ring_client) -> None: """Test the Ring sensors.""" await setup_platform(hass, "sensor") @@ -40,10 +39,6 @@ async def test_sensor(hass: HomeAssistant, requests_mock: requests_mock.Mocker) assert downstairs_volume_state is not None assert downstairs_volume_state.state == "2" - downstairs_wifi_signal_strength_state = hass.states.get( - "sensor.downstairs_wifi_signal_strength" - ) - ingress_mic_volume_state = hass.states.get("sensor.ingress_mic_volume") assert ingress_mic_volume_state.state == "11" @@ -53,56 +48,118 @@ async def test_sensor(hass: HomeAssistant, requests_mock: requests_mock.Mocker) ingress_voice_volume_state = hass.states.get("sensor.ingress_voice_volume") assert ingress_voice_volume_state.state == "11" - if not WIFI_ENABLED: - return - assert downstairs_wifi_signal_strength_state is not None - assert downstairs_wifi_signal_strength_state.state == "-39" - - front_door_wifi_signal_category_state = hass.states.get( - "sensor.front_door_wifi_signal_category" - ) - assert front_door_wifi_signal_category_state is not None - assert front_door_wifi_signal_category_state.state == "good" - - front_door_wifi_signal_strength_state = hass.states.get( - "sensor.front_door_wifi_signal_strength" - ) - assert front_door_wifi_signal_strength_state is not None - assert front_door_wifi_signal_strength_state.state == "-58" - - -async def test_history( +@pytest.mark.parametrize( + ("device_id", "device_name", "sensor_name", "expected_value"), + [ + (987654, "front_door", "wifi_signal_category", "good"), + (987654, "front_door", "wifi_signal_strength", "-58"), + (123456, "downstairs", "wifi_signal_category", "good"), + (123456, "downstairs", "wifi_signal_strength", "-39"), + (765432, "front", "wifi_signal_category", "good"), + (765432, "front", "wifi_signal_strength", "-58"), + ], + ids=[ + "doorbell-category", + "doorbell-strength", + "chime-category", + "chime-strength", + "stickup_cam-category", + "stickup_cam-strength", + ], +) +async def test_health_sensor( hass: HomeAssistant, + mock_ring_client, freezer: FrozenDateTimeFactory, - requests_mock: requests_mock.Mocker, + entity_registry: er.EntityRegistry, + device_id, + device_name, + sensor_name, + expected_value, ) -> None: - """Test history derived sensors.""" - await setup_platform(hass, Platform.SENSOR) + """Test the Ring health sensors.""" + entity_id = f"sensor.{device_name}_{sensor_name}" + # Enable the sensor as the health sensors are disabled by default + entity_entry = entity_registry.async_get_or_create( + "sensor", + "ring", + f"{device_id}-{sensor_name}", + suggested_object_id=f"{device_name}_{sensor_name}", + disabled_by=None, + ) + assert entity_entry.disabled is False + assert entity_entry.entity_id == entity_id + + await setup_platform(hass, "sensor") + await hass.async_block_till_done() + + sensor_state = hass.states.get(entity_id) + assert sensor_state is not None + assert sensor_state.state == "unknown" + freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) - await hass.async_block_till_done(True) + await hass.async_block_till_done(wait_background_tasks=True) + sensor_state = hass.states.get(entity_id) + assert sensor_state is not None + assert sensor_state.state == expected_value - front_door_last_activity_state = hass.states.get("sensor.front_door_last_activity") - assert front_door_last_activity_state.state == "2017-03-05T15:03:40+00:00" - ingress_last_activity_state = hass.states.get("sensor.ingress_last_activity") - assert ingress_last_activity_state.state == "2024-02-02T11:21:24+00:00" +@pytest.mark.parametrize( + ("device_name", "sensor_name", "expected_value"), + [ + ("front_door", "last_motion", "2017-03-05T15:03:40+00:00"), + ("front_door", "last_ding", "2018-03-05T15:03:40+00:00"), + ("front_door", "last_activity", "2018-03-05T15:03:40+00:00"), + ("front", "last_motion", "2017-03-05T15:03:40+00:00"), + ("ingress", "last_activity", "2024-02-02T11:21:24+00:00"), + ], + ids=[ + "doorbell-motion", + "doorbell-ding", + "doorbell-activity", + "stickup_cam-motion", + "other-activity", + ], +) +async def test_history_sensor( + hass: HomeAssistant, + mock_ring_client, + freezer: FrozenDateTimeFactory, + device_name, + sensor_name, + expected_value, +) -> None: + """Test the Ring sensors.""" + await setup_platform(hass, "sensor") + + entity_id = f"sensor.{device_name}_{sensor_name}" + sensor_state = hass.states.get(entity_id) + assert sensor_state is not None + assert sensor_state.state == "unknown" + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + sensor_state = hass.states.get(entity_id) + assert sensor_state is not None + assert sensor_state.state == expected_value async def test_only_chime_devices( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, + mock_ring_devices, freezer: FrozenDateTimeFactory, - caplog, + caplog: pytest.LogCaptureFixture, ) -> None: """Tests the update service works correctly if only chimes are returned.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to("2021-01-09 12:00:00+00:00") - requests_mock.get( - "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("chime_devices.json", "ring"), - ) + + mock_ring_devices.all_devices = mock_ring_devices.chimes + await setup_platform(hass, Platform.SENSOR) await hass.async_block_till_done() caplog.set_level(logging.DEBUG) diff --git a/tests/components/ring/test_siren.py b/tests/components/ring/test_siren.py index b3d46c601de..695b54c3971 100644 --- a/tests/components/ring/test_siren.py +++ b/tests/components/ring/test_siren.py @@ -1,9 +1,6 @@ """The tests for the Ring button platform.""" -from unittest.mock import patch - import pytest -import requests_mock import ring_doorbell from homeassistant.config_entries import SOURCE_REAUTH @@ -16,19 +13,18 @@ from .common import setup_platform async def test_entity_registry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ring_client, ) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, Platform.SIREN) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("siren.downstairs_siren") assert entry.unique_id == "123456-siren" -async def test_sirens_report_correctly( - hass: HomeAssistant, requests_mock: requests_mock.Mocker -) -> None: +async def test_sirens_report_correctly(hass: HomeAssistant, mock_ring_client) -> None: """Tests that the initial state of a device that should be on is correct.""" await setup_platform(hass, Platform.SIREN) @@ -38,16 +34,11 @@ async def test_sirens_report_correctly( async def test_default_ding_chime_can_be_played( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client, mock_ring_devices ) -> None: """Tests the play chime request is sent correctly.""" await setup_platform(hass, Platform.SIREN) - # Mocks the response for playing a test sound - requests_mock.post( - "https://api.ring.com/clients_api/chimes/123456/play_sound", - text="SUCCESS", - ) await hass.services.async_call( "siren", "turn_on", @@ -57,26 +48,19 @@ async def test_default_ding_chime_can_be_played( await hass.async_block_till_done() - assert requests_mock.request_history[-1].url.startswith( - "https://api.ring.com/clients_api/chimes/123456/play_sound?" - ) - assert "kind=ding" in requests_mock.request_history[-1].url + downstairs_chime_mock = mock_ring_devices.get_device(123456) + downstairs_chime_mock.test_sound.assert_called_once_with(kind="ding") state = hass.states.get("siren.downstairs_siren") assert state.state == "unknown" async def test_turn_on_plays_default_chime( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client, mock_ring_devices ) -> None: """Tests the play chime request is sent correctly when turned on.""" await setup_platform(hass, Platform.SIREN) - # Mocks the response for playing a test sound - requests_mock.post( - "https://api.ring.com/clients_api/chimes/123456/play_sound", - text="SUCCESS", - ) await hass.services.async_call( "siren", "turn_on", @@ -86,26 +70,21 @@ async def test_turn_on_plays_default_chime( await hass.async_block_till_done() - assert requests_mock.request_history[-1].url.startswith( - "https://api.ring.com/clients_api/chimes/123456/play_sound?" - ) - assert "kind=ding" in requests_mock.request_history[-1].url + downstairs_chime_mock = mock_ring_devices.get_device(123456) + downstairs_chime_mock.test_sound.assert_called_once_with(kind="ding") state = hass.states.get("siren.downstairs_siren") assert state.state == "unknown" async def test_explicit_ding_chime_can_be_played( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + mock_ring_client, + mock_ring_devices, ) -> None: """Tests the play chime request is sent correctly.""" await setup_platform(hass, Platform.SIREN) - # Mocks the response for playing a test sound - requests_mock.post( - "https://api.ring.com/clients_api/chimes/123456/play_sound", - text="SUCCESS", - ) await hass.services.async_call( "siren", "turn_on", @@ -115,26 +94,19 @@ async def test_explicit_ding_chime_can_be_played( await hass.async_block_till_done() - assert requests_mock.request_history[-1].url.startswith( - "https://api.ring.com/clients_api/chimes/123456/play_sound?" - ) - assert "kind=ding" in requests_mock.request_history[-1].url + downstairs_chime_mock = mock_ring_devices.get_device(123456) + downstairs_chime_mock.test_sound.assert_called_once_with(kind="ding") state = hass.states.get("siren.downstairs_siren") assert state.state == "unknown" async def test_motion_chime_can_be_played( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client, mock_ring_devices ) -> None: """Tests the play chime request is sent correctly.""" await setup_platform(hass, Platform.SIREN) - # Mocks the response for playing a test sound - requests_mock.post( - "https://api.ring.com/clients_api/chimes/123456/play_sound", - text="SUCCESS", - ) await hass.services.async_call( "siren", "turn_on", @@ -144,10 +116,8 @@ async def test_motion_chime_can_be_played( await hass.async_block_till_done() - assert requests_mock.request_history[-1].url.startswith( - "https://api.ring.com/clients_api/chimes/123456/play_sound?" - ) - assert "kind=motion" in requests_mock.request_history[-1].url + downstairs_chime_mock = mock_ring_devices.get_device(123456) + downstairs_chime_mock.test_sound.assert_called_once_with(kind="motion") state = hass.states.get("siren.downstairs_siren") assert state.state == "unknown" @@ -164,7 +134,8 @@ async def test_motion_chime_can_be_played( ) async def test_siren_errors_when_turned_on( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, + mock_ring_devices, exception_type, reauth_expected, ) -> None: @@ -174,18 +145,17 @@ async def test_siren_errors_when_turned_on( assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - with patch.object( - ring_doorbell.RingChime, "test_sound", side_effect=exception_type - ) as mock_siren: - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "siren", - "turn_on", - {"entity_id": "siren.downstairs_siren", "tone": "motion"}, - blocking=True, - ) - await hass.async_block_till_done() - assert mock_siren.call_count == 1 + downstairs_chime_mock = mock_ring_devices.get_device(123456) + downstairs_chime_mock.test_sound.side_effect = exception_type + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "siren", + "turn_on", + {"entity_id": "siren.downstairs_siren", "tone": "motion"}, + blocking=True, + ) + downstairs_chime_mock.test_sound.assert_called_once_with(kind="motion") assert ( any( flow diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index e4ddd7cd855..405f20420b7 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,9 +1,8 @@ """The tests for the Ring switch platform.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import PropertyMock import pytest -import requests_mock import ring_doorbell from homeassistant.config_entries import SOURCE_REAUTH @@ -15,15 +14,14 @@ from homeassistant.setup import async_setup_component from .common import setup_platform -from tests.common import load_fixture - async def test_entity_registry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ring_client, ) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, Platform.SWITCH) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("switch.front_siren") assert entry.unique_id == "765432-siren" @@ -33,7 +31,7 @@ async def test_entity_registry( async def test_siren_off_reports_correctly( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client ) -> None: """Tests that the initial state of a device that should be off is correct.""" await setup_platform(hass, Platform.SWITCH) @@ -44,7 +42,7 @@ async def test_siren_off_reports_correctly( async def test_siren_on_reports_correctly( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client ) -> None: """Tests that the initial state of a device that should be on is correct.""" await setup_platform(hass, Platform.SWITCH) @@ -54,18 +52,10 @@ async def test_siren_on_reports_correctly( assert state.attributes.get("friendly_name") == "Internal Siren" -async def test_siren_can_be_turned_on( - hass: HomeAssistant, requests_mock: requests_mock.Mocker -) -> None: +async def test_siren_can_be_turned_on(hass: HomeAssistant, mock_ring_client) -> None: """Tests the siren turns on correctly.""" await setup_platform(hass, Platform.SWITCH) - # Mocks the response for turning a siren on - requests_mock.put( - "https://api.ring.com/clients_api/doorbots/765432/siren_on", - text=load_fixture("doorbot_siren_on_response.json", "ring"), - ) - state = hass.states.get("switch.front_siren") assert state.state == "off" @@ -79,17 +69,15 @@ async def test_siren_can_be_turned_on( async def test_updates_work( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, mock_ring_client, mock_ring_devices ) -> None: """Tests the update service works correctly.""" await setup_platform(hass, Platform.SWITCH) state = hass.states.get("switch.front_siren") assert state.state == "off" - # Changes the return to indicate that the siren is now on. - requests_mock.get( - "https://api.ring.com/clients_api/ring_devices", - text=load_fixture("devices_updated.json", "ring"), - ) + + front_siren_mock = mock_ring_devices.get_device(765432) + front_siren_mock.siren = 20 await async_setup_component(hass, "homeassistant", {}) await hass.services.async_call( @@ -116,7 +104,8 @@ async def test_updates_work( ) async def test_switch_errors_when_turned_on( hass: HomeAssistant, - requests_mock: requests_mock.Mocker, + mock_ring_client, + mock_ring_devices, exception_type, reauth_expected, ) -> None: @@ -126,16 +115,16 @@ async def test_switch_errors_when_turned_on( assert not any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) - with patch.object( - ring_doorbell.RingStickUpCam, "siren", new_callable=PropertyMock - ) as mock_switch: - mock_switch.side_effect = exception_type - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.front_siren"}, blocking=True - ) - await hass.async_block_till_done() - assert mock_switch.call_count == 1 + front_siren_mock = mock_ring_devices.get_device(765432) + p = PropertyMock(side_effect=exception_type) + type(front_siren_mock).siren = p + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.front_siren"}, blocking=True + ) + await hass.async_block_till_done() + p.assert_called_once() assert ( any( flow diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py index ff831b59062..53d5b9573b6 100644 --- a/tests/components/risco/test_alarm_control_panel.py +++ b/tests/components/risco/test_alarm_control_panel.py @@ -143,30 +143,38 @@ def two_part_local_alarm(): @pytest.mark.parametrize("exception", [CannotConnectError, UnauthorizedError]) async def test_error_on_login( - hass: HomeAssistant, login_with_error, cloud_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + login_with_error, + cloud_config_entry, ) -> None: """Test error on login.""" await hass.config_entries.async_setup(cloud_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - assert not registry.async_is_registered(FIRST_CLOUD_ENTITY_ID) - assert not registry.async_is_registered(SECOND_CLOUD_ENTITY_ID) + assert not entity_registry.async_is_registered(FIRST_CLOUD_ENTITY_ID) + assert not entity_registry.async_is_registered(SECOND_CLOUD_ENTITY_ID) async def test_cloud_setup( - hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + two_part_cloud_alarm, + setup_risco_cloud, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) - assert registry.async_is_registered(FIRST_CLOUD_ENTITY_ID) - assert registry.async_is_registered(SECOND_CLOUD_ENTITY_ID) + assert entity_registry.async_is_registered(FIRST_CLOUD_ENTITY_ID) + assert entity_registry.async_is_registered(SECOND_CLOUD_ENTITY_ID) - registry = dr.async_get(hass) - device = registry.async_get_device(identifiers={(DOMAIN, TEST_SITE_UUID + "_0")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_0")} + ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device(identifiers={(DOMAIN, TEST_SITE_UUID + "_1")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_1")} + ) assert device is not None assert device.manufacturer == "Risco" @@ -274,11 +282,13 @@ async def _test_cloud_no_service_call( @pytest.mark.parametrize("options", [CUSTOM_MAPPING_OPTIONS]) async def test_cloud_sets_custom_mapping( - hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + two_part_cloud_alarm, + setup_risco_cloud, ) -> None: """Test settings the various modes when mapping some states.""" - registry = er.async_get(hass) - entity = registry.async_get(FIRST_CLOUD_ENTITY_ID) + entity = entity_registry.async_get(FIRST_CLOUD_ENTITY_ID) assert entity.supported_features == EXPECTED_FEATURES await _test_cloud_service_call( @@ -309,11 +319,13 @@ async def test_cloud_sets_custom_mapping( @pytest.mark.parametrize("options", [FULL_CUSTOM_MAPPING]) async def test_cloud_sets_full_custom_mapping( - hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + two_part_cloud_alarm, + setup_risco_cloud, ) -> None: """Test settings the various modes when mapping all states.""" - registry = er.async_get(hass) - entity = registry.async_get(FIRST_CLOUD_ENTITY_ID) + entity = entity_registry.async_get(FIRST_CLOUD_ENTITY_ID) assert ( entity.supported_features == EXPECTED_FEATURES | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS @@ -479,32 +491,36 @@ async def test_cloud_sets_with_incorrect_code( @pytest.mark.parametrize("exception", [CannotConnectError, UnauthorizedError]) async def test_error_on_connect( - hass: HomeAssistant, connect_with_error, local_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + connect_with_error, + local_config_entry, ) -> None: """Test error on connect.""" await hass.config_entries.async_setup(local_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - assert not registry.async_is_registered(FIRST_LOCAL_ENTITY_ID) - assert not registry.async_is_registered(SECOND_LOCAL_ENTITY_ID) + assert not entity_registry.async_is_registered(FIRST_LOCAL_ENTITY_ID) + assert not entity_registry.async_is_registered(SECOND_LOCAL_ENTITY_ID) async def test_local_setup( - hass: HomeAssistant, two_part_local_alarm, setup_risco_local + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + two_part_local_alarm, + setup_risco_local, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) - assert registry.async_is_registered(FIRST_LOCAL_ENTITY_ID) - assert registry.async_is_registered(SECOND_LOCAL_ENTITY_ID) + assert entity_registry.async_is_registered(FIRST_LOCAL_ENTITY_ID) + assert entity_registry.async_is_registered(SECOND_LOCAL_ENTITY_ID) - registry = dr.async_get(hass) - device = registry.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_SITE_UUID + "_0_local")} ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_SITE_UUID + "_1_local")} ) assert device is not None @@ -630,11 +646,13 @@ async def _test_local_no_service_call( @pytest.mark.parametrize("options", [CUSTOM_MAPPING_OPTIONS]) async def test_local_sets_custom_mapping( - hass: HomeAssistant, two_part_local_alarm, setup_risco_local + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + two_part_local_alarm, + setup_risco_local, ) -> None: """Test settings the various modes when mapping some states.""" - registry = er.async_get(hass) - entity = registry.async_get(FIRST_LOCAL_ENTITY_ID) + entity = entity_registry.async_get(FIRST_LOCAL_ENTITY_ID) assert entity.supported_features == EXPECTED_FEATURES await _test_local_service_call( @@ -699,11 +717,13 @@ async def test_local_sets_custom_mapping( @pytest.mark.parametrize("options", [FULL_CUSTOM_MAPPING]) async def test_local_sets_full_custom_mapping( - hass: HomeAssistant, two_part_local_alarm, setup_risco_local + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + two_part_local_alarm, + setup_risco_local, ) -> None: """Test settings the various modes when mapping all states.""" - registry = er.async_get(hass) - entity = registry.async_get(FIRST_LOCAL_ENTITY_ID) + entity = entity_registry.async_get(FIRST_LOCAL_ENTITY_ID) assert ( entity.supported_features == EXPECTED_FEATURES | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS diff --git a/tests/components/risco/test_binary_sensor.py b/tests/components/risco/test_binary_sensor.py index b6ea723064e..b6ff29a0bce 100644 --- a/tests/components/risco/test_binary_sensor.py +++ b/tests/components/risco/test_binary_sensor.py @@ -23,32 +23,36 @@ SECOND_ARMED_ENTITY_ID = SECOND_ENTITY_ID + "_armed" @pytest.mark.parametrize("exception", [CannotConnectError, UnauthorizedError]) async def test_error_on_login( - hass: HomeAssistant, login_with_error, cloud_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + login_with_error, + cloud_config_entry, ) -> None: """Test error on login.""" await hass.config_entries.async_setup(cloud_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - assert not registry.async_is_registered(FIRST_ENTITY_ID) - assert not registry.async_is_registered(SECOND_ENTITY_ID) + assert not entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert not entity_registry.async_is_registered(SECOND_ENTITY_ID) async def test_cloud_setup( - hass: HomeAssistant, two_zone_cloud, setup_risco_cloud + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + two_zone_cloud, + setup_risco_cloud, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) - assert registry.async_is_registered(FIRST_ENTITY_ID) - assert registry.async_is_registered(SECOND_ENTITY_ID) + assert entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert entity_registry.async_is_registered(SECOND_ENTITY_ID) - registry = dr.async_get(hass) - device = registry.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_0")} ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_1")} ) assert device is not None @@ -81,42 +85,46 @@ async def test_cloud_states( @pytest.mark.parametrize("exception", [CannotConnectError, UnauthorizedError]) async def test_error_on_connect( - hass: HomeAssistant, connect_with_error, local_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + connect_with_error, + local_config_entry, ) -> None: """Test error on connect.""" await hass.config_entries.async_setup(local_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - assert not registry.async_is_registered(FIRST_ENTITY_ID) - assert not registry.async_is_registered(SECOND_ENTITY_ID) - assert not registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) - assert not registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) + assert not entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert not entity_registry.async_is_registered(SECOND_ENTITY_ID) + assert not entity_registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) + assert not entity_registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) async def test_local_setup( - hass: HomeAssistant, two_zone_local, setup_risco_local + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + two_zone_local, + setup_risco_local, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) - assert registry.async_is_registered(FIRST_ENTITY_ID) - assert registry.async_is_registered(SECOND_ENTITY_ID) - assert registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) - assert registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) + assert entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert entity_registry.async_is_registered(SECOND_ENTITY_ID) + assert entity_registry.async_is_registered(FIRST_ALARMED_ENTITY_ID) + assert entity_registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) - registry = dr.async_get(hass) - device = registry.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_0_local")} ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_1_local")} ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device(identifiers={(DOMAIN, TEST_SITE_UUID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_SITE_UUID)}) assert device is not None assert device.manufacturer == "Risco" diff --git a/tests/components/risco/test_init.py b/tests/components/risco/test_init.py new file mode 100644 index 00000000000..4f604c75fe9 --- /dev/null +++ b/tests/components/risco/test_init.py @@ -0,0 +1,30 @@ +"""Tests for the Risco integration.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def mock_error_handler(): + """Create a mock for add_error_handler.""" + with patch("homeassistant.components.risco.RiscoLocal.add_error_handler") as mock: + yield mock + + +async def test_connection_reset( + hass: HomeAssistant, two_zone_local, mock_error_handler, setup_risco_local +) -> None: + """Test config entry reload on connection reset.""" + + callback = mock_error_handler.call_args.args[0] + assert callback is not None + + with patch.object(hass.config_entries, "async_reload") as reload_mock: + await callback(Exception()) + reload_mock.assert_not_awaited() + + await callback(ConnectionResetError()) + reload_mock.assert_awaited_once() diff --git a/tests/components/risco/test_sensor.py b/tests/components/risco/test_sensor.py index a8236ad3d87..72444bdc9f2 100644 --- a/tests/components/risco/test_sensor.py +++ b/tests/components/risco/test_sensor.py @@ -5,11 +5,8 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest -from homeassistant.components.risco import ( - LAST_EVENT_TIMESTAMP_KEY, - CannotConnectError, - UnauthorizedError, -) +from homeassistant.components.risco import CannotConnectError, UnauthorizedError +from homeassistant.components.risco.coordinator import LAST_EVENT_TIMESTAMP_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -126,15 +123,17 @@ def _no_zones_and_partitions(): @pytest.mark.parametrize("exception", [CannotConnectError, UnauthorizedError]) async def test_error_on_login( - hass: HomeAssistant, login_with_error, cloud_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + login_with_error, + cloud_config_entry, ) -> None: """Test error on login.""" await hass.config_entries.async_setup(cloud_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) for entity_id in ENTITY_IDS.values(): - assert not registry.async_is_registered(entity_id) + assert not entity_registry.async_is_registered(entity_id) def _check_state(hass, category, entity_id): @@ -161,15 +160,15 @@ def _check_state(hass, category, entity_id): @pytest.fixture -def _set_utc_time_zone(hass): - hass.config.set_time_zone("UTC") +async def _set_utc_time_zone(hass): + await hass.config.async_set_time_zone("UTC") @pytest.fixture def save_mock(): """Create a mock for async_save.""" with patch( - "homeassistant.components.risco.Store.async_save", + "homeassistant.components.risco.coordinator.Store.async_save", ) as save_mock: yield save_mock @@ -177,15 +176,15 @@ def save_mock(): @pytest.mark.parametrize("events", [TEST_EVENTS]) async def test_cloud_setup( hass: HomeAssistant, + entity_registry: er.EntityRegistry, two_zone_cloud, _set_utc_time_zone, save_mock, setup_risco_cloud, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) for entity_id in ENTITY_IDS.values(): - assert registry.async_is_registered(entity_id) + assert entity_registry.async_is_registered(entity_id) save_mock.assert_awaited_once_with({LAST_EVENT_TIMESTAMP_KEY: TEST_EVENTS[0].time}) for category, entity_id in ENTITY_IDS.items(): @@ -196,7 +195,7 @@ async def test_cloud_setup( "homeassistant.components.risco.RiscoCloud.get_events", return_value=[] ) as events_mock, patch( - "homeassistant.components.risco.Store.async_load", + "homeassistant.components.risco.coordinator.Store.async_load", return_value={LAST_EVENT_TIMESTAMP_KEY: TEST_EVENTS[0].time}, ), ): @@ -209,9 +208,11 @@ async def test_cloud_setup( async def test_local_setup( - hass: HomeAssistant, setup_risco_local, _no_zones_and_partitions + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_risco_local, + _no_zones_and_partitions, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) for entity_id in ENTITY_IDS.values(): - assert not registry.async_is_registered(entity_id) + assert not entity_registry.async_is_registered(entity_id) diff --git a/tests/components/risco/test_switch.py b/tests/components/risco/test_switch.py index 100796b9ea1..acf80462d54 100644 --- a/tests/components/risco/test_switch.py +++ b/tests/components/risco/test_switch.py @@ -17,23 +17,27 @@ SECOND_ENTITY_ID = "switch.zone_1_bypassed" @pytest.mark.parametrize("exception", [CannotConnectError, UnauthorizedError]) async def test_error_on_login( - hass: HomeAssistant, login_with_error, cloud_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + login_with_error, + cloud_config_entry, ) -> None: """Test error on login.""" await hass.config_entries.async_setup(cloud_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - assert not registry.async_is_registered(FIRST_ENTITY_ID) - assert not registry.async_is_registered(SECOND_ENTITY_ID) + assert not entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert not entity_registry.async_is_registered(SECOND_ENTITY_ID) async def test_cloud_setup( - hass: HomeAssistant, two_zone_cloud, setup_risco_cloud + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + two_zone_cloud, + setup_risco_cloud, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) - assert registry.async_is_registered(FIRST_ENTITY_ID) - assert registry.async_is_registered(SECOND_ENTITY_ID) + assert entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert entity_registry.async_is_registered(SECOND_ENTITY_ID) async def _check_cloud_state(hass, zones, bypassed, entity_id, zone_id): @@ -90,23 +94,27 @@ async def test_cloud_unbypass( @pytest.mark.parametrize("exception", [CannotConnectError, UnauthorizedError]) async def test_error_on_connect( - hass: HomeAssistant, connect_with_error, local_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + connect_with_error, + local_config_entry, ) -> None: """Test error on connect.""" await hass.config_entries.async_setup(local_config_entry.entry_id) await hass.async_block_till_done() - registry = er.async_get(hass) - assert not registry.async_is_registered(FIRST_ENTITY_ID) - assert not registry.async_is_registered(SECOND_ENTITY_ID) + assert not entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert not entity_registry.async_is_registered(SECOND_ENTITY_ID) async def test_local_setup( - hass: HomeAssistant, two_zone_local, setup_risco_local + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + two_zone_local, + setup_risco_local, ) -> None: """Test entity setup.""" - registry = er.async_get(hass) - assert registry.async_is_registered(FIRST_ENTITY_ID) - assert registry.async_is_registered(SECOND_ENTITY_ID) + assert entity_registry.async_is_registered(FIRST_ENTITY_ID) + assert entity_registry.async_is_registered(SECOND_ENTITY_ID) async def _check_local_state(hass, zones, bypassed, entity_id, zone_id, callback): diff --git a/tests/components/rituals_perfume_genie/common.py b/tests/components/rituals_perfume_genie/common.py index f2a54ca5def..044582c5735 100644 --- a/tests/components/rituals_perfume_genie/common.py +++ b/tests/components/rituals_perfume_genie/common.py @@ -85,7 +85,7 @@ def mock_diffuser_v2_no_battery_no_cartridge() -> MagicMock: async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_diffusers: list[MagicMock] = [mock_diffuser(hublot="lot123")], + mock_diffusers: list[MagicMock], ) -> None: """Initialize the Rituals Perfume Genie integration with the given Config Entry and Diffuser list.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index d1001d1ad93..435e762a646 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -12,6 +12,7 @@ from homeassistant.helpers import entity_registry as er from .common import ( init_integration, mock_config_entry, + mock_diffuser, mock_diffuser_v1_battery_cartridge, ) @@ -31,7 +32,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant) -> None: async def test_config_entry_unload(hass: HomeAssistant) -> None: """Test the Rituals Perfume Genie configuration entry setup and unloading.""" config_entry = mock_config_entry(unique_id="id_123_unload") - await init_integration(hass, config_entry) + await init_integration(hass, config_entry, [mock_diffuser(hublot="lot123")]) await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 16ebc8806f9..6e3fb229aa9 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -224,7 +224,599 @@ HOME_DATA_RAW = { "desc": None, }, ], - } + }, + { + "id": "dyad_product", + "name": "Roborock Dyad Pro", + "model": "roborock.wetdryvac.a56", + "category": "roborock.wetdryvac", + "capability": 2, + "schema": [ + { + "id": "134", + "name": "烘干状态", + "code": "drying_status", + "mode": "ro", + "type": "RAW", + }, + { + "id": "200", + "name": "启停", + "code": "start", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "201", + "name": "状态", + "code": "status", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "202", + "name": "自清洁模式", + "code": "self_clean_mode", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "203", + "name": "自清洁强度", + "code": "self_clean_level", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "204", + "name": "烘干强度", + "code": "warm_level", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "205", + "name": "洗地模式", + "code": "clean_mode", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "206", + "name": "吸力", + "code": "suction", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "207", + "name": "水量", + "code": "water_level", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "208", + "name": "滚刷转速", + "code": "brush_speed", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "209", + "name": "电量", + "code": "power", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "210", + "name": "预约时间", + "code": "countdown_time", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "212", + "name": "自动自清洁", + "code": "auto_self_clean_set", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "213", + "name": "自动烘干", + "code": "auto_dry", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "214", + "name": "滤网已工作时间", + "code": "mesh_left", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "215", + "name": "滚刷已工作时间", + "code": "brush_left", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "216", + "name": "错误值", + "code": "error", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "218", + "name": "滤网重置", + "code": "mesh_reset", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "219", + "name": "滚刷重置", + "code": "brush_reset", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "221", + "name": "音量", + "code": "volume_set", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "222", + "name": "直立解锁自动运行开关", + "code": "stand_lock_auto_run", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "223", + "name": "自动自清洁 - 模式", + "code": "auto_self_clean_set_mode", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "224", + "name": "自动烘干 - 模式", + "code": "auto_dry_mode", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "225", + "name": "静音烘干时长", + "code": "silent_dry_duration", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "226", + "name": "勿扰模式开关", + "code": "silent_mode", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "227", + "name": "勿扰开启时间", + "code": "silent_mode_start_time", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "228", + "name": "勿扰结束时间", + "code": "silent_mode_end_time", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "229", + "name": "近30天每天洗地时长", + "code": "recent_run_time", + "mode": "rw", + "type": "STRING", + }, + { + "id": "230", + "name": "洗地总时长", + "code": "total_run_time", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "235", + "name": "featureinfo", + "code": "feature_info", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "236", + "name": "恢复初始设置", + "code": "recover_settings", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "237", + "name": "烘干倒计时", + "code": "dry_countdown", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "10000", + "name": "ID点数据查询", + "code": "id_query", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10001", + "name": "防串货", + "code": "f_c", + "mode": "ro", + "type": "STRING", + }, + { + "id": "10002", + "name": "定时任务", + "code": "schedule_task", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10003", + "name": "语音包切换", + "code": "snd_switch", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10004", + "name": "语音包/OBA信息", + "code": "snd_state", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10005", + "name": "产品信息", + "code": "product_info", + "mode": "ro", + "type": "STRING", + }, + { + "id": "10006", + "name": "隐私协议", + "code": "privacy_info", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10007", + "name": "OTA info", + "code": "ota_nfo", + "mode": "ro", + "type": "STRING", + }, + { + "id": "10101", + "name": "rpc req", + "code": "rpc_req", + "mode": "wo", + "type": "STRING", + }, + { + "id": "10102", + "name": "rpc resp", + "code": "rpc_resp", + "mode": "ro", + "type": "STRING", + }, + ], + }, + { + "id": "zeo_id", + "name": "Zeo One", + "model": "roborock.wm.a102", + "category": "roborock.wm", + "capability": 2, + "schema": [ + { + "id": "134", + "name": "烘干状态", + "code": "drying_status", + "mode": "ro", + "type": "RAW", + }, + { + "id": "200", + "name": "启动", + "code": "start", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "201", + "name": "暂停", + "code": "pause", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "202", + "name": "关机", + "code": "shutdown", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "203", + "name": "状态", + "code": "status", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "204", + "name": "模式", + "code": "mode", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "205", + "name": "程序", + "code": "program", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "206", + "name": "童锁", + "code": "child_lock", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "207", + "name": "洗涤温度", + "code": "temp", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "208", + "name": "漂洗次数", + "code": "rinse_times", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "209", + "name": "滚筒转速", + "code": "spin_level", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "210", + "name": "干燥度", + "code": "drying_mode", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "211", + "name": "自动投放-洗衣液", + "code": "detergent_set", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "212", + "name": "自动投放-柔顺剂", + "code": "softener_set", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "213", + "name": "洗衣液投放量", + "code": "detergent_type", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "214", + "name": "柔顺剂投放量", + "code": "softener_type", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "217", + "name": "预约时间", + "code": "countdown", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "218", + "name": "洗衣剩余时间", + "code": "washing_left", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "219", + "name": "门锁状态", + "code": "doorlock_state", + "mode": "ro", + "type": "BOOL", + }, + { + "id": "220", + "name": "故障", + "code": "error", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "221", + "name": "云程序设置", + "code": "custom_param_save", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "222", + "name": "云程序读取", + "code": "custom_param_get", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "223", + "name": "提示音", + "code": "sound_set", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "224", + "name": "距离上次筒自洁次数", + "code": "times_after_clean", + "mode": "ro", + "type": "VALUE", + }, + { + "id": "225", + "name": "记忆洗衣偏好开关", + "code": "default_setting", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "226", + "name": "洗衣液用尽", + "code": "detergent_empty", + "mode": "ro", + "type": "BOOL", + }, + { + "id": "227", + "name": "柔顺剂用尽", + "code": "softener_empty", + "mode": "ro", + "type": "BOOL", + }, + { + "id": "229", + "name": "筒灯设定", + "code": "light_setting", + "mode": "rw", + "type": "BOOL", + }, + { + "id": "230", + "name": "洗衣液投放量(单次)", + "code": "detergent_volume", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "231", + "name": "柔顺剂投放量(单次)", + "code": "softener_volume", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "232", + "name": "远程控制授权", + "code": "app_authorization", + "mode": "rw", + "type": "VALUE", + }, + { + "id": "10000", + "name": "ID点查询", + "code": "id_query", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10001", + "name": "防串货", + "code": "f_c", + "mode": "ro", + "type": "STRING", + }, + { + "id": "10004", + "name": "语音包/OBA信息", + "code": "snd_state", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10005", + "name": "产品信息", + "code": "product_info", + "mode": "ro", + "type": "STRING", + }, + { + "id": "10006", + "name": "隐私协议", + "code": "privacy_info", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10007", + "name": "OTA info", + "code": "ota_nfo", + "mode": "rw", + "type": "STRING", + }, + { + "id": "10008", + "name": "洗衣记录", + "code": "washing_log", + "mode": "ro", + "type": "BOOL", + }, + { + "id": "10101", + "name": "rpc req", + "code": "rpc_req", + "mode": "wo", + "type": "STRING", + }, + { + "id": "10102", + "name": "rpc resp", + "code": "rpc_resp", + "mode": "ro", + "type": "STRING", + }, + ], + }, ], "devices": [ { @@ -304,7 +896,112 @@ HOME_DATA_RAW = { "silentOtaSwitch": True, }, ], - "receivedDevices": [], + "receivedDevices": [ + { + "duid": "dyad_duid", + "name": "Dyad Pro", + "localKey": "abc", + "fv": "01.12.34", + "productId": "dyad_product", + "activeTime": 1700754026, + "timeZoneId": "Europe/Stockholm", + "iconUrl": "", + "share": True, + "shareTime": 1701367095, + "online": True, + "pv": "A01", + "tuyaMigrated": False, + "deviceStatus": { + "10002": "", + "202": 0, + "235": 0, + "214": 513, + "225": 360, + "212": 1, + "228": 360, + "209": 100, + "10001": '{"f":"t"}', + "237": 0, + "10007": '{"mqttOtaData":{"mqttOtaStatus":{"status":"IDLE"}}}', + "227": 1320, + "10005": '{"sn":"dyad_sn","ssid":"dyad_ssid","timezone":"Europe/Stockholm","posix_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ip":"1.123.12.1","mac":"b0:4a:33:33:33:33","oba":{"language":"en","name":"A.03.0291_CE","bom":"A.03.0291","location":"de","wifiplan":"EU","timezone":"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin","logserver":"awsde0","featureset":"0"}"}', + "213": 1, + "207": 4, + "10004": '{"sid_in_use":25,"sid_version":5,"location":"de","bom":"A.03.0291","language":"en"}', + "206": 3, + "216": 0, + "221": 100, + "222": 0, + "223": 2, + "203": 2, + "230": 352, + "205": 1, + "210": 0, + "200": 0, + "226": 0, + "208": 1, + "229": "000,000,003,000,005,000,000,000,003,000,005,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,012,003,000,000", + "201": 3, + "215": 513, + "204": 1, + "224": 1, + }, + "silentOtaSwitch": False, + "f": False, + }, + { + "duid": "zeo_duid", + "name": "Zeo One", + "localKey": "zeo_local_key", + "fv": "01.00.94", + "productId": "zeo_id", + "activeTime": 1699964128, + "timeZoneId": "Europe/Berlin", + "iconUrl": "", + "share": True, + "shareTime": 1712763572, + "online": True, + "pv": "A01", + "tuyaMigrated": False, + "sn": "zeo_sn", + "featureSet": "0", + "newFeatureSet": "40", + "deviceStatus": { + "208": 2, + "205": 33, + "221": 0, + "226": 0, + "10001": '{"f":"t"}', + "214": 2, + "225": 0, + "232": 0, + "222": 347414, + "206": 0, + "200": 1, + "219": 0, + "223": 0, + "220": 0, + "201": 0, + "202": 1, + "10005": '{"sn":"zeo_sn","ssid":"internet","timezone":"Europe/Berlin","posix_timezone":"CET-1CEST,M3.5.0,M10.5.0/3","ip":"192.111.11.11","mac":"b0:4a:00:00:00:00","rssi":-57,"oba":{"language":"en","name":"A.03.0403_CE","bom":"A.03.0403","location":"de","wifiplan":"EU","timezone":"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin","logserver":"awsde0","loglevel":"4","featureset":"0"}}', + "211": 1, + "210": 1, + "217": 0, + "203": 7, + "213": 2, + "209": 7, + "224": 21, + "218": 227, + "212": 1, + "207": 4, + "204": 1, + "10007": '{"mqttOtaData":{"mqttOtaStatus":{"status":"IDLE"}}}', + "227": 1, + }, + "silentOtaSwitch": False, + "f": False, + }, + ], "rooms": [ {"id": 2362048, "name": "Example room 1"}, {"id": 2362044, "name": "Example room 2"}, diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index fc097dd73ae..5134ef7eea2 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -18,9 +18,10 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from ...common import MockConfigEntry from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL +from tests.common import MockConfigEntry + async def test_config_flow_success( hass: HomeAssistant, diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index bc45c6dec05..c884baef123 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -13,8 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .mock_data import MAP_DATA, PROP + from tests.common import MockConfigEntry, async_fire_time_changed -from tests.components.roborock.mock_data import MAP_DATA, PROP from tests.typing import ClientSessionGenerator diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 437c9847e21..15a64cbecf3 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -27,18 +27,21 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from .mock_data import PROP + from tests.common import MockConfigEntry -from tests.components.roborock.mock_data import PROP ENTITY_ID = "vacuum.roborock_s7_maxv" DEVICE_ID = "abc123" async def test_registry_entries( - hass: HomeAssistant, bypass_api_fixture, setup_entry: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + bypass_api_fixture, + setup_entry: MockConfigEntry, ) -> None: """Tests devices are registered in the entity registry.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(ENTITY_ID) assert entry.unique_id == DEVICE_ID diff --git a/tests/components/roku/conftest.py b/tests/components/roku/conftest.py index 4cec3e233e6..160a1bf3127 100644 --- a/tests/components/roku/conftest.py +++ b/tests/components/roku/conftest.py @@ -1,11 +1,11 @@ """Fixtures for Roku integration tests.""" -from collections.abc import Generator import json from unittest.mock import MagicMock, patch import pytest from rokuecp import Device as RokuDevice +from typing_extensions import Generator from homeassistant.components.roku.const import DOMAIN from homeassistant.const import CONF_HOST @@ -32,7 +32,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.roku.async_setup_entry", return_value=True): yield @@ -51,9 +51,7 @@ async def mock_device( @pytest.fixture -def mock_roku_config_flow( - mock_device: RokuDevice, -) -> Generator[None, MagicMock, None]: +def mock_roku_config_flow(mock_device: RokuDevice) -> Generator[MagicMock]: """Return a mocked Roku client.""" with patch( @@ -66,9 +64,7 @@ def mock_roku_config_flow( @pytest.fixture -def mock_roku( - request: pytest.FixtureRequest, mock_device: RokuDevice -) -> Generator[None, MagicMock, None]: +def mock_roku(mock_device: RokuDevice) -> Generator[MagicMock]: """Return a mocked Roku client.""" with patch( diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py index 076e16ebad0..ad27a857101 100644 --- a/tests/components/roku/test_binary_sensor.py +++ b/tests/components/roku/test_binary_sensor.py @@ -17,12 +17,12 @@ from tests.common import MockConfigEntry async def test_roku_binary_sensors( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the Roku binary sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("binary_sensor.my_roku_3_headphones_connected") entry = entity_registry.async_get("binary_sensor.my_roku_3_headphones_connected") assert entry @@ -83,14 +83,13 @@ async def test_roku_binary_sensors( @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_rokutv_binary_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_device: RokuDevice, mock_roku: MagicMock, ) -> None: """Test the Roku binary sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("binary_sensor.58_onn_roku_tv_headphones_connected") entry = entity_registry.async_get( "binary_sensor.58_onn_roku_tv_headphones_connected" diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index ec7213d3b3c..9aff8f581d7 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -36,7 +36,7 @@ from homeassistant.components.roku.const import ( SERVICE_SEARCH, ) from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ENTITY_ID, @@ -70,11 +70,13 @@ MAIN_ENTITY_ID = f"{MP_DOMAIN}.my_roku_3" TV_ENTITY_ID = f"{MP_DOMAIN}.58_onn_roku_tv" -async def test_setup(hass: HomeAssistant, init_integration: MockConfigEntry) -> None: +async def test_setup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: """Test setup with basic config.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get(MAIN_ENTITY_ID) entry = entity_registry.async_get(MAIN_ENTITY_ID) @@ -115,13 +117,12 @@ async def test_idle_setup( @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_tv_setup( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_roku: MagicMock, ) -> None: """Test Roku TV setup.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get(TV_ENTITY_ID) entry = entity_registry.async_get(TV_ENTITY_ID) diff --git a/tests/components/roku/test_remote.py b/tests/components/roku/test_remote.py index 3d40006a259..d499239bcee 100644 --- a/tests/components/roku/test_remote.py +++ b/tests/components/roku/test_remote.py @@ -24,11 +24,11 @@ async def test_setup(hass: HomeAssistant, init_integration: MockConfigEntry) -> async def test_unique_id( - hass: HomeAssistant, init_integration: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, ) -> None: """Test unique id.""" - entity_registry = er.async_get(hass) - main = entity_registry.async_get(MAIN_ENTITY_ID) assert main.unique_id == UPNP_SERIAL diff --git a/tests/components/roku/test_select.py b/tests/components/roku/test_select.py index fa93dfd4b8d..78cd65250f8 100644 --- a/tests/components/roku/test_select.py +++ b/tests/components/roku/test_select.py @@ -29,13 +29,12 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_application_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_device: RokuDevice, mock_roku: MagicMock, ) -> None: """Test the creation and values of the Roku selects.""" - entity_registry = er.async_get(hass) - entity_registry.async_get_or_create( SELECT_DOMAIN, DOMAIN, @@ -122,14 +121,13 @@ async def test_application_state( ) async def test_application_select_error( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_roku: MagicMock, error: RokuError, error_string: str, ) -> None: """Test error handling of the Roku selects.""" - entity_registry = er.async_get(hass) - entity_registry.async_get_or_create( SELECT_DOMAIN, DOMAIN, @@ -165,13 +163,12 @@ async def test_application_select_error( @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_channel_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_device: RokuDevice, mock_roku: MagicMock, ) -> None: """Test the creation and values of the Roku selects.""" - entity_registry = er.async_get(hass) - state = hass.states.get("select.58_onn_roku_tv_channel") assert state assert state.attributes.get(ATTR_OPTIONS) == [ diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py index 2d431e7f5dc..e65424e3e66 100644 --- a/tests/components/roku/test_sensor.py +++ b/tests/components/roku/test_sensor.py @@ -21,12 +21,11 @@ from tests.common import MockConfigEntry async def test_roku_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Roku sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.my_roku_3_active_app") entry = entity_registry.async_get("sensor.my_roku_3_active_app") assert entry @@ -67,13 +66,12 @@ async def test_roku_sensors( @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) async def test_rokutv_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, mock_roku: MagicMock, ) -> None: """Test the Roku TV sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.58_onn_roku_tv_active_app") entry = entity_registry.async_get("sensor.58_onn_roku_tv_active_app") assert entry diff --git a/tests/components/roon/test_config_flow.py b/tests/components/roon/test_config_flow.py index 6f83331d1c7..9822c88fa48 100644 --- a/tests/components/roon/test_config_flow.py +++ b/tests/components/roon/test_config_flow.py @@ -48,7 +48,7 @@ class RoonApiMockException(RoonApiMock): @property def token(self): """Throw exception.""" - raise Exception + raise Exception # pylint: disable=broad-exception-raised class RoonDiscoveryMock: diff --git a/tests/components/rova/test_init.py b/tests/components/rova/test_init.py index e522d5bfb12..2190e2f8ce3 100644 --- a/tests/components/rova/test_init.py +++ b/tests/components/rova/test_init.py @@ -12,8 +12,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir +from . import setup_with_selected_platforms + from tests.common import MockConfigEntry -from tests.components.rova import setup_with_selected_platforms async def test_reload( diff --git a/tests/components/rss_feed_template/test_init.py b/tests/components/rss_feed_template/test_init.py index 351c9e9d1cb..802fbb2244b 100644 --- a/tests/components/rss_feed_template/test_init.py +++ b/tests/components/rss_feed_template/test_init.py @@ -1,16 +1,24 @@ """The tests for the rss_feed_api component.""" +from asyncio import AbstractEventLoop from http import HTTPStatus +from aiohttp.test_utils import TestClient from defusedxml import ElementTree import pytest from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + @pytest.fixture -def mock_http_client(event_loop, hass, hass_client): +def mock_http_client( + event_loop: AbstractEventLoop, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> TestClient: """Set up test fixture.""" loop = event_loop config = { diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py index e968df9d860..6e790b4ff00 100644 --- a/tests/components/rtsp_to_webrtc/conftest.py +++ b/tests/components/rtsp_to_webrtc/conftest.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator -from typing import Any, TypeVar +from collections.abc import Awaitable, Callable +from typing import Any from unittest.mock import patch import pytest import rtsp_to_webrtc +from typing_extensions import AsyncGenerator from homeassistant.components import camera from homeassistant.components.rtsp_to_webrtc import DOMAIN @@ -23,9 +24,8 @@ SERVER_URL = "http://127.0.0.1:8083" CONFIG_ENTRY_DATA = {"server_url": SERVER_URL} # Typing helpers -ComponentSetup = Callable[[], Awaitable[None]] -_T = TypeVar("_T") -YieldFixture = Generator[_T, None, None] +type ComponentSetup = Callable[[], Awaitable[None]] +type AsyncYieldFixture[_T] = AsyncGenerator[_T] @pytest.fixture(autouse=True) @@ -39,7 +39,7 @@ async def webrtc_server() -> None: @pytest.fixture -async def mock_camera(hass) -> AsyncGenerator[None, None]: +async def mock_camera(hass: HomeAssistant) -> AsyncGenerator[None]: """Initialize a demo camera platform.""" assert await async_setup_component( hass, "camera", {camera.DOMAIN: {"platform": "demo"}} @@ -91,7 +91,7 @@ async def rtsp_to_webrtc_client() -> None: @pytest.fixture async def setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry -) -> YieldFixture[ComponentSetup]: +) -> AsyncYieldFixture[ComponentSetup]: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py index 27656dd10c7..3071c3d9d08 100644 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -11,7 +11,7 @@ import pytest import rtsp_to_webrtc from homeassistant.components.rtsp_to_webrtc import CONF_STUN_SERVER, DOMAIN -from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/ruckus_unleashed/__init__.py b/tests/components/ruckus_unleashed/__init__.py index cf510b87314..ccbf404cce0 100644 --- a/tests/components/ruckus_unleashed/__init__.py +++ b/tests/components/ruckus_unleashed/__init__.py @@ -1,5 +1,7 @@ """Tests for the Ruckus Unleashed integration.""" +from __future__ import annotations + from unittest.mock import AsyncMock, patch from aioruckus import AjaxSession, RuckusAjaxApi @@ -181,7 +183,7 @@ class RuckusAjaxApiPatchContext: def _patched_async_create( host: str, username: str, password: str - ) -> "AjaxSession": + ) -> AjaxSession: return AjaxSession(None, host, username, password) self.patchers.append( diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py index 6da0f68b5d8..79d7c2dfda4 100644 --- a/tests/components/ruckus_unleashed/test_device_tracker.py +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -84,13 +84,14 @@ async def test_clients_update_auth_failed(hass: HomeAssistant) -> None: assert test_client.state == STATE_UNAVAILABLE -async def test_restoring_clients(hass: HomeAssistant) -> None: +async def test_restoring_clients( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" entry = mock_config_entry() entry.add_to_hass(hass) - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( "device_tracker", DOMAIN, DEFAULT_UNIQUEID, diff --git a/tests/components/ruckus_unleashed/test_init.py b/tests/components/ruckus_unleashed/test_init.py index 48c0a5a270e..8147f040bde 100644 --- a/tests/components/ruckus_unleashed/test_init.py +++ b/tests/components/ruckus_unleashed/test_init.py @@ -53,13 +53,14 @@ async def test_setup_entry_connection_error(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_router_device_setup(hass: HomeAssistant) -> None: +async def test_router_device_setup( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test a router device is created.""" await init_integration(hass) device_info = DEFAULT_AP_INFO[0] - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(CONNECTION_NETWORK_MAC, device_info[API_AP_MAC])}, connections={(CONNECTION_NETWORK_MAC, device_info[API_AP_MAC])}, diff --git a/tests/components/ruuvi_gateway/conftest.py b/tests/components/ruuvi_gateway/conftest.py index 6a57ae00b1e..754fda0fd98 100644 --- a/tests/components/ruuvi_gateway/conftest.py +++ b/tests/components/ruuvi_gateway/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/ruuvitag_ble/test_config_flow.py b/tests/components/ruuvitag_ble/test_config_flow.py index b6c79f1de0e..3414fa34536 100644 --- a/tests/components/ruuvitag_ble/test_config_flow.py +++ b/tests/components/ruuvitag_ble/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Mock bluetooth for all tests in this module.""" diff --git a/tests/components/ruuvitag_ble/test_sensor.py b/tests/components/ruuvitag_ble/test_sensor.py index 12cf0a4c0d6..14826a692a6 100644 --- a/tests/components/ruuvitag_ble/test_sensor.py +++ b/tests/components/ruuvitag_ble/test_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from homeassistant.components.ruuvitag_ble.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT @@ -13,7 +15,8 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors(enable_bluetooth: None, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_sensors(hass: HomeAssistant) -> None: """Test the RuuviTag BLE sensors.""" entry = MockConfigEntry(domain=DOMAIN, unique_id=RUUVITAG_SERVICE_INFO.address) entry.add_to_hass(hass) @@ -29,12 +32,12 @@ async def test_sensors(enable_bluetooth: None, hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all()) >= 4 - for sensor, value, unit, state_class in [ + for sensor, value, unit, state_class in ( ("temperature", "7.2", "°C", "measurement"), ("humidity", "61.84", "%", "measurement"), ("pressure", "1013.54", "hPa", "measurement"), ("voltage", "2395", "mV", "measurement"), - ]: + ): state = hass.states.get(f"sensor.{CONFIGURED_PREFIX}_{sensor}") assert state is not None assert state.state == value diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py index d1854017452..7d68d3108f0 100644 --- a/tests/components/sabnzbd/conftest.py +++ b/tests/components/sabnzbd/conftest.py @@ -1,13 +1,13 @@ """Configuration for Sabnzbd tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.sabnzbd.async_setup_entry", return_value=True diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 8bef7317918..8d38adad06d 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable from datetime import datetime -from socket import AddressFamily +from socket import AddressFamily # pylint: disable=no-name-in-module from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -19,6 +19,7 @@ from samsungtvws.encrypted.remote import SamsungTVEncryptedWSAsyncRemote from samsungtvws.event import ED_INSTALLED_APP_EVENT from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand +from typing_extensions import Generator from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT from homeassistant.core import HomeAssistant, ServiceCall @@ -30,7 +31,7 @@ from tests.common import async_mock_service @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.samsungtv.async_setup_entry", return_value=True @@ -55,11 +56,6 @@ async def silent_ssdp_scanner(hass): yield -@pytest.fixture(autouse=True) -def samsungtv_mock_get_source_ip(mock_get_source_ip): - """Mock network util's async_get_source_ip.""" - - @pytest.fixture(autouse=True) def samsungtv_mock_async_get_local_ip(): """Mock upnp util's async_get_local_ip.""" diff --git a/tests/components/samsungtv/const.py b/tests/components/samsungtv/const.py index 43d240ed779..1a7347ff0ce 100644 --- a/tests/components/samsungtv/const.py +++ b/tests/components/samsungtv/const.py @@ -3,7 +3,11 @@ from samsungtvws.event import ED_INSTALLED_APP_EVENT from homeassistant.components import ssdp -from homeassistant.components.samsungtv.const import CONF_SESSION_ID, METHOD_WEBSOCKET +from homeassistant.components.samsungtv.const import ( + CONF_SESSION_ID, + METHOD_LEGACY, + METHOD_WEBSOCKET, +) from homeassistant.components.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, @@ -21,6 +25,12 @@ from homeassistant.const import ( CONF_TOKEN, ) +MOCK_CONFIG = { + CONF_HOST: "fake_host", + CONF_NAME: "fake", + CONF_PORT: 55000, + CONF_METHOD: METHOD_LEGACY, +} MOCK_CONFIG_ENCRYPTED_WS = { CONF_HOST: "fake_host", CONF_NAME: "fake", @@ -41,6 +51,15 @@ MOCK_ENTRYDATA_WS = { CONF_MODEL: "any", CONF_NAME: "any", } +MOCK_ENTRY_WS_WITH_MAC = { + CONF_IP_ADDRESS: "test", + CONF_HOST: "fake_host", + CONF_METHOD: "websocket", + CONF_MAC: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "fake", + CONF_PORT: 8002, + CONF_TOKEN: "123456789", +} MOCK_SSDP_DATA_RENDERING_CONTROL_ST = ssdp.SsdpServiceInfo( ssdp_usn="mock_usn", diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 1b8cf4c999d..42a3f4fb396 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -1,4 +1,80 @@ # serializer version: 1 +# name: test_cleanup_mac + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + tuple( + 'mac', + 'none', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'samsungtv', + 'any', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': '82GXARRS', + 'name': 'fake', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_cleanup_mac.1 + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'samsungtv', + 'any', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': '82GXARRS', + 'name': 'fake', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- # name: test_setup_updates_from_ssdp StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py index a1fb585bfaa..e16ea718cbb 100644 --- a/tests/components/samsungtv/test_device_trigger.py +++ b/tests/components/samsungtv/test_device_trigger.py @@ -11,22 +11,23 @@ from homeassistant.components.samsungtv import DOMAIN, device_trigger from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry -from .test_media_player import ENTITY_ID, MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import MOCK_ENTRYDATA_ENCRYPTED_WS from tests.common import MockConfigEntry, async_get_device_automations @pytest.mark.usefixtures("remoteencws", "rest_api") -async def test_get_triggers(hass: HomeAssistant) -> None: +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test we get the expected triggers.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - device_reg = get_dev_reg(hass) - device = device_reg.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) turn_on_trigger = { "platform": "device", @@ -44,13 +45,13 @@ async def test_get_triggers(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remoteencws", "rest_api") async def test_if_fires_on_turn_on_request( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls: list[ServiceCall] ) -> None: """Test for turn_on and turn_off triggers firing.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + entity_id = "media_player.fake" - device_reg = get_dev_reg(hass) - device = device_reg.async_get_device(identifiers={(DOMAIN, "any")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) assert await async_setup_component( hass, @@ -75,12 +76,12 @@ async def test_if_fires_on_turn_on_request( { "trigger": { "platform": "samsungtv.turn_on", - "entity_id": ENTITY_ID, + "entity_id": entity_id, }, "action": { "service": "test.automation", "data_template": { - "some": ENTITY_ID, + "some": entity_id, "id": "{{ trigger.id }}", }, }, @@ -90,19 +91,21 @@ async def test_if_fires_on_turn_on_request( ) await hass.services.async_call( - "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + "media_player", "turn_on", {"entity_id": entity_id}, blocking=True ) await hass.async_block_till_done() assert len(calls) == 2 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - assert calls[1].data["some"] == ENTITY_ID + assert calls[1].data["some"] == entity_id assert calls[1].data["id"] == 0 @pytest.mark.usefixtures("remoteencws", "rest_api") -async def test_failure_scenarios(hass: HomeAssistant) -> None: +async def test_failure_scenarios( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test failure scenarios.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -126,9 +129,8 @@ async def test_failure_scenarios(hass: HomeAssistant) -> None: entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) entry.add_to_hass(hass) - device_reg = get_dev_reg(hass) - device = device_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={("fake", "fake")} ) diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 2e590518187..7b20002ae5b 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -10,11 +10,11 @@ from homeassistant.core import HomeAssistant from . import setup_samsungtv_entry from .const import ( + MOCK_ENTRY_WS_WITH_MAC, MOCK_ENTRYDATA_ENCRYPTED_WS, SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI, ) -from .test_media_player import MOCK_ENTRY_WS_WITH_MAC from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -42,7 +42,7 @@ async def test_entry_diagnostics( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", - "minor_version": 1, + "minor_version": 2, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -79,7 +79,7 @@ async def test_entry_diagnostics_encrypted( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", - "minor_version": 1, + "minor_version": 2, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -115,7 +115,7 @@ async def test_entry_diagnostics_encrypte_offline( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", - "minor_version": 1, + "minor_version": 2, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 14c85b2c636..479664d4ec0 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -33,10 +33,11 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_samsungtv_entry from .const import ( + MOCK_ENTRY_WS_WITH_MAC, MOCK_ENTRYDATA_ENCRYPTED_WS, MOCK_ENTRYDATA_WS, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, @@ -216,3 +217,54 @@ async def test_incorrectly_formatted_mac_fixed(hass: HomeAssistant) -> None: config_entries = hass.config_entries.async_entries(SAMSUNGTV_DOMAIN) assert len(config_entries) == 1 assert config_entries[0].data[CONF_MAC] == "aa:bb:aa:aa:aa:aa" + + +@pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.xfail +async def test_cleanup_mac( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion +) -> None: + """Test for `none` mac cleanup #103512. + + Reverted due to device registry collisions in #119249 / #119082 + """ + entry = MockConfigEntry( + domain=SAMSUNGTV_DOMAIN, + data=MOCK_ENTRY_WS_WITH_MAC, + entry_id="123456", + unique_id="any", + version=2, + minor_version=1, + ) + entry.add_to_hass(hass) + + # Setup initial device registry, with incorrect MAC + device_registry.async_get_or_create( + config_entry_id="123456", + connections={ + (dr.CONNECTION_NETWORK_MAC, "none"), + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + }, + identifiers={("samsungtv", "any")}, + model="82GXARRS", + name="fake", + ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert device_entries == snapshot + assert device_entries[0].connections == { + (dr.CONNECTION_NETWORK_MAC, "none"), + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + } + + # Run setup, and ensure the NONE mac is removed + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert device_entries == snapshot + assert device_entries[0].connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + } + + assert entry.version == 2 + assert entry.minor_version == 2 diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index db4f3f0e41f..4c7ee0e116d 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -42,7 +42,6 @@ from homeassistant.components.samsungtv.const import ( DOMAIN as SAMSUNGTV_DOMAIN, ENCRYPTED_WEBSOCKET_PORT, METHOD_ENCRYPTED_WEBSOCKET, - METHOD_LEGACY, METHOD_WEBSOCKET, TIMEOUT_WEBSOCKET, ) @@ -82,6 +81,8 @@ import homeassistant.util.dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry from .const import ( + MOCK_CONFIG, + MOCK_ENTRY_WS_WITH_MAC, MOCK_ENTRYDATA_ENCRYPTED_WS, SAMPLE_DEVICE_INFO_FRAME, SAMPLE_DEVICE_INFO_WIFI, @@ -91,12 +92,6 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed ENTITY_ID = f"{DOMAIN}.fake" -MOCK_CONFIG = { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, - CONF_METHOD: METHOD_LEGACY, -} MOCK_CONFIGWS = { CONF_HOST: "fake_host", CONF_NAME: "fake", @@ -123,17 +118,6 @@ MOCK_ENTRY_WS = { } -MOCK_ENTRY_WS_WITH_MAC = { - CONF_IP_ADDRESS: "test", - CONF_HOST: "fake_host", - CONF_METHOD: "websocket", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - CONF_NAME: "fake", - CONF_PORT: 8002, - CONF_TOKEN: "123456789", -} - - @pytest.mark.usefixtures("remote") async def test_setup(hass: HomeAssistant) -> None: """Test setup of platform.""" @@ -568,11 +552,9 @@ async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_VOLUP")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] assert state.state == STATE_ON @@ -599,14 +581,12 @@ async def test_send_key_connection_closed_retry_succeed( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) - # key because of retry two times and update called + # key because of retry two times assert remote.control.call_count == 2 assert remote.control.call_args_list == [ call("KEY_VOLUP"), call("KEY_VOLUP"), ] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] assert state.state == STATE_ON @@ -930,11 +910,9 @@ async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_VOLUP")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: @@ -943,11 +921,9 @@ async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: await hass.services.async_call( DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_VOLDOWN")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: @@ -959,11 +935,9 @@ async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True}, True, ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_MUTE")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: @@ -972,20 +946,16 @@ async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_PLAY")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 2 assert remote.control.call_args_list == [call("KEY_PLAY"), call("KEY_PAUSE")] - assert remote.close.call_count == 2 - assert remote.close.call_args_list == [call(), call()] async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: @@ -994,20 +964,16 @@ async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_PAUSE")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 2 assert remote.control.call_args_list == [call("KEY_PAUSE"), call("KEY_PLAY")] - assert remote.close.call_count == 2 - assert remote.close.call_args_list == [call(), call()] async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: @@ -1016,11 +982,9 @@ async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: await hass.services.async_call( DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_CHUP")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: @@ -1029,11 +993,9 @@ async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_CHDOWN")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] @pytest.mark.usefixtures("remotews", "rest_api") @@ -1048,7 +1010,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() with patch( - "homeassistant.components.samsungtv.media_player.send_magic_packet" + "homeassistant.components.samsungtv.entity.send_magic_packet" ) as mock_send_magic_packet: await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -1060,7 +1022,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: """Test turn on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match="does not support this service"): await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1090,8 +1052,6 @@ async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: call("KEY_6"), call("KEY_ENTER"), ] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] assert sleep.call_count == 3 @@ -1111,10 +1071,8 @@ async def test_play_media_invalid_type(hass: HomeAssistant) -> None: }, True, ) - # only update called + # control not called assert remote.control.call_count == 0 - assert remote.close.call_count == 0 - assert remote.call_count == 1 async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: @@ -1133,10 +1091,8 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: }, True, ) - # only update called + # control not called assert remote.control.call_count == 0 - assert remote.close.call_count == 0 - assert remote.call_count == 1 async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: @@ -1154,10 +1110,8 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: }, True, ) - # only update called + # control not called assert remote.control.call_count == 0 - assert remote.close.call_count == 0 - assert remote.call_count == 1 async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: @@ -1169,11 +1123,9 @@ async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "HDMI"}, True, ) - # key and update called + # key called assert remote.control.call_count == 1 assert remote.control.call_args_list == [call("KEY_HDMI")] - assert remote.close.call_count == 1 - assert remote.close.call_args_list == [call()] async def test_select_source_invalid_source(hass: HomeAssistant) -> None: @@ -1187,10 +1139,8 @@ async def test_select_source_invalid_source(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "INVALID"}, True, ) - # only update called + # control not called assert remote.control.call_count == 0 - assert remote.close.call_count == 0 - assert remote.call_count == 1 @pytest.mark.usefixtures("rest_api") @@ -1369,7 +1319,7 @@ async def test_upnp_shutdown( state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - assert await entry.async_unload(hass) + assert await hass.config_entries.async_unload(entry.entry_id) state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py index 1f9115afca5..98cf712e0d2 100644 --- a/tests/components/samsungtv/test_remote.py +++ b/tests/components/samsungtv/test_remote.py @@ -1,6 +1,6 @@ """The tests for the SamsungTV remote platform.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from samsungtvws.encrypted.remote import SamsungTVEncryptedCommand @@ -10,12 +10,16 @@ from homeassistant.components.remote import ( DOMAIN as REMOTE_DOMAIN, SERVICE_SEND_COMMAND, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.components.samsungtv.const import DOMAIN as SAMSUNGTV_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_samsungtv_entry -from .test_media_player import MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import MOCK_CONFIG, MOCK_ENTRY_WS_WITH_MAC, MOCK_ENTRYDATA_ENCRYPTED_WS + +from tests.common import MockConfigEntry ENTITY_ID = f"{REMOTE_DOMAIN}.fake" @@ -28,12 +32,12 @@ async def test_setup(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remoteencws", "rest_api") -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique id.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) - entity_registry = er.async_get(hass) - main = entity_registry.async_get(ENTITY_ID) assert main.unique_id == "any" @@ -92,3 +96,35 @@ async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> N assert len(commands) == 1 assert isinstance(command := commands[0], SamsungTVEncryptedCommand) assert command.body["param3"] == "dash" + + +@pytest.mark.usefixtures("remotews", "rest_api") +async def test_turn_on_wol(hass: HomeAssistant) -> None: + """Test turn on.""" + entry = MockConfigEntry( + domain=SAMSUNGTV_DOMAIN, + data=MOCK_ENTRY_WS_WITH_MAC, + unique_id="any", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + with patch( + "homeassistant.components.samsungtv.entity.send_magic_packet" + ) as mock_send_magic_packet: + await hass.services.async_call( + REMOTE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + await hass.async_block_till_done() + assert mock_send_magic_packet.called + + +async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: + """Test turn on.""" + await setup_samsungtv_entry(hass, MOCK_CONFIG) + with pytest.raises(HomeAssistantError, match="does not support this service"): + await hass.services.async_call( + REMOTE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + # nothing called as not supported feature + assert remote.control.call_count == 0 diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 0bf57a899a9..6607c60b8e8 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -6,24 +6,30 @@ import pytest from homeassistant.components import automation from homeassistant.components.samsungtv import DOMAIN -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import SERVICE_RELOAD, SERVICE_TURN_ON from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry -from .test_media_player import ENTITY_ID, MOCK_ENTRYDATA_ENCRYPTED_WS +from .const import MOCK_ENTRYDATA_ENCRYPTED_WS from tests.common import MockEntity, MockEntityPlatform @pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_turn_on_trigger_device_id( - hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + entity_domain: str, ) -> None: """Test for turn_on triggers by device_id firing.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + entity_id = f"{entity_domain}.fake" + device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) assert device, repr(device_registry.devices) @@ -50,7 +56,7 @@ async def test_turn_on_trigger_device_id( ) await hass.services.async_call( - "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + entity_domain, SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True ) await hass.async_block_till_done() @@ -65,10 +71,10 @@ async def test_turn_on_trigger_device_id( # Ensure WOL backup is called when trigger not present with patch( - "homeassistant.components.samsungtv.media_player.send_magic_packet" + "homeassistant.components.samsungtv.entity.send_magic_packet" ) as mock_send_magic_packet: await hass.services.async_call( - "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + entity_domain, SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True ) await hass.async_block_till_done() @@ -77,12 +83,15 @@ async def test_turn_on_trigger_device_id( @pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_turn_on_trigger_entity_id( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, calls: list[ServiceCall], entity_domain: str ) -> None: """Test for turn_on triggers by entity_id firing.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + entity_id = f"{entity_domain}.fake" + assert await async_setup_component( hass, automation.DOMAIN, @@ -91,12 +100,12 @@ async def test_turn_on_trigger_entity_id( { "trigger": { "platform": "samsungtv.turn_on", - "entity_id": ENTITY_ID, + "entity_id": entity_id, }, "action": { "service": "test.automation", "data_template": { - "some": ENTITY_ID, + "some": entity_id, "id": "{{ trigger.id }}", }, }, @@ -106,21 +115,23 @@ async def test_turn_on_trigger_entity_id( ) await hass.services.async_call( - "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + entity_domain, SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True ) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data["some"] == ENTITY_ID + assert calls[0].data["some"] == entity_id assert calls[0].data["id"] == 0 @pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_wrong_trigger_platform_type( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, entity_domain: str ) -> None: """Test wrong trigger platform type.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + entity_id = f"{entity_domain}.fake" await async_setup_component( hass, @@ -130,12 +141,12 @@ async def test_wrong_trigger_platform_type( { "trigger": { "platform": "samsungtv.wrong_type", - "entity_id": ENTITY_ID, + "entity_id": entity_id, }, "action": { "service": "test.automation", "data_template": { - "some": ENTITY_ID, + "some": entity_id, "id": "{{ trigger.id }}", }, }, @@ -151,11 +162,13 @@ async def test_wrong_trigger_platform_type( @pytest.mark.usefixtures("remoteencws", "rest_api") +@pytest.mark.parametrize("entity_domain", ["media_player", "remote"]) async def test_trigger_invalid_entity_id( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, entity_domain: str ) -> None: """Test turn on trigger using invalid entity_id.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + entity_id = f"{entity_domain}.fake" platform = MockEntityPlatform(hass) @@ -175,7 +188,7 @@ async def test_trigger_invalid_entity_id( "action": { "service": "test.automation", "data_template": { - "some": ENTITY_ID, + "some": entity_id, "id": "{{ trigger.id }}", }, }, diff --git a/tests/components/sanix/conftest.py b/tests/components/sanix/conftest.py index d1f4424b166..86eaa870770 100644 --- a/tests/components/sanix/conftest.py +++ b/tests/components/sanix/conftest.py @@ -1,6 +1,5 @@ """Sanix tests configuration.""" -from collections.abc import Generator from datetime import datetime from unittest.mock import AsyncMock, patch from zoneinfo import ZoneInfo @@ -17,6 +16,7 @@ from sanix import ( ATTR_API_TIME, ) from sanix.models import Measurement +from typing_extensions import Generator from homeassistant.components.sanix.const import CONF_SERIAL_NUMBER, DOMAIN from homeassistant.const import CONF_TOKEN @@ -67,7 +67,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.sanix.async_setup_entry", diff --git a/tests/components/sanix/test_init.py b/tests/components/sanix/test_init.py index 467737628fe..af3a3615669 100644 --- a/tests/components/sanix/test_init.py +++ b/tests/components/sanix/test_init.py @@ -7,8 +7,9 @@ from unittest.mock import AsyncMock from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry -from tests.components.sanix import setup_integration async def test_load_unload_entry( diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index a878b27614e..5afdebda9da 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -37,8 +37,9 @@ def entities( return entities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_config_yaml_alias_anchor( - hass: HomeAssistant, entities, enable_custom_integrations: None + hass: HomeAssistant, entities: list[MockLight] ) -> None: """Test the usage of YAML aliases and anchors. @@ -84,9 +85,8 @@ async def test_config_yaml_alias_anchor( assert light_2.last_call("turn_on")[1].get("brightness") == 100 -async def test_config_yaml_bool( - hass: HomeAssistant, entities, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_config_yaml_bool(hass: HomeAssistant, entities: list[MockLight]) -> None: """Test parsing of booleans in yaml config.""" light_1, light_2 = await setup_lights(hass, entities) @@ -113,9 +113,8 @@ async def test_config_yaml_bool( assert light_2.last_call("turn_on")[1].get("brightness") == 100 -async def test_activate_scene( - hass: HomeAssistant, entities, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_activate_scene(hass: HomeAssistant, entities: list[MockLight]) -> None: """Test active scene.""" light_1, light_2 = await setup_lights(hass, entities) @@ -167,9 +166,8 @@ async def test_activate_scene( assert calls[0].data.get("transition") == 42 -async def test_restore_state( - hass: HomeAssistant, entities, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_restore_state(hass: HomeAssistant, entities: list[MockLight]) -> None: """Test we restore state integration.""" mock_restore_cache(hass, (State("scene.test", "2021-01-01T23:59:59+00:00"),)) @@ -195,8 +193,9 @@ async def test_restore_state( assert hass.states.get("scene.test").state == "2021-01-01T23:59:59+00:00" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_restore_state_does_not_restore_unavailable( - hass: HomeAssistant, entities, enable_custom_integrations: None + hass: HomeAssistant, entities: list[MockLight] ) -> None: """Test we restore state integration but ignore unavailable.""" mock_restore_cache(hass, (State("scene.test", STATE_UNAVAILABLE),)) diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index ddb98cee39d..c43b2500ccb 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Coroutine from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR @@ -181,7 +182,7 @@ async def test_events_one_day( hass: HomeAssistant, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], caplog: pytest.LogCaptureFixture, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test events only during one day of the week.""" freezer.move_to("2022-08-30 13:20:00-07:00") @@ -225,7 +226,7 @@ async def test_adjacent_cross_midnight( hass: HomeAssistant, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], caplog: pytest.LogCaptureFixture, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test adjacent events don't toggle on->off->on.""" freezer.move_to("2022-08-30 13:20:00-07:00") @@ -286,7 +287,7 @@ async def test_adjacent_within_day( hass: HomeAssistant, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], caplog: pytest.LogCaptureFixture, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test adjacent events don't toggle on->off->on.""" freezer.move_to("2022-08-30 13:20:00-07:00") @@ -349,7 +350,7 @@ async def test_non_adjacent_within_day( hass: HomeAssistant, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], caplog: pytest.LogCaptureFixture, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test adjacent events don't toggle on->off->on.""" freezer.move_to("2022-08-30 13:20:00-07:00") @@ -429,7 +430,7 @@ async def test_to_midnight( schedule_setup: Callable[..., Coroutine[Any, Any, bool]], caplog: pytest.LogCaptureFixture, schedule: list[dict[str, str]], - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test time range allow to 24:00.""" freezer.move_to("2022-08-30 13:20:00-07:00") @@ -516,7 +517,7 @@ async def test_load( async def test_schedule_updates( hass: HomeAssistant, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test the schedule updates when time changes.""" freezer.move_to("2022-08-10 20:10:00-07:00") @@ -569,16 +570,17 @@ async def test_ws_list( async def test_ws_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], ) -> None: """Test WS delete cleans up entity registry.""" - ent_reg = er.async_get(hass) - assert await schedule_setup() state = hass.states.get("schedule.from_storage") assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "from_storage") is not None + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "from_storage") is not None + ) client = await hass_ws_client(hass) await client.send_json( @@ -589,7 +591,7 @@ async def test_ws_delete( state = hass.states.get("schedule.from_storage") assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "from_storage") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "from_storage") is None @pytest.mark.freeze_time("2022-08-10 20:10:00-07:00") @@ -604,14 +606,13 @@ async def test_ws_delete( async def test_update( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], to: str, next_event: str, saved_to: str, ) -> None: """Test updating the schedule.""" - ent_reg = er.async_get(hass) - assert await schedule_setup() state = hass.states.get("schedule.from_storage") @@ -620,7 +621,9 @@ async def test_update( assert state.attributes[ATTR_FRIENDLY_NAME] == "from storage" assert state.attributes[ATTR_ICON] == "mdi:party-popper" assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-08-12T17:00:00-07:00" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "from_storage") is not None + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "from_storage") is not None + ) client = await hass_ws_client(hass) @@ -674,8 +677,9 @@ async def test_update( async def test_ws_create( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], - freezer, + freezer: FrozenDateTimeFactory, to: str, next_event: str, saved_to: str, @@ -683,13 +687,11 @@ async def test_ws_create( """Test create WS.""" freezer.move_to("2022-08-11 8:52:00-07:00") - ent_reg = er.async_get(hass) - assert await schedule_setup(items=[]) state = hass.states.get("schedule.party_mode") assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "party_mode") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "party_mode") is None client = await hass_ws_client(hass) await client.send_json( diff --git a/tests/components/schedule/test_recorder.py b/tests/components/schedule/test_recorder.py index df28730ee79..a7410472a44 100644 --- a/tests/components/schedule/test_recorder.py +++ b/tests/components/schedule/test_recorder.py @@ -4,7 +4,8 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.recorder import Recorder +import pytest + from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.schedule.const import ATTR_NEXT_EVENT, DOMAIN from homeassistant.const import ATTR_EDITABLE, ATTR_FRIENDLY_NAME, ATTR_ICON @@ -16,11 +17,8 @@ from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done -async def test_exclude_attributes( - recorder_mock: Recorder, - hass: HomeAssistant, - enable_custom_integrations: None, -) -> None: +@pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test attributes to be excluded.""" now = dt_util.utcnow() assert await async_setup_component( diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 40d880b73f8..dcb6bc52a7b 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -1,10 +1,10 @@ """Common fixtures for the Schlage tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, create_autospec, patch from pyschlage.lock import Lock import pytest +from typing_extensions import Generator from homeassistant.components.schlage.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -46,7 +46,7 @@ async def mock_added_config_entry( @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.schlage.async_setup_entry", return_value=True diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 5b26da7b27e..6c06f124693 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -14,10 +14,11 @@ from tests.common import async_fire_time_changed async def test_lock_device_registry( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, ) -> None: """Test lock is added to device registry.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={("schlage", "test")}) assert device.model == "" assert device.sw_version == "1.0" diff --git a/tests/components/schlage/test_sensor.py b/tests/components/schlage/test_sensor.py index 775438795ff..2c0cabbb1e8 100644 --- a/tests/components/schlage/test_sensor.py +++ b/tests/components/schlage/test_sensor.py @@ -8,10 +8,11 @@ from homeassistant.helpers import device_registry as dr async def test_sensor_device_registry( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, ) -> None: """Test sensor is added to device registry.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={("schlage", "test")}) assert device.model == "" assert device.sw_version == "1.0" diff --git a/tests/components/schlage/test_switch.py b/tests/components/schlage/test_switch.py index bf74a79b406..f1cded3ce22 100644 --- a/tests/components/schlage/test_switch.py +++ b/tests/components/schlage/test_switch.py @@ -10,10 +10,11 @@ from homeassistant.helpers import device_registry as dr async def test_switch_device_registry( - hass: HomeAssistant, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_added_config_entry: ConfigEntry, ) -> None: """Test switch is added to device registry.""" - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={("schlage", "test")}) assert device.model == "" assert device.sw_version == "1.0" diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index a7181943884..f6109dbc19a 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, patch import uuid import pytest +from typing_extensions import Generator from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD, DEFAULT_VERIFY_SSL @@ -35,7 +35,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Automatically path uuid generator.""" with patch( "homeassistant.components.scrape.async_setup_entry", diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index 09036f213dc..363e30b9269 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -76,15 +76,16 @@ async def test_setup_no_data_fails_with_recovery( assert state.state == "Current Version: 2021.12.10" -async def test_setup_config_no_configuration(hass: HomeAssistant) -> None: +async def test_setup_config_no_configuration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test setup from yaml missing configuration options.""" config = {DOMAIN: None} assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - entities = er.async_get(hass) - assert entities.entities == {} + assert entity_registry.entities == {} async def test_setup_config_no_sensors( @@ -131,15 +132,15 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) async def test_device_remove_devices( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, loaded_entry: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) - registry: er.EntityRegistry = er.async_get(hass) - entity = registry.entities["sensor.current_version"] + entity = entity_registry.entities["sensor.current_version"] - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) client = await hass_ws_client(hass) response = await client.remove_device(device_entry.id, loaded_entry.entry_id) diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index 4d9c2b732dc..d1f2a22d036 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -8,8 +8,6 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.rest.const import DEFAULT_METHOD -from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -139,7 +137,9 @@ async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.MEASUREMENT -async def test_scrape_unique_id(hass: HomeAssistant) -> None: +async def test_scrape_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test Scrape sensor for unique id.""" config = { DOMAIN: return_integration_config( @@ -165,8 +165,7 @@ async def test_scrape_unique_id(hass: HomeAssistant) -> None: state = hass.states.get("sensor.current_temp") assert state.state == "22.1" - registry = er.async_get(hass) - entry = registry.async_get("sensor.current_temp") + entry = entity_registry.async_get("sensor.current_temp") assert entry assert entry.unique_id == "very_unique_id" @@ -449,7 +448,9 @@ async def test_scrape_sensor_errors(hass: HomeAssistant) -> None: assert state2.state == STATE_UNKNOWN -async def test_scrape_sensor_unique_id(hass: HomeAssistant) -> None: +async def test_scrape_sensor_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test Scrape sensor with unique_id.""" config = { DOMAIN: [ @@ -476,22 +477,22 @@ async def test_scrape_sensor_unique_id(hass: HomeAssistant) -> None: state = hass.states.get("sensor.ha_version") assert state.state == "Current Version: 2021.12.10" - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("sensor.ha_version") + entity = entity_registry.async_get("sensor.ha_version") assert entity.unique_id == "ha_version_unique_id" async def test_setup_config_entry( - hass: HomeAssistant, loaded_entry: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + loaded_entry: MockConfigEntry, ) -> None: """Test setup from config entry.""" state = hass.states.get("sensor.current_version") assert state.state == "Current Version: 2021.12.10" - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("sensor.current_version") + entity = entity_registry.async_get("sensor.current_version") assert entity.unique_id == "3699ef88-69e6-11ed-a1eb-0242ac120002" @@ -581,9 +582,9 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None: [ { CONF_RESOURCE: "https://www.home-assistant.io", - CONF_METHOD: DEFAULT_METHOD, + CONF_METHOD: "GET", CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, - CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_TIMEOUT: 10, CONF_ENCODING: DEFAULT_ENCODING, SENSOR_DOMAIN: [ { diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py index e562b84ad14..9c8a21b1ba4 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -10,9 +10,13 @@ MOCK_ADAPTER_MAC = "aa:bb:cc:dd:ee:ff" MOCK_ADAPTER_IP = "127.0.0.1" MOCK_ADAPTER_PORT = 80 +MOCK_CONFIG_ENTRY_ID = "screenlogictest" +MOCK_DEVICE_AREA = "pool" + _LOGGER = logging.getLogger(__name__) +GATEWAY_IMPORT_PATH = "homeassistant.components.screenlogic.ScreenLogicGateway" GATEWAY_DISCOVERY_IMPORT_PATH = "homeassistant.components.screenlogic.coordinator.async_discover_gateways_by_unique_id" @@ -36,6 +40,9 @@ def num_key_string_to_int(data: dict) -> None: DATA_FULL_CHEM = num_key_string_to_int( load_json_object_fixture("screenlogic/data_full_chem.json") ) +DATA_FULL_CHEM_CHLOR = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_chem_chlor.json") +) DATA_FULL_NO_GPM = num_key_string_to_int( load_json_object_fixture("screenlogic/data_full_no_gpm.json") ) diff --git a/tests/components/screenlogic/conftest.py b/tests/components/screenlogic/conftest.py index 7c4d6adf16b..b1c192f0022 100644 --- a/tests/components/screenlogic/conftest.py +++ b/tests/components/screenlogic/conftest.py @@ -5,7 +5,13 @@ import pytest from homeassistant.components.screenlogic import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL -from . import MOCK_ADAPTER_IP, MOCK_ADAPTER_MAC, MOCK_ADAPTER_NAME, MOCK_ADAPTER_PORT +from . import ( + MOCK_ADAPTER_IP, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + MOCK_ADAPTER_PORT, + MOCK_CONFIG_ENTRY_ID, +) from tests.common import MockConfigEntry @@ -24,5 +30,5 @@ def mock_config_entry() -> MockConfigEntry: CONF_SCAN_INTERVAL: 30, }, unique_id=MOCK_ADAPTER_MAC, - entry_id="screenlogictest", + entry_id=MOCK_CONFIG_ENTRY_ID, ) diff --git a/tests/components/screenlogic/fixtures/data_full_chem_chlor.json b/tests/components/screenlogic/fixtures/data_full_chem_chlor.json new file mode 100644 index 00000000000..d80639add55 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_chem_chlor.json @@ -0,0 +1,909 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 98364, + "list": [ + "CHLORINATOR", + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM", + "HYBRID_HEATER" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 7.61, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Pool Low Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060", + "major": 1, + "minor": 60 + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } + } +} diff --git a/tests/components/screenlogic/test_data.py b/tests/components/screenlogic/test_data.py index d17db6c5b33..b0a8bf342f2 100644 --- a/tests/components/screenlogic/test_data.py +++ b/tests/components/screenlogic/test_data.py @@ -22,15 +22,13 @@ from tests.common import MockConfigEntry async def test_async_cleanup_entries( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """Test cleanup of unused entities.""" - mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - device: dr.DeviceEntry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, diff --git a/tests/components/screenlogic/test_diagnostics.py b/tests/components/screenlogic/test_diagnostics.py index 0b587bcd0e5..c6d6ea60e87 100644 --- a/tests/components/screenlogic/test_diagnostics.py +++ b/tests/components/screenlogic/test_diagnostics.py @@ -23,14 +23,13 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" mock_config_entry.add_to_hass(hass) - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py index 6aab9ecec93..6416c93f779 100644 --- a/tests/components/screenlogic/test_init.py +++ b/tests/components/screenlogic/test_init.py @@ -115,17 +115,15 @@ def _migration_connect(*args, **kwargs): ) async def test_async_migrate_entries( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, entity_def: dict, ent_data: EntityMigrationData, ) -> None: """Test migration to new entity names.""" - mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - device: dr.DeviceEntry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, @@ -181,15 +179,13 @@ async def test_async_migrate_entries( async def test_entity_migration_data( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """Test ENTITY_MIGRATION data guards.""" - mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - device: dr.DeviceEntry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, diff --git a/tests/components/screenlogic/test_services.py b/tests/components/screenlogic/test_services.py new file mode 100644 index 00000000000..d175ea27c84 --- /dev/null +++ b/tests/components/screenlogic/test_services.py @@ -0,0 +1,496 @@ +"""Tests for ScreenLogic integration service calls.""" + +from typing import Any +from unittest.mock import DEFAULT, AsyncMock, patch + +import pytest +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.device_const.system import COLOR_MODE +from typing_extensions import AsyncGenerator + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.screenlogic.const import ( + ATTR_COLOR_MODE, + ATTR_CONFIG_ENTRY, + ATTR_RUNTIME, + SERVICE_SET_COLOR_MODE, + SERVICE_START_SUPER_CHLORINATION, + SERVICE_STOP_SUPER_CHLORINATION, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr +from homeassistant.util import slugify + +from . import ( + DATA_FULL_CHEM, + DATA_FULL_CHEM_CHLOR, + DATA_MIN_ENTITY_CLEANUP, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + MOCK_CONFIG_ENTRY_ID, + MOCK_DEVICE_AREA, + stub_async_connect, +) + +from tests.common import MockConfigEntry + +NON_SL_CONFIG_ENTRY_ID = "test" + + +@pytest.fixture(name="dataset") +def dataset_fixture(): + """Define the default dataset for service tests.""" + return DATA_FULL_CHEM + + +@pytest.fixture(name="service_fixture") +async def setup_screenlogic_services_fixture( + hass: HomeAssistant, + request: pytest.FixtureRequest, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> AsyncGenerator[dict[str, Any]]: + """Define the setup for a patched screenlogic integration.""" + data = ( + marker.args[0] + if (marker := request.node.get_closest_marker("dataset")) is not None + else DATA_FULL_CHEM + ) + + def _service_connect(*args, **kwargs): + return stub_async_connect(data, *args, **kwargs) + + mock_config_entry.add_to_hass(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + suggested_area=MOCK_DEVICE_AREA, + ) + + with ( + patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), + patch.multiple( + ScreenLogicGateway, + async_connect=_service_connect, + is_connected=True, + _async_connected_request=DEFAULT, + async_set_color_lights=DEFAULT, + async_set_scg_config=DEFAULT, + ) as gateway, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + yield {"gateway": gateway, "device": device} + + +@pytest.mark.parametrize( + ("data", "target"), + [ + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_OFF.name.lower(), + ATTR_CONFIG_ENTRY: MOCK_CONFIG_ENTRY_ID, + }, + None, + ), + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), + }, + { + ATTR_AREA_ID: MOCK_DEVICE_AREA, + }, + ), + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), + }, + { + ATTR_ENTITY_ID: f"{Platform.SENSOR}.{slugify(f'{MOCK_ADAPTER_NAME} Air Temperature')}", + }, + ), + ], +) +async def test_service_set_color_mode( + hass: HomeAssistant, + service_fixture: dict[str, Any], + data: dict[str, Any], + target: dict[str, Any], +) -> None: + """Test set_color_mode service.""" + + mocked_async_set_color_lights: AsyncMock = service_fixture["gateway"][ + "async_set_color_lights" + ] + + assert hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE) + + non_screenlogic_entry = MockConfigEntry(entry_id="test") + non_screenlogic_entry.add_to_hass(hass) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COLOR_MODE, + service_data=data, + blocking=True, + target=target, + ) + + mocked_async_set_color_lights.assert_awaited_once() + + +async def test_service_set_color_mode_with_device( + hass: HomeAssistant, + service_fixture: dict[str, Any], +) -> None: + """Test set_color_mode service with a device target.""" + mocked_async_set_color_lights: AsyncMock = service_fixture["gateway"][ + "async_set_color_lights" + ] + + assert hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE) + + sl_device: dr.DeviceEntry = service_fixture["device"] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COLOR_MODE, + service_data={ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower()}, + blocking=True, + target={ATTR_DEVICE_ID: sl_device.id}, + ) + + mocked_async_set_color_lights.assert_awaited_once() + + +@pytest.mark.parametrize( + ("data", "target", "error_msg"), + [ + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_OFF.name.lower(), + ATTR_CONFIG_ENTRY: "invalidconfigentry", + }, + None, + f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry " + "'invalidconfigentry' not found", + ), + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_OFF.name.lower(), + ATTR_CONFIG_ENTRY: NON_SL_CONFIG_ENTRY_ID, + }, + None, + f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry " + "'test' is not a screenlogic config", + ), + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), + }, + { + ATTR_AREA_ID: "invalidareaid", + }, + f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for " + "target not found", + ), + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), + }, + { + ATTR_DEVICE_ID: "invaliddeviceid", + }, + f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for " + "target not found", + ), + ( + { + ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), + }, + { + ATTR_ENTITY_ID: "sensor.invalidentityid", + }, + f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for " + "target not found", + ), + ], +) +async def test_service_set_color_mode_error( + hass: HomeAssistant, + service_fixture: dict[str, Any], + data: dict[str, Any], + target: dict[str, Any], + error_msg: str, +) -> None: + """Test set_color_mode service error cases.""" + + mocked_async_set_color_lights: AsyncMock = service_fixture["gateway"][ + "async_set_color_lights" + ] + + non_screenlogic_entry = MockConfigEntry(entry_id=NON_SL_CONFIG_ENTRY_ID) + non_screenlogic_entry.add_to_hass(hass) + + assert hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE) + + with pytest.raises( + ServiceValidationError, + match=error_msg, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COLOR_MODE, + service_data=data, + blocking=True, + target=target, + ) + + mocked_async_set_color_lights.assert_not_awaited() + + +@pytest.mark.dataset(DATA_FULL_CHEM_CHLOR) +@pytest.mark.parametrize( + ("data", "target"), + [ + ( + { + ATTR_CONFIG_ENTRY: MOCK_CONFIG_ENTRY_ID, + ATTR_RUNTIME: 24, + }, + None, + ), + ], +) +async def test_service_start_super_chlorination( + hass: HomeAssistant, + service_fixture: dict[str, Any], + data: dict[str, Any], + target: dict[str, Any], +) -> None: + """Test start_super_chlorination service.""" + + mocked_async_set_scg_config: AsyncMock = service_fixture["gateway"][ + "async_set_scg_config" + ] + + assert hass.services.has_service(DOMAIN, SERVICE_START_SUPER_CHLORINATION) + + await hass.services.async_call( + DOMAIN, + SERVICE_START_SUPER_CHLORINATION, + service_data=data, + blocking=True, + target=target, + ) + + mocked_async_set_scg_config.assert_awaited_once() + + +@pytest.mark.parametrize( + ("data", "target", "error_msg"), + [ + ( + { + ATTR_CONFIG_ENTRY: "invalidconfigentry", + ATTR_RUNTIME: 24, + }, + None, + f"Failed to call service '{SERVICE_START_SUPER_CHLORINATION}'. " + "Config entry 'invalidconfigentry' not found", + ), + ( + { + ATTR_CONFIG_ENTRY: MOCK_CONFIG_ENTRY_ID, + ATTR_RUNTIME: 24, + }, + None, + f"Equipment configuration for {MOCK_ADAPTER_NAME} does not" + f" support {SERVICE_START_SUPER_CHLORINATION}", + ), + ], +) +async def test_service_start_super_chlorination_error( + hass: HomeAssistant, + service_fixture: dict[str, Any], + data: dict[str, Any], + target: dict[str, Any], + error_msg: str, +) -> None: + """Test start_super_chlorination service error cases.""" + + mocked_async_set_scg_config: AsyncMock = service_fixture["gateway"][ + "async_set_scg_config" + ] + + assert hass.services.has_service(DOMAIN, SERVICE_START_SUPER_CHLORINATION) + + with pytest.raises( + ServiceValidationError, + match=error_msg, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_START_SUPER_CHLORINATION, + service_data=data, + blocking=True, + target=target, + ) + + mocked_async_set_scg_config.assert_not_awaited() + + +@pytest.mark.dataset(DATA_FULL_CHEM_CHLOR) +@pytest.mark.parametrize( + ("data", "target"), + [ + ( + { + ATTR_CONFIG_ENTRY: MOCK_CONFIG_ENTRY_ID, + }, + None, + ), + ], +) +async def test_service_stop_super_chlorination( + hass: HomeAssistant, + service_fixture: dict[str, Any], + data: dict[str, Any], + target: dict[str, Any], +) -> None: + """Test stop_super_chlorination service.""" + + mocked_async_set_scg_config: AsyncMock = service_fixture["gateway"][ + "async_set_scg_config" + ] + + assert hass.services.has_service(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION) + + await hass.services.async_call( + DOMAIN, + SERVICE_STOP_SUPER_CHLORINATION, + service_data=data, + blocking=True, + target=target, + ) + + mocked_async_set_scg_config.assert_awaited_once() + + +@pytest.mark.parametrize( + ("data", "target", "error_msg"), + [ + ( + { + ATTR_CONFIG_ENTRY: "invalidconfigentry", + }, + None, + f"Failed to call service '{SERVICE_STOP_SUPER_CHLORINATION}'. " + "Config entry 'invalidconfigentry' not found", + ), + ( + { + ATTR_CONFIG_ENTRY: MOCK_CONFIG_ENTRY_ID, + }, + None, + f"Equipment configuration for {MOCK_ADAPTER_NAME} does not" + f" support {SERVICE_STOP_SUPER_CHLORINATION}", + ), + ], +) +async def test_service_stop_super_chlorination_error( + hass: HomeAssistant, + service_fixture: dict[str, Any], + data: dict[str, Any], + target: dict[str, Any], + error_msg: str, +) -> None: + """Test stop_super_chlorination service error cases.""" + + mocked_async_set_scg_config: AsyncMock = service_fixture["gateway"][ + "async_set_scg_config" + ] + + assert hass.services.has_service(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION) + + with pytest.raises( + ServiceValidationError, + match=error_msg, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_STOP_SUPER_CHLORINATION, + service_data=data, + blocking=True, + target=target, + ) + + mocked_async_set_scg_config.assert_not_awaited() + + +async def test_service_config_entry_not_loaded( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the error case of config not loaded.""" + mock_config_entry.add_to_hass(hass) + + _ = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + mock_set_color_lights = AsyncMock() + + with ( + patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), + patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_MIN_ENTITY_CLEANUP, *args, **kwargs + ), + async_disconnect=DEFAULT, + is_connected=True, + _async_connected_request=DEFAULT, + async_set_color_lights=mock_set_color_lights, + ), + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises( + ServiceValidationError, + match=f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. " + f"Config entry '{MOCK_CONFIG_ENTRY_ID}' not loaded", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COLOR_MODE, + service_data={ + ATTR_COLOR_MODE: COLOR_MODE.ALL_OFF.name.lower(), + ATTR_CONFIG_ENTRY: MOCK_CONFIG_ENTRY_ID, + }, + blocking=True, + ) + + mock_set_color_lights.assert_not_awaited() diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 790ef7e79bc..2352e9c64e6 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -24,6 +24,7 @@ from homeassistant.core import ( Context, CoreState, HomeAssistant, + ServiceCall, State, callback, split_entity_id, @@ -57,7 +58,7 @@ ENTITY_ID = "script.test" @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "script") @@ -374,7 +375,9 @@ async def test_reload_service(hass: HomeAssistant, running) -> None: assert hass.services.has_service(script.DOMAIN, "test") -async def test_reload_unchanged_does_not_stop(hass: HomeAssistant, calls) -> None: +async def test_reload_unchanged_does_not_stop( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test that reloading stops any running actions as appropriate.""" test_entity = "test.entity" @@ -461,7 +464,7 @@ async def test_reload_unchanged_does_not_stop(hass: HomeAssistant, calls) -> Non ], ) async def test_reload_unchanged_script( - hass: HomeAssistant, calls, script_config + hass: HomeAssistant, calls: list[ServiceCall], script_config ) -> None: """Test an unmodified script is not reloaded.""" with patch( @@ -888,7 +891,9 @@ async def test_extraction_functions( assert script.blueprint_in_script(hass, "script.test3") is None -async def test_config_basic(hass: HomeAssistant) -> None: +async def test_config_basic( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test passing info in config.""" assert await async_setup_component( hass, @@ -908,8 +913,7 @@ async def test_config_basic(hass: HomeAssistant) -> None: assert test_script.name == "Script Name" assert test_script.attributes["icon"] == "mdi:party" - registry = er.async_get(hass) - entry = registry.async_get("script.test_script") + entry = entity_registry.async_get("script.test_script") assert entry assert entry.unique_id == "test_script" @@ -1503,11 +1507,12 @@ async def test_websocket_config( assert msg["error"]["code"] == "not_found" -async def test_script_service_changed_entity_id(hass: HomeAssistant) -> None: +async def test_script_service_changed_entity_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the script service works for scripts with overridden entity_id.""" - entity_reg = er.async_get(hass) - entry = entity_reg.async_get_or_create("script", "script", "test") - entry = entity_reg.async_update_entity( + entry = entity_registry.async_get_or_create("script", "script", "test") + entry = entity_registry.async_update_entity( entry.entity_id, new_entity_id="script.custom_entity_id" ) assert entry.entity_id == "script.custom_entity_id" @@ -1545,7 +1550,7 @@ async def test_script_service_changed_entity_id(hass: HomeAssistant) -> None: assert calls[0].data["entity_id"] == "script.custom_entity_id" # Change entity while the script entity is loaded, and make sure the service still works - entry = entity_reg.async_update_entity( + entry = entity_registry.async_update_entity( entry.entity_id, new_entity_id="script.custom_entity_id_2" ) assert entry.entity_id == "script.custom_entity_id_2" @@ -1558,7 +1563,9 @@ async def test_script_service_changed_entity_id(hass: HomeAssistant) -> None: assert calls[1].data["entity_id"] == "script.custom_entity_id_2" -async def test_blueprint_automation(hass: HomeAssistant, calls) -> None: +async def test_blueprint_automation( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test blueprint script.""" assert await async_setup_component( hass, @@ -1741,3 +1748,46 @@ async def test_responses_no_response(hass: HomeAssistant) -> None: ) is None ) + + +async def test_script_queued_mode(hass: HomeAssistant) -> None: + """Test calling a queued mode script called in parallel.""" + calls = 0 + + async def async_service_handler(*args, **kwargs) -> None: + """Service that simulates doing background I/O.""" + nonlocal calls + calls += 1 + await asyncio.sleep(0) + + hass.services.async_register("test", "simulated_remote", async_service_handler) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test_main": { + "sequence": [ + { + "parallel": [ + {"service": "script.test_sub"}, + {"service": "script.test_sub"}, + {"service": "script.test_sub"}, + {"service": "script.test_sub"}, + ] + } + ] + }, + "test_sub": { + "mode": "queued", + "sequence": [ + {"service": "test.simulated_remote"}, + ], + }, + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call("script", "test_main", blocking=True) + assert calls == 4 diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index 465d287318d..ca915cede6f 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -15,7 +15,7 @@ from homeassistant.components.script import ( ATTR_MODE, ) from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -24,13 +24,13 @@ from tests.components.recorder.common import async_wait_recording_done @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") async def test_exclude_attributes( - recorder_mock: Recorder, hass: HomeAssistant, calls + recorder_mock: Recorder, hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test automation registered attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/season/conftest.py b/tests/components/season/conftest.py index b0b4f1058d9..a45a2078d9b 100644 --- a/tests/components/season/conftest.py +++ b/tests/components/season/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import patch import pytest +from typing_extensions import Generator from homeassistant.components.season.const import DOMAIN, TYPE_ASTRONOMICAL from homeassistant.const import CONF_TYPE @@ -25,7 +25,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.season.async_setup_entry", return_value=True): yield diff --git a/tests/components/season/test_sensor.py b/tests/components/season/test_sensor.py index dd42ad6ce1c..ffc8e9f1a07 100644 --- a/tests/components/season/test_sensor.py +++ b/tests/components/season/test_sensor.py @@ -75,6 +75,7 @@ def idfn(val): @pytest.mark.parametrize(("type", "day", "expected"), NORTHERN_PARAMETERS, ids=idfn) async def test_season_northern_hemisphere( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, type: str, day: datetime, @@ -97,7 +98,6 @@ async def test_season_northern_hemisphere( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM assert state.attributes[ATTR_OPTIONS] == ["spring", "summer", "autumn", "winter"] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.season") assert entry assert entry.unique_id == mock_config_entry.entry_id @@ -107,6 +107,8 @@ async def test_season_northern_hemisphere( @pytest.mark.parametrize(("type", "day", "expected"), SOUTHERN_PARAMETERS, ids=idfn) async def test_season_southern_hemisphere( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, type: str, day: datetime, @@ -129,13 +131,11 @@ async def test_season_southern_hemisphere( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM assert state.attributes[ATTR_OPTIONS] == ["spring", "summer", "autumn", "winter"] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.season") assert entry assert entry.unique_id == mock_config_entry.entry_id assert entry.translation_key == "season" - device_registry = dr.async_get(hass) assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry @@ -146,6 +146,7 @@ async def test_season_southern_hemisphere( async def test_season_equator( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """Test that season should be unknown for equator.""" @@ -160,7 +161,6 @@ async def test_season_equator( assert state assert state.state == STATE_UNKNOWN - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.season") assert entry assert entry.unique_id == mock_config_entry.entry_id diff --git a/tests/components/select/conftest.py b/tests/components/select/conftest.py index 700749f9aba..6e789f88573 100644 --- a/tests/components/select/conftest.py +++ b/tests/components/select/conftest.py @@ -2,7 +2,7 @@ import pytest -from tests.components.select.common import MockSelectEntity +from .common import MockSelectEntity @pytest.fixture diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index c83e2585d5b..0ffb860179d 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -47,13 +47,13 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in [ + for action in ( "select_first", "select_last", "select_next", "select_option", "select_previous", - ] + ) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -101,13 +101,13 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in [ + for action in ( "select_first", "select_last", "select_next", "select_option", "select_previous", - ] + ) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index 526ad678c19..e60df688658 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -105,7 +105,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["selected_option"] + for condition in ("selected_option",) ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index e587e125e11..c7a55c56202 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -105,7 +105,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["current_option_changed"] + for trigger in ("current_option_changed",) ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -117,7 +117,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -239,7 +239,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index 24653e6b7c7..61b62226679 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -15,9 +15,9 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 55d404b8331..6b4aedab828 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -832,9 +832,9 @@ async def test_climate_no_fan_no_swing( assert state.attributes["swing_modes"] is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_set_timer( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -947,9 +947,9 @@ async def test_climate_set_timer( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_pure_boost( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1058,9 +1058,9 @@ async def test_climate_pure_boost( assert state4.state == "s" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_climate_react( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1228,9 +1228,9 @@ async def test_climate_climate_react( assert state4.state == "temperature" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_climate_react_fahrenheit( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1374,9 +1374,9 @@ async def test_climate_climate_react_fahrenheit( assert state4.state == "temperature" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate_full_ac_state( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_entity.py b/tests/components/sensibo/test_entity.py index 071e5473e5c..e17877b63b1 100644 --- a/tests/components/sensibo/test_entity.py +++ b/tests/components/sensibo/test_entity.py @@ -21,24 +21,26 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er async def test_entity( - hass: HomeAssistant, load_int: ConfigEntry, get_data: SensiboData + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + load_int: ConfigEntry, + get_data: SensiboData, ) -> None: """Test the Sensibo climate.""" state1 = hass.states.get("climate.hallway") assert state1 - dr_reg = dr.async_get(hass) - dr_entries = dr.async_entries_for_config_entry(dr_reg, load_int.entry_id) + dr_entries = dr.async_entries_for_config_entry(device_registry, load_int.entry_id) dr_entry: dr.DeviceEntry for dr_entry in dr_entries: if dr_entry.name == "Hallway": assert dr_entry.identifiers == {("sensibo", "ABC999111")} device_id = dr_entry.id - er_reg = er.async_get(hass) er_entries = er.async_entries_for_device( - er_reg, device_id, include_disabled_entities=True + entity_registry, device_id, include_disabled_entities=True ) er_entry: er.RegistryEntry for er_entry in er_entries: diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 7138da9191f..2938d4ede0e 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -154,15 +154,15 @@ async def test_unload_entry(hass: HomeAssistant, get_data: SensiboData) -> None: async def test_device_remove_devices( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, load_int: ConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) - registry: er.EntityRegistry = er.async_get(hass) - entity = registry.entities["climate.hallway"] + entity = entity_registry.entities["climate.hallway"] - device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) client = await hass_ws_client(hass) response = await client.remove_device(device_entry.id, load_int.entry_id) diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index e0a5a6a8bde..de369698f50 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -22,9 +22,9 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -52,9 +52,9 @@ async def test_number( assert state1.state == "0.2" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_set_value( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, get_data: SensiboData, ) -> None: diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 4e254568ac4..3c6fb584a6e 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -16,9 +16,9 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensirion_ble/test_config_flow.py b/tests/components/sensirion_ble/test_config_flow.py index 00e92d37118..a94f4f737e2 100644 --- a/tests/components/sensirion_ble/test_config_flow.py +++ b/tests/components/sensirion_ble/test_config_flow.py @@ -19,7 +19,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Mock bluetooth for all tests in this module.""" diff --git a/tests/components/sensirion_ble/test_sensor.py b/tests/components/sensirion_ble/test_sensor.py index 35e13a4133c..cc95303a4ee 100644 --- a/tests/components/sensirion_ble/test_sensor.py +++ b/tests/components/sensirion_ble/test_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from homeassistant.components.sensirion_ble.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT @@ -13,7 +15,8 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors(enable_bluetooth: None, hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_bluetooth") +async def test_sensors(hass: HomeAssistant) -> None: """Test the Sensirion BLE sensors.""" entry = MockConfigEntry(domain=DOMAIN, unique_id=SENSIRION_SERVICE_INFO.address) entry.add_to_hass(hass) @@ -29,11 +32,11 @@ async def test_sensors(enable_bluetooth: None, hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all()) >= 3 - for sensor, value, unit, state_class in [ + for sensor, value, unit, state_class in ( ("carbon_dioxide", "724", "ppm", "measurement"), ("humidity", "27.8", "%", "measurement"), ("temperature", "20.1", "°C", "measurement"), - ]: + ): state = hass.states.get(f"sensor.{CONFIGURED_PREFIX}_{sensor}") assert state is not None assert state.state == value diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 2a142633ab3..3bc9a660e93 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -21,6 +21,8 @@ from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component from homeassistant.util.json import load_json +from .common import UNITS_OF_MEASUREMENT, MockSensor + from tests.common import ( MockConfigEntry, async_get_device_automation_capabilities, @@ -28,7 +30,6 @@ from tests.common import ( async_mock_service, setup_test_component_platform, ) -from tests.components.sensor.common import UNITS_OF_MEASUREMENT, MockSensor @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -57,6 +58,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.BATTERY: "CONF_IS_BATTERY_LEVEL", SensorDeviceClass.CO: "CONF_IS_CO", SensorDeviceClass.CO2: "CONF_IS_CO2", + SensorDeviceClass.CONDUCTIVITY: "CONF_IS_CONDUCTIVITY", SensorDeviceClass.ENERGY_STORAGE: "CONF_IS_ENERGY", SensorDeviceClass.VOLUME_STORAGE: "CONF_IS_VOLUME", }.get(device_class, f"CONF_IS_{device_class.value.upper()}") @@ -65,6 +67,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: # Ensure it has correct value constant_value = { SensorDeviceClass.BATTERY: "is_battery_level", + SensorDeviceClass.CONDUCTIVITY: "is_conductivity", SensorDeviceClass.ENERGY_STORAGE: "is_energy", SensorDeviceClass.VOLUME_STORAGE: "is_volume", }.get(device_class, f"is_{device_class.value}") @@ -170,7 +173,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_value"] + for condition in ("is_value",) ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -462,13 +465,13 @@ async def test_get_condition_capabilities_none( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_not_above_below( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, ) -> None: """Test for bad value conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -505,12 +508,12 @@ async def test_if_state_not_above_below( assert "must contain at least one of below, above" in caplog.text +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_above( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -574,12 +577,12 @@ async def test_if_state_above( assert calls[0].data["some"] == "event - test_event1" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_above_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -643,12 +646,12 @@ async def test_if_state_above_legacy( assert calls[0].data["some"] == "event - test_event1" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_below( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -712,12 +715,12 @@ async def test_if_state_below( assert calls[0].data["some"] == "event - test_event1" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_between( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 49e00a927b4..87a6d9929c3 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -24,6 +24,8 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.json import load_json +from .common import UNITS_OF_MEASUREMENT, MockSensor + from tests.common import ( MockConfigEntry, async_fire_time_changed, @@ -32,7 +34,6 @@ from tests.common import ( async_mock_service, setup_test_component_platform, ) -from tests.components.sensor.common import UNITS_OF_MEASUREMENT, MockSensor @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -61,6 +62,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.BATTERY: "CONF_BATTERY_LEVEL", SensorDeviceClass.CO: "CONF_CO", SensorDeviceClass.CO2: "CONF_CO2", + SensorDeviceClass.CONDUCTIVITY: "CONF_CONDUCTIVITY", SensorDeviceClass.ENERGY_STORAGE: "CONF_ENERGY", SensorDeviceClass.VOLUME_STORAGE: "CONF_VOLUME", }.get(device_class, f"CONF_{device_class.value.upper()}") @@ -69,6 +71,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: # Ensure it has correct value constant_value = { SensorDeviceClass.BATTERY: "battery_level", + SensorDeviceClass.CONDUCTIVITY: "conductivity", SensorDeviceClass.ENERGY_STORAGE: "energy", SensorDeviceClass.VOLUME_STORAGE: "volume", }.get(device_class, device_class.value) @@ -172,7 +175,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["value"] + for trigger in ("value",) ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -419,13 +422,13 @@ async def test_get_trigger_capabilities_none( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_not_on_above_below( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -459,12 +462,12 @@ async def test_if_fires_not_on_above_below( assert "must contain at least one of below, above" in caplog.text +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_above( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -524,12 +527,12 @@ async def test_if_fires_on_state_above( ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_below( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -589,12 +592,12 @@ async def test_if_fires_on_state_below( ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_between( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -666,12 +669,12 @@ async def test_if_fires_on_state_between( ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for value triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -731,12 +734,12 @@ async def test_if_fires_on_state_legacy( ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 079984476b0..126e327f364 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2,14 +2,13 @@ from __future__ import annotations -from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal -import logging from types import ModuleType from typing import Any import pytest +from typing_extensions import Generator from homeassistant.components import sensor from homeassistant.components.number import NumberDeviceClass @@ -51,6 +50,8 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM +from .common import MockRestoreSensor, MockSensor + from tests.common import ( MockConfigEntry, MockEntityPlatform, @@ -65,7 +66,6 @@ from tests.common import ( mock_restore_cache_with_extra_data, setup_test_component_platform, ) -from tests.components.sensor.common import MockRestoreSensor, MockSensor TEST_DOMAIN = "test" @@ -603,6 +603,7 @@ async def test_restore_sensor_restore_state( ) async def test_custom_unit( hass: HomeAssistant, + entity_registry: er.EntityRegistry, device_class, native_unit, custom_unit, @@ -611,8 +612,6 @@ async def test_custom_unit( custom_state, ) -> None: """Test custom unit.""" - entity_registry = er.async_get(hass) - entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") entity_registry.async_update_entity_options( entry.entity_id, "sensor", {"unit_of_measurement": custom_unit} @@ -863,6 +862,7 @@ async def test_custom_unit( ) async def test_custom_unit_change( hass: HomeAssistant, + entity_registry: er.EntityRegistry, native_unit, custom_unit, state_unit, @@ -872,7 +872,6 @@ async def test_custom_unit_change( device_class, ) -> None: """Test custom unit changes are picked up.""" - entity_registry = er.async_get(hass) entity0 = MockSensor( name="Test", native_value=str(native_value), @@ -948,6 +947,7 @@ async def test_custom_unit_change( ) async def test_unit_conversion_priority( hass: HomeAssistant, + entity_registry: er.EntityRegistry, unit_system, native_unit, automatic_unit, @@ -964,8 +964,6 @@ async def test_unit_conversion_priority( hass.config.units = unit_system - entity_registry = er.async_get(hass) - entity0 = MockSensor( name="Test", device_class=device_class, @@ -1095,6 +1093,7 @@ async def test_unit_conversion_priority( ) async def test_unit_conversion_priority_precision( hass: HomeAssistant, + entity_registry: er.EntityRegistry, unit_system, native_unit, automatic_unit, @@ -1112,8 +1111,6 @@ async def test_unit_conversion_priority_precision( hass.config.units = unit_system - entity_registry = er.async_get(hass) - entity0 = MockSensor( name="Test", device_class=device_class, @@ -1280,6 +1277,7 @@ async def test_unit_conversion_priority_precision( ) async def test_unit_conversion_priority_suggested_unit_change( hass: HomeAssistant, + entity_registry: er.EntityRegistry, unit_system, native_unit, original_unit, @@ -1292,8 +1290,6 @@ async def test_unit_conversion_priority_suggested_unit_change( hass.config.units = unit_system - entity_registry = er.async_get(hass) - # Pre-register entities entry = entity_registry.async_get_or_create( "sensor", "test", "very_unique", unit_of_measurement=original_unit @@ -1387,6 +1383,7 @@ async def test_unit_conversion_priority_suggested_unit_change( ) async def test_unit_conversion_priority_suggested_unit_change_2( hass: HomeAssistant, + entity_registry: er.EntityRegistry, native_unit_1, native_unit_2, suggested_unit, @@ -1398,8 +1395,6 @@ async def test_unit_conversion_priority_suggested_unit_change_2( hass.config.units = METRIC_SYSTEM - entity_registry = er.async_get(hass) - # Pre-register entities entity_registry.async_get_or_create( "sensor", "test", "very_unique", unit_of_measurement=native_unit_1 @@ -1486,6 +1481,7 @@ async def test_unit_conversion_priority_suggested_unit_change_2( ) async def test_suggested_precision_option( hass: HomeAssistant, + entity_registry: er.EntityRegistry, unit_system, native_unit, integration_suggested_precision, @@ -1498,7 +1494,6 @@ async def test_suggested_precision_option( hass.config.units = unit_system - entity_registry = er.async_get(hass) entity0 = MockSensor( name="Test", device_class=device_class, @@ -1560,6 +1555,7 @@ async def test_suggested_precision_option( ) async def test_suggested_precision_option_update( hass: HomeAssistant, + entity_registry: er.EntityRegistry, unit_system, native_unit, suggested_unit, @@ -1574,8 +1570,6 @@ async def test_suggested_precision_option_update( hass.config.units = unit_system - entity_registry = er.async_get(hass) - # Pre-register entities entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") entity_registry.async_update_entity_options( @@ -1620,11 +1614,9 @@ async def test_suggested_precision_option_update( async def test_suggested_precision_option_removal( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test suggested precision stored in the registry is removed.""" - - entity_registry = er.async_get(hass) - # Pre-register entities entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") entity_registry.async_update_entity_options( @@ -1684,6 +1676,7 @@ async def test_suggested_precision_option_removal( ) async def test_unit_conversion_priority_legacy_conversion_removed( hass: HomeAssistant, + entity_registry: er.EntityRegistry, unit_system, native_unit, original_unit, @@ -1695,8 +1688,6 @@ async def test_unit_conversion_priority_legacy_conversion_removed( hass.config.units = unit_system - entity_registry = er.async_get(hass) - # Pre-register entities entity_registry.async_get_or_create( "sensor", "test", "very_unique", unit_of_measurement=original_unit @@ -2187,6 +2178,7 @@ async def test_numeric_state_expected_helper( ) async def test_unit_conversion_update( hass: HomeAssistant, + entity_registry: er.EntityRegistry, unit_system_1, unit_system_2, native_unit, @@ -2205,8 +2197,6 @@ async def test_unit_conversion_update( hass.config.units = unit_system_1 - entity_registry = er.async_get(hass) - entity0 = MockSensor( name="Test 0", device_class=device_class, @@ -2394,7 +2384,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -2409,7 +2399,9 @@ async def test_name(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, SENSOR_DOMAIN) + await hass.config_entries.async_forward_entry_setups( + config_entry, [SENSOR_DOMAIN] + ) return True mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -2491,13 +2483,12 @@ def test_async_rounded_state_unregistered_entity_is_passthrough( def test_async_rounded_state_registered_entity_with_display_precision( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ) -> None: """Test async_rounded_state on registered with display precision. The -0 should be dropped. """ - entity_registry = er.async_get(hass) - entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") entity_registry.async_update_entity_options( entry.entity_id, @@ -2618,6 +2609,7 @@ def test_deprecated_constants_sensor_device_class( ) async def test_suggested_unit_guard_invalid_unit( hass: HomeAssistant, + entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, device_class: SensorDeviceClass, native_unit: str, @@ -2626,8 +2618,6 @@ async def test_suggested_unit_guard_invalid_unit( An invalid suggested unit creates a log entry and the suggested unit will be ignored. """ - entity_registry = er.async_get(hass) - state_value = 10 invalid_suggested_unit = "invalid_unit" @@ -2643,25 +2633,13 @@ async def test_suggested_unit_guard_invalid_unit( assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) await hass.async_block_till_done() - # Unit of measurement should be native one - state = hass.states.get(entity.entity_id) - assert int(state.state) == state_value - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit + assert not hass.states.get("sensor.invalid") + assert not entity_registry.async_get("sensor.invalid") - # Assert the suggested unit is ignored and not stored in the entity registry - entry = entity_registry.async_get(entity.entity_id) - assert entry.unit_of_measurement == native_unit - assert entry.options == {} assert ( - "homeassistant.components.sensor", - logging.WARNING, - ( - " sets an" - " invalid suggested_unit_of_measurement. Please create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test%22." - " This warning will become an error in Home Assistant Core 2024.5" - ), - ) in caplog.record_tuples + "Entity suggest an incorrect unit of measurement: invalid_unit" + in caplog.text + ) @pytest.mark.parametrize( @@ -2685,6 +2663,7 @@ async def test_suggested_unit_guard_invalid_unit( ) async def test_suggested_unit_guard_valid_unit( hass: HomeAssistant, + entity_registry: er.EntityRegistry, device_class: SensorDeviceClass, native_unit: str, native_value: int, @@ -2696,8 +2675,6 @@ async def test_suggested_unit_guard_valid_unit( Suggested unit is valid and therefore should be used for unit conversion and stored in the entity registry. """ - entity_registry = er.async_get(hass) - entity = MockSensor( name="Valid", device_class=device_class, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index ec43d81fc4a..62cb66d2053 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -41,6 +41,8 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM +from .common import MockSensor + from tests.common import setup_test_component_platform from tests.components.recorder.common import ( assert_dict_of_states_equal_without_context_and_last_changed, @@ -50,7 +52,6 @@ from tests.components.recorder.common import ( do_adhoc_statistics, statistics_during_period, ) -from tests.components.sensor.common import MockSensor from tests.typing import RecorderInstanceGenerator, WebSocketGenerator BATTERY_SENSOR_ATTRIBUTES = { @@ -2412,7 +2413,7 @@ async def test_list_statistic_ids( "unit_class": unit_class, }, ] - for stat_type in ["mean", "sum", "dogs"]: + for stat_type in ("mean", "sum", "dogs"): statistic_ids = await async_list_statistic_ids(hass, statistic_type=stat_type) if statistic_type == stat_type: assert statistic_ids == [ @@ -3741,69 +3742,62 @@ async def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test4": None, } start = zero - with freeze_time(start) as freezer: - for i in range(24): - seq = [-10, 15, 30] - # test1 has same value in every period - four, _states = await async_record_states( - hass, freezer, start, "sensor.test1", attributes, seq + for i in range(24): + seq = [-10, 15, 30] + # test1 has same value in every period + four, _states = await async_record_states( + hass, freezer, start, "sensor.test1", attributes, seq + ) + states["sensor.test1"] += _states["sensor.test1"] + last_state = last_states["sensor.test1"] + expected_minima["sensor.test1"].append(_min(seq, last_state)) + expected_maxima["sensor.test1"].append(_max(seq, last_state)) + expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test1"] = seq[-1] + # test2 values change: min/max at the last state + seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] + four, _states = await async_record_states( + hass, freezer, start, "sensor.test2", attributes, seq + ) + states["sensor.test2"] += _states["sensor.test2"] + last_state = last_states["sensor.test2"] + expected_minima["sensor.test2"].append(_min(seq, last_state)) + expected_maxima["sensor.test2"].append(_max(seq, last_state)) + expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test2"] = seq[-1] + # test3 values change: min/max at the first state + seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] + four, _states = await async_record_states( + hass, freezer, start, "sensor.test3", attributes, seq + ) + states["sensor.test3"] += _states["sensor.test3"] + last_state = last_states["sensor.test3"] + expected_minima["sensor.test3"].append(_min(seq, last_state)) + expected_maxima["sensor.test3"].append(_max(seq, last_state)) + expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) + last_states["sensor.test3"] = seq[-1] + # test4 values grow + seq = [i, i + 0.5, i + 0.75] + start_meter = start + for j in range(len(seq)): + _states = await async_record_meter_state( + hass, + freezer, + start_meter, + "sensor.test4", + sum_attributes, + seq[j : j + 1], ) - states["sensor.test1"] += _states["sensor.test1"] - last_state = last_states["sensor.test1"] - expected_minima["sensor.test1"].append(_min(seq, last_state)) - expected_maxima["sensor.test1"].append(_max(seq, last_state)) - expected_averages["sensor.test1"].append( - _weighted_average(seq, i, last_state) - ) - last_states["sensor.test1"] = seq[-1] - # test2 values change: min/max at the last state - seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] - four, _states = await async_record_states( - hass, freezer, start, "sensor.test2", attributes, seq - ) - states["sensor.test2"] += _states["sensor.test2"] - last_state = last_states["sensor.test2"] - expected_minima["sensor.test2"].append(_min(seq, last_state)) - expected_maxima["sensor.test2"].append(_max(seq, last_state)) - expected_averages["sensor.test2"].append( - _weighted_average(seq, i, last_state) - ) - last_states["sensor.test2"] = seq[-1] - # test3 values change: min/max at the first state - seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] - four, _states = await async_record_states( - hass, freezer, start, "sensor.test3", attributes, seq - ) - states["sensor.test3"] += _states["sensor.test3"] - last_state = last_states["sensor.test3"] - expected_minima["sensor.test3"].append(_min(seq, last_state)) - expected_maxima["sensor.test3"].append(_max(seq, last_state)) - expected_averages["sensor.test3"].append( - _weighted_average(seq, i, last_state) - ) - last_states["sensor.test3"] = seq[-1] - # test4 values grow - seq = [i, i + 0.5, i + 0.75] - start_meter = start - for j in range(len(seq)): - _states = await async_record_meter_state( - hass, - freezer, - start_meter, - "sensor.test4", - sum_attributes, - seq[j : j + 1], - ) - start_meter += timedelta(minutes=1) - states["sensor.test4"] += _states["sensor.test4"] - last_state = last_states["sensor.test4"] - expected_states["sensor.test4"].append(seq[-1]) - expected_sums["sensor.test4"].append( - _sum(seq, last_state, expected_sums["sensor.test4"]) - ) - last_states["sensor.test4"] = seq[-1] + start_meter += timedelta(minutes=1) + states["sensor.test4"] += _states["sensor.test4"] + last_state = last_states["sensor.test4"] + expected_states["sensor.test4"].append(seq[-1]) + expected_sums["sensor.test4"].append( + _sum(seq, last_state, expected_sums["sensor.test4"]) + ) + last_states["sensor.test4"] = seq[-1] - start += timedelta(minutes=5) + start += timedelta(minutes=5) await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -3886,12 +3880,12 @@ async def test_compile_statistics_hourly_daily_monthly_summary( start = zero end = zero + timedelta(minutes=5) for i in range(24): - for entity_id in [ + for entity_id in ( "sensor.test1", "sensor.test2", "sensor.test3", "sensor.test4", - ]: + ): expected_average = ( expected_averages[entity_id][i] if entity_id in expected_averages @@ -3935,12 +3929,12 @@ async def test_compile_statistics_hourly_daily_monthly_summary( start = zero end = zero + timedelta(hours=1) for i in range(2): - for entity_id in [ + for entity_id in ( "sensor.test1", "sensor.test2", "sensor.test3", "sensor.test4", - ]: + ): expected_average = ( mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) if entity_id in expected_averages @@ -3992,12 +3986,12 @@ async def test_compile_statistics_hourly_daily_monthly_summary( start = dt_util.parse_datetime("2021-08-31T06:00:00+00:00") end = start + timedelta(days=1) for i in range(2): - for entity_id in [ + for entity_id in ( "sensor.test1", "sensor.test2", "sensor.test3", "sensor.test4", - ]: + ): expected_average = ( mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) if entity_id in expected_averages @@ -4049,12 +4043,12 @@ async def test_compile_statistics_hourly_daily_monthly_summary( start = dt_util.parse_datetime("2021-08-01T06:00:00+00:00") end = dt_util.parse_datetime("2021-09-01T06:00:00+00:00") for i in range(2): - for entity_id in [ + for entity_id in ( "sensor.test1", "sensor.test2", "sensor.test3", "sensor.test4", - ]: + ): expected_average = ( mean(expected_averages[entity_id][i * 12 : (i + 1) * 12]) if entity_id in expected_averages @@ -4785,10 +4779,10 @@ async def test_validate_statistics_unit_change_no_conversion( with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(StatisticsMeta)) assert len(db_states) == len(expected_result) - for i in range(len(db_states)): - assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + for i, db_state in enumerate(db_states): + assert db_state.statistic_id == expected_result[i]["statistic_id"] assert ( - db_states[i].unit_of_measurement + db_state.unit_of_measurement == expected_result[i]["unit_of_measurement"] ) @@ -4919,10 +4913,10 @@ async def test_validate_statistics_unit_change_equivalent_units( with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(StatisticsMeta)) assert len(db_states) == len(expected_result) - for i in range(len(db_states)): - assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + for i, db_state in enumerate(db_states): + assert db_state.statistic_id == expected_result[i]["statistic_id"] assert ( - db_states[i].unit_of_measurement + db_state.unit_of_measurement == expected_result[i]["unit_of_measurement"] ) @@ -5004,10 +4998,10 @@ async def test_validate_statistics_unit_change_equivalent_units_2( with session_scope(hass=hass, read_only=True) as session: db_states = list(session.query(StatisticsMeta)) assert len(db_states) == len(expected_result) - for i in range(len(db_states)): - assert db_states[i].statistic_id == expected_result[i]["statistic_id"] + for i, db_state in enumerate(db_states): + assert db_state.statistic_id == expected_result[i]["statistic_id"] assert ( - db_states[i].unit_of_measurement + db_state.unit_of_measurement == expected_result[i]["unit_of_measurement"] ) @@ -5234,9 +5228,8 @@ async def async_record_states_partially_unavailable(hass, zero, entity_id, attri return four, states -async def test_exclude_attributes( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_exclude_attributes(hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" entity0 = MockSensor( has_entity_name=True, diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py index 88c98e6589f..d770c459426 100644 --- a/tests/components/sensor/test_recorder_missing_stats.py +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -2,11 +2,13 @@ from datetime import datetime, timedelta from pathlib import Path +import threading from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.statistics import ( get_latest_short_term_statistics_with_session, @@ -57,6 +59,7 @@ def test_compile_missing_statistics( recorder_helper.async_initialize_recorder(hass) setup_component(hass, "sensor", {}) setup_component(hass, "recorder", {"recorder": config}) + get_instance(hass).recorder_and_worker_thread_ids.add(threading.get_ident()) hass.start() wait_recording_done(hass) wait_recording_done(hass) @@ -98,6 +101,7 @@ def test_compile_missing_statistics( setup_component(hass, "sensor", {}) hass.states.set("sensor.test1", "0", POWER_SENSOR_ATTRIBUTES) setup_component(hass, "recorder", {"recorder": config}) + get_instance(hass).recorder_and_worker_thread_ids.add(threading.get_ident()) hass.start() wait_recording_done(hass) wait_recording_done(hass) diff --git a/tests/components/sensorpro/conftest.py b/tests/components/sensorpro/conftest.py index 85c56845ad8..12199e03a97 100644 --- a/tests/components/sensorpro/conftest.py +++ b/tests/components/sensorpro/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/sensorpush/conftest.py b/tests/components/sensorpush/conftest.py index 2a983a7a4ed..0166f00d1e8 100644 --- a/tests/components/sensorpush/conftest.py +++ b/tests/components/sensorpush/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/senz/test_config_flow.py b/tests/components/senz/test_config_flow.py index 04ef1a6de0c..4faf8775a62 100644 --- a/tests/components/senz/test_config_flow.py +++ b/tests/components/senz/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from aiosenz import AUTHORIZATION_ENDPOINT, TOKEN_ENDPOINT +import pytest from homeassistant import config_entries from homeassistant.components.application_credentials import ( @@ -21,11 +22,11 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/seventeentrack/conftest.py b/tests/components/seventeentrack/conftest.py index 2e266a9b13c..1ab4eed11ee 100644 --- a/tests/components/seventeentrack/conftest.py +++ b/tests/components/seventeentrack/conftest.py @@ -1,10 +1,10 @@ """Configuration for 17Track tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from py17track.package import Package import pytest +from typing_extensions import Generator from homeassistant.components.seventeentrack.const import ( CONF_SHOW_ARCHIVED, @@ -69,7 +69,7 @@ VALID_PLATFORM_CONFIG_FULL = { @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.seventeentrack.async_setup_entry", return_value=True diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index 31fc5deec24..75cc6435073 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory from py17track.errors import SeventeenTrackError from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import IssueRegistry +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import goto_future, init_integration @@ -311,7 +311,7 @@ async def test_non_valid_platform_config( async def test_full_valid_platform_config( hass: HomeAssistant, mock_seventeentrack: AsyncMock, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Ensure everything starts correctly.""" assert await async_setup_component(hass, "sensor", VALID_PLATFORM_CONFIG_FULL) diff --git a/tests/components/seventeentrack/test_services.py b/tests/components/seventeentrack/test_services.py index cbd7132bf67..4347189a5c0 100644 --- a/tests/components/seventeentrack/test_services.py +++ b/tests/components/seventeentrack/test_services.py @@ -2,14 +2,19 @@ from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.seventeentrack import DOMAIN, SERVICE_GET_PACKAGES -from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from . import init_integration +from .conftest import get_package from tests.common import MockConfigEntry -from tests.components.seventeentrack import init_integration -from tests.components.seventeentrack.conftest import get_package async def test_get_packages_from_list( @@ -29,7 +34,7 @@ async def test_get_packages_from_list( "package_state": ["in_transit", "delivered"], }, blocking=True, - return_response=SupportsResponse.ONLY, + return_response=True, ) assert service_response == snapshot @@ -51,12 +56,67 @@ async def test_get_all_packages( "config_entry_id": mock_config_entry.entry_id, }, blocking=True, - return_response=SupportsResponse.ONLY, + return_response=True, ) assert service_response == snapshot +async def test_service_called_with_unloaded_entry( + hass: HomeAssistant, + mock_seventeentrack: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test service call with not ready config entry.""" + await init_integration(hass, mock_config_entry) + mock_config_entry.mock_state(hass, ConfigEntryState.SETUP_ERROR) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PACKAGES, + { + "config_entry_id": mock_config_entry.entry_id, + }, + blocking=True, + return_response=True, + ) + + +async def test_service_called_with_non_17track_device( + hass: HomeAssistant, + mock_seventeentrack: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, +) -> None: + """Test service calls with non 17Track device.""" + await init_integration(hass, mock_config_entry) + + other_domain = "Not17Track" + other_config_id = "555" + other_mock_config_entry = MockConfigEntry( + title="Not 17Track", domain=other_domain, entry_id=other_config_id + ) + other_mock_config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=other_config_id, + identifiers={(other_domain, "1")}, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PACKAGES, + { + "config_entry_id": device_entry.id, + }, + blocking=True, + return_response=True, + ) + + async def _mock_packages(mock_seventeentrack): package1 = get_package(status=10) package2 = get_package( diff --git a/tests/components/sfr_box/conftest.py b/tests/components/sfr_box/conftest.py index dec99738a03..e86cd06650e 100644 --- a/tests/components/sfr_box/conftest.py +++ b/tests/components/sfr_box/conftest.py @@ -1,11 +1,11 @@ """Provide common SFR Box fixtures.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, patch import pytest from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo +from typing_extensions import Generator from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntry @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.sfr_box.async_setup_entry", return_value=True @@ -59,7 +59,7 @@ def get_config_entry_with_auth(hass: HomeAssistant) -> ConfigEntry: @pytest.fixture -def dsl_get_info() -> Generator[DslInfo, None, None]: +def dsl_get_info() -> Generator[DslInfo]: """Fixture for SFRBox.dsl_get_info.""" dsl_info = DslInfo(**json.loads(load_fixture("dsl_getInfo.json", DOMAIN))) with patch( @@ -70,7 +70,7 @@ def dsl_get_info() -> Generator[DslInfo, None, None]: @pytest.fixture -def ftth_get_info() -> Generator[FtthInfo, None, None]: +def ftth_get_info() -> Generator[FtthInfo]: """Fixture for SFRBox.ftth_get_info.""" info = FtthInfo(**json.loads(load_fixture("ftth_getInfo.json", DOMAIN))) with patch( @@ -81,7 +81,7 @@ def ftth_get_info() -> Generator[FtthInfo, None, None]: @pytest.fixture -def system_get_info() -> Generator[SystemInfo, None, None]: +def system_get_info() -> Generator[SystemInfo]: """Fixture for SFRBox.system_get_info.""" info = SystemInfo(**json.loads(load_fixture("system_getInfo.json", DOMAIN))) with patch( @@ -92,7 +92,7 @@ def system_get_info() -> Generator[SystemInfo, None, None]: @pytest.fixture -def wan_get_info() -> Generator[WanInfo, None, None]: +def wan_get_info() -> Generator[WanInfo]: """Fixture for SFRBox.wan_get_info.""" info = WanInfo(**json.loads(load_fixture("wan_getInfo.json", DOMAIN))) with patch( diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index f3d012712ca..8dba537f6cb 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -1,11 +1,11 @@ """Test the SFR Box binary sensors.""" -from collections.abc import Generator from unittest.mock import patch import pytest from sfrbox_api.models import SystemInfo from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -18,7 +18,7 @@ pytestmark = pytest.mark.usefixtures( @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.sfr_box.PLATFORMS", [Platform.BINARY_SENSOR]): yield diff --git a/tests/components/sfr_box/test_button.py b/tests/components/sfr_box/test_button.py index 618ad6fc34b..4f20a2f34a3 100644 --- a/tests/components/sfr_box/test_button.py +++ b/tests/components/sfr_box/test_button.py @@ -1,11 +1,11 @@ """Test the SFR Box buttons.""" -from collections.abc import Generator from unittest.mock import patch import pytest from sfrbox_api.exceptions import SFRBoxError from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry @@ -18,7 +18,7 @@ pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS_WITH_AUTH.""" with ( patch( diff --git a/tests/components/sfr_box/test_diagnostics.py b/tests/components/sfr_box/test_diagnostics.py index 512a737d434..597631d12f1 100644 --- a/tests/components/sfr_box/test_diagnostics.py +++ b/tests/components/sfr_box/test_diagnostics.py @@ -1,11 +1,11 @@ """Test the SFR Box diagnostics.""" -from collections.abc import Generator from unittest.mock import patch import pytest from sfrbox_api.models import SystemInfo from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -19,7 +19,7 @@ pytestmark = pytest.mark.usefixtures( @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.sfr_box.PLATFORMS", []): yield diff --git a/tests/components/sfr_box/test_init.py b/tests/components/sfr_box/test_init.py index 4bcd4ae9208..14688009c5c 100644 --- a/tests/components/sfr_box/test_init.py +++ b/tests/components/sfr_box/test_init.py @@ -1,10 +1,10 @@ """Test the SFR Box setup process.""" -from collections.abc import Generator from unittest.mock import patch import pytest from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError +from typing_extensions import Generator from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.sfr_box.PLATFORMS", []): yield diff --git a/tests/components/sfr_box/test_sensor.py b/tests/components/sfr_box/test_sensor.py index afdcf87b9db..506e1ed8962 100644 --- a/tests/components/sfr_box/test_sensor.py +++ b/tests/components/sfr_box/test_sensor.py @@ -1,10 +1,10 @@ """Test the SFR Box sensors.""" -from collections.abc import Generator from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -15,7 +15,7 @@ pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get @pytest.fixture(autouse=True) -def override_platforms() -> Generator[None, None, None]: +def override_platforms() -> Generator[None]: """Override PLATFORMS.""" with patch("homeassistant.components.sfr_box.PLATFORMS", [Platform.SENSOR]): yield diff --git a/tests/components/sharkiq/const.py b/tests/components/sharkiq/const.py index e8d920e7763..5e61f611505 100644 --- a/tests/components/sharkiq/const.py +++ b/tests/components/sharkiq/const.py @@ -68,7 +68,7 @@ SHARK_PROPERTIES_DICT = { "Robot_Room_List": { "base_type": "string", "read_only": True, - "value": "Kitchen", + "value": "AY001MRT1:Kitchen:Living Room", }, } diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index c72ad1a8c36..e5154008f56 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -151,11 +151,12 @@ async def setup_integration(hass): await hass.async_block_till_done() -async def test_simple_properties(hass: HomeAssistant) -> None: +async def test_simple_properties( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that simple properties work as intended.""" state = hass.states.get(VAC_ENTITY_ID) - registry = er.async_get(hass) - entity = registry.async_get(VAC_ENTITY_ID) + entity = entity_registry.async_get(VAC_ENTITY_ID) assert entity assert state @@ -225,18 +226,19 @@ async def test_fan_speed(hass: HomeAssistant, fan_speed: str) -> None: ], ) async def test_device_properties( - hass: HomeAssistant, device_property: str, target_value: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + device_property: str, + target_value: str, ) -> None: """Test device properties.""" - registry = dr.async_get(hass) - device = registry.async_get_device(identifiers={(DOMAIN, "AC000Wxxxxxxxxx")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "AC000Wxxxxxxxxx")}) assert getattr(device, device_property) == target_value @pytest.mark.parametrize( ("room_list", "exception"), [ - (["KITCHEN"], exceptions.ServiceValidationError), (["KITCHEN", "MUD_ROOM", "DOG HOUSE"], exceptions.ServiceValidationError), (["Office"], exceptions.ServiceValidationError), ([], MultipleInvalid), @@ -246,8 +248,9 @@ async def test_clean_room_error( hass: HomeAssistant, room_list: list, exception: Exception ) -> None: """Test clean_room errors.""" + data = {ATTR_ENTITY_ID: VAC_ENTITY_ID, ATTR_ROOMS: room_list} + with pytest.raises(exception): - data = {ATTR_ENTITY_ID: VAC_ENTITY_ID, ATTR_ROOMS: room_list} await hass.services.async_call(DOMAIN, SERVICE_CLEAN_ROOM, data, blocking=True) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 348b1115a6f..4631a17969e 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -20,12 +20,12 @@ from homeassistant.components.shelly.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceRegistry, format_mac, ) -from homeassistant.helpers.entity_registry import async_get from tests.common import MockConfigEntry, async_fire_time_changed @@ -113,7 +113,7 @@ def register_entity( capabilities: Mapping[str, Any] | None = None, ) -> str: """Register enabled entity, return entity_id.""" - entity_registry = async_get(hass) + entity_registry = er.async_get(hass) entity_registry.async_get_or_create( domain, DOMAIN, @@ -132,7 +132,7 @@ def get_entity( unique_id: str, ) -> str | None: """Get Shelly entity.""" - entity_registry = async_get(hass) + entity_registry = er.async_get(hass) return entity_registry.async_get_entity_id( domain, DOMAIN, f"{MOCK_MAC}-{unique_id}" ) @@ -145,9 +145,9 @@ def get_entity_state(hass: HomeAssistant, entity_id: str) -> str: return entity.state -def register_device(device_reg: DeviceRegistry, config_entry: ConfigEntry) -> None: +def register_device(device_registry: DeviceRegistry, config_entry: ConfigEntry) -> None: """Register Shelly device.""" - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, ) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 18813ff7eba..a16cc62fbae 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -11,11 +11,11 @@ from homeassistant.components.shelly.const import ( EVENT_SHELLY_CLICK, REST_SENSORS_UPDATE_INTERVAL, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from . import MOCK_MAC -from tests.common import async_capture_events, async_mock_service, mock_device_registry +from tests.common import async_capture_events, async_mock_service MOCK_SETTINGS = { "name": "Test name", @@ -122,7 +122,7 @@ MOCK_BLOCKS = [ set_state=AsyncMock(side_effect=mock_light_set_state), ), Mock( - sensor_ids={"motion": 0, "temp": 22.1, "gas": "mild"}, + sensor_ids={"motion": 0, "temp": 22.1, "gas": "mild", "motionActive": 1}, channel="0", motion=0, temp=22.1, @@ -226,7 +226,10 @@ MOCK_STATUS_RPC = { "switch:0": {"output": True}, "input:0": {"id": 0, "state": None}, "input:1": {"id": 1, "percent": 89, "xpercent": 8.9}, - "input:2": {"id": 2, "counts": {"total": 56174, "xtotal": 561.74}}, + "input:2": { + "id": 2, + "counts": {"total": 56174, "xtotal": 561.74, "freq": 208.00, "xfreq": 6.11}, + }, "light:0": {"output": True, "brightness": 53.0}, "light:1": {"output": True, "brightness": 53.0}, "light:2": {"output": True, "brightness": 53.0}, @@ -254,6 +257,7 @@ MOCK_STATUS_RPC = { "current_C": 12.3, "output": True, }, + "humidity:0": {"rh": 44.4}, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, @@ -287,13 +291,7 @@ def mock_ws_server(): @pytest.fixture -def device_reg(hass: HomeAssistant): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture -def calls(hass: HomeAssistant): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -409,5 +407,5 @@ async def mock_rpc_device(): @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 524bc1e8ffc..026a7041863 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -162,12 +162,12 @@ async def test_block_sleeping_binary_sensor( async def test_block_restored_sleeping_binary_sensor( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored sleeping binary sensor.""" entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, BINARY_SENSOR_DOMAIN, "test_name_motion", "sensor_0-motion", entry ) @@ -189,12 +189,12 @@ async def test_block_restored_sleeping_binary_sensor( async def test_block_restored_sleeping_binary_sensor_no_last_state( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored sleeping binary sensor missing last state.""" entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, BINARY_SENSOR_DOMAIN, "test_name_motion", "sensor_0-motion", entry ) @@ -297,12 +297,12 @@ async def test_rpc_sleeping_binary_sensor( async def test_rpc_restored_sleeping_binary_sensor( hass: HomeAssistant, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC restored binary sensor.""" entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud-cloud", entry ) @@ -326,12 +326,12 @@ async def test_rpc_restored_sleeping_binary_sensor( async def test_rpc_restored_sleeping_binary_sensor_no_last_state( hass: HomeAssistant, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC restored sleeping binary sensor missing last state.""" entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud-cloud", entry ) diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index a70cdef3fb1..fea46b1d2d1 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -8,6 +8,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, @@ -33,9 +34,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from homeassistant.helpers.issue_registry import IssueRegistry from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import MOCK_MAC, init_integration, register_device, register_entity @@ -244,7 +245,7 @@ async def test_climate_set_preset_mode( async def test_block_restored_climate( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored climate.""" @@ -253,7 +254,7 @@ async def test_block_restored_climate( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, CLIMATE_DOMAIN, @@ -310,7 +311,7 @@ async def test_block_restored_climate( async def test_block_restored_climate_us_customery( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored climate with US CUSTOMATY unit system.""" @@ -320,7 +321,7 @@ async def test_block_restored_climate_us_customery( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, CLIMATE_DOMAIN, @@ -382,14 +383,14 @@ async def test_block_restored_climate_us_customery( async def test_block_restored_climate_unavailable( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored climate unavailable state.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, CLIMATE_DOMAIN, @@ -409,14 +410,14 @@ async def test_block_restored_climate_unavailable( async def test_block_restored_climate_set_preset_before_online( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored climate set preset before device is online.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, CLIMATE_DOMAIN, @@ -492,7 +493,6 @@ async def test_block_set_mode_auth_error( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -511,14 +511,14 @@ async def test_block_set_mode_auth_error( async def test_block_restored_climate_auth_error( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored climate with authentication error during init.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, CLIMATE_DOMAIN, @@ -560,7 +560,7 @@ async def test_device_not_calibrated( hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, - issue_registry: IssueRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test to create an issue when the device is not calibrated.""" await init_integration(hass, 1, sleep_period=1000, model=MODEL_VALVE) @@ -611,6 +611,7 @@ async def test_rpc_climate_hvac_mode( assert state.attributes[ATTR_TEMPERATURE] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 entry = entity_registry.async_get(ENTITY_ID) assert entry @@ -621,6 +622,7 @@ async def test_rpc_climate_hvac_mode( state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "enable", False) await hass.services.async_call( @@ -638,6 +640,31 @@ async def test_rpc_climate_hvac_mode( assert state.state == HVACMode.OFF +async def test_rpc_climate_without_humidity( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test climate entity without the humidity value.""" + new_status = deepcopy(mock_rpc_device.status) + new_status.pop("humidity:0") + monkeypatch.setattr(mock_rpc_device, "status", new_status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 23 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert ATTR_CURRENT_HUMIDITY not in state.attributes + + entry = entity_registry.async_get(ENTITY_ID) + assert entry + assert entry.unique_id == "123456789ABC-thermostat:0" + + async def test_rpc_climate_set_temperature( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index c73b93f9fdb..a26c6eac405 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -23,7 +23,7 @@ from homeassistant.components.shelly.const import ( BLEScannerMode, ) from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_RECONFIGURE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -1125,7 +1125,7 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( await hass.async_block_till_done() mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text assert len(mock_rpc_device.initialize.mock_calls) == 1 @@ -1187,3 +1187,120 @@ async def test_sleeping_device_gen2_with_new_firmware( "sleep_period": 666, "gen": 2, } + + +@pytest.mark.parametrize("gen", [1, 2, 3]) +async def test_reconfigure_successful( + hass: HomeAssistant, + gen: int, + mock_block_device: Mock, + mock_rpc_device: Mock, +) -> None: + """Test starting a reconfiguration flow.""" + entry = MockConfigEntry( + domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "test-mac", "type": MODEL_1, "auth": False, "gen": gen}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": "10.10.10.10", "port": 99}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == {"host": "10.10.10.10", "port": 99, "gen": gen} + + +@pytest.mark.parametrize("gen", [1, 2, 3]) +async def test_reconfigure_unsuccessful( + hass: HomeAssistant, + gen: int, + mock_block_device: Mock, + mock_rpc_device: Mock, +) -> None: + """Test reconfiguration flow failed.""" + entry = MockConfigEntry( + domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={"mac": "another-mac", "type": MODEL_1, "auth": False, "gen": gen}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": "10.10.10.10", "port": 99}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "another_device" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (DeviceConnectionError, "cannot_connect"), + (CustomPortNotSupported, "custom_port_not_supported"), + ], +) +async def test_reconfigure_with_exception( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_rpc_device: Mock, +) -> None: + """Test reconfiguration flow when an exception is raised.""" + entry = MockConfigEntry( + domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"host": "10.10.10.10", "port": 99}, + ) + + assert result["errors"] == {"base": base_error} diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 1dc45a98c44..1e0af115c9e 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -15,24 +15,19 @@ from homeassistant.components.shelly.const import ( ATTR_CLICK_TYPE, ATTR_DEVICE, ATTR_GENERATION, + CONF_BLE_SCANNER_MODE, DOMAIN, ENTRY_RELOAD_COOLDOWN, MAX_PUSH_UPDATE_FAILURES, RPC_RECONNECT_INTERVAL, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, + BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import Event, HomeAssistant, State -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceRegistry, - async_entries_for_config_entry, - async_get as async_get_dev_reg, - format_mac, -) +from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import ( MOCK_MAC, @@ -341,6 +336,7 @@ async def test_block_device_push_updates_failure( async def test_block_button_click_event( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_block_device: Mock, events: list[Event], monkeypatch: pytest.MonkeyPatch, @@ -356,10 +352,9 @@ async def test_block_button_click_event( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] # Generate button click event mock_block_device.mock_update() @@ -485,8 +480,28 @@ async def test_rpc_reload_with_invalid_auth( assert flow["context"].get("entry_id") == entry.entry_id +async def test_rpc_connection_error_during_unload( + hass: HomeAssistant, mock_rpc_device: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test RPC DeviceConnectionError suppressed during config entry unload.""" + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.shelly.coordinator.async_stop_scanner", + side_effect=DeviceConnectionError, + ): + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert "Error during shutdown for device" in caplog.text + assert entry.state is ConfigEntryState.NOT_LOADED + + async def test_rpc_click_event( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_rpc_device: Mock, events: list[Event], monkeypatch: pytest.MonkeyPatch, @@ -494,8 +509,7 @@ async def test_rpc_click_event( """Test RPC click event.""" entry = await init_integration(hass, 2) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] # Generate config change from switch to light inject_rpc_device_event( @@ -713,6 +727,32 @@ async def test_rpc_reconnect_error( assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE +async def test_rpc_error_running_connected_events( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test RPC error while running connected events.""" + with patch( + "homeassistant.components.shelly.coordinator.async_ensure_ble_enabled", + side_effect=DeviceConnectionError, + ): + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert "Error running connected events for device" in caplog.text + assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + + # Move time to generate reconnect without error + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + + async def test_rpc_polling_connection_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -758,21 +798,23 @@ async def test_rpc_polling_disconnected( async def test_rpc_update_entry_fw_ver( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC update entry firmware version.""" monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 600) entry = await init_integration(hass, 2, sleep_period=600) - dev_reg = async_get_dev_reg(hass) # Make device online mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) assert entry.unique_id - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)}, - connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, ) assert device assert device.sw_version == "some fw string" @@ -782,9 +824,9 @@ async def test_rpc_update_entry_fw_ver( mock_rpc_device.mock_update() await hass.async_block_till_done() - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)}, - connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, ) assert device assert device.sw_version == "99.0.0" @@ -812,16 +854,16 @@ async def test_rpc_runs_connected_events_when_initialized( async def test_block_sleeping_device_connection_error( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, mock_block_device: Mock, - device_reg: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Test block sleeping device connection error during initialize.""" sleep_period = 1000 entry = await init_integration(hass, 1, sleep_period=sleep_period, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, BINARY_SENSOR_DOMAIN, "test_name_motion", "sensor_0-motion", entry ) @@ -857,16 +899,16 @@ async def test_block_sleeping_device_connection_error( async def test_rpc_sleeping_device_connection_error( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, - device_reg: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Test RPC sleeping device connection error during initialize.""" sleep_period = 1000 entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud-cloud", entry ) diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 39238f1674a..d47cca17460 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -20,12 +20,7 @@ from homeassistant.components.shelly.const import ( ) from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceRegistry, - async_entries_for_config_entry, - async_get as async_get_dev_reg, -) +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import init_integration @@ -44,6 +39,7 @@ from tests.common import MockConfigEntry, async_get_device_automations ) async def test_get_triggers_block_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, button_type: str, @@ -59,8 +55,7 @@ async def test_get_triggers_block_device( ], ) entry = await init_integration(hass, 1) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] expected_triggers = [] if is_valid: @@ -73,7 +68,7 @@ async def test_get_triggers_block_device( CONF_SUBTYPE: "button1", "metadata": {}, } - for type_ in ["single", "long"] + for type_ in ("single", "long") ] triggers = await async_get_device_automations( @@ -84,12 +79,11 @@ async def test_get_triggers_block_device( async def test_get_triggers_rpc_device( - hass: HomeAssistant, mock_rpc_device: Mock + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_rpc_device: Mock ) -> None: """Test we get the expected triggers from a shelly RPC device.""" entry = await init_integration(hass, 2) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] expected_triggers = [ { @@ -100,14 +94,14 @@ async def test_get_triggers_rpc_device( CONF_SUBTYPE: "button1", "metadata": {}, } - for trigger_type in [ + for trigger_type in ( "btn_down", "btn_up", "single_push", "double_push", "triple_push", "long_push", - ] + ) ] triggers = await async_get_device_automations( @@ -118,12 +112,11 @@ async def test_get_triggers_rpc_device( async def test_get_triggers_button( - hass: HomeAssistant, mock_block_device: Mock + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_block_device: Mock ) -> None: """Test we get the expected triggers from a shelly button.""" entry = await init_integration(hass, 1, model=MODEL_BUTTON1) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] expected_triggers = [ { @@ -134,7 +127,7 @@ async def test_get_triggers_button( CONF_SUBTYPE: "button", "metadata": {}, } - for trigger_type in ["single", "double", "triple", "long"] + for trigger_type in ("single", "double", "triple", "long") ] triggers = await async_get_device_automations( @@ -145,13 +138,15 @@ async def test_get_triggers_button( async def test_get_triggers_non_initialized_devices( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test we get the empty triggers for non-initialized devices.""" monkeypatch.setattr(mock_block_device, "initialized", False) entry = await init_integration(hass, 1) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] expected_triggers = [] @@ -163,15 +158,15 @@ async def test_get_triggers_non_initialized_devices( async def test_get_triggers_for_invalid_device_id( - hass: HomeAssistant, device_reg: DeviceRegistry, mock_block_device: Mock + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_block_device: Mock ) -> None: """Test error raised for invalid shelly device_id.""" await init_integration(hass, 1) config_entry = MockConfigEntry(domain=DOMAIN, data={}) config_entry.add_to_hass(hass) - invalid_device = device_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) with pytest.raises(InvalidDeviceAutomationConfig): @@ -181,12 +176,14 @@ async def test_get_triggers_for_invalid_device_id( async def test_if_fires_on_click_event_block_device( - hass: HomeAssistant, calls: list[ServiceCall], mock_block_device: Mock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mock_block_device: Mock, ) -> None: """Test for click_event trigger firing for block device.""" entry = await init_integration(hass, 1) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] assert await async_setup_component( hass, @@ -223,12 +220,14 @@ async def test_if_fires_on_click_event_block_device( async def test_if_fires_on_click_event_rpc_device( - hass: HomeAssistant, calls: list[ServiceCall], mock_rpc_device: Mock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mock_rpc_device: Mock, ) -> None: """Test for click_event trigger firing for rpc device.""" entry = await init_integration(hass, 2) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] assert await async_setup_component( hass, @@ -266,6 +265,7 @@ async def test_if_fires_on_click_event_rpc_device( async def test_validate_trigger_block_device_not_ready( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, calls: list[ServiceCall], mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -273,8 +273,7 @@ async def test_validate_trigger_block_device_not_ready( """Test validate trigger config when block device is not ready.""" monkeypatch.setattr(mock_block_device, "initialized", False) entry = await init_integration(hass, 1) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] assert await async_setup_component( hass, @@ -311,6 +310,7 @@ async def test_validate_trigger_block_device_not_ready( async def test_validate_trigger_rpc_device_not_ready( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, calls: list[ServiceCall], mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -318,8 +318,7 @@ async def test_validate_trigger_rpc_device_not_ready( """Test validate trigger config when RPC device is not ready.""" monkeypatch.setattr(mock_rpc_device, "initialized", False) entry = await init_integration(hass, 2) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] assert await async_setup_component( hass, @@ -355,12 +354,14 @@ async def test_validate_trigger_rpc_device_not_ready( async def test_validate_trigger_invalid_triggers( - hass: HomeAssistant, mock_block_device: Mock, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_block_device: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test for click_event with invalid triggers.""" entry = await init_integration(hass, 1) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] assert await async_setup_component( hass, @@ -385,3 +386,93 @@ async def test_validate_trigger_invalid_triggers( ) assert "Invalid (type,subtype): ('single', 'button3')" in caplog.text + + +async def test_rpc_no_runtime_data( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the device trigger for the RPC device when there is no runtime_data in the entry.""" + entry = await init_integration(hass, 2) + monkeypatch.delattr(entry, "runtime_data") + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single_push"}, + }, + }, + ] + }, + ) + message = { + CONF_DEVICE_ID: device.id, + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single_push" + + +async def test_block_no_runtime_data( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + calls: list[ServiceCall], + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the device trigger for the block device when there is no runtime_data in the entry.""" + entry = await init_integration(hass, 1) + monkeypatch.delattr(entry, "runtime_data") + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "single", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single"}, + }, + }, + ] + }, + ) + message = { + CONF_DEVICE_ID: device.id, + ATTR_CLICK_TYPE: "single", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single" diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 61ec8ce6779..998d56fc6cc 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -117,13 +117,13 @@ async def test_shared_device_mac( gen: int, mock_block_device: Mock, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test first time shared device with another domain.""" config_entry = MockConfigEntry(domain="test", data={}, unique_id="some_id") config_entry.add_to_hass(hass) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, ) @@ -243,13 +243,13 @@ async def test_sleeping_block_device_online( device_sleep: int, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test sleeping block device online.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id="shelly") config_entry.add_to_hass(hass) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, ) @@ -263,7 +263,7 @@ async def test_sleeping_block_device_online( assert "will resume when device is online" in caplog.text mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text assert entry.data["sleep_period"] == device_sleep @@ -284,7 +284,7 @@ async def test_sleeping_rpc_device_online( assert "will resume when device is online" in caplog.text mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text assert entry.data["sleep_period"] == device_sleep @@ -302,7 +302,7 @@ async def test_sleeping_rpc_device_online_new_firmware( mutate_rpc_device_status(monkeypatch, mock_rpc_device, "sys", "wakeup_period", 1500) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text assert entry.data["sleep_period"] == 1500 diff --git a/tests/components/shelly/test_logbook.py b/tests/components/shelly/test_logbook.py index cd1714d6b26..8962b26544b 100644 --- a/tests/components/shelly/test_logbook.py +++ b/tests/components/shelly/test_logbook.py @@ -11,10 +11,7 @@ from homeassistant.components.shelly.const import ( ) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import ( - async_entries_for_config_entry, - async_get as async_get_dev_reg, -) +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import init_integration @@ -23,12 +20,11 @@ from tests.components.logbook.common import MockRow, mock_humanify async def test_humanify_shelly_click_event_block_device( - hass: HomeAssistant, mock_block_device: Mock + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_block_device: Mock ) -> None: """Test humanifying Shelly click event for block device.""" entry = await init_integration(hass, 1) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -74,12 +70,11 @@ async def test_humanify_shelly_click_event_block_device( async def test_humanify_shelly_click_event_rpc_device( - hass: HomeAssistant, mock_rpc_device: Mock + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_rpc_device: Mock ) -> None: """Test humanifying Shelly click event for rpc device.""" entry = await init_integration(hass, 2) - dev_reg = async_get_dev_reg(hass) - device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + device = dr.async_entries_for_config_entry(device_registry, entry.entry_id)[0] hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 0b9fee9e47f..ff453b3251c 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -61,12 +61,12 @@ async def test_block_number_update( async def test_block_restored_number( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored number.""" entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) capabilities = { "min": 0, "max": 100, @@ -107,12 +107,12 @@ async def test_block_restored_number( async def test_block_restored_number_no_last_state( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored number missing last state.""" entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) capabilities = { "min": 0, "max": 100, @@ -188,7 +188,7 @@ async def test_block_set_value_connection_error( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -227,7 +227,6 @@ async def test_block_set_value_auth_error( {ATTR_ENTITY_ID: "number.test_name_valve_position", ATTR_VALUE: 30}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index ceaa9b66b8d..513bcd875e2 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -25,10 +25,11 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfEnergy, + UnitOfFrequency, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry, async_get +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import ( @@ -182,12 +183,12 @@ async def test_block_sleeping_sensor( async def test_block_restored_sleeping_sensor( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored sleeping sensor.""" entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, SENSOR_DOMAIN, "test_name_temperature", "sensor_0-temp", entry ) @@ -215,12 +216,12 @@ async def test_block_restored_sleeping_sensor( async def test_block_restored_sleeping_sensor_no_last_state( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block restored sleeping sensor missing last state.""" entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, SENSOR_DOMAIN, "test_name_temperature", "sensor_0-temp", entry ) @@ -282,12 +283,12 @@ async def test_block_sensor_removal( async def test_block_not_matched_restored_sleeping_sensor( hass: HomeAssistant, mock_block_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block not matched to restored sleeping sensor.""" entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, SENSOR_DOMAIN, "test_name_temperature", "sensor_0-temp", entry ) @@ -355,11 +356,11 @@ async def test_rpc_sensor( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_rssi_sensor_removal( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, - entity_registry_enabled_by_default: None, ) -> None: """Test RPC RSSI sensor removal if no WiFi stations enabled.""" entity_id = f"{SENSOR_DOMAIN}.test_name_rssi" @@ -443,7 +444,7 @@ async def test_rpc_polling_sensor( async def test_rpc_sleeping_sensor( hass: HomeAssistant, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC online sleeping sensor.""" @@ -477,12 +478,12 @@ async def test_rpc_sleeping_sensor( async def test_rpc_restored_sleeping_sensor( hass: HomeAssistant, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC restored sensor.""" entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, SENSOR_DOMAIN, @@ -515,12 +516,12 @@ async def test_rpc_restored_sleeping_sensor( async def test_rpc_restored_sleeping_sensor_no_last_state( hass: HomeAssistant, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC restored sensor missing last state.""" entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, SENSOR_DOMAIN, @@ -548,18 +549,18 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( assert hass.states.get(entity_id).state == "22.9" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_em1_sensors( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry_enabled_by_default: None + hass: HomeAssistant, entity_registry: EntityRegistry, mock_rpc_device: Mock ) -> None: """Test RPC sensors for EM1 component.""" - registry = async_get(hass) await init_integration(hass, 2) state = hass.states.get("sensor.test_name_em0_power") assert state assert state.state == "85.3" - entry = registry.async_get("sensor.test_name_em0_power") + entry = entity_registry.async_get("sensor.test_name_em0_power") assert entry assert entry.unique_id == "123456789ABC-em1:0-power_em1" @@ -567,7 +568,7 @@ async def test_rpc_em1_sensors( assert state assert state.state == "123.3" - entry = registry.async_get("sensor.test_name_em1_power") + entry = entity_registry.async_get("sensor.test_name_em1_power") assert entry assert entry.unique_id == "123456789ABC-em1:1-power_em1" @@ -575,7 +576,7 @@ async def test_rpc_em1_sensors( assert state assert state.state == "123.4564" - entry = registry.async_get("sensor.test_name_em0_total_active_energy") + entry = entity_registry.async_get("sensor.test_name_em0_total_active_energy") assert entry assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" @@ -583,7 +584,7 @@ async def test_rpc_em1_sensors( assert state assert state.state == "987.6543" - entry = registry.async_get("sensor.test_name_em1_total_active_energy") + entry = entity_registry.async_get("sensor.test_name_em1_total_active_energy") assert entry assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" @@ -618,7 +619,6 @@ async def test_rpc_sleeping_update_entity_service( service_data={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() # Entity should be available after update_entity service call state = hass.states.get(entity_id) @@ -667,7 +667,6 @@ async def test_block_sleeping_update_entity_service( service_data={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() # Entity should be available after update_entity service call state = hass.states.get(entity_id) @@ -803,3 +802,29 @@ async def test_rpc_disabled_xtotal_counter( entity_id = f"{SENSOR_DOMAIN}.gas_counter_value" assert hass.states.get(entity_id) is None + + +async def test_rpc_pulse_counter_frequency_sensors( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, +) -> None: + """Test RPC counter sensor.""" + await init_integration(hass, 2) + + entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency" + state = hass.states.get(entity_id) + assert state.state == "208.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfFrequency.HERTZ + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-input:2-counter_frequency" + + entity_id = f"{SENSOR_DOMAIN}.gas_pulse_counter_frequency_value" + assert hass.states.get(entity_id).state == "6.11" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-input:2-counter_frequency_value" diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index dd214c8841d..637a92a7fbe 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -7,11 +7,12 @@ 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.shelly.const import ( + DOMAIN, + MODEL_WALL_DISPLAY, + MOTION_MODELS, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( @@ -20,17 +21,20 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component -from . import init_integration, register_entity +from . import get_entity_state, init_integration, register_device, register_entity + +from tests.common import mock_restore_cache RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 +MOTION_BLOCK_ID = 3 async def test_block_device_services( @@ -56,6 +60,121 @@ async def test_block_device_services( assert hass.states.get("switch.test_name_channel_1").state == STATE_OFF +@pytest.mark.parametrize("model", MOTION_MODELS) +async def test_block_motion_switch( + hass: HomeAssistant, + model: str, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly motion active turn on/off services.""" + entity_id = "switch.test_name_motion_detection" + await init_integration(hass, 1, sleep_period=1000, model=model) + + # Make device online + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, entity_id) == STATE_ON + + # turn off + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + monkeypatch.setattr(mock_block_device.blocks[MOTION_BLOCK_ID], "motionActive", 0) + mock_block_device.mock_update() + + mock_block_device.set_shelly_motion_detection.assert_called_once_with(False) + assert get_entity_state(hass, entity_id) == STATE_OFF + + # turn on + mock_block_device.set_shelly_motion_detection.reset_mock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + monkeypatch.setattr(mock_block_device.blocks[MOTION_BLOCK_ID], "motionActive", 1) + mock_block_device.mock_update() + + mock_block_device.set_shelly_motion_detection.assert_called_once_with(True) + assert get_entity_state(hass, entity_id) == STATE_ON + + +@pytest.mark.parametrize("model", MOTION_MODELS) +async def test_block_restored_motion_switch( + hass: HomeAssistant, + model: str, + mock_block_device: Mock, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test block restored motion active switch.""" + entry = await init_integration( + hass, 1, sleep_period=1000, model=model, skip_setup=True + ) + register_device(device_registry, entry) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_motion_detection", + "sensor_0-motionActive", + entry, + ) + + mock_restore_cache(hass, [State(entity_id, STATE_OFF)]) + monkeypatch.setattr(mock_block_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_OFF + + # Make device online + monkeypatch.setattr(mock_block_device, "initialized", True) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, entity_id) == STATE_ON + + +@pytest.mark.parametrize("model", MOTION_MODELS) +async def test_block_restored_motion_switch_no_last_state( + hass: HomeAssistant, + model: str, + mock_block_device: Mock, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test block restored motion active switch missing last state.""" + entry = await init_integration( + hass, 1, sleep_period=1000, model=model, skip_setup=True + ) + register_device(device_registry, entry) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_motion_detection", + "sensor_0-motionActive", + entry, + ) + monkeypatch.setattr(mock_block_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_UNKNOWN + + # Make device online + monkeypatch.setattr(mock_block_device, "initialized", True) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, entity_id) == STATE_ON + + async def test_block_device_unique_ids( hass: HomeAssistant, entity_registry: EntityRegistry, mock_block_device: Mock ) -> None: @@ -106,7 +225,6 @@ async def test_block_set_state_auth_error( {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -250,7 +368,6 @@ async def test_rpc_auth_error( {ATTR_ENTITY_ID: "switch.test_switch_0"}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -266,13 +383,12 @@ async def test_rpc_auth_error( assert flow["context"].get("entry_id") == entry.entry_id -async def test_block_device_gas_valve( +async def test_remove_gas_valve_switch( hass: HomeAssistant, mock_block_device: Mock, entity_registry: EntityRegistry, - monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test block device Shelly Gas with Valve addon.""" + """Test removing deprecated switch entity for Shelly Gas Valve.""" entity_id = register_entity( hass, SWITCH_DOMAIN, @@ -281,41 +397,7 @@ async def test_block_device_gas_valve( ) await init_integration(hass, 1, MODEL_GAS) - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC-valve_0-valve" - - assert hass.states.get(entity_id).state == STATE_OFF # valve is closed - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_ON # valve is open - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF # valve is closed - - 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_ON # valve is open + assert entity_registry.async_get(entity_id) is None async def test_wall_display_relay_mode( @@ -348,63 +430,3 @@ async def test_wall_display_relay_mode( entry = entity_registry.async_get(switch_entity_id) assert entry assert entry.unique_id == "123456789ABC-switch:0" - - -async def test_create_issue_valve_switch( - hass: HomeAssistant, - mock_block_device: Mock, - entity_registry_enabled_by_default: None, - monkeypatch: pytest.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_update.py b/tests/components/shelly/test_update.py index 0f26fd14d12..8448c116815 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -44,13 +44,13 @@ from . import ( from tests.common import mock_restore_cache +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_block_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, - entity_registry_enabled_by_default: None, ) -> None: """Test block device update entity.""" entity_id = "update.test_name_firmware_update" @@ -96,13 +96,13 @@ async def test_block_update( assert entry.unique_id == "123456789ABC-fwupdate" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_block_beta_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, - entity_registry_enabled_by_default: None, ) -> None: """Test block device beta update entity.""" entity_id = "update.test_name_beta_firmware_update" @@ -156,12 +156,12 @@ async def test_block_beta_update( assert entry.unique_id == "123456789ABC-fwupdate_beta" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_block_update_connection_error( hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, - entity_registry_enabled_by_default: None, ) -> None: """Test block device update connection error.""" monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") @@ -173,21 +173,21 @@ async def test_block_update_connection_error( ) await init_integration(hass, 1) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, blocking=True, ) - assert "Error starting OTA update" in caplog.text + assert "Error starting OTA update" in str(excinfo.value) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_block_update_auth_error( hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, - entity_registry_enabled_by_default: None, ) -> None: """Test block device update authentication error.""" monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") @@ -207,7 +207,6 @@ async def test_block_update_auth_error( {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED @@ -380,12 +379,12 @@ async def test_rpc_sleeping_update( async def test_rpc_restored_sleeping_update( hass: HomeAssistant, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC restored update entity.""" entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, UPDATE_DOMAIN, @@ -430,7 +429,7 @@ async def test_rpc_restored_sleeping_update( async def test_rpc_restored_sleeping_update_no_last_state( hass: HomeAssistant, mock_rpc_device: Mock, - device_reg: DeviceRegistry, + device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC restored update entity missing last state.""" @@ -443,7 +442,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( }, ) entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) - register_device(device_reg, entry) + register_device(device_registry, entry) entity_id = register_entity( hass, UPDATE_DOMAIN, @@ -476,13 +475,13 @@ async def test_rpc_restored_sleeping_update_no_last_state( assert state.attributes[ATTR_SUPPORTED_FEATURES] == UpdateEntityFeature(0) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_beta_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, - entity_registry_enabled_by_default: None, ) -> None: """Test RPC device beta update entity.""" entity_id = "update.test_name_beta_firmware_update" @@ -598,10 +597,11 @@ async def test_rpc_beta_update( @pytest.mark.parametrize( ("exc", "error"), [ - (DeviceConnectionError, "Error starting OTA update"), + (DeviceConnectionError, "OTA update connection error: DeviceConnectionError()"), (RpcCallError(-1, "error"), "OTA update request error"), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_update_errors( hass: HomeAssistant, exc: Exception, @@ -609,7 +609,6 @@ async def test_rpc_update_errors( mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, - entity_registry_enabled_by_default: None, ) -> None: """Test RPC device update connection/call errors.""" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") @@ -626,22 +625,22 @@ async def test_rpc_update_errors( ) await init_integration(hass, 2) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, blocking=True, ) - assert error in caplog.text + assert error in str(excinfo.value) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_rpc_update_auth_error( hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, - entity_registry_enabled_by_default: None, ) -> None: """Test RPC device update authentication error.""" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") @@ -669,7 +668,6 @@ async def test_rpc_update_auth_error( blocking=True, ) - await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index b588cd28906..58b55e4f2dd 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -24,14 +24,16 @@ GAS_VALVE_BLOCK_ID = 6 async def test_block_device_gas_valve( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_block_device: Mock, + monkeypatch: pytest.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) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-valve_0-valve" diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index c28ea66a32b..4e758764e3d 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.shopping_list.const import ( SERVICE_REMOVE_ITEM, SERVICE_SORT, ) -from homeassistant.components.websocket_api.const import ( +from homeassistant.components.websocket_api import ( ERR_INVALID_FORMAT, ERR_NOT_FOUND, TYPE_RESULT, diff --git a/tests/components/sia/test_config_flow.py b/tests/components/sia/test_config_flow.py index 36f2292bdea..95de53d7fbe 100644 --- a/tests/components/sia/test_config_flow.py +++ b/tests/components/sia/test_config_flow.py @@ -139,7 +139,7 @@ async def entry_with_additional_account_config(hass, flow_at_add_account_step): ) -async def setup_sia(hass, config_entry: MockConfigEntry): +async def setup_sia(hass: HomeAssistant, config_entry: MockConfigEntry): """Add mock config to HASS.""" assert await async_setup_component(hass, DOMAIN, {}) config_entry.add_to_hass(hass) diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 09d6c2a1ca8..5db6347a832 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -10,19 +10,24 @@ from PIL import UnidentifiedImageError import pytest import simplehound.core as hound -import homeassistant.components.image_processing as ip +from homeassistant.components.image_processing import DOMAIN as IP_DOMAIN, SERVICE_SCAN import homeassistant.components.sighthound.image_processing as sh -from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_API_KEY, + CONF_ENTITY_ID, + CONF_SOURCE, +) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component TEST_DIR = os.path.dirname(__file__) VALID_CONFIG = { - ip.DOMAIN: { + IP_DOMAIN: { "platform": "sighthound", CONF_API_KEY: "abc123", - ip.CONF_SOURCE: {ip.CONF_ENTITY_ID: "camera.demo_camera"}, + CONF_SOURCE: {CONF_ENTITY_ID: "camera.demo_camera"}, }, "camera": {"platform": "demo"}, } @@ -96,7 +101,7 @@ async def test_bad_api_key( with mock.patch( "simplehound.core.cloud.detect", side_effect=hound.SimplehoundException ): - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + await async_setup_component(hass, IP_DOMAIN, VALID_CONFIG) await hass.async_block_till_done() assert "Sighthound error" in caplog.text assert not hass.states.get(VALID_ENTITY_ID) @@ -104,14 +109,14 @@ async def test_bad_api_key( async def test_setup_platform(hass: HomeAssistant, mock_detections) -> None: """Set up platform with one entity.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + await async_setup_component(hass, IP_DOMAIN, VALID_CONFIG) await hass.async_block_till_done() assert hass.states.get(VALID_ENTITY_ID) async def test_process_image(hass: HomeAssistant, mock_image, mock_detections) -> None: """Process an image.""" - await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + await async_setup_component(hass, IP_DOMAIN, VALID_CONFIG) await hass.async_block_till_done() assert hass.states.get(VALID_ENTITY_ID) @@ -125,7 +130,7 @@ async def test_process_image(hass: HomeAssistant, mock_image, mock_detections) - hass.bus.async_listen(sh.EVENT_PERSON_DETECTED, capture_person_event) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) + await hass.services.async_call(IP_DOMAIN, SERVICE_SCAN, service_data=data) await hass.async_block_till_done() state = hass.states.get(VALID_ENTITY_ID) @@ -142,13 +147,13 @@ async def test_catch_bad_image( ) -> None: """Process an image.""" valid_config_save_file = deepcopy(VALID_CONFIG) - valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) - await async_setup_component(hass, ip.DOMAIN, valid_config_save_file) + valid_config_save_file[IP_DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) + await async_setup_component(hass, IP_DOMAIN, valid_config_save_file) await hass.async_block_till_done() assert hass.states.get(VALID_ENTITY_ID) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) + await hass.services.async_call(IP_DOMAIN, SERVICE_SCAN, service_data=data) await hass.async_block_till_done() assert "Sighthound unable to process image" in caplog.text @@ -156,8 +161,8 @@ async def test_catch_bad_image( async def test_save_image(hass: HomeAssistant, mock_image, mock_detections) -> None: """Save a processed image.""" valid_config_save_file = deepcopy(VALID_CONFIG) - valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) - await async_setup_component(hass, ip.DOMAIN, valid_config_save_file) + valid_config_save_file[IP_DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) + await async_setup_component(hass, IP_DOMAIN, valid_config_save_file) await hass.async_block_till_done() assert hass.states.get(VALID_ENTITY_ID) @@ -167,7 +172,7 @@ async def test_save_image(hass: HomeAssistant, mock_image, mock_detections) -> N pil_img = pil_img_open.return_value pil_img = pil_img.convert.return_value data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) + await hass.services.async_call(IP_DOMAIN, SERVICE_SCAN, service_data=data) await hass.async_block_till_done() state = hass.states.get(VALID_ENTITY_ID) assert state.state == "2" @@ -183,9 +188,9 @@ async def test_save_timestamped_image( ) -> None: """Save a processed image.""" valid_config_save_ts_file = deepcopy(VALID_CONFIG) - valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) - valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_TIMESTAMPTED_FILE: True}) - await async_setup_component(hass, ip.DOMAIN, valid_config_save_ts_file) + valid_config_save_ts_file[IP_DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) + valid_config_save_ts_file[IP_DOMAIN].update({sh.CONF_SAVE_TIMESTAMPTED_FILE: True}) + await async_setup_component(hass, IP_DOMAIN, valid_config_save_ts_file) await hass.async_block_till_done() assert hass.states.get(VALID_ENTITY_ID) @@ -195,7 +200,7 @@ async def test_save_timestamped_image( pil_img = pil_img_open.return_value pil_img = pil_img.convert.return_value data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} - await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) + await hass.services.async_call(IP_DOMAIN, SERVICE_SCAN, service_data=data) await hass.async_block_till_done() state = hass.states.get(VALID_ENTITY_ID) assert state.state == "2" diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py index e2f76d54c87..d0085fd6e21 100644 --- a/tests/components/signal_messenger/test_notify.py +++ b/tests/components/signal_messenger/test_notify.py @@ -64,6 +64,26 @@ def test_send_message( assert_sending_requests(signal_requests_mock) +def test_send_message_styled( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test send styled message.""" + signal_requests_mock = signal_requests_mock_factory() + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + data = {"text_mode": "styled"} + signal_notification_service.send_message(MESSAGE, data=data) + post_data = json.loads(signal_requests_mock.request_history[-1].text) + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 2 + assert post_data["text_mode"] == "styled" + assert_sending_requests(signal_requests_mock) + + def test_send_message_to_api_with_bad_data_throws_error( signal_notification_service: SignalNotificationService, signal_requests_mock_factory: Mocker, @@ -97,13 +117,33 @@ def test_send_message_with_bad_data_throws_vol_error( ), pytest.raises(vol.Invalid) as exc, ): - data = {"test": "test"} - signal_notification_service.send_message(MESSAGE, data=data) + signal_notification_service.send_message(MESSAGE, data={"test": "test"}) assert "Sending signal message" in caplog.text assert "extra keys not allowed" in str(exc.value) +def test_send_message_styled_with_bad_data_throws_vol_error( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sending a styled message with bad data throws an error.""" + with ( + caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ), + pytest.raises(vol.Invalid) as exc, + ): + signal_notification_service.send_message(MESSAGE, data={"text_mode": "test"}) + + assert "Sending signal message" in caplog.text + assert ( + "value must be one of ['normal', 'styled'] for dictionary value @ data['text_mode']" + in str(exc.value) + ) + + def test_send_message_with_attachment( signal_notification_service: SignalNotificationService, signal_requests_mock_factory: Mocker, @@ -129,6 +169,32 @@ def test_send_message_with_attachment( assert_sending_requests(signal_requests_mock, 1) +def test_send_message_styled_with_attachment( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test send message with attachment.""" + signal_requests_mock = signal_requests_mock_factory() + with ( + caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ), + tempfile.NamedTemporaryFile( + mode="w", suffix=".png", prefix=os.path.basename(__file__) + ) as temp_file, + ): + temp_file.write("attachment_data") + data = {"attachments": [temp_file.name], "text_mode": "styled"} + signal_notification_service.send_message(MESSAGE, data=data) + post_data = json.loads(signal_requests_mock.request_history[-1].text) + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 2 + assert_sending_requests(signal_requests_mock, 1) + assert post_data["text_mode"] == "styled" + + def test_send_message_with_attachment_as_url( signal_notification_service: SignalNotificationService, signal_requests_mock_factory: Mocker, @@ -148,6 +214,26 @@ def test_send_message_with_attachment_as_url( assert_sending_requests(signal_requests_mock, 1) +def test_send_message_styled_with_attachment_as_url( + signal_notification_service: SignalNotificationService, + signal_requests_mock_factory: Mocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test send message with attachment as URL.""" + signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT))) + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.signal_messenger.notify" + ): + data = {"urls": [URL_ATTACHMENT], "text_mode": "styled"} + signal_notification_service.send_message(MESSAGE, data=data) + post_data = json.loads(signal_requests_mock.request_history[-1].text) + assert "Sending signal message" in caplog.text + assert signal_requests_mock.called + assert signal_requests_mock.call_count == 3 + assert_sending_requests(signal_requests_mock, 1) + assert post_data["text_mode"] == "styled" + + def test_get_attachments( signal_notification_service: SignalNotificationService, signal_requests_mock_factory: Mocker, @@ -192,8 +278,9 @@ def test_get_attachments_with_large_attachment( """Test getting attachments as URL with large attachment (per Content-Length header) throws error.""" signal_requests_mock = signal_requests_mock_factory(True, str(len(CONTENT) + 1)) with pytest.raises(ValueError) as exc: - data = {"urls": [URL_ATTACHMENT]} - signal_notification_service.get_attachments_as_bytes(data, len(CONTENT), hass) + signal_notification_service.get_attachments_as_bytes( + {"urls": [URL_ATTACHMENT]}, len(CONTENT), hass + ) assert signal_requests_mock.called assert signal_requests_mock.call_count == 1 @@ -208,9 +295,8 @@ def test_get_attachments_with_large_attachment_no_header( """Test getting attachments as URL with large attachment (per content length) throws error.""" signal_requests_mock = signal_requests_mock_factory() with pytest.raises(ValueError) as exc: - data = {"urls": [URL_ATTACHMENT]} signal_notification_service.get_attachments_as_bytes( - data, len(CONTENT) - 1, hass + {"urls": [URL_ATTACHMENT]}, len(CONTENT) - 1, hass ) assert signal_requests_mock.called diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index e6a9d70b164..6948f98b159 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -246,7 +246,7 @@ async def test_entry_diagnostics( "battery": [], "dbm": 0, "vmUse": 161592, - "resSet": 10540, + "resSet": 10540, # codespell:ignore resset "uptime": 810043.74, "wifiDisconnects": 1, "wifiDriverReloads": 1, diff --git a/tests/components/simplisafe/test_init.py b/tests/components/simplisafe/test_init.py index f626f479a2f..130ce59cd4a 100644 --- a/tests/components/simplisafe/test_init.py +++ b/tests/components/simplisafe/test_init.py @@ -9,13 +9,12 @@ from homeassistant.setup import async_setup_component async def test_base_station_migration( - hass: HomeAssistant, api, config, config_entry + hass: HomeAssistant, device_registry: dr.DeviceRegistry, api, config, config_entry ) -> None: """Test that errors are shown when duplicates are added.""" old_identifers = (DOMAIN, 12345) new_identifiers = (DOMAIN, "12345") - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={old_identifers}, diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 3a53e8ce684..fd07cc414e7 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( @@ -18,6 +17,7 @@ from asyncsleepiq import ( SleepIQSleeper, ) import pytest +from typing_extensions import Generator from homeassistant.components.sleepiq import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -46,7 +46,7 @@ SLEEPIQ_CONFIG = { @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.sleepiq.async_setup_entry", return_value=True @@ -97,7 +97,7 @@ def mock_bed() -> MagicMock: @pytest.fixture def mock_asyncsleepiq_single_foundation( mock_bed: MagicMock, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock an AsyncSleepIQ object with a single foundation.""" with patch("homeassistant.components.sleepiq.AsyncSleepIQ", autospec=True) as mock: client = mock.return_value @@ -131,7 +131,7 @@ def mock_asyncsleepiq_single_foundation( @pytest.fixture -def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock, None, None]: +def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock]: """Mock an AsyncSleepIQ object with a split foundation.""" with patch("homeassistant.components.sleepiq.AsyncSleepIQ", autospec=True) as mock: client = mock.return_value diff --git a/tests/components/sleepiq/test_binary_sensor.py b/tests/components/sleepiq/test_binary_sensor.py index bbb0200dd23..65654de74ac 100644 --- a/tests/components/sleepiq/test_binary_sensor.py +++ b/tests/components/sleepiq/test_binary_sensor.py @@ -24,10 +24,11 @@ from .conftest import ( ) -async def test_binary_sensors(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_binary_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test the SleepIQ binary sensors.""" await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get( f"binary_sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_is_in_bed" diff --git a/tests/components/sleepiq/test_button.py b/tests/components/sleepiq/test_button.py index 0979d01ba7b..33ad4d72b46 100644 --- a/tests/components/sleepiq/test_button.py +++ b/tests/components/sleepiq/test_button.py @@ -8,10 +8,11 @@ from homeassistant.helpers import entity_registry as er from .conftest import BED_ID, BED_NAME, BED_NAME_LOWER, setup_platform -async def test_button_calibrate(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_button_calibrate( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test the SleepIQ calibrate button.""" await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get(f"button.sleepnumber_{BED_NAME_LOWER}_calibrate") assert ( @@ -33,10 +34,11 @@ async def test_button_calibrate(hass: HomeAssistant, mock_asyncsleepiq) -> None: mock_asyncsleepiq.beds[BED_ID].calibrate.assert_called_once() -async def test_button_stop_pump(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_button_stop_pump( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test the SleepIQ stop pump button.""" await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get(f"button.sleepnumber_{BED_NAME_LOWER}_stop_pump") assert ( diff --git a/tests/components/sleepiq/test_light.py b/tests/components/sleepiq/test_light.py index e261115c415..9564bca7a99 100644 --- a/tests/components/sleepiq/test_light.py +++ b/tests/components/sleepiq/test_light.py @@ -12,10 +12,11 @@ from .conftest import BED_ID, BED_NAME, BED_NAME_LOWER, setup_platform from tests.common import async_fire_time_changed -async def test_setup(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test for successfully setting up the SleepIQ platform.""" entry = await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) assert len(entity_registry.entities) == 2 diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index f3a38cc89e5..52df2eb27aa 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -26,10 +26,11 @@ from .conftest import ( ) -async def test_firmness(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_firmness( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test the SleepIQ firmness 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}_firmness" @@ -84,10 +85,11 @@ async def test_firmness(hass: HomeAssistant, mock_asyncsleepiq) -> None: mock_asyncsleepiq.beds[BED_ID].sleepers[0].set_sleepnumber.assert_called_with(42) -async def test_actuators(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_actuators( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test the SleepIQ actuator position values for a bed with adjustable head and foot.""" entry = await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get(f"number.sleepnumber_{BED_NAME_LOWER}_right_head_position") assert state.state == "60.0" @@ -159,10 +161,11 @@ async def test_actuators(hass: HomeAssistant, mock_asyncsleepiq) -> None: ].set_position.assert_called_with(42) -async def test_foot_warmer_timer(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_foot_warmer_timer( + hass: HomeAssistant, entity_registry: er.EntityRegistry, 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" diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index cc61494689e..ef4c7fb6df0 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -32,11 +32,12 @@ from .conftest import ( async def test_split_foundation_preset( - hass: HomeAssistant, mock_asyncsleepiq: MagicMock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, ) -> None: """Test the SleepIQ select entity for split foundation presets.""" entry = await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get( f"select.sleepnumber_{BED_NAME_LOWER}_foundation_preset_right" @@ -88,11 +89,12 @@ async def test_split_foundation_preset( async def test_single_foundation_preset( - hass: HomeAssistant, mock_asyncsleepiq_single_foundation: MagicMock + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq_single_foundation: MagicMock, ) -> None: """Test the SleepIQ select entity for single foundation presets.""" entry = await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get(f"select.sleepnumber_{BED_NAME_LOWER}_foundation_preset") assert state.state == PRESET_R_STATE @@ -127,10 +129,13 @@ async def test_single_foundation_preset( ].set_preset.assert_called_with("Zero G") -async def test_foot_warmer(hass: HomeAssistant, mock_asyncsleepiq: MagicMock) -> None: +async def test_foot_warmer( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + 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" diff --git a/tests/components/sleepiq/test_sensor.py b/tests/components/sleepiq/test_sensor.py index c027aaee87b..ae25958419c 100644 --- a/tests/components/sleepiq/test_sensor.py +++ b/tests/components/sleepiq/test_sensor.py @@ -18,10 +18,11 @@ from .conftest import ( ) -async def test_sleepnumber_sensors(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_sleepnumber_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test the SleepIQ sleepnumber for a bed with two sides.""" entry = await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get( f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_sleepnumber" @@ -56,10 +57,11 @@ async def test_sleepnumber_sensors(hass: HomeAssistant, mock_asyncsleepiq) -> No assert entry.unique_id == f"{SLEEPER_R_ID}_sleep_number" -async def test_pressure_sensors(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_pressure_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test the SleepIQ pressure for a bed with two sides.""" entry = await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) state = hass.states.get( f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_pressure" diff --git a/tests/components/sleepiq/test_switch.py b/tests/components/sleepiq/test_switch.py index 8ab865663dc..7c41b6b9d19 100644 --- a/tests/components/sleepiq/test_switch.py +++ b/tests/components/sleepiq/test_switch.py @@ -12,10 +12,11 @@ from .conftest import BED_ID, BED_NAME, BED_NAME_LOWER, setup_platform from tests.common import async_fire_time_changed -async def test_setup(hass: HomeAssistant, mock_asyncsleepiq) -> None: +async def test_setup( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: """Test for successfully setting up the SleepIQ platform.""" entry = await setup_platform(hass, DOMAIN) - entity_registry = er.async_get(hass) assert len(entity_registry.entities) == 1 diff --git a/tests/components/slimproto/conftest.py b/tests/components/slimproto/conftest.py index 637f5ec0a99..ece30d3e5cf 100644 --- a/tests/components/slimproto/conftest.py +++ b/tests/components/slimproto/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.slimproto.const import DOMAIN @@ -23,7 +23,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.slimproto.async_setup_entry", diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index 82f5baf952f..c06ab551ef6 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -4,6 +4,8 @@ from http import HTTPStatus from ipaddress import ip_address from unittest.mock import patch +import pytest + from homeassistant import setup from homeassistant.components import zeroconf from homeassistant.components.smappee.const import ( @@ -427,11 +429,11 @@ async def test_abort_cloud_flow_if_local_device_exists(hass: HomeAssistant) -> N assert len(hass.config_entries.async_entries(DOMAIN)) == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_full_user_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" assert await setup.async_setup_component( diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d25cc8849e5..17e2c781989 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -1,6 +1,7 @@ """Test configuration and mocks for the SmartThings component.""" import secrets +from typing import Any from unittest.mock import Mock, patch from uuid import uuid4 @@ -38,13 +39,14 @@ from homeassistant.components.smartthings.const import ( STORAGE_VERSION, ) from homeassistant.config import async_process_ha_core_config -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID, ) +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -53,7 +55,9 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 COMPONENT_PREFIX = "homeassistant.components.smartthings." -async def setup_platform(hass, platform: str, *, devices=None, scenes=None): +async def setup_platform( + hass: HomeAssistant, platform: str, *, devices=None, scenes=None +): """Set up the SmartThings platform and prerequisites.""" hass.config.components.add(DOMAIN) config_entry = MockConfigEntry( @@ -68,13 +72,16 @@ async def setup_platform(hass, platform: str, *, devices=None, scenes=None): ) hass.data[DOMAIN] = {DATA_BROKERS: {config_entry.entry_id: broker}} - await hass.config_entries.async_forward_entry_setup(config_entry, platform) + config_entry.mock_state(hass, ConfigEntryState.LOADED) + await hass.config_entries.async_forward_entry_setups(config_entry, [platform]) await hass.async_block_till_done() return config_entry @pytest.fixture(autouse=True) -async def setup_component(hass, config_file, hass_storage): +async def setup_component( + hass: HomeAssistant, config_file: dict[str, str], hass_storage: dict[str, Any] +) -> None: """Load the SmartThing component.""" hass_storage[STORAGE_KEY] = {"data": config_file, "version": STORAGE_VERSION} await async_process_ha_core_config( @@ -166,7 +173,7 @@ def installed_apps_fixture(installed_app, locations, app): @pytest.fixture(name="config_file") -def config_file_fixture(): +def config_file_fixture() -> dict[str, str]: """Fixture representing the local config file contents.""" return {CONF_INSTANCE_ID: str(uuid4()), CONF_WEBHOOK_ID: secrets.token_hex()} diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 9d704cdf8c9..52fd5d28aa7 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -47,7 +47,10 @@ async def test_entity_state(hass: HomeAssistant, device_factory) -> None: async def test_entity_and_device_attributes( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -62,8 +65,6 @@ async def test_entity_and_device_attributes( Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device]) # Assert @@ -117,7 +118,9 @@ async def test_unload_config_entry(hass: HomeAssistant, device_factory) -> None: ) -async def test_entity_category(hass: HomeAssistant, device_factory) -> None: +async def test_entity_category( + hass: HomeAssistant, entity_registry: er.EntityRegistry, device_factory +) -> None: """Tests the state attributes properly match the light types.""" device1 = device_factory( "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} @@ -127,7 +130,6 @@ async def test_entity_category(hass: HomeAssistant, device_factory) -> None: ) await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device1, device2]) - entity_registry = er.async_get(hass) entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") assert entry assert entry.entity_category is None diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 3fb293e587f..e4b8cb6d373 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -17,6 +17,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_PRESET_MODE, + ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, @@ -29,7 +30,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.climate.const import ATTR_SWING_MODE from homeassistant.components.smartthings import climate from homeassistant.components.smartthings.const import DOMAIN from homeassistant.const import ( @@ -202,6 +202,60 @@ def air_conditioner_fixture(device_factory): return device +@pytest.fixture(name="air_conditioner_windfree") +def air_conditioner_windfree_fixture(device_factory): + """Fixture returns a air conditioner.""" + device = device_factory( + "Air Conditioner", + capabilities=[ + Capability.air_conditioner_mode, + Capability.demand_response_load_control, + Capability.air_conditioner_fan_mode, + Capability.switch, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint, + Capability.fan_oscillation_mode, + ], + status={ + Attribute.air_conditioner_mode: "auto", + Attribute.supported_ac_modes: [ + "cool", + "dry", + "wind", + "auto", + "heat", + "wind", + ], + Attribute.drlc_status: { + "duration": 0, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "override": False, + }, + Attribute.fan_mode: "medium", + Attribute.supported_ac_fan_modes: [ + "auto", + "low", + "medium", + "high", + "turbo", + ], + Attribute.switch: "on", + Attribute.cooling_setpoint: 23, + "supportedAcOptionalMode": ["windFree"], + Attribute.supported_fan_oscillation_modes: [ + "all", + "horizontal", + "vertical", + "fixed", + ], + Attribute.fan_oscillation_mode: "vertical", + }, + ) + device.status.attributes[Attribute.temperature] = Status(24, "C", None) + return device + + async def test_legacy_thermostat_entity_state( hass: HomeAssistant, legacy_thermostat ) -> None: @@ -424,6 +478,23 @@ async def test_ac_set_hvac_mode_off(hass: HomeAssistant, air_conditioner) -> Non assert state.state == HVACMode.OFF +async def test_ac_set_hvac_mode_wind( + hass: HomeAssistant, air_conditioner_windfree +) -> None: + """Test the AC HVAC mode to fan only as wind mode for supported models.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner_windfree]) + state = hass.states.get("climate.air_conditioner") + assert state.state != HVACMode.OFF + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.air_conditioner", ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + blocking=True, + ) + state = hass.states.get("climate.air_conditioner") + assert state.state == HVACMode.FAN_ONLY + + async def test_set_temperature_heat_mode(hass: HomeAssistant, thermostat) -> None: """Test the temperature is set successfully when in heat mode.""" thermostat.status.thermostat_mode = "heat" @@ -597,11 +668,14 @@ async def test_set_turn_on(hass: HomeAssistant, air_conditioner) -> None: assert state.state == HVACMode.HEAT_COOL -async def test_entity_and_device_attributes(hass: HomeAssistant, thermostat) -> None: +async def test_entity_and_device_attributes( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + thermostat, +) -> None: """Test the attributes of the entries are correct.""" await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) entry = entity_registry.async_get("climate.thermostat") assert entry diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index e19ac403e5d..bb292b53ee8 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -29,7 +29,10 @@ from .conftest import setup_platform async def test_entity_and_device_attributes( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -44,8 +47,6 @@ async def test_entity_and_device_attributes( Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, COVER_DOMAIN, devices=[device]) # Assert diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index b8928ef5247..043c022b225 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -44,7 +44,10 @@ async def test_entity_state(hass: HomeAssistant, device_factory) -> None: async def test_entity_and_device_attributes( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -62,8 +65,6 @@ async def test_entity_and_device_attributes( ) # Act await setup_platform(hass, FAN_DOMAIN, devices=[device]) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Assert entry = entity_registry.async_get("fan.fan_1") assert entry diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 53de2273707..22b181a3645 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -106,7 +106,10 @@ async def test_entity_state(hass: HomeAssistant, light_devices) -> None: async def test_entity_and_device_attributes( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -120,8 +123,6 @@ async def test_entity_and_device_attributes( Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, LIGHT_DOMAIN, devices=[device]) # Assert diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 2e149df6213..3c2a2651fb9 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -19,7 +19,10 @@ from .conftest import setup_platform async def test_entity_and_device_attributes( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -34,8 +37,6 @@ async def test_entity_and_device_attributes( Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, LOCK_DOMAIN, devices=[device]) # Assert diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py index d33db0a1dd9..a20db1aaae8 100644 --- a/tests/components/smartthings/test_scene.py +++ b/tests/components/smartthings/test_scene.py @@ -13,10 +13,10 @@ from homeassistant.helpers import entity_registry as er from .conftest import setup_platform -async def test_entity_and_device_attributes(hass: HomeAssistant, scene) -> None: +async def test_entity_and_device_attributes( + hass: HomeAssistant, entity_registry: er.EntityRegistry, scene +) -> None: """Test the attributes of the entity are correct.""" - # Arrange - entity_registry = er.async_get(hass) # Act await setup_platform(hass, SCENE_DOMAIN, scenes=[scene]) # Assert diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 6529a7f25f0..021ee9cc810 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -87,7 +87,10 @@ async def test_entity_three_axis_invalid_state( async def test_entity_and_device_attributes( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -102,8 +105,6 @@ async def test_entity_and_device_attributes( Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) # Assert @@ -123,7 +124,10 @@ async def test_entity_and_device_attributes( async def test_energy_sensors_for_switch_device( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -140,8 +144,6 @@ async def test_energy_sensors_for_switch_device( Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) # Assert @@ -180,7 +182,12 @@ async def test_energy_sensors_for_switch_device( assert entry.sw_version == "v7.89" -async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> None: +async def test_power_consumption_sensor( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, +) -> None: """Test the attributes of the entity are correct.""" # Arrange device = device_factory( @@ -203,8 +210,6 @@ async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) # Assert @@ -253,8 +258,6 @@ async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) # Assert diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index d858a9eea5a..fadd7600e87 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -18,7 +18,10 @@ from .conftest import setup_platform async def test_entity_and_device_attributes( - hass: HomeAssistant, device_factory + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_factory, ) -> None: """Test the attributes of the entity are correct.""" # Arrange @@ -33,8 +36,6 @@ async def test_entity_and_device_attributes( Attribute.mnfv: "v7.89", }, ) - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) # Act await setup_platform(hass, SWITCH_DOMAIN, devices=[device]) # Assert diff --git a/tests/components/smhi/conftest.py b/tests/components/smhi/conftest.py index 62da5207565..95fbc15e69d 100644 --- a/tests/components/smhi/conftest.py +++ b/tests/components/smhi/conftest.py @@ -13,6 +13,12 @@ def api_response(): return load_fixture("smhi.json", DOMAIN) +@pytest.fixture(scope="package") +def api_response_night(): + """Return an API response for night only.""" + return load_fixture("smhi_night.json", DOMAIN) + + @pytest.fixture(scope="package") def api_response_lack_data(): """Return an API response.""" diff --git a/tests/components/smhi/fixtures/smhi_night.json b/tests/components/smhi/fixtures/smhi_night.json new file mode 100644 index 00000000000..121544bd2f1 --- /dev/null +++ b/tests/components/smhi/fixtures/smhi_night.json @@ -0,0 +1,700 @@ +{ + "approvedTime": "2023-08-07T07:07:34Z", + "referenceTime": "2023-08-07T07:00:00Z", + "geometry": { + "type": "Point", + "coordinates": [[15.990068, 57.997072]] + }, + "timeSeries": [ + { + "validTime": "2023-08-07T23:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [18.4] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [0.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [93] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [37] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [1] + } + ] + }, + { + "validTime": "2023-08-08T00:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [18.2] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [0.1] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [103] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [27] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.6] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [1] + } + ] + }, + { + "validTime": "2023-08-08T01:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.5] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [104] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [27] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.6] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [1] + } + ] + }, + { + "validTime": "2023-08-08T02:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.6] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [109] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.6] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [1] + } + ] + }, + { + "validTime": "2023-08-08T03:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.1] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [114] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [96] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [1] + } + ] + } + ] +} diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index 0fef9e19ec3..0d2f6b3b3bf 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -1,4 +1,85 @@ # serializer version: 1 +# name: test_clear_night[clear-night_forecast] + dict({ + 'weather.smhi_test': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'clear-night', + 'datetime': '2023-08-08T00:00:00', + 'humidity': 100, + 'precipitation': 0.0, + 'pressure': 992.0, + 'temperature': 18.0, + 'templow': 18.0, + 'wind_bearing': 103, + 'wind_gust_speed': 23.76, + 'wind_speed': 9.72, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'clear-night', + 'datetime': '2023-08-08T01:00:00', + 'humidity': 100, + 'precipitation': 0.0, + 'pressure': 992.0, + 'temperature': 18.0, + 'templow': 18.0, + 'wind_bearing': 104, + 'wind_gust_speed': 27.36, + 'wind_speed': 9.72, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'clear-night', + 'datetime': '2023-08-08T02:00:00', + 'humidity': 97, + 'precipitation': 0.0, + 'pressure': 992.0, + 'temperature': 18.0, + 'templow': 18.0, + 'wind_bearing': 109, + 'wind_gust_speed': 32.4, + 'wind_speed': 12.96, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'sunny', + 'datetime': '2023-08-08T03:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 17.0, + 'templow': 17.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + ]), + }), + }) +# --- +# name: test_clear_night[clear_night] + ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'cloud_coverage': 100, + 'friendly_name': 'test', + 'humidity': 100, + 'precipitation_unit': , + 'pressure': 992.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 18.0, + 'temperature_unit': , + 'thunder_probability': 37, + 'visibility': 0.4, + 'visibility_unit': , + 'wind_bearing': 93, + 'wind_gust_speed': 22.32, + 'wind_speed': 9.0, + 'wind_speed_unit': , + }) +# --- # name: test_forecast_service[get_forecast] dict({ 'forecast': list([ diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index aefbccb64ec..cfb386c8f6f 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -6,7 +6,7 @@ from smhi.smhi_lib import APIURL_TEMPLATE from homeassistant.components.smhi.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er from . import ENTITY_ID, TEST_CONFIG, TEST_CONFIG_MIGRATE @@ -57,7 +57,10 @@ async def test_remove_entry( async def test_migrate_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + api_response: str, ) -> None: """Test migrate entry data.""" uri = APIURL_TEMPLATE.format( @@ -68,8 +71,7 @@ async def test_migrate_entry( entry.add_to_hass(hass) assert entry.version == 1 - entity_reg = async_get(hass) - entity = entity_reg.async_get_or_create( + entity = entity_registry.async_get_or_create( domain="weather", config_entry=entry, original_name="Weather", @@ -87,7 +89,7 @@ async def test_migrate_entry( assert entry.version == 2 assert entry.unique_id == "17.84197-17.84197" - entity_get = entity_reg.async_get(entity.entity_id) + entity_get = entity_registry.async_get(entity.entity_id) assert entity_get.unique_id == "17.84197, 17.84197" diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 4d187e7c728..1870d7b498a 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from unittest.mock import patch +from freezegun import freeze_time import pytest from smhi.smhi_lib import APIURL_TEMPLATE, SmhiForecast, SmhiForecastException from syrupy.assertion import SnapshotAssertion @@ -10,26 +11,24 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY from homeassistant.components.smhi.weather import CONDITION_CLASSES, RETRY_TIMEOUT from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, ATTR_FORECAST_CONDITION, + ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) -from homeassistant.components.weather.const import ( - ATTR_WEATHER_CLOUD_COVERAGE, - ATTR_WEATHER_WIND_GUST_SPEED, -) from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN, UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow +from homeassistant.util import dt as dt_util from . import ENTITY_ID, TEST_CONFIG @@ -66,6 +65,44 @@ async def test_setup_hass( assert state.attributes == snapshot +@freeze_time(datetime(2023, 8, 7, 1, tzinfo=dt_util.UTC)) +async def test_clear_night( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + api_response_night: str, + snapshot: SnapshotAssertion, +) -> None: + """Test for successfully setting up the smhi integration.""" + hass.config.latitude = "59.32624" + hass.config.longitude = "17.84197" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response_night) + + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + state = hass.states.get(ENTITY_ID) + + assert state + assert state.state == ATTR_CONDITION_CLEAR_NIGHT + assert state.attributes == snapshot(name="clear_night") + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + {"entity_id": ENTITY_ID, "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == snapshot(name="clear-night_forecast") + + async def test_properties_no_data(hass: HomeAssistant) -> None: """Test properties when no API data available.""" entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) @@ -197,7 +234,7 @@ async def test_refresh_weather_forecast_retry( """Test the refresh weather forecast function.""" entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) - now = utcnow() + now = dt_util.utcnow() with patch( "homeassistant.components.smhi.weather.Smhi.async_get_forecast", @@ -309,7 +346,10 @@ def test_condition_class() -> None: async def test_custom_speed_unit( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + api_response: str, ) -> None: """Test Wind Gust speed with custom unit.""" uri = APIURL_TEMPLATE.format( @@ -329,8 +369,7 @@ async def test_custom_speed_unit( assert state.name == "test" assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 22.32 - entity_reg = er.async_get(hass) - entity_reg.async_update_entity_options( + entity_registry.async_update_entity_options( state.entity_id, WEATHER_DOMAIN, {ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND}, @@ -449,10 +488,7 @@ async def test_forecast_services_lack_of_data( @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_forecast_service( hass: HomeAssistant, diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 15be7b66d27..901d7e547fe 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -173,7 +173,6 @@ def test_sending_insecure_files_fails( pytest.raises(ServiceValidationError) as exc, ): result, _ = message.send_message(message_data, data=data) - assert content_type in result assert exc.value.translation_key == "remote_path_not_allowed" assert exc.value.translation_domain == DOMAIN assert ( diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py index 9e3325bd73a..e5806ac5f40 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -1,13 +1,13 @@ """Test the snapcast config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.snapcast.async_setup_entry", return_value=True @@ -16,7 +16,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_create_server() -> Generator[AsyncMock, None, None]: +def mock_create_server() -> Generator[AsyncMock]: """Create mock snapcast connection.""" mock_connection = AsyncMock() mock_connection.start = AsyncMock(return_value=None) diff --git a/tests/components/snmp/test_float_sensor.py b/tests/components/snmp/test_float_sensor.py index 0e11ee03968..a4f6e21dad7 100644 --- a/tests/components/snmp/test_float_sensor.py +++ b/tests/components/snmp/test_float_sensor.py @@ -41,7 +41,9 @@ async def test_basic_config(hass: HomeAssistant) -> None: assert state.attributes == {"friendly_name": "SNMP"} -async def test_entity_config(hass: HomeAssistant) -> None: +async def test_entity_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity configuration.""" config = { @@ -64,7 +66,6 @@ async def test_entity_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get("sensor.snmp_sensor").unique_id == "very_unique" state = hass.states.get("sensor.snmp_sensor") diff --git a/tests/components/snmp/test_init.py b/tests/components/snmp/test_init.py new file mode 100644 index 00000000000..0aa97dcc475 --- /dev/null +++ b/tests/components/snmp/test_init.py @@ -0,0 +1,22 @@ +"""SNMP tests.""" + +from unittest.mock import patch + +from pysnmp.hlapi.asyncio import SnmpEngine +from pysnmp.hlapi.asyncio.cmdgen import lcd + +from homeassistant.components import snmp +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + + +async def test_async_get_snmp_engine(hass: HomeAssistant) -> None: + """Test async_get_snmp_engine.""" + engine = await snmp.async_get_snmp_engine(hass) + assert isinstance(engine, SnmpEngine) + engine2 = await snmp.async_get_snmp_engine(hass) + assert engine is engine2 + with patch.object(lcd, "unconfigure") as mock_unconfigure: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert mock_unconfigure.called diff --git a/tests/components/snmp/test_integer_sensor.py b/tests/components/snmp/test_integer_sensor.py index 0ea9ac4d434..dab2b080c97 100644 --- a/tests/components/snmp/test_integer_sensor.py +++ b/tests/components/snmp/test_integer_sensor.py @@ -41,7 +41,9 @@ async def test_basic_config(hass: HomeAssistant) -> None: assert state.attributes == {"friendly_name": "SNMP"} -async def test_entity_config(hass: HomeAssistant) -> None: +async def test_entity_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity configuration.""" config = { @@ -64,7 +66,6 @@ async def test_entity_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get("sensor.snmp_sensor").unique_id == "very_unique" state = hass.states.get("sensor.snmp_sensor") diff --git a/tests/components/snmp/test_negative_sensor.py b/tests/components/snmp/test_negative_sensor.py index c5ac6460841..dba09ea75bd 100644 --- a/tests/components/snmp/test_negative_sensor.py +++ b/tests/components/snmp/test_negative_sensor.py @@ -41,7 +41,9 @@ async def test_basic_config(hass: HomeAssistant) -> None: assert state.attributes == {"friendly_name": "SNMP"} -async def test_entity_config(hass: HomeAssistant) -> None: +async def test_entity_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity configuration.""" config = { @@ -64,7 +66,6 @@ async def test_entity_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get("sensor.snmp_sensor").unique_id == "very_unique" state = hass.states.get("sensor.snmp_sensor") diff --git a/tests/components/snmp/test_string_sensor.py b/tests/components/snmp/test_string_sensor.py index 536b819b711..5362e79c98d 100644 --- a/tests/components/snmp/test_string_sensor.py +++ b/tests/components/snmp/test_string_sensor.py @@ -41,7 +41,9 @@ async def test_basic_config(hass: HomeAssistant) -> None: assert state.attributes == {"friendly_name": "SNMP"} -async def test_entity_config(hass: HomeAssistant) -> None: +async def test_entity_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test entity configuration.""" config = { @@ -61,7 +63,6 @@ async def test_entity_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert entity_registry.async_get("sensor.snmp_sensor").unique_id == "very_unique" state = hass.states.get("sensor.snmp_sensor") diff --git a/tests/components/snooz/conftest.py b/tests/components/snooz/conftest.py index 8cdc2ec0982..e15c7d836c8 100644 --- a/tests/components/snooz/conftest.py +++ b/tests/components/snooz/conftest.py @@ -10,7 +10,7 @@ from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 7a6b3af1cde..984c343a657 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -21,7 +21,7 @@ API_KEY = "a1b2c3d4e5f6g7h8" @pytest.fixture(autouse=True) -def enable_all_entities(entity_registry_enabled_by_default): +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: """Make sure all entities are enabled.""" diff --git a/tests/components/solarlog/__init__.py b/tests/components/solarlog/__init__.py index 9074cab8416..74b19bd297e 100644 --- a/tests/components/solarlog/__init__.py +++ b/tests/components/solarlog/__init__.py @@ -1 +1,19 @@ """Tests for the solarlog integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_platform( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> MockConfigEntry: + """Set up the SolarLog platform.""" + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.solarlog.PLATFORMS", platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/solarlog/conftest.py b/tests/components/solarlog/conftest.py new file mode 100644 index 00000000000..08340487d99 --- /dev/null +++ b/tests/components/solarlog/conftest.py @@ -0,0 +1,90 @@ +"""Test helpers.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.solarlog.const import DOMAIN as SOLARLOG_DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import HOST, NAME + +from tests.common import ( + MockConfigEntry, + load_json_object_fixture, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=SOLARLOG_DOMAIN, + title="solarlog", + data={ + CONF_HOST: HOST, + CONF_NAME: NAME, + "extended_data": True, + }, + minor_version=2, + entry_id="ce5f5431554d101905d31797e1232da8", + ) + + +@pytest.fixture +def mock_solarlog_connector(): + """Build a fixture for the SolarLog API that connects successfully and returns one device.""" + + mock_solarlog_api = AsyncMock() + mock_solarlog_api.test_connection = AsyncMock(return_value=True) + mock_solarlog_api.update_data.return_value = load_json_object_fixture( + "solarlog_data.json", SOLARLOG_DOMAIN + ) + with ( + patch( + "homeassistant.components.solarlog.coordinator.SolarLogConnector", + autospec=True, + return_value=mock_solarlog_api, + ), + patch( + "homeassistant.components.solarlog.config_flow.SolarLogConnector", + autospec=True, + return_value=mock_solarlog_api, + ), + ): + yield mock_solarlog_api + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.solarlog.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="test_connect") +def mock_test_connection(): + """Mock a successful _test_connection.""" + with patch( + "homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection", + return_value=True, + ): + yield + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass: HomeAssistant): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="entity_reg") +def entity_reg_fixture(hass: HomeAssistant): + """Return an empty, loaded, registry.""" + return mock_registry(hass) diff --git a/tests/components/solarlog/const.py b/tests/components/solarlog/const.py new file mode 100644 index 00000000000..e23633c80ae --- /dev/null +++ b/tests/components/solarlog/const.py @@ -0,0 +1,4 @@ +"""Common const used across tests for SolarLog.""" + +NAME = "Solarlog test 1 2 3" +HOST = "http://1.1.1.1" diff --git a/tests/components/solarlog/fixtures/solarlog_data.json b/tests/components/solarlog/fixtures/solarlog_data.json new file mode 100644 index 00000000000..4976f4fa8b7 --- /dev/null +++ b/tests/components/solarlog/fixtures/solarlog_data.json @@ -0,0 +1,24 @@ +{ + "power_ac": 100, + "power_dc": 102, + "voltage_ac": 100, + "voltage_dc": 100, + "yield_day": 4.21, + "yield_yesterday": 5.21, + "yield_month": 515, + "yield_year": 1023, + "yield_total": 56513, + "consumption_ac": 54.87, + "consumption_day": 5.31, + "consumption_yesterday": 7.34, + "consumption_month": 758, + "consumption_year": 4587, + "consumption_total": 354687, + "total_power": 120, + "self_consumption_year": 545, + "alternator_loss": 2, + "efficiency": 0.9804, + "usage": 0.5487, + "power_available": 45.13, + "capacity": 0.85 +} diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5fb369bc3b6 --- /dev/null +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -0,0 +1,2234 @@ +# serializer version: 1 +# name: test_all_entities[sensor.solarlog_alternator_loss-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_alternator_loss', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alternator loss', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alternator_loss', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_alternator_loss', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_alternator_loss-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog Alternator loss', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_alternator_loss', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[sensor.solarlog_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Capacity', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'capacity', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_capacity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'solarlog Capacity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85.0', + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_consumption_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption AC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog Consumption AC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_consumption_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.87', + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_consumption_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption day', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_day', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Consumption day', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_consumption_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.005', + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_consumption_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption month', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_month', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Consumption month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_consumption_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.758', + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_consumption_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption total', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_total', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Consumption total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_consumption_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '354.687', + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_consumption_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption year', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Consumption year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_consumption_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.587', + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_consumption_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption yesterday', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_yesterday', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_consumption_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Consumption yesterday', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_consumption_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[sensor.solarlog_efficiency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_efficiency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Efficiency', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'efficiency', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_efficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_efficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'solarlog Efficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_efficiency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98.0', + }) +# --- +# name: test_all_entities[sensor.solarlog_installed_peak_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_installed_peak_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installed peak power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_total_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_installed_peak_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog Installed peak power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_installed_peak_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_all_entities[sensor.solarlog_last_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_last_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_update', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_last_updated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solarlog_last_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'solarlog Last update', + }), + 'context': , + 'entity_id': 'sensor.solarlog_last_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.solarlog_power_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_power_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power AC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_power_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog Power AC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_power_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.solarlog_power_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_power_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power available', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_available', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_available', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_power_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog Power available', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_power_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.13', + }) +# --- +# name: test_all_entities[sensor.solarlog_power_dc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_power_dc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power DC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_dc', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_power_dc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog Power DC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_power_dc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.solarlog_self_consumption_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_self_consumption_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Self-consumption year', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'self_consumption_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_self_consumption_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_self_consumption_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Self-consumption year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_self_consumption_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '545', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_alternator_loss-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_alternator_loss', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alternator loss', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alternator_loss', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_alternator_loss', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_alternator_loss-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog_test_1_2_3 Alternator loss', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_alternator_loss', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Capacity', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'capacity', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_capacity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'solarlog_test_1_2_3 Capacity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '85.0', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption AC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog_test_1_2_3 Consumption AC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.87', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption day', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_day', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Consumption day', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.005', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption month', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_month', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Consumption month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.758', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption total', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_total', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Consumption total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '354.687', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption year', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Consumption year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.587', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption yesterday', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_yesterday', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_consumption_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Consumption yesterday', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_consumption_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.007', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_efficiency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_efficiency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Efficiency', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'efficiency', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_efficiency', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_efficiency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'solarlog_test_1_2_3 Efficiency', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_efficiency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98.0', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_installed_peak_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_installed_peak_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installed peak power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_total_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_installed_peak_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog_test_1_2_3 Installed peak power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_installed_peak_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_last_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_last_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last update', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_update', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_last_updated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_last_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'solarlog_test_1_2_3 Last update', + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_last_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_power_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_power_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power AC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_power_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog_test_1_2_3 Power AC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_power_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_power_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_power_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power available', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_available', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_available', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_power_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog_test_1_2_3 Power available', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_power_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.13', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_power_dc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_power_dc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power DC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_dc', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_power_dc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'solarlog_test_1_2_3 Power DC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_power_dc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '102', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Usage', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'usage', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'solarlog_test_1_2_3 Usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.9', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_voltage_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_voltage_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage AC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_voltage_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'solarlog_test_1_2_3 Voltage AC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_voltage_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_voltage_dc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_voltage_dc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage DC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_voltage_dc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'solarlog_test_1_2_3 Voltage DC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_voltage_dc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield day', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_day', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Yield day', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.004', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield month', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_month', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Yield month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.515', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield total', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_total', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Yield total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '56.513', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield year', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Yield year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.023', + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield yesterday', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_yesterday', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_test_1_2_3_yield_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog_test_1_2_3 Yield yesterday', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_test_1_2_3_yield_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.005', + }) +# --- +# name: test_all_entities[sensor.solarlog_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Usage', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'usage', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_usage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solarlog_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'solarlog Usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solarlog_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.9', + }) +# --- +# name: test_all_entities[sensor.solarlog_voltage_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_voltage_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage AC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_ac', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_voltage_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'solarlog Voltage AC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_voltage_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.solarlog_voltage_dc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_voltage_dc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage DC', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_dc', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_dc', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_voltage_dc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'solarlog Voltage DC', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_voltage_dc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_day-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_yield_day', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield day', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_day', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_day', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_day-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Yield day', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_yield_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.004', + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_yield_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield month', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_month', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Yield month', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_yield_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.515', + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_yield_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield total', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_total', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Yield total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_yield_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '56.513', + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_yield_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield year', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Yield year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_yield_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.023', + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_yesterday-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solarlog_yield_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield yesterday', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'yield_yesterday', + 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solarlog_yield_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'solarlog Yield yesterday', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solarlog_yield_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.005', + }) +# --- diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index c356a129806..34da13cdf8f 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -1,8 +1,9 @@ """Test the solarlog config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from solarlog_cli.solarlog_exceptions import SolarLogConnectionError, SolarLogError from homeassistant import config_entries from homeassistant.components.solarlog import config_flow @@ -11,13 +12,12 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import HOST, NAME + from tests.common import MockConfigEntry -NAME = "Solarlog test 1 2 3" -HOST = "http://1.1.1.1" - -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -29,34 +29,22 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( "homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection", - return_value={"title": "solarlog test 1 2 3"}, - ), - patch( - "homeassistant.components.solarlog.async_setup_entry", return_value=True, - ) as mock_setup_entry, + ), ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {"host": HOST, "name": NAME} + result["flow_id"], + {CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": False}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "solarlog_test_1_2_3" - assert result2["data"] == {"host": "http://1.1.1.1"} + assert result2["data"][CONF_HOST] == "http://1.1.1.1" + assert result2["data"]["extended_data"] is False assert len(mock_setup_entry.mock_calls) == 1 -@pytest.fixture(name="test_connect") -def mock_controller(): - """Mock a successful _host_in_configuration_exists.""" - with patch( - "homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection", - return_value=True, - ): - yield - - def init_config_flow(hass): """Init a configuration flow.""" flow = config_flow.SolarLogConfigFlow() @@ -64,19 +52,75 @@ def init_config_flow(hass): return flow -async def test_user(hass: HomeAssistant, test_connect) -> None: +@pytest.mark.usefixtures("test_connect") +async def test_user( + hass: HomeAssistant, + mock_solarlog_connector: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: """Test user config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + # tests with all provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": False} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "solarlog_test_1_2_3" + assert result["data"][CONF_HOST] == HOST + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SolarLogConnectionError, {CONF_HOST: "cannot_connect"}), + (SolarLogError, {CONF_HOST: "unknown"}), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + exception: Exception, + error: dict[str, str], + mock_solarlog_connector: AsyncMock, +) -> None: + """Test we can handle Form exceptions.""" flow = init_config_flow(hass) result = await flow.async_step_user() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - # tets with all provided - result = await flow.async_step_user({CONF_NAME: NAME, CONF_HOST: HOST}) + mock_solarlog_connector.test_connection.side_effect = exception + + # tests with connection error + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_HOST: HOST, "extended_data": False} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == error + + mock_solarlog_connector.test_connection.side_effect = None + + # tests with all provided + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_HOST: HOST, "extended_data": False} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "solarlog_test_1_2_3" assert result["data"][CONF_HOST] == HOST + assert result["data"]["extended_data"] is False async def test_import(hass: HomeAssistant, test_connect) -> None: @@ -85,18 +129,24 @@ async def test_import(hass: HomeAssistant, test_connect) -> None: # import with only host result = await flow.async_step_import({CONF_HOST: HOST}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "solarlog" assert result["data"][CONF_HOST] == HOST # import with only name result = await flow.async_step_import({CONF_NAME: NAME}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "solarlog_test_1_2_3" assert result["data"][CONF_HOST] == DEFAULT_HOST # import with host and name result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "solarlog_test_1_2_3" assert result["data"][CONF_HOST] == HOST @@ -111,7 +161,7 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect) -> None # Should fail, same HOST different NAME (default) result = await flow.async_step_import( - {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9"} + {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9", "extended_data": False} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -123,7 +173,7 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect) -> None # SHOULD pass, diff HOST (without http://), different NAME result = await flow.async_step_import( - {CONF_HOST: "2.2.2.2", CONF_NAME: "solarlog_test_7_8_9"} + {CONF_HOST: "2.2.2.2", CONF_NAME: "solarlog_test_7_8_9", "extended_data": False} ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "solarlog_test_7_8_9" @@ -131,8 +181,44 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect) -> None # SHOULD pass, diff HOST, same NAME result = await flow.async_step_import( - {CONF_HOST: "http://2.2.2.2", CONF_NAME: NAME} + {CONF_HOST: "http://2.2.2.2", CONF_NAME: NAME, "extended_data": False} ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "solarlog_test_1_2_3" assert result["data"][CONF_HOST] == "http://2.2.2.2" + + +async def test_reconfigure_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test config flow options.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="solarlog_test_1_2_3", + data={ + CONF_HOST: HOST, + "extended_data": False, + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"extended_data": True} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/solarlog/test_init.py b/tests/components/solarlog/test_init.py new file mode 100644 index 00000000000..f9f00ef601b --- /dev/null +++ b/tests/components/solarlog/test_init.py @@ -0,0 +1,95 @@ +"""Test the initialization.""" + +from unittest.mock import AsyncMock + +from solarlog_cli.solarlog_exceptions import SolarLogConnectionError + +from homeassistant.components.solarlog.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import setup_platform +from .const import HOST, NAME + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_solarlog_connector: AsyncMock, +) -> None: + """Test load and unload.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_raise_config_entry_not_ready_when_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_solarlog_connector: AsyncMock, +) -> None: + """Config entry state is SETUP_RETRY when Solarlog is offline.""" + + mock_solarlog_connector.update_data.side_effect = SolarLogConnectionError + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_migrate_config_entry( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +) -> None: + """Test successful migration of entry data.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=NAME, + data={ + CONF_HOST: HOST, + }, + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + device = device_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Solar-Log", + name="solarlog", + ) + sensor_entity = entity_reg.async_get_or_create( + config_entry=entry, + platform=DOMAIN, + domain=Platform.SENSOR, + unique_id=f"{entry.entry_id}_time", + device_id=device.id, + ) + + assert entry.version == 1 + assert entry.minor_version == 1 + assert sensor_entity.unique_id == f"{entry.entry_id}_time" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_reg.async_get(sensor_entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}_last_updated" + + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data[CONF_HOST] == HOST + assert entry.data["extended_data"] is False diff --git a/tests/components/solarlog/test_sensor.py b/tests/components/solarlog/test_sensor.py new file mode 100644 index 00000000000..bc90e8b25c0 --- /dev/null +++ b/tests/components/solarlog/test_sensor.py @@ -0,0 +1,59 @@ +"""Test the Home Assistant solarlog sensor module.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from solarlog_cli.solarlog_exceptions import ( + SolarLogConnectionError, + SolarLogUpdateError, +) +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_solarlog_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "exception", + [ + SolarLogConnectionError, + SolarLogUpdateError, + ], +) +async def test_connection_error( + hass: HomeAssistant, + exception: Exception, + mock_solarlog_connector: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + + mock_solarlog_connector.update_data.side_effect = exception + + freezer.tick(delta=timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.solarlog_power_ac").state == STATE_UNAVAILABLE diff --git a/tests/components/sonarr/conftest.py b/tests/components/sonarr/conftest.py index 7c18fb372a1..739880a99aa 100644 --- a/tests/components/sonarr/conftest.py +++ b/tests/components/sonarr/conftest.py @@ -1,6 +1,5 @@ """Fixtures for Sonarr integration tests.""" -from collections.abc import Generator import json from unittest.mock import MagicMock, patch @@ -14,6 +13,7 @@ from aiopyarr import ( SystemStatus, ) import pytest +from typing_extensions import Generator from homeassistant.components.sonarr.const import ( CONF_BASE_PATH, @@ -102,14 +102,14 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.sonarr.async_setup_entry", return_value=True): yield @pytest.fixture -def mock_sonarr_config_flow() -> Generator[None, MagicMock, None]: +def mock_sonarr_config_flow() -> Generator[MagicMock]: """Return a mocked Sonarr client.""" with patch( "homeassistant.components.sonarr.config_flow.SonarrClient", autospec=True @@ -127,7 +127,7 @@ def mock_sonarr_config_flow() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_sonarr() -> Generator[None, MagicMock, None]: +def mock_sonarr() -> Generator[MagicMock]: """Return a mocked Sonarr client.""" with patch( "homeassistant.components.sonarr.SonarrClient", autospec=True diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 3641ae95de8..3ccff4c88ba 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -22,15 +22,14 @@ from tests.common import MockConfigEntry, async_fire_time_changed UPCOMING_ENTITY_ID = f"{SENSOR_DOMAIN}.sonarr_upcoming" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_sonarr: MagicMock, - entity_registry_enabled_by_default: None, ) -> None: """Test the creation and values of the sensors.""" - registry = er.async_get(hass) - sensors = { "commands": "sonarr_commands", "diskspace": "sonarr_disk_space", @@ -44,7 +43,7 @@ async def test_sensors( await hass.async_block_till_done() for unique, oid in sensors.items(): - entity = registry.async_get(f"sensor.{oid}") + entity = entity_registry.async_get(f"sensor.{oid}") assert entity assert entity.unique_id == f"{mock_config_entry.entry_id}_{unique}" @@ -100,16 +99,15 @@ async def test_sensors( ) async def test_disabled_by_default_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, entity_id: str, ) -> None: """Test the disabled by default sensors.""" - registry = er.async_get(hass) - state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/songpal/__init__.py b/tests/components/songpal/__init__.py index ab585c5a6d5..15bf0c530d3 100644 --- a/tests/components/songpal/__init__.py +++ b/tests/components/songpal/__init__.py @@ -23,7 +23,9 @@ CONF_DATA = { } -def _create_mocked_device(throw_exception=False, wired_mac=MAC, wireless_mac=None): +def _create_mocked_device( + throw_exception=False, wired_mac=MAC, wireless_mac=None, no_soundfield=False +): mocked_device = MagicMock() type(mocked_device).get_supported_methods = AsyncMock( @@ -101,7 +103,14 @@ def _create_mocked_device(throw_exception=False, wired_mac=MAC, wireless_mac=Non soundField = MagicMock() soundField.currentValue = "sound_mode2" soundField.candidate = [sound_mode1, sound_mode2, sound_mode3] - type(mocked_device).get_sound_settings = AsyncMock(return_value=[soundField]) + + settings = MagicMock() + settings.target = "soundField" + settings.__iter__.return_value = [soundField] + + type(mocked_device).get_sound_settings = AsyncMock( + return_value=[] if no_soundfield else [settings] + ) type(mocked_device).set_power = AsyncMock() type(mocked_device).set_sound_settings = AsyncMock() diff --git a/tests/components/songpal/test_media_player.py b/tests/components/songpal/test_media_player.py index 88443bf58b9..8f56170b839 100644 --- a/tests/components/songpal/test_media_player.py +++ b/tests/components/songpal/test_media_player.py @@ -122,7 +122,11 @@ async def test_setup_failed( assert not any(x.levelno == logging.ERROR for x in caplog.records) -async def test_state(hass: HomeAssistant) -> None: +async def test_state( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test state of the entity.""" mocked_device = _create_mocked_device() entry = MockConfigEntry(domain=songpal.DOMAIN, data=CONF_DATA) @@ -144,7 +148,6 @@ async def test_state(hass: HomeAssistant) -> None: assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(songpal.DOMAIN, MAC)}) assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} assert device.manufacturer == "Sony Corporation" @@ -152,12 +155,52 @@ async def test_state(hass: HomeAssistant) -> None: assert device.sw_version == SW_VERSION assert device.model == MODEL - entity_registry = er.async_get(hass) entity = entity_registry.async_get(ENTITY_ID) assert entity.unique_id == MAC -async def test_state_wireless(hass: HomeAssistant) -> None: +async def test_state_nosoundmode( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test state of the entity with no soundField in sound settings.""" + mocked_device = _create_mocked_device(no_soundfield=True) + entry = MockConfigEntry(domain=songpal.DOMAIN, data=CONF_DATA) + entry.add_to_hass(hass) + + with _patch_media_player_device(mocked_device): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.name == FRIENDLY_NAME + assert state.state == STATE_ON + attributes = state.as_dict()["attributes"] + assert attributes["volume_level"] == 0.5 + assert attributes["is_volume_muted"] is False + assert attributes["source_list"] == ["title1", "title2"] + assert attributes["source"] == "title2" + assert "sound_mode_list" not in attributes + assert "sound_mode" not in attributes + assert attributes["supported_features"] == SUPPORT_SONGPAL + + device = device_registry.async_get_device(identifiers={(songpal.DOMAIN, MAC)}) + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} + assert device.manufacturer == "Sony Corporation" + assert device.name == FRIENDLY_NAME + assert device.sw_version == SW_VERSION + assert device.model == MODEL + + entity = entity_registry.async_get(ENTITY_ID) + assert entity.unique_id == MAC + + +async def test_state_wireless( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test state of the entity with only Wireless MAC.""" mocked_device = _create_mocked_device(wired_mac=None, wireless_mac=WIRELESS_MAC) entry = MockConfigEntry(domain=songpal.DOMAIN, data=CONF_DATA) @@ -179,7 +222,6 @@ async def test_state_wireless(hass: HomeAssistant) -> None: assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(songpal.DOMAIN, WIRELESS_MAC)} ) @@ -189,12 +231,15 @@ async def test_state_wireless(hass: HomeAssistant) -> None: assert device.sw_version == SW_VERSION assert device.model == MODEL - entity_registry = er.async_get(hass) entity = entity_registry.async_get(ENTITY_ID) assert entity.unique_id == WIRELESS_MAC -async def test_state_both(hass: HomeAssistant) -> None: +async def test_state_both( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test state of the entity with both Wired and Wireless MAC.""" mocked_device = _create_mocked_device(wired_mac=MAC, wireless_mac=WIRELESS_MAC) entry = MockConfigEntry(domain=songpal.DOMAIN, data=CONF_DATA) @@ -216,7 +261,6 @@ async def test_state_both(hass: HomeAssistant) -> None: assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL - device_registry = dr.async_get(hass) device = device_registry.async_get_device(identifiers={(songpal.DOMAIN, MAC)}) assert device.connections == { (dr.CONNECTION_NETWORK_MAC, MAC), @@ -227,7 +271,6 @@ async def test_state_both(hass: HomeAssistant) -> None: assert device.sw_version == SW_VERSION assert device.model == MODEL - entity_registry = er.async_get(hass) entity = entity_registry.async_get(ENTITY_ID) # We prefer the wired mac if present. assert entity.unique_id == MAC @@ -399,7 +442,9 @@ async def test_disconnected( @pytest.mark.parametrize( ("error_code", "swallow"), [(ERROR_REQUEST_RETRY, True), (1234, False)] ) -async def test_error_swallowing(hass, caplog, service, error_code, swallow): +async def test_error_swallowing( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, service, error_code, swallow +) -> None: """Test swallowing specific errors on turn_on and turn_off.""" mocked_device = _create_mocked_device() entry = MockConfigEntry(domain=songpal.DOMAIN, data=CONF_DATA) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 3da0dd5c983..51dd2b9047c 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from soco import SoCo from soco.alarms import Alarms +from soco.data_structures import DidlFavorite, SearchResult from soco.events_base import Event as SonosEvent from homeassistant.components import ssdp, zeroconf @@ -17,7 +18,7 @@ from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture class SonosMockEventListener: @@ -204,6 +205,7 @@ class SoCoMockFactory: my_speaker_info["uid"] = mock_soco.uid mock_soco.get_speaker_info = Mock(return_value=my_speaker_info) mock_soco.add_to_queue = Mock(return_value=10) + mock_soco.add_uri_to_queue = Mock(return_value=10) mock_soco.avTransport = SonosMockService("AVTransport", ip_address) mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address) @@ -235,6 +237,17 @@ def patch_gethostbyname(host: str) -> str: return host +@pytest.fixture(name="soco_sharelink") +def soco_sharelink(): + """Fixture to mock soco.plugins.sharelink.ShareLinkPlugin.""" + with patch("homeassistant.components.sonos.speaker.ShareLinkPlugin") as mock_share: + mock_instance = MagicMock() + mock_instance.is_share_link.return_value = True + mock_instance.add_share_link_to_queue.return_value = 10 + mock_share.return_value = mock_instance + yield mock_instance + + @pytest.fixture(name="soco_factory") def soco_factory( music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock @@ -304,15 +317,46 @@ def config_fixture(): return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.2"]}}} +@pytest.fixture(name="sonos_favorites") +def sonos_favorites_fixture() -> SearchResult: + """Create sonos favorites fixture.""" + favorites = load_json_value_fixture("sonos_favorites.json", "sonos") + favorite_list = [DidlFavorite.from_dict(fav) for fav in favorites] + return SearchResult(favorite_list, "favorites", 3, 3, 1) + + class MockMusicServiceItem: """Mocks a Soco MusicServiceItem.""" - def __init__(self, title: str, item_id: str, parent_id: str, item_class: str): + def __init__( + self, + title: str, + item_id: str, + parent_id: str, + item_class: str, + album_art_uri: None | str = None, + ) -> None: """Initialize the mock item.""" self.title = title self.item_id = item_id self.item_class = item_class self.parent_id = parent_id + self.album_art_uri: None | str = album_art_uri + + +def list_from_json_fixture(file_name: str) -> list[MockMusicServiceItem]: + """Create a list of music service items from a json fixture file.""" + item_list = load_json_value_fixture(file_name, "sonos") + return [ + MockMusicServiceItem( + item.get("title"), + item.get("item_id"), + item.get("parent_id"), + item.get("item_class"), + item.get("album_art_uri"), + ) + for item in item_list + ] def mock_browse_by_idstring( @@ -389,6 +433,10 @@ def mock_browse_by_idstring( "object.container.album.musicAlbum", ), ] + if search_type == "tracks": + return list_from_json_fixture("music_library_tracks.json") + if search_type == "albums" and idstring == "A:ALBUM": + return list_from_json_fixture("music_library_albums.json") return [] @@ -407,13 +455,23 @@ def mock_get_music_library_information( ] +@pytest.fixture(name="music_library_browse_categories") +def music_library_browse_categories() -> list[MockMusicServiceItem]: + """Create fixture for top-level music library categories.""" + return list_from_json_fixture("music_library_categories.json") + + @pytest.fixture(name="music_library") -def music_library_fixture(): +def music_library_fixture( + sonos_favorites: SearchResult, + music_library_browse_categories: list[MockMusicServiceItem], +) -> Mock: """Create music_library fixture.""" music_library = MagicMock() - music_library.get_sonos_favorites.return_value.update_id = 1 - music_library.browse_by_idstring = mock_browse_by_idstring + music_library.get_sonos_favorites.return_value = sonos_favorites + music_library.browse_by_idstring = Mock(side_effect=mock_browse_by_idstring) music_library.get_music_library_information = mock_get_music_library_information + music_library.browse = Mock(return_value=music_library_browse_categories) return music_library @@ -421,6 +479,7 @@ def music_library_fixture(): def alarm_clock_fixture(): """Create alarmClock fixture.""" alarm_clock = SonosMockService("AlarmClock") + # pylint: disable-next=attribute-defined-outside-init alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms.return_value = { "CurrentAlarmListVersion": "RINCON_test:14", @@ -438,6 +497,7 @@ def alarm_clock_fixture(): def alarm_clock_fixture_extended(): """Create alarmClock fixture.""" alarm_clock = SonosMockService("AlarmClock") + # pylint: disable-next=attribute-defined-outside-init alarm_clock.ListAlarms = Mock() alarm_clock.ListAlarms.return_value = { "CurrentAlarmListVersion": "RINCON_test:15", @@ -581,12 +641,6 @@ def tv_event_fixture(soco): return SonosMockEvent(soco, soco.avTransport, variables) -@pytest.fixture(autouse=True) -def mock_get_source_ip(mock_get_source_ip): - """Mock network util's async_get_source_ip in all sonos tests.""" - return mock_get_source_ip - - @pytest.fixture(name="zgs_discovery", scope="package") def zgs_discovery_fixture(): """Load ZoneGroupState discovery payload and return it.""" diff --git a/tests/components/sonos/fixtures/music_library_albums.json b/tests/components/sonos/fixtures/music_library_albums.json new file mode 100644 index 00000000000..24ee386e338 --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_albums.json @@ -0,0 +1,30 @@ +[ + { + "title": "A Hard Day's Night", + "item_id": "A:ALBUM/A%20Hard%20Day's%20Night", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fA%2520Hard%2520Day's%2520Night%2f01%2520A%2520Hard%2520Day's%2520Night%25201.m4a&v=53" + }, + { + "title": "Abbey Road", + "item_id": "A:ALBUM/Abbey%20Road", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fAbbeyA%2520Road%2f01%2520Come%2520Together.m4a&v=53" + }, + { + "title": "Between Good And Evil", + "item_id": "A:ALBUM/Between%20Good%20And%20Evil", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fSantana%2fA%2520Between%2520Good%2520And%2520Evil%2f02%2520A%2520Persuasion.m4a&v=53" + }, + { + "title": "Special Characters,'()+", + "item_id": "A:ALBUM/Special%20Characters,'()+", + "parent_id": "A:ALBUM", + "item_class": "object.container.album.musicAlbum", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fSpecial%2fA%2520Special%2520Characters,()+%2f01%2520A%2520TheFirstTrack.m4a&v=53" + } +] diff --git a/tests/components/sonos/fixtures/music_library_categories.json b/tests/components/sonos/fixtures/music_library_categories.json new file mode 100644 index 00000000000..b6d6d3bf2dd --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_categories.json @@ -0,0 +1,44 @@ +[ + { + "title": "Contributing Artists", + "item_id": "A:ARTIST", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Artists", + "item_id": "A:ALBUMARTIST", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Albums", + "item_id": "A:ALBUM", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Genres", + "item_id": "A:GENRE", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Composers", + "item_id": "A:COMPOSER", + "parent_id": "A:", + "item_class": "object.container" + }, + { + "title": "Tracks", + "item_id": "A:TRACKS", + "parent_id": "A:", + "item_class": "object.container.playlistContainer" + }, + { + "title": "Playlists", + "item_id": "A:PLAYLISTS", + "parent_id": "A:", + "item_class": "object.container" + } +] diff --git a/tests/components/sonos/fixtures/music_library_tracks.json b/tests/components/sonos/fixtures/music_library_tracks.json new file mode 100644 index 00000000000..1f1fcdbc21c --- /dev/null +++ b/tests/components/sonos/fixtures/music_library_tracks.json @@ -0,0 +1,14 @@ +[ + { + "title": "A Hard Day's Night", + "item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%20Night/A%20Hard%20Day%2fs%20Night.mp3", + "parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night", + "item_class": "object.container.album.musicTrack" + }, + { + "title": "I Should Have Known Better", + "item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3", + "parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night", + "item_class": "object.container.album.musicTrack" + } +] diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json new file mode 100644 index 00000000000..21ee68f4872 --- /dev/null +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -0,0 +1,38 @@ +[ + { + "title": "66 - Watercolors", + "parent_id": "FV:2", + "item_id": "FV:2/4", + "resource_meta_data": "66 - Watercolorsobject.item.audioItem.audioBroadcastSA_RINCON9479_X_#Svc9479-99999999-Token", + "resources": [ + { + "uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc", + "protocol_info": "a:b:c:d" + } + ] + }, + { + "title": "James Taylor Radio", + "parent_id": "FV:2", + "item_id": "FV:2/13", + "resource_meta_data": "James Taylor Radioobject.item.audioItem.audioBroadcast.#stationSA_RINCON60423_X_#Svc60423-99999999-Token", + "resources": [ + { + "uri": "x-sonosapi-radio:ST%3aetc", + "protocol_info": "a:b:c:d" + } + ] + }, + { + "title": "1984", + "parent_id": "FV:2", + "item_id": "FV:2/8", + "resource_meta_data": "1984object.container.album.musicAlbumRINCON_AssociatedZPUDN", + "resources": [ + { + "uri": "x-rincon-playlist:RINCON_test#A:ALBUMARTIST/Aerosmith/1984", + "protocol_info": "a:b:c:d" + } + ] + } +] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr new file mode 100644 index 00000000000..ae8e813ae5d --- /dev/null +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -0,0 +1,143 @@ +# serializer version: 1 +# name: test_browse_media_library + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'contributing_artist', + 'media_content_id': 'A:ARTIST', + 'media_content_type': 'contributing_artist', + 'thumbnail': None, + 'title': 'Contributing Artists', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'artist', + 'media_content_id': 'A:ALBUMARTIST', + 'media_content_type': 'artist', + 'thumbnail': None, + 'title': 'Artists', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM', + 'media_content_type': 'album', + 'thumbnail': None, + 'title': 'Albums', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'genre', + 'media_content_id': 'A:GENRE', + 'media_content_type': 'genre', + 'thumbnail': None, + 'title': 'Genres', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'composer', + 'media_content_id': 'A:COMPOSER', + 'media_content_type': 'composer', + 'thumbnail': None, + 'title': 'Composers', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'A:TRACKS', + 'media_content_type': 'track', + 'thumbnail': None, + 'title': 'Tracks', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'playlist', + 'media_content_id': 'A:PLAYLISTS', + 'media_content_type': 'playlist', + 'thumbnail': None, + 'title': 'Playlists', + }), + ]) +# --- +# name: test_browse_media_library_albums + list([ + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': "A:ALBUM/A%20Hard%20Day's%20Night", + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/A%20Hard%20Day%27s%20Night/01%20A%20Hard%20Day%27s%20Night%201.m4a&v=53', + 'title': "A Hard Day's Night", + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM/Abbey%20Road', + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/AbbeyA%20Road/01%20Come%20Together.m4a&v=53', + 'title': 'Abbey Road', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'A:ALBUM/Between%20Good%20And%20Evil', + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/Santana/A%20Between%20Good%20And%20Evil/02%20A%20Persuasion.m4a&v=53', + 'title': 'Between Good And Evil', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': "A:ALBUM/Special%20Characters,'()+", + 'media_content_type': 'album', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/Special/A%20Special%20Characters%2C%28%29%2B/01%20A%20TheFirstTrack.m4a&v=53', + 'title': "Special Characters,'()+", + }), + ]) +# --- +# name: test_browse_media_root + list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png', + 'title': 'Favorites', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'library', + 'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png', + 'title': 'Music Library', + }), + ]) +# --- diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index f8ac5fc6dbf..85ab8f4dd5a 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -213,6 +213,8 @@ async def test_async_poll_manual_hosts_1( not in caplog.text ) + await hass.async_block_till_done(wait_background_tasks=True) + async def test_async_poll_manual_hosts_2( hass: HomeAssistant, @@ -237,6 +239,8 @@ async def test_async_poll_manual_hosts_2( in caplog.text ) + await hass.async_block_till_done(wait_background_tasks=True) + async def test_async_poll_manual_hosts_3( hass: HomeAssistant, @@ -261,6 +265,8 @@ async def test_async_poll_manual_hosts_3( in caplog.text ) + await hass.async_block_till_done(wait_background_tasks=True) + async def test_async_poll_manual_hosts_4( hass: HomeAssistant, @@ -285,6 +291,8 @@ async def test_async_poll_manual_hosts_4( not in caplog.text ) + await hass.async_block_till_done(wait_background_tasks=True) + class SpeakerActivity: """Unit test class to track speaker activity messages.""" @@ -348,6 +356,8 @@ async def test_async_poll_manual_hosts_5( assert "Activity on Living Room" in caplog.text assert "Activity on Bedroom" in caplog.text + await hass.async_block_till_done(wait_background_tasks=True) + async def test_async_poll_manual_hosts_6( hass: HomeAssistant, @@ -386,6 +396,8 @@ async def test_async_poll_manual_hosts_6( assert speaker_1_activity.call_count == 0 assert speaker_2_activity.call_count == 0 + await hass.async_block_till_done(wait_background_tasks=True) + async def test_async_poll_manual_hosts_7( hass: HomeAssistant, @@ -413,6 +425,8 @@ async def test_async_poll_manual_hosts_7( assert "media_player.garage" in entity_registry.entities assert "media_player.studio" in entity_registry.entities + await hass.async_block_till_done(wait_background_tasks=True) + async def test_async_poll_manual_hosts_8( hass: HomeAssistant, @@ -439,3 +453,4 @@ async def test_async_poll_manual_hosts_8( assert "media_player.basement" in entity_registry.entities assert "media_player.garage" in entity_registry.entities assert "media_player.studio" in entity_registry.entities + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index d8d0e1c3a07..6e03935f7f6 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -2,8 +2,9 @@ from functools import partial -from homeassistant.components.media_player.browse_media import BrowseMedia -from homeassistant.components.media_player.const import MediaClass, MediaType +from syrupy import SnapshotAssertion + +from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType from homeassistant.components.sonos.media_browser import ( build_item_response, get_thumbnail_url_full, @@ -12,6 +13,8 @@ from homeassistant.core import HomeAssistant from .conftest import SoCoMockFactory +from tests.typing import WebSocketGenerator + class MockMusicServiceItem: """Mocks a Soco MusicServiceItem.""" @@ -95,3 +98,81 @@ async def test_build_item_response( browse_item.children[1].media_content_id == "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3" ) + + +async def test_browse_media_root( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_media_library( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": "", + "media_content_type": "library", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + + +async def test_browse_media_library_albums( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco, + discover, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": "A:ALBUM", + "media_content_type": "album", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["children"] == snapshot + assert soco_mock.music_library.browse_by_idstring.call_count == 1 diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 976d3480429..ab9b598bb04 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,18 +1,22 @@ """Tests for the Sonos Media Player platform.""" import logging +from typing import Any import pytest from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, DOMAIN as MP_DOMAIN, SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOURCE, MediaPlayerEnqueue, ) -from homeassistant.components.media_player.const import ATTR_MEDIA_ENQUEUE +from homeassistant.components.sonos.const import SOURCE_LINEIN, SOURCE_TV from homeassistant.components.sonos.media_player import LONG_SERVICE_TIMEOUT from homeassistant.const import STATE_IDLE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, CONNECTION_UPNP, @@ -188,6 +192,244 @@ async def test_play_media_library( ) +_track_url = "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3" + + +async def test_play_media_lib_track_play( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, +) -> None: + """Tests playing media track with enqueue mode play.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "track", + "media_content_id": _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.PLAY, + }, + blocking=True, + ) + assert soco_mock.add_uri_to_queue.call_count == 1 + assert soco_mock.add_uri_to_queue.call_args_list[0].args[0] == _track_url + assert soco_mock.add_uri_to_queue.call_args_list[0].kwargs["position"] == 1 + assert ( + soco_mock.add_uri_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert soco_mock.play_from_queue.call_count == 1 + assert soco_mock.play_from_queue.call_args_list[0].args[0] == 9 + + +async def test_play_media_lib_track_next( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, +) -> None: + """Tests playing media track with enqueue mode next.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "track", + "media_content_id": _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.NEXT, + }, + blocking=True, + ) + assert soco_mock.add_uri_to_queue.call_count == 1 + assert soco_mock.add_uri_to_queue.call_args_list[0].args[0] == _track_url + assert soco_mock.add_uri_to_queue.call_args_list[0].kwargs["position"] == 1 + assert ( + soco_mock.add_uri_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert soco_mock.play_from_queue.call_count == 0 + + +async def test_play_media_lib_track_replace( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, +) -> None: + """Tests playing media track with enqueue mode replace.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "track", + "media_content_id": _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.REPLACE, + }, + blocking=True, + ) + assert soco_mock.play_uri.call_count == 1 + assert soco_mock.play_uri.call_args_list[0].args[0] == _track_url + assert soco_mock.play_uri.call_args_list[0].kwargs["force_radio"] is False + + +async def test_play_media_lib_track_add( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, +) -> None: + """Tests playing media track with enqueue mode add.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "track", + "media_content_id": _track_url, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.ADD, + }, + blocking=True, + ) + assert soco_mock.add_uri_to_queue.call_count == 1 + assert soco_mock.add_uri_to_queue.call_args_list[0].args[0] == _track_url + assert ( + soco_mock.add_uri_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert soco_mock.play_from_queue.call_count == 0 + + +_share_link: str = "spotify:playlist:abcdefghij0123456789XY" + + +async def test_play_media_share_link_add( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco_sharelink, +) -> None: + """Tests playing a share link with enqueue option add.""" + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "playlist", + "media_content_id": _share_link, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.ADD, + }, + blocking=True, + ) + assert soco_sharelink.add_share_link_to_queue.call_count == 1 + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].args[0] == _share_link + ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + + +async def test_play_media_share_link_next( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco_sharelink, +) -> None: + """Tests playing a share link with enqueue option next.""" + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "playlist", + "media_content_id": _share_link, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.NEXT, + }, + blocking=True, + ) + assert soco_sharelink.add_share_link_to_queue.call_count == 1 + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].args[0] == _share_link + ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["position"] == 1 + ) + + +async def test_play_media_share_link_play( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco_sharelink, +) -> None: + """Tests playing a share link with enqueue option play.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "playlist", + "media_content_id": _share_link, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.PLAY, + }, + blocking=True, + ) + assert soco_sharelink.add_share_link_to_queue.call_count == 1 + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].args[0] == _share_link + ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["position"] == 1 + ) + assert soco_mock.play_from_queue.call_count == 1 + soco_mock.play_from_queue.assert_called_with(9) + + +async def test_play_media_share_link_replace( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + soco_sharelink, +) -> None: + """Tests playing a share link with enqueue option replace.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "playlist", + "media_content_id": _share_link, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.REPLACE, + }, + blocking=True, + ) + assert soco_mock.clear_queue.call_count == 1 + assert soco_sharelink.add_share_link_to_queue.call_count == 1 + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].args[0] == _share_link + ) + assert ( + soco_sharelink.add_share_link_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert soco_mock.play_from_queue.call_count == 1 + soco_mock.play_from_queue.assert_called_with(0) + + _mock_playlists = [ MockMusicServiceItem( "playlist1", @@ -272,3 +514,196 @@ async def test_play_media_music_library_playlist_dne( assert soco_mock.play_uri.call_count == 0 assert media_content_id in caplog.text assert "playlist" in caplog.text + + +@pytest.mark.parametrize( + ("source", "result"), + [ + ( + SOURCE_LINEIN, + { + "switch_to_line_in": 1, + }, + ), + ( + SOURCE_TV, + { + "switch_to_tv": 1, + }, + ), + ], +) +async def test_select_source_line_in_tv( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + source: str, + result: dict[str, Any], +) -> None: + """Test the select_source method with a variety of inputs.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": source, + }, + blocking=True, + ) + assert soco_mock.switch_to_line_in.call_count == result.get("switch_to_line_in", 0) + assert soco_mock.switch_to_tv.call_count == result.get("switch_to_tv", 0) + + +@pytest.mark.parametrize( + ("source", "result"), + [ + ( + "James Taylor Radio", + { + "play_uri": 1, + "play_uri_uri": "x-sonosapi-radio:ST%3aetc", + "play_uri_title": "James Taylor Radio", + }, + ), + ( + "66 - Watercolors", + { + "play_uri": 1, + "play_uri_uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc", + "play_uri_title": "66 - Watercolors", + }, + ), + ], +) +async def test_select_source_play_uri( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + source: str, + result: dict[str, Any], +) -> None: + """Test the select_source method with a variety of inputs.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": source, + }, + blocking=True, + ) + assert soco_mock.play_uri.call_count == result.get("play_uri") + soco_mock.play_uri.assert_called_with( + result.get("play_uri_uri"), + title=result.get("play_uri_title"), + timeout=LONG_SERVICE_TIMEOUT, + ) + + +@pytest.mark.parametrize( + ("source", "result"), + [ + ( + "1984", + { + "add_to_queue": 1, + "add_to_queue_item_id": "A:ALBUMARTIST/Aerosmith/1984", + "clear_queue": 1, + "play_from_queue": 1, + }, + ), + ], +) +async def test_select_source_play_queue( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + source: str, + result: dict[str, Any], +) -> None: + """Test the select_source method with a variety of inputs.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": source, + }, + blocking=True, + ) + assert soco_mock.clear_queue.call_count == result.get("clear_queue") + assert soco_mock.add_to_queue.call_count == result.get("add_to_queue") + assert soco_mock.add_to_queue.call_args_list[0].args[0].item_id == result.get( + "add_to_queue_item_id" + ) + assert ( + soco_mock.add_to_queue.call_args_list[0].kwargs["timeout"] + == LONG_SERVICE_TIMEOUT + ) + assert soco_mock.play_from_queue.call_count == result.get("play_from_queue") + soco_mock.play_from_queue.assert_called_with(0) + + +async def test_select_source_error( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, +) -> None: + """Test the select_source method with a variety of inputs.""" + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + { + "entity_id": "media_player.zone_a", + "source": "invalid_source", + }, + blocking=True, + ) + assert "invalid_source" in str(sve.value) + assert "Could not find a Sonos favorite" in str(sve.value) + + +async def test_play_media_favorite_item_id( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, +) -> None: + """Test playing media with a favorite item id.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "favorite_item_id", + "media_content_id": "FV:2/4", + }, + blocking=True, + ) + assert soco_mock.play_uri.call_count == 1 + assert ( + soco_mock.play_uri.call_args_list[0].args[0] + == "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc" + ) + assert ( + soco_mock.play_uri.call_args_list[0].kwargs["timeout"] == LONG_SERVICE_TIMEOUT + ) + assert soco_mock.play_uri.call_args_list[0].kwargs["title"] == "66 - Watercolors" + + # Test exception handling with an invalid id. + with pytest.raises(ValueError) as sve: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + "entity_id": "media_player.zone_a", + "media_content_type": "favorite_item_id", + "media_content_id": "UNKNOWN_ID", + }, + blocking=True, + ) + assert "UNKNOWN_ID" in str(sve.value) diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py index 2fa951c6a79..487020e0b12 100644 --- a/tests/components/sonos/test_repairs.py +++ b/tests/components/sonos/test_repairs.py @@ -10,7 +10,7 @@ from homeassistant.components.sonos.const import ( SUB_FAIL_ISSUE_ID, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry +from homeassistant.helpers import issue_registry as ir from homeassistant.util import dt as dt_util from .conftest import SonosMockEvent, SonosMockSubscribe @@ -19,11 +19,13 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_subscription_repair_issues( - hass: HomeAssistant, config_entry: MockConfigEntry, soco: SoCo, zgs_discovery + hass: HomeAssistant, + config_entry: MockConfigEntry, + soco: SoCo, + zgs_discovery, + issue_registry: ir.IssueRegistry, ) -> None: """Test repair issues handling for failed subscriptions.""" - issue_registry = async_get_issue_registry(hass) - subscription: SonosMockSubscribe = soco.zoneGroupTopology.subscribe.return_value subscription.event_listener = Mock(address=("192.168.4.2", 1400)) diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 14b4c9177f9..0de96d05605 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.spaceapi import DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI @@ -10,6 +11,8 @@ from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, UnitOfTemp from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + CONFIG = { DOMAIN: { "space": "Home", @@ -80,7 +83,7 @@ SENSOR_OUTPUT = { @pytest.fixture -def mock_client(hass, hass_client): +def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: """Start the Home Assistant HTTP component.""" with patch("homeassistant.components.spaceapi", return_value=True): hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) diff --git a/tests/components/spc/test_init.py b/tests/components/spc/test_init.py index 92c3282dd23..3dfea94a4bd 100644 --- a/tests/components/spc/test_init.py +++ b/tests/components/spc/test_init.py @@ -2,6 +2,9 @@ from unittest.mock import Mock, PropertyMock, patch +import pyspcwebgw +from pyspcwebgw.const import AreaMode + from homeassistant.bootstrap import async_setup_component from homeassistant.components.spc import DATA_API from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED @@ -32,8 +35,6 @@ async def test_invalid_device_config(hass: HomeAssistant, monkeypatch) -> None: async def test_update_alarm_device(hass: HomeAssistant) -> None: """Test that alarm panel state changes on incoming websocket data.""" - import pyspcwebgw - from pyspcwebgw.const import AreaMode config = {"spc": {"api_url": "http://localhost/", "ws_url": "ws://localhost/"}} diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 6de549c8bc7..6040fcd84f2 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -76,12 +76,12 @@ async def test_zeroconf_abort_if_existing_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, component_setup, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check a full flow.""" result = await hass.config_entries.flow.async_init( @@ -143,12 +143,12 @@ async def test_full_flow( } +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_if_spotify_error( hass: HomeAssistant, component_setup, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check Spotify errors causes flow to abort.""" result = await hass.config_entries.flow.async_init( @@ -185,12 +185,12 @@ async def test_abort_if_spotify_error( assert result["reason"] == "connection_error" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication( hass: HomeAssistant, component_setup, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test Spotify reauthentication.""" old_entry = MockConfigEntry( @@ -253,12 +253,12 @@ async def test_reauthentication( } +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_account_mismatch( hass: HomeAssistant, component_setup, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test Spotify reauthentication with different account.""" old_entry = MockConfigEntry( diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 93cde0bccdd..cb990e454b7 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries from homeassistant.components.recorder import Recorder -from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 14442aa5181..b219ad47f3a 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -424,7 +424,10 @@ async def test_binary_data_from_yaml_setup( async def test_issue_when_using_old_query( - recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture + recorder_mock: Recorder, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue for an old query that will do a full table scan.""" @@ -433,7 +436,6 @@ async def test_issue_when_using_old_query( assert "Query contains entity_id but does not reference states_meta" in caplog.text assert not hass.states.async_all() - issue_registry = ir.async_get(hass) config = YAML_CONFIG_FULL_TABLE_SCAN["sql"] @@ -457,6 +459,7 @@ async def test_issue_when_using_old_query_without_unique_id( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, yaml_config: dict[str, Any], + issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue for an old query that will do a full table scan.""" @@ -465,7 +468,6 @@ async def test_issue_when_using_old_query_without_unique_id( assert "Query contains entity_id but does not reference states_meta" in caplog.text assert not hass.states.async_all() - issue_registry = ir.async_get(hass) config = yaml_config["sql"] query = config[CONF_QUERY] diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index 12fa7ffd6d6..45eb726443f 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Generator import datetime as dt from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from typing_extensions import Generator from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE from homeassistant.const import CONF_ID @@ -20,11 +20,11 @@ from tests.common import MockConfigEntry @pytest.fixture(name="setup_hass_config", autouse=True) -def fixture_setup_hass_config(hass: HomeAssistant) -> None: +async def fixture_setup_hass_config(hass: HomeAssistant) -> None: """Set up things to be run when tests are started.""" hass.config.latitude = 33.27 hass.config.longitude = 112 - hass.config.set_time_zone(PHOENIX_TIME_ZONE) + await hass.config.async_set_time_zone(PHOENIX_TIME_ZONE) @pytest.fixture(name="hass_tz_info") @@ -48,7 +48,7 @@ def fixture_mock_config_entry() -> MockConfigEntry: @pytest.fixture(name="mock_srp_energy") -def fixture_mock_srp_energy() -> Generator[None, MagicMock, None]: +def fixture_mock_srp_energy() -> Generator[MagicMock]: """Return a mocked SrpEnergyClient client.""" with patch( "homeassistant.components.srp_energy.SrpEnergyClient", autospec=True @@ -60,7 +60,7 @@ def fixture_mock_srp_energy() -> Generator[None, MagicMock, None]: @pytest.fixture(name="mock_srp_energy_config_flow") -def fixture_mock_srp_energy_config_flow() -> Generator[None, MagicMock, None]: +def fixture_mock_srp_energy_config_flow() -> Generator[MagicMock]: """Return a mocked config_flow SrpEnergyClient client.""" with patch( "homeassistant.components.srp_energy.config_flow.SrpEnergyClient", autospec=True diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 5131388c4e3..d10496500d2 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -42,7 +42,6 @@ async def init_ssdp_component(hass: HomeAssistant) -> SsdpListener: "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"st": "mock-st"}]}, ) -@pytest.mark.usefixtures("mock_get_source_ip") async def test_ssdp_flow_dispatched_on_st( mock_get_ssdp, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_flow_init ) -> None: @@ -85,7 +84,6 @@ async def test_ssdp_flow_dispatched_on_st( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"manufacturerURL": "mock-url"}]}, ) -@pytest.mark.usefixtures("mock_get_source_ip") async def test_ssdp_flow_dispatched_on_manufacturer_url( mock_get_ssdp, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_flow_init ) -> None: @@ -125,7 +123,6 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( assert "Failed to fetch ssdp data" not in caplog.text -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"manufacturer": "Paulus"}]}, @@ -170,7 +167,6 @@ async def test_scan_match_upnp_devicedesc_manufacturer( } -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"deviceType": "Paulus"}]}, @@ -216,7 +212,6 @@ async def test_scan_match_upnp_devicedesc_devicetype( } -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={ @@ -260,7 +255,6 @@ async def test_scan_not_all_present( assert not mock_flow_init.mock_calls -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={ @@ -307,7 +301,6 @@ async def test_scan_not_all_match( assert not mock_flow_init.mock_calls -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"deviceType": "Paulus"}]}, @@ -383,7 +376,6 @@ async def test_flow_start_only_alive( ) -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={}, @@ -441,7 +433,6 @@ async def test_discovery_from_advertisement_sets_ssdp_st( "homeassistant.components.ssdp.async_build_source_set", return_value={IPv4Address("192.168.1.1")}, ) -@pytest.mark.usefixtures("mock_get_source_ip") async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: """Test we start and stop the scanner.""" ssdp_listener = await init_ssdp_component(hass) @@ -463,7 +454,6 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: assert ssdp_listener.async_stop.call_count == 1 -@pytest.mark.usefixtures("mock_get_source_ip") @pytest.mark.no_fail_on_log_exception @patch("homeassistant.components.ssdp.async_get_ssdp", return_value={}) async def test_scan_with_registered_callback( @@ -559,7 +549,6 @@ async def test_scan_with_registered_callback( assert async_integration_callback_from_cache.call_count == 1 -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"st": "mock-st"}]}, @@ -688,7 +677,6 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={ @@ -714,7 +702,6 @@ async def test_async_detect_interfaces_setting_empty_route( assert sources == {("2001:db8::", 0, 0, 1), ("192.168.1.5", 0)} -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={ @@ -764,7 +751,6 @@ async def test_bind_failure_skips_adapter( assert sources == {("192.168.1.5", 0)} # Note no UpnpServer for IPv6 address. -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={ @@ -800,7 +786,6 @@ async def test_ipv4_does_additional_search_for_sonos( assert ssdp_listener.async_search.call_args[1] == {} -@pytest.mark.usefixtures("mock_get_source_ip") @patch( "homeassistant.components.ssdp.async_get_ssdp", return_value={"mock-domain": [{"deviceType": "Paulus"}]}, diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index fd9a5ca85bd..5a716fd8ce8 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -42,7 +42,9 @@ VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] VALUES_NUMERIC = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test configuration defined unique_id.""" assert await async_setup_component( hass, @@ -62,8 +64,7 @@ async def test_unique_id(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - entity_reg = er.async_get(hass) - entity_id = entity_reg.async_get_entity_id( + entity_id = entity_registry.async_get_entity_id( "sensor", STATISTICS_DOMAIN, "uniqueid_sensor_test" ) assert entity_id == "sensor.test" @@ -1313,13 +1314,13 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: # With all values in buffer - for i in range(len(VALUES_NUMERIC)): + for i, value in enumerate(VALUES_NUMERIC): current_time += timedelta(minutes=1) freezer.move_to(current_time) async_fire_time_changed(hass, current_time) hass.states.async_set( "sensor.test_monitored", - str(VALUES_NUMERIC[i]), + str(value), {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) hass.states.async_set( @@ -1342,7 +1343,7 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: "value mismatch for characteristic " f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " "(buffer filled) - " - f"assert {state.state} == {str(characteristic['value_9'])}" + f"assert {state.state} == {characteristic['value_9']!s}" ) assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == characteristic["unit"] @@ -1368,7 +1369,7 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: "value mismatch for characteristic " f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " "(one stored value) - " - f"assert {state.state} == {str(characteristic['value_1'])}" + f"assert {state.state} == {characteristic['value_1']!s}" ) # With empty buffer @@ -1391,7 +1392,7 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: "value mismatch for characteristic " f"'{characteristic['source_sensor_domain']}/{characteristic['name']}' " "(buffer empty) - " - f"assert {state.state} == {str(characteristic['value_0'])}" + f"assert {state.state} == {characteristic['value_0']!s}" ) diff --git a/tests/components/steam_online/__init__.py b/tests/components/steam_online/__init__.py index c7d67509489..d374eb1b917 100644 --- a/tests/components/steam_online/__init__.py +++ b/tests/components/steam_online/__init__.py @@ -7,8 +7,11 @@ import urllib.parse import steam -from homeassistant.components.steam_online import DOMAIN -from homeassistant.components.steam_online.const import CONF_ACCOUNT, CONF_ACCOUNTS +from homeassistant.components.steam_online.const import ( + CONF_ACCOUNT, + CONF_ACCOUNTS, + DOMAIN, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant diff --git a/tests/components/steam_online/test_config_flow.py b/tests/components/steam_online/test_config_flow.py index 9292f58d231..a5bce80d890 100644 --- a/tests/components/steam_online/test_config_flow.py +++ b/tests/components/steam_online/test_config_flow.py @@ -166,7 +166,9 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["data"] == CONF_OPTIONS_2 -async def test_options_flow_deselect(hass: HomeAssistant) -> None: +async def test_options_flow_deselect( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test deselecting user.""" entry = create_entry(hass) with ( @@ -198,7 +200,7 @@ async def test_options_flow_deselect(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_ACCOUNTS: {}} - assert len(er.async_get(hass).entities) == 0 + assert len(entity_registry.entities) == 0 async def test_options_flow_timeout(hass: HomeAssistant) -> None: diff --git a/tests/components/steam_online/test_init.py b/tests/components/steam_online/test_init.py index ccc7690aae3..73daac0296c 100644 --- a/tests/components/steam_online/test_init.py +++ b/tests/components/steam_online/test_init.py @@ -37,12 +37,13 @@ async def test_async_setup_entry_auth_failed(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_device_info(hass: HomeAssistant) -> None: +async def test_device_info( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test device info.""" entry = create_entry(hass) with patch_interface(): await hass.config_entries.async_setup(entry.entry_id) - device_registry = dr.async_get(hass) await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) diff --git a/tests/components/steamist/test_init.py b/tests/components/steamist/test_init.py index 96ea59afda2..0ef8edca9a8 100644 --- a/tests/components/steamist/test_init.py +++ b/tests/components/steamist/test_init.py @@ -70,6 +70,7 @@ async def test_config_entry_retry_later(hass: HomeAssistant) -> None: async def test_config_entry_fills_unique_id_with_directed_discovery( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ) -> None: """Test that the unique id is added if its missing via directed (not broadcast) discovery.""" config_entry = MockConfigEntry( @@ -107,7 +108,6 @@ async def test_config_entry_fills_unique_id_with_directed_discovery( assert config_entry.data[CONF_NAME] == DEVICE_NAME assert config_entry.title == DEVICE_NAME - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, FORMATTED_MAC_ADDRESS)} ) diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index 280d15cd1ef..3cf3de54940 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -13,13 +13,13 @@ so that it can inspect the output. from __future__ import annotations import asyncio -from collections.abc import Generator import logging import threading from unittest.mock import Mock, patch from aiohttp import web import pytest +from typing_extensions import Generator from homeassistant.components.stream.core import StreamOutput from homeassistant.components.stream.worker import StreamState @@ -175,7 +175,7 @@ def hls_sync(): @pytest.fixture(autouse=True) -def should_retry() -> Generator[Mock, None, None]: +def should_retry() -> Generator[Mock]: """Fixture to disable stream worker retries in tests by default.""" with patch( "homeassistant.components.stream._should_retry", return_value=False diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 6a20914250e..ce66848a2b1 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -46,7 +46,7 @@ HLS_CONFIG = { @pytest.fixture -async def setup_component(hass) -> None: +async def setup_component(hass: HomeAssistant) -> None: """Test fixture to setup the stream component.""" await async_setup_component(hass, "stream", HLS_CONFIG) @@ -69,7 +69,7 @@ class HlsClient: @pytest.fixture -def hls_stream(hass, hass_client): +def hls_stream(hass: HomeAssistant, hass_client: ClientSessionGenerator): """Create test fixture for creating an HLS client for a stream.""" async def create_client_for_stream(stream): @@ -309,6 +309,7 @@ async def test_stream_retries( def av_open_side_effect(*args, **kwargs): hass.loop.call_soon_threadsafe(futures.pop().set_result, None) + # pylint: disable-next=c-extension-no-member raise av.error.InvalidDataError(-2, "error") with ( diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 4cf3909dd0d..5577076830b 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -33,6 +33,8 @@ from .common import ( ) from .test_hls import STREAM_SOURCE, HlsClient, make_playlist +from tests.typing import ClientSessionGenerator + SEGMENT_DURATION = 6 TEST_PART_DURATION = 0.75 NUM_PART_SEGMENTS = int(-(-SEGMENT_DURATION // TEST_PART_DURATION)) @@ -45,7 +47,7 @@ VERY_LARGE_LAST_BYTE_POS = 9007199254740991 @pytest.fixture -def hls_stream(hass, hass_client): +def hls_stream(hass: HomeAssistant, hass_client: ClientSessionGenerator): """Create test fixture for creating an HLS client for a stream.""" async def create_client_for_stream(stream): diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 0d47a63a000..2cb90c5ee9a 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -341,9 +341,11 @@ async def test_stream_open_fails(hass: HomeAssistant) -> None: dynamic_stream_settings(), ) stream.add_provider(HLS_PROVIDER) - with patch("av.open") as av_open, pytest.raises(StreamWorkerError): + with patch("av.open") as av_open: + # pylint: disable-next=c-extension-no-member av_open.side_effect = av.error.InvalidDataError(-2, "error") - run_worker(hass, stream, STREAM_SOURCE) + with pytest.raises(StreamWorkerError): + run_worker(hass, stream, STREAM_SOURCE) await hass.async_block_till_done() av_open.assert_called_once() @@ -768,9 +770,11 @@ async def test_worker_log( ) stream.add_provider(HLS_PROVIDER) - with patch("av.open") as av_open, pytest.raises(StreamWorkerError) as err: + with patch("av.open") as av_open: + # pylint: disable-next=c-extension-no-member av_open.side_effect = av.error.InvalidDataError(-2, "error") - run_worker(hass, stream, stream_url) + with pytest.raises(StreamWorkerError) as err: + run_worker(hass, stream, stream_url) await hass.async_block_till_done() assert ( str(err.value) == f"Error opening stream (ERRORTYPE_-2, error) {redacted_url}" diff --git a/tests/components/streamlabswater/__init__.py b/tests/components/streamlabswater/__init__.py index f8776708887..c0f6cbf2bde 100644 --- a/tests/components/streamlabswater/__init__.py +++ b/tests/components/streamlabswater/__init__.py @@ -1,7 +1,7 @@ """Tests for the StreamLabs integration.""" from homeassistant.core import HomeAssistant -from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import MockConfigEntry @@ -10,7 +10,7 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) """Fixture for setting up the component.""" config_entry.add_to_hass(hass) - hass.config.units = IMPERIAL_SYSTEM + hass.config.units = US_CUSTOMARY_SYSTEM await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/streamlabswater/conftest.py b/tests/components/streamlabswater/conftest.py index c303c1b7ef0..5a53c7204fa 100644 --- a/tests/components/streamlabswater/conftest.py +++ b/tests/components/streamlabswater/conftest.py @@ -1,10 +1,10 @@ """Common fixtures for the StreamLabs tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from streamlabswater.streamlabswater import StreamlabsClient +from typing_extensions import Generator from homeassistant.components.streamlabswater import DOMAIN from homeassistant.const import CONF_API_KEY @@ -13,7 +13,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.streamlabswater.async_setup_entry", return_value=True @@ -32,7 +32,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture(name="streamlabswater") -def mock_streamlabswater() -> Generator[AsyncMock, None, None]: +def mock_streamlabswater() -> Generator[AsyncMock]: """Mock the StreamLabs client.""" locations = load_json_object_fixture("streamlabswater/get_locations.json") diff --git a/tests/components/streamlabswater/test_binary_sensor.py b/tests/components/streamlabswater/test_binary_sensor.py index 7c9351c5e69..7beb088d498 100644 --- a/tests/components/streamlabswater/test_binary_sensor.py +++ b/tests/components/streamlabswater/test_binary_sensor.py @@ -8,8 +8,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import setup_integration + from tests.common import MockConfigEntry -from tests.components.streamlabswater import setup_integration async def test_all_entities( diff --git a/tests/components/streamlabswater/test_config_flow.py b/tests/components/streamlabswater/test_config_flow.py index 0cee3b8b088..b8e9bbc1157 100644 --- a/tests/components/streamlabswater/test_config_flow.py +++ b/tests/components/streamlabswater/test_config_flow.py @@ -120,75 +120,3 @@ async def test_form_entry_already_exists(hass: HomeAssistant) -> None: assert result["type"] is 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"] is 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"] is 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"] is 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"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/streamlabswater/test_sensor.py b/tests/components/streamlabswater/test_sensor.py index f27b61d724b..6afb71f3fd7 100644 --- a/tests/components/streamlabswater/test_sensor.py +++ b/tests/components/streamlabswater/test_sensor.py @@ -8,8 +8,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import setup_integration + from tests.common import MockConfigEntry -from tests.components.streamlabswater import setup_integration async def test_all_entities( diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 165a520c653..d28d9c308a7 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -1,11 +1,12 @@ """Test STT component setup.""" -from collections.abc import AsyncIterable, Generator +from collections.abc import AsyncIterable from http import HTTPStatus from pathlib import Path from unittest.mock import AsyncMock import pytest +from typing_extensions import Generator from homeassistant.components.stt import ( DOMAIN, @@ -131,7 +132,7 @@ def config_flow_test_domain_fixture() -> str: @pytest.fixture(autouse=True) def config_flow_fixture( hass: HomeAssistant, config_flow_test_domain: str -) -> Generator[None, None, None]: +) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{config_flow_test_domain}.config_flow") @@ -187,7 +188,7 @@ async def mock_config_entry_setup( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True async def async_unload_entry_init( diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index 52c57e7348a..0e15dead33f 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -62,19 +62,13 @@ MOCK_DATETIME = datetime.fromtimestamp(1595560000, UTC) VEHICLE_STATUS_EV = { VEHICLE_STATUS: { - "AVG_FUEL_CONSUMPTION": 2.3, - "DISTANCE_TO_EMPTY_FUEL": 707, - "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "AVG_FUEL_CONSUMPTION": 51.1, + "DISTANCE_TO_EMPTY_FUEL": 170, "DOOR_BOOT_POSITION": "CLOSED", - "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", "DOOR_ENGINE_HOOD_POSITION": "CLOSED", - "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_LEFT_POSITION": "CLOSED", - "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_RIGHT_POSITION": "CLOSED", - "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_LEFT_POSITION": "CLOSED", - "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", @@ -85,37 +79,12 @@ VEHICLE_STATUS_EV = { "EV_STATE_OF_CHARGE_PERCENT": 20, "EV_TIME_TO_FULLY_CHARGED_UTC": MOCK_DATETIME, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": 150, - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, - "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", - "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", - "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 0, - "TYRE_PRESSURE_FRONT_RIGHT": 2550, - "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_FRONT_LEFT": 0.0, + "TYRE_PRESSURE_FRONT_RIGHT": 31.9, + "TYRE_PRESSURE_REAR_LEFT": 32.6, "TYRE_PRESSURE_REAR_RIGHT": None, - "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", - "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", - "TYRE_STATUS_REAR_LEFT": "UNKNOWN", - "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", "VEHICLE_STATE_TYPE": "IGNITION_OFF", "WINDOW_BACK_STATUS": "UNKNOWN", "WINDOW_FRONT_LEFT_STATUS": "VENTED", @@ -123,7 +92,6 @@ VEHICLE_STATUS_EV = { "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", "WINDOW_SUNROOF_STATUS": "UNKNOWN", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -132,53 +100,22 @@ VEHICLE_STATUS_EV = { VEHICLE_STATUS_G3 = { VEHICLE_STATUS: { - "AVG_FUEL_CONSUMPTION": 2.3, - "DISTANCE_TO_EMPTY_FUEL": 707, - "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "AVG_FUEL_CONSUMPTION": 51.1, + "DISTANCE_TO_EMPTY_FUEL": 170, "DOOR_BOOT_POSITION": "CLOSED", - "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", "DOOR_ENGINE_HOOD_POSITION": "CLOSED", - "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_LEFT_POSITION": "CLOSED", - "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_RIGHT_POSITION": "CLOSED", - "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_LEFT_POSITION": "CLOSED", - "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", "REMAINING_FUEL_PERCENT": 77, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": 150, - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, - "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", - "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", - "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 2550, - "TYRE_PRESSURE_FRONT_RIGHT": 2550, - "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_FRONT_LEFT": 0.0, + "TYRE_PRESSURE_FRONT_RIGHT": 31.9, + "TYRE_PRESSURE_REAR_LEFT": 32.6, "TYRE_PRESSURE_REAR_RIGHT": None, - "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", - "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", - "TYRE_STATUS_REAR_LEFT": "UNKNOWN", - "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", "VEHICLE_STATE_TYPE": "IGNITION_OFF", "WINDOW_BACK_STATUS": "UNKNOWN", "WINDOW_FRONT_LEFT_STATUS": "VENTED", @@ -186,15 +123,14 @@ VEHICLE_STATUS_G3 = { "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", "WINDOW_SUNROOF_STATUS": "UNKNOWN", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } } EXPECTED_STATE_EV_IMPERIAL = { - "AVG_FUEL_CONSUMPTION": "102.3", - "DISTANCE_TO_EMPTY_FUEL": "439.3", + "AVG_FUEL_CONSUMPTION": "51.1", + "DISTANCE_TO_EMPTY_FUEL": "170", "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", @@ -203,45 +139,37 @@ EXPECTED_STATE_EV_IMPERIAL = { "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", - "ODOMETER": "766.8", - "POSITION_HEADING_DEGREE": "150", - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, + "ODOMETER": "1234", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", "TYRE_PRESSURE_FRONT_LEFT": "0.0", - "TYRE_PRESSURE_FRONT_RIGHT": "37.0", - "TYRE_PRESSURE_REAR_LEFT": "35.5", + "TYRE_PRESSURE_FRONT_RIGHT": "31.9", + "TYRE_PRESSURE_REAR_LEFT": "32.6", "TYRE_PRESSURE_REAR_RIGHT": "unknown", "VEHICLE_STATE_TYPE": "IGNITION_OFF", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } EXPECTED_STATE_EV_METRIC = { - "AVG_FUEL_CONSUMPTION": "2.3", - "DISTANCE_TO_EMPTY_FUEL": "707", + "AVG_FUEL_CONSUMPTION": "4.6", + "DISTANCE_TO_EMPTY_FUEL": "274", "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": "1.6", + "EV_DISTANCE_TO_EMPTY": "2", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", - "ODOMETER": "1234", - "POSITION_HEADING_DEGREE": "150", - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, + "ODOMETER": "1986", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "0", - "TYRE_PRESSURE_FRONT_RIGHT": "2550", - "TYRE_PRESSURE_REAR_LEFT": "2450", + "TYRE_PRESSURE_FRONT_LEFT": "0.0", + "TYRE_PRESSURE_FRONT_RIGHT": "219.9", + "TYRE_PRESSURE_REAR_LEFT": "224.8", "TYRE_PRESSURE_REAR_RIGHT": "unknown", "VEHICLE_STATE_TYPE": "IGNITION_OFF", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -259,9 +187,6 @@ EXPECTED_STATE_EV_UNAVAILABLE = { "EV_STATE_OF_CHARGE_PERCENT": "unavailable", "EV_TIME_TO_FULLY_CHARGED_UTC": "unavailable", "ODOMETER": "unavailable", - "POSITION_HEADING_DEGREE": "unavailable", - "POSITION_SPEED_KMPH": "unavailable", - "POSITION_TIMESTAMP": "unavailable", "TIMESTAMP": "unavailable", "TRANSMISSION_MODE": "unavailable", "TYRE_PRESSURE_FRONT_LEFT": "unavailable", @@ -269,7 +194,6 @@ EXPECTED_STATE_EV_UNAVAILABLE = { "TYRE_PRESSURE_REAR_LEFT": "unavailable", "TYRE_PRESSURE_REAR_RIGHT": "unavailable", "VEHICLE_STATE_TYPE": "unavailable", - "HEADING": "unavailable", "LATITUDE": "unavailable", "LONGITUDE": "unavailable", } diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 446f025e077..f769eba252c 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -1,6 +1,7 @@ """Common functions needed to setup tests for Subaru component.""" from datetime import timedelta +from typing import Any from unittest.mock import patch import pytest @@ -29,6 +30,8 @@ from homeassistant.const import ( CONF_PIN, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -56,6 +59,7 @@ MOCK_API_GET_REMOTE_STATUS = f"{MOCK_API}get_remote_status" MOCK_API_GET_SAFETY_STATUS = f"{MOCK_API}get_safety_status" MOCK_API_GET_SUBSCRIPTION_STATUS = f"{MOCK_API}get_subscription_status" MOCK_API_GET_DATA = f"{MOCK_API}get_data" +MOCK_API_GET_RAW_DATA = f"{MOCK_API}get_raw_data" MOCK_API_UPDATE = f"{MOCK_API}update" MOCK_API_FETCH = f"{MOCK_API}fetch" @@ -103,15 +107,18 @@ def advance_time_to_next_fetch(hass): async def setup_subaru_config_entry( - hass, + hass: HomeAssistant, config_entry, - vehicle_list=[TEST_VIN_2_EV], - vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], - vehicle_status=VEHICLE_STATUS_EV, + vehicle_list: list[str] | UndefinedType = UNDEFINED, + vehicle_data: dict[str, Any] | UndefinedType = UNDEFINED, + vehicle_status: dict[str, Any] | UndefinedType = UNDEFINED, connect_effect=None, fetch_effect=None, ): """Run async_setup with API mocks in place.""" + if vehicle_data is UNDEFINED: + vehicle_data = VEHICLE_DATA[TEST_VIN_2_EV] + with ( patch( MOCK_API_CONNECT, @@ -120,7 +127,7 @@ async def setup_subaru_config_entry( ), patch( MOCK_API_GET_VEHICLES, - return_value=vehicle_list, + return_value=[TEST_VIN_2_EV] if vehicle_list is UNDEFINED else vehicle_list, ), patch( MOCK_API_VIN_TO_NAME, @@ -160,7 +167,9 @@ async def setup_subaru_config_entry( ), patch( MOCK_API_GET_DATA, - return_value=vehicle_status, + return_value=VEHICLE_STATUS_EV + if vehicle_status is UNDEFINED + else vehicle_status, ), patch( MOCK_API_UPDATE, diff --git a/tests/components/subaru/fixtures/diagnostics_config_entry.json b/tests/components/subaru/fixtures/diagnostics_config_entry.json deleted file mode 100644 index 327b0c48174..00000000000 --- a/tests/components/subaru/fixtures/diagnostics_config_entry.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "config_entry": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "country": "USA", - "pin": "**REDACTED**", - "device_id": "**REDACTED**" - }, - "options": { - "update_enabled": true - }, - "data": [ - { - "vehicle_status": { - "AVG_FUEL_CONSUMPTION": 2.3, - "DISTANCE_TO_EMPTY_FUEL": 707, - "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", - "DOOR_BOOT_POSITION": "CLOSED", - "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", - "DOOR_ENGINE_HOOD_POSITION": "CLOSED", - "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", - "DOOR_FRONT_LEFT_POSITION": "CLOSED", - "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", - "DOOR_FRONT_RIGHT_POSITION": "CLOSED", - "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", - "DOOR_REAR_LEFT_POSITION": "CLOSED", - "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", - "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EV_CHARGER_STATE_TYPE": "CHARGING", - "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", - "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": 1, - "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", - "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": 20, - "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", - "ODOMETER": "**REDACTED**", - "POSITION_HEADING_DEGREE": 150, - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, - "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", - "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", - "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", - "TIMESTAMP": 1595560000.0, - "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 0, - "TYRE_PRESSURE_FRONT_RIGHT": 2550, - "TYRE_PRESSURE_REAR_LEFT": 2450, - "TYRE_PRESSURE_REAR_RIGHT": null, - "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", - "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", - "TYRE_STATUS_REAR_LEFT": "UNKNOWN", - "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", - "VEHICLE_STATE_TYPE": "IGNITION_OFF", - "WINDOW_BACK_STATUS": "UNKNOWN", - "WINDOW_FRONT_LEFT_STATUS": "VENTED", - "WINDOW_FRONT_RIGHT_STATUS": "VENTED", - "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", - "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", - "WINDOW_SUNROOF_STATUS": "UNKNOWN", - "HEADING": 170, - "LATITUDE": "**REDACTED**", - "LONGITUDE": "**REDACTED**" - } - } - ] -} diff --git a/tests/components/subaru/fixtures/diagnostics_device.json b/tests/components/subaru/fixtures/diagnostics_device.json deleted file mode 100644 index f67be94a171..00000000000 --- a/tests/components/subaru/fixtures/diagnostics_device.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "config_entry": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "country": "USA", - "pin": "**REDACTED**", - "device_id": "**REDACTED**" - }, - "options": { - "update_enabled": true - }, - "data": { - "vehicle_status": { - "AVG_FUEL_CONSUMPTION": 2.3, - "DISTANCE_TO_EMPTY_FUEL": 707, - "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", - "DOOR_BOOT_POSITION": "CLOSED", - "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", - "DOOR_ENGINE_HOOD_POSITION": "CLOSED", - "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", - "DOOR_FRONT_LEFT_POSITION": "CLOSED", - "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", - "DOOR_FRONT_RIGHT_POSITION": "CLOSED", - "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", - "DOOR_REAR_LEFT_POSITION": "CLOSED", - "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", - "DOOR_REAR_RIGHT_POSITION": "CLOSED", - "EV_CHARGER_STATE_TYPE": "CHARGING", - "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", - "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": 1, - "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", - "EV_STATE_OF_CHARGE_MODE": "EV_MODE", - "EV_STATE_OF_CHARGE_PERCENT": 20, - "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", - "ODOMETER": "**REDACTED**", - "POSITION_HEADING_DEGREE": 150, - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, - "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", - "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", - "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", - "TIMESTAMP": 1595560000.0, - "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 0, - "TYRE_PRESSURE_FRONT_RIGHT": 2550, - "TYRE_PRESSURE_REAR_LEFT": 2450, - "TYRE_PRESSURE_REAR_RIGHT": null, - "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", - "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", - "TYRE_STATUS_REAR_LEFT": "UNKNOWN", - "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", - "VEHICLE_STATE_TYPE": "IGNITION_OFF", - "WINDOW_BACK_STATUS": "UNKNOWN", - "WINDOW_FRONT_LEFT_STATUS": "VENTED", - "WINDOW_FRONT_RIGHT_STATUS": "VENTED", - "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", - "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", - "WINDOW_SUNROOF_STATUS": "UNKNOWN", - "HEADING": 170, - "LATITUDE": "**REDACTED**", - "LONGITUDE": "**REDACTED**" - } - } -} diff --git a/tests/components/subaru/fixtures/raw_api_data.json b/tests/components/subaru/fixtures/raw_api_data.json new file mode 100644 index 00000000000..61274ddc761 --- /dev/null +++ b/tests/components/subaru/fixtures/raw_api_data.json @@ -0,0 +1,232 @@ +{ + "switchVehicle": { + "customer": { + "sessionCustomer": "123", + "email": "Abc@email.com", + "firstName": "Hass", + "lastName": "User", + "oemCustId": "ABC", + "zip": "123456", + "phone": "123-456-4565" + }, + "vehicleName": "Subaru", + "stolenVehicle": false, + "features": [ + "ABS_MIL", + "AHBL_MIL", + "ATF_MIL", + "AWD_MIL", + "BSD", + "BSDRCT_MIL", + "CEL_MIL", + "EBD_MIL", + "EOL_MIL", + "EPAS_MIL", + "EPB_MIL", + "ESS_MIL", + "EYESIGHT", + "HEVCM_MIL", + "HEV_MIL", + "NAV_TOMTOM", + "OPL_MIL", + "PHEV", + "RAB_MIL", + "RCC", + "REARBRK", + "RPOIA", + "SRS_MIL", + "TEL_MIL", + "TIF_36", + "TIR_35", + "TPMS_MIL", + "VDC_MIL", + "WASH_MIL", + "g2" + ], + "vin": "JF2ABCDE6L0000001", + "modelYear": "2019", + "modelCode": "KRH", + "engineSize": 2.0, + "nickname": "Subaru", + "vehicleKey": 123456, + "active": true, + "licensePlate": "ABC-DEF", + "licensePlateState": "AA", + "email": "test@test.com", + "firstName": "Test", + "lastName": "User", + "subscriptionFeatures": ["REMOTE", "SAFETY", "RetailPHEV"], + "accessLevel": 1, + "oemCustId": "123-ABC-456", + "zip": "12345", + "vehicleMileage": 123456, + "phone": "123-456-4565", + "userOemCustId": "123-ABC-456", + "subscriptionStatus": "ACTIVE", + "authorizedVehicle": true, + "preferredDealer": "Dealer", + "cachedStateCode": "AA", + "subscriptionPlans": [], + "crmRightToRepair": false, + "needMileagePrompt": false, + "phev": null, + "sunsetUpgraded": true, + "extDescrip": "Cool-Gray Khaki", + "intDescrip": "Navy", + "modelName": "Crosstrek", + "transCode": "CVT", + "provisioned": true, + "remoteServicePinExist": true, + "needEmergencyContactPrompt": false, + "vehicleGeoPosition": { + "latitude": 40, + "longitude": -100.0, + "speed": null, + "heading": null, + "timestamp": "2020-07-24T03:06:40" + }, + "show3gSunsetBanner": false, + "timeZone": "America/New_York" + }, + "vehicleStatus": { + "success": true, + "errorCode": null, + "dataName": null, + "data": { + "vhsId": 123456789, + "odometerValue": 123456, + "odometerValueKilometers": 123456, + "eventDate": 1595560000000, + "eventDateStr": "2020-07-24T03:06+0000", + "latitude": 40.0, + "longitude": -100.0, + "positionHeadingDegree": "261", + "tirePressureFrontLeft": "2600", + "tirePressureFrontRight": "2700", + "tirePressureRearLeft": "2650", + "tirePressureRearRight": "2650", + "tirePressureFrontLeftPsi": "37.71", + "tirePressureFrontRightPsi": "39.16", + "tirePressureRearLeftPsi": "38.44", + "tirePressureRearRightPsi": "38.44", + "distanceToEmptyFuelMiles": 529.41, + "distanceToEmptyFuelKilometers": 852, + "avgFuelConsumptionMpg": 52.3, + "avgFuelConsumptionLitersPer100Kilometers": 4.5, + "evStateOfChargePercent": 14, + "evDistanceToEmptyMiles": 529.41, + "evDistanceToEmptyKilometers": 852, + "evDistanceToEmptyByStateMiles": null, + "evDistanceToEmptyByStateKilometers": null, + "vehicleStateType": "IGNITION_OFF", + "windowFrontLeftStatus": "VENTED", + "windowFrontRightStatus": "VENTED", + "windowRearLeftStatus": "UNKNOWN", + "windowRearRightStatus": "UNKNOWN", + "windowSunroofStatus": "UNKNOWN", + "tyreStatusFrontLeft": "UNKNOWN", + "tyreStatusFrontRight": "UNKNOWN", + "tyreStatusRearLeft": "UNKNOWN", + "tyreStatusRearRight": "UNKNOWN", + "remainingFuelPercent": null, + "distanceToEmptyFuelMiles10s": 530, + "distanceToEmptyFuelKilometers10s": 850 + } + }, + "condition": { + "success": true, + "errorCode": null, + "dataName": "remoteServiceStatus", + "data": { + "serviceRequestId": null, + "success": true, + "cancelled": false, + "remoteServiceType": "condition", + "remoteServiceState": "finished", + "subState": null, + "errorCode": null, + "result": { + "avgFuelConsumption": null, + "avgFuelConsumptionUnit": "MPG", + "distanceToEmptyFuel": null, + "distanceToEmptyFuelUnit": "MILES", + "odometer": 123456, + "odometerUnit": "MILES", + "tirePressureFrontLeft": null, + "tirePressureFrontLeftUnit": "PSI", + "tirePressureFrontRight": null, + "tirePressureFrontRightUnit": "PSI", + "tirePressureRearLeft": null, + "tirePressureRearLeftUnit": "PSI", + "tirePressureRearRight": null, + "tirePressureRearRightUnit": "PSI", + "lastUpdatedTime": "2020-07-24T03:06:00+0000", + "windowFrontLeftStatus": "VENTED", + "windowFrontRightStatus": "VENTED", + "windowRearLeftStatus": "UNKNOWN", + "windowRearRightStatus": "UNKNOWN", + "windowSunroofStatus": "UNKNOWN", + "remainingFuelPercent": null, + "evDistanceToEmpty": 17, + "evDistanceToEmptyUnit": "MILES", + "evChargerStateType": "CHARGING_STOPPED", + "evIsPluggedIn": "UNLOCKED_CONNECTED", + "evStateOfChargeMode": "EV_MODE", + "evTimeToFullyCharged": "65535", + "evStateOfChargePercent": "100", + "vehicleStateType": "IGNITION_OFF", + "doorBootPosition": "CLOSED", + "doorEngineHoodPosition": "CLOSED", + "doorFrontLeftPosition": "CLOSED", + "doorFrontRightPosition": "CLOSED", + "doorRearLeftPosition": "CLOSED", + "doorRearRightPosition": "CLOSED" + }, + "updateTime": null, + "vin": "JF2ABCDE6L0000001", + "errorDescription": null + } + }, + "locate": { + "success": true, + "errorCode": null, + "dataName": "remoteServiceStatus", + "data": { + "serviceRequestId": null, + "success": true, + "cancelled": false, + "remoteServiceType": "locate", + "remoteServiceState": "finished", + "subState": null, + "errorCode": null, + "result": { + "latitude": 40.0, + "longitude": -100.0, + "speed": null, + "heading": null, + "locationTimestamp": 1595560000000 + }, + "updateTime": null, + "vin": "JF2ABCDE6L0000001", + "errorDescription": null + } + }, + "climatePresetSettings": { + "success": true, + "errorCode": null, + "dataName": null, + "data": [ + "{\"name\": \"Auto\", \"runTimeMinutes\": \"10\", \"climateZoneFrontTemp\": \"74\", \"climateZoneFrontAirMode\": \"AUTO\", \"climateZoneFrontAirVolume\": \"AUTO\", \"outerAirCirculation\": \"auto\", \"heatedRearWindowActive\": \"false\", \"airConditionOn\": \"false\", \"heatedSeatFrontLeft\": \"off\", \"heatedSeatFrontRight\": \"off\", \"startConfiguration\": \"START_ENGINE_ALLOW_KEY_IN_IGNITION\", \"canEdit\": \"true\", \"disabled\": \"false\", \"vehicleType\": \"gas\", \"presetType\": \"subaruPreset\" }", + "{\"name\":\"Full Cool\",\"runTimeMinutes\":\"10\",\"climateZoneFrontTemp\":\"60\",\"climateZoneFrontAirMode\":\"feet_face_balanced\",\"climateZoneFrontAirVolume\":\"7\",\"airConditionOn\":\"true\",\"heatedSeatFrontLeft\":\"high_cool\",\"heatedSeatFrontRight\":\"high_cool\",\"heatedRearWindowActive\":\"false\",\"outerAirCirculation\":\"outsideAir\",\"startConfiguration\":\"START_ENGINE_ALLOW_KEY_IN_IGNITION\",\"canEdit\":\"true\",\"disabled\":\"true\",\"vehicleType\":\"gas\",\"presetType\":\"subaruPreset\"}", + "{\"name\": \"Full Heat\", \"runTimeMinutes\": \"10\", \"climateZoneFrontTemp\": \"85\", \"climateZoneFrontAirMode\": \"feet_window\", \"climateZoneFrontAirVolume\": \"7\", \"airConditionOn\": \"false\", \"heatedSeatFrontLeft\": \"high_heat\", \"heatedSeatFrontRight\": \"high_heat\", \"heatedRearWindowActive\": \"true\", \"outerAirCirculation\": \"outsideAir\", \"startConfiguration\": \"START_ENGINE_ALLOW_KEY_IN_IGNITION\", \"canEdit\": \"true\", \"disabled\": \"true\", \"vehicleType\": \"gas\", \"presetType\": \"subaruPreset\" }", + "{\"name\": \"Full Cool\", \"runTimeMinutes\": \"10\", \"climateZoneFrontTemp\": \"60\", \"climateZoneFrontAirMode\": \"feet_face_balanced\", \"climateZoneFrontAirVolume\": \"7\", \"airConditionOn\": \"true\", \"heatedSeatFrontLeft\": \"OFF\", \"heatedSeatFrontRight\": \"OFF\", \"heatedRearWindowActive\": \"false\", \"outerAirCirculation\": \"outsideAir\", \"startConfiguration\": \"START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION\", \"canEdit\": \"true\", \"disabled\": \"true\", \"vehicleType\": \"phev\", \"presetType\": \"subaruPreset\" }", + "{\"name\": \"Full Heat\", \"runTimeMinutes\": \"10\", \"climateZoneFrontTemp\": \"85\", \"climateZoneFrontAirMode\": \"feet_window\", \"climateZoneFrontAirVolume\": \"7\", \"airConditionOn\": \"false\", \"heatedSeatFrontLeft\": \"high_heat\", \"heatedSeatFrontRight\": \"high_heat\", \"heatedRearWindowActive\": \"true\", \"outerAirCirculation\": \"outsideAir\", \"startConfiguration\": \"START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION\", \"canEdit\": \"true\", \"disabled\": \"true\", \"vehicleType\": \"phev\", \"presetType\": \"subaruPreset\" }" + ] + }, + "remoteEngineStartSettings": { + "success": true, + "errorCode": null, + "dataName": null, + "data": "{\"name\": \"Full Heat\", \"runTimeMinutes\": \"10\", \"climateZoneFrontTemp\": \"85\", \"climateZoneFrontAirMode\": \"feet_window\", \"climateZoneFrontAirVolume\": \"7\", \"airConditionOn\": \"false\", \"heatedSeatFrontLeft\": \"high_heat\", \"heatedSeatFrontRight\": \"high_heat\", \"heatedRearWindowActive\": \"true\", \"outerAirCirculation\": \"outsideAir\", \"startConfiguration\": \"START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION\", \"canEdit\": \"true\", \"disabled\": \"true\", \"vehicleType\": \"phev\", \"presetType\": \"subaruPreset\" }" + } +} diff --git a/tests/components/subaru/snapshots/test_diagnostics.ambr b/tests/components/subaru/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..14c19dd78a9 --- /dev/null +++ b/tests/components/subaru/snapshots/test_diagnostics.ambr @@ -0,0 +1,326 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'config_entry': dict({ + 'country': 'USA', + 'device_id': '**REDACTED**', + 'password': '**REDACTED**', + 'pin': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'data': list([ + dict({ + 'vehicle_status': dict({ + 'AVG_FUEL_CONSUMPTION': 51.1, + 'DISTANCE_TO_EMPTY_FUEL': 170, + 'DOOR_BOOT_POSITION': 'CLOSED', + 'DOOR_ENGINE_HOOD_POSITION': 'CLOSED', + 'DOOR_FRONT_LEFT_POSITION': 'CLOSED', + 'DOOR_FRONT_RIGHT_POSITION': 'CLOSED', + 'DOOR_REAR_LEFT_POSITION': 'CLOSED', + 'DOOR_REAR_RIGHT_POSITION': 'CLOSED', + 'EV_CHARGER_STATE_TYPE': 'CHARGING', + 'EV_CHARGE_SETTING_AMPERE_TYPE': 'MAXIMUM', + 'EV_CHARGE_VOLT_TYPE': 'CHARGE_LEVEL_1', + 'EV_DISTANCE_TO_EMPTY': 1, + 'EV_IS_PLUGGED_IN': 'UNLOCKED_CONNECTED', + 'EV_STATE_OF_CHARGE_MODE': 'EV_MODE', + 'EV_STATE_OF_CHARGE_PERCENT': 20, + 'EV_TIME_TO_FULLY_CHARGED_UTC': '2020-07-24T03:06:40+00:00', + 'LATITUDE': '**REDACTED**', + 'LONGITUDE': '**REDACTED**', + 'ODOMETER': '**REDACTED**', + 'TIMESTAMP': 1595560000.0, + 'TRANSMISSION_MODE': 'UNKNOWN', + 'TYRE_PRESSURE_FRONT_LEFT': 0.0, + 'TYRE_PRESSURE_FRONT_RIGHT': 31.9, + 'TYRE_PRESSURE_REAR_LEFT': 32.6, + 'TYRE_PRESSURE_REAR_RIGHT': None, + 'VEHICLE_STATE_TYPE': 'IGNITION_OFF', + 'WINDOW_BACK_STATUS': 'UNKNOWN', + 'WINDOW_FRONT_LEFT_STATUS': 'VENTED', + 'WINDOW_FRONT_RIGHT_STATUS': 'VENTED', + 'WINDOW_REAR_LEFT_STATUS': 'UNKNOWN', + 'WINDOW_REAR_RIGHT_STATUS': 'UNKNOWN', + 'WINDOW_SUNROOF_STATUS': 'UNKNOWN', + }), + }), + ]), + 'options': dict({ + 'update_enabled': True, + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'config_entry': dict({ + 'country': 'USA', + 'device_id': '**REDACTED**', + 'password': '**REDACTED**', + 'pin': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'data': dict({ + 'vehicle_status': dict({ + 'AVG_FUEL_CONSUMPTION': 51.1, + 'DISTANCE_TO_EMPTY_FUEL': 170, + 'DOOR_BOOT_POSITION': 'CLOSED', + 'DOOR_ENGINE_HOOD_POSITION': 'CLOSED', + 'DOOR_FRONT_LEFT_POSITION': 'CLOSED', + 'DOOR_FRONT_RIGHT_POSITION': 'CLOSED', + 'DOOR_REAR_LEFT_POSITION': 'CLOSED', + 'DOOR_REAR_RIGHT_POSITION': 'CLOSED', + 'EV_CHARGER_STATE_TYPE': 'CHARGING', + 'EV_CHARGE_SETTING_AMPERE_TYPE': 'MAXIMUM', + 'EV_CHARGE_VOLT_TYPE': 'CHARGE_LEVEL_1', + 'EV_DISTANCE_TO_EMPTY': 1, + 'EV_IS_PLUGGED_IN': 'UNLOCKED_CONNECTED', + 'EV_STATE_OF_CHARGE_MODE': 'EV_MODE', + 'EV_STATE_OF_CHARGE_PERCENT': 20, + 'EV_TIME_TO_FULLY_CHARGED_UTC': '2020-07-24T03:06:40+00:00', + 'LATITUDE': '**REDACTED**', + 'LONGITUDE': '**REDACTED**', + 'ODOMETER': '**REDACTED**', + 'TIMESTAMP': 1595560000.0, + 'TRANSMISSION_MODE': 'UNKNOWN', + 'TYRE_PRESSURE_FRONT_LEFT': 0.0, + 'TYRE_PRESSURE_FRONT_RIGHT': 31.9, + 'TYRE_PRESSURE_REAR_LEFT': 32.6, + 'TYRE_PRESSURE_REAR_RIGHT': None, + 'VEHICLE_STATE_TYPE': 'IGNITION_OFF', + 'WINDOW_BACK_STATUS': 'UNKNOWN', + 'WINDOW_FRONT_LEFT_STATUS': 'VENTED', + 'WINDOW_FRONT_RIGHT_STATUS': 'VENTED', + 'WINDOW_REAR_LEFT_STATUS': 'UNKNOWN', + 'WINDOW_REAR_RIGHT_STATUS': 'UNKNOWN', + 'WINDOW_SUNROOF_STATUS': 'UNKNOWN', + }), + }), + 'options': dict({ + 'update_enabled': True, + }), + 'raw_data': dict({ + 'climatePresetSettings': dict({ + 'data': list([ + '{"name": "Auto", "runTimeMinutes": "10", "climateZoneFrontTemp": "74", "climateZoneFrontAirMode": "AUTO", "climateZoneFrontAirVolume": "AUTO", "outerAirCirculation": "auto", "heatedRearWindowActive": "false", "airConditionOn": "false", "heatedSeatFrontLeft": "off", "heatedSeatFrontRight": "off", "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", "canEdit": "true", "disabled": "false", "vehicleType": "gas", "presetType": "subaruPreset" }', + '{"name":"Full Cool","runTimeMinutes":"10","climateZoneFrontTemp":"60","climateZoneFrontAirMode":"feet_face_balanced","climateZoneFrontAirVolume":"7","airConditionOn":"true","heatedSeatFrontLeft":"high_cool","heatedSeatFrontRight":"high_cool","heatedRearWindowActive":"false","outerAirCirculation":"outsideAir","startConfiguration":"START_ENGINE_ALLOW_KEY_IN_IGNITION","canEdit":"true","disabled":"true","vehicleType":"gas","presetType":"subaruPreset"}', + '{"name": "Full Heat", "runTimeMinutes": "10", "climateZoneFrontTemp": "85", "climateZoneFrontAirMode": "feet_window", "climateZoneFrontAirVolume": "7", "airConditionOn": "false", "heatedSeatFrontLeft": "high_heat", "heatedSeatFrontRight": "high_heat", "heatedRearWindowActive": "true", "outerAirCirculation": "outsideAir", "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", "canEdit": "true", "disabled": "true", "vehicleType": "gas", "presetType": "subaruPreset" }', + '{"name": "Full Cool", "runTimeMinutes": "10", "climateZoneFrontTemp": "60", "climateZoneFrontAirMode": "feet_face_balanced", "climateZoneFrontAirVolume": "7", "airConditionOn": "true", "heatedSeatFrontLeft": "OFF", "heatedSeatFrontRight": "OFF", "heatedRearWindowActive": "false", "outerAirCirculation": "outsideAir", "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", "canEdit": "true", "disabled": "true", "vehicleType": "phev", "presetType": "subaruPreset" }', + '{"name": "Full Heat", "runTimeMinutes": "10", "climateZoneFrontTemp": "85", "climateZoneFrontAirMode": "feet_window", "climateZoneFrontAirVolume": "7", "airConditionOn": "false", "heatedSeatFrontLeft": "high_heat", "heatedSeatFrontRight": "high_heat", "heatedRearWindowActive": "true", "outerAirCirculation": "outsideAir", "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", "canEdit": "true", "disabled": "true", "vehicleType": "phev", "presetType": "subaruPreset" }', + ]), + 'dataName': None, + 'errorCode': None, + 'success': True, + }), + 'condition': dict({ + 'data': dict({ + 'cancelled': False, + 'errorCode': None, + 'errorDescription': None, + 'remoteServiceState': 'finished', + 'remoteServiceType': 'condition', + 'result': dict({ + 'avgFuelConsumption': None, + 'avgFuelConsumptionUnit': 'MPG', + 'distanceToEmptyFuel': None, + 'distanceToEmptyFuelUnit': 'MILES', + 'doorBootPosition': 'CLOSED', + 'doorEngineHoodPosition': 'CLOSED', + 'doorFrontLeftPosition': 'CLOSED', + 'doorFrontRightPosition': 'CLOSED', + 'doorRearLeftPosition': 'CLOSED', + 'doorRearRightPosition': 'CLOSED', + 'evChargerStateType': 'CHARGING_STOPPED', + 'evDistanceToEmpty': 17, + 'evDistanceToEmptyUnit': 'MILES', + 'evIsPluggedIn': 'UNLOCKED_CONNECTED', + 'evStateOfChargeMode': 'EV_MODE', + 'evStateOfChargePercent': '100', + 'evTimeToFullyCharged': '65535', + 'lastUpdatedTime': '2020-07-24T03:06:00+0000', + 'odometer': '**REDACTED**', + 'odometerUnit': 'MILES', + 'remainingFuelPercent': None, + 'tirePressureFrontLeft': None, + 'tirePressureFrontLeftUnit': 'PSI', + 'tirePressureFrontRight': None, + 'tirePressureFrontRightUnit': 'PSI', + 'tirePressureRearLeft': None, + 'tirePressureRearLeftUnit': 'PSI', + 'tirePressureRearRight': None, + 'tirePressureRearRightUnit': 'PSI', + 'vehicleStateType': 'IGNITION_OFF', + 'windowFrontLeftStatus': 'VENTED', + 'windowFrontRightStatus': 'VENTED', + 'windowRearLeftStatus': 'UNKNOWN', + 'windowRearRightStatus': 'UNKNOWN', + 'windowSunroofStatus': 'UNKNOWN', + }), + 'serviceRequestId': None, + 'subState': None, + 'success': True, + 'updateTime': None, + 'vin': '**REDACTED**', + }), + 'dataName': 'remoteServiceStatus', + 'errorCode': None, + 'success': True, + }), + 'locate': dict({ + 'data': dict({ + 'cancelled': False, + 'errorCode': None, + 'errorDescription': None, + 'remoteServiceState': 'finished', + 'remoteServiceType': 'locate', + 'result': dict({ + 'heading': None, + 'latitude': '**REDACTED**', + 'locationTimestamp': 1595560000000, + 'longitude': '**REDACTED**', + 'speed': None, + }), + 'serviceRequestId': None, + 'subState': None, + 'success': True, + 'updateTime': None, + 'vin': '**REDACTED**', + }), + 'dataName': 'remoteServiceStatus', + 'errorCode': None, + 'success': True, + }), + 'remoteEngineStartSettings': dict({ + 'data': '{"name": "Full Heat", "runTimeMinutes": "10", "climateZoneFrontTemp": "85", "climateZoneFrontAirMode": "feet_window", "climateZoneFrontAirVolume": "7", "airConditionOn": "false", "heatedSeatFrontLeft": "high_heat", "heatedSeatFrontRight": "high_heat", "heatedRearWindowActive": "true", "outerAirCirculation": "outsideAir", "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", "canEdit": "true", "disabled": "true", "vehicleType": "phev", "presetType": "subaruPreset" }', + 'dataName': None, + 'errorCode': None, + 'success': True, + }), + 'switchVehicle': dict({ + 'accessLevel': 1, + 'active': True, + 'authorizedVehicle': True, + 'cachedStateCode': '**REDACTED**', + 'crmRightToRepair': False, + 'customer': '**REDACTED**', + 'email': '**REDACTED**', + 'engineSize': 2.0, + 'extDescrip': 'Cool-Gray Khaki', + 'features': list([ + 'ABS_MIL', + 'AHBL_MIL', + 'ATF_MIL', + 'AWD_MIL', + 'BSD', + 'BSDRCT_MIL', + 'CEL_MIL', + 'EBD_MIL', + 'EOL_MIL', + 'EPAS_MIL', + 'EPB_MIL', + 'ESS_MIL', + 'EYESIGHT', + 'HEVCM_MIL', + 'HEV_MIL', + 'NAV_TOMTOM', + 'OPL_MIL', + 'PHEV', + 'RAB_MIL', + 'RCC', + 'REARBRK', + 'RPOIA', + 'SRS_MIL', + 'TEL_MIL', + 'TIF_36', + 'TIR_35', + 'TPMS_MIL', + 'VDC_MIL', + 'WASH_MIL', + 'g2', + ]), + 'firstName': '**REDACTED**', + 'intDescrip': 'Navy', + 'lastName': '**REDACTED**', + 'licensePlate': '**REDACTED**', + 'licensePlateState': '**REDACTED**', + 'modelCode': 'KRH', + 'modelName': 'Crosstrek', + 'modelYear': '2019', + 'needEmergencyContactPrompt': False, + 'needMileagePrompt': False, + 'nickname': '**REDACTED**', + 'oemCustId': '**REDACTED**', + 'phev': None, + 'phone': '**REDACTED**', + 'preferredDealer': '**REDACTED**', + 'provisioned': True, + 'remoteServicePinExist': True, + 'show3gSunsetBanner': False, + 'stolenVehicle': False, + 'subscriptionFeatures': list([ + 'REMOTE', + 'SAFETY', + 'RetailPHEV', + ]), + 'subscriptionPlans': list([ + ]), + 'subscriptionStatus': 'ACTIVE', + 'sunsetUpgraded': True, + 'timeZone': '**REDACTED**', + 'transCode': 'CVT', + 'userOemCustId': '**REDACTED**', + 'vehicleGeoPosition': '**REDACTED**', + 'vehicleKey': '**REDACTED**', + 'vehicleMileage': '**REDACTED**', + 'vehicleName': '**REDACTED**', + 'vin': '**REDACTED**', + 'zip': '**REDACTED**', + }), + 'vehicleStatus': dict({ + 'data': dict({ + 'avgFuelConsumptionLitersPer100Kilometers': 4.5, + 'avgFuelConsumptionMpg': 52.3, + 'distanceToEmptyFuelKilometers': 852, + 'distanceToEmptyFuelKilometers10s': 850, + 'distanceToEmptyFuelMiles': 529.41, + 'distanceToEmptyFuelMiles10s': 530, + 'evDistanceToEmptyByStateKilometers': None, + 'evDistanceToEmptyByStateMiles': None, + 'evDistanceToEmptyKilometers': 852, + 'evDistanceToEmptyMiles': 529.41, + 'evStateOfChargePercent': 14, + 'eventDate': 1595560000000, + 'eventDateStr': '2020-07-24T03:06+0000', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'odometerValue': '**REDACTED**', + 'odometerValueKilometers': '**REDACTED**', + 'positionHeadingDegree': '261', + 'remainingFuelPercent': None, + 'tirePressureFrontLeft': '2600', + 'tirePressureFrontLeftPsi': '37.71', + 'tirePressureFrontRight': '2700', + 'tirePressureFrontRightPsi': '39.16', + 'tirePressureRearLeft': '2650', + 'tirePressureRearLeftPsi': '38.44', + 'tirePressureRearRight': '2650', + 'tirePressureRearRightPsi': '38.44', + 'tyreStatusFrontLeft': 'UNKNOWN', + 'tyreStatusFrontRight': 'UNKNOWN', + 'tyreStatusRearLeft': 'UNKNOWN', + 'tyreStatusRearRight': 'UNKNOWN', + 'vehicleStateType': 'IGNITION_OFF', + 'vhsId': '**REDACTED**', + 'windowFrontLeftStatus': 'VENTED', + 'windowFrontRightStatus': 'VENTED', + 'windowRearLeftStatus': 'UNKNOWN', + 'windowRearRightStatus': 'UNKNOWN', + 'windowSunroofStatus': 'UNKNOWN', + }), + 'dataName': None, + 'errorCode': None, + 'success': True, + }), + }), + }) +# --- diff --git a/tests/components/subaru/test_device_tracker.py b/tests/components/subaru/test_device_tracker.py index b8a970007ab..d4cb8e642f4 100644 --- a/tests/components/subaru/test_device_tracker.py +++ b/tests/components/subaru/test_device_tracker.py @@ -15,9 +15,10 @@ from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time_to_next_fe DEVICE_ID = "device_tracker.test_vehicle_2" -async def test_device_tracker(hass: HomeAssistant, ev_entry) -> None: +async def test_device_tracker( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ev_entry +) -> None: """Test subaru device tracker entity exists and has correct info.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry actual = hass.states.get(DEVICE_ID) diff --git a/tests/components/subaru/test_diagnostics.py b/tests/components/subaru/test_diagnostics.py index 9445f1ca235..651689330b1 100644 --- a/tests/components/subaru/test_diagnostics.py +++ b/tests/components/subaru/test_diagnostics.py @@ -4,13 +4,19 @@ import json from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.subaru.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .api_responses import TEST_VIN_2_EV -from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time_to_next_fetch +from .conftest import ( + MOCK_API_FETCH, + MOCK_API_GET_DATA, + MOCK_API_GET_RAW_DATA, + advance_time_to_next_fetch, +) from tests.common import load_fixture from tests.components.diagnostics import ( @@ -21,51 +27,58 @@ from tests.typing import ClientSessionGenerator async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, ev_entry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + ev_entry, ) -> None: """Test config entry diagnostics.""" config_entry = hass.config_entries.async_entries(DOMAIN)[0] - diagnostics_fixture = json.loads( - load_fixture("subaru/diagnostics_config_entry.json") - ) - assert ( await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - == diagnostics_fixture + == snapshot ) async def test_device_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, ev_entry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + ev_entry, ) -> None: """Test device diagnostics.""" config_entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_VIN_2_EV)}, ) assert reg_device is not None - diagnostics_fixture = json.loads(load_fixture("subaru/diagnostics_device.json")) - - assert ( - await get_diagnostics_for_device(hass, hass_client, config_entry, reg_device) - == diagnostics_fixture - ) + raw_data = json.loads(load_fixture("subaru/raw_api_data.json")) + with patch(MOCK_API_GET_RAW_DATA, return_value=raw_data) as mock_get_raw_data: + assert ( + await get_diagnostics_for_device( + hass, hass_client, config_entry, reg_device + ) + == snapshot + ) + mock_get_raw_data.assert_called_once() async def test_device_diagnostics_vehicle_not_found( - hass: HomeAssistant, hass_client: ClientSessionGenerator, ev_entry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + ev_entry, ) -> None: """Test device diagnostics when the vehicle cannot be found.""" config_entry = hass.config_entries.async_entries(DOMAIN)[0] - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device( identifiers={(DOMAIN, TEST_VIN_2_EV)}, ) diff --git a/tests/components/subaru/test_lock.py b/tests/components/subaru/test_lock.py index 4d19d49579e..c954634cf63 100644 --- a/tests/components/subaru/test_lock.py +++ b/tests/components/subaru/test_lock.py @@ -24,9 +24,10 @@ MOCK_API_UNLOCK = f"{MOCK_API}unlock" DEVICE_ID = "lock.test_vehicle_2_door_locks" -async def test_device_exists(hass: HomeAssistant, ev_entry) -> None: +async def test_device_exists( + hass: HomeAssistant, entity_registry: er.EntityRegistry, ev_entry +) -> None: """Test subaru lock entity exists.""" - entity_registry = er.async_get(hass) entry = entity_registry.async_get(DEVICE_ID) assert entry @@ -60,8 +61,7 @@ async def test_lock_cmd_fails(hass: HomeAssistant, ev_entry) -> None: await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True ) - await hass.async_block_till_done() - mock_lock.assert_called_once() + mock_lock.assert_not_called() async def test_unlock_specific_door(hass: HomeAssistant, ev_entry) -> None: @@ -86,5 +86,4 @@ async def test_unlock_specific_door_invalid(hass: HomeAssistant, ev_entry) -> No {ATTR_ENTITY_ID: DEVICE_ID, ATTR_DOOR: "bad_value"}, blocking=True, ) - await hass.async_block_till_done() - mock_unlock.assert_not_called() + mock_unlock.assert_not_called() diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index de1df044d71..a468a2442e1 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -14,14 +14,11 @@ from homeassistant.components.subaru.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .api_responses import ( - EXPECTED_STATE_EV_IMPERIAL, EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, TEST_VIN_2_EV, - VEHICLE_STATUS_EV, ) from .conftest import ( MOCK_API_FETCH, @@ -31,20 +28,6 @@ from .conftest import ( ) -async def test_sensors_ev_imperial(hass: HomeAssistant, ev_entry) -> None: - """Test sensors supporting imperial units.""" - hass.config.units = US_CUSTOMARY_SYSTEM - - with ( - patch(MOCK_API_FETCH), - patch(MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV), - ): - advance_time_to_next_fetch(hass) - await hass.async_block_till_done() - - _assert_data(hass, EXPECTED_STATE_EV_IMPERIAL) - - async def test_sensors_ev_metric(hass: HomeAssistant, ev_entry) -> None: """Test sensors supporting metric units.""" _assert_data(hass, EXPECTED_STATE_EV_METRIC) @@ -74,10 +57,14 @@ async def test_sensors_missing_vin_data(hass: HomeAssistant, ev_entry) -> None: ], ) async def test_sensor_migrate_unique_ids( - hass: HomeAssistant, entitydata, old_unique_id, new_unique_id, subaru_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entitydata, + old_unique_id, + new_unique_id, + subaru_config_entry, ) -> None: """Test successful migration of entity unique_ids.""" - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=subaru_config_entry, @@ -106,10 +93,14 @@ async def test_sensor_migrate_unique_ids( ], ) async def test_sensor_migrate_unique_ids_duplicate( - hass: HomeAssistant, entitydata, old_unique_id, new_unique_id, subaru_config_entry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entitydata, + old_unique_id, + new_unique_id, + subaru_config_entry, ) -> None: """Test unsuccessful migration of entity unique_ids due to duplicate.""" - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=subaru_config_entry, diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 6c124bec30e..51ade6009dc 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the Suez Water tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.suez_water.async_setup_entry", return_value=True diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 1d689ffe0d6..3170a6779f0 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -139,80 +139,3 @@ async def test_form_error( 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"] is 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"] is 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"] is 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"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 48a214274c9..a30076d6d3c 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta from unittest.mock import patch +from astral import LocationInfo +import astral.sun from freezegun import freeze_time import pytest @@ -25,9 +27,6 @@ async def test_setting_rising(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(entity.ENTITY_ID) - from astral import LocationInfo - import astral.sun - utc_today = utc_now.date() location = LocationInfo( diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 13de0dffbdd..cb97ae565c7 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -15,10 +15,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_setting_rising( hass: HomeAssistant, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, - entity_registry_enabled_by_default: None, ) -> None: """Test retrieving sun setting and rising.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) @@ -112,8 +113,7 @@ async def test_setting_rising( entry_ids = hass.config_entries.async_entries("sun") - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("sensor.sun_next_dawn") + entity = entity_registry.async_get("sensor.sun_next_dawn") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC @@ -140,42 +140,42 @@ async def test_setting_rising( solar_azimuth_state.state != hass.states.get("sensor.sun_solar_azimuth").state ) - entity = entity_reg.async_get("sensor.sun_next_dusk") + entity = entity_registry.async_get("sensor.sun_next_dusk") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dusk" - entity = entity_reg.async_get("sensor.sun_next_midnight") + entity = entity_registry.async_get("sensor.sun_next_midnight") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_midnight" - entity = entity_reg.async_get("sensor.sun_next_noon") + entity = entity_registry.async_get("sensor.sun_next_noon") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_noon" - entity = entity_reg.async_get("sensor.sun_next_rising") + entity = entity_registry.async_get("sensor.sun_next_rising") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_rising" - entity = entity_reg.async_get("sensor.sun_next_setting") + entity = entity_registry.async_get("sensor.sun_next_setting") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_setting" - entity = entity_reg.async_get("sensor.sun_solar_elevation") + entity = entity_registry.async_get("sensor.sun_solar_elevation") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_elevation" - entity = entity_reg.async_get("sensor.sun_solar_azimuth") + entity = entity_registry.async_get("sensor.sun_solar_azimuth") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_azimuth" - entity = entity_reg.async_get("sensor.sun_solar_rising") + entity = entity_registry.async_get("sensor.sun_solar_rising") assert entity assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_rising" diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index e315ea8cdcd..fc1af35faea 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -14,7 +14,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -27,7 +27,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -41,7 +41,7 @@ def setup_comp(hass): ) -async def test_sunset_trigger(hass: HomeAssistant, calls) -> None: +async def test_sunset_trigger(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the sunset trigger.""" now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 2, tzinfo=dt_util.UTC) @@ -86,7 +86,7 @@ async def test_sunset_trigger(hass: HomeAssistant, calls) -> None: assert calls[0].data["id"] == 0 -async def test_sunrise_trigger(hass: HomeAssistant, calls) -> None: +async def test_sunrise_trigger(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test the sunrise trigger.""" now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 14, tzinfo=dt_util.UTC) @@ -108,7 +108,9 @@ async def test_sunrise_trigger(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_sunset_trigger_with_offset(hass: HomeAssistant, calls) -> None: +async def test_sunset_trigger_with_offset( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the sunset trigger with offset.""" now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 2, 30, tzinfo=dt_util.UTC) @@ -144,7 +146,9 @@ async def test_sunset_trigger_with_offset(hass: HomeAssistant, calls) -> None: assert calls[0].data["some"] == "sun - sunset - 0:30:00" -async def test_sunrise_trigger_with_offset(hass: HomeAssistant, calls) -> None: +async def test_sunrise_trigger_with_offset( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test the sunrise trigger with offset.""" now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC) trigger_time = datetime(2015, 9, 16, 13, 30, tzinfo=dt_util.UTC) diff --git a/tests/components/surepetcare/test_binary_sensor.py b/tests/components/surepetcare/test_binary_sensor.py index 106cf2f9155..0f5a9486073 100644 --- a/tests/components/surepetcare/test_binary_sensor.py +++ b/tests/components/surepetcare/test_binary_sensor.py @@ -17,10 +17,12 @@ EXPECTED_ENTITY_IDS = { async def test_binary_sensors( - hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + surepetcare, + mock_config_entry_setup: MockConfigEntry, ) -> None: """Test the generation of unique ids.""" - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() for entity_id, unique_id in EXPECTED_ENTITY_IDS.items(): diff --git a/tests/components/surepetcare/test_lock.py b/tests/components/surepetcare/test_lock.py index d4275e8385c..a47c4a336dc 100644 --- a/tests/components/surepetcare/test_lock.py +++ b/tests/components/surepetcare/test_lock.py @@ -21,10 +21,12 @@ EXPECTED_ENTITY_IDS = { async def test_locks( - hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + surepetcare, + mock_config_entry_setup: MockConfigEntry, ) -> None: """Test the generation of unique ids.""" - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() for entity_id, unique_id in EXPECTED_ENTITY_IDS.items(): diff --git a/tests/components/surepetcare/test_sensor.py b/tests/components/surepetcare/test_sensor.py index f543cdb9d35..ecf8a5cfc4f 100644 --- a/tests/components/surepetcare/test_sensor.py +++ b/tests/components/surepetcare/test_sensor.py @@ -16,10 +16,12 @@ EXPECTED_ENTITY_IDS = { async def test_sensors( - hass: HomeAssistant, surepetcare, mock_config_entry_setup: MockConfigEntry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + surepetcare, + mock_config_entry_setup: MockConfigEntry, ) -> None: """Test the generation of unique ids.""" - entity_registry = er.async_get(hass) state_entity_ids = hass.states.async_entity_ids() for entity_id, unique_id in EXPECTED_ENTITY_IDS.items(): diff --git a/tests/components/swiss_public_transport/conftest.py b/tests/components/swiss_public_transport/conftest.py index d01fba0f9d0..c139b99e54d 100644 --- a/tests/components/swiss_public_transport/conftest.py +++ b/tests/components/swiss_public_transport/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the swiss_public_transport tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.swiss_public_transport.async_setup_entry", diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py index 47969cdc9dd..b728c87d4b0 100644 --- a/tests/components/swiss_public_transport/test_config_flow.py +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -1,6 +1,6 @@ """Test the swiss_public_transport config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from opendata_transport.exceptions import ( OpendataTransportConnectionError, @@ -8,13 +8,11 @@ from opendata_transport.exceptions import ( ) 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 @@ -126,78 +124,3 @@ async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> No assert result["type"] is 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"] is 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_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"] is 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, - unique_id=f"{MOCK_DATA_IMPORT[CONF_START]} {MOCK_DATA_IMPORT[CONF_DESTINATION]}", - ) - 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"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 9ad656bcc2b..0b41ce7992d 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -7,7 +7,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -25,7 +25,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -54,7 +54,7 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in ["turn_off", "turn_on", "toggle"] + for action in ("turn_off", "turn_on", "toggle") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -102,7 +102,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["turn_off", "turn_on", "toggle"] + for action in ("turn_off", "turn_on", "toggle") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -110,12 +110,12 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_action( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off actions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -185,12 +185,12 @@ async def test_action( assert turn_on_calls[-1].data == {"entity_id": entry.entity_id} +@pytest.mark.usefixtures("enable_custom_integrations") async def test_action_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off actions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index cd0a67fa992..2ba2c6adb5c 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -10,7 +10,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -59,7 +59,7 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -107,7 +107,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_off", "is_on"] + for condition in ("is_off", "is_on") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -178,12 +178,12 @@ async def test_get_condition_capabilities_legacy( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -265,12 +265,12 @@ async def test_if_state( assert calls[1].data["some"] == "is_off event - test_event2" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -323,12 +323,12 @@ async def test_if_state_legacy( assert calls[0].data["some"] == "is_on event - test_event1" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_for_condition( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for firing if condition is on with delay.""" point1 = dt_util.utcnow() diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index c528f982ebb..092b7a964bb 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -9,7 +9,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.switch import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -59,7 +59,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -107,7 +107,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -176,12 +176,12 @@ async def test_get_trigger_capabilities_legacy( assert capabilities == expected_capabilities +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -287,12 +287,12 @@ async def test_if_fires_on_state_change( } +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -348,12 +348,12 @@ async def test_if_fires_on_state_change_legacy( ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, - enable_custom_integrations: None, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index aa3e4ccce58..989b10c11d6 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -20,15 +20,16 @@ from tests.common import ( @pytest.fixture(autouse=True) -def entities(hass: HomeAssistant, mock_switch_entities: list[MockSwitch]): +def entities( + hass: HomeAssistant, mock_switch_entities: list[MockSwitch] +) -> list[MockSwitch]: """Initialize the test switch.""" setup_test_component_platform(hass, switch.DOMAIN, mock_switch_entities) return mock_switch_entities -async def test_methods( - hass: HomeAssistant, entities, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_methods(hass: HomeAssistant, entities: list[MockSwitch]) -> None: """Test is_on, turn_on, turn_off methods.""" switch_1, switch_2, switch_3 = entities assert await async_setup_component( @@ -60,11 +61,11 @@ async def test_methods( assert switch.is_on(hass, switch_3.entity_id) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_switch_context( hass: HomeAssistant, entities, hass_admin_user: MockUser, - enable_custom_integrations: None, ) -> None: """Test that switch context works.""" assert await async_setup_component(hass, "switch", {"switch": {"platform": "test"}}) diff --git a/tests/components/switch_as_x/conftest.py b/tests/components/switch_as_x/conftest.py index 82827924070..88a86892d2d 100644 --- a/tests/components/switch_as_x/conftest.py +++ b/tests/components/switch_as_x/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -18,7 +18,7 @@ async def setup_homeassistant(hass: HomeAssistant): @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.switch_as_x.async_setup_entry", return_value=True diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 206ae232d56..2da4c52c7f9 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -75,18 +75,18 @@ async def test_config_flow( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_flow_registered_entity( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, mock_setup_entry: AsyncMock, hidden_by_before: er.RegistryEntryHider | None, hidden_by_after: er.RegistryEntryHider, ) -> None: """Test the config flow hides a registered entity.""" - registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", suggested_object_id="ceiling" ) assert switch_entity_entry.entity_id == "switch.ceiling" - registry.async_update_entity("switch.ceiling", hidden_by=hidden_by_before) + entity_registry.async_update_entity("switch.ceiling", hidden_by=hidden_by_before) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -122,7 +122,7 @@ async def test_config_flow_registered_entity( CONF_TARGET_DOMAIN: target_domain, } - switch_entity_entry = registry.async_get("switch.ceiling") + switch_entity_entry = entity_registry.async_get("switch.ceiling") assert switch_entity_entry.hidden_by == hidden_by_after diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 266d0fd0409..3889a43f741 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -80,11 +80,14 @@ async def test_config_entry_unregistered_uuid( ], ) async def test_entity_registry_events( - hass: HomeAssistant, target_domain: str, state_on: str, state_off: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + target_domain: str, + state_on: str, + state_off: str, ) -> None: """Test entity registry events are tracked.""" - registry = er.async_get(hass) - registry_entry = registry.async_get_or_create( + registry_entry = entity_registry.async_get_or_create( "switch", "test", "unique", original_name="ABC" ) switch_entity_id = registry_entry.entity_id @@ -112,7 +115,9 @@ async def test_entity_registry_events( # Change entity_id new_switch_entity_id = f"{switch_entity_id}_new" - registry.async_update_entity(switch_entity_id, new_entity_id=new_switch_entity_id) + entity_registry.async_update_entity( + switch_entity_id, new_entity_id=new_switch_entity_id + ) hass.states.async_set(new_switch_entity_id, STATE_OFF) await hass.async_block_till_done() @@ -129,27 +134,27 @@ async def test_entity_registry_events( with patch( "homeassistant.components.switch_as_x.async_unload_entry", ) as mock_setup_entry: - registry.async_update_entity(new_switch_entity_id, name="New name") + entity_registry.async_update_entity(new_switch_entity_id, name="New name") await hass.async_block_till_done() mock_setup_entry.assert_not_called() # Check removing the entity removes the config entry - registry.async_remove(new_switch_entity_id) + entity_registry.async_remove(new_switch_entity_id) await hass.async_block_till_done() assert hass.states.get(f"{target_domain}.abc") is None - assert registry.async_get(f"{target_domain}.abc") is None + assert entity_registry.async_get(f"{target_domain}.abc") is None assert len(hass.config_entries.async_entries("switch_as_x")) == 0 @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_device_registry_config_entry_1( - hass: HomeAssistant, target_domain: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + target_domain: str, ) -> None: """Test we add our config entry to the tracked switch's device.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - switch_config_entry = MockConfigEntry() switch_config_entry.add_to_hass(hass) @@ -206,12 +211,12 @@ async def test_device_registry_config_entry_1( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_device_registry_config_entry_2( - hass: HomeAssistant, target_domain: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + target_domain: str, ) -> None: """Test we add our config entry to the tracked switch's device.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - switch_config_entry = MockConfigEntry() switch_config_entry.add_to_hass(hass) @@ -262,7 +267,7 @@ async def test_device_registry_config_entry_2( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_entity_id( - hass: HomeAssistant, target_domain: Platform + hass: HomeAssistant, entity_registry: er.EntityRegistry, target_domain: Platform ) -> None: """Test light switch setup from config entry with entity id.""" config_entry = MockConfigEntry( @@ -292,17 +297,17 @@ async def test_config_entry_entity_id( assert state.name == "ABC" # Check the light is added to the entity registry - registry = er.async_get(hass) - entity_entry = registry.async_get(f"{target_domain}.abc") + entity_entry = entity_registry.async_get(f"{target_domain}.abc") assert entity_entry assert entity_entry.unique_id == config_entry.entry_id @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) -async def test_config_entry_uuid(hass: HomeAssistant, target_domain: Platform) -> None: +async def test_config_entry_uuid( + hass: HomeAssistant, entity_registry: er.EntityRegistry, target_domain: Platform +) -> None: """Test light switch setup from config entry with entity registry id.""" - registry = er.async_get(hass) - registry_entry = registry.async_get_or_create( + registry_entry = entity_registry.async_get_or_create( "switch", "test", "unique", original_name="ABC" ) @@ -328,11 +333,13 @@ async def test_config_entry_uuid(hass: HomeAssistant, target_domain: Platform) - @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) -async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + target_domain: Platform, +) -> None: """Test the entity is added to the wrapped entity's device.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - test_config_entry = MockConfigEntry() test_config_entry.add_to_hass(hass) @@ -370,11 +377,10 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test removing a config entry.""" - registry = er.async_get(hass) - # Setup the config entry switch_as_x_config_entry = MockConfigEntry( data={}, @@ -394,7 +400,7 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are present assert hass.states.get(f"{target_domain}.abc") is not None - assert registry.async_get(f"{target_domain}.abc") is not None + assert entity_registry.async_get(f"{target_domain}.abc") is not None # Remove the config entry assert await hass.config_entries.async_remove(switch_as_x_config_entry.entry_id) @@ -402,7 +408,7 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(f"{target_domain}.abc") is None - assert registry.async_get(f"{target_domain}.abc") is None + assert entity_registry.async_get(f"{target_domain}.abc") is None @pytest.mark.parametrize( @@ -415,15 +421,16 @@ async def test_setup_and_remove_config_entry( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_reset_hidden_by( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, hidden_by_before: er.RegistryEntryHider | None, hidden_by_after: er.RegistryEntryHider, ) -> None: """Test removing a config entry resets hidden by.""" - registry = er.async_get(hass) - - switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") - registry.async_update_entity( + switch_entity_entry = entity_registry.async_get_or_create( + "switch", "test", "unique" + ) + entity_registry.async_update_entity( switch_entity_entry.entity_id, hidden_by=hidden_by_before ) @@ -447,22 +454,21 @@ async def test_reset_hidden_by( await hass.async_block_till_done() # Check hidden by is reset - switch_entity_entry = registry.async_get(switch_entity_entry.entity_id) + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) assert switch_entity_entry.hidden_by == hidden_by_after @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_entity_category_inheritance( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test the entity category is inherited from source device.""" - registry = er.async_get(hass) - - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", original_name="ABC" ) - registry.async_update_entity( + entity_registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -484,7 +490,7 @@ async def test_entity_category_inheritance( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get(f"{target_domain}.abc") + entity_entry = entity_registry.async_get(f"{target_domain}.abc") assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id assert entity_entry.entity_category is EntityCategory.CONFIG @@ -493,15 +499,14 @@ async def test_entity_category_inheritance( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_entity_options( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test the source entity is stored as an entity option.""" - registry = er.async_get(hass) - - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", original_name="ABC" ) - registry.async_update_entity( + entity_registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -523,7 +528,7 @@ async def test_entity_options( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get(f"{target_domain}.abc") + entity_entry = entity_registry.async_get(f"{target_domain}.abc") assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id assert entity_entry.options == { @@ -534,12 +539,11 @@ async def test_entity_options( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_entity_name( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test the source entity has entity_name set to True.""" - registry = er.async_get(hass) - device_registry = dr.async_get(hass) - switch_config_entry = MockConfigEntry() switch_config_entry.add_to_hass(hass) @@ -549,14 +553,14 @@ async def test_entity_name( name="Device name", ) - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", device_id=device_entry.id, has_entity_name=True, ) - switch_entity_entry = registry.async_update_entity( + switch_entity_entry = entity_registry.async_update_entity( switch_entity_entry.entity_id, config_entry_id=switch_config_entry.entry_id, ) @@ -579,7 +583,7 @@ async def test_entity_name( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get(f"{target_domain}.device_name") + entity_entry = entity_registry.async_get(f"{target_domain}.device_name") assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id assert entity_entry.has_entity_name is True @@ -593,12 +597,11 @@ async def test_entity_name( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_custom_name_1( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test the source entity has a custom name.""" - registry = er.async_get(hass) - device_registry = dr.async_get(hass) - switch_config_entry = MockConfigEntry() switch_config_entry.add_to_hass(hass) @@ -608,7 +611,7 @@ async def test_custom_name_1( name="Device name", ) - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", @@ -616,7 +619,7 @@ async def test_custom_name_1( has_entity_name=True, original_name="Original entity name", ) - switch_entity_entry = registry.async_update_entity( + switch_entity_entry = entity_registry.async_update_entity( switch_entity_entry.entity_id, config_entry_id=switch_config_entry.entry_id, name="Custom entity name", @@ -640,7 +643,7 @@ async def test_custom_name_1( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get( + entity_entry = entity_registry.async_get( f"{target_domain}.device_name_original_entity_name" ) assert entity_entry @@ -656,6 +659,8 @@ async def test_custom_name_1( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_custom_name_2( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test the source entity has a custom name. @@ -663,9 +668,6 @@ async def test_custom_name_2( This tests the custom name is only copied from the source device when the switch_as_x config entry is setup the first time. """ - registry = er.async_get(hass) - device_registry = dr.async_get(hass) - switch_config_entry = MockConfigEntry() switch_config_entry.add_to_hass(hass) @@ -675,7 +677,7 @@ async def test_custom_name_2( name="Device name", ) - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", @@ -683,7 +685,7 @@ async def test_custom_name_2( has_entity_name=True, original_name="Original entity name", ) - switch_entity_entry = registry.async_update_entity( + switch_entity_entry = entity_registry.async_update_entity( switch_entity_entry.entity_id, config_entry_id=switch_config_entry.entry_id, name="New custom entity name", @@ -706,13 +708,13 @@ async def test_custom_name_2( # Register the switch as x entity in the entity registry, this means # the entity has been setup before - switch_as_x_entity_entry = registry.async_get_or_create( + switch_as_x_entity_entry = entity_registry.async_get_or_create( target_domain, "switch_as_x", switch_as_x_config_entry.entry_id, suggested_object_id="device_name_original_entity_name", ) - switch_as_x_entity_entry = registry.async_update_entity( + switch_as_x_entity_entry = entity_registry.async_update_entity( switch_as_x_entity_entry.entity_id, config_entry_id=switch_config_entry.entry_id, name="Old custom entity name", @@ -721,7 +723,7 @@ async def test_custom_name_2( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get( + entity_entry = entity_registry.async_get( f"{target_domain}.device_name_original_entity_name" ) assert entity_entry @@ -738,13 +740,13 @@ async def test_custom_name_2( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_import_expose_settings_1( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test importing assistant expose settings.""" await async_setup_component(hass, "homeassistant", {}) - registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", @@ -773,15 +775,15 @@ async def test_import_expose_settings_1( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get(f"{target_domain}.abc") + entity_entry = entity_registry.async_get(f"{target_domain}.abc") assert entity_entry # Check switch_as_x expose settings were copied from the switch expose_settings = exposed_entities.async_get_entity_settings( hass, entity_entry.entity_id ) - for assistant in EXPOSE_SETTINGS: - assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] + for assistant, settings in EXPOSE_SETTINGS.items(): + assert expose_settings[assistant]["should_expose"] == settings # Check the switch is no longer exposed expose_settings = exposed_entities.async_get_entity_settings( @@ -794,6 +796,7 @@ async def test_import_expose_settings_1( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_import_expose_settings_2( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test importing assistant expose settings. @@ -803,9 +806,8 @@ async def test_import_expose_settings_2( """ await async_setup_component(hass, "homeassistant", {}) - registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", @@ -833,7 +835,7 @@ async def test_import_expose_settings_2( # Register the switch as x entity in the entity registry, this means # the entity has been setup before - switch_as_x_entity_entry = registry.async_get_or_create( + switch_as_x_entity_entry = entity_registry.async_get_or_create( target_domain, "switch_as_x", switch_as_x_config_entry.entry_id, @@ -847,37 +849,34 @@ async def test_import_expose_settings_2( assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get(f"{target_domain}.abc") + entity_entry = entity_registry.async_get(f"{target_domain}.abc") assert entity_entry # Check switch_as_x expose settings were not copied from the switch expose_settings = exposed_entities.async_get_entity_settings( hass, entity_entry.entity_id ) - for assistant in EXPOSE_SETTINGS: - assert ( - expose_settings[assistant]["should_expose"] - is not EXPOSE_SETTINGS[assistant] - ) + for assistant, settings in EXPOSE_SETTINGS.items(): + assert expose_settings[assistant]["should_expose"] is not settings # Check the switch settings were not modified expose_settings = exposed_entities.async_get_entity_settings( hass, switch_entity_entry.entity_id ) - for assistant in EXPOSE_SETTINGS: - assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] + for assistant, settings in EXPOSE_SETTINGS.items(): + assert expose_settings[assistant]["should_expose"] == settings @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_restore_expose_settings( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test removing a config entry restores assistant expose settings.""" await async_setup_component(hass, "homeassistant", {}) - registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create( + switch_entity_entry = entity_registry.async_get_or_create( "switch", "test", "unique", @@ -900,7 +899,7 @@ async def test_restore_expose_settings( switch_as_x_config_entry.add_to_hass(hass) # Register the switch as x entity - switch_as_x_entity_entry = registry.async_get_or_create( + switch_as_x_entity_entry = entity_registry.async_get_or_create( target_domain, "switch_as_x", switch_as_x_config_entry.entry_id, @@ -920,18 +919,17 @@ async def test_restore_expose_settings( expose_settings = exposed_entities.async_get_entity_settings( hass, switch_entity_entry.entity_id ) - for assistant in EXPOSE_SETTINGS: - assert expose_settings[assistant]["should_expose"] == EXPOSE_SETTINGS[assistant] + for assistant, settings in EXPOSE_SETTINGS.items(): + assert expose_settings[assistant]["should_expose"] == settings @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_migrate( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test migration.""" - registry = er.async_get(hass) - # Setup the config entry config_entry = MockConfigEntry( data={}, @@ -960,17 +958,16 @@ async def test_migrate( # Check the state and entity registry entry are present assert hass.states.get(f"{target_domain}.abc") is not None - assert registry.async_get(f"{target_domain}.abc") is not None + assert entity_registry.async_get(f"{target_domain}.abc") is not None @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_migrate_from_future( hass: HomeAssistant, + entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test migration.""" - registry = er.async_get(hass) - # Setup the config entry config_entry = MockConfigEntry( data={}, @@ -998,4 +995,4 @@ async def test_migrate_from_future( # Check the state and entity registry entry are not present assert hass.states.get(f"{target_domain}.abc") is None - assert registry.async_get(f"{target_domain}.abc") is None + assert entity_registry.async_get(f"{target_domain}.abc") is None diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index c824a16d952..b2a8445546e 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -36,19 +36,13 @@ def patch_async_setup_entry(return_value=True): ) -async def init_integration( - hass: HomeAssistant, - *, - data: dict = ENTRY_CONFIG, - skip_entry_setup: bool = False, -) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Switchbot integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data=data) + entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG) entry.add_to_hass(hass) - if not skip_entry_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 3df082c4361..44f68a1c8ae 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index a62a100f55a..182e9457f22 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -487,7 +487,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", side_effect=SwitchbotAuthenticationError("error from api"), ): result = await hass.config_entries.flow.async_configure( @@ -510,7 +510,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None: return_value=True, ), patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", return_value={ CONF_KEY_ID: "ff", CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", @@ -560,8 +560,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> assert result["errors"] == {} with patch( - "homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key", - side_effect=SwitchbotAccountConnectionError, + "homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key", + side_effect=SwitchbotAccountConnectionError("Switchbot API down"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -572,7 +572,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) -> ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == "api_error" + assert result["description_placeholders"] == {"error_detail": "Switchbot API down"} async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None: diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 12a570d5b26..030a477596c 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -1,5 +1,7 @@ """Test the switchbot sensors.""" +import pytest + from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switchbot.const import DOMAIN from homeassistant.const import ( @@ -19,9 +21,8 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: None -) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" await async_setup_component(hass, DOMAIN, {}) inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py index bfaea2c5a31..ed233ff2de9 100644 --- a/tests/components/switchbot_cloud/conftest.py +++ b/tests/components/switchbot_cloud/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the SwitchBot via API tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.switchbot_cloud.async_setup_entry", diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index 543f6cad008..8ff395fcab3 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -1,16 +1,32 @@ """Common fixtures and objects for the Switcher integration tests.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_bridge(request): - """Return a mocked SwitcherBridge.""" +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" with patch( - "homeassistant.components.switcher_kis.utils.SwitcherBridge", autospec=True - ) as bridge_mock: + "homeassistant.components.switcher_kis.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_bridge(request: pytest.FixtureRequest) -> Generator[MagicMock]: + """Return a mocked SwitcherBridge.""" + with ( + patch( + "homeassistant.components.switcher_kis.SwitcherBridge", autospec=True + ) as bridge_mock, + patch( + "homeassistant.components.switcher_kis.utils.SwitcherBridge", + new=bridge_mock, + ), + ): bridge = bridge_mock.return_value bridge.devices = [] diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index aa0370bd347..3c5f3ff241e 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -13,13 +13,6 @@ from aioswitcher.device import ( ThermostatSwing, ) -from homeassistant.components.switcher_kis import ( - CONF_DEVICE_ID, - CONF_DEVICE_PASSWORD, - CONF_PHONE_ID, - DOMAIN, -) - DUMMY_AUTO_OFF_SET = "01:30:00" DUMMY_AUTO_SHUT_DOWN = "02:00:00" DUMMY_DEVICE_ID1 = "a123bc" @@ -59,14 +52,6 @@ DUMMY_REMOTE_ID = "ELEC7001" DUMMY_POSITION = 54 DUMMY_DIRECTION = ShutterDirection.SHUTTER_STOP -YAML_CONFIG = { - DOMAIN: { - CONF_PHONE_ID: DUMMY_PHONE_ID, - CONF_DEVICE_ID: DUMMY_DEVICE_ID1, - CONF_DEVICE_PASSWORD: DUMMY_DEVICE_PASSWORD, - } -} - DUMMY_PLUG_DEVICE = SwitcherPowerPlug( DeviceType.POWER_PLUG, DeviceState.ON, diff --git a/tests/components/switcher_kis/test_button.py b/tests/components/switcher_kis/test_button.py index c1350c0fec2..264c163e111 100644 --- a/tests/components/switcher_kis/test_button.py +++ b/tests/components/switcher_kis/test_button.py @@ -70,8 +70,6 @@ async def test_swing_button( await init_integration(hass) assert mock_bridge - assert hass.states.get(ASSUME_ON_EID) is None - assert hass.states.get(ASSUME_OFF_EID) is None assert hass.states.get(SWING_ON_EID) is not None assert hass.states.get(SWING_OFF_EID) is not None diff --git a/tests/components/switcher_kis/test_config_flow.py b/tests/components/switcher_kis/test_config_flow.py index 913424abae5..e42b8ac484d 100644 --- a/tests/components/switcher_kis/test_config_flow.py +++ b/tests/components/switcher_kis/test_config_flow.py @@ -1,11 +1,11 @@ """Test the Switcher config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from homeassistant import config_entries -from homeassistant.components.switcher_kis.const import DATA_DISCOVERY, DOMAIN +from homeassistant.components.switcher_kis.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -14,20 +14,6 @@ from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE from tests.common import MockConfigEntry -async def test_import(hass: HomeAssistant) -> None: - """Test import step.""" - with patch( - "homeassistant.components.switcher_kis.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Switcher" - assert result["data"] == {} - - @pytest.mark.parametrize( "mock_bridge", [ @@ -40,68 +26,60 @@ async def test_import(hass: HomeAssistant) -> None: ], indirect=True, ) -async def test_user_setup(hass: HomeAssistant, mock_bridge) -> None: +async def test_user_setup( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bridge +) -> None: """Test we can finish a config flow.""" with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - await hass.async_block_till_done() - assert mock_bridge.is_running is False - assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 2 + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] is None - - with patch( - "homeassistant.components.switcher_kis.async_setup_entry", return_value=True - ): result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Switcher" - assert result2["result"].data == {} + assert mock_bridge.is_running is False + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Switcher" + assert result2["result"].data == {} + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 async def test_user_setup_abort_no_devices_found( - hass: HomeAssistant, mock_bridge + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bridge ) -> None: """Test we abort a config flow if no devices found.""" with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert mock_bridge.is_running is False + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "no_devices_found" + await hass.async_block_till_done() - assert mock_bridge.is_running is False - assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 0 - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "no_devices_found" + assert len(mock_setup_entry.mock_calls) == 0 -@pytest.mark.parametrize( - "source", - [ - config_entries.SOURCE_IMPORT, - config_entries.SOURCE_USER, - ], -) -async def test_single_instance(hass: HomeAssistant, source) -> None: +async def test_single_instance(hass: HomeAssistant) -> None: """Test we only allow a single config flow.""" MockConfigEntry(domain=DOMAIN).add_to_hass(hass) await hass.async_block_till_done() result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index f0484ca2f67..a652348463e 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -1,69 +1,36 @@ """Test cases for the switcher_kis component.""" from datetime import timedelta -from unittest.mock import patch import pytest -from homeassistant import config_entries -from homeassistant.components.switcher_kis.const import ( - DATA_DEVICE, - DOMAIN, - MAX_UPDATE_INTERVAL_SEC, -) +from homeassistant.components.switcher_kis.const import DOMAIN, MAX_UPDATE_INTERVAL_SEC from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from . import init_integration -from .consts import DUMMY_SWITCHER_DEVICES, YAML_CONFIG +from .consts import DUMMY_DEVICE_ID1, DUMMY_DEVICE_ID4, DUMMY_SWITCHER_DEVICES from tests.common import async_fire_time_changed - - -@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) -async def test_async_setup_yaml_config(hass: HomeAssistant, mock_bridge) -> None: - """Test setup started by configuration from YAML.""" - assert await async_setup_component(hass, DOMAIN, YAML_CONFIG) - await hass.async_block_till_done() - - assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 - assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 - - -@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) -async def test_async_setup_user_config_flow(hass: HomeAssistant, mock_bridge) -> None: - """Test setup started by user config flow.""" - with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() - - assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 - assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 +from tests.typing import WebSocketGenerator async def test_update_fail( hass: HomeAssistant, mock_bridge, caplog: pytest.LogCaptureFixture ) -> None: """Test entities state unavailable when updates fail..""" - await init_integration(hass) + entry = await init_integration(hass) assert mock_bridge mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) await hass.async_block_till_done() assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 - assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 + assert len(entry.runtime_data) == 2 async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) @@ -108,11 +75,62 @@ async def test_entry_unload(hass: HomeAssistant, mock_bridge) -> None: assert entry.state is ConfigEntryState.LOADED assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED assert mock_bridge.is_running is False - assert len(hass.data[DOMAIN]) == 0 + + +async def test_remove_device( + hass: HomeAssistant, mock_bridge, hass_ws_client: WebSocketGenerator +) -> None: + """Test being able to remove a disconnected device.""" + assert await async_setup_component(hass, "config", {}) + entry = await init_integration(hass) + entry_id = entry.entry_id + assert mock_bridge + + mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) + await hass.async_block_till_done() + + assert mock_bridge.is_running is True + assert len(entry.runtime_data) == 2 + + device_registry = dr.async_get(hass) + live_device_id = DUMMY_DEVICE_ID1 + dead_device_id = DUMMY_DEVICE_ID4 + + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + + # Create a dead device + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, dead_device_id)}, + manufacturer="Switcher", + model="Switcher Model", + name="Switcher Device", + ) + await hass.async_block_till_done() + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3 + + # Try to remove a live device - fails + device = device_registry.async_get_device(identifiers={(DOMAIN, live_device_id)}) + client = await hass_ws_client(hass) + response = await client.remove_device(device.id, entry_id) + assert not response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3 + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, live_device_id)}) + is not None + ) + + # Try to remove a dead device - succeeds + device = device_registry.async_get_device(identifiers={(DOMAIN, dead_device_id)}) + response = await client.remove_device(device.id, entry_id) + assert response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, dead_device_id)}) is None + ) diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py index f61cdd5a010..1be2efed987 100644 --- a/tests/components/switcher_kis/test_sensor.py +++ b/tests/components/switcher_kis/test_sensor.py @@ -2,7 +2,6 @@ import pytest -from homeassistant.components.switcher_kis.const import DATA_DEVICE, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify @@ -32,12 +31,11 @@ DEVICE_SENSORS_TUPLE = ( @pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None: """Test sensor platform.""" - await init_integration(hass) + entry = await init_integration(hass) assert mock_bridge assert mock_bridge.is_running is True - assert len(hass.data[DOMAIN]) == 2 - assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 + assert len(entry.runtime_data) == 2 for device, sensors in DEVICE_SENSORS_TUPLE: for sensor, field in sensors: @@ -46,7 +44,9 @@ async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None: assert state.state == str(getattr(device, field)) -async def test_sensor_disabled(hass: HomeAssistant, mock_bridge) -> None: +async def test_sensor_disabled( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_bridge +) -> None: """Test sensor disabled by default.""" await init_integration(hass) assert mock_bridge @@ -54,11 +54,10 @@ async def test_sensor_disabled(hass: HomeAssistant, mock_bridge) -> None: mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) await hass.async_block_till_done() - registry = er.async_get(hass) device = DUMMY_WATER_HEATER_DEVICE unique_id = f"{device.device_id}-{device.mac_address}-auto_off_set" entity_id = f"sensor.{slugify(device.name)}_auto_shutdown" - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == unique_id @@ -66,7 +65,9 @@ async def test_sensor_disabled(hass: HomeAssistant, mock_bridge) -> None: assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity( + entry.entity_id, disabled_by=None + ) assert updated_entry != entry assert updated_entry.disabled is False diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index 044a3738543..2f05d0187be 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -1,16 +1,16 @@ """Configure Synology DSM tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.synology_dsm.async_setup_entry", return_value=True diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 85814f84aad..1574526a701 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -19,7 +19,6 @@ from homeassistant.components.synology_dsm.const import ( CONF_SNAPSHOT_QUALITY, DEFAULT_SCAN_INTERVAL, DEFAULT_SNAPSHOT_QUALITY, - DEFAULT_TIMEOUT, DOMAIN, ) from homeassistant.config_entries import ( @@ -35,7 +34,6 @@ from homeassistant.const import ( CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL, - CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -608,18 +606,16 @@ async def test_options_flow(hass: HomeAssistant, service: MagicMock) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL - assert config_entry.options[CONF_TIMEOUT] == DEFAULT_TIMEOUT assert config_entry.options[CONF_SNAPSHOT_QUALITY] == DEFAULT_SNAPSHOT_QUALITY # Manual result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 2, CONF_TIMEOUT: 30, CONF_SNAPSHOT_QUALITY: 0}, + user_input={CONF_SCAN_INTERVAL: 2, CONF_SNAPSHOT_QUALITY: 0}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options[CONF_SCAN_INTERVAL] == 2 - assert config_entry.options[CONF_TIMEOUT] == 30 assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py index 2a792d174f8..433a4b15c23 100644 --- a/tests/components/synology_dsm/test_media_source.py +++ b/tests/components/synology_dsm/test_media_source.py @@ -50,7 +50,8 @@ def dsm_with_photos() -> MagicMock: dsm.photos.get_albums = AsyncMock(return_value=[SynoPhotosAlbum(1, "Album 1", 10)]) dsm.photos.get_items_from_album = AsyncMock( return_value=[ - SynoPhotosItem(10, "", "filename.jpg", 12345, "10_1298753", "sm", False) + SynoPhotosItem(10, "", "filename.jpg", 12345, "10_1298753", "sm", False), + SynoPhotosItem(10, "", "filename.jpg", 12345, "10_1298753", "sm", True), ] ) dsm.photos.get_item_thumbnail_url = AsyncMock( @@ -102,6 +103,11 @@ async def test_resolve_media_bad_identifier( "/synology_dsm/ABC012345/12631_47189/filename.png", "image/png", ), + ( + "ABC012345/12/12631_47189/filename.png_shared", + "/synology_dsm/ABC012345/12631_47189/filename.png_shared", + "image/png", + ), ], ) async def test_resolve_media_success( @@ -333,7 +339,7 @@ async def test_browse_media_get_items_thumbnail_error( result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 + assert len(result.children) == 2 item = result.children[0] assert isinstance(item, BrowseMedia) assert item.thumbnail is None @@ -372,7 +378,7 @@ async def test_browse_media_get_items( result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 + assert len(result.children) == 2 item = result.children[0] assert isinstance(item, BrowseMedia) assert item.identifier == "mocked_syno_dsm_entry/1/10_1298753/filename.jpg" @@ -382,6 +388,15 @@ async def test_browse_media_get_items( assert item.can_play assert not item.can_expand assert item.thumbnail == "http://my.thumbnail.url" + item = result.children[1] + assert isinstance(item, BrowseMedia) + assert item.identifier == "mocked_syno_dsm_entry/1/10_1298753/filename.jpg_shared" + assert item.title == "filename.jpg" + assert item.media_class == MediaClass.IMAGE + assert item.media_content_type == "image/jpeg" + assert item.can_play + assert not item.can_expand + assert item.thumbnail == "http://my.thumbnail.url" @pytest.mark.usefixtures("setup_media_source") @@ -435,3 +450,8 @@ async def test_media_view( request, "mocked_syno_dsm_entry", "10_1298753/filename.jpg" ) assert isinstance(result, web.Response) + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, "mocked_syno_dsm_entry", "10_1298753/filename.jpg_shared" + ) + assert isinstance(result, web.Response) diff --git a/tests/components/system_health/test_init.py b/tests/components/system_health/test_init.py index e677b7d1d34..e51ab8fab99 100644 --- a/tests/components/system_health/test_init.py +++ b/tests/components/system_health/test_init.py @@ -110,7 +110,7 @@ async def test_info_endpoint_register_callback_exc( """Test that the info endpoint requires auth.""" async def mock_info(hass): - raise Exception("TEST ERROR") + raise Exception("TEST ERROR") # pylint: disable=broad-exception-raised async_register_info(hass, "lovelace", mock_info) assert await async_setup_component(hass, "system_health", {}) diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index e3550101dcc..918d995fab9 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch from homeassistant.bootstrap import async_setup_component from homeassistant.components import system_log from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType from tests.common import async_capture_events from tests.typing import WebSocketGenerator @@ -35,8 +36,8 @@ async def get_error_log(hass_ws_client): def _generate_and_log_exception(exception, log): try: - raise Exception(exception) - except Exception: # pylint: disable=broad-except + raise Exception(exception) # pylint: disable=broad-exception-raised + except Exception: _LOGGER.exception(log) @@ -89,7 +90,9 @@ class WatchLogErrorHandler(system_log.LogErrorHandler): self.watch_event.set() -async def async_setup_system_log(hass, config) -> WatchLogErrorHandler: +async def async_setup_system_log( + hass: HomeAssistant, config: ConfigType +) -> WatchLogErrorHandler: """Set up the system_log component.""" WatchLogErrorHandler.instances = [] with patch( @@ -109,7 +112,7 @@ async def test_normal_logs( await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() _LOGGER.debug("debug") - _LOGGER.info("info") + _LOGGER.info("Info") # Assert done by get_error_log logs = await get_error_log(hass_ws_client) @@ -133,10 +136,10 @@ async def test_warning(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) """Test that warning are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.warning("warning message") + _LOGGER.warning("Warning message") log = find_log(await get_error_log(hass_ws_client), "WARNING") - assert_log(log, "", "warning message", "WARNING") + assert_log(log, "", "Warning message", "WARNING") async def test_warning_good_format( @@ -145,11 +148,11 @@ async def test_warning_good_format( """Test that warning with good format arguments are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.warning("warning message: %s", "test") + _LOGGER.warning("Warning message: %s", "test") await hass.async_block_till_done() log = find_log(await get_error_log(hass_ws_client), "WARNING") - assert_log(log, "", "warning message: test", "WARNING") + assert_log(log, "", "Warning message: test", "WARNING") async def test_warning_missing_format_args( @@ -158,11 +161,11 @@ async def test_warning_missing_format_args( """Test that warning with missing format arguments are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.warning("warning message missing a format arg %s") + _LOGGER.warning("Warning message missing a format arg %s") await hass.async_block_till_done() log = find_log(await get_error_log(hass_ws_client), "WARNING") - assert_log(log, "", ["warning message missing a format arg %s"], "WARNING") + assert_log(log, "", ["Warning message missing a format arg %s"], "WARNING") async def test_error(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: @@ -170,10 +173,10 @@ async def test_error(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.error("error message") + _LOGGER.error("Error message") log = find_log(await get_error_log(hass_ws_client), "ERROR") - assert_log(log, "", "error message", "ERROR") + assert_log(log, "", "Error message", "ERROR") async def test_config_not_fire_event(hass: HomeAssistant) -> None: @@ -200,17 +203,17 @@ async def test_error_posted_as_event(hass: HomeAssistant) -> None: watcher = await async_setup_system_log( hass, {"system_log": {"max_entries": 2, "fire_event": True}} ) - wait_empty = watcher.add_watcher("error message") + wait_empty = watcher.add_watcher("Error message") events = async_capture_events(hass, system_log.EVENT_SYSTEM_LOG) - _LOGGER.error("error message") + _LOGGER.error("Error message") await wait_empty await hass.async_block_till_done() await hass.async_block_till_done() assert len(events) == 1 - assert_log(events[0].data, "", "error message", "ERROR") + assert_log(events[0].data, "", "Error message", "ERROR") async def test_critical( @@ -220,10 +223,10 @@ async def test_critical( await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.critical("critical message") + _LOGGER.critical("Critical message") log = find_log(await get_error_log(hass_ws_client), "CRITICAL") - assert_log(log, "", "critical message", "CRITICAL") + assert_log(log, "", "Critical message", "CRITICAL") async def test_remove_older_logs( @@ -232,18 +235,18 @@ async def test_remove_older_logs( """Test that older logs are rotated out.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.error("error message 1") - _LOGGER.error("error message 2") - _LOGGER.error("error message 3") + _LOGGER.error("Error message 1") + _LOGGER.error("Error message 2") + _LOGGER.error("Error message 3") await hass.async_block_till_done() log = await get_error_log(hass_ws_client) - assert_log(log[0], "", "error message 3", "ERROR") - assert_log(log[1], "", "error message 2", "ERROR") + assert_log(log[0], "", "Error message 3", "ERROR") + assert_log(log[1], "", "Error message 2", "ERROR") def log_msg(nr=2): """Log an error at same line.""" - _LOGGER.error("error message %s", nr) + _LOGGER.error("Error message %s", nr) async def test_dedupe_logs( @@ -252,19 +255,19 @@ async def test_dedupe_logs( """Test that duplicate log entries are dedupe.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.error("error message 1") + _LOGGER.error("Error message 1") log_msg() log_msg("2-2") - _LOGGER.error("error message 3") + _LOGGER.error("Error message 3") log = await get_error_log(hass_ws_client) - assert_log(log[0], "", "error message 3", "ERROR") + assert_log(log[0], "", "Error message 3", "ERROR") assert log[1]["count"] == 2 - assert_log(log[1], "", ["error message 2", "error message 2-2"], "ERROR") + assert_log(log[1], "", ["Error message 2", "Error message 2-2"], "ERROR") log_msg() log = await get_error_log(hass_ws_client) - assert_log(log[0], "", ["error message 2", "error message 2-2"], "ERROR") + assert_log(log[0], "", ["Error message 2", "Error message 2-2"], "ERROR") assert log[0]["timestamp"] > log[0]["first_occurred"] log_msg("2-3") @@ -277,11 +280,11 @@ async def test_dedupe_logs( log[0], "", [ - "error message 2-2", - "error message 2-3", - "error message 2-4", - "error message 2-5", - "error message 2-6", + "Error message 2-2", + "Error message 2-3", + "Error message 2-4", + "Error message 2-5", + "Error message 2-6", ], "ERROR", ) @@ -293,7 +296,7 @@ async def test_clear_logs( """Test that the log can be cleared via a service call.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() - _LOGGER.error("error message") + _LOGGER.error("Error message") await hass.services.async_call(system_log.DOMAIN, system_log.SERVICE_CLEAR, {}) await hass.async_block_till_done() @@ -354,7 +357,7 @@ async def test_unknown_path( await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) await hass.async_block_till_done() _LOGGER.findCaller = MagicMock(return_value=("unknown_path", 0, None, None)) - _LOGGER.error("error message") + _LOGGER.error("Error message") log = (await get_error_log(hass_ws_client))[0] assert log["source"] == ["unknown_path", 0] @@ -385,8 +388,8 @@ async def async_log_error_from_test_path(hass, path, watcher): return_value=logger_frame, ), ): - wait_empty = watcher.add_watcher("error message") - _LOGGER.error("error message") + wait_empty = watcher.add_watcher("Error message") + _LOGGER.error("Error message") await wait_empty @@ -444,7 +447,7 @@ async def test_raise_during_log_capture( raise_during_repr = RaisesDuringRepr() - _LOGGER.error("raise during repr: %s", raise_during_repr) + _LOGGER.error("Raise during repr: %s", raise_during_repr) log = find_log(await get_error_log(hass_ws_client), "ERROR") assert log is not None assert_log(log, "", "Bad logger message: repr error", "ERROR") diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index c8cf614e04d..e16debdf263 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -2,13 +2,13 @@ from __future__ import annotations -from collections.abc import Generator import socket from unittest.mock import AsyncMock, Mock, NonCallableMock, patch from psutil import NoSuchProcess, Process from psutil._common import sdiskpart, sdiskusage, shwtemp, snetio, snicaddr, sswap import pytest +from typing_extensions import Generator from homeassistant.components.systemmonitor.const import DOMAIN from homeassistant.components.systemmonitor.coordinator import VirtualMemory @@ -18,7 +18,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def mock_sys_platform() -> Generator[None, None, None]: +def mock_sys_platform() -> Generator[None]: """Mock sys platform to Linux.""" with patch("sys.platform", "linux"): yield @@ -42,7 +42,7 @@ class MockProcess(Process): @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setup entry.""" with patch( "homeassistant.components.systemmonitor.async_setup_entry", diff --git a/tests/components/systemmonitor/test_binary_sensor.py b/tests/components/systemmonitor/test_binary_sensor.py index e3fbdedc081..97369dc2738 100644 --- a/tests/components/systemmonitor/test_binary_sensor.py +++ b/tests/components/systemmonitor/test_binary_sensor.py @@ -20,9 +20,9 @@ from .conftest import MockProcess from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, entity_registry: er.EntityRegistry, @@ -62,9 +62,9 @@ async def test_binary_sensor( assert state.attributes == snapshot(name=f"{state.name} - attributes") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensor_icon( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, mock_config_entry: MockConfigEntry, diff --git a/tests/components/systemmonitor/test_config_flow.py b/tests/components/systemmonitor/test_config_flow.py index bd98099accc..f5cc46da096 100644 --- a/tests/components/systemmonitor/test_config_flow.py +++ b/tests/components/systemmonitor/test_config_flow.py @@ -5,15 +5,10 @@ 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.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -39,51 +34,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: 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"] is FlowResultType.CREATE_ENTRY - assert result["options"] == { - "binary_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: @@ -111,55 +61,6 @@ async def test_form_already_configured( 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={ - "binary_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"] is 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, device_registry: dr.DeviceRegistry, diff --git a/tests/components/systemmonitor/test_repairs.py b/tests/components/systemmonitor/test_repairs.py index d054bfa99a4..6c1ff9dfd16 100644 --- a/tests/components/systemmonitor/test_repairs.py +++ b/tests/components/systemmonitor/test_repairs.py @@ -5,6 +5,7 @@ from __future__ import annotations from http import HTTPStatus from unittest.mock import Mock +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.repairs.websocket_api import ( @@ -22,10 +23,10 @@ from tests.common import ANY, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_migrate_process_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, hass_client: ClientSessionGenerator, @@ -120,11 +121,11 @@ async def test_migrate_process_sensor( assert hass.config_entries.async_entries(DOMAIN) == snapshot(name="after_migration") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_other_fixable_issues( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, - entity_registry_enabled_by_default: None, mock_added_config_entry: ConfigEntry, ) -> None: """Test fixing other issues.""" diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index a11112d8f86..ce15083da67 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -17,16 +17,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component from .conftest import MockProcess from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, entity_registry: er.EntityRegistry, @@ -76,9 +75,9 @@ async def test_sensor( assert state.attributes == snapshot(name=f"{state.name} - attributes") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_process_sensor_not_loaded( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, entity_registry: er.EntityRegistry, @@ -108,9 +107,9 @@ async def test_process_sensor_not_loaded( assert process_sensor is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_not_loading_veth_networks( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_added_config_entry: ConfigEntry, ) -> None: """Test the sensor.""" @@ -123,9 +122,9 @@ async def test_sensor_not_loading_veth_networks( assert network_sensor_2 is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_icon( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, mock_config_entry: MockConfigEntry, @@ -142,58 +141,6 @@ async def test_sensor_icon( assert get_cpu_icon() == "mdi:cpu-64-bit" -async def test_sensor_yaml( - hass: HomeAssistant, - entity_registry_enabled_by_default: None, - mock_psutil: Mock, - mock_os: Mock, -) -> None: - """Test the sensor imported from YAML.""" - config = { - "sensor": { - "platform": "systemmonitor", - "resources": [ - {"type": "disk_use_percent"}, - {"type": "disk_use_percent", "arg": "/media/share"}, - {"type": "memory_free", "arg": "/"}, - {"type": "network_out", "arg": "eth0"}, - {"type": "process", "arg": "python3"}, - ], - } - } - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - memory_sensor = hass.states.get("sensor.system_monitor_memory_free") - assert memory_sensor is not None - assert memory_sensor.state == "40.0" - - process_sensor = hass.states.get("binary_sensor.system_monitor_process_python3") - assert process_sensor is not None - assert process_sensor.state == STATE_ON - - -async def test_sensor_yaml_fails_missing_argument( - caplog: pytest.LogCaptureFixture, - hass: HomeAssistant, - entity_registry_enabled_by_default: None, - mock_psutil: Mock, - mock_os: Mock, -) -> None: - """Test the sensor imported from YAML fails on missing mandatory argument.""" - config = { - "sensor": { - "platform": "systemmonitor", - "resources": [ - {"type": "network_in"}, - ], - } - } - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - assert "Mandatory 'arg' is missing for sensor type 'network_in'" in caplog.text - - async def test_sensor_updating( hass: HomeAssistant, mock_psutil: Mock, @@ -302,10 +249,10 @@ async def test_sensor_process_fails( assert "Failed to load process with ID: 1, old name: python3" in caplog.text +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_network_sensors( freezer: FrozenDateTimeFactory, hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_added_config_entry: ConfigEntry, mock_psutil: Mock, ) -> None: @@ -378,9 +325,9 @@ async def test_sensor_network_sensors( assert throughput_network_out_sensor.state == STATE_UNKNOWN +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_missing_cpu_temperature( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, mock_config_entry: MockConfigEntry, @@ -402,9 +349,9 @@ async def test_missing_cpu_temperature( assert temp_sensor is None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_processor_temperature( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, mock_config_entry: MockConfigEntry, @@ -452,9 +399,9 @@ async def test_processor_temperature( await hass.async_block_till_done() +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_exception_handling_disk_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_added_config_entry: ConfigEntry, caplog: pytest.LogCaptureFixture, @@ -511,9 +458,9 @@ async def test_exception_handling_disk_sensor( assert disk_sensor.attributes["unit_of_measurement"] == "%" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cpu_percentage_is_zero_returns_unknown( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_added_config_entry: ConfigEntry, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/systemmonitor/test_util.py b/tests/components/systemmonitor/test_util.py index 439ec88361b..b35c7b2e96c 100644 --- a/tests/components/systemmonitor/test_util.py +++ b/tests/components/systemmonitor/test_util.py @@ -17,9 +17,9 @@ from tests.common import MockConfigEntry (OSError("OS error"), "was excluded because of: OS error"), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_disk_setup_failure( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, mock_config_entry: MockConfigEntry, @@ -40,9 +40,9 @@ async def test_disk_setup_failure( assert error_text in caplog.text +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_disk_util( hass: HomeAssistant, - entity_registry_enabled_by_default: None, mock_psutil: Mock, mock_os: Mock, mock_config_entry: MockConfigEntry, diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index 6f44bee8960..a8883f47fe2 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -10,6 +10,7 @@ import requests from homeassistant import config_entries from homeassistant.components import zeroconf +from homeassistant.components.tado.config_flow import NoHomes from homeassistant.components.tado.const import ( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT, @@ -409,3 +410,83 @@ async def test_import_step_unique_id_configured(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert mock_setup_entry.call_count == 0 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PyTado.exceptions.TadoWrongCredentialsException, "invalid_auth"), + (RuntimeError, "cannot_connect"), + (NoHomes, "no_homes"), + (ValueError, "unknown"), + ], +) +async def test_reconfigure_flow( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test re-configuration flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + unique_id="unique_id", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + 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, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + entry = hass.config_entries.async_get_entry(entry.entry_id) + assert entry + assert entry.title == "Mock Title" + assert entry.data == { + "username": "test-username", + "password": "test-password", + "home_id": 1, + } diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py new file mode 100644 index 00000000000..bdd7977f858 --- /dev/null +++ b/tests/components/tado/test_helper.py @@ -0,0 +1,87 @@ +"""Helper method tests.""" + +from unittest.mock import patch + +from homeassistant.components.tado import TadoConnector +from homeassistant.components.tado.const import ( + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TIMER, +) +from homeassistant.components.tado.helper import decide_duration, decide_overlay_mode +from homeassistant.core import HomeAssistant + + +def dummy_tado_connector(hass: HomeAssistant, fallback) -> TadoConnector: + """Return dummy tado connector.""" + return TadoConnector(hass, username="dummy", password="dummy", fallback=fallback) + + +async def test_overlay_mode_duration_set(hass: HomeAssistant) -> None: + """Test overlay method selection when duration is set.""" + tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE) + overlay_mode = decide_overlay_mode(tado=tado, duration=3600, zone_id=1) + # Must select TIMER overlay + assert overlay_mode == CONST_OVERLAY_TIMER + + +async def test_overlay_mode_next_time_block_fallback(hass: HomeAssistant) -> None: + """Test overlay method selection when duration is not set.""" + integration_fallback = CONST_OVERLAY_TADO_MODE + tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) + overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1) + # Must fallback to integration wide setting + assert overlay_mode == integration_fallback + + +async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: + """Test overlay method selection when tado default is selected.""" + integration_fallback = CONST_OVERLAY_TADO_DEFAULT + zone_fallback = CONST_OVERLAY_MANUAL + tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) + + class MockZoneData: + def __init__(self) -> None: + self.default_overlay_termination_type = zone_fallback + + zone_id = 1 + + zone_data = {"zone": {zone_id: MockZoneData()}} + with patch.dict(tado.data, zone_data): + overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=zone_id) + # Must fallback to zone setting + assert overlay_mode == zone_fallback + + +async def test_duration_enabled_without_tado_default(hass: HomeAssistant) -> None: + """Test duration decide method when overlay is timer and duration is set.""" + overlay = CONST_OVERLAY_TIMER + expected_duration = 600 + tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_MANUAL) + duration = decide_duration( + tado=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 + ) + # Should return the same duration value + assert duration == expected_duration + + +async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: + """Test overlay method selection when ended up with timer overlay and None duration.""" + zone_fallback = CONST_OVERLAY_TIMER + expected_duration = 45000 + tado = dummy_tado_connector(hass=hass, fallback=zone_fallback) + + class MockZoneData: + def __init__(self) -> None: + self.default_overlay_termination_duration = expected_duration + + zone_id = 1 + + zone_data = {"zone": {zone_id: MockZoneData()}} + with patch.dict(tado.data, zone_data): + duration = decide_duration( + tado=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback + ) + # Must fallback to zone timer setting + assert duration == expected_duration diff --git a/tests/components/tado/test_repairs.py b/tests/components/tado/test_repairs.py new file mode 100644 index 00000000000..9b7a010e359 --- /dev/null +++ b/tests/components/tado/test_repairs.py @@ -0,0 +1,64 @@ +"""Repair tests.""" + +import pytest + +from homeassistant.components.tado.const import ( + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_MODE, + DOMAIN, + WATER_HEATER_FALLBACK_REPAIR, +) +from homeassistant.components.tado.repairs import manage_water_heater_fallback_issue +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + + +class MockWaterHeater: + """Mock Water heater entity.""" + + def __init__(self, zone_name) -> None: + """Init mock entity class.""" + self.zone_name = zone_name + + +async def test_manage_water_heater_fallback_issue_not_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test water heater fallback issue is not needed.""" + zone_name = "Hot Water" + expected_issue_id = f"{WATER_HEATER_FALLBACK_REPAIR}_{zone_name}" + water_heater_names = [zone_name] + manage_water_heater_fallback_issue( + water_heater_names=water_heater_names, + integration_overlay_fallback=CONST_OVERLAY_TADO_MODE, + hass=hass, + ) + assert ( + issue_registry.async_get_issue(issue_id=expected_issue_id, domain=DOMAIN) + is None + ) + + +@pytest.mark.parametrize( + "integration_overlay_fallback", [CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_MANUAL] +) +async def test_manage_water_heater_fallback_issue_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + integration_overlay_fallback: str, +) -> None: + """Test water heater fallback issue created cases.""" + zone_name = "Hot Water" + expected_issue_id = f"{WATER_HEATER_FALLBACK_REPAIR}_{zone_name}" + water_heater_names = [zone_name] + manage_water_heater_fallback_issue( + water_heater_names=water_heater_names, + integration_overlay_fallback=integration_overlay_fallback, + hass=hass, + ) + assert ( + issue_registry.async_get_issue(issue_id=expected_issue_id, domain=DOMAIN) + is not None + ) diff --git a/tests/components/tado/test_service.py b/tests/components/tado/test_service.py index 759470cb5ea..994f135199f 100644 --- a/tests/components/tado/test_service.py +++ b/tests/components/tado/test_service.py @@ -80,7 +80,7 @@ async def test_add_meter_readings_exception( blocking=True, ) - assert "Could not set meter reading" in str(exc) + assert "Could not set meter reading" in str(exc) async def test_add_meter_readings_invalid( @@ -109,7 +109,7 @@ async def test_add_meter_readings_invalid( blocking=True, ) - assert "invalid new reading" in str(exc) + assert "invalid new reading" in str(exc) async def test_add_meter_readings_duplicate( @@ -138,4 +138,4 @@ async def test_add_meter_readings_duplicate( blocking=True, ) - assert "reading already exists for date" in str(exc) + assert "reading already exists for date" in str(exc) diff --git a/tests/components/tag/__init__.py b/tests/components/tag/__init__.py index 5908bd04e59..5c701af5d0a 100644 --- a/tests/components/tag/__init__.py +++ b/tests/components/tag/__init__.py @@ -1 +1,7 @@ """Tests for the Tag integration.""" + +TEST_TAG_ID = "test tag id" +TEST_TAG_ID_2 = "test tag id 2" +TEST_TAG_NAME = "test tag name" +TEST_TAG_NAME_2 = "test tag name 2" +TEST_DEVICE_ID = "device id" diff --git a/tests/components/tag/snapshots/test_init.ambr b/tests/components/tag/snapshots/test_init.ambr new file mode 100644 index 00000000000..29a9a2665b8 --- /dev/null +++ b/tests/components/tag/snapshots/test_init.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_migration + dict({ + 'data': dict({ + 'items': list([ + dict({ + 'id': 'test tag id', + 'migrated': True, + 'name': 'test tag name', + }), + dict({ + 'device_id': 'some_scanner', + 'id': 'new tag', + 'last_scanned': '2024-02-29T13:00:00+00:00', + }), + dict({ + 'id': '1234567890', + }), + ]), + }), + 'key': 'tag', + 'minor_version': 3, + 'version': 1, + }) +# --- diff --git a/tests/components/tag/test_event.py b/tests/components/tag/test_event.py index 0338ed504d7..e0a10455d1e 100644 --- a/tests/components/tag/test_event.py +++ b/tests/components/tag/test_event.py @@ -1,26 +1,26 @@ """Tests for the tag component.""" +from typing import Any + from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.tag import DOMAIN, EVENT_TAG_SCANNED, async_scan_tag -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import TEST_DEVICE_ID, TEST_TAG_ID, TEST_TAG_NAME + from tests.common import async_capture_events from tests.typing import WebSocketGenerator -TEST_TAG_ID = "test tag id" -TEST_TAG_NAME = "test tag name" -TEST_DEVICE_ID = "device id" - @pytest.fixture def storage_setup_named_tag( - hass, - hass_storage, + hass: HomeAssistant, + hass_storage: dict[str, Any], ): """Storage setup for test case of named tags.""" @@ -29,10 +29,21 @@ def storage_setup_named_tag( hass_storage[DOMAIN] = { "key": DOMAIN, "version": 1, - "data": {"items": [{"id": TEST_TAG_ID, CONF_NAME: TEST_TAG_NAME}]}, + "minor_version": 2, + "data": { + "items": [ + { + "id": TEST_TAG_ID, + "tag_id": TEST_TAG_ID, + } + ] + }, } else: hass_storage[DOMAIN] = items + entity_registry = er.async_get(hass) + entry = entity_registry.async_get_or_create(DOMAIN, DOMAIN, TEST_TAG_ID) + entity_registry.async_update_entity(entry.entity_id, name=TEST_TAG_NAME) config = {DOMAIN: {}} return await async_setup_component(hass, DOMAIN, config) @@ -67,7 +78,7 @@ async def test_named_tag_scanned_event( @pytest.fixture -def storage_setup_unnamed_tag(hass, hass_storage): +def storage_setup_unnamed_tag(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup for test case of unnamed tags.""" async def _storage(items=None): @@ -75,7 +86,8 @@ def storage_setup_unnamed_tag(hass, hass_storage): hass_storage[DOMAIN] = { "key": DOMAIN, "version": 1, - "data": {"items": [{"id": TEST_TAG_ID}]}, + "minor_version": 2, + "data": {"items": [{"id": TEST_TAG_ID, "tag_id": TEST_TAG_ID}]}, } else: hass_storage[DOMAIN] = items @@ -107,6 +119,6 @@ async def test_unnamed_tag_scanned_event( event = events[0] event_data = event.data - assert event_data["name"] is None + assert event_data["name"] == "Tag test tag id" assert event_data["device_id"] == TEST_DEVICE_ID assert event_data["tag_id"] == TEST_TAG_ID diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index d7f77c0d2e2..6f309391d2b 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -1,19 +1,27 @@ """Tests for the tag component.""" +import logging +from typing import Any + from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.tag import DOMAIN, TAGS, async_scan_tag +from homeassistant.components.tag import DOMAIN, _create_entry, async_scan_tag +from homeassistant.const import CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import collection +from homeassistant.helpers import collection, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import TEST_DEVICE_ID, TEST_TAG_ID, TEST_TAG_ID_2, TEST_TAG_NAME, TEST_TAG_NAME_2 + +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None): @@ -21,7 +29,48 @@ def storage_setup(hass, hass_storage): hass_storage[DOMAIN] = { "key": DOMAIN, "version": 1, - "data": {"items": [{"id": "test tag"}]}, + "minor_version": 2, + "data": { + "items": [ + { + "id": TEST_TAG_ID, + }, + { + "id": TEST_TAG_ID_2, + }, + ] + }, + } + else: + hass_storage[DOMAIN] = items + entity_registry = er.async_get(hass) + _create_entry(entity_registry, TEST_TAG_ID, TEST_TAG_NAME) + _create_entry(entity_registry, TEST_TAG_ID_2, TEST_TAG_NAME_2) + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + + +@pytest.fixture +def storage_setup_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]): + """Storage version 1.1 setup.""" + + async def _storage(items=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "minor_version": 1, + "data": { + "items": [ + { + "id": TEST_TAG_ID, + "tag_id": TEST_TAG_ID, + CONF_NAME: TEST_TAG_NAME, + } + ] + }, } else: hass_storage[DOMAIN] = items @@ -31,6 +80,48 @@ def storage_setup(hass, hass_storage): return _storage +async def test_migration( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + storage_setup_1_1, + freezer: FrozenDateTimeFactory, + hass_storage: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test migrating tag store.""" + assert await storage_setup_1_1() + + client = await hass_ws_client(hass) + + freezer.move_to("2024-02-29 13:00") + + await client.send_json_auto_id({"type": f"{DOMAIN}/list"}) + resp = await client.receive_json() + assert resp["success"] + assert resp["result"] == [{"id": TEST_TAG_ID, "name": "test tag name"}] + + # Scan a new tag + await async_scan_tag(hass, "new tag", "some_scanner") + + # Add a new tag through WS + await client.send_json_auto_id( + { + "type": f"{DOMAIN}/create", + "tag_id": "1234567890", + "name": "Kitchen tag", + } + ) + resp = await client.receive_json() + assert resp["success"] + assert resp["result"] == {"id": "1234567890", "name": "Kitchen tag"} + + # Trigger store + freezer.tick(11) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot + + async def test_ws_list( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup ) -> None: @@ -39,14 +130,13 @@ async def test_ws_list( client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": f"{DOMAIN}/list"}) + await client.send_json_auto_id({"type": f"{DOMAIN}/list"}) resp = await client.receive_json() assert resp["success"] - - result = {item["id"]: item for item in resp["result"]} - - assert len(result) == 1 - assert "test tag" in result + assert resp["result"] == [ + {"id": TEST_TAG_ID, "name": "test tag name"}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2"}, + ] async def test_ws_update( @@ -58,21 +148,17 @@ async def test_ws_update( client = await hass_ws_client(hass) - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": f"{DOMAIN}/update", - f"{DOMAIN}_id": "test tag", + f"{DOMAIN}_id": TEST_TAG_ID, "name": "New name", } ) resp = await client.receive_json() assert resp["success"] - item = resp["result"] - - assert item["id"] == "test tag" - assert item["name"] == "New name" + assert item == {"id": TEST_TAG_ID, "name": "New name"} async def test_tag_scanned( @@ -86,29 +172,38 @@ async def test_tag_scanned( client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": f"{DOMAIN}/list"}) + await client.send_json_auto_id({"type": f"{DOMAIN}/list"}) resp = await client.receive_json() assert resp["success"] result = {item["id"]: item for item in resp["result"]} - assert len(result) == 1 - assert "test tag" in result + assert resp["result"] == [ + {"id": TEST_TAG_ID, "name": "test tag name"}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2"}, + ] now = dt_util.utcnow() freezer.move_to(now) await async_scan_tag(hass, "new tag", "some_scanner") - await client.send_json({"id": 7, "type": f"{DOMAIN}/list"}) + await client.send_json_auto_id({"type": f"{DOMAIN}/list"}) resp = await client.receive_json() assert resp["success"] result = {item["id"]: item for item in resp["result"]} - assert len(result) == 2 - assert "test tag" in result - assert "new tag" in result - assert result["new tag"]["last_scanned"] == now.isoformat() + assert len(result) == 3 + assert resp["result"] == [ + {"id": TEST_TAG_ID, "name": "test tag name"}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2"}, + { + "device_id": "some_scanner", + "id": "new tag", + "last_scanned": now.isoformat(), + "name": "Tag new tag", + }, + ] def track_changes(coll: collection.ObservableCollection): @@ -128,11 +223,100 @@ async def test_tag_id_exists( ) -> None: """Test scanning tags.""" assert await storage_setup() - changes = track_changes(hass.data[DOMAIN][TAGS]) + changes = track_changes(hass.data[DOMAIN]) client = await hass_ws_client(hass) - await client.send_json({"id": 2, "type": f"{DOMAIN}/create", "tag_id": "test tag"}) + await client.send_json_auto_id({"type": f"{DOMAIN}/create", "tag_id": TEST_TAG_ID}) response = await client.receive_json() assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert len(changes) == 0 + + +async def test_entity( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup, +) -> None: + """Test tag entity.""" + assert await storage_setup() + + await hass_ws_client(hass) + + entity = hass.states.get("tag.test_tag_name") + assert entity + assert entity.state == STATE_UNKNOWN + + now = dt_util.utcnow() + freezer.move_to(now) + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + + entity = hass.states.get("tag.test_tag_name") + assert entity + assert entity.state == now.isoformat(timespec="milliseconds") + assert entity.attributes == { + "tag_id": "test tag id", + "last_scanned_by_device_id": "device id", + "friendly_name": "test tag name", + } + + entity = hass.states.get("tag.test_tag_name_2") + assert entity + assert entity.state == STATE_UNKNOWN + + +async def test_entity_created_and_removed( + caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup, + entity_registry: er.EntityRegistry, +) -> None: + """Test tag entity created and removed.""" + caplog.at_level(logging.DEBUG) + assert await storage_setup() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": f"{DOMAIN}/create", + "tag_id": "1234567890", + "name": "Kitchen tag", + } + ) + resp = await client.receive_json() + assert resp["success"] + item = resp["result"] + + assert item["id"] == "1234567890" + assert item["name"] == "Kitchen tag" + + entity = hass.states.get("tag.kitchen_tag") + assert entity + assert entity.state == STATE_UNKNOWN + entity_id = entity.entity_id + assert entity_registry.async_get(entity_id) + + now = dt_util.utcnow() + freezer.move_to(now) + await async_scan_tag(hass, "1234567890", TEST_DEVICE_ID) + + entity = hass.states.get("tag.kitchen_tag") + assert entity + assert entity.state == now.isoformat(timespec="milliseconds") + + await client.send_json_auto_id( + { + "type": f"{DOMAIN}/delete", + "tag_id": "1234567890", + } + ) + resp = await client.receive_json() + assert resp["success"] + + entity = hass.states.get("tag.kitchen_tag") + assert not entity + assert not entity_registry.async_get(entity_id) diff --git a/tests/components/tag/test_trigger.py b/tests/components/tag/test_trigger.py index a034334508f..60d45abb7b9 100644 --- a/tests/components/tag/test_trigger.py +++ b/tests/components/tag/test_trigger.py @@ -1,12 +1,14 @@ """Tests for tag triggers.""" +from typing import Any + import pytest from homeassistant.components import automation from homeassistant.components.tag import async_scan_tag from homeassistant.components.tag.const import DEVICE_ID, DOMAIN, TAG_ID from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from tests.common import async_mock_service @@ -18,7 +20,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def tag_setup(hass, hass_storage): +def tag_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Tag setup.""" async def _storage(items=None): @@ -26,7 +28,8 @@ def tag_setup(hass, hass_storage): hass_storage[DOMAIN] = { "key": DOMAIN, "version": 1, - "data": {"items": [{"id": "test tag"}]}, + "minor_version": 2, + "data": {"items": [{"id": "test tag", "tag_id": "test tag"}]}, } else: hass_storage[DOMAIN] = items @@ -37,12 +40,14 @@ def tag_setup(hass, hass_storage): @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") -async def test_triggers(hass: HomeAssistant, tag_setup, calls) -> None: +async def test_triggers( + hass: HomeAssistant, tag_setup, calls: list[ServiceCall] +) -> None: """Test tag triggers.""" assert await tag_setup() assert await async_setup_component( @@ -88,7 +93,7 @@ async def test_triggers(hass: HomeAssistant, tag_setup, calls) -> None: async def test_exception_bad_trigger( - hass: HomeAssistant, calls, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, calls: list[ServiceCall], caplog: pytest.LogCaptureFixture ) -> None: """Test for exception on event triggers firing.""" @@ -112,7 +117,7 @@ async def test_exception_bad_trigger( async def test_multiple_tags_and_devices_trigger( - hass: HomeAssistant, tag_setup, calls + hass: HomeAssistant, tag_setup, calls: list[ServiceCall] ) -> None: """Test multiple tags and devices triggers.""" assert await tag_setup() diff --git a/tests/components/tailscale/conftest.py b/tests/components/tailscale/conftest.py index 5cf3f344739..cb7419daf89 100644 --- a/tests/components/tailscale/conftest.py +++ b/tests/components/tailscale/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest from tailscale.models import Devices +from typing_extensions import Generator from homeassistant.components.tailscale.const import CONF_TAILNET, DOMAIN from homeassistant.const import CONF_API_KEY @@ -27,7 +27,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.tailscale.async_setup_entry", return_value=True @@ -36,7 +36,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_tailscale_config_flow() -> Generator[None, MagicMock, None]: +def mock_tailscale_config_flow() -> Generator[MagicMock]: """Return a mocked Tailscale client.""" with patch( "homeassistant.components.tailscale.config_flow.Tailscale", autospec=True @@ -49,7 +49,7 @@ def mock_tailscale_config_flow() -> Generator[None, MagicMock, None]: @pytest.fixture -def mock_tailscale(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_tailscale(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Return a mocked Tailscale client.""" fixture: str = "tailscale/devices.json" if hasattr(request, "param") and request.param: diff --git a/tests/components/tailscale/test_binary_sensor.py b/tests/components/tailscale/test_binary_sensor.py index 1d1cda84723..b2b593101d7 100644 --- a/tests/components/tailscale/test_binary_sensor.py +++ b/tests/components/tailscale/test_binary_sensor.py @@ -15,12 +15,11 @@ from tests.common import MockConfigEntry async def test_tailscale_binary_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Tailscale binary sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("binary_sensor.frencks_iphone_client") entry = entity_registry.async_get("binary_sensor.frencks_iphone_client") assert entry @@ -31,6 +30,20 @@ async def test_tailscale_binary_sensors( assert state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Client" assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE + state = hass.states.get("binary_sensor.frencks_iphone_key_expiry_disabled") + entry = entity_registry.async_get( + "binary_sensor.frencks_iphone_key_expiry_disabled" + ) + assert entry + assert state + assert entry.unique_id == "123456_key_expiry_disabled" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == "frencks-iphone Key expiry disabled" + ) + assert ATTR_DEVICE_CLASS not in state.attributes + state = hass.states.get("binary_sensor.frencks_iphone_supports_hairpinning") entry = entity_registry.async_get( "binary_sensor.frencks_iphone_supports_hairpinning" diff --git a/tests/components/tailscale/test_sensor.py b/tests/components/tailscale/test_sensor.py index aa2bc6c472a..776b707202b 100644 --- a/tests/components/tailscale/test_sensor.py +++ b/tests/components/tailscale/test_sensor.py @@ -11,12 +11,11 @@ from tests.common import MockConfigEntry async def test_tailscale_sensors( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, ) -> None: """Test the Tailscale sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) - state = hass.states.get("sensor.router_expires") entry = entity_registry.async_get("sensor.router_expires") assert entry diff --git a/tests/components/tailwind/conftest.py b/tests/components/tailwind/conftest.py index b7443e59581..f23463548bc 100644 --- a/tests/components/tailwind/conftest.py +++ b/tests/components/tailwind/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from gotailwind import TailwindDeviceStatus import pytest +from typing_extensions import Generator from homeassistant.components.tailwind.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TOKEN @@ -36,7 +36,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.tailwind.async_setup_entry", return_value=True @@ -45,7 +45,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_tailwind(device_fixture: str) -> Generator[MagicMock, None, None]: +def mock_tailwind(device_fixture: str) -> Generator[MagicMock]: """Return a mocked Tailwind client.""" with ( patch( diff --git a/tests/components/tami4/conftest.py b/tests/components/tami4/conftest.py index 2e8b4f4ffac..84b96c04735 100644 --- a/tests/components/tami4/conftest.py +++ b/tests/components/tami4/conftest.py @@ -1,12 +1,13 @@ """Common fixutres with default mocks as well as common test helper methods.""" -from collections.abc import Generator from datetime import datetime -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from Tami4EdgeAPI.device import Device +from Tami4EdgeAPI.device_metadata import DeviceMetadata from Tami4EdgeAPI.water_quality import UV, Filter, WaterQuality +from typing_extensions import Generator from homeassistant.components.tami4.const import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.core import HomeAssistant @@ -32,17 +33,17 @@ async def create_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -def mock_api(mock__get_devices, mock_get_water_quality): +def mock_api(mock__get_devices_metadata, mock_get_device): """Fixture to mock all API calls.""" @pytest.fixture -def mock__get_devices(request): +def mock__get_devices_metadata(request: pytest.FixtureRequest) -> Generator[None]: """Fixture to mock _get_devices which makes a call to the API.""" side_effect = getattr(request, "param", None) - device = Device( + device_metadata = DeviceMetadata( id=1, name="Drink Water", connected=True, @@ -52,43 +53,56 @@ def mock__get_devices(request): ) with patch( - "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI._get_devices", - return_value=[device], + "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI._get_devices_metadata", + return_value=[device_metadata], side_effect=side_effect, ): yield @pytest.fixture -def mock_get_water_quality(request): - """Fixture to mock get_water_quality which makes a call to the API.""" +def mock_get_device( + request: pytest.FixtureRequest, +) -> Generator[None]: + """Fixture to mock get_device which makes a call to the API.""" side_effect = getattr(request, "param", None) water_quality = WaterQuality( uv=UV( - last_replacement=int(datetime.now().timestamp()), upcoming_replacement=int(datetime.now().timestamp()), - status="on", + installed=True, ), filter=Filter( - last_replacement=int(datetime.now().timestamp()), upcoming_replacement=int(datetime.now().timestamp()), - status="on", milli_litters_passed=1000, + installed=True, ), ) + device_metadata = DeviceMetadata( + id=1, + name="Drink Water", + connected=True, + psn="psn", + type="type", + device_firmware="v1.1", + ) + + device = Device( + water_quality=water_quality, device_metadata=device_metadata, drinks=[] + ) + with patch( - "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI.get_water_quality", - return_value=water_quality, + "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI.get_device", + return_value=device, side_effect=side_effect, ): yield @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( @@ -98,7 +112,9 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_request_otp(request): +def mock_request_otp( + request: pytest.FixtureRequest, +) -> Generator[MagicMock]: """Mock request_otp.""" side_effect = getattr(request, "param", None) @@ -112,7 +128,7 @@ def mock_request_otp(request): @pytest.fixture -def mock_submit_otp(request): +def mock_submit_otp(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Mock submit_otp.""" side_effect = getattr(request, "param", None) diff --git a/tests/components/tami4/test_config_flow.py b/tests/components/tami4/test_config_flow.py index cf81b015254..4210c391d70 100644 --- a/tests/components/tami4/test_config_flow.py +++ b/tests/components/tami4/test_config_flow.py @@ -13,7 +13,7 @@ async def test_step_user_valid_number( hass: HomeAssistant, mock_setup_entry, mock_request_otp, - mock__get_devices, + mock__get_devices_metadata, ) -> None: """Test user step with valid phone number.""" @@ -37,7 +37,7 @@ async def test_step_user_invalid_number( hass: HomeAssistant, mock_setup_entry, mock_request_otp, - mock__get_devices, + mock__get_devices_metadata, ) -> None: """Test user step with invalid phone number.""" @@ -66,7 +66,7 @@ async def test_step_user_exception( hass: HomeAssistant, mock_setup_entry, mock_request_otp, - mock__get_devices, + mock__get_devices_metadata, expected_error, ) -> None: """Test user step with exception.""" @@ -92,7 +92,7 @@ async def test_step_otp_valid( mock_setup_entry, mock_request_otp, mock_submit_otp, - mock__get_devices, + mock__get_devices_metadata, ) -> None: """Test user step with valid phone number.""" @@ -134,7 +134,7 @@ async def test_step_otp_exception( mock_setup_entry, mock_request_otp, mock_submit_otp, - mock__get_devices, + mock__get_devices_metadata, expected_error, ) -> None: """Test user step with valid phone number.""" diff --git a/tests/components/tami4/test_init.py b/tests/components/tami4/test_init.py index 2e9663c2728..2fe16d84cdb 100644 --- a/tests/components/tami4/test_init.py +++ b/tests/components/tami4/test_init.py @@ -17,7 +17,7 @@ async def test_init_success(mock_api, hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "mock_get_water_quality", [exceptions.APIRequestFailedException], indirect=True + "mock_get_device", [exceptions.APIRequestFailedException], indirect=True ) async def test_init_with_api_error(mock_api, hass: HomeAssistant) -> None: """Test init with api error.""" @@ -27,7 +27,7 @@ async def test_init_with_api_error(mock_api, hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("mock__get_devices", "expected_state"), + ("mock__get_devices_metadata", "expected_state"), [ ( exceptions.RefreshTokenExpiredException, @@ -38,7 +38,7 @@ async def test_init_with_api_error(mock_api, hass: HomeAssistant) -> None: ConfigEntryState.SETUP_RETRY, ), ], - indirect=["mock__get_devices"], + indirect=["mock__get_devices_metadata"], ) async def test_init_error_raised( mock_api, hass: HomeAssistant, expected_state: ConfigEntryState diff --git a/tests/components/tankerkoenig/conftest.py b/tests/components/tankerkoenig/conftest.py index 1a3dcb6f991..8f2e2c2fb53 100644 --- a/tests/components/tankerkoenig/conftest.py +++ b/tests/components/tankerkoenig/conftest.py @@ -1,9 +1,9 @@ """Fixtures for Tankerkoenig integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.tankerkoenig import DOMAIN from homeassistant.const import CONF_SHOW_ON_MAP @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry @pytest.fixture(name="tankerkoenig") -def mock_tankerkoenig() -> Generator[AsyncMock, None, None]: +def mock_tankerkoenig() -> Generator[AsyncMock]: """Mock the aiotankerkoenig client.""" with ( patch( diff --git a/tests/components/tasmota/conftest.py b/tests/components/tasmota/conftest.py index 1bb1f085e91..07ca8b31825 100644 --- a/tests/components/tasmota/conftest.py +++ b/tests/components/tasmota/conftest.py @@ -10,6 +10,7 @@ from homeassistant.components.tasmota.const import ( DEFAULT_PREFIX, DOMAIN, ) +from homeassistant.core import HomeAssistant, ServiceCall from tests.common import ( MockConfigEntry, @@ -33,7 +34,7 @@ def entity_reg(hass): @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 499e732719c..f3d85f019f3 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -22,9 +22,11 @@ from hatasmota.utils import ( from homeassistant.components.tasmota.const import DEFAULT_PREFIX, DOMAIN from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import async_fire_mqtt_message +from tests.typing import WebSocketGenerator DEFAULT_CONFIG = { "ip": "192.168.15.10", @@ -108,19 +110,17 @@ DEFAULT_SENSOR_CONFIG = { } -async def remove_device(hass, ws_client, device_id, config_entry_id=None): +async def remove_device( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_id: str, + config_entry_id: str | None = None, +) -> None: """Remove config entry from a device.""" if config_entry_id is None: config_entry_id = hass.config_entries.async_entries(DOMAIN)[0].entry_id - 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() + ws_client = await hass_ws_client(hass) + response = await ws_client.remove_device(device_id, config_entry_id) assert response["success"] @@ -693,7 +693,7 @@ async def help_test_entity_id_update_subscriptions( assert state is not None assert mqtt_mock.async_subscribe.call_count == len(topics) for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY, ANY) mqtt_mock.async_subscribe.reset_mock() entity_reg.async_update_entity( @@ -707,7 +707,7 @@ async def help_test_entity_id_update_subscriptions( state = hass.states.get(f"{domain}.milk") assert state is not None for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY, ANY) async def help_test_entity_id_update_discovery_update( diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index 8d299a272f7..450ad678ff6 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -12,7 +12,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.tasmota import _LOGGER from homeassistant.components.tasmota.const import DEFAULT_PREFIX, DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component @@ -350,7 +350,11 @@ async def test_update_remove_triggers( async def test_if_fires_on_mqtt_message_btn( - hass: HomeAssistant, device_reg, calls, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + device_reg, + calls: list[ServiceCall], + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test button triggers firing.""" # Discover a device with 2 device triggers @@ -421,7 +425,11 @@ async def test_if_fires_on_mqtt_message_btn( async def test_if_fires_on_mqtt_message_swc( - hass: HomeAssistant, device_reg, calls, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + device_reg, + calls: list[ServiceCall], + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test switch triggers firing.""" # Discover a device with 2 device triggers @@ -515,7 +523,11 @@ async def test_if_fires_on_mqtt_message_swc( async def test_if_fires_on_mqtt_message_late_discover( - hass: HomeAssistant, device_reg, calls, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + device_reg, + calls: list[ServiceCall], + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test triggers firing of MQTT device triggers discovered after setup.""" # Discover a device without device triggers @@ -594,7 +606,11 @@ async def test_if_fires_on_mqtt_message_late_discover( async def test_if_fires_on_mqtt_message_after_update( - hass: HomeAssistant, device_reg, calls, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + device_reg, + calls: list[ServiceCall], + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test triggers firing after update.""" # Discover a device with device trigger @@ -724,7 +740,11 @@ async def test_no_resubscribe_same_topic( async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( - hass: HomeAssistant, device_reg, calls, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + device_reg, + calls: list[ServiceCall], + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test triggers not firing after removal.""" # Discover a device with device trigger @@ -798,7 +818,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_reg, - calls, + calls: list[ServiceCall], mqtt_mock: MqttMockHAClient, setup_tasmota, ) -> None: @@ -849,7 +869,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( assert len(calls) == 1 # Remove the device - await remove_device(hass, await hass_ws_client(hass), device_entry.id) + await remove_device(hass, hass_ws_client, device_entry.id) await hass.async_block_till_done() async_fire_mqtt_message( @@ -1139,7 +1159,7 @@ async def test_attach_unknown_remove_device_from_registry( ) # Remove the device - await remove_device(hass, await hass_ws_client(hass), device_entry.id) + await remove_device(hass, hass_ws_client, device_entry.id) await hass.async_block_till_done() diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 122c22f752e..5405e6c417d 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -30,7 +30,9 @@ async def test_subscribing_config_topic( discovery_topic = DEFAULT_PREFIX assert mqtt_mock.async_subscribe.called - mqtt_mock.async_subscribe.assert_any_call(discovery_topic + "/#", ANY, 0, "utf-8") + mqtt_mock.async_subscribe.assert_any_call( + discovery_topic + "/#", ANY, 0, "utf-8", ANY + ) async def test_future_discovery_message( @@ -338,7 +340,7 @@ async def test_device_remove_multiple_config_entries_1( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert device_entry.config_entries == [tasmota_entry.entry_id, mock_entry.entry_id] async_fire_mqtt_message( hass, @@ -352,7 +354,7 @@ async def test_device_remove_multiple_config_entries_1( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None - assert device_entry.config_entries == {mock_entry.entry_id} + assert device_entry.config_entries == [mock_entry.entry_id] async def test_device_remove_multiple_config_entries_2( @@ -394,7 +396,7 @@ async def test_device_remove_multiple_config_entries_2( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} + assert device_entry.config_entries == [tasmota_entry.entry_id, mock_entry.entry_id] assert other_device_entry.id != device_entry.id # Remove other config entry from the device @@ -408,7 +410,7 @@ async def test_device_remove_multiple_config_entries_2( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None - assert device_entry.config_entries == {tasmota_entry.entry_id} + assert device_entry.config_entries == [tasmota_entry.entry_id] mqtt_mock.async_publish.assert_not_called() # Remove other config entry from the other device - Tasmota should not do any cleanup @@ -446,7 +448,7 @@ async def test_device_remove_stale( assert device_entry is not None # Remove the device - await remove_device(hass, await hass_ws_client(hass), device_entry.id) + await remove_device(hass, hass_ws_client, device_entry.id) # Verify device entry is removed device_entry = device_reg.async_get_device( @@ -578,6 +580,7 @@ async def test_same_topic( device_reg, entity_reg, setup_tasmota, + issue_registry: ir.IssueRegistry, ) -> None: """Test detecting devices with same topic.""" configs = [ @@ -624,7 +627,6 @@ async def test_same_topic( # Verify a repairs issue was created issue_id = "topic_duplicated_tasmota_49A3BC/cmnd/" - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue("tasmota", issue_id) assert issue.data["mac"] == " ".join(config["mac"] for config in configs[0:2]) @@ -702,6 +704,7 @@ async def test_topic_no_prefix( device_reg, entity_reg, setup_tasmota, + issue_registry: ir.IssueRegistry, ) -> None: """Test detecting devices with same topic.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -734,7 +737,6 @@ async def test_topic_no_prefix( # Verify a repairs issue was created issue_id = "topic_no_prefix_00000049A3BC" - issue_registry = ir.async_get(hass) assert ("tasmota", issue_id) in issue_registry.issues # Rediscover device with fixed config @@ -753,5 +755,4 @@ async def test_topic_no_prefix( assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 1 # Verify the repairs issue has been removed - issue_registry = ir.async_get(hass) assert ("tasmota", issue_id) not in issue_registry.issues diff --git a/tests/components/tasmota/test_init.py b/tests/components/tasmota/test_init.py index 72a86fc9986..0123421d5ae 100644 --- a/tests/components/tasmota/test_init.py +++ b/tests/components/tasmota/test_init.py @@ -49,7 +49,7 @@ async def test_device_remove( ) assert device_entry is not None - await remove_device(hass, await hass_ws_client(hass), device_entry.id) + await remove_device(hass, hass_ws_client, device_entry.id) await hass.async_block_till_done() # Verify device entry is removed @@ -98,9 +98,7 @@ async def test_device_remove_non_tasmota_device( ) assert device_entry is not None - await remove_device( - hass, await hass_ws_client(hass), device_entry.id, config_entry.entry_id - ) + await remove_device(hass, hass_ws_client, device_entry.id, config_entry.entry_id) await hass.async_block_till_done() # Verify device entry is removed @@ -131,7 +129,7 @@ async def test_device_remove_stale_tasmota_device( ) assert device_entry is not None - await remove_device(hass, await hass_ws_client(hass), device_entry.id) + await remove_device(hass, hass_ws_client, device_entry.id) await hass.async_block_till_done() # Verify device entry is removed @@ -166,12 +164,10 @@ async def test_tasmota_ws_remove_discovered_device( ) assert device_entry is not None - client = await hass_ws_client(hass) tasmota_config_entry = hass.config_entries.async_entries(DOMAIN)[0] - response = await client.remove_device( - device_entry.id, tasmota_config_entry.entry_id + await remove_device( + hass, hass_ws_client, device_entry.id, tasmota_config_entry.entry_id ) - assert response["success"] # Verify device entry is cleared device_entry = device_reg.async_get_device( diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 61034ae66e9..2de80de4319 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -483,6 +483,7 @@ TEMPERATURE_SENSOR_CONFIG = { ) async def test_controlling_state_via_mqtt( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mqtt_mock: MqttMockHAClient, setup_tasmota, sensor_config, @@ -491,7 +492,6 @@ async def test_controlling_state_via_mqtt( states, ) -> None: """Test state update via MQTT.""" - entity_reg = er.async_get(hass) config = copy.deepcopy(DEFAULT_CONFIG) sensor_config = copy.deepcopy(sensor_config) mac = config["mac"] @@ -514,7 +514,7 @@ async def test_controlling_state_via_mqtt( assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) - entry = entity_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry.disabled is False assert entry.disabled_by is None assert entry.entity_category is None @@ -588,6 +588,7 @@ async def test_controlling_state_via_mqtt( ) async def test_quantity_override( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mqtt_mock: MqttMockHAClient, setup_tasmota, sensor_config, @@ -595,7 +596,6 @@ async def test_quantity_override( states, ) -> None: """Test quantity override for certain sensors.""" - entity_reg = er.async_get(hass) config = copy.deepcopy(DEFAULT_CONFIG) sensor_config = copy.deepcopy(sensor_config) mac = config["mac"] @@ -620,7 +620,7 @@ async def test_quantity_override( for attribute, expected in expected_state.get("attributes", {}).items(): assert state.attributes.get(attribute) == expected - entry = entity_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry.disabled is False assert entry.disabled_by is None assert entry.entity_category is None @@ -742,13 +742,14 @@ async def test_bad_indexed_sensor_state_via_mqtt( @pytest.mark.parametrize("status_sensor_disabled", [False]) async def test_status_sensor_state_via_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test state update via MQTT.""" - entity_reg = er.async_get(hass) - # Pre-enable the status sensor - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( Platform.SENSOR, "tasmota", "00000049A3BC_status_sensor_status_sensor_status_signal", @@ -856,13 +857,14 @@ async def test_battery_sensor_state_via_mqtt( @pytest.mark.parametrize("status_sensor_disabled", [False]) async def test_single_shot_status_sensor_state_via_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test state update via MQTT.""" - entity_reg = er.async_get(hass) - # Pre-enable the status sensor - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( Platform.SENSOR, "tasmota", "00000049A3BC_status_sensor_status_sensor_status_restart_reason", @@ -941,13 +943,15 @@ async def test_single_shot_status_sensor_state_via_mqtt( @pytest.mark.parametrize("status_sensor_disabled", [False]) @patch.object(hatasmota.status_sensor, "datetime", Mock(wraps=datetime.datetime)) async def test_restart_time_status_sensor_state_via_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test state update via MQTT.""" - entity_reg = er.async_get(hass) # Pre-enable the status sensor - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( Platform.SENSOR, "tasmota", "00000049A3BC_status_sensor_status_sensor_last_restart_time", @@ -1119,6 +1123,7 @@ async def test_indexed_sensor_attributes( ) async def test_diagnostic_sensors( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mqtt_mock: MqttMockHAClient, setup_tasmota, sensor_name, @@ -1126,8 +1131,6 @@ async def test_diagnostic_sensors( disabled_by, ) -> None: """Test properties of diagnostic sensors.""" - entity_reg = er.async_get(hass) - config = copy.deepcopy(DEFAULT_CONFIG) mac = config["mac"] @@ -1141,7 +1144,7 @@ async def test_diagnostic_sensors( state = hass.states.get(f"sensor.{sensor_name}") assert bool(state) != disabled - entry = entity_reg.async_get(f"sensor.{sensor_name}") + entry = entity_registry.async_get(f"sensor.{sensor_name}") assert entry.disabled == disabled assert entry.disabled_by is disabled_by assert entry.entity_category == "diagnostic" @@ -1149,11 +1152,12 @@ async def test_diagnostic_sensors( @pytest.mark.parametrize("status_sensor_disabled", [False]) async def test_enable_status_sensor( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mqtt_mock: MqttMockHAClient, + setup_tasmota, ) -> None: """Test enabling status sensor.""" - entity_reg = er.async_get(hass) - config = copy.deepcopy(DEFAULT_CONFIG) mac = config["mac"] @@ -1167,12 +1171,12 @@ async def test_enable_status_sensor( state = hass.states.get("sensor.tasmota_signal") assert state is None - entry = entity_reg.async_get("sensor.tasmota_signal") + entry = entity_registry.async_get("sensor.tasmota_signal") assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Enable the signal level status sensor - updated_entry = entity_reg.async_update_entity( + updated_entry = entity_registry.async_update_entity( "sensor.tasmota_signal", disabled_by=None ) assert updated_entry != entry diff --git a/tests/components/technove/conftest.py b/tests/components/technove/conftest.py index 06db6e24f47..be34ebfefa5 100644 --- a/tests/components/technove/conftest.py +++ b/tests/components/technove/conftest.py @@ -1,10 +1,10 @@ """Fixtures for TechnoVE integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest from technove import Station as TechnoVEStation +from typing_extensions import Generator from homeassistant.components.technove.const import DOMAIN from homeassistant.const import CONF_HOST @@ -24,7 +24,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.technove.async_setup_entry", return_value=True @@ -33,7 +33,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_onboarding() -> Generator[MagicMock, None, None]: +def mock_onboarding() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -49,7 +49,7 @@ def device_fixture() -> TechnoVEStation: @pytest.fixture -def mock_technove(device_fixture: TechnoVEStation) -> Generator[MagicMock, None, None]: +def mock_technove(device_fixture: TechnoVEStation) -> Generator[MagicMock]: """Return a mocked TechnoVE client.""" with ( patch( diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 9f0730992d2..295e34fd541 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -2,20 +2,22 @@ from __future__ import annotations -from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch from pytedee_async.bridge import TedeeBridge from pytedee_async.lock import TedeeLock import pytest +from typing_extensions import Generator from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +WEBHOOK_ID = "bq33efxmdi3vxy55q2wbnudbra7iv8mjrq9x0gea33g4zqtd87093pwveg8xcb33" + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -26,13 +28,16 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_LOCAL_ACCESS_TOKEN: "api_token", CONF_HOST: "192.168.1.42", + CONF_WEBHOOK_ID: WEBHOOK_ID, }, unique_id="0000-0000", + version=1, + minor_version=2, ) @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.tedee.async_setup_entry", return_value=True @@ -41,7 +46,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_tedee(request) -> Generator[MagicMock, None, None]: +def mock_tedee() -> Generator[MagicMock]: """Return a mocked Tedee client.""" with ( patch( @@ -63,6 +68,8 @@ def mock_tedee(request) -> Generator[MagicMock, None, None]: tedee.get_local_bridge.return_value = TedeeBridge(0, "0000-0000", "Bridge-AB1C") tedee.parse_webhook_message.return_value = None + tedee.register_webhook.return_value = 1 + tedee.delete_webhooks.return_value = None locks_json = json.loads(load_fixture("locks.json", DOMAIN)) @@ -78,7 +85,6 @@ async def init_integration( ) -> MockConfigEntry: """Set up the Tedee 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() diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 1da1e392bf3..588e63f693b 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Tedee config flow.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pytedee_async import ( TedeeClientException, @@ -11,10 +11,12 @@ import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry FLOW_UNIQUE_ID = "112233445566778899" @@ -23,25 +25,30 @@ LOCAL_ACCESS_TOKEN = "api_token" async def test_flow(hass: HomeAssistant, mock_tedee: MagicMock) -> None: """Test config flow with one bridge.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM + with patch( + "homeassistant.components.tedee.config_flow.webhook_generate_id", + return_value=WEBHOOK_ID, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.62", + CONF_LOCAL_ACCESS_TOKEN: "token", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { CONF_HOST: "192.168.1.62", CONF_LOCAL_ACCESS_TOKEN: "token", - }, - ) - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - CONF_HOST: "192.168.1.62", - CONF_LOCAL_ACCESS_TOKEN: "token", - } + CONF_WEBHOOK_ID: WEBHOOK_ID, + } async def test_flow_already_configured( diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index 9388aaf008c..d4ac1c9d290 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -1,16 +1,29 @@ """Test initialization of tedee.""" -from unittest.mock import MagicMock +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse -from pytedee_async.exception import TedeeAuthException, TedeeClientException +from pytedee_async.exception import ( + TedeeAuthException, + TedeeClientException, + TedeeWebhookException, +) import pytest from syrupy import SnapshotAssertion +from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN +from homeassistant.components.webhook import async_generate_url from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator async def test_load_unload_config_entry( @@ -51,6 +64,80 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_cleanup_on_shutdown( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + 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 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_tedee.delete_webhook.assert_called_once() + + +async def test_webhook_cleanup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + 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 + + mock_tedee.delete_webhook.side_effect = TedeeWebhookException("") + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_tedee.delete_webhook.assert_called_once() + assert "Failed to unregister Tedee webhook from bridge" in caplog.text + + +async def test_webhook_registration_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the webhook is cleaned up on shutdown.""" + mock_tedee.register_webhook.side_effect = TedeeWebhookException("") + 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 + + mock_tedee.register_webhook.assert_called_once() + assert "Failed to register Tedee webhook from bridge" in caplog.text + + +async def test_webhook_registration_cleanup_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the errors during webhook cleanup during registration.""" + mock_tedee.cleanup_webhooks_by_host.side_effect = TedeeWebhookException("") + 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 + + mock_tedee.cleanup_webhooks_by_host.assert_called_once() + assert "Failed to cleanup Tedee webhooks by host:" in caplog.text + + async def test_bridge_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -68,3 +155,97 @@ async def test_bridge_device( ) assert device assert device == snapshot + + +@pytest.mark.parametrize( + ( + "body", + "expected_code", + "side_effect", + ), + [ + ( + {"hello": "world"}, + HTTPStatus.OK, + None, + ), # Success + ( + None, + HTTPStatus.BAD_REQUEST, + None, + ), # Missing data + ( + {}, + HTTPStatus.BAD_REQUEST, + TedeeWebhookException, + ), # Error + ], +) +async def test_webhook_post( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tedee: MagicMock, + hass_client_no_auth: ClientSessionGenerator, + body: dict[str, Any], + expected_code: HTTPStatus, + side_effect: Exception, +) -> None: + """Test webhook callback.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + mock_tedee.parse_webhook_message.side_effect = side_effect + resp = await client.post(urlparse(webhook_url).path, json=body) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + assert resp.status == expected_code + + +async def test_config_flow_entry_migrate_2_1(hass: HomeAssistant) -> None: + """Test that config entry fails setup if the version is from the future.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + minor_version=1, + ) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) + + +async def test_migration( + hass: HomeAssistant, + mock_tedee: MagicMock, +) -> None: + """Test migration of the config entry.""" + + mock_config_entry = MockConfigEntry( + title="My Tedee", + domain=DOMAIN, + data={ + CONF_LOCAL_ACCESS_TOKEN: "api_token", + CONF_HOST: "192.168.1.42", + }, + version=1, + minor_version=1, + unique_id="0000-0000", + ) + + with patch( + "homeassistant.components.tedee.webhook_generate_id", + return_value=WEBHOOK_ID, + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.version == 1 + assert mock_config_entry.minor_version == 2 + assert mock_config_entry.data[CONF_WEBHOOK_ID] == WEBHOOK_ID + assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index f108c4f09f0..ffc4a8c30d6 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -2,9 +2,10 @@ from datetime import timedelta from unittest.mock import MagicMock +from urllib.parse import urlparse from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock +from pytedee_async import TedeeLock, TedeeLockState from pytedee_async.exception import ( TedeeClientException, TedeeDataUpdateException, @@ -18,15 +19,21 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_LOCKED, STATE_LOCKING, + STATE_UNLOCKED, STATE_UNLOCKING, ) +from homeassistant.components.webhook import async_generate_url from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from .conftest import WEBHOOK_ID + from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator pytestmark = pytest.mark.usefixtures("init_integration") @@ -267,3 +274,32 @@ async def test_new_lock( assert state state = hass.states.get("lock.lock_6g7h") assert state + + +async def test_webhook_update( + hass: HomeAssistant, + mock_tedee: MagicMock, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test updated data set through webhook.""" + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_UNLOCKED + + webhook_data = {"dummystate": 6} + mock_tedee.locks_dict[ + 12345 + ].state = TedeeLockState.LOCKED # is updated in the lib, so mock and assert in L296 + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + await client.post( + urlparse(webhook_url).path, + json=webhook_data, + ) + mock_tedee.parse_webhook_message.assert_called_once_with(webhook_data) + + state = hass.states.get("lock.lock_1a2b") + assert state + assert state.state == STATE_LOCKED diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 0906b6afcbd..6ea5d1446dd 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -1,9 +1,11 @@ """Tests for the telegram_bot integration.""" +from datetime import datetime from unittest.mock import patch import pytest -from telegram import User +from telegram import Chat, Message, User +from telegram.constants import ChatType from homeassistant.components.telegram_bot import ( CONF_ALLOWED_CHAT_IDS, @@ -79,6 +81,11 @@ def mock_register_webhook(): def mock_external_calls(): """Mock calls that make calls to the live Telegram API.""" test_user = User(123456, "Testbot", True) + message = Message( + message_id=12345, + date=datetime.now(), + chat=Chat(id=123456, type=ChatType.PRIVATE), + ) with ( patch( "telegram.Bot.get_me", @@ -92,6 +99,10 @@ def mock_external_calls(): "telegram.Bot.bot", test_user, ), + patch( + "telegram.Bot.send_message", + return_value=message, + ), patch("telegram.ext.Updater._bootstrap"), ): yield diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index d6588535b4f..aad758827ca 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -4,9 +4,14 @@ from unittest.mock import AsyncMock, patch from telegram import Update -from homeassistant.components.telegram_bot import DOMAIN, SERVICE_SEND_MESSAGE +from homeassistant.components.telegram_bot import ( + ATTR_MESSAGE, + ATTR_MESSAGE_THREAD_ID, + DOMAIN, + SERVICE_SEND_MESSAGE, +) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_capture_events @@ -23,6 +28,43 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N assert hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE) is True +async def test_send_message(hass: HomeAssistant, webhook_platform) -> None: + """Test the send_message service.""" + context = Context() + events = async_capture_events(hass, "telegram_sent") + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + blocking=True, + context=context, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context == context + + +async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> None: + """Test the send_message service for threads.""" + context = Context() + events = async_capture_events(hass, "telegram_sent") + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, + blocking=True, + context=context, + ) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context == context + assert events[0].data[ATTR_MESSAGE_THREAD_ID] == "123" + + async def test_webhook_endpoint_generates_telegram_text_event( hass: HomeAssistant, webhook_platform, @@ -47,6 +89,7 @@ async def test_webhook_endpoint_generates_telegram_text_event( assert len(events) == 1 assert events[0].data["text"] == update_message_text["message"]["text"] + assert isinstance(events[0].context, Context) async def test_webhook_endpoint_generates_telegram_command_event( @@ -73,6 +116,7 @@ async def test_webhook_endpoint_generates_telegram_command_event( assert len(events) == 1 assert events[0].data["command"] == update_message_command["message"]["text"] + assert isinstance(events[0].context, Context) async def test_webhook_endpoint_generates_telegram_callback_event( @@ -99,6 +143,7 @@ async def test_webhook_endpoint_generates_telegram_callback_event( assert len(events) == 1 assert events[0].data["data"] == update_callback_query["callback_query"]["data"] + assert isinstance(events[0].context, Context) async def test_polling_platform_message_text_update( @@ -140,6 +185,7 @@ async def test_polling_platform_message_text_update( assert len(events) == 1 assert events[0].data["text"] == update_message_text["message"]["text"] + assert isinstance(events[0].context, Context) async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_text_event( diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 894c1777fef..b400d443be7 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -2,19 +2,22 @@ import pytest +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @pytest.fixture -async def start_ha(hass, count, domain, config, caplog): +async def start_ha( + hass: HomeAssistant, count, domain, config, caplog: pytest.LogCaptureFixture +): """Do setup of integration.""" with assert_setup_component(count, domain): assert await async_setup_component( @@ -29,6 +32,6 @@ async def start_ha(hass, count, domain, config, caplog): @pytest.fixture -async def caplog_setup_text(caplog): +async def caplog_setup_text(caplog: pytest.LogCaptureFixture) -> str: """Return setup log of integration.""" return caplog.text diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index eb4daa3bcb8..6a2a95a64eb 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -18,20 +18,20 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback TEMPLATE_NAME = "alarm_control_panel.test_template_panel" PANEL_NAME = "alarm_control_panel.test" @pytest.fixture -def service_calls(hass): +def call_service_events(hass: HomeAssistant) -> list[Event]: """Track service call events for alarm_control_panel.test.""" - events = [] + events: list[Event] = [] entity_id = "alarm_control_panel.test" @callback - def capture_events(event): + def capture_events(event: Event) -> None: if event.data[ATTR_DOMAIN] != ALARM_DOMAIN: return if event.data[ATTR_SERVICE_DATA][ATTR_ENTITY_ID] != [entity_id]: @@ -103,7 +103,7 @@ TEMPLATE_ALARM_CONFIG = { async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: """Test the state text of a template.""" - for set_state in [ + for set_state in ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, @@ -113,7 +113,7 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, - ]: + ): hass.states.async_set(PANEL_NAME, set_state) await hass.async_block_till_done() state = hass.states.get(TEMPLATE_NAME) @@ -144,7 +144,7 @@ async def test_optimistic_states(hass: HomeAssistant, start_ha) -> None: await hass.async_block_till_done() assert state.state == "unknown" - for service, set_state in [ + for service, set_state in ( ("alarm_arm_away", STATE_ALARM_ARMED_AWAY), ("alarm_arm_home", STATE_ALARM_ARMED_HOME), ("alarm_arm_night", STATE_ALARM_ARMED_NIGHT), @@ -152,9 +152,12 @@ async def test_optimistic_states(hass: HomeAssistant, start_ha) -> None: ("alarm_arm_custom_bypass", STATE_ALARM_ARMED_CUSTOM_BYPASS), ("alarm_disarm", STATE_ALARM_DISARMED), ("alarm_trigger", STATE_ALARM_TRIGGERED), - ]: + ): await hass.services.async_call( - ALARM_DOMAIN, service, {"entity_id": TEMPLATE_NAME}, blocking=True + ALARM_DOMAIN, + service, + {"entity_id": TEMPLATE_NAME, "code": "1234"}, + blocking=True, ) await hass.async_block_till_done() assert hass.states.get(TEMPLATE_NAME).state == set_state @@ -281,15 +284,20 @@ async def test_name(hass: HomeAssistant, start_ha) -> None: "alarm_trigger", ], ) -async def test_actions(hass: HomeAssistant, service, start_ha, service_calls) -> None: +async def test_actions( + hass: HomeAssistant, service, start_ha, call_service_events: list[Event] +) -> None: """Test alarm actions.""" await hass.services.async_call( - ALARM_DOMAIN, service, {"entity_id": TEMPLATE_NAME}, blocking=True + ALARM_DOMAIN, + service, + {"entity_id": TEMPLATE_NAME, "code": "1234"}, + blocking=True, ) await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data["service"] == service - assert service_calls[0].data["service_data"]["code"] == TEMPLATE_NAME + assert len(call_service_events) == 1 + assert call_service_events[0].data["service"] == service + assert call_service_events[0].data["service_data"]["code"] == TEMPLATE_NAME @pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 452f926dca5..50cad5be9e1 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -1273,9 +1273,9 @@ async def test_trigger_entity_restore_state( state = hass.states.get("binary_sensor.test") assert state.state == initial_state - for attr in restored_attributes: + for attr, value in restored_attributes.items(): if attr in initial_attributes: - assert state.attributes[attr] == restored_attributes[attr] + assert state.attributes[attr] == value else: assert attr not in state.attributes assert "another" not in state.attributes @@ -1403,3 +1403,42 @@ async def test_trigger_entity_restore_state_auto_off_expired( state = hass.states.get("binary_sensor.test") assert state.state == OFF + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for Template.""" + + device_config_entry = MockConfigEntry() + device_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=device_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + await hass.async_block_till_done() + assert device_entry is not None + assert device_entry.id is not None + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{10 > 8}}", + "template_type": "binary_sensor", + "device_id": device_entry.id, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + template_entity = entity_registry.async_get("binary_sensor.my_template") + assert template_entity is not None + assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index 2e83100734a..c861c7874d4 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -14,8 +14,8 @@ from homeassistant.const import ( CONF_ICON, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er from tests.common import assert_setup_component @@ -62,7 +62,10 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: async def test_all_optional_config( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + calls: list[ServiceCall], ) -> None: """Test: including all optional templates is ok.""" with assert_setup_component(1, "template"): @@ -124,8 +127,7 @@ async def test_all_optional_config( _TEST_OPTIONS_BUTTON, ) - er = async_get(hass) - assert er.async_get_entity_id("button", "template", "test-test") + assert entity_registry.async_get_entity_id("button", "template", "test-test") async def test_name_template(hass: HomeAssistant) -> None: diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 8c5dda401dd..f277b918661 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.template import DOMAIN, async_setup_entry from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -122,6 +123,90 @@ async def test_config_flow( assert state.attributes[key] == extra_attrs[key] +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + ), + [ + ( + "sensor", + "{{ 15 }}", + ), + ( + "binary_sensor", + "{{ false }}", + ), + ], +) +async def test_config_flow_device( + hass: HomeAssistant, + template_type: str, + state_template: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Test remove the device registry configuration entry when the device changes.""" + + # Configure a device registry + entry_device = MockConfigEntry() + entry_device.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry_device.entry_id, + identifiers={("test", "identifier_test1")}, + connections={("mac", "20:31:32:33:34:01")}, + ) + await hass.async_block_till_done() + + device_id = device.id + assert device_id is not None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == template_type + + with patch( + "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My template", + "state": state_template, + "device_id": device_id, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My template" + assert result["data"] == {} + assert result["options"] == { + "name": "My template", + "state": state_template, + "template_type": template_type, + "device_id": device_id, + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "name": "My template", + "state": state_template, + "template_type": template_type, + "device_id": device_id, + } + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema: @@ -130,7 +215,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") @pytest.mark.parametrize( @@ -852,3 +937,148 @@ async def test_option_flow_sensor_preview_config_entry_removed( msg = await client.receive_json() assert not msg["success"] assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + ), + [ + ( + "sensor", + "{{ 15 }}", + ), + ( + "binary_sensor", + "{{ false }}", + ), + ], +) +async def test_options_flow_change_device( + hass: HomeAssistant, + template_type: str, + state_template: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Test remove the device registry configuration entry when the device changes.""" + + # Configure a device registry + entry_device1 = MockConfigEntry() + entry_device1.add_to_hass(hass) + device1 = device_registry.async_get_or_create( + config_entry_id=entry_device1.entry_id, + identifiers={("test", "identifier_test1")}, + connections={("mac", "20:31:32:33:34:01")}, + ) + entry_device2 = MockConfigEntry() + entry_device2.add_to_hass(hass) + device2 = device_registry.async_get_or_create( + config_entry_id=entry_device1.entry_id, + identifiers={("test", "identifier_test2")}, + connections={("mac", "20:31:32:33:34:02")}, + ) + await hass.async_block_till_done() + + device_id1 = device1.id + assert device_id1 is not None + + device_id2 = device2.id + assert device_id2 is not None + + # Setup the config entry with device 1 + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "template_type": template_type, + "name": "Test", + "state": state_template, + "device_id": device_id1, + }, + title="Sensor template", + ) + template_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + # Change to link to device 2 + result = await hass.config_entries.options.async_init( + template_config_entry.entry_id + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "state": state_template, + "device_id": device_id2, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "template_type": template_type, + "name": "Test", + "state": state_template, + "device_id": device_id2, + } + assert template_config_entry.data == {} + assert template_config_entry.options == { + "template_type": template_type, + "name": "Test", + "state": state_template, + "device_id": device_id2, + } + + # Remove link with device + result = await hass.config_entries.options.async_init( + template_config_entry.entry_id + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "state": state_template, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "template_type": template_type, + "name": "Test", + "state": state_template, + } + assert template_config_entry.data == {} + assert template_config_entry.options == { + "template_type": template_type, + "name": "Test", + "state": state_template, + } + + # Change to link to device 1 + result = await hass.config_entries.options.async_init( + template_config_entry.entry_id + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "state": state_template, + "device_id": device_id1, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "template_type": template_type, + "name": "Test", + "state": state_template, + "device_id": device_id1, + } + assert template_config_entry.data == {} + assert template_config_entry.options == { + "template_type": template_type, + "name": "Test", + "state": state_template, + "device_id": device_id1, + } diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index e9a29fdc2e2..2674b9697ed 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -26,7 +26,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from tests.common import assert_setup_component @@ -267,11 +267,11 @@ async def test_template_position( hass.states.async_set("cover.test", STATE_OPEN) attrs = {} - for set_state, pos, test_state in [ + for set_state, pos, test_state in ( (STATE_CLOSED, 42, STATE_OPEN), (STATE_OPEN, 0.0, STATE_CLOSED), (STATE_CLOSED, None, STATE_UNKNOWN), - ]: + ): attrs["position"] = pos hass.states.async_set("cover.test", set_state, attributes=attrs) await hass.async_block_till_done() @@ -445,7 +445,9 @@ async def test_template_open_or_position( }, ], ) -async def test_open_action(hass: HomeAssistant, start_ha, calls) -> None: +async def test_open_action( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test the open_cover command.""" state = hass.states.get("cover.test_template_cover") assert state.state == STATE_CLOSED @@ -484,7 +486,9 @@ async def test_open_action(hass: HomeAssistant, start_ha, calls) -> None: }, ], ) -async def test_close_stop_action(hass: HomeAssistant, start_ha, calls) -> None: +async def test_close_stop_action( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test the close-cover and stop_cover commands.""" state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPEN @@ -513,7 +517,9 @@ async def test_close_stop_action(hass: HomeAssistant, start_ha, calls) -> None: {"input_number": {"test": {"min": "0", "max": "100", "initial": "42"}}}, ], ) -async def test_set_position(hass: HomeAssistant, start_ha, calls) -> None: +async def test_set_position( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test the set_position command.""" with assert_setup_component(1, "cover"): assert await setup.async_setup_component( @@ -643,7 +649,12 @@ async def test_set_position(hass: HomeAssistant, start_ha, calls) -> None: ], ) async def test_set_tilt_position( - hass: HomeAssistant, service, attr, start_ha, calls, tilt_position + hass: HomeAssistant, + service, + attr, + start_ha, + calls: list[ServiceCall], + tilt_position, ) -> None: """Test the set_tilt_position command.""" await hass.services.async_call( @@ -676,7 +687,9 @@ async def test_set_tilt_position( }, ], ) -async def test_set_position_optimistic(hass: HomeAssistant, start_ha, calls) -> None: +async def test_set_position_optimistic( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test optimistic position mode.""" state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_position") is None @@ -691,12 +704,12 @@ async def test_set_position_optimistic(hass: HomeAssistant, start_ha, calls) -> state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_position") == 42.0 - for service, test_state in [ + for service, test_state in ( (SERVICE_CLOSE_COVER, STATE_CLOSED), (SERVICE_OPEN_COVER, STATE_OPEN), (SERVICE_TOGGLE, STATE_CLOSED), (SERVICE_TOGGLE, STATE_OPEN), - ]: + ): await hass.services.async_call( DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) @@ -724,7 +737,7 @@ async def test_set_position_optimistic(hass: HomeAssistant, start_ha, calls) -> ], ) async def test_set_tilt_position_optimistic( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test the optimistic tilt_position mode.""" state = hass.states.get("cover.test_template_cover") @@ -740,12 +753,12 @@ async def test_set_tilt_position_optimistic( state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") == 42.0 - for service, pos in [ + for service, pos in ( (SERVICE_CLOSE_COVER_TILT, 0.0), (SERVICE_OPEN_COVER_TILT, 100.0), (SERVICE_TOGGLE_COVER_TILT, 0.0), (SERVICE_TOGGLE_COVER_TILT, 100.0), - ]: + ): await hass.services.async_call( DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 93520b0f621..82ad4ede91c 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -16,7 +16,7 @@ from homeassistant.components.fan import ( NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from tests.common import assert_setup_component from tests.components.fan import common @@ -157,13 +157,13 @@ async def test_templates_with_entities(hass: HomeAssistant, start_ha) -> None: hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) hass.states.async_set(_OSC_INPUT, "True") - for set_state, set_value, value in [ + for set_state, set_value, value in ( (_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD, 66), (_PERCENTAGE_INPUT_NUMBER, 33, 33), (_PERCENTAGE_INPUT_NUMBER, 66, 66), (_PERCENTAGE_INPUT_NUMBER, 100, 100), (_PERCENTAGE_INPUT_NUMBER, "dog", 0), - ]: + ): hass.states.async_set(set_state, set_value) await hass.async_block_till_done() _verify(hass, STATE_ON, value, True, DIRECTION_FORWARD, None) @@ -266,7 +266,7 @@ async def test_availability_template_with_entities( hass: HomeAssistant, start_ha ) -> None: """Test availability tempalates with values from other entities.""" - for state, test_assert in [(STATE_ON, True), (STATE_OFF, False)]: + for state, test_assert in ((STATE_ON, True), (STATE_OFF, False)): hass.states.async_set(_STATE_AVAILABILITY_BOOLEAN, state) await hass.async_block_till_done() assert (hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE) == test_assert @@ -387,7 +387,7 @@ async def test_invalid_availability_template_keeps_component_available( assert "x" in caplog_setup_text -async def test_on_off(hass: HomeAssistant, calls) -> None: +async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test turn on and turn off.""" await _register_components(hass) @@ -406,7 +406,7 @@ async def test_on_off(hass: HomeAssistant, calls) -> None: async def test_set_invalid_direction_from_initial_stage( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set invalid direction when fan is in initial state.""" await _register_components(hass) @@ -419,14 +419,14 @@ async def test_set_invalid_direction_from_initial_stage( _verify(hass, STATE_ON, 0, None, None, None) -async def test_set_osc(hass: HomeAssistant, calls) -> None: +async def test_set_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set oscillating.""" await _register_components(hass) expected_calls = 0 await common.async_turn_on(hass, _TEST_FAN) expected_calls += 1 - for state in [True, False]: + for state in (True, False): await common.async_oscillate(hass, _TEST_FAN, state) assert hass.states.get(_OSC_INPUT).state == str(state) _verify(hass, STATE_ON, 0, state, None, None) @@ -437,14 +437,14 @@ async def test_set_osc(hass: HomeAssistant, calls) -> None: assert calls[-1].data["option"] == state -async def test_set_direction(hass: HomeAssistant, calls) -> None: +async def test_set_direction(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid direction.""" await _register_components(hass) expected_calls = 0 await common.async_turn_on(hass, _TEST_FAN) expected_calls += 1 - for cmd in [DIRECTION_FORWARD, DIRECTION_REVERSE]: + for cmd in (DIRECTION_FORWARD, DIRECTION_REVERSE): await common.async_set_direction(hass, _TEST_FAN, cmd) assert hass.states.get(_DIRECTION_INPUT_SELECT).state == cmd _verify(hass, STATE_ON, 0, None, cmd, None) @@ -455,29 +455,31 @@ async def test_set_direction(hass: HomeAssistant, calls) -> None: assert calls[-1].data["option"] == cmd -async def test_set_invalid_direction(hass: HomeAssistant, calls) -> None: +async def test_set_invalid_direction( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test set invalid direction when fan has valid direction.""" await _register_components(hass) await common.async_turn_on(hass, _TEST_FAN) - for cmd in [DIRECTION_FORWARD, "invalid"]: + for cmd in (DIRECTION_FORWARD, "invalid"): await common.async_set_direction(hass, _TEST_FAN, cmd) assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD _verify(hass, STATE_ON, 0, None, DIRECTION_FORWARD, None) -async def test_preset_modes(hass: HomeAssistant, calls) -> None: +async def test_preset_modes(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test preset_modes.""" await _register_components( hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] ) await common.async_turn_on(hass, _TEST_FAN) - for extra, state, expected_calls in [ + for extra, state, expected_calls in ( ("auto", "auto", 2), ("smart", "smart", 3), ("invalid", "smart", 3), - ]: + ): if extra != state: with pytest.raises(NotValidPresetModeError): await common.async_set_preset_mode(hass, _TEST_FAN, extra) @@ -493,18 +495,18 @@ async def test_preset_modes(hass: HomeAssistant, calls) -> None: assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" -async def test_set_percentage(hass: HomeAssistant, calls) -> None: +async def test_set_percentage(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid speed percentage.""" await _register_components(hass) expected_calls = 0 await common.async_turn_on(hass, _TEST_FAN) expected_calls += 1 - for state, value in [ + for state, value in ( (STATE_ON, 100), (STATE_ON, 66), (STATE_ON, 0), - ]: + ): await common.async_set_percentage(hass, _TEST_FAN, value) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value _verify(hass, state, value, None, None, None) @@ -519,24 +521,26 @@ async def test_set_percentage(hass: HomeAssistant, calls) -> None: _verify(hass, STATE_ON, 50, None, None, None) -async def test_increase_decrease_speed(hass: HomeAssistant, calls) -> None: +async def test_increase_decrease_speed( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test set valid increase and decrease speed.""" await _register_components(hass, speed_count=3) await common.async_turn_on(hass, _TEST_FAN) - for func, extra, state, value in [ + for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 66), (common.async_decrease_speed, None, STATE_ON, 33), (common.async_decrease_speed, None, STATE_ON, 0), (common.async_increase_speed, None, STATE_ON, 33), - ]: + ): await func(hass, _TEST_FAN, extra) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value _verify(hass, state, value, None, None, None) -async def test_no_value_template(hass: HomeAssistant, calls) -> None: +async def test_no_value_template(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test a fan without a value_template.""" await _register_fan_sources(hass) @@ -648,25 +652,27 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: async def test_increase_decrease_speed_default_speed_count( - hass: HomeAssistant, calls + hass: HomeAssistant, calls: list[ServiceCall] ) -> None: """Test set valid increase and decrease speed.""" await _register_components(hass) await common.async_turn_on(hass, _TEST_FAN) - for func, extra, state, value in [ + for func, extra, state, value in ( (common.async_set_percentage, 100, STATE_ON, 100), (common.async_decrease_speed, None, STATE_ON, 99), (common.async_decrease_speed, None, STATE_ON, 98), (common.async_decrease_speed, 31, STATE_ON, 67), (common.async_decrease_speed, None, STATE_ON, 66), - ]: + ): await func(hass, _TEST_FAN, extra) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == value _verify(hass, state, value, None, None, None) -async def test_set_invalid_osc_from_initial_state(hass: HomeAssistant, calls) -> None: +async def test_set_invalid_osc_from_initial_state( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test set invalid oscillating when fan is in initial state.""" await _register_components(hass) @@ -677,7 +683,7 @@ async def test_set_invalid_osc_from_initial_state(hass: HomeAssistant, calls) -> _verify(hass, STATE_ON, 0, None, None, None) -async def test_set_invalid_osc(hass: HomeAssistant, calls) -> None: +async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set invalid oscillating when fan has valid osc.""" await _register_components(hass) diff --git a/tests/components/template/test_image.py b/tests/components/template/test_image.py index 6162276fcec..bda9e2530ca 100644 --- a/tests/components/template/test_image.py +++ b/tests/components/template/test_image.py @@ -17,7 +17,7 @@ from homeassistant.components.input_text import ( ) from homeassistant.const import ATTR_ENTITY_PICTURE, CONF_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import assert_setup_component @@ -211,7 +211,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: assert hass.states.async_all("image") == [] -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id configuration.""" with assert_setup_component(1, "template"): assert await setup.async_setup_component( @@ -232,8 +234,7 @@ async def test_unique_id(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - ent_reg = async_get(hass) - entry = ent_reg.async_get(_TEST_IMAGE) + entry = entity_registry.async_get(_TEST_IMAGE) assert entry assert entry.unique_id == "b-a" diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 991228623b1..d13fd9035b0 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -8,11 +8,12 @@ import pytest from homeassistant import config from homeassistant.components.template import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.reload import SERVICE_RELOAD from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed, get_fixture_path +from tests.common import MockConfigEntry, async_fire_time_changed, get_fixture_path @pytest.mark.parametrize(("count", "domain"), [(1, "sensor")]) @@ -268,3 +269,91 @@ async def async_yaml_patch_helper(hass, filename): blocking=True, ) await hass.async_block_till_done() + + +async def test_change_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test remove the device registry configuration entry when the device changes.""" + + # Configure a device registry + entry_device1 = MockConfigEntry() + entry_device1.add_to_hass(hass) + device1 = device_registry.async_get_or_create( + config_entry_id=entry_device1.entry_id, + identifiers={("test", "identifier_test1")}, + connections={("mac", "20:31:32:33:34:01")}, + ) + entry_device2 = MockConfigEntry() + entry_device2.add_to_hass(hass) + device2 = device_registry.async_get_or_create( + config_entry_id=entry_device1.entry_id, + identifiers={("test", "identifier_test2")}, + connections={("mac", "20:31:32:33:34:02")}, + ) + await hass.async_block_till_done() + + device_id1 = device1.id + assert device_id1 is not None + + device_id2 = device2.id + assert device_id2 is not None + + # Setup the config entry (binary_sensor) + sensor_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "template_type": "binary_sensor", + "name": "Teste", + "state": "{{15}}", + "device_id": device_id1, + }, + title="Binary sensor template", + ) + sensor_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(sensor_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm that the configuration entry has been added to the device 1 registry (current) + current_device = device_registry.async_get(device_id=device_id1) + assert sensor_config_entry.entry_id in current_device.config_entries + + # Change configuration options to use device 2 and reload the integration + result = await hass.config_entries.options.async_init(sensor_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "state": "{{15}}", + "device_id": device_id2, + }, + ) + await hass.async_block_till_done() + + # Confirm that the configuration entry has been removed from the device 1 registry (previous) + previous_device = device_registry.async_get(device_id=device_id1) + assert sensor_config_entry.entry_id not in previous_device.config_entries + + # Confirm that the configuration entry has been added to the device 2 registry (current) + current_device = device_registry.async_get(device_id=device_id2) + assert sensor_config_entry.entry_id in current_device.config_entries + + result = await hass.config_entries.options.async_init(sensor_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "state": "{{15}}", + }, + ) + await hass.async_block_till_done() + + # Confirm that the configuration entry has been removed from the device 2 registry (previous) + previous_device = device_registry.async_get(device_id=device_id2) + assert sensor_config_entry.entry_id not in previous_device.config_entries + + # Confirm that there is no device with the helper configuration entry + assert ( + dr.async_entries_for_config_entry(device_registry, sensor_config_entry.entry_id) + == [] + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 0dfbc0f833d..ad97146d0fb 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.setup import async_setup_component from tests.common import assert_setup_component @@ -340,7 +340,9 @@ async def test_missing_key(hass: HomeAssistant, count, setup_light) -> None: }, ], ) -async def test_on_action(hass: HomeAssistant, setup_light, calls) -> None: +async def test_on_action( + hass: HomeAssistant, setup_light, calls: list[ServiceCall] +) -> None: """Test on action.""" hass.states.async_set("light.test_state", STATE_OFF) await hass.async_block_till_done() @@ -399,7 +401,7 @@ async def test_on_action(hass: HomeAssistant, setup_light, calls) -> None: ], ) async def test_on_action_with_transition( - hass: HomeAssistant, setup_light, calls + hass: HomeAssistant, setup_light, calls: list[ServiceCall] ) -> None: """Test on action with transition.""" hass.states.async_set("light.test_state", STATE_OFF) @@ -441,7 +443,7 @@ async def test_on_action_with_transition( async def test_on_action_optimistic( hass: HomeAssistant, setup_light, - calls, + calls: list[ServiceCall], ) -> None: """Test on action with optimistic state.""" hass.states.async_set("light.test_state", STATE_OFF) @@ -499,7 +501,9 @@ async def test_on_action_optimistic( }, ], ) -async def test_off_action(hass: HomeAssistant, setup_light, calls) -> None: +async def test_off_action( + hass: HomeAssistant, setup_light, calls: list[ServiceCall] +) -> None: """Test off action.""" hass.states.async_set("light.test_state", STATE_ON) await hass.async_block_till_done() @@ -557,7 +561,7 @@ async def test_off_action(hass: HomeAssistant, setup_light, calls) -> None: ], ) async def test_off_action_with_transition( - hass: HomeAssistant, setup_light, calls + hass: HomeAssistant, setup_light, calls: list[ServiceCall] ) -> None: """Test off action with transition.""" hass.states.async_set("light.test_state", STATE_ON) @@ -595,7 +599,9 @@ async def test_off_action_with_transition( }, ], ) -async def test_off_action_optimistic(hass: HomeAssistant, setup_light, calls) -> None: +async def test_off_action_optimistic( + hass: HomeAssistant, setup_light, calls: list[ServiceCall] +) -> None: """Test off action with optimistic state.""" state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF @@ -633,7 +639,7 @@ async def test_off_action_optimistic(hass: HomeAssistant, setup_light, calls) -> async def test_level_action_no_template( hass: HomeAssistant, setup_light, - calls, + calls: list[ServiceCall], ) -> None: """Test setting brightness with optimistic template.""" state = hass.states.get("light.test_template_light") @@ -752,7 +758,7 @@ async def test_temperature_template( async def test_temperature_action_no_template( hass: HomeAssistant, setup_light, - calls, + calls: list[ServiceCall], ) -> None: """Test setting temperature with optimistic template.""" state = hass.states.get("light.test_template_light") @@ -872,10 +878,10 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None ], ) async def test_legacy_color_action_no_template( - hass, + hass: HomeAssistant, setup_light, - calls, -): + calls: list[ServiceCall], +) -> None: """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -916,7 +922,7 @@ async def test_legacy_color_action_no_template( async def test_hs_color_action_no_template( hass: HomeAssistant, setup_light, - calls, + calls: list[ServiceCall], ) -> None: """Test setting hs color with optimistic template.""" state = hass.states.get("light.test_template_light") @@ -958,7 +964,7 @@ async def test_hs_color_action_no_template( async def test_rgb_color_action_no_template( hass: HomeAssistant, setup_light, - calls, + calls: list[ServiceCall], ) -> None: """Test setting rgb color with optimistic template.""" state = hass.states.get("light.test_template_light") @@ -1001,7 +1007,7 @@ async def test_rgb_color_action_no_template( async def test_rgbw_color_action_no_template( hass: HomeAssistant, setup_light, - calls, + calls: list[ServiceCall], ) -> None: """Test setting rgbw color with optimistic template.""" state = hass.states.get("light.test_template_light") @@ -1048,7 +1054,7 @@ async def test_rgbw_color_action_no_template( async def test_rgbww_color_action_no_template( hass: HomeAssistant, setup_light, - calls, + calls: list[ServiceCall], ) -> None: """Test setting rgbww color with optimistic template.""" state = hass.states.get("light.test_template_light") @@ -1097,12 +1103,12 @@ async def test_rgbww_color_action_no_template( ], ) async def test_legacy_color_template( - hass, - expected_hs, - expected_color_mode, - count, - color_template, -): + hass: HomeAssistant, + expected_hs: tuple[float, float] | None, + expected_color_mode: ColorMode, + count: int, + color_template: str, +) -> None: """Test the template for the color.""" light_config = { "test_template_light": { @@ -1348,7 +1354,7 @@ async def test_rgbww_template( ], ) async def test_all_colors_mode_no_template( - hass: HomeAssistant, setup_light, calls + hass: HomeAssistant, setup_light, calls: list[ServiceCall] ) -> None: """Test setting color and color temperature with optimistic template.""" state = hass.states.get("light.test_template_light") @@ -1564,7 +1570,7 @@ async def test_all_colors_mode_no_template( ], ) async def test_effect_action_valid_effect( - hass: HomeAssistant, setup_light, calls + hass: HomeAssistant, setup_light, calls: list[ServiceCall] ) -> None: """Test setting valid effect with template.""" state = hass.states.get("light.test_template_light") @@ -1609,7 +1615,7 @@ async def test_effect_action_valid_effect( ], ) async def test_effect_action_invalid_effect( - hass: HomeAssistant, setup_light, calls + hass: HomeAssistant, setup_light, calls: list[ServiceCall] ) -> None: """Test setting invalid effect with template.""" state = hass.states.get("light.test_template_light") diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 77b7c9657d4..f4e81cbfd63 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -4,8 +4,14 @@ import pytest from homeassistant import setup from homeassistant.components import lock -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant, ServiceCall OPTIMISTIC_LOCK_CONFIG = { "platform": "template", @@ -25,6 +31,26 @@ OPTIMISTIC_LOCK_CONFIG = { }, } +OPTIMISTIC_CODED_LOCK_CONFIG = { + "platform": "template", + "lock": { + "service": "test.automation", + "data_template": { + "action": "lock", + "caller": "{{ this.entity_id }}", + "code": "{{ code }}", + }, + }, + "unlock": { + "service": "test.automation", + "data_template": { + "action": "unlock", + "caller": "{{ this.entity_id }}", + "code": "{{ code }}", + }, + }, +} + @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( @@ -138,10 +164,24 @@ async def test_template_state_boolean_off(hass: HomeAssistant, start_ha) -> None }, } }, + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "value_template": "{{ 1 == 1 }}", + "code_format_template": "{{ rubbish }", + } + }, + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "value_template": "{{ 1 == 1 }}", + "code_format_template": "{% if rubbish %}", + } + }, ], ) async def test_template_syntax_error(hass: HomeAssistant, start_ha) -> None: - """Test templating syntax error.""" + """Test templating syntax errors don't create entities.""" assert hass.states.async_all("lock") == [] @@ -180,7 +220,9 @@ async def test_template_static(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_lock_action(hass: HomeAssistant, start_ha, calls) -> None: +async def test_lock_action( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test lock action.""" await setup.async_setup_component(hass, "switch", {}) hass.states.async_set("switch.test_state", STATE_OFF) @@ -190,7 +232,9 @@ async def test_lock_action(hass: HomeAssistant, start_ha, calls) -> None: assert state.state == lock.STATE_UNLOCKED await hass.services.async_call( - lock.DOMAIN, lock.SERVICE_LOCK, {ATTR_ENTITY_ID: "lock.template_lock"} + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.template_lock"}, ) await hass.async_block_till_done() @@ -211,7 +255,9 @@ async def test_lock_action(hass: HomeAssistant, start_ha, calls) -> None: }, ], ) -async def test_unlock_action(hass: HomeAssistant, start_ha, calls) -> None: +async def test_unlock_action( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test unlock action.""" await setup.async_setup_component(hass, "switch", {}) hass.states.async_set("switch.test_state", STATE_ON) @@ -221,7 +267,9 @@ async def test_unlock_action(hass: HomeAssistant, start_ha, calls) -> None: assert state.state == lock.STATE_LOCKED await hass.services.async_call( - lock.DOMAIN, lock.SERVICE_UNLOCK, {ATTR_ENTITY_ID: "lock.template_lock"} + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.template_lock"}, ) await hass.async_block_till_done() @@ -230,6 +278,234 @@ async def test_unlock_action(hass: HomeAssistant, start_ha, calls) -> None: assert calls[0].data["caller"] == "lock.template_lock" +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_CODED_LOCK_CONFIG, + "value_template": "{{ states.switch.test_state.state }}", + "code_format_template": "{{ '.+' }}", + } + }, + ], +) +async def test_lock_action_with_code( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: + """Test lock action with defined code format and supplied lock code.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_UNLOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "LOCK_CODE"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "lock" + assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["code"] == "LOCK_CODE" + + +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_CODED_LOCK_CONFIG, + "value_template": "{{ states.switch.test_state.state }}", + "code_format_template": "{{ '.+' }}", + } + }, + ], +) +async def test_unlock_action_with_code( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: + """Test unlock action with code format and supplied unlock code.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_LOCKED + + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "UNLOCK_CODE"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == "unlock" + assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["code"] == "UNLOCK_CODE" + + +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "value_template": "{{ 1 == 1 }}", + "code_format_template": "{{ '\\\\d+' }}", + } + }, + ], +) +@pytest.mark.parametrize( + "test_action", + [ + lock.SERVICE_LOCK, + lock.SERVICE_UNLOCK, + ], +) +async def test_lock_actions_fail_with_invalid_code( + hass: HomeAssistant, start_ha, calls: list[ServiceCall], test_action +) -> None: + """Test invalid lock codes.""" + await hass.services.async_call( + lock.DOMAIN, + test_action, + {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "non-number-value"}, + ) + await hass.services.async_call( + lock.DOMAIN, + test_action, + {ATTR_ENTITY_ID: "lock.template_lock"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "value_template": "{{ 1 == 1 }}", + "code_format_template": "{{ 1/0 }}", + } + }, + ], +) +async def test_lock_actions_dont_execute_with_code_template_rendering_error( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: + """Test lock code format rendering fails block lock/unlock actions.""" + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.template_lock"}, + ) + await hass.services.async_call( + lock.DOMAIN, + lock.SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "any-value"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_CODED_LOCK_CONFIG, + "value_template": "{{ states.switch.test_state.state }}", + "code_format_template": "{{ None }}", + } + }, + ], +) +async def test_actions_with_none_as_codeformat_ignores_code( + hass: HomeAssistant, action, start_ha, calls: list[ServiceCall] +) -> None: + """Test lock actions with supplied lock code.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_UNLOCKED + + await hass.services.async_call( + lock.DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "any code"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["action"] == action + assert calls[0].data["caller"] == "lock.template_lock" + assert calls[0].data["code"] == "any code" + + +@pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) +@pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) +@pytest.mark.parametrize( + "config", + [ + { + lock.DOMAIN: { + **OPTIMISTIC_LOCK_CONFIG, + "value_template": "{{ states.switch.test_state.state }}", + "code_format_template": "[12]{1", + } + }, + ], +) +async def test_actions_with_invalid_regexp_as_codeformat_never_execute( + hass: HomeAssistant, action, start_ha, calls: list[ServiceCall] +) -> None: + """Test lock actions don't execute with invalid regexp.""" + await setup.async_setup_component(hass, "switch", {}) + hass.states.async_set("switch.test_state", STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_UNLOCKED + + await hass.services.async_call( + lock.DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "1"}, + ) + await hass.services.async_call( + lock.DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.template_lock", ATTR_CODE: "x"}, + ) + await hass.services.async_call( + lock.DOMAIN, + action, + {ATTR_ENTITY_ID: "lock.template_lock"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index bfaf3b6a0a1..bf04151fd36 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -15,8 +15,8 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er from tests.common import assert_setup_component, async_capture_events @@ -127,7 +127,9 @@ async def test_all_optional_config(hass: HomeAssistant) -> None: _verify(hass, 4, 1, 3, 5) -async def test_templates_with_entities(hass: HomeAssistant, calls) -> None: +async def test_templates_with_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +) -> None: """Test templates with values from other entities.""" with assert_setup_component(4, "input_number"): assert await setup.async_setup_component( @@ -206,8 +208,7 @@ async def test_templates_with_entities(hass: HomeAssistant, calls) -> None: await hass.async_start() await hass.async_block_till_done() - ent_reg = async_get(hass) - entry = ent_reg.async_get(_TEST_NUMBER) + entry = entity_registry.async_get(_TEST_NUMBER) assert entry assert entry.unique_id == "b-a" diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 6567926cd01..4106abdd469 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -15,8 +15,8 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.core import Context, HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er from tests.common import assert_setup_component, async_capture_events @@ -132,7 +132,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: assert hass.states.async_all("select") == [] -async def test_templates_with_entities(hass: HomeAssistant, calls) -> None: +async def test_templates_with_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +) -> None: """Test templates with values from other entities.""" with assert_setup_component(1, "input_select"): assert await setup.async_setup_component( @@ -187,8 +189,7 @@ async def test_templates_with_entities(hass: HomeAssistant, calls) -> None: await hass.async_start() await hass.async_block_till_done() - ent_reg = async_get(hass) - entry = ent_reg.async_get(_TEST_SELECT) + entry = entity_registry.async_get(_TEST_SELECT) assert entry assert entry.unique_id == "b-a" diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index fdcc0587a73..37d6d120491 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.template import Template from homeassistant.setup import ATTR_COMPONENT, async_setup_component @@ -1828,9 +1828,9 @@ async def test_trigger_entity_restore_state( state = hass.states.get("sensor.test") assert state.state == initial_state - for attr in restored_attributes: + for attr, value in restored_attributes.items(): if attr in initial_attributes: - assert state.attributes[attr] == restored_attributes[attr] + assert state.attributes[attr] == value else: assert attr not in state.attributes assert "another" not in state.attributes @@ -1896,3 +1896,42 @@ async def test_trigger_action( assert len(events) == 1 assert events[0].context.parent_id == context.id + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for device for Template.""" + + device_config_entry = MockConfigEntry() + device_config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=device_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + await hass.async_block_till_done() + assert device_entry is not None + assert device_entry.id is not None + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": "{{10}}", + "template_type": "sensor", + "device_id": device_entry.id, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + template_entity = entity_registry.async_get("sensor.my_template") + assert template_entity is not None + assert template_entity.device_id == device_entry.id diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index acf80006798..68cca990ef1 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -12,7 +12,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, mock_component, mock_restore_cache @@ -354,7 +354,7 @@ async def test_missing_off_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_on_action(hass: HomeAssistant, calls) -> None: +async def test_on_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test on action.""" assert await async_setup_component( hass, @@ -394,7 +394,9 @@ async def test_on_action(hass: HomeAssistant, calls) -> None: assert calls[-1].data["caller"] == "switch.test_template_switch" -async def test_on_action_optimistic(hass: HomeAssistant, calls) -> None: +async def test_on_action_optimistic( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test on action in optimistic mode.""" assert await async_setup_component( hass, @@ -435,7 +437,7 @@ async def test_on_action_optimistic(hass: HomeAssistant, calls) -> None: assert calls[-1].data["caller"] == "switch.test_template_switch" -async def test_off_action(hass: HomeAssistant, calls) -> None: +async def test_off_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test off action.""" assert await async_setup_component( hass, @@ -475,7 +477,9 @@ async def test_off_action(hass: HomeAssistant, calls) -> None: assert calls[-1].data["caller"] == "switch.test_template_switch" -async def test_off_action_optimistic(hass: HomeAssistant, calls) -> None: +async def test_off_action_optimistic( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test off action in optimistic mode.""" assert await async_setup_component( hass, diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index 0f95503c333..98b03be3c64 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -14,7 +14,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -22,7 +22,7 @@ from tests.common import async_fire_time_changed, mock_component @pytest.fixture(autouse=True) -def setup_comp(hass, calls): +def setup_comp(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Initialize components.""" mock_component(hass, "group") hass.states.async_set("test.entity", "hello") @@ -48,7 +48,9 @@ def setup_comp(hass, calls): }, ], ) -async def test_if_fires_on_change_bool(hass: HomeAssistant, start_ha, calls) -> None: +async def test_if_fires_on_change_bool( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test for firing on boolean change.""" assert len(calls) == 0 @@ -269,7 +271,9 @@ async def test_if_fires_on_change_bool(hass: HomeAssistant, start_ha, calls) -> ), ], ) -async def test_general(hass: HomeAssistant, call_setup, start_ha, calls) -> None: +async def test_general( + hass: HomeAssistant, call_setup, start_ha, calls: list[ServiceCall] +) -> None: """Test for firing on change.""" assert len(calls) == 0 @@ -305,7 +309,7 @@ async def test_general(hass: HomeAssistant, call_setup, start_ha, calls) -> None ], ) async def test_if_not_fires_because_fail( - hass: HomeAssistant, call_setup, start_ha, calls + hass: HomeAssistant, call_setup, start_ha, calls: list[ServiceCall] ) -> None: """Test for not firing after TemplateError.""" assert len(calls) == 0 @@ -343,7 +347,7 @@ async def test_if_not_fires_because_fail( ], ) async def test_if_fires_on_change_with_template_advanced( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with template advanced.""" context = Context() @@ -374,7 +378,9 @@ async def test_if_fires_on_change_with_template_advanced( }, ], ) -async def test_if_action(hass: HomeAssistant, start_ha, calls) -> None: +async def test_if_action( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test for firing if action.""" # Condition is not true yet hass.bus.async_fire("test_event") @@ -405,7 +411,7 @@ async def test_if_action(hass: HomeAssistant, start_ha, calls) -> None: ], ) async def test_if_fires_on_change_with_bad_template( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with bad template.""" assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE @@ -441,7 +447,9 @@ async def test_if_fires_on_change_with_bad_template( }, ], ) -async def test_wait_template_with_trigger(hass: HomeAssistant, start_ha, calls) -> None: +async def test_wait_template_with_trigger( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test using wait template with 'trigger.entity_id'.""" await hass.async_block_till_done() @@ -457,7 +465,9 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, start_ha, calls) assert calls[0].data["some"] == "template - test.entity - hello - world - None" -async def test_if_fires_on_change_with_for(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_change_with_for( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on change with for.""" assert await async_setup_component( hass, @@ -510,7 +520,7 @@ async def test_if_fires_on_change_with_for(hass: HomeAssistant, calls) -> None: ], ) async def test_if_fires_on_change_with_for_advanced( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for advanced.""" context = Context() @@ -554,7 +564,7 @@ async def test_if_fires_on_change_with_for_advanced( ], ) async def test_if_fires_on_change_with_for_0_advanced( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for: 0 advanced.""" context = Context() @@ -595,7 +605,7 @@ async def test_if_fires_on_change_with_for_0_advanced( ], ) async def test_if_fires_on_change_with_for_2( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for.""" context = Context() @@ -626,7 +636,7 @@ async def test_if_fires_on_change_with_for_2( ], ) async def test_if_not_fires_on_change_with_for( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for.""" hass.states.async_set("test.entity", "world") @@ -660,7 +670,7 @@ async def test_if_not_fires_on_change_with_for( ], ) async def test_if_not_fires_when_turned_off_with_for( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for.""" hass.states.async_set("test.entity", "world") @@ -698,7 +708,7 @@ async def test_if_not_fires_when_turned_off_with_for( ], ) async def test_if_fires_on_change_with_for_template_1( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") @@ -726,7 +736,7 @@ async def test_if_fires_on_change_with_for_template_1( ], ) async def test_if_fires_on_change_with_for_template_2( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") @@ -754,7 +764,7 @@ async def test_if_fires_on_change_with_for_template_2( ], ) async def test_if_fires_on_change_with_for_template_3( - hass: HomeAssistant, start_ha, calls + hass: HomeAssistant, start_ha, calls: list[ServiceCall] ) -> None: """Test for firing on change with for template.""" hass.states.async_set("test.entity", "world") @@ -781,7 +791,9 @@ async def test_if_fires_on_change_with_for_template_3( }, ], ) -async def test_invalid_for_template_1(hass: HomeAssistant, start_ha, calls) -> None: +async def test_invalid_for_template_1( + hass: HomeAssistant, start_ha, calls: list[ServiceCall] +) -> None: """Test for invalid for template.""" with mock.patch.object(template_trigger, "_LOGGER") as mock_logger: hass.states.async_set("test.entity", "world") @@ -790,7 +802,7 @@ async def test_invalid_for_template_1(hass: HomeAssistant, start_ha, calls) -> N async def test_if_fires_on_time_change( - hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls: list[ServiceCall] ) -> None: """Test for firing on time changes.""" start_time = dt_util.utcnow() + timedelta(hours=24) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 2c6f083abce..8b1d082a62b 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -12,7 +12,7 @@ from homeassistant.components.vacuum import ( STATE_RETURNING, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import async_update_entity @@ -355,7 +355,7 @@ async def test_unused_services(hass: HomeAssistant) -> None: _verify(hass, STATE_UNKNOWN, None) -async def test_state_services(hass: HomeAssistant, calls) -> None: +async def test_state_services(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test state services.""" await _register_components(hass) @@ -404,7 +404,9 @@ async def test_state_services(hass: HomeAssistant, calls) -> None: assert calls[-1].data["caller"] == _TEST_VACUUM -async def test_clean_spot_service(hass: HomeAssistant, calls) -> None: +async def test_clean_spot_service( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test clean spot service.""" await _register_components(hass) @@ -419,7 +421,7 @@ async def test_clean_spot_service(hass: HomeAssistant, calls) -> None: assert calls[-1].data["caller"] == _TEST_VACUUM -async def test_locate_service(hass: HomeAssistant, calls) -> None: +async def test_locate_service(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test locate service.""" await _register_components(hass) @@ -434,7 +436,7 @@ async def test_locate_service(hass: HomeAssistant, calls) -> None: assert calls[-1].data["caller"] == _TEST_VACUUM -async def test_set_fan_speed(hass: HomeAssistant, calls) -> None: +async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test set valid fan speed.""" await _register_components(hass) @@ -461,7 +463,9 @@ async def test_set_fan_speed(hass: HomeAssistant, calls) -> None: assert calls[-1].data["option"] == "medium" -async def test_set_invalid_fan_speed(hass: HomeAssistant, calls) -> None: +async def test_set_invalid_fan_speed( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test set invalid fan speed when fan has valid speed.""" await _register_components(hass) diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index e457f2e263b..fd7694cfbed 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -18,7 +18,6 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, Forecast, ) @@ -68,7 +67,7 @@ ATTR_FORECAST = "forecast" ) async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: """Test the state text of a template.""" - for attr, v_attr, value in [ + for attr, v_attr, value in ( ( "sensor.attribution", ATTR_ATTRIBUTION, @@ -85,7 +84,7 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), ("sensor.dew_point", ATTR_WEATHER_DEW_POINT, 2.2), ("sensor.apparent_temperature", ATTR_WEATHER_APPARENT_TEMPERATURE, 25), - ]: + ): hass.states.async_set(attr, value) await hass.async_block_till_done() state = hass.states.get("weather.test") @@ -96,10 +95,7 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( @@ -125,10 +121,10 @@ async def test_forecasts( hass: HomeAssistant, start_ha, snapshot: SnapshotAssertion, service: str ) -> None: """Test forecast service.""" - for attr, _v_attr, value in [ + for attr, _v_attr, value in ( ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), - ]: + ): hass.states.async_set(attr, value) await hass.async_block_till_done() @@ -224,7 +220,6 @@ async def test_forecasts( ("service", "expected"), [ (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), - (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), ], ) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -254,10 +249,10 @@ async def test_forecast_invalid( expected: dict[str, Any], ) -> None: """Test invalid forecasts.""" - for attr, _v_attr, value in [ + for attr, _v_attr, value in ( ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), - ]: + ): hass.states.async_set(attr, value) await hass.async_block_till_done() @@ -308,7 +303,6 @@ async def test_forecast_invalid( ("service", "expected"), [ (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), - (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), ], ) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -337,10 +331,10 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( expected: dict[str, Any], ) -> None: """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" - for attr, _v_attr, value in [ + for attr, _v_attr, value in ( ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), - ]: + ): hass.states.async_set(attr, value) await hass.async_block_till_done() @@ -377,7 +371,6 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( ("service", "expected"), [ (SERVICE_GET_FORECASTS, {"weather.forecast": {"forecast": []}}), - (LEGACY_SERVICE_GET_FORECAST, {"forecast": []}), ], ) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -406,10 +399,10 @@ async def test_forecast_invalid_datetime_missing( expected: dict[str, Any], ) -> None: """Test forecast service invalid when datetime missing.""" - for attr, _v_attr, value in [ + for attr, _v_attr, value in ( ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), - ]: + ): hass.states.async_set(attr, value) await hass.async_block_till_done() @@ -444,10 +437,7 @@ async def test_forecast_invalid_datetime_missing( @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( @@ -472,10 +462,10 @@ async def test_forecast_format_error( hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture, service: str ) -> None: """Test forecast service invalid on incorrect format.""" - for attr, _v_attr, value in [ + for attr, _v_attr, value in ( ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), - ]: + ): hass.states.async_set(attr, value) await hass.async_block_till_done() @@ -679,10 +669,7 @@ async def test_trigger_action( @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( diff --git a/tests/components/tesla_wall_connector/test_sensor.py b/tests/components/tesla_wall_connector/test_sensor.py index d064b9028b5..62eca46c388 100644 --- a/tests/components/tesla_wall_connector/test_sensor.py +++ b/tests/components/tesla_wall_connector/test_sensor.py @@ -20,6 +20,12 @@ async def test_sensors(hass: HomeAssistant) -> None: EntityAndExpectedValues( "sensor.tesla_wall_connector_handle_temperature", "25.5", "-1.4" ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_pcb_temperature", "30.5", "-1.2" + ), + EntityAndExpectedValues( + "sensor.tesla_wall_connector_mcu_temperature", "42.0", "-1" + ), EntityAndExpectedValues( "sensor.tesla_wall_connector_grid_voltage", "230.2", "229.2" ), @@ -55,6 +61,8 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_first_update = get_vitals_mock() mock_vitals_first_update.evse_state = 1 mock_vitals_first_update.handle_temp_c = 25.51 + mock_vitals_first_update.pcba_temp_c = 30.5 + mock_vitals_first_update.mcu_temp_c = 42.0 mock_vitals_first_update.grid_v = 230.15 mock_vitals_first_update.grid_hz = 50.021 mock_vitals_first_update.voltageA_v = 230.1 @@ -68,6 +76,8 @@ async def test_sensors(hass: HomeAssistant) -> None: mock_vitals_second_update = get_vitals_mock() mock_vitals_second_update.evse_state = 3 mock_vitals_second_update.handle_temp_c = -1.42 + mock_vitals_second_update.pcba_temp_c = -1.2 + mock_vitals_second_update.mcu_temp_c = -1 mock_vitals_second_update.grid_v = 229.21 mock_vitals_second_update.grid_hz = 49.981 mock_vitals_second_update.voltageA_v = 228.1 diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index ac3a2904c27..c4fbdaf3fbd 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -18,18 +18,16 @@ async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = """Set up the Teslemetry platform.""" mock_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, + domain=DOMAIN, data=CONFIG, minor_version=2, unique_id="abc-123" ) mock_entry.add_to_hass(hass) if platforms is None: await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() else: with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done() return mock_entry @@ -41,6 +39,7 @@ def assert_entities( snapshot: SnapshotAssertion, ) -> None: """Test that all entities match their snapshot.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry_id) assert entity_entries diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 9040ec96a03..410eaa62b69 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -8,10 +8,11 @@ from unittest.mock import patch import pytest from .const import ( + COMMAND_OK, LIVE_STATUS, METADATA, PRODUCTS, - RESPONSE_OK, + SITE_INFO, VEHICLE_DATA, WAKE_UP_ONLINE, ) @@ -70,7 +71,7 @@ def mock_request(): """Mock Tesla Fleet API Vehicle Specific class.""" with patch( "homeassistant.components.teslemetry.Teslemetry._request", - return_value=RESPONSE_OK, + return_value=COMMAND_OK, ) as mock_request: yield mock_request @@ -83,3 +84,13 @@ def mock_live_status(): side_effect=lambda: deepcopy(LIVE_STATUS), ) as mock_live_status: yield mock_live_status + + +@pytest.fixture(autouse=True) +def mock_site_info(): + """Mock Teslemetry Energy Specific site_info method.""" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.site_info", + side_effect=lambda: deepcopy(SITE_INFO), + ) as mock_live_status: + yield mock_live_status diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 96e9ead8912..6a3a657a1b1 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -14,10 +14,24 @@ PRODUCTS = load_json_object_fixture("products.json", DOMAIN) VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) +SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) + +COMMAND_OK = {"response": {"result": True, "reason": ""}} +COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} +COMMAND_IGNORED_REASON = {"response": {"result": False, "reason": "already_set"}} +COMMAND_NOREASON = {"response": {"result": False}} # Unexpected +COMMAND_ERROR = { + "response": None, + "error": "vehicle unavailable: vehicle is offline or asleep", + "error_description": "", +} +COMMAND_NOERROR = {"answer": 42} +COMMAND_ERRORS = (COMMAND_REASON, COMMAND_NOREASON, COMMAND_ERROR, COMMAND_NOERROR) RESPONSE_OK = {"response": {}, "error": None} METADATA = { + "uid": "abc-123", "region": "NA", "scopes": [ "openid", @@ -31,6 +45,7 @@ METADATA = { ], } METADATA_NOSCOPE = { + "uid": "abc-123", "region": "NA", "scopes": ["openid", "offline_access", "vehicle_device_data"], } diff --git a/tests/components/teslemetry/fixtures/products.json b/tests/components/teslemetry/fixtures/products.json index aa59062e8d4..e1b76e4cefb 100644 --- a/tests/components/teslemetry/fixtures/products.json +++ b/tests/components/teslemetry/fixtures/products.json @@ -4,7 +4,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "VINVINVIN", + "vin": "LRWXF7EK4KC700000", "color": null, "access_type": "OWNER", "display_name": "Test", diff --git a/tests/components/teslemetry/fixtures/site_info.json b/tests/components/teslemetry/fixtures/site_info.json index d39fc1f68aa..60958bbabbb 100644 --- a/tests/components/teslemetry/fixtures/site_info.json +++ b/tests/components/teslemetry/fixtures/site_info.json @@ -26,7 +26,7 @@ "storm_mode_capable": true, "flex_energy_request_capable": false, "car_charging_data_supported": false, - "off_grid_vehicle_charging_reserve_supported": false, + "off_grid_vehicle_charging_reserve_supported": true, "vehicle_charging_performance_view_enabled": false, "vehicle_charging_solar_offset_view_enabled": false, "battery_solar_offset_view_enabled": true, @@ -41,15 +41,55 @@ "battery_type": "ac_powerwall", "configurable": true, "grid_services_enabled": false, + "gateways": [ + { + "device_id": "gateway-id", + "din": "gateway-din", + "serial_number": "CN00000000J50D", + "part_number": "1152100-14-J", + "part_type": 10, + "part_name": "Tesla Backup Gateway 2", + "is_active": true, + "site_id": "1234-abcd", + "firmware_version": "24.4.0 0fe780c9", + "updated_datetime": "2024-05-14T00:00:00.000Z" + } + ], + "batteries": [ + { + "device_id": "battery-1-id", + "din": "battery-1-din", + "serial_number": "TG000000001DA5", + "part_number": "3012170-10-B", + "part_type": 2, + "part_name": "Powerwall 2", + "nameplate_max_charge_power": 5000, + "nameplate_max_discharge_power": 5000, + "nameplate_energy": 13500 + }, + { + "device_id": "battery-2-id", + "din": "battery-2-din", + "serial_number": "TG000000002DA5", + "part_number": "3012170-05-C", + "part_type": 2, + "part_name": "Powerwall 2", + "nameplate_max_charge_power": 5000, + "nameplate_max_discharge_power": 5000, + "nameplate_energy": 13500 + } + ], "wall_connectors": [ { "device_id": "123abc", - "din": "abc123", + "din": "abd-123", + "part_name": "Gen 3 Wall Connector", "is_active": true }, { "device_id": "234bcd", - "din": "bcd234", + "din": "bcd-234", + "part_name": "Gen 3 Wall Connector", "is_active": true } ], @@ -59,7 +99,7 @@ "system_alerts_enabled": true }, "version": "23.44.0 eb113390", - "battery_count": 3, + "battery_count": 2, "tou_settings": { "optimization_strategy": "economics", "schedule": [ diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index ba73fe3c4e6..6c787df4897 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -3,7 +3,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "VINVINVIN", + "vin": "LRWXF7EK4KC700000", "color": null, "access_type": "OWNER", "granular_access": { @@ -73,14 +73,14 @@ }, "climate_state": { "allow_cabin_overheat_protection": true, - "auto_seat_climate_left": false, + "auto_seat_climate_left": true, "auto_seat_climate_right": true, "auto_steering_wheel_heat": false, "battery_heater": false, "battery_heater_no_power": null, "cabin_overheat_protection": "On", "cabin_overheat_protection_actively_cooling": false, - "climate_keeper_mode": "off", + "climate_keeper_mode": "keep", "cop_activation_temperature": "High", "defrost_mode": 0, "driver_temp_setting": 22, @@ -88,7 +88,7 @@ "hvac_auto_request": "On", "inside_temp": 29.8, "is_auto_conditioning_on": false, - "is_climate_on": false, + "is_climate_on": true, "is_front_defroster_on": false, "is_preconditioning": false, "is_rear_defroster_on": false, @@ -148,7 +148,7 @@ "car_special_type": "base", "car_type": "model3", "charge_port_type": "CCS", - "cop_user_set_temp_supported": false, + "cop_user_set_temp_supported": true, "dashcam_clip_save_supported": true, "default_charge_to_max": false, "driver_assist": "TeslaAP3", @@ -204,17 +204,18 @@ "is_user_present": false, "locked": false, "media_info": { - "audio_volume": 2.6667, + "a2dp_source_name": "Pixel 8 Pro", + "audio_volume": 1.6667, "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": "Spotify", - "now_playing_station": "", - "now_playing_title": "" + "media_playback_status": "Playing", + "now_playing_album": "Elon Musk", + "now_playing_artist": "Walter Isaacson", + "now_playing_duration": 651000, + "now_playing_elapsed": 1000, + "now_playing_source": "Audible", + "now_playing_station": "Elon Musk", + "now_playing_title": "Chapter 51: Cybertruck: Tesla, 2018–2019" }, "media_state": { "remote_control_enabled": true @@ -236,11 +237,11 @@ "service_mode": false, "service_mode_plus": false, "software_update": { - "download_perc": 0, + "download_perc": 100, "expected_duration_sec": 2700, "install_perc": 1, - "status": "", - "version": " " + "status": "available", + "version": "2024.12.0.0" }, "speed_limit_mode": { "active": false, diff --git a/tests/components/teslemetry/fixtures/vehicle_data_alt.json b/tests/components/teslemetry/fixtures/vehicle_data_alt.json index 13d11073fb1..76416982eba 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data_alt.json +++ b/tests/components/teslemetry/fixtures/vehicle_data_alt.json @@ -3,7 +3,7 @@ "id": 1234, "user_id": 1234, "vehicle_id": 1234, - "vin": "VINVINVIN", + "vin": "LRWXF7EK4KC700000", "color": null, "access_type": "OWNER", "granular_access": { @@ -19,7 +19,7 @@ "backseat_token_updated_at": null, "ble_autopair_enrolled": false, "charge_state": { - "battery_heater_on": false, + "battery_heater_on": true, "battery_level": 77, "battery_range": 266.87, "charge_amps": 16, @@ -69,19 +69,19 @@ "timestamp": null, "trip_charging": false, "usable_battery_level": 77, - "user_charge_enable_request": null + "user_charge_enable_request": true }, "climate_state": { "allow_cabin_overheat_protection": true, "auto_seat_climate_left": false, "auto_seat_climate_right": false, "auto_steering_wheel_heat": false, - "battery_heater": false, + "battery_heater": true, "battery_heater_no_power": null, "cabin_overheat_protection": "Off", "cabin_overheat_protection_actively_cooling": false, "climate_keeper_mode": "off", - "cop_activation_temperature": "High", + "cop_activation_temperature": "Low", "defrost_mode": 0, "driver_temp_setting": 22, "fan_status": 0, @@ -197,11 +197,11 @@ "dashcam_state": "Recording", "df": 0, "dr": 0, - "fd_window": 0, + "fd_window": 1, "feature_bitmask": "fbdffbff,187f", - "fp_window": 0, - "ft": 0, - "is_user_present": false, + "fp_window": 1, + "ft": 1, + "is_user_present": true, "locked": false, "media_info": { "audio_volume": 2.6667, @@ -224,12 +224,12 @@ "parsed_calendar_supported": true, "pf": 0, "pr": 0, - "rd_window": 0, + "rd_window": 1, "remote_start": false, "remote_start_enabled": true, "remote_start_supported": true, - "rp_window": 0, - "rt": 0, + "rp_window": 1, + "rt": 1, "santa_mode": 0, "sentry_mode": false, "sentry_mode_available": true, diff --git a/tests/components/teslemetry/snapshots/test_binary_sensors.ambr b/tests/components/teslemetry/snapshots/test_binary_sensors.ambr new file mode 100644 index 00000000000..6f35fe9da25 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_binary_sensors.ambr @@ -0,0 +1,1571 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.energy_site_backup_capable-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.energy_site_backup_capable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Backup capable', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_capable', + 'unique_id': '123456-backup_capable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_backup_capable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Backup capable', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_backup_capable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_services_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.energy_site_grid_services_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid services active', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_active', + 'unique_id': '123456-grid_services_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_services_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Grid services active', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_services_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_services_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.energy_site_grid_services_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Grid services enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'components_grid_services_enabled', + 'unique_id': '123456-components_grid_services_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_grid_services_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Grid services enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_services_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_battery_heater-entry] + 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.test_battery_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery heater', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_battery_heater_on', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_heater_on', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_battery_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Battery heater', + }), + 'context': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-entry] + 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.test_cabin_overheat_protection_actively_cooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cabin overheat protection actively cooling', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection_actively_cooling', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection_actively_cooling', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_actively_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Cabin overheat protection actively cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_cable-entry] + 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.test_charge_cable', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge cable', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_conn_charge_cable', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charge_cable-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Charge cable', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_cable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_charger_has_multiple_phases', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charger has multiple phases', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charger_phases', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_phases', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charger has multiple phases', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charger_has_multiple_phases', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_dashcam-entry] + 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.test_dashcam', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dashcam', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dashcam_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dashcam_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_dashcam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Dashcam', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dashcam', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] + 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.test_front_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_df', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_df', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] + 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.test_front_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front driver window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fd_window', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] + 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.test_front_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pf', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] + 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.test_front_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Front passenger window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_fp_window', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_fp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_front_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning-entry] + 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.test_preconditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_is_preconditioning', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_is_preconditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning_enabled-entry] + 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.test_preconditioning_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Preconditioning enabled', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_preconditioning_enabled', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_preconditioning_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_preconditioning_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] + 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.test_rear_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_dr', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_dr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] + 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.test_rear_driver_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear driver window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rd_window', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rd_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_driver_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] + 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.test_rear_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_pr', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_pr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] + 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.test_rear_passenger_window', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear passenger window', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rp_window', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rp_window', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_rear_passenger_window-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] + 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.test_scheduled_charging_pending', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scheduled charging pending', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_scheduled_charging_pending', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_scheduled_charging_pending', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Scheduled charging pending', + }), + 'context': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': 'LRWXF7EK4KC700000-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] + 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.test_tire_pressure_warning_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front left', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fl', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fl', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_right-entry] + 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.test_tire_pressure_warning_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning front right', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_fr', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_fr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_rear_left-entry] + 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.test_tire_pressure_warning_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear left', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rl', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rl', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_rear_right-entry] + 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.test_tire_pressure_warning_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tire pressure warning rear right', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_tpms_soft_warning_rr', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_soft_warning_rr', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_trip_charging-entry] + 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.test_trip_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Trip charging', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_trip_charging', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_trip_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_trip_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Trip charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_user_present-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_present', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'User present', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_is_user_present', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_is_user_present', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_user_present-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test User present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Backup capable', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_backup_capable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.energy_site_grid_services_active-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Grid services active', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_services_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.energy_site_grid_services_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Grid services enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_grid_services_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Battery heater', + }), + 'context': , + 'entity_id': 'binary_sensor.test_battery_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_actively_cooling-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Cabin overheat protection actively cooling', + }), + 'context': , + 'entity_id': 'binary_sensor.test_cabin_overheat_protection_actively_cooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_charge_cable-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Charge cable', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charge_cable', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charger has multiple phases', + }), + 'context': , + 'entity_id': 'binary_sensor.test_charger_has_multiple_phases', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Test Dashcam', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dashcam', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_driver_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Front passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Front passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_front_passenger_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_preconditioning_enabled-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Preconditioning enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.test_preconditioning_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear driver window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_driver_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Rear passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Rear passenger window', + }), + 'context': , + 'entity_id': 'binary_sensor.test_rear_passenger_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Scheduled charging pending', + }), + 'context': , + 'entity_id': 'binary_sensor.test_scheduled_charging_pending', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test Status', + }), + 'context': , + 'entity_id': 'binary_sensor.test_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_right-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning front right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_rear_left-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear left', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_rear_right-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Tire pressure warning rear right', + }), + 'context': , + 'entity_id': 'binary_sensor.test_tire_pressure_warning_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_trip_charging-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Trip charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_trip_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_user_present-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test User present', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_present', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr new file mode 100644 index 00000000000..84cf4c21078 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_button[button.test_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flash_lights', + 'unique_id': 'LRWXF7EK4KC700000-flash_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Flash lights', + }), + 'context': , + 'entity_id': 'button.test_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_homelink-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_homelink', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Homelink', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink', + 'unique_id': 'LRWXF7EK4KC700000-homelink', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_homelink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink', + }), + 'context': , + 'entity_id': 'button.test_homelink', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_honk_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_honk_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Honk horn', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'LRWXF7EK4KC700000-honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_honk_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Honk horn', + }), + 'context': , + 'entity_id': 'button.test_honk_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_keyless_driving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_keyless_driving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keyless driving', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_keyless_driving', + 'unique_id': 'LRWXF7EK4KC700000-enable_keyless_driving', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_keyless_driving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Keyless driving', + }), + 'context': , + 'entity_id': 'button.test_keyless_driving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_play_fart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_play_fart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Play fart', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boombox', + 'unique_id': 'LRWXF7EK4KC700000-boombox', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_play_fart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Play fart', + }), + 'context': , + 'entity_id': 'button.test_play_fart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_wake-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_wake', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wake', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake', + 'unique_id': 'LRWXF7EK4KC700000-wake', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_wake-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wake', + }), + 'context': , + 'entity_id': 'button.test_wake', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 097df8bde85..b65796fe10e 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -1,4 +1,70 @@ # serializer version: 1 +# name: test_climate[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + 'temperature': 40, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- # name: test_climate[climate.test_climate-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -41,11 +107,151 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': , - 'unique_id': 'VINVINVIN-driver_temp', + 'unique_id': 'LRWXF7EK4KC700000-driver_temp', 'unit_of_measurement': None, }) # --- # name: test_climate[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'keep', + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_climate_alt[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_alt[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_alt[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_alt[climate.test_climate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 30.0, @@ -74,3 +280,143 @@ 'state': 'off', }) # --- +# name: test_climate_offline[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_offline[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_climate_offline[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_offline[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr new file mode 100644 index 00000000000..7689a08a373 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -0,0 +1,577 @@ +# serializer version: 1 +# name: test_cover[cover.test_charge_port_door-entry] + 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.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_charge_port_door-state] + 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_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover[cover.test_frunk-entry] + 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.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover[cover.test_trunk-entry] + 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.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover[cover.test_windows-entry] + 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.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover_alt[cover.test_charge_port_door-entry] + 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.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_charge_port_door-state] + 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_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_frunk-entry] + 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.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_trunk-entry] + 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.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_windows-entry] + 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.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_noscope[cover.test_charge_port_door-entry] + 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.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_noscope[cover.test_charge_port_door-state] + 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_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_noscope[cover.test_frunk-entry] + 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.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_noscope[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover_noscope[cover.test_trunk-entry] + 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.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_noscope[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover_noscope[cover.test_windows-entry] + 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.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_noscope[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..9859d9db360 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_device_tracker[device_tracker.test_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'LRWXF7EK4KC700000-location', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.test_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Location', + 'gps_accuracy': 0, + 'latitude': -30.222626, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_device_tracker[device_tracker.test_route-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_route', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Route', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'route', + 'unique_id': 'LRWXF7EK4KC700000-route', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.test_route-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Route', + 'gps_accuracy': 0, + 'latitude': 30.2226265, + 'longitude': -97.6236871, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_route', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 74eff27c4a0..4a942daa508 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -3,292 +3,432 @@ dict({ 'energysites': list([ dict({ - 'backup_capable': True, - 'battery_power': 5060, - 'energy_left': 38896.47368421053, - 'generator_power': 0, - 'grid_power': 0, - 'grid_services_active': False, - 'grid_services_power': 0, - 'grid_status': 'Active', - 'island_status': 'on_grid', - 'load_power': 6245, - 'percentage_charged': 95.50537403739663, - 'solar_power': 1185, - 'storm_mode_active': False, - 'timestamp': '2024-01-01T00:00:00+00:00', - 'total_pack_energy': 40727, - 'wall_connectors': dict({ - 'abd-123': dict({ - 'din': 'abd-123', - 'wall_connector_fault_state': 2, - 'wall_connector_power': 0, - 'wall_connector_state': 2, - }), - 'bcd-234': dict({ - 'din': 'bcd-234', - 'wall_connector_fault_state': 2, - 'wall_connector_power': 0, - 'wall_connector_state': 2, + 'info': dict({ + 'backup_reserve_percent': 0, + 'battery_count': 2, + 'components_backup': True, + 'components_backup_time_remaining_enabled': True, + 'components_batteries': list([ + dict({ + 'device_id': 'battery-1-id', + 'din': 'battery-1-din', + 'nameplate_energy': 13500, + 'nameplate_max_charge_power': 5000, + 'nameplate_max_discharge_power': 5000, + 'part_name': 'Powerwall 2', + 'part_number': '3012170-10-B', + 'part_type': 2, + 'serial_number': 'TG000000001DA5', + }), + dict({ + 'device_id': 'battery-2-id', + 'din': 'battery-2-din', + 'nameplate_energy': 13500, + 'nameplate_max_charge_power': 5000, + 'nameplate_max_discharge_power': 5000, + 'part_name': 'Powerwall 2', + 'part_number': '3012170-05-C', + 'part_type': 2, + 'serial_number': 'TG000000002DA5', + }), + ]), + 'components_battery': True, + 'components_battery_solar_offset_view_enabled': True, + 'components_battery_type': 'ac_powerwall', + 'components_car_charging_data_supported': False, + 'components_configurable': True, + 'components_customer_preferred_export_rule': 'pv_only', + 'components_disallow_charge_from_grid_with_solar_installed': True, + 'components_energy_service_self_scheduling_enabled': True, + 'components_energy_value_header': 'Energy Value', + 'components_energy_value_subheader': 'Estimated Value', + 'components_flex_energy_request_capable': False, + 'components_gateway': 'teg', + 'components_gateways': list([ + dict({ + 'device_id': 'gateway-id', + 'din': 'gateway-din', + 'firmware_version': '24.4.0 0fe780c9', + 'is_active': True, + 'part_name': 'Tesla Backup Gateway 2', + 'part_number': '1152100-14-J', + 'part_type': 10, + 'serial_number': 'CN00000000J50D', + 'site_id': '1234-abcd', + 'updated_datetime': '2024-05-14T00:00:00.000Z', + }), + ]), + 'components_grid': True, + 'components_grid_services_enabled': False, + 'components_load_meter': True, + 'components_net_meter_mode': 'battery_ok', + 'components_off_grid_vehicle_charging_reserve_supported': True, + 'components_set_islanding_mode_enabled': True, + 'components_show_grid_import_battery_source_cards': True, + 'components_solar': True, + 'components_solar_type': 'pv_panel', + 'components_solar_value_enabled': True, + 'components_storm_mode_capable': True, + 'components_system_alerts_enabled': True, + 'components_tou_capable': True, + 'components_vehicle_charging_performance_view_enabled': False, + 'components_vehicle_charging_solar_offset_view_enabled': False, + 'components_wall_connectors': list([ + dict({ + 'device_id': '123abc', + 'din': 'abd-123', + 'is_active': True, + 'part_name': 'Gen 3 Wall Connector', + }), + dict({ + 'device_id': '234bcd', + 'din': 'bcd-234', + 'is_active': True, + 'part_name': 'Gen 3 Wall Connector', + }), + ]), + 'components_wifi_commissioning_enabled': True, + 'default_real_mode': 'self_consumption', + 'id': '1233-abcd', + 'installation_date': '**REDACTED**', + 'installation_time_zone': '', + 'max_site_meter_power_ac': 1000000000, + 'min_site_meter_power_ac': -1000000000, + 'nameplate_energy': 40500, + 'nameplate_power': 15000, + 'site_name': 'Site', + 'tou_settings_optimization_strategy': 'economics', + 'tou_settings_schedule': list([ + dict({ + 'end_seconds': 3600, + 'start_seconds': 0, + 'target': 'off_peak', + 'week_days': list([ + 1, + 0, + ]), + }), + dict({ + 'end_seconds': 0, + 'start_seconds': 3600, + 'target': 'peak', + 'week_days': list([ + 1, + 0, + ]), + }), + ]), + 'user_settings_breaker_alert_enabled': False, + 'user_settings_go_off_grid_test_banner_enabled': False, + 'user_settings_powerwall_onboarding_settings_set': True, + 'user_settings_powerwall_tesla_electric_interested_in': False, + 'user_settings_storm_mode_enabled': True, + 'user_settings_sync_grid_alert_enabled': True, + 'user_settings_vpp_tour_enabled': True, + 'version': '23.44.0 eb113390', + 'vpp_backup_reserve_percent': 0, + }), + 'live': dict({ + 'backup_capable': True, + 'battery_power': 5060, + 'energy_left': 38896.47368421053, + 'generator_power': 0, + 'grid_power': 0, + 'grid_services_active': False, + 'grid_services_power': 0, + 'grid_status': 'Active', + 'island_status': 'on_grid', + 'load_power': 6245, + 'percentage_charged': 95.50537403739663, + 'solar_power': 1185, + 'storm_mode_active': False, + 'timestamp': '2024-01-01T00:00:00+00:00', + 'total_pack_energy': 40727, + 'wall_connectors': dict({ + 'abd-123': dict({ + 'din': 'abd-123', + 'wall_connector_fault_state': 2, + 'wall_connector_power': 0, + 'wall_connector_state': 2, + }), + 'bcd-234': dict({ + 'din': 'bcd-234', + 'wall_connector_fault_state': 2, + 'wall_connector_power': 0, + 'wall_connector_state': 2, + }), }), }), }), ]), + 'scopes': list([ + 'openid', + 'offline_access', + 'user_data', + 'vehicle_device_data', + 'vehicle_cmds', + 'vehicle_charging_cmds', + 'energy_device_data', + 'energy_cmds', + ]), 'vehicles': list([ dict({ - 'access_type': 'OWNER', - 'api_version': 71, - 'backseat_token': None, - 'backseat_token_updated_at': None, - 'ble_autopair_enrolled': False, - 'calendar_enabled': True, - 'charge_state_battery_heater_on': False, - 'charge_state_battery_level': 77, - 'charge_state_battery_range': 266.87, - 'charge_state_charge_amps': 16, - 'charge_state_charge_current_request': 16, - 'charge_state_charge_current_request_max': 16, - 'charge_state_charge_enable_request': True, - 'charge_state_charge_energy_added': 0, - 'charge_state_charge_limit_soc': 80, - 'charge_state_charge_limit_soc_max': 100, - 'charge_state_charge_limit_soc_min': 50, - 'charge_state_charge_limit_soc_std': 80, - 'charge_state_charge_miles_added_ideal': 0, - 'charge_state_charge_miles_added_rated': 0, - 'charge_state_charge_port_cold_weather_mode': False, - 'charge_state_charge_port_color': '', - 'charge_state_charge_port_door_open': True, - 'charge_state_charge_port_latch': 'Engaged', - 'charge_state_charge_rate': 0, - 'charge_state_charger_actual_current': 0, - 'charge_state_charger_phases': None, - 'charge_state_charger_pilot_current': 16, - 'charge_state_charger_power': 0, - 'charge_state_charger_voltage': 2, - 'charge_state_charging_state': 'Stopped', - 'charge_state_conn_charge_cable': 'IEC', - 'charge_state_est_battery_range': 275.04, - 'charge_state_fast_charger_brand': '', - 'charge_state_fast_charger_present': False, - 'charge_state_fast_charger_type': 'ACSingleWireCAN', - 'charge_state_ideal_battery_range': 266.87, - 'charge_state_max_range_charge_counter': 0, - 'charge_state_minutes_to_full_charge': 0, - 'charge_state_not_enough_power_to_heat': None, - 'charge_state_off_peak_charging_enabled': False, - 'charge_state_off_peak_charging_times': 'all_week', - 'charge_state_off_peak_hours_end_time': 900, - 'charge_state_preconditioning_enabled': False, - 'charge_state_preconditioning_times': 'all_week', - 'charge_state_scheduled_charging_mode': 'Off', - 'charge_state_scheduled_charging_pending': False, - 'charge_state_scheduled_charging_start_time': None, - 'charge_state_scheduled_charging_start_time_app': 600, - 'charge_state_scheduled_departure_time': 1704837600, - 'charge_state_scheduled_departure_time_minutes': 480, - 'charge_state_supercharger_session_trip_planner': False, - 'charge_state_time_to_full_charge': 0, - 'charge_state_timestamp': 1705707520649, - 'charge_state_trip_charging': False, - 'charge_state_usable_battery_level': 77, - 'charge_state_user_charge_enable_request': None, - 'climate_state_allow_cabin_overheat_protection': True, - 'climate_state_auto_seat_climate_left': False, - 'climate_state_auto_seat_climate_right': True, - 'climate_state_auto_steering_wheel_heat': False, - 'climate_state_battery_heater': False, - 'climate_state_battery_heater_no_power': None, - 'climate_state_cabin_overheat_protection': 'On', - 'climate_state_cabin_overheat_protection_actively_cooling': False, - 'climate_state_climate_keeper_mode': 'off', - 'climate_state_cop_activation_temperature': 'High', - 'climate_state_defrost_mode': 0, - 'climate_state_driver_temp_setting': 22, - 'climate_state_fan_status': 0, - 'climate_state_hvac_auto_request': 'On', - 'climate_state_inside_temp': 29.8, - 'climate_state_is_auto_conditioning_on': False, - 'climate_state_is_climate_on': False, - 'climate_state_is_front_defroster_on': False, - 'climate_state_is_preconditioning': False, - 'climate_state_is_rear_defroster_on': False, - 'climate_state_left_temp_direction': 251, - 'climate_state_max_avail_temp': 28, - 'climate_state_min_avail_temp': 15, - 'climate_state_outside_temp': 30, - 'climate_state_passenger_temp_setting': 22, - 'climate_state_remote_heater_control_enabled': False, - 'climate_state_right_temp_direction': 251, - 'climate_state_seat_heater_left': 0, - 'climate_state_seat_heater_rear_center': 0, - 'climate_state_seat_heater_rear_left': 0, - 'climate_state_seat_heater_rear_right': 0, - 'climate_state_seat_heater_right': 0, - 'climate_state_side_mirror_heaters': False, - 'climate_state_steering_wheel_heat_level': 0, - 'climate_state_steering_wheel_heater': False, - 'climate_state_supports_fan_only_cabin_overheat_protection': True, - 'climate_state_timestamp': 1705707520649, - 'climate_state_wiper_blade_heater': False, - 'color': None, - 'drive_state_active_route_latitude': '**REDACTED**', - 'drive_state_active_route_longitude': '**REDACTED**', - 'drive_state_active_route_miles_to_arrival': 0.039491, - 'drive_state_active_route_minutes_to_arrival': 0.103577, - 'drive_state_active_route_traffic_minutes_delay': 0, - 'drive_state_gps_as_of': 1701129612, - 'drive_state_heading': 185, - 'drive_state_latitude': '**REDACTED**', - 'drive_state_longitude': '**REDACTED**', - 'drive_state_native_latitude': '**REDACTED**', - 'drive_state_native_location_supported': 1, - 'drive_state_native_longitude': '**REDACTED**', - 'drive_state_native_type': 'wgs', - 'drive_state_power': -7, - 'drive_state_shift_state': None, - 'drive_state_speed': None, - 'drive_state_timestamp': 1705707520649, - 'granular_access_hide_private': False, - 'gui_settings_gui_24_hour_time': False, - 'gui_settings_gui_charge_rate_units': 'kW', - 'gui_settings_gui_distance_units': 'km/hr', - 'gui_settings_gui_range_display': 'Rated', - 'gui_settings_gui_temperature_units': 'C', - 'gui_settings_gui_tirepressure_units': 'Psi', - 'gui_settings_show_range_units': False, - 'gui_settings_timestamp': 1705707520649, - 'id': '**REDACTED**', - 'id_s': '**REDACTED**', - 'in_service': False, - 'state': 'online', - 'tokens': '**REDACTED**', - 'user_id': '**REDACTED**', - 'vehicle_config_aux_park_lamps': 'Eu', - 'vehicle_config_badge_version': 1, - 'vehicle_config_can_accept_navigation_requests': True, - 'vehicle_config_can_actuate_trunks': True, - 'vehicle_config_car_special_type': 'base', - 'vehicle_config_car_type': 'model3', - 'vehicle_config_charge_port_type': 'CCS', - 'vehicle_config_cop_user_set_temp_supported': False, - 'vehicle_config_dashcam_clip_save_supported': True, - 'vehicle_config_default_charge_to_max': False, - 'vehicle_config_driver_assist': 'TeslaAP3', - 'vehicle_config_ece_restrictions': False, - 'vehicle_config_efficiency_package': 'M32021', - 'vehicle_config_eu_vehicle': True, - 'vehicle_config_exterior_color': 'DeepBlue', - 'vehicle_config_exterior_trim': 'Black', - 'vehicle_config_exterior_trim_override': '', - 'vehicle_config_has_air_suspension': False, - 'vehicle_config_has_ludicrous_mode': False, - 'vehicle_config_has_seat_cooling': False, - 'vehicle_config_headlamp_type': 'Global', - 'vehicle_config_interior_trim_type': 'White2', - 'vehicle_config_key_version': 2, - 'vehicle_config_motorized_charge_port': True, - 'vehicle_config_paint_color_override': '0,9,25,0.7,0.04', - 'vehicle_config_performance_package': 'Base', - 'vehicle_config_plg': True, - 'vehicle_config_pws': True, - 'vehicle_config_rear_drive_unit': 'PM216MOSFET', - 'vehicle_config_rear_seat_heaters': 1, - 'vehicle_config_rear_seat_type': 0, - 'vehicle_config_rhd': True, - 'vehicle_config_roof_color': 'RoofColorGlass', - 'vehicle_config_seat_type': None, - 'vehicle_config_spoiler_type': 'None', - 'vehicle_config_sun_roof_installed': None, - 'vehicle_config_supports_qr_pairing': False, - 'vehicle_config_third_row_seats': 'None', - 'vehicle_config_timestamp': 1705707520649, - 'vehicle_config_trim_badging': '74d', - 'vehicle_config_use_range_badging': True, - 'vehicle_config_utc_offset': 36000, - 'vehicle_config_webcam_selfie_supported': True, - 'vehicle_config_webcam_supported': True, - 'vehicle_config_wheel_type': 'Pinwheel18CapKit', - 'vehicle_id': '**REDACTED**', - 'vehicle_state_api_version': 71, - 'vehicle_state_autopark_state_v2': 'unavailable', - 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2023.44.30.8 06f534d46010', - 'vehicle_state_center_display_state': 0, - 'vehicle_state_dashcam_clip_save_available': True, - 'vehicle_state_dashcam_state': 'Recording', - 'vehicle_state_df': 0, - 'vehicle_state_dr': 0, - 'vehicle_state_fd_window': 0, - 'vehicle_state_feature_bitmask': 'fbdffbff,187f', - 'vehicle_state_fp_window': 0, - 'vehicle_state_ft': 0, - 'vehicle_state_is_user_present': False, - 'vehicle_state_locked': False, - 'vehicle_state_media_info_audio_volume': 2.6667, - 'vehicle_state_media_info_audio_volume_increment': 0.333333, - 'vehicle_state_media_info_audio_volume_max': 10.333333, - 'vehicle_state_media_info_media_playback_status': 'Stopped', - 'vehicle_state_media_info_now_playing_album': '', - 'vehicle_state_media_info_now_playing_artist': '', - 'vehicle_state_media_info_now_playing_duration': 0, - 'vehicle_state_media_info_now_playing_elapsed': 0, - 'vehicle_state_media_info_now_playing_source': 'Spotify', - 'vehicle_state_media_info_now_playing_station': '', - 'vehicle_state_media_info_now_playing_title': '', - 'vehicle_state_media_state_remote_control_enabled': True, - 'vehicle_state_notifications_supported': True, - 'vehicle_state_odometer': 6481.019282, - 'vehicle_state_parsed_calendar_supported': True, - 'vehicle_state_pf': 0, - 'vehicle_state_pr': 0, - 'vehicle_state_rd_window': 0, - 'vehicle_state_remote_start': False, - 'vehicle_state_remote_start_enabled': True, - 'vehicle_state_remote_start_supported': True, - 'vehicle_state_rp_window': 0, - 'vehicle_state_rt': 0, - 'vehicle_state_santa_mode': 0, - 'vehicle_state_sentry_mode': False, - 'vehicle_state_sentry_mode_available': True, - 'vehicle_state_service_mode': False, - 'vehicle_state_service_mode_plus': False, - 'vehicle_state_software_update_download_perc': 0, - 'vehicle_state_software_update_expected_duration_sec': 2700, - 'vehicle_state_software_update_install_perc': 1, - 'vehicle_state_software_update_status': '', - 'vehicle_state_software_update_version': ' ', - 'vehicle_state_speed_limit_mode_active': False, - 'vehicle_state_speed_limit_mode_current_limit_mph': 69, - 'vehicle_state_speed_limit_mode_max_limit_mph': 120, - 'vehicle_state_speed_limit_mode_min_limit_mph': 50, - 'vehicle_state_speed_limit_mode_pin_code_set': True, - 'vehicle_state_timestamp': 1705707520649, - 'vehicle_state_tpms_hard_warning_fl': False, - 'vehicle_state_tpms_hard_warning_fr': False, - 'vehicle_state_tpms_hard_warning_rl': False, - 'vehicle_state_tpms_hard_warning_rr': False, - 'vehicle_state_tpms_last_seen_pressure_time_fl': 1705700812, - 'vehicle_state_tpms_last_seen_pressure_time_fr': 1705700793, - 'vehicle_state_tpms_last_seen_pressure_time_rl': 1705700794, - 'vehicle_state_tpms_last_seen_pressure_time_rr': 1705700823, - 'vehicle_state_tpms_pressure_fl': 2.775, - 'vehicle_state_tpms_pressure_fr': 2.8, - 'vehicle_state_tpms_pressure_rl': 2.775, - 'vehicle_state_tpms_pressure_rr': 2.775, - 'vehicle_state_tpms_rcp_front_value': 2.9, - 'vehicle_state_tpms_rcp_rear_value': 2.9, - 'vehicle_state_tpms_soft_warning_fl': False, - 'vehicle_state_tpms_soft_warning_fr': False, - 'vehicle_state_tpms_soft_warning_rl': False, - 'vehicle_state_tpms_soft_warning_rr': False, - 'vehicle_state_valet_mode': False, - 'vehicle_state_valet_pin_needed': False, - 'vehicle_state_vehicle_name': 'Test', - 'vehicle_state_vehicle_self_test_progress': 0, - 'vehicle_state_vehicle_self_test_requested': False, - 'vehicle_state_webcam_available': True, - 'vin': '**REDACTED**', + 'data': dict({ + 'access_type': 'OWNER', + 'api_version': 71, + 'backseat_token': None, + 'backseat_token_updated_at': None, + 'ble_autopair_enrolled': False, + 'calendar_enabled': True, + 'charge_state_battery_heater_on': False, + 'charge_state_battery_level': 77, + 'charge_state_battery_range': 266.87, + 'charge_state_charge_amps': 16, + 'charge_state_charge_current_request': 16, + 'charge_state_charge_current_request_max': 16, + 'charge_state_charge_enable_request': True, + 'charge_state_charge_energy_added': 0, + 'charge_state_charge_limit_soc': 80, + 'charge_state_charge_limit_soc_max': 100, + 'charge_state_charge_limit_soc_min': 50, + 'charge_state_charge_limit_soc_std': 80, + 'charge_state_charge_miles_added_ideal': 0, + 'charge_state_charge_miles_added_rated': 0, + 'charge_state_charge_port_cold_weather_mode': False, + 'charge_state_charge_port_color': '', + 'charge_state_charge_port_door_open': True, + 'charge_state_charge_port_latch': 'Engaged', + 'charge_state_charge_rate': 0, + 'charge_state_charger_actual_current': 0, + 'charge_state_charger_phases': None, + 'charge_state_charger_pilot_current': 16, + 'charge_state_charger_power': 0, + 'charge_state_charger_voltage': 2, + 'charge_state_charging_state': 'Stopped', + 'charge_state_conn_charge_cable': 'IEC', + 'charge_state_est_battery_range': 275.04, + 'charge_state_fast_charger_brand': '', + 'charge_state_fast_charger_present': False, + 'charge_state_fast_charger_type': 'ACSingleWireCAN', + 'charge_state_ideal_battery_range': 266.87, + 'charge_state_max_range_charge_counter': 0, + 'charge_state_minutes_to_full_charge': 0, + 'charge_state_not_enough_power_to_heat': None, + 'charge_state_off_peak_charging_enabled': False, + 'charge_state_off_peak_charging_times': 'all_week', + 'charge_state_off_peak_hours_end_time': 900, + 'charge_state_preconditioning_enabled': False, + 'charge_state_preconditioning_times': 'all_week', + 'charge_state_scheduled_charging_mode': 'Off', + 'charge_state_scheduled_charging_pending': False, + 'charge_state_scheduled_charging_start_time': None, + 'charge_state_scheduled_charging_start_time_app': 600, + 'charge_state_scheduled_departure_time': 1704837600, + 'charge_state_scheduled_departure_time_minutes': 480, + 'charge_state_supercharger_session_trip_planner': False, + 'charge_state_time_to_full_charge': 0, + 'charge_state_timestamp': 1705707520649, + 'charge_state_trip_charging': False, + 'charge_state_usable_battery_level': 77, + 'charge_state_user_charge_enable_request': None, + 'climate_state_allow_cabin_overheat_protection': True, + 'climate_state_auto_seat_climate_left': True, + 'climate_state_auto_seat_climate_right': True, + 'climate_state_auto_steering_wheel_heat': False, + 'climate_state_battery_heater': False, + 'climate_state_battery_heater_no_power': None, + 'climate_state_cabin_overheat_protection': 'On', + 'climate_state_cabin_overheat_protection_actively_cooling': False, + 'climate_state_climate_keeper_mode': 'keep', + 'climate_state_cop_activation_temperature': 'High', + 'climate_state_defrost_mode': 0, + 'climate_state_driver_temp_setting': 22, + 'climate_state_fan_status': 0, + 'climate_state_hvac_auto_request': 'On', + 'climate_state_inside_temp': 29.8, + 'climate_state_is_auto_conditioning_on': False, + 'climate_state_is_climate_on': True, + 'climate_state_is_front_defroster_on': False, + 'climate_state_is_preconditioning': False, + 'climate_state_is_rear_defroster_on': False, + 'climate_state_left_temp_direction': 251, + 'climate_state_max_avail_temp': 28, + 'climate_state_min_avail_temp': 15, + 'climate_state_outside_temp': 30, + 'climate_state_passenger_temp_setting': 22, + 'climate_state_remote_heater_control_enabled': False, + 'climate_state_right_temp_direction': 251, + 'climate_state_seat_heater_left': 0, + 'climate_state_seat_heater_rear_center': 0, + 'climate_state_seat_heater_rear_left': 0, + 'climate_state_seat_heater_rear_right': 0, + 'climate_state_seat_heater_right': 0, + 'climate_state_side_mirror_heaters': False, + 'climate_state_steering_wheel_heat_level': 0, + 'climate_state_steering_wheel_heater': False, + 'climate_state_supports_fan_only_cabin_overheat_protection': True, + 'climate_state_timestamp': 1705707520649, + 'climate_state_wiper_blade_heater': False, + 'color': None, + 'drive_state_active_route_latitude': '**REDACTED**', + 'drive_state_active_route_longitude': '**REDACTED**', + 'drive_state_active_route_miles_to_arrival': 0.039491, + 'drive_state_active_route_minutes_to_arrival': 0.103577, + 'drive_state_active_route_traffic_minutes_delay': 0, + 'drive_state_gps_as_of': 1701129612, + 'drive_state_heading': 185, + 'drive_state_latitude': '**REDACTED**', + 'drive_state_longitude': '**REDACTED**', + 'drive_state_native_latitude': '**REDACTED**', + 'drive_state_native_location_supported': 1, + 'drive_state_native_longitude': '**REDACTED**', + 'drive_state_native_type': 'wgs', + 'drive_state_power': -7, + 'drive_state_shift_state': None, + 'drive_state_speed': None, + 'drive_state_timestamp': 1705707520649, + 'granular_access_hide_private': False, + 'gui_settings_gui_24_hour_time': False, + 'gui_settings_gui_charge_rate_units': 'kW', + 'gui_settings_gui_distance_units': 'km/hr', + 'gui_settings_gui_range_display': 'Rated', + 'gui_settings_gui_temperature_units': 'C', + 'gui_settings_gui_tirepressure_units': 'Psi', + 'gui_settings_show_range_units': False, + 'gui_settings_timestamp': 1705707520649, + 'id': '**REDACTED**', + 'id_s': '**REDACTED**', + 'in_service': False, + 'state': 'online', + 'tokens': '**REDACTED**', + 'user_id': '**REDACTED**', + 'vehicle_config_aux_park_lamps': 'Eu', + 'vehicle_config_badge_version': 1, + 'vehicle_config_can_accept_navigation_requests': True, + 'vehicle_config_can_actuate_trunks': True, + 'vehicle_config_car_special_type': 'base', + 'vehicle_config_car_type': 'model3', + 'vehicle_config_charge_port_type': 'CCS', + 'vehicle_config_cop_user_set_temp_supported': True, + 'vehicle_config_dashcam_clip_save_supported': True, + 'vehicle_config_default_charge_to_max': False, + 'vehicle_config_driver_assist': 'TeslaAP3', + 'vehicle_config_ece_restrictions': False, + 'vehicle_config_efficiency_package': 'M32021', + 'vehicle_config_eu_vehicle': True, + 'vehicle_config_exterior_color': 'DeepBlue', + 'vehicle_config_exterior_trim': 'Black', + 'vehicle_config_exterior_trim_override': '', + 'vehicle_config_has_air_suspension': False, + 'vehicle_config_has_ludicrous_mode': False, + 'vehicle_config_has_seat_cooling': False, + 'vehicle_config_headlamp_type': 'Global', + 'vehicle_config_interior_trim_type': 'White2', + 'vehicle_config_key_version': 2, + 'vehicle_config_motorized_charge_port': True, + 'vehicle_config_paint_color_override': '0,9,25,0.7,0.04', + 'vehicle_config_performance_package': 'Base', + 'vehicle_config_plg': True, + 'vehicle_config_pws': True, + 'vehicle_config_rear_drive_unit': 'PM216MOSFET', + 'vehicle_config_rear_seat_heaters': 1, + 'vehicle_config_rear_seat_type': 0, + 'vehicle_config_rhd': True, + 'vehicle_config_roof_color': 'RoofColorGlass', + 'vehicle_config_seat_type': None, + 'vehicle_config_spoiler_type': 'None', + 'vehicle_config_sun_roof_installed': None, + 'vehicle_config_supports_qr_pairing': False, + 'vehicle_config_third_row_seats': 'None', + 'vehicle_config_timestamp': 1705707520649, + 'vehicle_config_trim_badging': '74d', + 'vehicle_config_use_range_badging': True, + 'vehicle_config_utc_offset': 36000, + 'vehicle_config_webcam_selfie_supported': True, + 'vehicle_config_webcam_supported': True, + 'vehicle_config_wheel_type': 'Pinwheel18CapKit', + 'vehicle_id': '**REDACTED**', + 'vehicle_state_api_version': 71, + 'vehicle_state_autopark_state_v2': 'unavailable', + 'vehicle_state_calendar_supported': True, + 'vehicle_state_car_version': '2023.44.30.8 06f534d46010', + 'vehicle_state_center_display_state': 0, + 'vehicle_state_dashcam_clip_save_available': True, + 'vehicle_state_dashcam_state': 'Recording', + 'vehicle_state_df': 0, + 'vehicle_state_dr': 0, + 'vehicle_state_fd_window': 0, + 'vehicle_state_feature_bitmask': 'fbdffbff,187f', + 'vehicle_state_fp_window': 0, + 'vehicle_state_ft': 0, + 'vehicle_state_is_user_present': False, + 'vehicle_state_locked': False, + 'vehicle_state_media_info_a2dp_source_name': 'Pixel 8 Pro', + 'vehicle_state_media_info_audio_volume': 1.6667, + 'vehicle_state_media_info_audio_volume_increment': 0.333333, + 'vehicle_state_media_info_audio_volume_max': 10.333333, + 'vehicle_state_media_info_media_playback_status': 'Playing', + 'vehicle_state_media_info_now_playing_album': 'Elon Musk', + 'vehicle_state_media_info_now_playing_artist': 'Walter Isaacson', + 'vehicle_state_media_info_now_playing_duration': 651000, + 'vehicle_state_media_info_now_playing_elapsed': 1000, + 'vehicle_state_media_info_now_playing_source': 'Audible', + 'vehicle_state_media_info_now_playing_station': 'Elon Musk', + 'vehicle_state_media_info_now_playing_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'vehicle_state_media_state_remote_control_enabled': True, + 'vehicle_state_notifications_supported': True, + 'vehicle_state_odometer': 6481.019282, + 'vehicle_state_parsed_calendar_supported': True, + 'vehicle_state_pf': 0, + 'vehicle_state_pr': 0, + 'vehicle_state_rd_window': 0, + 'vehicle_state_remote_start': False, + 'vehicle_state_remote_start_enabled': True, + 'vehicle_state_remote_start_supported': True, + 'vehicle_state_rp_window': 0, + 'vehicle_state_rt': 0, + 'vehicle_state_santa_mode': 0, + 'vehicle_state_sentry_mode': False, + 'vehicle_state_sentry_mode_available': True, + 'vehicle_state_service_mode': False, + 'vehicle_state_service_mode_plus': False, + 'vehicle_state_software_update_download_perc': 100, + 'vehicle_state_software_update_expected_duration_sec': 2700, + 'vehicle_state_software_update_install_perc': 1, + 'vehicle_state_software_update_status': 'available', + 'vehicle_state_software_update_version': '2024.12.0.0', + 'vehicle_state_speed_limit_mode_active': False, + 'vehicle_state_speed_limit_mode_current_limit_mph': 69, + 'vehicle_state_speed_limit_mode_max_limit_mph': 120, + 'vehicle_state_speed_limit_mode_min_limit_mph': 50, + 'vehicle_state_speed_limit_mode_pin_code_set': True, + 'vehicle_state_timestamp': 1705707520649, + 'vehicle_state_tpms_hard_warning_fl': False, + 'vehicle_state_tpms_hard_warning_fr': False, + 'vehicle_state_tpms_hard_warning_rl': False, + 'vehicle_state_tpms_hard_warning_rr': False, + 'vehicle_state_tpms_last_seen_pressure_time_fl': 1705700812, + 'vehicle_state_tpms_last_seen_pressure_time_fr': 1705700793, + 'vehicle_state_tpms_last_seen_pressure_time_rl': 1705700794, + 'vehicle_state_tpms_last_seen_pressure_time_rr': 1705700823, + 'vehicle_state_tpms_pressure_fl': 2.775, + 'vehicle_state_tpms_pressure_fr': 2.8, + 'vehicle_state_tpms_pressure_rl': 2.775, + 'vehicle_state_tpms_pressure_rr': 2.775, + 'vehicle_state_tpms_rcp_front_value': 2.9, + 'vehicle_state_tpms_rcp_rear_value': 2.9, + 'vehicle_state_tpms_soft_warning_fl': False, + 'vehicle_state_tpms_soft_warning_fr': False, + 'vehicle_state_tpms_soft_warning_rl': False, + 'vehicle_state_tpms_soft_warning_rr': False, + 'vehicle_state_valet_mode': False, + 'vehicle_state_valet_pin_needed': False, + 'vehicle_state_vehicle_name': 'Test', + 'vehicle_state_vehicle_self_test_progress': 0, + 'vehicle_state_vehicle_self_test_requested': False, + 'vehicle_state_webcam_available': True, + 'vin': '**REDACTED**', + }), }), ]), }) diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr new file mode 100644 index 00000000000..951e4557bdd --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_devices[{('teslemetry', '123456')}] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://teslemetry.com/console', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'teslemetry', + '123456', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tesla', + 'model': 'Powerwall 2, Tesla Backup Gateway 2', + 'name': 'Energy Site', + 'name_by_user': None, + 'serial_number': '123456', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[{('teslemetry', 'LRWXF7EK4KC700000')}] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://teslemetry.com/console', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'teslemetry', + 'LRWXF7EK4KC700000', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tesla', + 'model': 'Model X', + 'name': 'Test', + 'name_by_user': None, + 'serial_number': 'LRWXF7EK4KC700000', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[{('teslemetry', 'abd-123')}] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://teslemetry.com/console', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'teslemetry', + 'abd-123', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tesla', + 'model': 'Gen 3 Wall Connector', + 'name': 'Wall Connector', + 'name_by_user': None, + 'serial_number': '123', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_devices[{('teslemetry', 'bcd-234')}] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://teslemetry.com/console', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'teslemetry', + 'bcd-234', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Tesla', + 'model': 'Gen 3 Wall Connector', + 'name': 'Wall Connector', + 'name_by_user': None, + 'serial_number': '234', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr new file mode 100644 index 00000000000..deaabbae904 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_lock[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge cable lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_lock[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..06500437701 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -0,0 +1,136 @@ +# serializer version: 1 +# name: test_media_player[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'media', + 'unique_id': 'LRWXF7EK4KC700000-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_alt[media_player.test_media_player-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': '', + 'media_artist': '', + 'media_playlist': '', + 'media_title': '', + 'source': 'Spotify', + 'supported_features': , + 'volume_level': 0.25806775026025003, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media', + 'unique_id': 'LRWXF7EK4KC700000-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr new file mode 100644 index 00000000000..f33b5e15d30 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -0,0 +1,231 @@ +# serializer version: 1 +# name: test_number[number.energy_site_backup_reserve-entry] + 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': None, + 'entity_id': 'number.energy_site_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-alert', + 'original_name': 'Backup reserve', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve_percent', + 'unique_id': '123456-backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Backup reserve', + 'icon': 'mdi:battery-alert', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-entry] + 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': None, + 'entity_id': 'number.energy_site_off_grid_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Off grid reserve', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'off_grid_vehicle_charging_reserve_percent', + 'unique_id': '123456-off_grid_vehicle_charging_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Off grid reserve', + 'icon': 'mdi:battery-unknown', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_off_grid_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number[number.test_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge current', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_current_request', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_current_request', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.test_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charge current', + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_number[number.test_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_limit_soc', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_limit_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.test_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Charge limit', + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr new file mode 100644 index 00000000000..4e6feda7e5d --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -0,0 +1,585 @@ +# serializer version: 1 +# name: test_select[select.energy_site_allow_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.energy_site_allow_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Allow export', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'components_customer_preferred_export_rule', + 'unique_id': '123456-components_customer_preferred_export_rule', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.energy_site_allow_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Allow export', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.energy_site_allow_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pv_only', + }) +# --- +# name: test_select[select.energy_site_operation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.energy_site_operation_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Operation mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'default_real_mode', + 'unique_id': '123456-default_real_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.energy_site_operation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Operation mode', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.energy_site_operation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'self_consumption', + }) +# --- +# name: test_select[select.test_seat_heater_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater front left', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater front left', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_front_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater front right', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater front right', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear center', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_center', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_center', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear center', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_center', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear left', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear left', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear right', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear right', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_third_row_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_third_row_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater third row left', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_third_row_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_third_row_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater third row left', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_third_row_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_select[select.test_seat_heater_third_row_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_third_row_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater third row right', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_third_row_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_third_row_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater third row right', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_third_row_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_select[select.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steering wheel heater', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heat_level', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_steering_wheel_heat_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Steering wheel heater', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 0d817ad1f7e..0b664e78626 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -714,6 +714,128 @@ 'state': '40.727', }) # --- +# name: test_sensors[sensor.energy_site_version-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_version', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'version', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'version', + 'unique_id': '123456-version', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.energy_site_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site version', + }), + 'context': , + 'entity_id': 'sensor.energy_site_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.44.0 eb113390', + }) +# --- +# name: test_sensors[sensor.energy_site_version-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site version', + }), + 'context': , + 'entity_id': 'sensor.energy_site_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.44.0 eb113390', + }) +# --- +# name: test_sensors[sensor.energy_site_vpp_backup_reserve-entry] + 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.energy_site_vpp_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VPP backup reserve', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vpp_backup_reserve_percent', + 'unique_id': '123456-vpp_backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.energy_site_vpp_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site VPP backup reserve', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.energy_site_vpp_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.energy_site_vpp_backup_reserve-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site VPP backup reserve', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.energy_site_vpp_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[sensor.test_battery_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -745,7 +867,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_level', - 'unique_id': 'VINVINVIN-charge_state_battery_level', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_level', 'unit_of_measurement': '%', }) # --- @@ -818,7 +940,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_battery_range', - 'unique_id': 'VINVINVIN-charge_state_battery_range', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_battery_range', 'unit_of_measurement': , }) # --- @@ -883,7 +1005,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_conn_charge_cable', - 'unique_id': 'VINVINVIN-charge_state_conn_charge_cable', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_conn_charge_cable', 'unit_of_measurement': None, }) # --- @@ -947,7 +1069,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_energy_added', - 'unique_id': 'VINVINVIN-charge_state_charge_energy_added', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_energy_added', 'unit_of_measurement': , }) # --- @@ -1017,7 +1139,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charge_rate', - 'unique_id': 'VINVINVIN-charge_state_charge_rate', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_rate', 'unit_of_measurement': , }) # --- @@ -1084,7 +1206,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_actual_current', - 'unique_id': 'VINVINVIN-charge_state_charger_actual_current', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_actual_current', 'unit_of_measurement': , }) # --- @@ -1151,7 +1273,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_power', - 'unique_id': 'VINVINVIN-charge_state_charger_power', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_power', 'unit_of_measurement': , }) # --- @@ -1218,7 +1340,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charger_voltage', - 'unique_id': 'VINVINVIN-charge_state_charger_voltage', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charger_voltage', 'unit_of_measurement': , }) # --- @@ -1292,7 +1414,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_charging_state', - 'unique_id': 'VINVINVIN-charge_state_charging_state', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charging_state', 'unit_of_measurement': None, }) # --- @@ -1374,7 +1496,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_miles_to_arrival', - 'unique_id': 'VINVINVIN-drive_state_active_route_miles_to_arrival', + 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_miles_to_arrival', 'unit_of_measurement': , }) # --- @@ -1444,7 +1566,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_driver_temp_setting', - 'unique_id': 'VINVINVIN-climate_state_driver_temp_setting', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_driver_temp_setting', 'unit_of_measurement': , }) # --- @@ -1517,7 +1639,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_est_battery_range', - 'unique_id': 'VINVINVIN-charge_state_est_battery_range', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_est_battery_range', 'unit_of_measurement': , }) # --- @@ -1582,7 +1704,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_fast_charger_type', - 'unique_id': 'VINVINVIN-charge_state_fast_charger_type', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_fast_charger_type', 'unit_of_measurement': None, }) # --- @@ -1649,7 +1771,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_ideal_battery_range', - 'unique_id': 'VINVINVIN-charge_state_ideal_battery_range', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_ideal_battery_range', 'unit_of_measurement': , }) # --- @@ -1719,7 +1841,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_inside_temp', - 'unique_id': 'VINVINVIN-climate_state_inside_temp', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_inside_temp', 'unit_of_measurement': , }) # --- @@ -1792,7 +1914,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_odometer', - 'unique_id': 'VINVINVIN-vehicle_state_odometer', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_odometer', 'unit_of_measurement': , }) # --- @@ -1862,7 +1984,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_outside_temp', - 'unique_id': 'VINVINVIN-climate_state_outside_temp', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_outside_temp', 'unit_of_measurement': , }) # --- @@ -1932,7 +2054,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'climate_state_passenger_temp_setting', - 'unique_id': 'VINVINVIN-climate_state_passenger_temp_setting', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_passenger_temp_setting', 'unit_of_measurement': , }) # --- @@ -1999,7 +2121,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_power', - 'unique_id': 'VINVINVIN-drive_state_power', + 'unique_id': 'LRWXF7EK4KC700000-drive_state_power', 'unit_of_measurement': , }) # --- @@ -2071,7 +2193,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_shift_state', - 'unique_id': 'VINVINVIN-drive_state_shift_state', + 'unique_id': 'LRWXF7EK4KC700000-drive_state_shift_state', 'unit_of_measurement': None, }) # --- @@ -2149,7 +2271,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_speed', - 'unique_id': 'VINVINVIN-drive_state_speed', + 'unique_id': 'LRWXF7EK4KC700000-drive_state_speed', 'unit_of_measurement': , }) # --- @@ -2216,7 +2338,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_energy_at_arrival', - 'unique_id': 'VINVINVIN-drive_state_active_route_energy_at_arrival', + 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_energy_at_arrival', 'unit_of_measurement': '%', }) # --- @@ -2281,7 +2403,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_minutes_to_arrival', - 'unique_id': 'VINVINVIN-drive_state_active_route_minutes_to_arrival', + 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_minutes_to_arrival', 'unit_of_measurement': None, }) # --- @@ -2342,7 +2464,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_minutes_to_full_charge', - 'unique_id': 'VINVINVIN-charge_state_minutes_to_full_charge', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_minutes_to_full_charge', 'unit_of_measurement': None, }) # --- @@ -2411,7 +2533,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fl', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fl', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fl', 'unit_of_measurement': , }) # --- @@ -2484,7 +2606,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_fr', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_fr', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_fr', 'unit_of_measurement': , }) # --- @@ -2557,7 +2679,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rl', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rl', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rl', 'unit_of_measurement': , }) # --- @@ -2630,7 +2752,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'vehicle_state_tpms_pressure_rr', - 'unique_id': 'VINVINVIN-vehicle_state_tpms_pressure_rr', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_tpms_pressure_rr', 'unit_of_measurement': , }) # --- @@ -2697,7 +2819,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'drive_state_active_route_traffic_minutes_delay', - 'unique_id': 'VINVINVIN-drive_state_active_route_traffic_minutes_delay', + 'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_traffic_minutes_delay', 'unit_of_measurement': , }) # --- @@ -2764,7 +2886,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state_usable_battery_level', - 'unique_id': 'VINVINVIN-charge_state_usable_battery_level', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_usable_battery_level', 'unit_of_measurement': '%', }) # --- diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f55cbae6a54 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -0,0 +1,489 @@ +# serializer version: 1 +# name: test_switch[switch.energy_site_allow_charging_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.energy_site_allow_charging_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Allow charging from grid', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'components_disallow_charge_from_grid_with_solar_installed', + 'unique_id': '123456-components_disallow_charge_from_grid_with_solar_installed', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.energy_site_allow_charging_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Allow charging from grid', + }), + 'context': , + 'entity_id': 'switch.energy_site_allow_charging_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.energy_site_storm_watch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.energy_site_storm_watch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storm watch', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'user_settings_storm_mode_enabled', + 'unique_id': '123456-user_settings_storm_mode_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.energy_site_storm_watch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Storm watch', + }), + 'context': , + 'entity_id': 'switch.energy_site_storm_watch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_auto_seat_climate_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate left', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_auto_seat_climate_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate right', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_auto_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_auto_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto steering wheel heater', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_steering_wheel_heat', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_steering_wheel_heat', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_auto_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto steering wheel heater', + }), + 'context': , + 'entity_id': 'switch.test_auto_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.test_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_user_charge_enable_request', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_defrost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_defrost_mode', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_defrost_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Defrost', + }), + 'context': , + 'entity_id': 'switch.test_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.test_sentry_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_sentry_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sentry mode', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_sentry_mode', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sentry_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_sentry_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Sentry mode', + }), + 'context': , + 'entity_id': 'switch.test_sentry_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.energy_site_allow_charging_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Allow charging from grid', + }), + 'context': , + 'entity_id': 'switch.energy_site_allow_charging_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.energy_site_storm_watch-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Storm watch', + }), + 'context': , + 'entity_id': 'switch.energy_site_storm_watch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_alt[switch.test_auto_seat_climate_left-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_auto_seat_climate_right-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_auto_steering_wheel_heater-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto steering wheel heater', + }), + 'context': , + 'entity_id': 'switch.test_auto_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_charge-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_alt[switch.test_defrost-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Defrost', + }), + 'context': , + 'entity_id': 'switch.test_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_sentry_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Sentry mode', + }), + 'context': , + 'entity_id': 'switch.test_sentry_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr new file mode 100644 index 00000000000..19dac161516 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_update[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_software_update_status', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_software_update_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.44.30.8', + 'latest_version': '2024.12.0.0', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_alt[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_software_update_status', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_software_update_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_alt[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.44.30.8', + 'latest_version': '2023.44.30.8', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_binary_sensors.py b/tests/components/teslemetry/test_binary_sensors.py new file mode 100644 index 00000000000..a7a8c03c174 --- /dev/null +++ b/tests/components/teslemetry/test_binary_sensors.py @@ -0,0 +1,61 @@ +"""Test the Teslemetry binary sensor platform.""" + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, assert_entities_alt, setup_platform +from .const import VEHICLE_DATA_ALT + +from tests.common import async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the binary sensor entities are correct.""" + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_refresh( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, + freezer: FrozenDateTimeFactory, +) -> None: + """Tests that the binary sensor entities are correct.""" + + entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) + + # Refresh + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_binary_sensor_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the binary sensor entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.BINARY_SENSOR]) + state = hass.states.get("binary_sensor.test_status") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py new file mode 100644 index 00000000000..a10e3efdff2 --- /dev/null +++ b/tests/components/teslemetry/test_button.py @@ -0,0 +1,53 @@ +"""Test the Teslemetry button platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the button entities are correct.""" + + entry = await setup_platform(hass, [Platform.BUTTON]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.parametrize( + ("name", "func"), + [ + ("flash_lights", "flash_lights"), + ("honk_horn", "honk_horn"), + ("keyless_driving", "remote_start_drive"), + ("play_fart", "remote_boombox"), + ("homelink", "trigger_homelink"), + ], +) +async def test_press(hass: HomeAssistant, name: str, func: str) -> None: + """Test pressing the API buttons.""" + await setup_platform(hass, [Platform.BUTTON]) + + with patch( + f"homeassistant.components.teslemetry.VehicleSpecific.{func}", + return_value=COMMAND_OK, + ) as command: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [f"button.test_{name}"]}, + blocking=True, + ) + command.assert_called_once() diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index a05bc07b305..250413396c1 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -1,6 +1,5 @@ """Test the Teslemetry climate platform.""" -from datetime import timedelta from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -11,26 +10,37 @@ from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, HVACMode, ) -from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from . import assert_entities, setup_platform -from .const import METADATA_NOSCOPE, WAKE_UP_ASLEEP, WAKE_UP_ONLINE +from .const import ( + COMMAND_ERRORS, + COMMAND_IGNORED_REASON, + METADATA_NOSCOPE, + VEHICLE_DATA_ALT, + WAKE_UP_ASLEEP, + WAKE_UP_ONLINE, +) from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_climate( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -43,27 +53,34 @@ async def test_climate( assert_entities(hass, entry.entry_id, entity_registry, snapshot) entity_id = "climate.test_climate" - state = hass.states.get(entity_id) - # Turn On + # Turn On and Set Temp await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 20, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + }, blocking=True, ) state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20 assert state.state == HVACMode.HEAT_COOL # Set Temp await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 21, + }, blocking=True, ) state = hass.states.get(entity_id) - assert state.attributes[ATTR_TEMPERATURE] == 20 + assert state.attributes[ATTR_TEMPERATURE] == 21 # Set Preset await hass.services.async_call( @@ -75,6 +92,16 @@ async def test_climate( state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == "keep" + # Set Preset + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: "off"}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == "off" + # Turn Off await hass.services.async_call( CLIMATE_DOMAIN, @@ -85,10 +112,127 @@ async def test_climate( state = hass.states.get(entity_id) assert state.state == HVACMode.OFF + entity_id = "climate.test_cabin_overheat_protection" -async def test_errors( + # Turn On and Set Low + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 30, + ATTR_HVAC_MODE: HVACMode.FAN_ONLY, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 30 + assert state.state == HVACMode.FAN_ONLY + + # Set Temp Medium + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 35, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 35 + + # Set Temp High + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 40, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 40 + + # Turn Off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + # Turn On + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.COOL + + # Set Temp do nothing + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 30, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 40 + assert state.state == HVACMode.COOL + + # pytest raises ServiceValidationError + with pytest.raises( + ServiceValidationError, + match="Cabin overheat protection does not support that temperature", + ): + # Invalid Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_alt( hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, ) -> None: + """Tests that the climate entity is correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.CLIMATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_offline( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the climate entity is correct.""" + + mock_vehicle_data.side_effect = VehicleOffline + entry = await setup_platform(hass, [Platform.CLIMATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_invalid_error(hass: HomeAssistant) -> None: """Tests service error is handled.""" await setup_platform(hass, platforms=[Platform.CLIMATE]) @@ -107,10 +251,57 @@ async def test_errors( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) + mock_on.assert_called_once() + assert ( + str(error.value) + == "Teslemetry command failed, The data request or command is unknown." + ) + + +@pytest.mark.parametrize("response", COMMAND_ERRORS) +async def test_errors(hass: HomeAssistant, response: str) -> None: + """Tests service reason is handled.""" + + await setup_platform(hass, platforms=[Platform.CLIMATE]) + entity_id = "climate.test_climate" + + with ( + patch( + "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + return_value=response, + ) as mock_on, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_on.assert_called_once() + + +async def test_ignored_error( + hass: HomeAssistant, +) -> None: + """Tests ignored error is handled.""" + + await setup_platform(hass, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + return_value=COMMAND_IGNORED_REASON, + ) as mock_on: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) mock_on.assert_called_once() - assert error.from_exception == InvalidCommand +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_asleep_or_offline( hass: HomeAssistant, mock_vehicle_data, @@ -127,7 +318,7 @@ async def test_asleep_or_offline( # Put the vehicle alseep mock_vehicle_data.reset_mock() mock_vehicle_data.side_effect = VehicleOffline - freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() mock_vehicle_data.assert_called_once() @@ -142,7 +333,7 @@ async def test_asleep_or_offline( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert error + assert str(error.value) == "The data request or command is unknown." mock_wake_up.assert_called_once() mock_wake_up.side_effect = None @@ -152,7 +343,7 @@ async def test_asleep_or_offline( mock_wake_up.return_value = WAKE_UP_ASLEEP mock_vehicle.return_value = WAKE_UP_ASLEEP with ( - patch("homeassistant.components.teslemetry.entity.asyncio.sleep"), + patch("homeassistant.components.teslemetry.helpers.asyncio.sleep"), pytest.raises(HomeAssistantError) as error, ): await hass.services.async_call( @@ -161,7 +352,7 @@ async def test_asleep_or_offline( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert error + assert str(error.value) == "Could not wake up vehicle" mock_wake_up.assert_called_once() mock_vehicle.assert_called() diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index 2f12b202712..fa35142dc07 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -12,26 +12,18 @@ from tesla_fleet_api.exceptions import ( from homeassistant import config_entries from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import CONFIG +from .const import CONFIG, METADATA from tests.common import MockConfigEntry BAD_CONFIG = {CONF_ACCESS_TOKEN: "bad_access_token"} -@pytest.fixture(autouse=True) -def mock_test(): - """Mock Teslemetry api class.""" - with patch( - "homeassistant.components.teslemetry.Teslemetry.test", return_value=True - ) as mock_test: - yield mock_test - - async def test_form( hass: HomeAssistant, ) -> None: @@ -67,14 +59,16 @@ async def test_form( (TeslaFleetError, {"base": "unknown"}), ], ) -async def test_form_errors(hass: HomeAssistant, side_effect, error, mock_test) -> None: +async def test_form_errors( + hass: HomeAssistant, side_effect, error, mock_metadata +) -> None: """Test errors are handled.""" result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_test.side_effect = side_effect + mock_metadata.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], CONFIG, @@ -84,7 +78,7 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error, mock_test) - assert result2["errors"] == error # Complete the flow - mock_test.side_effect = None + mock_metadata.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], CONFIG, @@ -92,12 +86,11 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error, mock_test) - assert result3["type"] is FlowResultType.CREATE_ENTRY -async def test_reauth(hass: HomeAssistant, mock_test) -> None: +async def test_reauth(hass: HomeAssistant, mock_metadata) -> None: """Test reauth flow.""" mock_entry = MockConfigEntry( - domain=DOMAIN, - data=BAD_CONFIG, + domain=DOMAIN, data=BAD_CONFIG, minor_version=2, unique_id="abc-123" ) mock_entry.add_to_hass(hass) @@ -124,7 +117,7 @@ async def test_reauth(hass: HomeAssistant, mock_test) -> None: ) await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_test.mock_calls) == 1 + assert len(mock_metadata.mock_calls) == 1 assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -141,14 +134,13 @@ async def test_reauth(hass: HomeAssistant, mock_test) -> None: ], ) async def test_reauth_errors( - hass: HomeAssistant, mock_test, side_effect, error + hass: HomeAssistant, mock_metadata, side_effect, error ) -> None: """Test reauth flows that fail.""" # Start the reauth mock_entry = MockConfigEntry( - domain=DOMAIN, - data=BAD_CONFIG, + domain=DOMAIN, data=BAD_CONFIG, minor_version=2, unique_id="abc-123" ) mock_entry.add_to_hass(hass) @@ -162,7 +154,7 @@ async def test_reauth_errors( data=BAD_CONFIG, ) - mock_test.side_effect = side_effect + mock_metadata.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( result["flow_id"], BAD_CONFIG, @@ -173,7 +165,7 @@ async def test_reauth_errors( assert result2["errors"] == error # Complete the flow - mock_test.side_effect = None + mock_metadata.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], CONFIG, @@ -182,3 +174,83 @@ async def test_reauth_errors( assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" assert mock_entry.data == CONFIG + + +async def test_unique_id_abort( + hass: HomeAssistant, +) -> None: + """Test duplicate unique ID in config.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + ) + assert result1["type"] is FlowResultType.CREATE_ENTRY + + # Setup a duplicate + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONFIG + ) + assert result2["type"] is FlowResultType.ABORT + + +async def test_migrate_from_1_1(hass: HomeAssistant, mock_metadata) -> None: + """Test config migration.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=1, + unique_id=None, + data=CONFIG, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.unique_id == METADATA["uid"] + + +async def test_migrate_error_from_1_1(hass: HomeAssistant, mock_metadata) -> None: + """Test config migration handles errors.""" + + mock_metadata.side_effect = TeslaFleetError + + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=1, + unique_id=None, + data=CONFIG, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migrate_error_from_future(hass: HomeAssistant, mock_metadata) -> None: + """Test a future version isn't migrated.""" + + mock_metadata.side_effect = TeslaFleetError + + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=2, + minor_version=1, + unique_id="abc-123", + data=CONFIG, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py new file mode 100644 index 00000000000..5f99a5d9c79 --- /dev/null +++ b/tests/components/teslemetry/test_cover.py @@ -0,0 +1,188 @@ +"""Test the Teslemetry cover platform.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_CLOSED, + STATE_OPEN, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT + + +async def test_cover( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the cover entities are correct.""" + + entry = await setup_platform(hass, [Platform.COVER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_cover_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the cover entities are correct without scopes.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.COVER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_cover_noscope( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_metadata, +) -> None: + """Tests that the cover entities are correct without scopes.""" + + mock_metadata.return_value = METADATA_NOSCOPE + entry = await setup_platform(hass, [Platform.COVER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_cover_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the cover entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.COVER]) + state = hass.states.get("cover.test_windows") + assert state.state == STATE_UNKNOWN + + +async def test_cover_services( + hass: HomeAssistant, +) -> None: + """Tests that the cover entities are correct.""" + + await setup_platform(hass, [Platform.COVER]) + + # Vent Windows + entity_id = "cover.test_windows" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.window_control", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ["cover.test_windows"]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED + + # Charge Port Door + entity_id = "cover.test_charge_port_door" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED + + # Frunk + entity_id = "cover.test_frunk" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + # Trunk + entity_id = "cover.test_trunk" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py new file mode 100644 index 00000000000..55deaefdab5 --- /dev/null +++ b/tests/components/teslemetry/test_device_tracker.py @@ -0,0 +1,33 @@ +"""Test the Teslemetry device tracker platform.""" + +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform + + +async def test_device_tracker( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the device tracker entities are correct.""" + + entry = await setup_platform(hass, [Platform.DEVICE_TRACKER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_device_tracker_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the device tracker entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.DEVICE_TRACKER]) + state = hass.states.get("device_tracker.test_location") + assert state.state == STATE_UNKNOWN diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index f21a421ed6e..31b4202b521 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -1,9 +1,8 @@ -"""Test the Tessie init.""" - -from datetime import timedelta +"""Test the Teslemetry init.""" from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, @@ -11,13 +10,18 @@ from tesla_fleet_api.exceptions import ( VehicleOffline, ) -from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.components.teslemetry.coordinator import ( + VEHICLE_INTERVAL, + VEHICLE_WAIT, +) +from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_platform -from .const import WAKE_UP_ASLEEP, WAKE_UP_ONLINE +from .const import VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -33,9 +37,11 @@ async def test_load_unload(hass: HomeAssistant) -> None: entry = await setup_platform(hass) assert entry.state is ConfigEntryState.LOADED + assert isinstance(entry.runtime_data, TeslemetryData) assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED + assert not hasattr(entry, "runtime_data") @pytest.mark.parametrize(("side_effect", "state"), ERRORS) @@ -49,49 +55,19 @@ async def test_init_error( assert entry.state is state +# Test devices +async def test_devices( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion +) -> None: + """Test device registry.""" + entry = await setup_platform(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + + for device in devices: + assert device == snapshot(name=f"{device.identifiers}") + + # Vehicle Coordinator - - -async def test_vehicle_first_refresh( - hass: HomeAssistant, - mock_wake_up, - mock_vehicle_data, - mock_products, - freezer: FrozenDateTimeFactory, -) -> None: - """Test first coordinator refresh but vehicle is asleep.""" - - # Mock vehicle is asleep - mock_wake_up.return_value = WAKE_UP_ASLEEP - entry = await setup_platform(hass) - assert entry.state is ConfigEntryState.SETUP_RETRY - mock_wake_up.assert_called_once() - - # Reset mock and set vehicle to online - mock_wake_up.reset_mock() - mock_wake_up.return_value = WAKE_UP_ONLINE - - # Wait for the retry - freezer.tick(timedelta(seconds=60)) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - - # Verify we have loaded - assert entry.state is ConfigEntryState.LOADED - mock_wake_up.assert_called_once() - mock_vehicle_data.assert_called_once() - - -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) -async def test_vehicle_first_refresh_error( - hass: HomeAssistant, mock_wake_up, side_effect, state -) -> None: - """Test first coordinator refresh with an error.""" - mock_wake_up.side_effect = side_effect - entry = await setup_platform(hass) - assert entry.state is state - - async def test_vehicle_refresh_offline( hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory ) -> None: @@ -102,7 +78,7 @@ async def test_vehicle_refresh_offline( mock_vehicle_data.reset_mock() mock_vehicle_data.side_effect = VehicleOffline - freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() mock_vehicle_data.assert_called_once() @@ -118,14 +94,80 @@ async def test_vehicle_refresh_error( assert entry.state is state -# Test Energy Coordinator +async def test_vehicle_sleep( + hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory +) -> None: + """Test coordinator refresh with an error.""" + await setup_platform(hass, [Platform.CLIMATE]) + assert mock_vehicle_data.call_count == 1 + + freezer.tick(VEHICLE_WAIT + VEHICLE_INTERVAL) + async_fire_time_changed(hass) + # Let vehicle sleep, no updates for 15 minutes + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + # No polling, call_count should not increase + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + # No polling, call_count should not increase + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 2 + + freezer.tick(VEHICLE_WAIT) + async_fire_time_changed(hass) + # Vehicle didn't sleep, go back to normal + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 3 + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + # Regular polling + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 4 + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + # Vehicle active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 5 + + freezer.tick(VEHICLE_WAIT) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 6 + + freezer.tick(VEHICLE_WAIT) + async_fire_time_changed(hass) + # Dont let sleep when active + await hass.async_block_till_done() + assert mock_vehicle_data.call_count == 7 +# Test Energy Live Coordinator @pytest.mark.parametrize(("side_effect", "state"), ERRORS) -async def test_energy_refresh_error( +async def test_energy_live_refresh_error( hass: HomeAssistant, mock_live_status, side_effect, state ) -> None: """Test coordinator refresh with an error.""" mock_live_status.side_effect = side_effect entry = await setup_platform(hass) assert entry.state is state + + +# Test Energy Site Coordinator +@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +async def test_energy_site_refresh_error( + hass: HomeAssistant, mock_site_info, side_effect, state +) -> None: + """Test coordinator refresh with an error.""" + mock_site_info.side_effect = side_effect + entry = await setup_platform(hass) + assert entry.state is state diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py new file mode 100644 index 00000000000..a50e97fe6ad --- /dev/null +++ b/tests/components/teslemetry/test_lock.py @@ -0,0 +1,111 @@ +"""Test the Teslemetry lock platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_LOCKED, + STATE_UNKNOWN, + STATE_UNLOCKED, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK + + +async def test_lock( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the lock entities are correct.""" + + entry = await setup_platform(hass, [Platform.LOCK]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_lock_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the lock entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.LOCK]) + state = hass.states.get("lock.test_lock") + assert state.state == STATE_UNKNOWN + + +async def test_lock_services( + hass: HomeAssistant, +) -> None: + """Tests that the lock services work.""" + + await setup_platform(hass, [Platform.LOCK]) + + entity_id = "lock.test_lock" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.door_lock", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_LOCKED + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.door_unlock", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNLOCKED + call.assert_called_once() + + entity_id = "lock.test_charge_cable_lock" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNLOCKED + call.assert_called_once() diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py new file mode 100644 index 00000000000..8544c11a625 --- /dev/null +++ b/tests/components/teslemetry/test_media_player.py @@ -0,0 +1,152 @@ +"""Test the Teslemetry media player platform.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_VOLUME_SET, + MediaPlayerState, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, assert_entities_alt, setup_platform +from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT + + +async def test_media_player( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the media player entities are correct.""" + + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the media player entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the media player entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.MEDIA_PLAYER]) + state = hass.states.get("media_player.test_media_player") + assert state.state == MediaPlayerState.OFF + + +async def test_media_player_noscope( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_metadata, +) -> None: + """Tests that the media player entities are correct without required scope.""" + + mock_metadata.return_value = METADATA_NOSCOPE + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_services( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the media player services work.""" + + await setup_platform(hass, [Platform.MEDIA_PLAYER]) + + entity_id = "media_player.test_media_player" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.adjust_volume", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5 + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PAUSED + call.assert_called_once() + + # This test will fail without the previous call to pause playback + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PLAYING + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_next_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_prev_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once() diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py new file mode 100644 index 00000000000..728d37c4d7c --- /dev/null +++ b/tests/components/teslemetry/test_number.py @@ -0,0 +1,113 @@ +"""Test the Teslemetry number platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the number entities are correct.""" + + entry = await setup_platform(hass, [Platform.NUMBER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_number_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the number entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.NUMBER]) + state = hass.states.get("number.test_charge_current") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_services(hass: HomeAssistant, mock_vehicle_data) -> None: + """Tests that the number services work.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.NUMBER]) + + entity_id = "number.test_charge_current" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.set_charging_amps", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 16}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "16" + call.assert_called_once() + + entity_id = "number.test_charge_limit" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.set_charge_limit", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 60}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "60" + call.assert_called_once() + + entity_id = "number.energy_site_backup_reserve" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.backup", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 80, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "80" + call.assert_called_once() + + entity_id = "number.energy_site_off_grid_reserve" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 88}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "88" + call.assert_called_once() diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py new file mode 100644 index 00000000000..3b1c8c436bf --- /dev/null +++ b/tests/components/teslemetry/test_select.py @@ -0,0 +1,114 @@ +"""Test the Teslemetry select platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.teslemetry.select import LOW +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the select entities are correct.""" + + entry = await setup_platform(hass, [Platform.SELECT]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_select_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the select entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.SELECT]) + state = hass.states.get("select.test_seat_heater_front_left") + assert state.state == STATE_UNKNOWN + + +async def test_select_services(hass: HomeAssistant, mock_vehicle_data) -> None: + """Tests that the select services work.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.SELECT]) + + entity_id = "select.test_seat_heater_front_left" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.remote_seat_heater_request", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == LOW + call.assert_called_once() + + entity_id = "select.test_steering_wheel_heater" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.remote_steering_wheel_heat_level_request", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == LOW + call.assert_called_once() + + entity_id = "select.energy_site_operation_mode" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.operation", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: EnergyOperationMode.AUTONOMOUS.value, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == EnergyOperationMode.AUTONOMOUS.value + call.assert_called_once() + + entity_id = "select.energy_site_allow_export" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.grid_import_export", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: EnergyExportMode.BATTERY_OK.value}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == EnergyExportMode.BATTERY_OK.value + call.assert_called_once() diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index be541da6728..c5bdd15d712 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,12 +1,10 @@ """Test the Teslemetry sensor platform.""" -from datetime import timedelta - from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -35,7 +33,7 @@ async def test_sensors( # Coordinator refresh mock_vehicle_data.return_value = VEHICLE_DATA_ALT - freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py new file mode 100644 index 00000000000..47a2843eb8f --- /dev/null +++ b/tests/components/teslemetry/test_switch.py @@ -0,0 +1,140 @@ +"""Test the Teslemetry switch platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +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, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, assert_entities_alt, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + + +async def test_switch( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the switch entities are correct.""" + + entry = await setup_platform(hass, [Platform.SWITCH]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_switch_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the switch entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.SWITCH]) + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_switch_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the switch entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.SWITCH]) + state = hass.states.get("switch.test_auto_seat_climate_left") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("name", "on", "off"), + [ + ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ( + "test_auto_seat_climate_left", + "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleSpecific.remote_auto_seat_climate_request", + ), + ( + "test_auto_seat_climate_right", + "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleSpecific.remote_auto_seat_climate_request", + ), + ( + "test_auto_steering_wheel_heater", + "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + ), + ( + "test_defrost", + "VehicleSpecific.set_preconditioning_max", + "VehicleSpecific.set_preconditioning_max", + ), + ( + "energy_site_storm_watch", + "EnergySpecific.storm_mode", + "EnergySpecific.storm_mode", + ), + ( + "energy_site_allow_charging_from_grid", + "EnergySpecific.grid_import_export", + "EnergySpecific.grid_import_export", + ), + ( + "test_sentry_mode", + "VehicleSpecific.set_sentry_mode", + "VehicleSpecific.set_sentry_mode", + ), + ], +) +async def test_switch_services( + hass: HomeAssistant, name: str, on: str, off: str +) -> None: + """Tests that the switch service calls work.""" + + await setup_platform(hass, [Platform.SWITCH]) + + entity_id = f"switch.{name}" + with patch( + f"homeassistant.components.teslemetry.{on}", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + call.assert_called_once() + + with patch( + f"homeassistant.components.teslemetry.{off}", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + call.assert_called_once() diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py new file mode 100644 index 00000000000..62bbcc94516 --- /dev/null +++ b/tests/components/teslemetry/test_update.py @@ -0,0 +1,93 @@ +"""Test the Teslemetry update platform.""" + +import copy +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL +from homeassistant.components.teslemetry.update import INSTALLING +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT + +from tests.common import async_fire_time_changed + + +async def test_update( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the update entities are correct.""" + + entry = await setup_platform(hass, [Platform.UPDATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_update_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the update entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.UPDATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_update_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the update entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.UPDATE]) + state = hass.states.get("update.test_update") + assert state.state == STATE_UNKNOWN + + +async def test_update_services( + hass: HomeAssistant, + mock_vehicle_data, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the update services work.""" + + await setup_platform(hass, [Platform.UPDATE]) + + entity_id = "update.test_update" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.schedule_software_update", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + call.assert_called_once() + + VEHICLE_INSTALLING = copy.deepcopy(VEHICLE_DATA) + VEHICLE_INSTALLING["response"]["vehicle_state"]["software_update"]["status"] = ( + INSTALLING + ) + mock_vehicle_data.return_value = VEHICLE_INSTALLING + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["in_progress"] == 1 diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py index 2f213c4e798..c19f6f65201 100644 --- a/tests/components/tessie/common.py +++ b/tests/components/tessie/common.py @@ -7,10 +7,12 @@ from aiohttp import ClientConnectionError, ClientResponseError from aiohttp.client import RequestInfo from syrupy import SnapshotAssertion +from homeassistant.components.tessie import PLATFORMS from homeassistant.components.tessie.const import DOMAIN, TessieStatus from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry, load_json_object_fixture @@ -20,7 +22,7 @@ TEST_VEHICLE_STATUS_AWAKE = {"status": TessieStatus.AWAKE} TEST_VEHICLE_STATUS_ASLEEP = {"status": TessieStatus.ASLEEP} TEST_RESPONSE = {"result": True} -TEST_RESPONSE_ERROR = {"result": False, "reason": "reason why"} +TEST_RESPONSE_ERROR = {"result": False, "reason": "reason_why"} TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} TESSIE_URL = "https://api.tessie.com/" @@ -47,7 +49,7 @@ ERROR_CONNECTION = ClientConnectionError() async def setup_platform( - hass: HomeAssistant, platforms: list[Platform] = [], side_effect=None + hass: HomeAssistant, platforms: list[Platform] | UndefinedType = UNDEFINED ) -> MockConfigEntry: """Set up the Tessie platform.""" @@ -57,13 +59,9 @@ async def setup_platform( ) 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, - ), - patch("homeassistant.components.tessie.PLATFORMS", platforms), + with patch( + "homeassistant.components.tessie.PLATFORMS", + PLATFORMS if platforms is UNDEFINED else platforms, ): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py index f38ef6c7e3f..77d1e3fd3e2 100644 --- a/tests/components/tessie/conftest.py +++ b/tests/components/tessie/conftest.py @@ -13,7 +13,7 @@ from .common import ( ) -@pytest.fixture +@pytest.fixture(autouse=True) def mock_get_state(): """Mock get_state function.""" with patch( @@ -23,7 +23,7 @@ def mock_get_state(): yield mock_get_state -@pytest.fixture +@pytest.fixture(autouse=True) def mock_get_status(): """Mock get_status function.""" with patch( @@ -33,11 +33,11 @@ def mock_get_status(): yield mock_get_status -@pytest.fixture +@pytest.fixture(autouse=True) 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", + "homeassistant.components.tessie.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/online.json b/tests/components/tessie/fixtures/online.json index 863e9bca783..ed49b4bfd75 100644 --- a/tests/components/tessie/fixtures/online.json +++ b/tests/components/tessie/fixtures/online.json @@ -68,7 +68,7 @@ "timestamp": 1701139037461, "trip_charging": false, "usable_battery_level": 75, - "user_charge_enable_request": null + "user_charge_enable_request": true }, "climate_state": { "allow_cabin_overheat_protection": true, diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py index fa6c8358ae6..c9cfca3288a 100644 --- a/tests/components/tessie/test_button.py +++ b/tests/components/tessie/test_button.py @@ -21,14 +21,14 @@ async def test_buttons( assert_entities(hass, entry.entry_id, entity_registry, snapshot) - for entity_id, func in [ + for entity_id, func in ( ("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"), - ]: + ): with patch( f"homeassistant.components.tessie.button.{func}", ) as mock_press: diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py index df86f0b2986..bc688e1ca70 100644 --- a/tests/components/tessie/test_climate.py +++ b/tests/components/tessie/test_climate.py @@ -128,5 +128,5 @@ async def test_errors(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - mock_set.assert_called_once() - assert error.from_exception == ERROR_UNKNOWN + mock_set.assert_called_once() + assert error.value.__cause__ == ERROR_UNKNOWN diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py index ac3217f864b..f3dc98e6e18 100644 --- a/tests/components/tessie/test_config_flow.py +++ b/tests/components/tessie/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.tessie.const import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,13 +15,37 @@ from .common import ( ERROR_CONNECTION, ERROR_UNKNOWN, TEST_CONFIG, - setup_platform, + TEST_STATE_OF_ALL_VEHICLES, ) from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: +@pytest.fixture(autouse=True) +def mock_config_flow_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles in config flow.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_config_flow_get_state_of_all_vehicles: + yield mock_config_flow_get_state_of_all_vehicles + + +@pytest.fixture(autouse=True) +def mock_async_setup_entry(): + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_async_setup_entry: + yield mock_async_setup_entry + + +async def test_form( + hass: HomeAssistant, + mock_config_flow_get_state_of_all_vehicles, + mock_async_setup_entry, +) -> None: """Test we get the form.""" result1 = await hass.config_entries.flow.async_init( @@ -30,17 +54,13 @@ async def test_form(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None assert result1["type"] is 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 + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_async_setup_entry.mock_calls) == 1 + assert len(mock_config_flow_get_state_of_all_vehicles.mock_calls) == 1 assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Tessie" @@ -56,7 +76,7 @@ async def test_form(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None ], ) async def test_form_errors( - hass: HomeAssistant, side_effect, error, mock_get_state_of_all_vehicles + hass: HomeAssistant, side_effect, error, mock_config_flow_get_state_of_all_vehicles ) -> None: """Test errors are handled.""" @@ -64,7 +84,7 @@ async def test_form_errors( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_get_state_of_all_vehicles.side_effect = side_effect + mock_config_flow_get_state_of_all_vehicles.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], TEST_CONFIG, @@ -74,15 +94,20 @@ async def test_form_errors( assert result2["errors"] == error # Complete the flow - mock_get_state_of_all_vehicles.side_effect = None + mock_config_flow_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"] is FlowResultType.CREATE_ENTRY -async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: +async def test_reauth( + hass: HomeAssistant, + mock_config_flow_get_state_of_all_vehicles, + mock_async_setup_entry, +) -> None: """Test reauth flow.""" mock_entry = MockConfigEntry( @@ -104,17 +129,13 @@ async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> No 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 + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_async_setup_entry.mock_calls) == 1 + assert len(mock_config_flow_get_state_of_all_vehicles.mock_calls) == 1 assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" @@ -130,14 +151,23 @@ async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> No ], ) async def test_reauth_errors( - hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error + hass: HomeAssistant, + mock_config_flow_get_state_of_all_vehicles, + mock_async_setup_entry, + side_effect, + error, ) -> None: """Test reauth flows that fail.""" - mock_entry = await setup_platform(hass, [Platform.BINARY_SENSOR]) - mock_get_state_of_all_vehicles.side_effect = side_effect + mock_config_flow_get_state_of_all_vehicles.side_effect = side_effect - result = await hass.config_entries.flow.async_init( + 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, @@ -148,7 +178,7 @@ async def test_reauth_errors( ) result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result1["flow_id"], TEST_CONFIG, ) await hass.async_block_till_done() @@ -157,7 +187,7 @@ async def test_reauth_errors( assert result2["errors"] == error # Complete the flow - mock_get_state_of_all_vehicles.side_effect = None + mock_config_flow_get_state_of_all_vehicles.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], TEST_CONFIG, @@ -166,3 +196,4 @@ async def test_reauth_errors( assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" assert mock_entry.data == TEST_CONFIG + assert len(mock_async_setup_entry.mock_calls) == 1 diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py index ebf4c503110..b731add10f8 100644 --- a/tests/components/tessie/test_cover.py +++ b/tests/components/tessie/test_cover.py @@ -37,12 +37,12 @@ async def test_covers( assert_entities(hass, entry.entry_id, entity_registry, snapshot) - for entity_id, openfunc, closefunc in [ + for entity_id, openfunc, closefunc in ( ("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"), - ]: + ): # Test open windows if openfunc: with patch( @@ -94,8 +94,8 @@ async def test_errors(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - mock_set.assert_called_once() - assert error.from_exception == ERROR_UNKNOWN + mock_set.assert_called_once() + assert error.value.__cause__ == ERROR_UNKNOWN # Test setting cover open with unknown error with ( @@ -111,5 +111,5 @@ async def test_errors(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - mock_set.assert_called_once() - assert str(error) == TEST_RESPONSE_ERROR["reason"] + mock_set.assert_called_once() + assert str(error.value) == TEST_RESPONSE_ERROR["reason"] diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py index 68d6fcf7777..81d1d758edf 100644 --- a/tests/components/tessie/test_init.py +++ b/tests/components/tessie/test_init.py @@ -16,22 +16,31 @@ async def test_load_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED -async def test_auth_failure(hass: HomeAssistant) -> None: +async def test_auth_failure( + hass: HomeAssistant, mock_get_state_of_all_vehicles +) -> None: """Test init with an authentication error.""" - entry = await setup_platform(hass, side_effect=ERROR_AUTH) + mock_get_state_of_all_vehicles.side_effect = ERROR_AUTH + entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_unknown_failure(hass: HomeAssistant) -> None: +async def test_unknown_failure( + hass: HomeAssistant, mock_get_state_of_all_vehicles +) -> None: """Test init with an client response error.""" - entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN) + mock_get_state_of_all_vehicles.side_effect = ERROR_UNKNOWN + entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_connection_failure(hass: HomeAssistant) -> None: +async def test_connection_failure( + hass: HomeAssistant, mock_get_state_of_all_vehicles +) -> None: """Test init with a network connection error.""" - entry = await setup_platform(hass, side_effect=ERROR_CONNECTION) + mock_get_state_of_all_vehicles.side_effect = ERROR_CONNECTION + entry = await setup_platform(hass) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index 0371b592f07..cfb6168b399 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -14,8 +14,7 @@ from homeassistant.components.lock import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry +from homeassistant.helpers import entity_registry as er, issue_registry as ir from .common import DOMAIN, assert_entities, setup_platform @@ -86,12 +85,11 @@ async def test_locks( async def test_speed_limit_lock( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Tests that the deprecated speed limit lock entity is correct.""" - - issue_registry = async_get_issue_registry(hass) - # Create the deprecated speed limit lock entity entity = entity_registry.async_get_or_create( LOCK_DOMAIN, diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py index 7f79dbe3297..f9526bf0a47 100644 --- a/tests/components/tessie/test_select.py +++ b/tests/components/tessie/test_select.py @@ -66,5 +66,5 @@ async def test_errors(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, blocking=True, ) - mock_set.assert_called_once() - assert error.from_exception == ERROR_UNKNOWN + mock_set.assert_called_once() + assert error.value.__cause__ == ERROR_UNKNOWN diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index 29e030b034e..5766e5dce2a 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -100,7 +100,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["set_value"] + for action in ("set_value",) ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index deacf029ced..8e20af6cb7a 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -20,12 +20,13 @@ from homeassistant.core import HomeAssistant, ServiceCall, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component +from .common import MockRestoreText, MockTextEntity + from tests.common import ( async_mock_restore_state_shutdown_restart, mock_restore_cache_with_extra_data, setup_test_component_platform, ) -from tests.components.text.common import MockRestoreText, MockTextEntity async def test_text_default(hass: HomeAssistant) -> None: diff --git a/tests/components/thermobeacon/conftest.py b/tests/components/thermobeacon/conftest.py index ca17cdbfe4c..c4eda1318aa 100644 --- a/tests/components/thermobeacon/conftest.py +++ b/tests/components/thermobeacon/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/thermopro/conftest.py b/tests/components/thermopro/conftest.py index 1a4c59ff609..445f52b7844 100644 --- a/tests/components/thermopro/conftest.py +++ b/tests/components/thermopro/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/thethingsnetwork/__init__.py b/tests/components/thethingsnetwork/__init__.py new file mode 100644 index 00000000000..be42f1d1f14 --- /dev/null +++ b/tests/components/thethingsnetwork/__init__.py @@ -0,0 +1,10 @@ +"""Define tests for the The Things Network.""" + +from homeassistant.core import HomeAssistant + + +async def init_integration(hass: HomeAssistant, config_entry) -> None: + """Mock TTNClient.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/thethingsnetwork/conftest.py b/tests/components/thethingsnetwork/conftest.py new file mode 100644 index 00000000000..02bec3a0f9e --- /dev/null +++ b/tests/components/thethingsnetwork/conftest.py @@ -0,0 +1,95 @@ +"""Define fixtures for the The Things Network tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from ttn_client import TTNSensorValue + +from homeassistant.components.thethingsnetwork.const import ( + CONF_APP_ID, + DOMAIN, + TTN_API_HOST, +) +from homeassistant.const import CONF_API_KEY, CONF_HOST + +from tests.common import MockConfigEntry + +HOST = "example.com" +APP_ID = "my_app" +API_KEY = "my_api_key" + +DEVICE_ID = "my_device" +DEVICE_ID_2 = "my_device_2" +DEVICE_FIELD = "a_field" +DEVICE_FIELD_2 = "a_field_2" +DEVICE_FIELD_VALUE = 42 + +DATA = { + DEVICE_ID: { + DEVICE_FIELD: TTNSensorValue( + { + "end_device_ids": {"device_id": DEVICE_ID}, + "received_at": "2024-03-11T08:49:11.153738893Z", + }, + DEVICE_FIELD, + DEVICE_FIELD_VALUE, + ) + } +} + +DATA_UPDATE = { + DEVICE_ID: { + DEVICE_FIELD: TTNSensorValue( + { + "end_device_ids": {"device_id": DEVICE_ID}, + "received_at": "2024-03-12T08:49:11.153738893Z", + }, + DEVICE_FIELD, + DEVICE_FIELD_VALUE, + ) + }, + DEVICE_ID_2: { + DEVICE_FIELD_2: TTNSensorValue( + { + "end_device_ids": {"device_id": DEVICE_ID_2}, + "received_at": "2024-03-12T08:49:11.153738893Z", + }, + DEVICE_FIELD_2, + DEVICE_FIELD_VALUE, + ) + }, +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=APP_ID, + title=APP_ID, + data={ + CONF_APP_ID: APP_ID, + CONF_HOST: TTN_API_HOST, + CONF_API_KEY: API_KEY, + }, + ) + + +@pytest.fixture +def mock_ttnclient(): + """Mock TTNClient.""" + + with ( + patch( + "homeassistant.components.thethingsnetwork.coordinator.TTNClient", + autospec=True, + ) as ttn_client, + patch( + "homeassistant.components.thethingsnetwork.config_flow.TTNClient", + new=ttn_client, + ), + ): + instance = ttn_client.return_value + instance.fetch_data = AsyncMock(return_value=DATA) + yield ttn_client diff --git a/tests/components/thethingsnetwork/test_config_flow.py b/tests/components/thethingsnetwork/test_config_flow.py new file mode 100644 index 00000000000..107d84e099b --- /dev/null +++ b/tests/components/thethingsnetwork/test_config_flow.py @@ -0,0 +1,132 @@ +"""Define tests for the The Things Network onfig flows.""" + +import pytest +from ttn_client import TTNAuthError + +from homeassistant.components.thethingsnetwork.const import CONF_APP_ID, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import init_integration +from .conftest import API_KEY, APP_ID, HOST + +USER_DATA = {CONF_HOST: HOST, CONF_APP_ID: APP_ID, CONF_API_KEY: API_KEY} + + +async def test_user(hass: HomeAssistant, mock_ttnclient) -> None: + """Test user config.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == APP_ID + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_APP_ID] == APP_ID + assert result["data"][CONF_API_KEY] == API_KEY + + +@pytest.mark.parametrize( + ("fetch_data_exception", "base_error"), + [(TTNAuthError, "invalid_auth"), (Exception, "unknown")], +) +async def test_user_errors( + hass: HomeAssistant, fetch_data_exception, base_error, mock_ttnclient +) -> None: + """Test user config errors.""" + + # Test error + mock_ttnclient.return_value.fetch_data.side_effect = fetch_data_exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert base_error in result["errors"]["base"] + + # Recover + mock_ttnclient.return_value.fetch_data.side_effect = None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_ttnclient, mock_config_entry +) -> None: + """Test that duplicate entries are caught.""" + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_step_reauth( + hass: HomeAssistant, mock_ttnclient, mock_config_entry +) -> None: + """Test that the reauth step works.""" + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": APP_ID, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + new_api_key = "1234" + new_user_input = dict(USER_DATA) + new_user_input[CONF_API_KEY] = new_api_key + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=new_user_input + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert hass.config_entries.async_entries()[0].data[CONF_API_KEY] == new_api_key diff --git a/tests/components/thethingsnetwork/test_init.py b/tests/components/thethingsnetwork/test_init.py new file mode 100644 index 00000000000..1e0b64c933d --- /dev/null +++ b/tests/components/thethingsnetwork/test_init.py @@ -0,0 +1,33 @@ +"""Define tests for the The Things Network init.""" + +import pytest +from ttn_client import TTNAuthError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from .conftest import DOMAIN + + +async def test_error_configuration( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test issue is logged when deprecated configuration is used.""" + await async_setup_component( + hass, DOMAIN, {DOMAIN: {"app_id": "123", "access_key": "42"}} + ) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, "manual_migration") + + +@pytest.mark.parametrize(("exception_class"), [TTNAuthError, Exception]) +async def test_init_exceptions( + hass: HomeAssistant, mock_ttnclient, exception_class, mock_config_entry +) -> None: + """Test TTN Exceptions.""" + + mock_ttnclient.return_value.fetch_data.side_effect = exception_class + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/thethingsnetwork/test_sensor.py b/tests/components/thethingsnetwork/test_sensor.py new file mode 100644 index 00000000000..91583ec6289 --- /dev/null +++ b/tests/components/thethingsnetwork/test_sensor.py @@ -0,0 +1,43 @@ +"""Define tests for the The Things Network sensor.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import init_integration +from .conftest import ( + APP_ID, + DATA_UPDATE, + DEVICE_FIELD, + DEVICE_FIELD_2, + DEVICE_ID, + DEVICE_ID_2, + DOMAIN, +) + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_ttnclient, + mock_config_entry, +) -> None: + """Test a working configurations.""" + + await init_integration(hass, mock_config_entry) + + # Check devices + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{APP_ID}_{DEVICE_ID}")} + ).name + == DEVICE_ID + ) + + # Check entities + assert entity_registry.async_get(f"sensor.{DEVICE_ID}_{DEVICE_FIELD}") + + assert not entity_registry.async_get(f"sensor.{DEVICE_ID_2}_{DEVICE_FIELD}") + push_callback = mock_ttnclient.call_args.kwargs["push_callback"] + await push_callback(DATA_UPDATE) + assert entity_registry.async_get(f"sensor.{DEVICE_ID_2}_{DEVICE_FIELD_2}") diff --git a/tests/components/thread/conftest.py b/tests/components/thread/conftest.py index 2b0f00a097f..1230d379b82 100644 --- a/tests/components/thread/conftest.py +++ b/tests/components/thread/conftest.py @@ -1,5 +1,7 @@ """Test fixtures for the Thread integration.""" +from unittest.mock import MagicMock + import pytest from homeassistant.components import thread @@ -24,5 +26,5 @@ async def thread_config_entry_fixture(hass: HomeAssistant): @pytest.fixture(autouse=True) -def use_mocked_zeroconf(mock_async_zeroconf): +def use_mocked_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Mock zeroconf in all tests.""" diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index a0d85fc6cea..4bec9aea011 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -2,7 +2,7 @@ import asyncio from typing import Any -from unittest.mock import ANY, AsyncMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest from python_otbr_api.tlv_parser import TLVError @@ -213,7 +213,9 @@ async def test_add_bad_dataset(hass: HomeAssistant, dataset, error) -> None: await dataset_store.async_add_dataset(hass, "test", dataset) -async def test_update_dataset_newer(hass: HomeAssistant, caplog) -> None: +async def test_update_dataset_newer( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test updating a dataset.""" await dataset_store.async_add_dataset(hass, "test", DATASET_1) await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) @@ -232,7 +234,9 @@ async def test_update_dataset_newer(hass: HomeAssistant, caplog) -> None: ) -async def test_update_dataset_older(hass: HomeAssistant, caplog) -> None: +async def test_update_dataset_older( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test updating a dataset.""" await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) await dataset_store.async_add_dataset(hass, "test", DATASET_1) @@ -354,7 +358,7 @@ async def test_loading_datasets_from_storage( async def test_migrate_drop_bad_datasets( - hass: HomeAssistant, hass_storage: dict[str, Any], caplog + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test migrating the dataset store when the store has bad datasets.""" hass_storage[dataset_store.STORAGE_KEY] = { @@ -398,7 +402,7 @@ async def test_migrate_drop_bad_datasets( async def test_migrate_drop_bad_datasets_preferred( - hass: HomeAssistant, hass_storage: dict[str, Any], caplog + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test migrating the dataset store when the store has bad datasets.""" hass_storage[dataset_store.STORAGE_KEY] = { @@ -429,7 +433,7 @@ async def test_migrate_drop_bad_datasets_preferred( async def test_migrate_drop_duplicate_datasets( - hass: HomeAssistant, hass_storage: dict[str, Any], caplog + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test migrating the dataset store when the store has duplicated datasets.""" hass_storage[dataset_store.STORAGE_KEY] = { @@ -466,7 +470,7 @@ async def test_migrate_drop_duplicate_datasets( async def test_migrate_drop_duplicate_datasets_2( - hass: HomeAssistant, hass_storage: dict[str, Any], caplog + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test migrating the dataset store when the store has duplicated datasets.""" hass_storage[dataset_store.STORAGE_KEY] = { @@ -503,7 +507,7 @@ async def test_migrate_drop_duplicate_datasets_2( async def test_migrate_drop_duplicate_datasets_preferred( - hass: HomeAssistant, hass_storage: dict[str, Any], caplog + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test migrating the dataset store when the store has duplicated datasets.""" hass_storage[dataset_store.STORAGE_KEY] = { @@ -540,7 +544,7 @@ async def test_migrate_drop_duplicate_datasets_preferred( async def test_migrate_set_default_border_agent_id( - hass: HomeAssistant, hass_storage: dict[str, Any], caplog + hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test migrating the dataset store adds default border agent.""" hass_storage[dataset_store.STORAGE_KEY] = { @@ -706,7 +710,7 @@ async def test_set_preferred_extended_address(hass: HomeAssistant) -> None: async def test_automatically_set_preferred_dataset( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test automatically setting the first dataset as the preferred dataset.""" add_service_listener_called = asyncio.Event() @@ -771,7 +775,7 @@ async def test_automatically_set_preferred_dataset( async def test_automatically_set_preferred_dataset_own_and_other_router( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test automatically setting the first dataset as the preferred dataset. @@ -850,7 +854,7 @@ async def test_automatically_set_preferred_dataset_own_and_other_router( async def test_automatically_set_preferred_dataset_other_router( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test automatically setting the first dataset as the preferred dataset. @@ -918,7 +922,7 @@ async def test_automatically_set_preferred_dataset_other_router( async def test_automatically_set_preferred_dataset_no_router( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test automatically setting the first dataset as the preferred dataset. diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py index 15ab0750316..ce86ba3532c 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -1,7 +1,7 @@ """Test the thread websocket API.""" import dataclasses -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -182,7 +182,7 @@ def ndb() -> Mock: async def test_diagnostics( hass: HomeAssistant, - mock_async_zeroconf: None, + mock_async_zeroconf: MagicMock, ndb: Mock, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index bdfd0390b9a..d9895aa72b2 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -1,6 +1,6 @@ """Test the thread websocket API.""" -from unittest.mock import ANY, AsyncMock, Mock +from unittest.mock import ANY, AsyncMock, MagicMock, Mock import pytest from zeroconf.asyncio import AsyncServiceInfo @@ -24,7 +24,9 @@ from . import ( ) -async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +async def test_discover_routers( + hass: HomeAssistant, mock_async_zeroconf: MagicMock +) -> None: """Test discovering thread routers.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() mock_async_zeroconf.async_remove_service_listener = AsyncMock() @@ -151,7 +153,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) ], ) async def test_discover_routers_unconfigured( - hass: HomeAssistant, mock_async_zeroconf: None, data, unconfigured + hass: HomeAssistant, mock_async_zeroconf: MagicMock, data, unconfigured ) -> None: """Test discovering thread routers and setting the unconfigured flag.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() @@ -197,7 +199,7 @@ async def test_discover_routers_unconfigured( "data", [ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_MISSING_DATA] ) async def test_discover_routers_bad_or_missing_optional_data( - hass: HomeAssistant, mock_async_zeroconf: None, data + hass: HomeAssistant, mock_async_zeroconf: MagicMock, data ) -> None: """Test discovering thread routers with bad or missing vendor mDNS data.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() @@ -247,7 +249,7 @@ async def test_discover_routers_bad_or_missing_optional_data( ], ) async def test_discover_routers_bad_or_missing_mandatory_data( - hass: HomeAssistant, mock_async_zeroconf: None, service + hass: HomeAssistant, mock_async_zeroconf: MagicMock, service ) -> None: """Test discovering thread routers with missing mandatory mDNS data.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() @@ -281,7 +283,7 @@ async def test_discover_routers_bad_or_missing_mandatory_data( async def test_discover_routers_get_service_info_fails( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test discovering thread routers with invalid mDNS data.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() @@ -311,7 +313,7 @@ async def test_discover_routers_get_service_info_fails( async def test_discover_routers_update_unchanged( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test discovering thread routers with identical mDNS data in update.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() @@ -353,7 +355,7 @@ async def test_discover_routers_update_unchanged( async def test_discover_routers_stop_twice( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test discovering thread routers stopping discovery twice.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index b277dcafcf4..f3390a9d8b8 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -1,6 +1,6 @@ """Test the thread websocket API.""" -from unittest.mock import ANY, AsyncMock +from unittest.mock import ANY, AsyncMock, MagicMock from zeroconf.asyncio import AsyncServiceInfo @@ -315,7 +315,9 @@ async def test_set_preferred_dataset_wrong_id( async def test_discover_routers( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_async_zeroconf: None + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_async_zeroconf: MagicMock, ) -> None: """Test discovering thread routers.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() diff --git a/tests/components/threshold/snapshots/test_config_flow.ambr b/tests/components/threshold/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..d6b4489c930 --- /dev/null +++ b/tests/components/threshold/snapshots/test_config_flow.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_config_flow_preview_success[missing_entity_id] + dict({ + 'attributes': dict({ + 'friendly_name': '', + }), + 'state': 'unavailable', + }) +# --- +# name: test_config_flow_preview_success[missing_upper_lower] + dict({ + 'attributes': dict({ + 'friendly_name': 'Test Sensor', + }), + 'state': 'unavailable', + }) +# --- +# name: test_config_flow_preview_success[success] + dict({ + 'attributes': dict({ + 'entity_id': 'sensor.test_monitored', + 'friendly_name': 'Test Sensor', + 'hysteresis': 0.0, + 'lower': 20.0, + 'position': 'below', + 'sensor_value': 16.0, + 'type': 'lower', + 'upper': None, + }), + 'state': 'on', + }) +# --- +# name: test_options_flow_preview + dict({ + 'attributes': dict({ + 'entity_id': 'sensor.test_monitored', + 'friendly_name': 'Test Sensor', + 'hysteresis': 0.0, + 'lower': 20.0, + 'position': 'below', + 'sensor_value': 16.0, + 'type': 'lower', + 'upper': None, + }), + 'state': 'on', + }) +# --- diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index c4b1dad78d5..53a8446c210 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -591,11 +591,12 @@ async def test_sensor_no_lower_upper( assert "Lower or Upper thresholds not provided" in caplog.text -async def test_device_id(hass: HomeAssistant) -> None: +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for source entity device for Threshold.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - source_config_entry = MockConfigEntry() source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( diff --git a/tests/components/threshold/test_config_flow.py b/tests/components/threshold/test_config_flow.py index 88c970d5c2c..c13717800bf 100644 --- a/tests/components/threshold/test_config_flow.py +++ b/tests/components/threshold/test_config_flow.py @@ -3,13 +3,16 @@ from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion from homeassistant import config_entries from homeassistant.components.threshold.const import DOMAIN +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_config_flow(hass: HomeAssistant) -> None: @@ -93,7 +96,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") async def test_options(hass: HomeAssistant) -> None: @@ -129,6 +132,7 @@ async def test_options(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ + "entity_id": input_sensor, "hysteresis": 0.0, "upper": 20.0, }, @@ -161,3 +165,183 @@ async def test_options(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.my_threshold") assert state.state == "off" assert state.attributes["type"] == "upper" + + +@pytest.mark.parametrize( + "user_input", + [ + ( + { + "name": "Test Sensor", + "entity_id": "sensor.test_monitored", + "hysteresis": 0.0, + "lower": 20.0, + } + ), + ( + { + "name": "Test Sensor", + "entity_id": "sensor.test_monitored", + "hysteresis": 0.0, + } + ), + ( + { + "name": "", + "entity_id": "", + "hysteresis": 0.0, + "lower": 20.0, + } + ), + ], + ids=("success", "missing_upper_lower", "missing_entity_id"), +) +async def test_config_flow_preview_success( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + user_input: str, + snapshot: SnapshotAssertion, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + # add state for the tests + hass.states.async_set( + "sensor.test_monitored", + 16, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["preview"] == "threshold" + + await client.send_json_auto_id( + { + "type": "threshold/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == snapshot + assert len(hass.states.async_all()) == 1 + + +async def test_options_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the options flow preview.""" + client = await hass_ws_client(hass) + + # add state for the tests + hass.states.async_set( + "sensor.test_monitored", + 16, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test_monitored", + "hysteresis": 0.0, + "lower": 20.0, + "name": "Test Sensor", + "upper": None, + }, + title="Test Sensor", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "threshold" + + await client.send_json_auto_id( + { + "type": "threshold/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "name": "Test Sensor", + "entity_id": "sensor.test_monitored", + "hysteresis": 0.0, + "lower": 20.0, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == snapshot + assert len(hass.states.async_all()) == 2 + + +async def test_options_flow_sensor_preview_config_entry_removed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test_monitored", + "hysteresis": 0.0, + "lower": 20.0, + "name": "Test Sensor", + "upper": None, + }, + title="Test Sensor", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "threshold" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "threshold/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "name": "Test Sensor", + "entity_id": "sensor.test_monitored", + "hysteresis": 0.0, + "lower": 20.0, + }, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "home_assistant_error", + "message": "Config entry not found", + } diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 86b580c47f5..6e85d659922 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -4,7 +4,7 @@ import pytest from homeassistant.components.threshold.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -12,6 +12,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("platform", ["binary_sensor"]) async def test_setup_and_remove_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, platform: str, ) -> None: """Test setting up and removing a config entry.""" @@ -19,7 +20,6 @@ async def test_setup_and_remove_config_entry( input_sensor = "sensor.input" - registry = er.async_get(hass) threshold_entity_id = f"{platform}.input_threshold" # Setup the config entry @@ -40,10 +40,11 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(threshold_entity_id) is not None + assert entity_registry.async_get(threshold_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(threshold_entity_id) + assert state assert state.state == "on" assert state.attributes["entity_id"] == input_sensor assert state.attributes["hysteresis"] == 0.0 @@ -59,4 +60,151 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert hass.states.get(threshold_entity_id) is None - assert registry.async_get(threshold_entity_id) is None + assert entity_registry.async_get(threshold_entity_id) is None + + +@pytest.mark.parametrize("platform", ["sensor"]) +async def test_entry_changed(hass: HomeAssistant, platform) -> None: + """Test reconfiguring.""" + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + + def _create_mock_entity(domain: str, name: str) -> er.RegistryEntry: + config_entry = MockConfigEntry( + data={}, + domain="test", + title=f"{name}", + ) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + identifiers={("test", name)}, config_entry_id=config_entry.entry_id + ) + return entity_registry.async_get_or_create( + domain, "test", name, suggested_object_id=name, device_id=device_entry.id + ) + + def _get_device_config_entries(entry: er.RegistryEntry) -> set[str]: + assert entry.device_id + device = device_registry.async_get(entry.device_id) + assert device + return device.config_entries + + # Set up entities, with backing devices and config entries + run1_entry = _create_mock_entity("sensor", "initial") + run2_entry = _create_mock_entity("sensor", "changed") + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.initial", + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My integration", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.entry_id in _get_device_config_entries(run1_entry) + assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + + hass.config_entries.async_update_entry( + config_entry, options={**config_entry.options, "entity_id": "sensor.changed"} + ) + await hass.async_block_till_done() + + # Check that the config entry association has updated + assert config_entry.entry_id not in _get_device_config_entries(run1_entry) + assert config_entry.entry_id in _get_device_config_entries(run2_entry) + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for source entity device for Threshold.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + # Configure the configuration entry for Threshold + threshold_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test_source", + "hysteresis": 0.0, + "lower": -2.0, + "name": "Threshold", + "upper": None, + }, + title="Threshold", + ) + threshold_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the threshold sensor + threshold_entity = entity_registry.async_get("binary_sensor.threshold") + assert threshold_entity is not None + assert threshold_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to Threshold config entry + device_registry.async_get_or_create( + config_entry_id=threshold_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=threshold_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, two devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + threshold_config_entry.entry_id + ) + assert len(devices_before_reload) == 3 + + # Config entry reload + await hass.config_entries.async_reload(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the threshold sensor after reload + threshold_entity = entity_registry.async_get("binary_sensor.threshold") + assert threshold_entity is not None + assert threshold_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + threshold_config_entry.entry_id + ) + assert len(devices_after_reload) == 1 diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index da3f3df1bd9..fc6596444c5 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -1,15 +1,19 @@ """Test helpers for Tibber.""" +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + import pytest from homeassistant.components.tibber.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @pytest.fixture -def config_entry(hass): +def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Tibber config entry.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -18,3 +22,24 @@ def config_entry(hass): ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture +async def mock_tibber_setup( + config_entry: MockConfigEntry, hass: HomeAssistant +) -> AsyncGenerator[None, MagicMock]: + """Mock tibber entry setup.""" + unique_user_id = "unique_user_id" + title = "title" + + tibber_mock = MagicMock() + tibber_mock.update_info = AsyncMock(return_value=True) + tibber_mock.user_id = PropertyMock(return_value=unique_user_id) + tibber_mock.name = PropertyMock(return_value=title) + tibber_mock.send_notification = AsyncMock() + tibber_mock.rt_disconnect = AsyncMock() + + with patch("tibber.Tibber", return_value=tibber_mock): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + yield tibber_mock diff --git a/tests/components/tibber/test_init.py b/tests/components/tibber/test_init.py new file mode 100644 index 00000000000..dcc23307050 --- /dev/null +++ b/tests/components/tibber/test_init.py @@ -0,0 +1,21 @@ +"""Test loading of the Tibber config entry.""" + +from unittest.mock import MagicMock + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + + +async def test_entry_unload( + recorder_mock: Recorder, hass: HomeAssistant, mock_tibber_setup: MagicMock +) -> None: + """Test unloading the entry.""" + entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "tibber") + assert entry.state == ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + mock_tibber_setup.rt_disconnect.assert_called_once() + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/tibber/test_notify.py b/tests/components/tibber/test_notify.py new file mode 100644 index 00000000000..69af92c4d5d --- /dev/null +++ b/tests/components/tibber/test_notify.py @@ -0,0 +1,67 @@ +"""Tests for tibber notification service.""" + +from asyncio import TimeoutError +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + + +async def test_notification_services( + recorder_mock: Recorder, hass: HomeAssistant, mock_tibber_setup: MagicMock +) -> None: + """Test create entry from user input.""" + # Assert notify entity has been added + notify_state = hass.states.get("notify.tibber") + assert notify_state is not None + + # Assert legacy notify service hass been added + assert hass.services.has_service("notify", DOMAIN) + + # Test legacy notify service + service = "tibber" + service_data = {"message": "The message", "title": "A title"} + await hass.services.async_call("notify", service, service_data, blocking=True) + calls: MagicMock = mock_tibber_setup.send_notification + + calls.assert_called_once_with(message="The message", title="A title") + calls.reset_mock() + + # Test notify entity service + service = "send_message" + service_data = { + "entity_id": "notify.tibber", + "message": "The message", + "title": "A title", + } + await hass.services.async_call("notify", service, service_data, blocking=True) + calls.assert_called_once_with("A title", "The message") + calls.reset_mock() + + calls.side_effect = TimeoutError + + with pytest.raises(HomeAssistantError): + # Test legacy notify service + await hass.services.async_call( + "notify", + service="tibber", + service_data={"message": "The message", "title": "A title"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + # Test notify entity service + await hass.services.async_call( + "notify", + service="send_message", + service_data={ + "entity_id": "notify.tibber", + "message": "The message", + "title": "A title", + }, + blocking=True, + ) diff --git a/tests/components/tibber/test_repairs.py b/tests/components/tibber/test_repairs.py new file mode 100644 index 00000000000..89e85e5f8e1 --- /dev/null +++ b/tests/components/tibber/test_repairs.py @@ -0,0 +1,66 @@ +"""Test loading of the Tibber config entry.""" + +from http import HTTPStatus +from unittest.mock import MagicMock + +from homeassistant.components.recorder import Recorder +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.typing import ClientSessionGenerator + + +async def test_repair_flow( + recorder_mock: Recorder, + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_tibber_setup: MagicMock, + hass_client: ClientSessionGenerator, +) -> None: + """Test unloading the entry.""" + + # Test legacy notify service + service = "tibber" + service_data = {"message": "The message", "title": "A title"} + await hass.services.async_call("notify", service, service_data, blocking=True) + calls: MagicMock = mock_tibber_setup.send_notification + + calls.assert_called_once_with(message="The message", title="A title") + calls.reset_mock() + + http_client = await hass_client() + # Assert the issue is present + assert issue_registry.async_get_issue( + domain="notify", + issue_id=f"migrate_notify_tibber_{service}", + ) + assert len(issue_registry.issues) == 1 + + url = RepairsFlowIndexView.url + resp = await http_client.post( + url, json={"handler": "notify", "issue_id": f"migrate_notify_tibber_{service}"} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Simulate the users confirmed the repair flow + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue( + domain="notify", + issue_id=f"migrate_notify_tibber_{service}", + ) + assert len(issue_registry.issues) == 0 diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py new file mode 100644 index 00000000000..fe437e421d7 --- /dev/null +++ b/tests/components/tibber/test_services.py @@ -0,0 +1,254 @@ +"""Test service for Tibber integration.""" + +import asyncio +import datetime as dt +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.tibber.const import DOMAIN +from homeassistant.components.tibber.services import PRICE_SERVICE_NAME, __get_prices +from homeassistant.core import ServiceCall +from homeassistant.exceptions import ServiceValidationError + + +def generate_mock_home_data(): + """Create mock data from the tibber connection.""" + today = remove_microseconds(dt.datetime.now()) + tomorrow = remove_microseconds(today + dt.timedelta(days=1)) + mock_homes = [ + MagicMock( + name="first_home", + info={ + "viewer": { + "home": { + "currentSubscription": { + "priceInfo": { + "today": [ + { + "startsAt": today.isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "startsAt": ( + today + dt.timedelta(hours=1) + ).isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + "tomorrow": [ + { + "startsAt": tomorrow.isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "startsAt": ( + tomorrow + dt.timedelta(hours=1) + ).isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + } + } + } + } + }, + ), + MagicMock( + name="second_home", + info={ + "viewer": { + "home": { + "currentSubscription": { + "priceInfo": { + "today": [ + { + "startsAt": today.isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "startsAt": ( + today + dt.timedelta(hours=1) + ).isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + "tomorrow": [ + { + "startsAt": tomorrow.isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "startsAt": ( + tomorrow + dt.timedelta(hours=1) + ).isoformat(), + "total": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + } + } + } + } + }, + ), + ] + mock_homes[0].name = "first_home" + mock_homes[1].name = "second_home" + return mock_homes + + +def create_mock_tibber_connection(): + """Create a mock tibber connection.""" + tibber_connection = MagicMock() + tibber_connection.get_homes.return_value = generate_mock_home_data() + return tibber_connection + + +def create_mock_hass(): + """Create a mock hass object.""" + mock_hass = MagicMock + mock_hass.data = {"tibber": create_mock_tibber_connection()} + return mock_hass + + +def remove_microseconds(dt): + """Remove microseconds from a datetime object.""" + return dt.replace(microsecond=0) + + +async def test_get_prices(): + """Test __get_prices with mock data.""" + today = remove_microseconds(dt.datetime.now()) + tomorrow = remove_microseconds(dt.datetime.now() + dt.timedelta(days=1)) + call = ServiceCall( + DOMAIN, + PRICE_SERVICE_NAME, + {"start": today.date().isoformat(), "end": tomorrow.date().isoformat()}, + ) + + result = await __get_prices(call, hass=create_mock_hass()) + + assert result == { + "prices": { + "first_home": [ + { + "start_time": today, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": today + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + "second_home": [ + { + "start_time": today, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": today + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + } + } + + +async def test_get_prices_no_input(): + """Test __get_prices with no input.""" + today = remove_microseconds(dt.datetime.now()) + call = ServiceCall(DOMAIN, PRICE_SERVICE_NAME, {}) + + result = await __get_prices(call, hass=create_mock_hass()) + + assert result == { + "prices": { + "first_home": [ + { + "start_time": today, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": today + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + "second_home": [ + { + "start_time": today, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": today + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + } + } + + +async def test_get_prices_start_tomorrow(): + """Test __get_prices with start date tomorrow.""" + tomorrow = remove_microseconds(dt.datetime.now() + dt.timedelta(days=1)) + call = ServiceCall( + DOMAIN, PRICE_SERVICE_NAME, {"start": tomorrow.date().isoformat()} + ) + + result = await __get_prices(call, hass=create_mock_hass()) + + assert result == { + "prices": { + "first_home": [ + { + "start_time": tomorrow, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": tomorrow + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + "second_home": [ + { + "start_time": tomorrow, + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + { + "start_time": tomorrow + dt.timedelta(hours=1), + "price": 0.46914, + "level": "VERY_EXPENSIVE", + }, + ], + } + } + + +async def test_get_prices_invalid_input(): + """Test __get_prices with invalid input.""" + + call = ServiceCall(DOMAIN, PRICE_SERVICE_NAME, {"start": "test"}) + task = asyncio.create_task(__get_prices(call, hass=create_mock_hass())) + + with pytest.raises(ServiceValidationError) as excinfo: + await task + + assert "Invalid datetime provided." in str(excinfo.value) diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index d6c510a8785..d817c9612aa 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.statistics import statistics_during_period -from homeassistant.components.tibber.sensor import TibberDataCoordinator +from homeassistant.components.tibber.coordinator import TibberDataCoordinator from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util diff --git a/tests/components/tilt_ble/conftest.py b/tests/components/tilt_ble/conftest.py index 552b41d10da..248e23d4c6b 100644 --- a/tests/components/tilt_ble/conftest.py +++ b/tests/components/tilt_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/time/test_init.py b/tests/components/time/test_init.py index 0f0dbe05e5b..f616570f956 100644 --- a/tests/components/time/test_init.py +++ b/tests/components/time/test_init.py @@ -12,8 +12,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .common import MockTimeEntity + from tests.common import setup_test_component_platform -from tests.components.time.common import MockTimeEntity async def test_date(hass: HomeAssistant) -> None: diff --git a/tests/components/time_date/conftest.py b/tests/components/time_date/conftest.py index 72363dcdf9e..4bcaa887b6f 100644 --- a/tests/components/time_date/conftest.py +++ b/tests/components/time_date/conftest.py @@ -1,13 +1,13 @@ """Fixtures for Time & Date integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.time_date.async_setup_entry", return_value=True diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index d7e87b3a471..ddeec48b3d2 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -5,10 +5,9 @@ from unittest.mock import ANY, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.time_date.const import DOMAIN, OPTION_TYPES +from homeassistant.components.time_date.const import OPTION_TYPES from homeassistant.core import HomeAssistant -from homeassistant.helpers import event, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.helpers import event import homeassistant.util.dt as dt_util from . import load_int @@ -25,11 +24,6 @@ from tests.common import async_fire_time_changed dt_util.utc_from_timestamp(45.5), dt_util.utc_from_timestamp(60), ), - ( - "beat", - dt_util.parse_datetime("2020-11-13 00:00:29+01:00"), - dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00"), - ), ( "date_time", dt_util.utc_from_timestamp(1495068899), @@ -51,7 +45,7 @@ async def test_intervals( tracked_time, ) -> None: """Test timing intervals of sensors when time zone is UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") freezer.move_to(start_time) await load_int(hass, display_option) @@ -61,7 +55,7 @@ async def test_intervals( async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test states of sensors.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) @@ -83,9 +77,6 @@ async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No state = hass.states.get("sensor.date_time_utc") assert state.state == "2017-05-18, 00:54" - state = hass.states.get("sensor.internet_time") - assert state.state == "@079" - state = hass.states.get("sensor.date_time_iso") assert state.state == "2017-05-18T00:54:00" @@ -110,9 +101,6 @@ async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No state = hass.states.get("sensor.date_time_utc") assert state.state == "2020-10-17, 16:42" - state = hass.states.get("sensor.internet_time") - assert state.state == "@738" - state = hass.states.get("sensor.date_time_iso") assert state.state == "2020-10-17T16:42:00" @@ -121,7 +109,7 @@ async def test_states_non_default_timezone( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test states of sensors in a timezone other than UTC.""" - hass.config.set_time_zone("America/New_York") + await hass.config.async_set_time_zone("America/New_York") now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) @@ -143,9 +131,6 @@ async def test_states_non_default_timezone( state = hass.states.get("sensor.date_time_utc") assert state.state == "2017-05-18, 00:54" - state = hass.states.get("sensor.internet_time") - assert state.state == "@079" - state = hass.states.get("sensor.date_time_iso") assert state.state == "2017-05-17T20:54:00" @@ -170,9 +155,6 @@ async def test_states_non_default_timezone( state = hass.states.get("sensor.date_time_utc") assert state.state == "2020-10-17, 16:42" - state = hass.states.get("sensor.internet_time") - assert state.state == "@738" - state = hass.states.get("sensor.date_time_iso") assert state.state == "2020-10-17T12:42:00" @@ -195,9 +177,6 @@ async def test_states_non_default_timezone( state = hass.states.get("sensor.date_time_utc") assert state.state == "2020-10-17, 16:42" - state = hass.states.get("sensor.internet_time") - assert state.state == "@738" - state = hass.states.get("sensor.date_time_iso") assert state.state == "2020-10-17T18:42:00" @@ -254,7 +233,7 @@ async def test_timezone_intervals( tracked_time, ) -> None: """Test timing intervals of sensors in timezone other than UTC.""" - hass.config.set_time_zone(time_zone) + await hass.config.async_set_time_zone(time_zone) freezer.move_to(start_time) await load_int(hass, "date") @@ -280,48 +259,5 @@ async def test_icons(hass: HomeAssistant) -> None: assert state.attributes["icon"] == "mdi:calendar-clock" state = hass.states.get("sensor.date_time_utc") assert state.attributes["icon"] == "mdi:calendar-clock" - state = hass.states.get("sensor.internet_time") - assert state.attributes["icon"] == "mdi:clock" state = hass.states.get("sensor.date_time_iso") assert state.attributes["icon"] == "mdi:calendar-clock" - - -@pytest.mark.parametrize( - ( - "display_options", - "expected_warnings", - "expected_issues", - ), - [ - (["time", "date"], [], []), - (["beat"], ["'beat': is deprecated"], ["deprecated_beat"]), - (["time", "beat"], ["'beat': is deprecated"], ["deprecated_beat"]), - ], -) -async def test_deprecation_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - display_options: list[str], - expected_warnings: list[str], - expected_issues: list[str], -) -> None: - """Test deprecation warning for swatch beat.""" - config = { - "sensor": { - "platform": "time_date", - "display_options": display_options, - } - } - - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - warnings = [record for record in caplog.records if record.levelname == "WARNING"] - assert len(warnings) == len(expected_warnings) - for expected_warning in expected_warnings: - assert any(expected_warning in warning.message for warning in warnings) - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == len(expected_issues) - for expected_issue in expected_issues: - assert (DOMAIN, expected_issue) in issue_registry.issues diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index c1c9f56094b..95baa07eaa9 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from typing import Any from unittest.mock import patch import pytest @@ -59,7 +60,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture -def storage_setup(hass: HomeAssistant, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): @@ -307,7 +308,6 @@ async def test_start_service(hass: HomeAssistant) -> None: {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10}, blocking=True, ) - await hass.async_block_till_done() await hass.services.async_call( DOMAIN, @@ -476,11 +476,13 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non async def test_config_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) _LOGGER.debug("ENTITIES @ start: %s", hass.states.async_entity_ids()) @@ -508,9 +510,9 @@ async def test_config_reload( assert state_1 is not None assert state_2 is not None assert state_3 is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None assert state_1.state == STATUS_IDLE assert ATTR_ICON not in state_1.attributes @@ -559,9 +561,9 @@ async def test_config_reload( assert state_1 is None assert state_2 is not None assert state_3 is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_1") is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_2") is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None assert state_2.state == STATUS_IDLE assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World reloaded" @@ -729,18 +731,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() timer_id = "from_storage" timer_entity_id = f"{DOMAIN}.{DOMAIN}_{timer_id}" - ent_reg = er.async_get(hass) state = hass.states.get(timer_entity_id) assert state is not None - from_reg = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, timer_id) + from_reg = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) assert from_reg == timer_entity_id client = await hass_ws_client(hass) @@ -753,11 +757,14 @@ async def test_ws_delete( state = hass.states.get(timer_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, timer_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test updating timer entity.""" @@ -765,11 +772,12 @@ async def test_update( timer_id = "from_storage" timer_entity_id = f"{DOMAIN}.{DOMAIN}_{timer_id}" - ent_reg = er.async_get(hass) state = hass.states.get(timer_entity_id) assert state.attributes[ATTR_FRIENDLY_NAME] == "timer from storage" - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id + ) client = await hass_ws_client(hass) @@ -801,18 +809,20 @@ async def test_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) timer_id = "new_timer" timer_entity_id = f"{DOMAIN}.{timer_id}" - ent_reg = er.async_get(hass) state = hass.states.get(timer_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, timer_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) is None client = await hass_ws_client(hass) @@ -830,7 +840,9 @@ async def test_ws_create( state = hass.states.get(timer_entity_id) assert state.state == STATUS_IDLE assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(42)) - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id + assert ( + entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id + ) async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -> None: diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 1a2e1ad9849..c4b28b527cb 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -22,11 +22,11 @@ def hass_time_zone(): @pytest.fixture(autouse=True) -def setup_fixture(hass, hass_time_zone): +async def setup_fixture(hass, hass_time_zone): """Set up things to be run when tests are started.""" hass.config.latitude = 50.27583 hass.config.longitude = 18.98583 - hass.config.set_time_zone(hass_time_zone) + await hass.config.async_set_time_zone(hass_time_zone) @pytest.fixture @@ -658,7 +658,9 @@ async def test_dst1( assert state.state == STATE_OFF -async def test_dst2(hass, freezer, hass_tz_info): +async def test_dst2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test DST when there's a time switch in the East.""" hass.config.time_zone = "CET" dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) @@ -684,7 +686,9 @@ async def test_dst2(hass, freezer, hass_tz_info): assert state.state == STATE_OFF -async def test_dst3(hass, freezer, hass_tz_info): +async def test_dst3( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test DST when there's a time switch forward in the West.""" hass.config.time_zone = "US/Pacific" dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) @@ -712,7 +716,9 @@ async def test_dst3(hass, freezer, hass_tz_info): assert state.state == STATE_OFF -async def test_dst4(hass, freezer, hass_tz_info): +async def test_dst4( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test DST when there's a time switch backward in the West.""" hass.config.time_zone = "US/Pacific" dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) @@ -1004,7 +1010,9 @@ async def test_simple_before_after_does_not_loop_berlin_in_range( assert state.attributes["next_update"] == "2019-01-11T06:00:00+01:00" -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique id.""" config = { "binary_sensor": [ @@ -1020,7 +1028,6 @@ async def test_unique_id(hass: HomeAssistant) -> None: await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() - entity_reg = er.async_get(hass) - entity = entity_reg.async_get("binary_sensor.evening") + entity = entity_registry.async_get("binary_sensor.evening") assert entity.unique_id == "very_unique_id" diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py index 15c0229c653..81f10061774 100644 --- a/tests/components/tod/test_config_flow.py +++ b/tests/components/tod/test_config_flow.py @@ -63,7 +63,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") @pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) diff --git a/tests/components/tod/test_init.py b/tests/components/tod/test_init.py index 4a9f55bdec3..d2ef7b14eaa 100644 --- a/tests/components/tod/test_init.py +++ b/tests/components/tod/test_init.py @@ -10,9 +10,10 @@ from tests.common import MockConfigEntry @pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) -async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test setting up and removing a config entry.""" - registry = er.async_get(hass) tod_entity_id = "binary_sensor.my_tod" # Setup the config entry @@ -31,7 +32,7 @@ async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check the entity is registered in the entity registry - assert registry.async_get(tod_entity_id) is not None + assert entity_registry.async_get(tod_entity_id) is not None # Check the platform is setup correctly state = hass.states.get(tod_entity_id) @@ -47,4 +48,4 @@ async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: # Check the state and entity registry entry are removed assert hass.states.get(tod_entity_id) is None - assert registry.async_get(tod_entity_id) is None + assert entity_registry.async_get(tod_entity_id) is None diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 95024b71757..5999b4b9fbe 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -1,14 +1,16 @@ """Tests for the todo integration.""" -from collections.abc import Generator import datetime from typing import Any from unittest.mock import AsyncMock import zoneinfo import pytest +from typing_extensions import Generator import voluptuous as vol +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.todo import ( DOMAIN, TodoItem, @@ -23,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -75,7 +78,7 @@ class MockTodoListEntity(TodoListEntity): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -91,7 +94,7 @@ def mock_setup_integration(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True async def async_unload_entry_init( @@ -113,9 +116,9 @@ def mock_setup_integration(hass: HomeAssistant) -> None: @pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant) -> None: +async def set_time_zone(hass: HomeAssistant) -> None: """Set the time zone for the tests that keesp UTC-6 all year round.""" - hass.config.set_time_zone("America/Regina") + await hass.config.async_set_time_zone("America/Regina") async def create_mock_platform( @@ -1110,6 +1113,7 @@ async def test_add_item_intent( hass_ws_client: WebSocketGenerator, ) -> None: """Test adding items to lists using an intent.""" + assert await async_setup_component(hass, "homeassistant", {}) await todo_intent.async_setup_intents(hass) entity1 = MockTodoListEntity() @@ -1128,6 +1132,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "beer"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1143,6 +1148,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "cheese"}, "name": {"value": "List 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1157,6 +1163,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "lIST 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1165,13 +1172,46 @@ async def test_add_item_intent( assert entity2.items[1].summary == "wine" assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION + # Should fail if lists are not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + # Missing list - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError): await intent.async_handle( hass, "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + assistant=conversation.DOMAIN, + ) + + # Fail with empty name/item + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": ""}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, ) diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 4968b6beefb..386385a0ddb 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -1,6 +1,5 @@ """Common fixtures for the todoist tests.""" -from collections.abc import Generator from http import HTTPStatus from unittest.mock import AsyncMock, patch @@ -8,6 +7,7 @@ import pytest from requests.exceptions import HTTPError from requests.models import Response from todoist_api_python.models import Collaborator, Due, Label, Project, Task +from typing_extensions import Generator from homeassistant.components.todoist import DOMAIN from homeassistant.const import CONF_TOKEN, Platform @@ -24,7 +24,7 @@ TODAY = dt_util.now().strftime("%Y-%m-%d") @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.todoist.async_setup_entry", return_value=True diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index ddffd879d46..8ba4da9b2e8 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch import urllib import zoneinfo +from freezegun.api import FrozenDateTimeFactory import pytest from todoist_api_python.models import Due @@ -42,9 +43,9 @@ def platforms() -> list[Platform]: @pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant): +async def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" - hass.config.set_time_zone(TZ_NAME) + await hass.config.async_set_time_zone(TZ_NAME) def get_events_url(entity: str, start: str, end: str) -> str: @@ -146,6 +147,7 @@ async def test_update_entity_for_custom_project_no_due_date_on( ) async def test_update_entity_for_calendar_with_due_date_in_the_future( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, api: AsyncMock, ) -> None: """Test that a task with a due date in the future has on state and correct end_time.""" diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index 373eb0158ea..2aabfcc5755 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -23,9 +23,9 @@ def platforms() -> list[Platform]: @pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant) -> None: +async def set_time_zone(hass: HomeAssistant) -> None: """Set the time zone for the tests that keesp UTC-6 all year round.""" - hass.config.set_time_zone("America/Regina") + await hass.config.async_set_time_zone("America/Regina") @pytest.mark.parametrize( diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index b0e2fba3123..43b0e33aed4 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.components.tomorrowio.sensor import TomorrowioSensorEntityDes from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -103,7 +103,7 @@ V4_FIELDS = [ @callback def _enable_entity(hass: HomeAssistant, entity_name: str) -> None: """Enable disabled entity.""" - ent_reg = async_get(hass) + ent_reg = er.async_get(hass) entry = ent_reg.async_get(entity_name) updated_entry = ent_reg.async_update_entity(entry.entity_id, disabled_by=None) assert updated_entry != entry diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 6f5117df9d5..4443c654929 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -36,14 +36,12 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_NAME from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util from .const import API_V4_ENTRY_DATA @@ -55,7 +53,7 @@ from tests.typing import WebSocketGenerator @callback def _enable_entity(hass: HomeAssistant, entity_name: str) -> None: """Enable disabled entity.""" - ent_reg = async_get(hass) + ent_reg = er.async_get(hass) entry = ent_reg.async_get(entity_name) updated_entry = ent_reg.async_update_entity(entry.entity_id, disabled_by=None) assert updated_entry != entry @@ -116,22 +114,24 @@ async def _setup_legacy(hass: HomeAssistant, config: dict[str, Any]) -> State: return hass.states.get("weather.tomorrow_io_daily") -async def test_new_config_entry(hass: HomeAssistant) -> None: +async def test_new_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) await _setup(hass, API_V4_ENTRY_DATA) assert len(hass.states.async_entity_ids("weather")) == 1 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 28 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 28 -async def test_legacy_config_entry(hass: HomeAssistant) -> None: +async def test_legacy_config_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the expected entities are created.""" - registry = er.async_get(hass) data = _get_config_schema(hass, SOURCE_USER)(API_V4_ENTRY_DATA) for entity_name in ("hourly", "nowcast"): - registry.async_get_or_create( + entity_registry.async_get_or_create( WEATHER_DOMAIN, DOMAIN, f"{_get_unique_id(hass, data)}_{entity_name}", @@ -140,7 +140,7 @@ async def test_legacy_config_entry(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids("weather")) == 3 entry = hass.config_entries.async_entries()[0] - assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 30 + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 30 async def test_v4_weather(hass: HomeAssistant, tomorrowio_config_entry_update) -> None: @@ -242,10 +242,7 @@ async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) @freeze_time(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) async def test_v4_forecast_service( @@ -271,37 +268,6 @@ async def test_v4_forecast_service( assert response == snapshot -async def test_legacy_v4_bad_forecast( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - tomorrowio_config_entry_update, - snapshot: SnapshotAssertion, -) -> None: - """Test bad forecast data.""" - freezer.move_to(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) - - weather_state = await _setup(hass, API_V4_ENTRY_DATA) - entity_id = weather_state.entity_id - hourly_forecast = tomorrowio_config_entry_update.return_value["forecasts"]["hourly"] - hourly_forecast[0]["values"]["precipitationProbability"] = "blah" - - # Trigger data refetch - freezer.tick(timedelta(minutes=32) + timedelta(seconds=1)) - await hass.async_block_till_done() - - response = await hass.services.async_call( - WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, - { - "entity_id": entity_id, - "type": "hourly", - }, - blocking=True, - return_response=True, - ) - assert response["forecast"][0]["precipitation_probability"] is None - - async def test_v4_bad_forecast( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py index 7bda813e447..588924b416f 100644 --- a/tests/components/toon/test_config_flow.py +++ b/tests/components/toon/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +import pytest from toonapi import Agreement, ToonError from homeassistant.components.toon.const import CONF_AGREEMENT, CONF_MIGRATE, DOMAIN @@ -45,11 +46,11 @@ async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: assert result["reason"] == "missing_configuration" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow_implementation( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test registering an integration and finishing flow works.""" await setup_component(hass) @@ -111,11 +112,11 @@ async def test_full_flow_implementation( } +@pytest.mark.usefixtures("current_request_with_host") async def test_no_agreements( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test abort when there are no displays.""" await setup_component(hass) @@ -153,11 +154,11 @@ async def test_no_agreements( assert result3["reason"] == "no_agreements" +@pytest.mark.usefixtures("current_request_with_host") async def test_multiple_agreements( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test abort when there are no displays.""" await setup_component(hass) @@ -205,11 +206,11 @@ async def test_multiple_agreements( assert result4["data"]["agreement_id"] == 1 +@pytest.mark.usefixtures("current_request_with_host") async def test_agreement_already_set_up( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test showing display form again if display already exists.""" await setup_component(hass) @@ -248,11 +249,11 @@ async def test_agreement_already_set_up( assert result3["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_toon_abort( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test we abort on Toon error.""" await setup_component(hass) @@ -290,7 +291,8 @@ async def test_toon_abort( assert result2["reason"] == "connection_error" -async def test_import(hass: HomeAssistant, current_request_with_host: None) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_import(hass: HomeAssistant) -> None: """Test if importing step works.""" await setup_component(hass) @@ -304,11 +306,11 @@ async def test_import(hass: HomeAssistant, current_request_with_host: None) -> N assert result["reason"] == "already_in_progress" +@pytest.mark.usefixtures("current_request_with_host") async def test_import_migration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test if importing step with migration works.""" old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1) diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index 8261cd74859..0b8b8bb79ac 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -37,7 +37,7 @@ 'attributes': ReadOnlyDict({ 'ac_loss': False, 'changed_by': None, - 'code_arm_required': True, + 'code_arm_required': False, 'code_format': None, 'cover_tampered': False, 'friendly_name': 'test', @@ -95,7 +95,7 @@ 'attributes': ReadOnlyDict({ 'ac_loss': False, 'changed_by': None, - 'code_arm_required': True, + 'code_arm_required': False, 'code_format': None, 'cover_tampered': False, 'friendly_name': 'test Partition 2', diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py index 03b08316be2..80de004be1d 100644 --- a/tests/components/totalconnect/test_button.py +++ b/tests/components/totalconnect/test_button.py @@ -48,7 +48,7 @@ async def test_bypass_button(hass: HomeAssistant, entity_id: str) -> None: service_data={ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert mock_request.call_count == 1 + assert mock_request.call_count == 1 # try to bypass, works this time await hass.services.async_call( diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 7e7e6961b91..88da9b699a7 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,10 +1,10 @@ """tplink conftest.""" -from collections.abc import Generator import copy from unittest.mock import DEFAULT, AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.tplink import DOMAIN from homeassistant.core import HomeAssistant @@ -84,13 +84,8 @@ def entity_reg_fixture(hass): return mock_registry(hass) -@pytest.fixture(autouse=True) -def tplink_mock_get_source_ip(mock_get_source_ip): - """Mock network util's async_get_source_ip.""" - - @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch.multiple( async_setup=DEFAULT, @@ -102,7 +97,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_init() -> Generator[AsyncMock, None, None]: +def mock_init() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch.multiple( "homeassistant.components.tplink", diff --git a/tests/components/tplink/test_diagnostics.py b/tests/components/tplink/test_diagnostics.py index bda5b143a6a..3543cf95572 100644 --- a/tests/components/tplink/test_diagnostics.py +++ b/tests/components/tplink/test_diagnostics.py @@ -38,7 +38,7 @@ async def test_diagnostics( fixture_file: str, sysinfo_vars: list[str], expected_oui: str | None, -): +) -> None: """Test diagnostics for config entry.""" diagnostics_data = json.loads(load_fixture(fixture_file, "tplink")) diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index b8f623ac6dc..481a9e0e2b3 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -213,7 +213,7 @@ async def test_config_entry_device_config_invalid( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, - caplog, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that an invalid device config logs an error and loads the config entry.""" entry_data = copy.deepcopy(CREATE_ENTRY_DATA_AUTH) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 1217a4d4cca..9f352e7ffc4 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -45,7 +45,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -58,7 +60,6 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "light.my_bulb" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == "AABBCCDDEEFF" diff --git a/tests/components/tplink/test_sensor.py b/tests/components/tplink/test_sensor.py index 15bc23837fa..43884083483 100644 --- a/tests/components/tplink/test_sensor.py +++ b/tests/components/tplink/test_sensor.py @@ -118,7 +118,9 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None: assert hass.states.get(sensor_entity_id) is None -async def test_sensor_unique_id(hass: HomeAssistant) -> None: +async def test_sensor_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a sensor unique ids.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -145,6 +147,5 @@ async def test_sensor_unique_id(hass: HomeAssistant) -> None: "sensor.my_plug_voltage": "aa:bb:cc:dd:ee:ff_voltage", "sensor.my_plug_current": "aa:bb:cc:dd:ee:ff_current_a", } - entity_registry = er.async_get(hass) for sensor_entity_id, value in expected.items(): assert entity_registry.async_get(sensor_entity_id).unique_id == value diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index 6fb841346a1..02913e0c37e 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -101,7 +101,9 @@ async def test_led_switch(hass: HomeAssistant, dev, domain: str) -> None: dev.set_led.reset_mock() -async def test_plug_unique_id(hass: HomeAssistant) -> None: +async def test_plug_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a plug unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -113,7 +115,6 @@ async def test_plug_unique_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() entity_id = "switch.my_plug" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == "aa:bb:cc:dd:ee:ff" @@ -187,7 +188,9 @@ async def test_strip(hass: HomeAssistant) -> None: strip.children[1].turn_on.reset_mock() -async def test_strip_unique_ids(hass: HomeAssistant) -> None: +async def test_strip_unique_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a strip unique id.""" already_migrated_config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS @@ -200,7 +203,6 @@ async def test_strip_unique_ids(hass: HomeAssistant) -> None: for plug_id in range(2): entity_id = f"switch.my_strip_plug{plug_id}" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"PLUG{plug_id}DEVICEID" ) diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py index afedaa2df3c..c29fcb633e4 100644 --- a/tests/components/tplink_omada/conftest.py +++ b/tests/components/tplink_omada/conftest.py @@ -1,16 +1,23 @@ """Test fixtures for TP-Link Omada integration.""" -from collections.abc import Generator +from collections.abc import AsyncIterable import json from unittest.mock import AsyncMock, MagicMock, patch import pytest +from tplink_omada_client.clients import ( + OmadaConnectedClient, + OmadaNetworkClient, + OmadaWiredClient, + OmadaWirelessClient, +) from tplink_omada_client.devices import ( OmadaGateway, OmadaListDevice, OmadaSwitch, OmadaSwitchPortDetails, ) +from typing_extensions import Generator from homeassistant.components.tplink_omada.config_flow import CONF_SITE from homeassistant.components.tplink_omada.const import DOMAIN @@ -38,7 +45,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.tplink_omada.async_setup_entry", return_value=True @@ -47,35 +54,86 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_omada_site_client() -> Generator[AsyncMock, None, None]: +def mock_omada_site_client() -> Generator[AsyncMock]: """Mock Omada site client.""" - site_client = AsyncMock() + site_client = MagicMock() gateway_data = json.loads(load_fixture("gateway-TL-ER7212PC.json", DOMAIN)) gateway = OmadaGateway(gateway_data) - site_client.get_gateway.return_value = gateway + site_client.get_gateway = AsyncMock(return_value=gateway) switch1_data = json.loads(load_fixture("switch-TL-SG3210XHP-M2.json", DOMAIN)) switch1 = OmadaSwitch(switch1_data) - site_client.get_switches.return_value = [switch1] + site_client.get_switches = AsyncMock(return_value=[switch1]) devices_data = json.loads(load_fixture("devices.json", DOMAIN)) devices = [OmadaListDevice(d) for d in devices_data] - site_client.get_devices.return_value = devices + site_client.get_devices = AsyncMock(return_value=devices) switch1_ports_data = json.loads( load_fixture("switch-ports-TL-SG3210XHP-M2.json", DOMAIN) ) switch1_ports = [OmadaSwitchPortDetails(p) for p in switch1_ports_data] - site_client.get_switch_ports.return_value = switch1_ports + site_client.get_switch_ports = AsyncMock(return_value=switch1_ports) + async def async_empty() -> AsyncIterable: + for c in (): + yield c + + site_client.get_known_clients.return_value = async_empty() + site_client.get_connected_clients.return_value = async_empty() return site_client @pytest.fixture -def mock_omada_client( - mock_omada_site_client: AsyncMock, -) -> Generator[MagicMock, None, None]: +def mock_omada_clients_only_site_client() -> Generator[AsyncMock]: + """Mock Omada site client containing only client connection data.""" + site_client = MagicMock() + + site_client.get_switches = AsyncMock(return_value=[]) + site_client.get_devices = AsyncMock(return_value=[]) + site_client.get_switch_ports = AsyncMock(return_value=[]) + site_client.get_client = AsyncMock(side_effect=_get_mock_client) + + site_client.get_known_clients.side_effect = _get_mock_known_clients + site_client.get_connected_clients.side_effect = _get_mock_connected_clients + + return site_client + + +async def _get_mock_known_clients() -> AsyncIterable[OmadaNetworkClient]: + """Mock known clients of the Omada network.""" + known_clients_data = json.loads(load_fixture("known-clients.json", DOMAIN)) + for c in known_clients_data: + if c["wireless"]: + yield OmadaWirelessClient(c) + else: + yield OmadaWiredClient(c) + + +async def _get_mock_connected_clients() -> AsyncIterable[OmadaConnectedClient]: + """Mock connected clients of the Omada network.""" + connected_clients_data = json.loads(load_fixture("connected-clients.json", DOMAIN)) + for c in connected_clients_data: + if c["wireless"]: + yield OmadaWirelessClient(c) + else: + yield OmadaWiredClient(c) + + +def _get_mock_client(mac: str) -> OmadaNetworkClient: + """Mock an Omada client.""" + connected_clients_data = json.loads(load_fixture("connected-clients.json", DOMAIN)) + + for c in connected_clients_data: + if c["mac"] == mac: + if c["wireless"]: + return OmadaWirelessClient(c) + return OmadaWiredClient(c) + + +@pytest.fixture +def mock_omada_client(mock_omada_site_client: AsyncMock) -> Generator[MagicMock]: """Mock Omada client.""" with patch( "homeassistant.components.tplink_omada.create_omada_client", @@ -87,13 +145,39 @@ def mock_omada_client( yield client +@pytest.fixture +def mock_omada_clients_only_client( + mock_omada_clients_only_site_client: AsyncMock, +) -> Generator[MagicMock]: + """Mock Omada client.""" + with patch( + "homeassistant.components.tplink_omada.create_omada_client", + autospec=True, + ) as client_mock: + client = client_mock.return_value + + client.get_site_client.return_value = mock_omada_clients_only_site_client + yield client + + @pytest.fixture async def init_integration( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, mock_omada_client: MagicMock, ) -> MockConfigEntry: """Set up the TP-Link Omada integration for testing.""" + mock_config_entry = MockConfigEntry( + title="Test Omada Controller", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "mocked-password", + CONF_USERNAME: "mocked-user", + CONF_VERIFY_SSL: False, + CONF_SITE: "Default", + }, + unique_id="12345", + ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/tplink_omada/fixtures/connected-clients.json b/tests/components/tplink_omada/fixtures/connected-clients.json new file mode 100644 index 00000000000..3139db7d4df --- /dev/null +++ b/tests/components/tplink_omada/fixtures/connected-clients.json @@ -0,0 +1,120 @@ +[ + { + "mac": "16-32-50-ED-FB-15", + "name": "16-32-50-ED-FB-15", + "deviceType": "unknown", + "ip": "192.168.1.177", + "connectType": 1, + "connectDevType": "ap", + "connectedToWirelessRouter": false, + "wireless": true, + "ssid": "OFFICE_SSID", + "signalLevel": 62, + "healthScore": -1, + "signalRank": 4, + "wifiMode": 4, + "apName": "Office", + "apMac": "E8-48-B8-7E-C7-1A", + "radioId": 0, + "channel": 1, + "rxRate": 65000, + "txRate": 72000, + "powerSave": false, + "rssi": -65, + "snr": 30, + "stackableSwitch": false, + "vid": 0, + "activity": 96, + "trafficDown": 25412800785, + "trafficUp": 1636427981, + "uptime": 621441, + "lastSeen": 1713109713169, + "authStatus": 0, + "guest": false, + "active": true, + "manager": false, + "downPacket": 30179275, + "upPacket": 14288106, + "support5g2": false, + "multiLink": [] + }, + { + "mac": "2E-DC-E1-C4-37-D3", + "name": "Apple", + "deviceType": "unknown", + "ip": "192.168.1.192", + "connectType": 1, + "connectDevType": "ap", + "connectedToWirelessRouter": false, + "wireless": true, + "ssid": "ROAMING_SSID", + "signalLevel": 67, + "healthScore": -1, + "signalRank": 4, + "wifiMode": 5, + "apName": "Spare Room", + "apMac": "C0-C9-E3-4B-AF-0E", + "radioId": 1, + "channel": 44, + "rxRate": 7000, + "txRate": 390000, + "powerSave": false, + "rssi": -63, + "snr": 32, + "stackableSwitch": false, + "vid": 0, + "activity": 0, + "trafficDown": 3327229, + "trafficUp": 746841, + "uptime": 2091, + "lastSeen": 1713109728764, + "authStatus": 0, + "guest": false, + "active": true, + "manager": false, + "downPacket": 5128, + "upPacket": 3611, + "support5g2": false, + "multiLink": [] + }, + { + "mac": "2C-71-FF-ED-34-83", + "name": "Banana", + "hostName": "testhost", + "deviceType": "unknown", + "ip": "192.168.1.102", + "connectType": 1, + "connectDevType": "ap", + "connectedToWirelessRouter": false, + "wireless": true, + "ssid": "ROAMING_SSID", + "signalLevel": 57, + "healthScore": -1, + "signalRank": 3, + "wifiMode": 5, + "apName": "Living Room", + "apMac": "C0-C9-E3-4B-A7-FE", + "radioId": 1, + "channel": 36, + "rxRate": 6000, + "txRate": 390000, + "powerSave": false, + "rssi": -67, + "snr": 28, + "stackableSwitch": false, + "vid": 0, + "activity": 39, + "trafficDown": 407300090, + "trafficUp": 94910187, + "uptime": 621461, + "lastSeen": 1713109729576, + "authStatus": 0, + "guest": false, + "active": true, + "manager": false, + "downPacket": 477858, + "upPacket": 501956, + "support5g2": false, + "multiLink": [] + } +] diff --git a/tests/components/tplink_omada/fixtures/known-clients.json b/tests/components/tplink_omada/fixtures/known-clients.json new file mode 100644 index 00000000000..31d951fab50 --- /dev/null +++ b/tests/components/tplink_omada/fixtures/known-clients.json @@ -0,0 +1,67 @@ +[ + { + "name": "16-32-50-ED-FB-15", + "mac": "16-32-50-ED-FB-15", + "wireless": true, + "guest": false, + "download": 259310931013, + "upload": 43957031162, + "duration": 6832173, + "lastSeen": 1712488285622, + "block": false, + "manager": false, + "lockToAp": false + }, + { + "name": "Banana", + "mac": "2C-71-FF-ED-34-83", + "wireless": true, + "guest": false, + "download": 22093851790, + "upload": 6961197401, + "duration": 16192898, + "lastSeen": 1712488285767, + "block": false, + "manager": false, + "lockToAp": false + }, + { + "name": "Pear", + "mac": "2C-D2-6B-BA-9C-94", + "wireless": true, + "guest": false, + "download": 0, + "upload": 0, + "duration": 23, + "lastSeen": 1713083620997, + "block": false, + "manager": false, + "lockToAp": false + }, + { + "name": "Apple", + "mac": "2E-DC-E1-C4-37-D3", + "wireless": true, + "guest": false, + "download": 1366833567, + "upload": 30126947, + "duration": 60255, + "lastSeen": 1713107649827, + "block": false, + "manager": false, + "lockToAp": false + }, + { + "name": "32-39-24-B1-67-23", + "mac": "32-39-24-B1-67-23", + "wireless": false, + "guest": false, + "download": 1621140542, + "upload": 433306522, + "duration": 60571, + "lastSeen": 1713107438528, + "block": false, + "manager": false, + "lockToAp": false + } +] diff --git a/tests/components/tplink_omada/snapshots/test_device_tracker.ambr b/tests/components/tplink_omada/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..8adc4c26f12 --- /dev/null +++ b/tests/components/tplink_omada/snapshots/test_device_tracker.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_scanner_created + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Banana', + 'host_name': 'testhost', + 'ip': '192.168.1.102', + 'mac': '2C-71-FF-ED-34-83', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.banana', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'home', + }) +# --- +# name: test_device_scanner_update_to_away_nulls_properties + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Banana', + 'mac': '2C-71-FF-ED-34-83', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.banana', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/tplink_omada/test_device_tracker.py b/tests/components/tplink_omada/test_device_tracker.py new file mode 100644 index 00000000000..199789b87d5 --- /dev/null +++ b/tests/components/tplink_omada/test_device_tracker.py @@ -0,0 +1,117 @@ +"""Tests for TP-Link Omada device tracker entities.""" + +from collections.abc import AsyncIterable +from datetime import timedelta +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion +from tplink_omada_client.clients import OmadaConnectedClient + +from homeassistant.components.tplink_omada.const import DOMAIN +from homeassistant.components.tplink_omada.coordinator import POLL_CLIENTS +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + +UPDATE_INTERVAL = timedelta(seconds=10) +POLL_INTERVAL = timedelta(seconds=POLL_CLIENTS + 10) + +MOCK_ENTRY_DATA = { + "host": "https://fake.omada.host", + "verify_ssl": True, + "site": "SiteId", + "username": "test-username", + "password": "test-password", +} + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_omada_clients_only_client: MagicMock, +) -> MockConfigEntry: + """Set up the TP-Link Omada integration for testing.""" + mock_config_entry = MockConfigEntry( + title="Test Omada Controller", + domain=DOMAIN, + data=dict(MOCK_ENTRY_DATA), + unique_id="12345", + ) + 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 + + +async def test_device_scanner_created( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test gateway connected switches.""" + + entity_id = "device_tracker.banana" + + updated_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) + assert not updated_entity.disabled + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity == snapshot + + +async def test_device_scanner_update_to_away_nulls_properties( + hass: HomeAssistant, + mock_omada_clients_only_site_client: MagicMock, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test gateway connected switches.""" + + entity_id = "device_tracker.banana" + + updated_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) + assert not updated_entity.disabled + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + await _setup_client_disconnect( + mock_omada_clients_only_site_client, "2C-71-FF-ED-34-83" + ) + + async_fire_time_changed(hass, utcnow() + (POLL_INTERVAL * 2)) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity is not None + assert entity == snapshot + + mock_omada_clients_only_site_client.get_connected_clients.assert_called_once() + + +async def _setup_client_disconnect( + mock_omada_site_client: MagicMock, + client_mac: str, +): + original_clients = [ + c + async for c in mock_omada_site_client.get_connected_clients() + if c.mac != client_mac + ] + + async def get_filtered_clients() -> AsyncIterable[OmadaConnectedClient]: + for c in original_clients: + yield c + + mock_omada_site_client.get_connected_clients.reset_mock() + mock_omada_site_client.get_connected_clients.side_effect = get_filtered_clients diff --git a/tests/components/tplink_omada/test_switch.py b/tests/components/tplink_omada/test_switch.py index be2c21d02ab..7d83140cc95 100644 --- a/tests/components/tplink_omada/test_switch.py +++ b/tests/components/tplink_omada/test_switch.py @@ -2,7 +2,7 @@ from datetime import timedelta from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from syrupy.assertion import SnapshotAssertion from tplink_omada_client import SwitchPortOverrides @@ -17,7 +17,7 @@ from tplink_omada_client.devices import ( from tplink_omada_client.exceptions import InvalidDevice from homeassistant.components import switch -from homeassistant.components.tplink_omada.controller import POLL_GATEWAY +from homeassistant.components.tplink_omada.coordinator import POLL_GATEWAY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -34,6 +34,7 @@ async def test_poe_switches( mock_omada_site_client: MagicMock, init_integration: MockConfigEntry, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test PoE switch.""" poe_switch_mac = "54-AF-97-00-00-01" @@ -44,6 +45,7 @@ async def test_poe_switches( poe_switch_mac, 1, snapshot, + entity_registry, ) await _test_poe_switch( @@ -53,6 +55,7 @@ async def test_poe_switches( poe_switch_mac, 2, snapshot, + entity_registry, ) @@ -84,10 +87,11 @@ async def test_gateway_connect_ipv4_switch( port_status = test_gateway.port_status[3] assert port_status.port_number == 4 - mock_omada_site_client.set_gateway_wan_port_connect_state.reset_mock() - mock_omada_site_client.set_gateway_wan_port_connect_state.return_value = ( - _get_updated_gateway_port_status( - mock_omada_site_client, test_gateway, 3, "internetState", 0 + mock_omada_site_client.set_gateway_wan_port_connect_state = AsyncMock( + return_value=( + _get_updated_gateway_port_status( + mock_omada_site_client, test_gateway, 3, "internetState", 0 + ) ) ) await call_service(hass, "turn_off", entity_id) @@ -136,8 +140,8 @@ async def test_gateway_port_poe_switch( port_config = test_gateway.port_configs[4] assert port_config.port_number == 5 - mock_omada_site_client.set_gateway_port_settings.return_value = ( - OmadaGatewayPortConfig(port_config.raw_data, poe_enabled=False) + mock_omada_site_client.set_gateway_port_settings = AsyncMock( + return_value=(OmadaGatewayPortConfig(port_config.raw_data, poe_enabled=False)) ) await call_service(hass, "turn_off", entity_id) _assert_gateway_poe_set(mock_omada_site_client, test_gateway, False) @@ -239,9 +243,8 @@ async def _test_poe_switch( network_switch_mac: str, port_num: int, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: - entity_registry = er.async_get(hass) - def assert_update_switch_port( device: OmadaSwitch, switch_port_details: OmadaSwitchPortDetails, @@ -260,9 +263,8 @@ async def _test_poe_switch( entry = entity_registry.async_get(entity_id) assert entry == snapshot - mock_omada_site_client.update_switch_port.reset_mock() - mock_omada_site_client.update_switch_port.return_value = await _update_port_details( - mock_omada_site_client, port_num, False + mock_omada_site_client.update_switch_port = AsyncMock( + return_value=await _update_port_details(mock_omada_site_client, port_num, False) ) await call_service(hass, "turn_off", entity_id) diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index 79e5c877563..feacbb7b13f 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -3,11 +3,13 @@ from http import HTTPStatus from unittest.mock import patch +from aiohttp.test_utils import TestClient import pytest from homeassistant import config_entries from homeassistant.components import traccar, zone from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.traccar import DOMAIN, TRACKER_UPDATE from homeassistant.config import async_process_ha_core_config from homeassistant.const import STATE_HOME, STATE_NOT_HOME @@ -17,17 +19,21 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component +from tests.typing import ClientSessionGenerator + HOME_LATITUDE = 37.239622 HOME_LONGITUDE = -115.815811 @pytest.fixture(autouse=True) -def mock_dev_track(mock_device_tracker_conf): +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: """Mock device tracker config loading.""" @pytest.fixture(name="client") -async def traccar_client(hass, hass_client_no_auth): +async def traccar_client( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> TestClient: """Mock client for Traccar (unauthenticated).""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -100,7 +106,13 @@ async def test_missing_data(hass: HomeAssistant, client, webhook_id) -> None: assert req.status == HTTPStatus.UNPROCESSABLE_ENTITY -async def test_enter_and_exit(hass: HomeAssistant, client, webhook_id) -> None: +async def test_enter_and_exit( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client, + webhook_id, +) -> None: """Test when there is a known zone.""" url = f"/api/webhook/{webhook_id}" data = {"lat": str(HOME_LATITUDE), "lon": str(HOME_LONGITUDE), "id": "123"} @@ -135,11 +147,9 @@ async def test_enter_and_exit(hass: HomeAssistant, client, webhook_id) -> None: ).state assert state_name == STATE_NOT_HOME - dev_reg = dr.async_get(hass) - assert len(dev_reg.devices) == 1 + assert len(device_registry.devices) == 1 - ent_reg = er.async_get(hass) - assert len(ent_reg.entities) == 1 + assert len(entity_registry.entities) == 1 async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None: diff --git a/tests/components/traccar_server/conftest.py b/tests/components/traccar_server/conftest.py index e5a65bfeabd..6a8e428e7a2 100644 --- a/tests/components/traccar_server/conftest.py +++ b/tests/components/traccar_server/conftest.py @@ -1,10 +1,10 @@ """Common fixtures for the Traccar Server tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from pytraccar import ApiClient, SubscriptionStatus +from typing_extensions import Generator from homeassistant.components.traccar_server.const import ( CONF_CUSTOM_ATTRIBUTES, @@ -30,7 +30,7 @@ from tests.common import ( @pytest.fixture -def mock_traccar_api_client() -> Generator[AsyncMock, None, None]: +def mock_traccar_api_client() -> Generator[AsyncMock]: """Mock a Traccar ApiClient client.""" with ( patch( diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py index fdc22f9ff97..5da6f592957 100644 --- a/tests/components/traccar_server/test_config_flow.py +++ b/tests/components/traccar_server/test_config_flow.py @@ -1,11 +1,11 @@ """Test the Traccar Server config flow.""" -from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock import pytest from pytraccar import TraccarException +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.components.traccar.device_tracker import PLATFORM_SCHEMA @@ -34,7 +34,7 @@ from tests.common import MockConfigEntry async def test_form( hass: HomeAssistant, - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -77,7 +77,7 @@ async def test_form_cannot_connect( hass: HomeAssistant, side_effect: Exception, error: str, - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -127,7 +127,7 @@ async def test_form_cannot_connect( async def test_options( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], ) -> None: """Test options flow.""" mock_config_entry.add_to_hass(hass) @@ -231,7 +231,7 @@ async def test_import_from_yaml( imported: dict[str, Any], data: dict[str, Any], options: dict[str, Any], - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], ) -> None: """Test importing configuration from YAML.""" result = await hass.config_entries.flow.async_init( @@ -277,7 +277,7 @@ async def test_abort_import_already_configured(hass: HomeAssistant) -> None: async def test_abort_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], ) -> None: """Test abort for existing server.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/traccar_server/test_diagnostics.py b/tests/components/traccar_server/test_diagnostics.py index 9019cd0ebf1..15d74ef9ef5 100644 --- a/tests/components/traccar_server/test_diagnostics.py +++ b/tests/components/traccar_server/test_diagnostics.py @@ -1,9 +1,9 @@ """Test Traccar Server diagnostics.""" -from collections.abc import Generator from unittest.mock import AsyncMock from syrupy import SnapshotAssertion +from typing_extensions import Generator from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -21,7 +21,7 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: @@ -44,7 +44,7 @@ async def test_entry_diagnostics( async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, device_registry: dr.DeviceRegistry, @@ -86,7 +86,7 @@ async def test_device_diagnostics( async def test_device_diagnostics_with_disabled_entity( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_traccar_api_client: Generator[AsyncMock, None, None], + mock_traccar_api_client: Generator[AsyncMock], mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, device_registry: dr.DeviceRegistry, diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index f2cfb6a109f..92ba2c67020 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -119,17 +119,17 @@ async def _assert_contexts(client, next_id, contexts, domain=None, item_id=None) ("script", "sequence", [set(), set()], [UNDEFINED, UNDEFINED], "id", []), ], ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_get_trace( hass: HomeAssistant, hass_storage: dict[str, Any], - hass_ws_client, + hass_ws_client: WebSocketGenerator, domain, prefix, extra_trace_keys, trigger, context_key, condition_results, - enable_custom_integrations: None, ) -> None: """Test tracing a script or automation.""" await async_setup_component(hass, "homeassistant", {}) @@ -425,7 +425,10 @@ async def test_get_trace( @pytest.mark.parametrize("domain", ["automation", "script"]) async def test_restore_traces( - hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client, domain + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_ws_client: WebSocketGenerator, + domain: str, ) -> None: """Test restored traces.""" hass.set_state(CoreState.not_running) @@ -595,9 +598,9 @@ async def test_trace_overflow( async def test_restore_traces_overflow( hass: HomeAssistant, hass_storage: dict[str, Any], - hass_ws_client, - domain, - num_restored_moon_traces, + hass_ws_client: WebSocketGenerator, + domain: str, + num_restored_moon_traces: int, ) -> None: """Test restored traces are evicted first.""" hass.set_state(CoreState.not_running) @@ -675,10 +678,10 @@ async def test_restore_traces_overflow( async def test_restore_traces_late_overflow( hass: HomeAssistant, hass_storage: dict[str, Any], - hass_ws_client, - domain, - num_restored_moon_traces, - restored_run_id, + hass_ws_client: WebSocketGenerator, + domain: str, + num_restored_moon_traces: int, + restored_run_id: str, ) -> None: """Test restored traces are evicted first.""" hass.set_state(CoreState.not_running) @@ -1570,10 +1573,10 @@ async def test_script_mode_2( assert trace["script_execution"] == "finished" +@pytest.mark.usefixtures("enable_custom_integrations") async def test_trace_blueprint_automation( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - enable_custom_integrations: None, ) -> None: """Test trace of blueprint automation.""" await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/tractive/__init__.py b/tests/components/tractive/__init__.py index dcde4b87436..48254a80f37 100644 --- a/tests/components/tractive/__init__.py +++ b/tests/components/tractive/__init__.py @@ -1 +1,18 @@ """Tests for the tractive integration.""" + +from unittest.mock import patch + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the Tractive integration in Home Assistant.""" + entry.add_to_hass(hass) + + with patch("homeassistant.components.tractive.TractiveClient._listen"): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py new file mode 100644 index 00000000000..9a17a557c49 --- /dev/null +++ b/tests/components/tractive/conftest.py @@ -0,0 +1,134 @@ +"""Common fixtures for the Tractive tests.""" + +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from aiotractive.trackable_object import TrackableObject +from aiotractive.tracker import Tracker +import pytest +from typing_extensions import Generator + +from homeassistant.components.tractive.const import DOMAIN, SERVER_UNAVAILABLE +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_tractive_client() -> Generator[AsyncMock]: + """Mock a Tractive client.""" + + def send_hardware_event( + entry: MockConfigEntry, event: dict[str, Any] | None = None + ): + """Send hardware event.""" + if event is None: + event = { + "tracker_id": "device_id_123", + "hardware": {"battery_level": 88}, + "tracker_state": "operational", + "charging_state": "CHARGING", + } + entry.runtime_data.client._send_hardware_update(event) + + def send_wellness_event( + entry: MockConfigEntry, event: dict[str, Any] | None = None + ): + """Send wellness event.""" + if event is None: + event = { + "pet_id": "pet_id_123", + "sleep": {"minutes_day_sleep": 100, "minutes_night_sleep": 300}, + "wellness": {"activity_label": "ok", "sleep_label": "good"}, + "activity": { + "calories": 999, + "minutes_goal": 200, + "minutes_active": 150, + "minutes_rest": 122, + }, + } + entry.runtime_data.client._send_wellness_update(event) + + def send_position_event( + entry: MockConfigEntry, event: dict[str, Any] | None = None + ): + """Send position event.""" + if event is None: + event = { + "tracker_id": "device_id_123", + "position": { + "latlong": [22.333, 44.555], + "accuracy": 99, + "sensor_used": "GPS", + }, + } + entry.runtime_data.client._send_position_update(event) + + def send_switch_event(entry: MockConfigEntry, event: dict[str, Any] | None = None): + """Send switch event.""" + if event is None: + event = { + "tracker_id": "device_id_123", + "buzzer_control": {"active": True}, + "led_control": {"active": False}, + "live_tracking": {"active": True}, + } + entry.runtime_data.client._send_switch_update(event) + + def send_server_unavailable_event(hass): + """Send server unavailable event.""" + async_dispatcher_send(hass, f"{SERVER_UNAVAILABLE}-12345") + + trackable_object = load_json_object_fixture("trackable_object.json", DOMAIN) + tracker_details = load_json_object_fixture("tracker_details.json", DOMAIN) + tracker_hw_info = load_json_object_fixture("tracker_hw_info.json", DOMAIN) + tracker_pos_report = load_json_object_fixture("tracker_pos_report.json", DOMAIN) + + with ( + patch( + "homeassistant.components.tractive.aiotractive.Tractive", autospec=True + ) as mock_client, + ): + client = mock_client.return_value + client.authenticate.return_value = {"user_id": "12345"} + client.trackable_objects.return_value = [ + Mock( + spec=TrackableObject, + _id="xyz123", + type="pet", + details=AsyncMock(return_value=trackable_object), + ), + ] + client.tracker.return_value = AsyncMock( + spec=Tracker, + details=AsyncMock(return_value=tracker_details), + hw_info=AsyncMock(return_value=tracker_hw_info), + pos_report=AsyncMock(return_value=tracker_pos_report), + set_live_tracking_active=AsyncMock(return_value={"pending": True}), + set_buzzer_active=AsyncMock(return_value={"pending": True}), + set_led_active=AsyncMock(return_value={"pending": True}), + ) + + client.send_hardware_event = send_hardware_event + client.send_wellness_event = send_wellness_event + client.send_position_event = send_position_event + client.send_switch_event = send_switch_event + client.send_server_unavailable_event = send_server_unavailable_event + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test-email@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="very_unique_string", + entry_id="3bd2acb0e4f0476d40865546d0d91921", + title="Test Pet", + ) diff --git a/tests/components/tractive/fixtures/trackable_object.json b/tests/components/tractive/fixtures/trackable_object.json new file mode 100644 index 00000000000..a33dd314bff --- /dev/null +++ b/tests/components/tractive/fixtures/trackable_object.json @@ -0,0 +1,43 @@ +{ + "device_id": "device_id_123", + "_id": "pet_id_123", + "details": { + "_id": "pet_id_123", + "_version": "123abc", + "name": "Test Pet", + "pet_type": "DOG", + "breed_ids": [], + "gender": "F", + "birthday": 1572606592, + "profile_picture_frame": null, + "height": 0.56, + "length": null, + "weight": 23700, + "chip_id": "", + "neutered": true, + "personality": [], + "lost_or_dead": null, + "lim": null, + "ribcage": null, + "weight_is_default": null, + "height_is_default": null, + "birthday_is_default": null, + "breed_is_default": null, + "instagram_username": "", + "profile_picture_id": null, + "cover_picture_id": null, + "characteristic_ids": [], + "gallery_picture_ids": [], + "activity_settings": { + "_id": "345abc", + "_version": "ccaabb4", + "daily_goal": 1000, + "daily_distance_goal": 2000, + "daily_active_minutes_goal": 120, + "activity_category_thresholds_override": null, + "_type": "activity_setting" + }, + "_type": "pet_detail", + "read_only": false + } +} diff --git a/tests/components/tractive/fixtures/tracker_details.json b/tests/components/tractive/fixtures/tracker_details.json new file mode 100644 index 00000000000..0acde4b991a --- /dev/null +++ b/tests/components/tractive/fixtures/tracker_details.json @@ -0,0 +1,38 @@ +{ + "_id": "device_id_123", + "_version": "abcd-123-efgh-456", + "hw_id": "device_id_123", + "model_number": "TG4422", + "hw_edition": "BLUE-WHITE", + "bluetooth_mac": null, + "geofence_sensitivity": "HIGH", + "battery_save_mode": null, + "read_only": false, + "demo": false, + "self_test_available": false, + "capabilities": [ + "LT", + "BUZZER", + "LT_BLE", + "LED_BLE", + "BUZZER_BLE", + "HW_REPORTS_BLE", + "WIFI_SCAN_REPORTS_BLE", + "LED", + "ACTIVITY_TRACKING", + "WIFI_ZONE", + "SLEEP_TRACKING" + ], + "supported_geofence_types": ["CIRCLE", "RECTANGLE", "POLYGON"], + "fw_version": "123.456", + "state": "OPERATIONAL", + "state_reason": "POWER_SAVING", + "charging_state": "NOT_CHARGING", + "battery_state": "FULL", + "power_saving_zone_id": "abcdef12345", + "prioritized_zone_id": "098765", + "prioritized_zone_type": "POWER_SAVING", + "prioritized_zone_last_seen_at": 1716106551, + "prioritized_zone_entered_at": 1716105066, + "_type": "tracker" +} diff --git a/tests/components/tractive/fixtures/tracker_hw_info.json b/tests/components/tractive/fixtures/tracker_hw_info.json new file mode 100644 index 00000000000..1f2929b328a --- /dev/null +++ b/tests/components/tractive/fixtures/tracker_hw_info.json @@ -0,0 +1,11 @@ +{ + "time": 1716105966, + "battery_level": 96, + "clip_mounted_state": null, + "_id": "device_id_123", + "_type": "device_hw_report", + "_version": "e87646946", + "report_id": "098123", + "power_saving_zone_id": "abcdef12345", + "hw_status": null +} diff --git a/tests/components/tractive/fixtures/tracker_pos_report.json b/tests/components/tractive/fixtures/tracker_pos_report.json new file mode 100644 index 00000000000..2fafd960ee8 --- /dev/null +++ b/tests/components/tractive/fixtures/tracker_pos_report.json @@ -0,0 +1,16 @@ +{ + "time": 1716106551, + "time_rcvd": 1716106561, + "pos_status": null, + "latlong": [33.222222, 44.555555], + "speed": null, + "pos_uncertainty": 30, + "_id": "device_id_123", + "_type": "device_pos_report", + "_version": "b7422b930", + "altitude": 85, + "report_id": "098123", + "sensor_used": "KNOWN_WIFI", + "nearby_user_id": null, + "power_saving_zone_id": "abcdef12345" +} diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..c6d50fb0fbb --- /dev/null +++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.test_pet_tracker_battery_charging-entry] + 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.test_pet_tracker_battery_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tracker battery charging', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_battery_charging', + 'unique_id': 'pet_id_123_battery_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pet_tracker_battery_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Pet Tracker battery charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pet_tracker_battery_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor[binary_sensor.test_pet_tracker_battery_charging-entry] + 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.test_pet_tracker_battery_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tracker battery charging', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_battery_charging', + 'unique_id': 'pet_id_123_battery_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[binary_sensor.test_pet_tracker_battery_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Pet Tracker battery charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pet_tracker_battery_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr new file mode 100644 index 00000000000..3a145a48b5a --- /dev/null +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_device_tracker[device_tracker.test_pet_tracker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_pet_tracker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tracker', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker', + 'unique_id': 'pet_id_123', + 'unit_of_measurement': None, + }) +# --- +# name: test_device_tracker[device_tracker.test_pet_tracker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 88, + 'friendly_name': 'Test Pet Tracker', + 'gps_accuracy': 99, + 'latitude': 22.333, + 'longitude': 44.555, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_pet_tracker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- +# name: test_sensor[device_tracker.test_pet_tracker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.test_pet_tracker', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tracker', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker', + 'unique_id': 'pet_id_123', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[device_tracker.test_pet_tracker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 88, + 'friendly_name': 'Test Pet Tracker', + 'gps_accuracy': 99, + 'latitude': 22.333, + 'longitude': 44.555, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.test_pet_tracker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'not_home', + }) +# --- diff --git a/tests/components/tractive/snapshots/test_diagnostics.ambr b/tests/components/tractive/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a66247749b7 --- /dev/null +++ b/tests/components/tractive/snapshots/test_diagnostics.ambr @@ -0,0 +1,72 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'email': '**REDACTED**', + 'password': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'tractive', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': 'very_unique_string', + 'version': 1, + }), + 'trackables': list([ + dict({ + '_id': '**REDACTED**', + 'details': dict({ + '_id': '**REDACTED**', + '_type': 'pet_detail', + '_version': '123abc', + 'activity_settings': dict({ + '_id': '**REDACTED**', + '_type': 'activity_setting', + '_version': 'ccaabb4', + 'activity_category_thresholds_override': None, + 'daily_active_minutes_goal': 120, + 'daily_distance_goal': 2000, + 'daily_goal': 1000, + }), + 'birthday': 1572606592, + 'birthday_is_default': None, + 'breed_ids': list([ + ]), + 'breed_is_default': None, + 'characteristic_ids': list([ + ]), + 'chip_id': '', + 'cover_picture_id': None, + 'gallery_picture_ids': list([ + ]), + 'gender': 'F', + 'height': 0.56, + 'height_is_default': None, + 'instagram_username': '', + 'length': None, + 'lim': None, + 'lost_or_dead': None, + 'name': 'Test Pet', + 'neutered': True, + 'personality': list([ + ]), + 'pet_type': 'DOG', + 'profile_picture_frame': None, + 'profile_picture_id': None, + 'read_only': False, + 'ribcage': None, + 'weight': 23700, + 'weight_is_default': None, + }), + 'device_id': 'device_id_123', + }), + ]), + }) +# --- diff --git a/tests/components/tractive/snapshots/test_sensor.ambr b/tests/components/tractive/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f1ed397450e --- /dev/null +++ b/tests/components/tractive/snapshots/test_sensor.ambr @@ -0,0 +1,524 @@ +# serializer version: 1 +# name: test_sensor[sensor.test_pet_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'low', + 'ok', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Activity', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity', + 'unique_id': 'pet_id_123_activity_label', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.test_pet_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Pet Activity', + 'options': list([ + 'good', + 'low', + 'ok', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_pet_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ok', + }) +# --- +# name: test_sensor[sensor.test_pet_activity_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_activity_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activity time', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activity_time', + 'unique_id': 'pet_id_123_minutes_active', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.test_pet_activity_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Activity time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_pet_activity_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- +# name: test_sensor[sensor.test_pet_calories_burned-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_calories_burned', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Calories burned', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calories', + 'unique_id': 'pet_id_123_calories', + 'unit_of_measurement': 'kcal', + }) +# --- +# name: test_sensor[sensor.test_pet_calories_burned-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Calories burned', + 'state_class': , + 'unit_of_measurement': 'kcal', + }), + 'context': , + 'entity_id': 'sensor.test_pet_calories_burned', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '999', + }) +# --- +# name: test_sensor[sensor.test_pet_daily_goal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_daily_goal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Daily goal', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_goal', + 'unique_id': 'pet_id_123_daily_goal', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.test_pet_daily_goal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Daily goal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_pet_daily_goal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_sensor[sensor.test_pet_day_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_day_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Day sleep', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minutes_day_sleep', + 'unique_id': 'pet_id_123_minutes_day_sleep', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.test_pet_day_sleep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Day sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_pet_day_sleep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor[sensor.test_pet_night_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_night_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night sleep', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minutes_night_sleep', + 'unique_id': 'pet_id_123_minutes_night_sleep', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.test_pet_night_sleep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Night sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_pet_night_sleep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300', + }) +# --- +# name: test_sensor[sensor.test_pet_rest_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_rest_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Rest time', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rest_time', + 'unique_id': 'pet_id_123_minutes_rest', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.test_pet_rest_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Rest time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_pet_rest_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '122', + }) +# --- +# name: test_sensor[sensor.test_pet_sleep-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'low', + 'ok', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_pet_sleep', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sleep', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sleep', + 'unique_id': 'pet_id_123_sleep_label', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.test_pet_sleep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Pet Sleep', + 'options': list([ + 'good', + 'low', + 'ok', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_pet_sleep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.test_pet_tracker_battery-entry] + 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.test_pet_tracker_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tracker battery', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_battery_level', + 'unique_id': 'pet_id_123_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.test_pet_tracker_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Pet Tracker battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_pet_tracker_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor[sensor.test_pet_tracker_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'inaccurate_position', + 'not_reporting', + 'operational', + 'system_shutdown_user', + 'system_startup', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_pet_tracker_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tracker state', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_state', + 'unique_id': 'pet_id_123_tracker_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.test_pet_tracker_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Pet Tracker state', + 'options': list([ + 'inaccurate_position', + 'not_reporting', + 'operational', + 'system_shutdown_user', + 'system_startup', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_pet_tracker_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'operational', + }) +# --- diff --git a/tests/components/tractive/snapshots/test_switch.ambr b/tests/components/tractive/snapshots/test_switch.ambr new file mode 100644 index 00000000000..ea9ea9d9e48 --- /dev/null +++ b/tests/components/tractive/snapshots/test_switch.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_sensor[switch.test_pet_live_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_pet_live_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Live tracking', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'live_tracking', + 'unique_id': 'pet_id_123_live_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[switch.test_pet_live_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Live tracking', + }), + 'context': , + 'entity_id': 'switch.test_pet_live_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor[switch.test_pet_tracker_buzzer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_pet_tracker_buzzer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tracker buzzer', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_buzzer', + 'unique_id': 'pet_id_123_buzzer', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[switch.test_pet_tracker_buzzer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Tracker buzzer', + }), + 'context': , + 'entity_id': 'switch.test_pet_tracker_buzzer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor[switch.test_pet_tracker_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_pet_tracker_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tracker LED', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_led', + 'unique_id': 'pet_id_123_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[switch.test_pet_tracker_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Tracker LED', + }), + 'context': , + 'entity_id': 'switch.test_pet_tracker_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.test_pet_live_tracking-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_pet_live_tracking', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Live tracking', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'live_tracking', + 'unique_id': 'pet_id_123_live_tracking', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_pet_live_tracking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Live tracking', + }), + 'context': , + 'entity_id': 'switch.test_pet_live_tracking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_pet_tracker_buzzer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_pet_tracker_buzzer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tracker buzzer', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_buzzer', + 'unique_id': 'pet_id_123_buzzer', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_pet_tracker_buzzer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Tracker buzzer', + }), + 'context': , + 'entity_id': 'switch.test_pet_tracker_buzzer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_pet_tracker_led-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_pet_tracker_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tracker LED', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_led', + 'unique_id': 'pet_id_123_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_pet_tracker_led-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Tracker LED', + }), + 'context': , + 'entity_id': 'switch.test_pet_tracker_led', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tractive/test_binary_sensor.py b/tests/components/tractive/test_binary_sensor.py new file mode 100644 index 00000000000..cd7ffbc3da3 --- /dev/null +++ b/tests/components/tractive/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Test the Tractive binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the binary sensor.""" + with patch("homeassistant.components.tractive.PLATFORMS", [Platform.BINARY_SENSOR]): + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py new file mode 100644 index 00000000000..ff78173ef7b --- /dev/null +++ b/tests/components/tractive/test_device_tracker.py @@ -0,0 +1,61 @@ +"""Test the Tractive device tracker platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.device_tracker import SourceType +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_device_tracker( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the device_tracker.""" + with patch( + "homeassistant.components.tractive.PLATFORMS", [Platform.DEVICE_TRACKER] + ): + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_position_event(mock_config_entry) + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_source_type_phone( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the device tracker with source type phone.""" + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_position_event( + mock_config_entry, + { + "tracker_id": "device_id_123", + "position": { + "latlong": [22.333, 44.555], + "accuracy": 99, + "sensor_used": "PHONE", + }, + }, + ) + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + + assert ( + hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] + is SourceType.BLUETOOTH + ) diff --git a/tests/components/tractive/test_diagnostics.py b/tests/components/tractive/test_diagnostics.py new file mode 100644 index 00000000000..cc4fcdeba15 --- /dev/null +++ b/tests/components/tractive/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test the Tractive diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import init_integration + +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_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry diagnostics.""" + await init_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot diff --git a/tests/components/tractive/test_init.py b/tests/components/tractive/test_init.py new file mode 100644 index 00000000000..3387232b231 --- /dev/null +++ b/tests/components/tractive/test_init.py @@ -0,0 +1,163 @@ +"""Test init of Tractive integration.""" + +from unittest.mock import AsyncMock, patch + +from aiotractive.exceptions import TractiveError, UnauthorizedError +import pytest + +from homeassistant.components.tractive.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a successful setup entry.""" + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_unload_entry( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful unload of entry.""" + await init_integration(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + with patch("homeassistant.components.tractive.TractiveClient.unsubscribe"): + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +@pytest.mark.parametrize( + ("method", "exc", "entry_state"), + [ + ("authenticate", UnauthorizedError, ConfigEntryState.SETUP_ERROR), + ("authenticate", TractiveError, ConfigEntryState.SETUP_RETRY), + ("trackable_objects", TractiveError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_failed( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, + method: str, + exc: Exception, + entry_state: ConfigEntryState, +) -> None: + """Test for setup failure.""" + getattr(mock_tractive_client, method).side_effect = exc + + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is entry_state + + +async def test_config_not_ready( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test for setup failure if the tracker_details doesn't contain '_id'.""" + mock_tractive_client.tracker.return_value.details.return_value.pop("_id") + + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_trackable_without_details( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a successful setup entry.""" + mock_tractive_client.trackable_objects.return_value[0].details.return_value = { + "device_id": "xyz098" + } + + await init_integration(hass, mock_config_entry) + + assert ( + "Tracker xyz098 has no details and will be skipped. This happens for shared trackers" + in caplog.text + ) + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_trackable_without_device_id( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a successful setup entry.""" + mock_tractive_client.trackable_objects.return_value[0].details.return_value = { + "device_id": None + } + + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_unsubscribe_on_ha_stop( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unsuscribe when HA stops.""" + await init_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.tractive.TractiveClient.unsubscribe" + ) as mock_unsuscribe: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert mock_unsuscribe.called + + +async def test_server_unavailable( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the sensor.""" + entity_id = "sensor.test_pet_tracker_battery" + + await init_integration(hass, mock_config_entry) + + # send event to make the entity available + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + # send server unavailable event, the entity should be unavailable + mock_tractive_client.send_server_unavailable_event(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # send event to make the entity available once again + mock_tractive_client.send_hardware_event(mock_config_entry) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/tractive/test_sensor.py b/tests/components/tractive/test_sensor.py new file mode 100644 index 00000000000..b53cc3c4d64 --- /dev/null +++ b/tests/components/tractive/test_sensor.py @@ -0,0 +1,30 @@ +"""Test the Tractive sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the sensor.""" + with patch("homeassistant.components.tractive.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_hardware_event(mock_config_entry) + mock_tractive_client.send_wellness_event(mock_config_entry) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py new file mode 100644 index 00000000000..cc7ce6cf81f --- /dev/null +++ b/tests/components/tractive/test_switch.py @@ -0,0 +1,228 @@ +"""Test the Tractive switch platform.""" + +from unittest.mock import AsyncMock, patch + +from aiotractive.exceptions import TractiveError +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the switch.""" + with patch("homeassistant.components.tractive.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_switch_on( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch can be turned on.""" + entity_id = "switch.test_pet_tracker_led" + + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_tractive_client.send_switch_event( + mock_config_entry, + {"tracker_id": "device_id_123", "led_control": {"active": True}}, + ) + await hass.async_block_till_done() + + assert mock_tractive_client.tracker.return_value.set_led_active.call_count == 1 + assert ( + mock_tractive_client.tracker.return_value.set_led_active.call_args[0][0] is True + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + +async def test_switch_off( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch can be turned off.""" + entity_id = "switch.test_pet_tracker_buzzer" + + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_tractive_client.send_switch_event( + mock_config_entry, + {"tracker_id": "device_id_123", "buzzer_control": {"active": False}}, + ) + await hass.async_block_till_done() + + assert mock_tractive_client.tracker.return_value.set_buzzer_active.call_count == 1 + assert ( + mock_tractive_client.tracker.return_value.set_buzzer_active.call_args[0][0] + is False + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + +async def test_live_tracking_switch( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the live_tracking switch.""" + entity_id = "switch.test_pet_live_tracking" + + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_tractive_client.send_switch_event( + mock_config_entry, + {"tracker_id": "device_id_123", "live_tracking": {"active": False}}, + ) + await hass.async_block_till_done() + + assert ( + mock_tractive_client.tracker.return_value.set_live_tracking_active.call_count + == 1 + ) + assert ( + mock_tractive_client.tracker.return_value.set_live_tracking_active.call_args[0][ + 0 + ] + is False + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + +async def test_switch_on_with_exception( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch turn on with exception.""" + entity_id = "switch.test_pet_tracker_led" + + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + mock_tractive_client.tracker.return_value.set_led_active.side_effect = TractiveError + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + +async def test_switch_off_with_exception( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch turn off with exception.""" + entity_id = "switch.test_pet_tracker_buzzer" + + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_switch_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + mock_tractive_client.tracker.return_value.set_buzzer_active.side_effect = ( + TractiveError + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON diff --git a/tests/components/tradfri/conftest.py b/tests/components/tradfri/conftest.py index 73cfea59ce1..08afe77b4a3 100644 --- a/tests/components/tradfri/conftest.py +++ b/tests/components/tradfri/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Generator +from collections.abc import Callable import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -12,6 +12,7 @@ from pytradfri.command import Command from pytradfri.const import ATTR_FIRMWARE_VERSION, ATTR_GATEWAY_ID from pytradfri.device import Device from pytradfri.gateway import Gateway +from typing_extensions import Generator from homeassistant.components.tradfri.const import DOMAIN @@ -22,7 +23,7 @@ from tests.common import load_fixture @pytest.fixture -def mock_entry_setup() -> Generator[AsyncMock, None, None]: +def mock_entry_setup() -> Generator[AsyncMock]: """Mock entry setup.""" with patch(f"{TRADFRI_PATH}.async_setup_entry") as mock_setup: mock_setup.return_value = True @@ -76,7 +77,7 @@ def mock_api_fixture( @pytest.fixture(autouse=True) def mock_api_factory( mock_api: Callable[[Command | list[Command], float | None], Any | None], -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock pytradfri api factory.""" with patch(f"{TRADFRI_PATH}.APIFactory", autospec=True) as factory_class: factory = factory_class.return_value diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py index ffdb5b44813..6c694f76233 100644 --- a/tests/components/trafikverket_camera/test_binary_sensor.py +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant.config_entries import ConfigEntry @@ -9,9 +10,9 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, get_camera: CameraInfo, ) -> None: diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index 83645f141fa..23ebd3f2189 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -15,9 +15,9 @@ from tests.components.recorder.common import async_wait_recording_done from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_exclude_attributes( recorder_mock: Recorder, - entity_registry_enabled_by_default: None, hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index 9d357bbd0ca..18ccbe56070 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -2,15 +2,16 @@ from __future__ import annotations +import pytest from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: None, load_int: ConfigEntry, get_camera: CameraInfo, ) -> None: diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py index 6ac4eaa3a78..ef6329bfd82 100644 --- a/tests/components/trafikverket_ferry/test_coordinator.py +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -22,9 +22,9 @@ from . import ENTRY_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator( hass: HomeAssistant, - entity_registry_enabled_by_default: None, freezer: FrozenDateTimeFactory, monkeypatch: pytest.MonkeyPatch, get_ferries: list[FerryStop], diff --git a/tests/components/trafikverket_train/__init__.py b/tests/components/trafikverket_train/__init__.py index 632f082c73b..f5e60eae535 100644 --- a/tests/components/trafikverket_train/__init__.py +++ b/tests/components/trafikverket_train/__init__.py @@ -2,12 +2,12 @@ from __future__ import annotations -from homeassistant.components.trafikverket_ferry.const import ( +from homeassistant.components.trafikverket_train.const import ( + CONF_FILTER_PRODUCT, 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 = { diff --git a/tests/components/trafikverket_train/test_sensor.py b/tests/components/trafikverket_train/test_sensor.py index 099bcf5ae1e..f21561dd287 100644 --- a/tests/components/trafikverket_train/test_sensor.py +++ b/tests/components/trafikverket_train/test_sensor.py @@ -6,6 +6,7 @@ from datetime import timedelta from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory +import pytest from pytrafikverket.exceptions import InvalidAuthentication, NoTrainAnnouncementFound from pytrafikverket.trafikverket_train import TrainStop from syrupy.assertion import SnapshotAssertion @@ -17,10 +18,10 @@ from homeassistant.core import HomeAssistant from tests.common import async_fire_time_changed +@pytest.mark.usefixtures("entity_registry_enabled_by_default") 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, @@ -64,10 +65,10 @@ async def test_sensor_next( assert state == snapshot +@pytest.mark.usefixtures("entity_registry_enabled_by_default") 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, @@ -80,10 +81,10 @@ async def test_sensor_single_stop( assert state == snapshot +@pytest.mark.usefixtures("entity_registry_enabled_by_default") 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, @@ -113,10 +114,10 @@ async def test_sensor_update_auth_failure( assert flow == snapshot +@pytest.mark.usefixtures("entity_registry_enabled_by_default") 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, @@ -143,10 +144,10 @@ async def test_sensor_update_failure( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("entity_registry_enabled_by_default") 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, diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index 307576ffdea..38d941c3779 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -119,7 +119,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data[DOMAIN] @pytest.mark.parametrize( diff --git a/tests/components/trend/conftest.py b/tests/components/trend/conftest.py index 5263b86d268..ca27094565a 100644 --- a/tests/components/trend/conftest.py +++ b/tests/components/trend/conftest.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -ComponentSetup = Callable[[dict[str, Any]], Awaitable[None]] +type ComponentSetup = Callable[[dict[str, Any]], Awaitable[None]] @pytest.fixture(name="config_entry") diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index d8d02755044..ad85f65a9fc 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -8,8 +8,10 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup +from homeassistant.components.trend.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .conftest import ComponentSetup @@ -197,7 +199,7 @@ async def test_max_samples( }, ) - for val in [0, 1, 2, 3, 2, 1]: + for val in (0, 1, 2, 3, 2, 1): hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() @@ -212,7 +214,7 @@ async def test_non_numeric( """Test for non-numeric sensor.""" await setup_component({"entity_id": "sensor.test_state"}) - for val in ["Non", "Numeric"]: + for val in ("Non", "Numeric"): hass.states.async_set("sensor.test_state", val) await hass.async_block_till_done() @@ -230,7 +232,7 @@ async def test_missing_attribute( }, ) - for val in [1, 2]: + for val in (1, 2): hass.states.async_set("sensor.test_state", "State", {"attr": val}) await hass.async_block_till_done() @@ -311,7 +313,7 @@ async def test_restore_state( 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]: + 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() @@ -320,7 +322,7 @@ async def test_restore_state( 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]: + 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() @@ -350,3 +352,46 @@ async def test_invalid_min_sample( "Invalid config for 'binary_sensor' from integration 'trend': min_samples must " "be smaller than or equal to max_samples" in record.message ) + + +async def test_device_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test for source entity device for Trend.""" + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + source_device_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + trend_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "Trend", + "entity_id": "sensor.test_source", + "invert": False, + }, + title="Trend", + ) + trend_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity = entity_registry.async_get("binary_sensor.trend") + assert trend_entity is not None + assert trend_entity.device_id == source_entity.device_id diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 47bcab2214d..7ffb18de297 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -1,18 +1,21 @@ """Test the Trend integration.""" +from homeassistant.components.trend.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .conftest import ComponentSetup 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 + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + 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 @@ -21,7 +24,7 @@ async def test_setup_and_remove_config_entry( 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 + assert entity_registry.async_get(trend_entity_id) is not None # Remove the config entry assert await hass.config_entries.async_remove(config_entry.entry_id) @@ -29,7 +32,7 @@ async def test_setup_and_remove_config_entry( # 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 + assert entity_registry.async_get(trend_entity_id) is None async def test_reload_config_entry( @@ -48,3 +51,87 @@ async def test_reload_config_entry( assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data == {**config_entry.data, "max_samples": 4.0} + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for source entity device for Trend.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + # Configure the configuration entry for Trend + trend_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "Trend", + "entity_id": "sensor.test_source", + "invert": False, + }, + title="Trend", + ) + trend_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the trend sensor + trend_entity = entity_registry.async_get("binary_sensor.trend") + assert trend_entity is not None + assert trend_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to Trend config entry + device_registry.async_get_or_create( + config_entry_id=trend_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=trend_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, two devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + trend_config_entry.entry_id + ) + assert len(devices_before_reload) == 3 + + # Config entry reload + await hass.config_entries.async_reload(trend_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the trend sensor after reload + trend_entity = entity_registry.async_get("binary_sensor.trend") + assert trend_entity is not None + assert trend_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + trend_config_entry.entry_id + ) + assert len(devices_after_reload) == 1 diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 5bdc156eacc..e1d9d973f25 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Generator from http import HTTPStatus +from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator import voluptuous as vol from homeassistant.components import media_source @@ -41,7 +42,7 @@ SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] TEST_DOMAIN = "test" -def mock_tts_get_cache_files_fixture_helper(): +def mock_tts_get_cache_files_fixture_helper() -> Generator[MagicMock]: """Mock the list TTS cache function.""" with patch( "homeassistant.components.tts._get_cache_files", return_value={} @@ -51,7 +52,7 @@ def mock_tts_get_cache_files_fixture_helper(): def mock_tts_init_cache_dir_fixture_helper( init_tts_cache_dir_side_effect: Any, -) -> Generator[MagicMock, None, None]: +) -> Generator[MagicMock]: """Mock the TTS cache dir in memory.""" with patch( "homeassistant.components.tts._init_tts_cache_dir", @@ -66,8 +67,11 @@ def init_tts_cache_dir_side_effect_fixture_helper() -> Any: def mock_tts_cache_dir_fixture_helper( - tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request -): + tmp_path: Path, + mock_tts_init_cache_dir: MagicMock, + mock_tts_get_cache_files: MagicMock, + request: pytest.FixtureRequest, +) -> Generator[Path]: """Mock the TTS cache dir with empty dir.""" mock_tts_init_cache_dir.return_value = str(tmp_path) @@ -88,7 +92,7 @@ def mock_tts_cache_dir_fixture_helper( pytest.fail("Test failed, see log for details") -def tts_mutagen_mock_fixture_helper(): +def tts_mutagen_mock_fixture_helper() -> Generator[MagicMock]: """Mock writing tags.""" with patch( "homeassistant.components.tts.SpeechManager.write_tags", @@ -222,7 +226,7 @@ async def mock_config_entry_setup( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, TTS_DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [TTS_DOMAIN]) return True async def async_unload_entry_init( diff --git a/tests/components/tts/conftest.py b/tests/components/tts/conftest.py index a8bdeea5545..b8abb086260 100644 --- a/tests/components/tts/conftest.py +++ b/tests/components/tts/conftest.py @@ -3,9 +3,11 @@ From http://doc.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures """ -from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock import pytest +from typing_extensions import Generator from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigFlow @@ -37,13 +39,13 @@ def pytest_runtest_makereport(item, call): @pytest.fixture(autouse=True, name="mock_tts_cache_dir") -def mock_tts_cache_dir_fixture_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_fixture_autouse(mock_tts_cache_dir: Path) -> Path: """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): +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @@ -80,7 +82,7 @@ class TTSFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 7d308ec0b23..e0354170b06 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus +from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -187,7 +188,7 @@ async def test_setup_component_no_access_cache_folder( ) async def test_service( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -248,7 +249,7 @@ async def test_service( ) async def test_service_default_language( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -309,7 +310,7 @@ async def test_service_default_language( ) async def test_service_default_special_language( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -366,7 +367,7 @@ async def test_service_default_special_language( ) async def test_service_language( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -423,7 +424,7 @@ async def test_service_language( ) async def test_service_wrong_language( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -477,7 +478,7 @@ async def test_service_wrong_language( ) async def test_service_options( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -561,7 +562,7 @@ class MockEntityWithDefaults(MockTTSEntity): ) async def test_service_default_options( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -629,7 +630,7 @@ async def test_service_default_options( ) async def test_merge_default_service_options( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -696,7 +697,7 @@ async def test_merge_default_service_options( ) async def test_service_wrong_options( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -752,7 +753,7 @@ async def test_service_wrong_options( ) async def test_service_clear_cache( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -814,7 +815,7 @@ async def test_service_clear_cache( async def test_service_receive_voice( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -886,7 +887,7 @@ async def test_service_receive_voice( async def test_service_receive_voice_german( hass: HomeAssistant, hass_client: ClientSessionGenerator, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -994,7 +995,7 @@ async def test_web_view_wrong_filename( ) async def test_service_without_cache( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, setup: str, tts_service: str, service_data: dict[str, Any], @@ -1042,7 +1043,7 @@ class MockEntityBoom(MockTTSEntity): @pytest.mark.parametrize("mock_provider", [MockProviderBoom(DEFAULT_LANG)]) async def test_setup_legacy_cache_dir( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, mock_provider: MockProvider, ) -> None: """Set up a TTS platform with cache and call service without cache.""" @@ -1078,7 +1079,7 @@ async def test_setup_legacy_cache_dir( @pytest.mark.parametrize("mock_tts_entity", [MockEntityBoom(DEFAULT_LANG)]) async def test_setup_cache_dir( hass: HomeAssistant, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, mock_tts_entity: MockTTSEntity, ) -> None: """Set up a TTS platform with cache and call service without cache.""" @@ -1185,7 +1186,7 @@ async def test_service_get_tts_error( async def test_load_cache_legacy_retrieve_without_mem_cache( hass: HomeAssistant, mock_provider: MockProvider, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, hass_client: ClientSessionGenerator, ) -> None: """Set up component and load cache and get without mem cache.""" @@ -1211,7 +1212,7 @@ async def test_load_cache_legacy_retrieve_without_mem_cache( async def test_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, - mock_tts_cache_dir, + mock_tts_cache_dir: Path, hass_client: ClientSessionGenerator, ) -> None: """Set up component and load cache and get without mem cache.""" diff --git a/tests/components/tts/test_legacy.py b/tests/components/tts/test_legacy.py index 59194f50d93..05bb6dec10f 100644 --- a/tests/components/tts/test_legacy.py +++ b/tests/components/tts/test_legacy.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + import pytest from homeassistant.components.media_player import ( @@ -139,7 +141,7 @@ async def test_platform_setup_with_error( async def test_service_without_cache_config( - hass: HomeAssistant, mock_tts_cache_dir, mock_tts + hass: HomeAssistant, mock_tts_cache_dir: Path, mock_tts ) -> None: """Set up a TTS platform without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 541e2f1c9e3..981e12ecceb 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN @@ -35,14 +35,14 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch("homeassistant.components.tuya.async_setup_entry", return_value=True): yield @pytest.fixture -def mock_tuya_login_control() -> Generator[MagicMock, None, None]: +def mock_tuya_login_control() -> Generator[MagicMock]: """Return a mocked Tuya login control.""" with patch( "homeassistant.components.tuya.config_flow.LoginControl", autospec=True diff --git a/tests/components/twentemilieu/conftest.py b/tests/components/twentemilieu/conftest.py index 670bd648cac..7b157572824 100644 --- a/tests/components/twentemilieu/conftest.py +++ b/tests/components/twentemilieu/conftest.py @@ -2,12 +2,12 @@ from __future__ import annotations -from collections.abc import Generator from datetime import date from unittest.mock import MagicMock, patch import pytest from twentemilieu import WasteType +from typing_extensions import Generator from homeassistant.components.twentemilieu.const import ( CONF_HOUSE_LETTER, @@ -38,7 +38,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.twentemilieu.async_setup_entry", return_value=True @@ -47,7 +47,7 @@ def mock_setup_entry() -> Generator[None, None, None]: @pytest.fixture -def mock_twentemilieu() -> Generator[MagicMock, None, None]: +def mock_twentemilieu() -> Generator[MagicMock]: """Return a mocked Twente Milieu client.""" with ( patch( diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py index 901252f050f..d4c519d6f66 100644 --- a/tests/components/twentemilieu/test_init.py +++ b/tests/components/twentemilieu/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.twentemilieu.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -26,7 +25,6 @@ async def test_load_unload_config_entry( 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 diff --git a/tests/components/twinkly/conftest.py b/tests/components/twinkly/conftest.py index 6705d570205..19361af2003 100644 --- a/tests/components/twinkly/conftest.py +++ b/tests/components/twinkly/conftest.py @@ -13,7 +13,7 @@ from . import TEST_MODEL, TEST_NAME, TEST_UID, ClientMock from tests.common import MockConfigEntry -ComponentSetup = Callable[[], Awaitable[ClientMock]] +type ComponentSetup = Callable[[], Awaitable[ClientMock]] DOMAIN = "twinkly" TITLE = "Twinkly" diff --git a/tests/components/twinkly/test_diagnostics.py b/tests/components/twinkly/test_diagnostics.py index 680f82365c0..5cb9fc1fe9e 100644 --- a/tests/components/twinkly/test_diagnostics.py +++ b/tests/components/twinkly/test_diagnostics.py @@ -11,7 +11,7 @@ from . import ClientMock from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -ComponentSetup = Callable[[], Awaitable[ClientMock]] +type ComponentSetup = Callable[[], Awaitable[ClientMock]] DOMAIN = "twinkly" diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index d37c386f0a3..0238bbdadba 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -1,246 +1,56 @@ """Tests for the Twitch component.""" -import asyncio -from collections.abc import AsyncGenerator, AsyncIterator -from dataclasses import dataclass -from datetime import datetime +from collections.abc import AsyncIterator +from typing import Any, Generic, TypeVar -from twitchAPI.object.api import FollowedChannelsResult, TwitchUser -from twitchAPI.twitch import ( - InvalidTokenException, - MissingScopeException, - TwitchAPIException, - TwitchAuthorizationException, - TwitchResourceNotFound, -) -from twitchAPI.type import AuthScope, AuthType +from twitchAPI.object.base import TwitchObject +from typing_extensions import AsyncGenerator +from homeassistant.components.twitch import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_array_fixture async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() -def _get_twitch_user(user_id: str = "123") -> TwitchUser: - return TwitchUser( - id=user_id, - display_name="channel123", - offline_image_url="logo.png", - profile_image_url="logo.png", - view_count=42, - ) +TwitchType = TypeVar("TwitchType", bound=TwitchObject) -async def async_iterator(iterable) -> AsyncIterator: - """Return async iterator.""" - for i in iterable: - yield i +class TwitchIterObject(Generic[TwitchType]): + """Twitch object iterator.""" + def __init__(self, fixture: str, target_type: type[TwitchType]) -> None: + """Initialize object.""" + self.raw_data = load_json_array_fixture(fixture, DOMAIN) + self.data = [target_type(**item) for item in self.raw_data] + self.total = len(self.raw_data) + self.target_type = target_type -@dataclass -class UserSubscriptionMock: - """User subscription mock.""" - - broadcaster_id: str - is_gift: bool - - -@dataclass -class FollowedChannelMock: - """Followed channel mock.""" - - broadcaster_login: str - followed_at: str - - -@dataclass -class ChannelFollowerMock: - """Channel follower mock.""" - - user_id: str - - -@dataclass -class StreamMock: - """Stream mock.""" - - game_name: str - title: str - thumbnail_url: str - - -class TwitchUserFollowResultMock: - """Mock for twitch user follow result.""" - - def __init__(self, follows: list[FollowedChannelMock]) -> None: - """Initialize mock.""" - self.total = len(follows) - self.data = follows - - def __aiter__(self): + async def __aiter__(self) -> AsyncIterator[TwitchType]: """Return async iterator.""" - return async_iterator(self.data) + async for item in get_generator_from_data(self.raw_data, self.target_type): + yield item -class ChannelFollowersResultMock: - """Mock for twitch channel follow result.""" - - def __init__(self, follows: list[ChannelFollowerMock]) -> None: - """Initialize mock.""" - self.total = len(follows) - self.data = follows - - def __aiter__(self): - """Return async iterator.""" - return async_iterator(self.data) +async def get_generator( + fixture: str, target_type: type[TwitchType] +) -> AsyncGenerator[TwitchType]: + """Return async generator.""" + data = load_json_array_fixture(fixture, DOMAIN) + async for item in get_generator_from_data(data, target_type): + yield item -STREAMS = StreamMock( - game_name="Good game", title="Title", thumbnail_url="stream-medium.png" -) - - -class TwitchMock: - """Mock for the twitch object.""" - - is_streaming = True - is_gifted = False - is_subscribed = False - is_following = True - different_user_id = False - - def __await__(self): - """Add async capabilities to the mock.""" - t = asyncio.create_task(self._noop()) - yield from t - return self - - async def _noop(self): - """Fake function to create task.""" - - async def get_users( - self, user_ids: list[str] | None = None, logins: list[str] | None = None - ) -> AsyncGenerator[TwitchUser, None]: - """Get list of mock users.""" - users = [_get_twitch_user("234" if self.different_user_id else "123")] - for user in users: - yield user - - def has_required_auth( - self, required_type: AuthType, required_scope: list[AuthScope] - ) -> bool: - """Return if auth required.""" - return True - - async def check_user_subscription( - self, broadcaster_id: str, user_id: str - ) -> UserSubscriptionMock: - """Check if the user is subscribed.""" - if self.is_subscribed: - return UserSubscriptionMock( - broadcaster_id=broadcaster_id, is_gift=self.is_gifted - ) - raise TwitchResourceNotFound - - async def set_user_authentication( - self, - token: str, - scope: list[AuthScope], - refresh_token: str | None = None, - validate: bool = True, - ) -> None: - """Set user authentication.""" - - async def get_followed_channels( - self, user_id: str, broadcaster_id: str | None = None - ) -> FollowedChannelsResult: - """Get followed channels.""" - if self.is_following: - return TwitchUserFollowResultMock( - [ - FollowedChannelMock( - followed_at=datetime(year=2023, month=8, day=1), - broadcaster_login="internetofthings", - ), - FollowedChannelMock( - followed_at=datetime(year=2023, month=8, day=1), - broadcaster_login="homeassistant", - ), - ] - ) - return TwitchUserFollowResultMock([]) - - async def get_channel_followers( - self, broadcaster_id: str - ) -> ChannelFollowersResultMock: - """Get channel followers.""" - return ChannelFollowersResultMock([ChannelFollowerMock(user_id="abc")]) - - async def get_streams( - self, user_id: list[str], first: int - ) -> AsyncGenerator[StreamMock, None]: - """Get streams for the user.""" - streams = [] - if self.is_streaming: - streams = [STREAMS] - for stream in streams: - yield stream - - -class TwitchUnauthorizedMock(TwitchMock): - """Twitch mock to test if the client is unauthorized.""" - - def __await__(self): - """Add async capabilities to the mock.""" - raise TwitchAuthorizationException - - -class TwitchMissingScopeMock(TwitchMock): - """Twitch mock to test missing scopes.""" - - async def set_user_authentication( - self, token: str, scope: list[AuthScope], validate: bool = True - ) -> None: - """Set user authentication.""" - raise MissingScopeException - - -class TwitchInvalidTokenMock(TwitchMock): - """Twitch mock to test invalid token.""" - - async def set_user_authentication( - self, token: str, scope: list[AuthScope], validate: bool = True - ) -> None: - """Set user authentication.""" - raise InvalidTokenException - - -class TwitchInvalidUserMock(TwitchMock): - """Twitch mock to test invalid user.""" - - async def get_users( - self, user_ids: list[str] | None = None, logins: list[str] | None = None - ) -> AsyncGenerator[TwitchUser, None]: - """Get list of mock users.""" - if user_ids is not None or logins is not None: - async for user in super().get_users(user_ids, logins): - yield user - else: - for user in []: - yield user - - -class TwitchAPIExceptionMock(TwitchMock): - """Twitch mock to test when twitch api throws unknown exception.""" - - async def check_user_subscription( - self, broadcaster_id: str, user_id: str - ) -> UserSubscriptionMock: - """Check if the user is subscribed.""" - raise TwitchAPIException +async def get_generator_from_data( + items: list[dict[str, Any]], target_type: type[TwitchType] +) -> AsyncGenerator[TwitchType]: + """Return async generator.""" + for item in items: + yield target_type(**item) diff --git a/tests/components/twitch/conftest.py b/tests/components/twitch/conftest.py index 1cebc068831..6c243a8dbbf 100644 --- a/tests/components/twitch/conftest.py +++ b/tests/components/twitch/conftest.py @@ -1,10 +1,11 @@ """Configure tests for the Twitch integration.""" -from collections.abc import Awaitable, Callable, Generator import time from unittest.mock import AsyncMock, patch import pytest +from twitchAPI.object.api import FollowedChannel, Stream, TwitchUser, UserSubscription +from typing_extensions import Generator from homeassistant.components.application_credentials import ( ClientCredential, @@ -14,11 +15,10 @@ from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN, OAUTH_SC from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry -from tests.components.twitch import TwitchMock -from tests.test_util.aiohttp import AiohttpClientMocker +from . import TwitchIterObject, get_generator -ComponentSetup = Callable[[TwitchMock | None], Awaitable[None]] +from tests.common import MockConfigEntry, load_json_object_fixture +from tests.test_util.aiohttp import AiohttpClientMocker CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -26,7 +26,7 @@ TITLE = "Test" @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.twitch.async_setup_entry", return_value=True @@ -92,23 +92,32 @@ def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: ) -@pytest.fixture(name="twitch_mock") -def twitch_mock() -> TwitchMock: +@pytest.fixture +def twitch_mock() -> Generator[AsyncMock]: """Return as fixture to inject other mocks.""" - return TwitchMock() - - -@pytest.fixture(name="twitch") -def mock_twitch(twitch_mock: TwitchMock): - """Mock Twitch.""" with ( patch( "homeassistant.components.twitch.Twitch", - return_value=twitch_mock, - ), + autospec=True, + ) as mock_client, patch( "homeassistant.components.twitch.config_flow.Twitch", - return_value=twitch_mock, + new=mock_client, ), ): - yield twitch_mock + mock_client.return_value.get_users = lambda *args, **kwargs: get_generator( + "get_users.json", TwitchUser + ) + mock_client.return_value.get_followed_channels.return_value = TwitchIterObject( + "get_followed_channels.json", FollowedChannel + ) + mock_client.return_value.get_streams.return_value = get_generator( + "get_streams.json", Stream + ) + mock_client.return_value.check_user_subscription.return_value = ( + UserSubscription( + **load_json_object_fixture("check_user_subscription.json", DOMAIN) + ) + ) + mock_client.return_value.has_required_auth.return_value = True + yield mock_client diff --git a/tests/components/twitch/fixtures/check_user_subscription.json b/tests/components/twitch/fixtures/check_user_subscription.json new file mode 100644 index 00000000000..b1b2a3d852a --- /dev/null +++ b/tests/components/twitch/fixtures/check_user_subscription.json @@ -0,0 +1,3 @@ +{ + "is_gift": true +} diff --git a/tests/components/twitch/fixtures/check_user_subscription_2.json b/tests/components/twitch/fixtures/check_user_subscription_2.json new file mode 100644 index 00000000000..94d56c5ee12 --- /dev/null +++ b/tests/components/twitch/fixtures/check_user_subscription_2.json @@ -0,0 +1,3 @@ +{ + "is_gift": false +} diff --git a/tests/components/twitch/fixtures/empty_response.json b/tests/components/twitch/fixtures/empty_response.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/tests/components/twitch/fixtures/empty_response.json @@ -0,0 +1 @@ +[] diff --git a/tests/components/twitch/fixtures/get_followed_channels.json b/tests/components/twitch/fixtures/get_followed_channels.json new file mode 100644 index 00000000000..4add7cc0a98 --- /dev/null +++ b/tests/components/twitch/fixtures/get_followed_channels.json @@ -0,0 +1,10 @@ +[ + { + "broadcaster_login": "internetofthings", + "followed_at": "2023-08-01" + }, + { + "broadcaster_login": "homeassistant", + "followed_at": "2023-08-01" + } +] diff --git a/tests/components/twitch/fixtures/get_streams.json b/tests/components/twitch/fixtures/get_streams.json new file mode 100644 index 00000000000..3714d97aaef --- /dev/null +++ b/tests/components/twitch/fixtures/get_streams.json @@ -0,0 +1,7 @@ +[ + { + "game_name": "Good game", + "title": "Title", + "thumbnail_url": "stream-medium.png" + } +] diff --git a/tests/components/twitch/fixtures/get_users.json b/tests/components/twitch/fixtures/get_users.json new file mode 100644 index 00000000000..b5262eb282e --- /dev/null +++ b/tests/components/twitch/fixtures/get_users.json @@ -0,0 +1,9 @@ +[ + { + "id": 123, + "display_name": "channel123", + "offline_image_url": "logo.png", + "profile_image_url": "logo.png", + "view_count": 42 + } +] diff --git a/tests/components/twitch/fixtures/get_users_2.json b/tests/components/twitch/fixtures/get_users_2.json new file mode 100644 index 00000000000..11ed194213a --- /dev/null +++ b/tests/components/twitch/fixtures/get_users_2.json @@ -0,0 +1,9 @@ +[ + { + "id": 456, + "display_name": "channel123", + "offline_image_url": "logo.png", + "profile_image_url": "logo.png", + "view_count": 42 + } +] diff --git a/tests/components/twitch/test_config_flow.py b/tests/components/twitch/test_config_flow.py index 94fa2ce0427..6935943a4d3 100644 --- a/tests/components/twitch/test_config_flow.py +++ b/tests/components/twitch/test_config_flow.py @@ -1,6 +1,9 @@ """Test config flow for Twitch.""" -from unittest.mock import patch +from unittest.mock import AsyncMock + +import pytest +from twitchAPI.object.api import TwitchUser from homeassistant.components.twitch.const import ( CONF_CHANNELS, @@ -12,11 +15,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from . import setup_integration +from . import get_generator, setup_integration +from .conftest import CLIENT_ID, TITLE from tests.common import MockConfigEntry -from tests.components.twitch import TwitchMock -from tests.components.twitch.conftest import CLIENT_ID, TITLE from tests.typing import ClientSessionGenerator @@ -46,12 +48,12 @@ async def _do_get_token( assert resp.headers["content-type"] == "text/html; charset=utf-8" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, mock_setup_entry, - twitch: TwitchMock, + twitch_mock: AsyncMock, scopes: list[str], ) -> None: """Check full flow.""" @@ -74,13 +76,13 @@ async def test_full_flow( assert result["options"] == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} +@pytest.mark.usefixtures("current_request_with_host") async def test_already_configured( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, config_entry: MockConfigEntry, mock_setup_entry, - twitch: TwitchMock, + twitch_mock: AsyncMock, scopes: list[str], ) -> None: """Check flow aborts when account already configured.""" @@ -90,22 +92,19 @@ async def test_already_configured( ) await _do_get_token(hass, result, hass_client_no_auth, scopes) - with patch( - "homeassistant.components.twitch.config_flow.Twitch", return_value=TwitchMock() - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, config_entry: MockConfigEntry, mock_setup_entry, - twitch: TwitchMock, + twitch_mock: AsyncMock, scopes: list[str], ) -> None: """Check reauth flow.""" @@ -131,12 +130,12 @@ async def test_reauth( assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_from_import( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, mock_setup_entry, - twitch: TwitchMock, + twitch_mock: AsyncMock, expires_at, scopes: list[str], ) -> None: @@ -160,10 +159,9 @@ async def test_reauth_from_import( await test_reauth( hass, hass_client_no_auth, - current_request_with_host, config_entry, mock_setup_entry, - twitch, + twitch_mock, scopes, ) entries = hass.config_entries.async_entries(DOMAIN) @@ -172,18 +170,20 @@ async def test_reauth_from_import( assert entry.options == {CONF_CHANNELS: ["internetofthings", "homeassistant"]} +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, config_entry: MockConfigEntry, mock_setup_entry, - twitch: TwitchMock, + twitch_mock: AsyncMock, scopes: list[str], ) -> None: """Check reauth flow.""" await setup_integration(hass, config_entry) - twitch.different_user_id = True + twitch_mock.return_value.get_users = lambda *args, **kwargs: get_generator( + "get_users_2.json", TwitchUser + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={ diff --git a/tests/components/twitch/test_init.py b/tests/components/twitch/test_init.py index d3b9313c46e..6261c69bf7d 100644 --- a/tests/components/twitch/test_init.py +++ b/tests/components/twitch/test_init.py @@ -1,8 +1,8 @@ -"""Tests for YouTube.""" +"""Tests for Twitch.""" import http import time -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aiohttp.client_exceptions import ClientError import pytest @@ -11,14 +11,14 @@ from homeassistant.components.twitch.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import TwitchMock, setup_integration +from . import setup_integration from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker async def test_setup_success( - hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock + hass: HomeAssistant, config_entry: MockConfigEntry, twitch_mock: AsyncMock ) -> None: """Test successful setup and unload.""" await setup_integration(hass, config_entry) @@ -38,7 +38,7 @@ async def test_expired_token_refresh_success( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, - twitch: TwitchMock, + twitch_mock: AsyncMock, ) -> None: """Test expired token is refreshed.""" @@ -84,7 +84,7 @@ async def test_expired_token_refresh_failure( status: http.HTTPStatus, expected_state: ConfigEntryState, config_entry: MockConfigEntry, - twitch: TwitchMock, + twitch_mock: AsyncMock, ) -> None: """Test failure while refreshing token with a transient error.""" @@ -93,8 +93,10 @@ async def test_expired_token_refresh_failure( OAUTH2_TOKEN, status=status, ) + config_entry.add_to_hass(hass) - await setup_integration(hass, config_entry) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() # Verify a transient failure has occurred entries = hass.config_entries.async_entries(DOMAIN) @@ -102,7 +104,7 @@ async def test_expired_token_refresh_failure( async def test_expired_token_refresh_client_error( - hass: HomeAssistant, config_entry: MockConfigEntry, twitch: TwitchMock + hass: HomeAssistant, config_entry: MockConfigEntry, twitch_mock: AsyncMock ) -> None: """Test failure while refreshing token with a client error.""" @@ -110,7 +112,10 @@ async def test_expired_token_refresh_client_error( "homeassistant.components.twitch.OAuth2Session.async_ensure_token_valid", side_effect=ClientError, ): - await setup_integration(hass, config_entry) + config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() # Verify a transient failure has occurred entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/twitch/test_sensor.py b/tests/components/twitch/test_sensor.py index bb6624f7847..e5cddf8e192 100644 --- a/tests/components/twitch/test_sensor.py +++ b/tests/components/twitch/test_sensor.py @@ -1,30 +1,28 @@ """The tests for an update of the Twitch component.""" from datetime import datetime +from unittest.mock import AsyncMock -import pytest +from twitchAPI.object.api import FollowedChannel, Stream, UserSubscription +from twitchAPI.type import TwitchResourceNotFound +from homeassistant.components.twitch import DOMAIN from homeassistant.core import HomeAssistant -from ...common import MockConfigEntry -from . import ( - TwitchAPIExceptionMock, - TwitchInvalidTokenMock, - TwitchInvalidUserMock, - TwitchMissingScopeMock, - TwitchMock, - TwitchUnauthorizedMock, - setup_integration, -) +from . import TwitchIterObject, get_generator_from_data, setup_integration + +from tests.common import MockConfigEntry, load_json_object_fixture ENTITY_ID = "sensor.channel123" async def test_offline( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry + hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry ) -> None: """Test offline state.""" - twitch.is_streaming = False + twitch_mock.return_value.get_streams.return_value = get_generator_from_data( + [], Stream + ) await setup_integration(hass, config_entry) sensor_state = hass.states.get(ENTITY_ID) @@ -33,7 +31,7 @@ async def test_offline( async def test_streaming( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry + hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry ) -> None: """Test streaming state.""" await setup_integration(hass, config_entry) @@ -46,10 +44,15 @@ async def test_streaming( async def test_oauth_without_sub_and_follow( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry + hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry ) -> None: """Test state with oauth.""" - twitch.is_following = False + twitch_mock.return_value.get_followed_channels.return_value = TwitchIterObject( + "empty_response.json", FollowedChannel + ) + twitch_mock.return_value.check_user_subscription.side_effect = ( + TwitchResourceNotFound + ) await setup_integration(hass, config_entry) sensor_state = hass.states.get(ENTITY_ID) @@ -58,11 +61,15 @@ async def test_oauth_without_sub_and_follow( async def test_oauth_with_sub( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry + hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry ) -> None: """Test state with oauth and sub.""" - twitch.is_subscribed = True - twitch.is_following = False + twitch_mock.return_value.get_followed_channels.return_value = TwitchIterObject( + "empty_response.json", FollowedChannel + ) + twitch_mock.return_value.check_user_subscription.return_value = UserSubscription( + **load_json_object_fixture("check_user_subscription_2.json", DOMAIN) + ) await setup_integration(hass, config_entry) sensor_state = hass.states.get(ENTITY_ID) @@ -72,7 +79,7 @@ async def test_oauth_with_sub( async def test_oauth_with_follow( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry + hass: HomeAssistant, twitch_mock: AsyncMock, config_entry: MockConfigEntry ) -> None: """Test state with oauth and follow.""" await setup_integration(hass, config_entry) @@ -82,40 +89,3 @@ async def test_oauth_with_follow( assert sensor_state.attributes["following_since"] == datetime( year=2023, month=8, day=1 ) - - -@pytest.mark.parametrize( - "twitch_mock", - [TwitchUnauthorizedMock(), TwitchMissingScopeMock(), TwitchInvalidTokenMock()], -) -async def test_auth_invalid( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry -) -> None: - """Test auth failures.""" - await setup_integration(hass, config_entry) - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state is None - - -@pytest.mark.parametrize("twitch_mock", [TwitchInvalidUserMock()]) -async def test_auth_with_invalid_user( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry -) -> None: - """Test auth with invalid user.""" - await setup_integration(hass, config_entry) - - sensor_state = hass.states.get(ENTITY_ID) - assert "subscribed" not in sensor_state.attributes - - -@pytest.mark.parametrize("twitch_mock", [TwitchAPIExceptionMock()]) -async def test_auth_with_api_exception( - hass: HomeAssistant, twitch: TwitchMock, config_entry: MockConfigEntry -) -> None: - """Test auth with invalid user.""" - await setup_integration(hass, config_entry) - - sensor_state = hass.states.get(ENTITY_ID) - assert sensor_state.attributes["subscribed"] is False - assert "subscription_is_gifted" not in sensor_state.attributes diff --git a/tests/components/ukraine_alarm/test_config_flow.py b/tests/components/ukraine_alarm/test_config_flow.py index ba37f188079..58b5dde2bac 100644 --- a/tests/components/ukraine_alarm/test_config_flow.py +++ b/tests/components/ukraine_alarm/test_config_flow.py @@ -1,10 +1,10 @@ """Test the Ukraine Alarm config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from aiohttp import ClientConnectionError, ClientError, ClientResponseError, RequestInfo import pytest +from typing_extensions import Generator from yarl import URL from homeassistant import config_entries @@ -41,7 +41,7 @@ REGIONS = { @pytest.fixture(autouse=True) -def mock_get_regions() -> Generator[None, AsyncMock, None]: +def mock_get_regions() -> Generator[AsyncMock]: """Mock the get_regions method.""" with patch( diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 1ef8948ec51..4a7d86eea38 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -3,103 +3,59 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta -from unittest.mock import patch +from types import MappingProxyType +from typing import Any +from unittest.mock import AsyncMock, patch from aiounifi.models.message import MessageKey +import orjson import pytest +from typing_extensions import Generator -from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.components.unifi import STORAGE_KEY, STORAGE_VERSION +from homeassistant.components.unifi.const import CONF_SITE_ID, DOMAIN as UNIFI_DOMAIN from homeassistant.components.unifi.hub.websocket import RETRY_TIMER -from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.components.unifi.test_hub import DEFAULT_CONFIG_ENTRY_ID from tests.test_util.aiohttp import AiohttpClientMocker +DEFAULT_CONFIG_ENTRY_ID = "1" +DEFAULT_HOST = "1.2.3.4" +DEFAULT_PORT = 1234 +DEFAULT_SITE = "site_id" -class WebsocketStateManager(asyncio.Event): - """Keep an async event that simules websocket context manager. - - Prepares disconnect and reconnect flows. - """ - - def __init__(self, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): - """Store hass object and initialize asyncio.Event.""" - self.hass = hass - self.aioclient_mock = aioclient_mock - super().__init__() - - async def disconnect(self): - """Mark future as done to make 'await self.api.start_websocket' return.""" - self.set() - await self.hass.async_block_till_done() - - async def reconnect(self, fail=False): - """Set up new future to make 'await self.api.start_websocket' block. - - Mock api calls done by 'await self.api.login'. - Fail will make 'await self.api.start_websocket' return immediately. - """ - hub = self.hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] - self.aioclient_mock.get( - f"https://{hub.config.host}:1234", status=302 - ) # Check UniFi OS - self.aioclient_mock.post( - f"https://{hub.config.host}:1234/api/login", - json={"data": "login successful", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - if not fail: - self.clear() - new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) - async_fire_time_changed(self.hass, new_time) - await self.hass.async_block_till_done() +CONTROLLER_HOST = { + "hostname": "controller_host", + "ip": DEFAULT_HOST, + "is_wired": True, + "last_seen": 1562600145, + "mac": "10:00:00:00:00:01", + "name": "Controller host", + "oui": "Producer", + "sw_mac": "00:00:00:00:01:01", + "sw_port": 1, + "wired-rx_bytes": 1234000000, + "wired-tx_bytes": 5678000000, + "uptime": 1562600160, +} -@pytest.fixture(autouse=True) -def websocket_mock(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): - """Mock 'await self.api.start_websocket' in 'UniFiController.start_websocket'.""" - websocket_state_manager = WebsocketStateManager(hass, aioclient_mock) - with patch("aiounifi.Controller.start_websocket") as ws_mock: - ws_mock.side_effect = websocket_state_manager.wait - yield websocket_state_manager - - -@pytest.fixture(autouse=True) -def mock_unifi_websocket(hass): - """No real websocket allowed.""" - - def make_websocket_call( - *, - message: MessageKey | None = None, - data: list[dict] | dict | None = None, - ): - """Generate a websocket call.""" - hub = hass.data[UNIFI_DOMAIN][DEFAULT_CONFIG_ENTRY_ID] - if data and not message: - hub.api.messages.handler(data) - elif data and message: - if not isinstance(data, list): - data = [data] - hub.api.messages.handler( - { - "meta": {"message": message.value}, - "data": data, - } - ) - else: - raise NotImplementedError - - return make_websocket_call - - -@pytest.fixture(autouse=True) -def mock_discovery(): +@pytest.fixture(autouse=True, name="mock_discovery") +def fixture_discovery(): """No real network traffic allowed.""" with patch( "homeassistant.components.unifi.config_flow._async_discover_unifi", @@ -108,8 +64,8 @@ def mock_discovery(): yield mock -@pytest.fixture -def mock_device_registry(hass, device_registry: dr.DeviceRegistry): +@pytest.fixture(name="mock_device_registry") +def fixture_device_registry(hass: HomeAssistant, device_registry: dr.DeviceRegistry): """Mock device registry.""" config_entry = MockConfigEntry(domain="something_else") config_entry.add_to_hass(hass) @@ -131,3 +87,306 @@ def mock_device_registry(hass, device_registry: dr.DeviceRegistry): config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, device)}, ) + + +# Config entry fixtures + + +@pytest.fixture(name="config_entry") +def fixture_config_entry( + hass: HomeAssistant, + config_entry_data: MappingProxyType[str, Any], + config_entry_options: MappingProxyType[str, Any], +) -> ConfigEntry: + """Define a config entry fixture.""" + config_entry = MockConfigEntry( + domain=UNIFI_DOMAIN, + entry_id="1", + unique_id="1", + data=config_entry_data, + options=config_entry_options, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(name="config_entry_data") +def fixture_config_entry_data() -> MappingProxyType[str, Any]: + """Define a config entry data fixture.""" + return { + CONF_HOST: DEFAULT_HOST, + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: DEFAULT_PORT, + CONF_SITE_ID: DEFAULT_SITE, + CONF_VERIFY_SSL: False, + } + + +@pytest.fixture(name="config_entry_options") +def fixture_config_entry_options() -> MappingProxyType[str, Any]: + """Define a config entry options fixture.""" + return {} + + +# Known wireless clients + + +@pytest.fixture(name="known_wireless_clients") +def fixture_known_wireless_clients() -> list[str]: + """Known previously observed wireless clients.""" + return [] + + +@pytest.fixture(autouse=True, name="mock_wireless_client_storage") +def fixture_wireless_client_storage( + hass_storage: dict[str, Any], known_wireless_clients: list[str] +): + """Mock the known wireless storage.""" + data: dict[str, list[str]] = ( + {"wireless_clients": known_wireless_clients} if known_wireless_clients else {} + ) + hass_storage[STORAGE_KEY] = {"version": STORAGE_VERSION, "data": data} + + +# UniFi request mocks + + +@pytest.fixture(name="mock_requests") +def fixture_request( + aioclient_mock: AiohttpClientMocker, + client_payload: list[dict[str, Any]], + clients_all_payload: list[dict[str, Any]], + device_payload: list[dict[str, Any]], + dpi_app_payload: list[dict[str, Any]], + dpi_group_payload: list[dict[str, Any]], + port_forward_payload: list[dict[str, Any]], + site_payload: list[dict[str, Any]], + system_information_payload: list[dict[str, Any]], + wlan_payload: list[dict[str, Any]], +) -> Callable[[str], None]: + """Mock default UniFi requests responses.""" + + def __mock_requests(host: str = DEFAULT_HOST, site_id: str = DEFAULT_SITE) -> None: + url = f"https://{host}:{DEFAULT_PORT}" + + def mock_get_request(path: str, payload: list[dict[str, Any]]) -> None: + aioclient_mock.get( + f"{url}{path}", + json={"meta": {"rc": "OK"}, "data": payload}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get(url, status=302) # UniFI OS check + aioclient_mock.post( + f"{url}/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + mock_get_request("/api/self/sites", site_payload) + mock_get_request(f"/api/s/{site_id}/stat/sta", client_payload) + mock_get_request(f"/api/s/{site_id}/rest/user", clients_all_payload) + mock_get_request(f"/api/s/{site_id}/stat/device", device_payload) + mock_get_request(f"/api/s/{site_id}/rest/dpiapp", dpi_app_payload) + mock_get_request(f"/api/s/{site_id}/rest/dpigroup", dpi_group_payload) + mock_get_request(f"/api/s/{site_id}/rest/portforward", port_forward_payload) + mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) + mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) + + return __mock_requests + + +# Request payload fixtures + + +@pytest.fixture(name="client_payload") +def fixture_client_data() -> list[dict[str, Any]]: + """Client data.""" + return [] + + +@pytest.fixture(name="clients_all_payload") +def fixture_clients_all_data() -> list[dict[str, Any]]: + """Clients all data.""" + return [] + + +@pytest.fixture(name="device_payload") +def fixture_device_data() -> list[dict[str, Any]]: + """Device data.""" + return [] + + +@pytest.fixture(name="dpi_app_payload") +def fixture_dpi_app_data() -> list[dict[str, Any]]: + """DPI app data.""" + return [] + + +@pytest.fixture(name="dpi_group_payload") +def fixture_dpi_group_data() -> list[dict[str, Any]]: + """DPI group data.""" + return [] + + +@pytest.fixture(name="port_forward_payload") +def fixture_port_forward_data() -> list[dict[str, Any]]: + """Port forward data.""" + return [] + + +@pytest.fixture(name="site_payload") +def fixture_site_data() -> list[dict[str, Any]]: + """Site data.""" + return [{"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"}] + + +@pytest.fixture(name="system_information_payload") +def fixture_system_information_data() -> list[dict[str, Any]]: + """System information data.""" + return [ + { + "anonymous_controller_id": "24f81231-a456-4c32-abcd-f5612345385f", + "build": "atag_7.4.162_21057", + "console_display_version": "3.1.15", + "hostname": "UDMP", + "name": "UDMP", + "previous_version": "7.4.156", + "timezone": "Europe/Stockholm", + "ubnt_device_type": "UDMPRO", + "udm_version": "3.0.20.9281", + "update_available": False, + "update_downloaded": False, + "uptime": 1196290, + "version": "7.4.162", + } + ] + + +@pytest.fixture(name="wlan_payload") +def fixture_wlan_data() -> list[dict[str, Any]]: + """WLAN data.""" + return [] + + +@pytest.fixture(name="mock_default_requests") +def fixture_default_requests( + mock_requests: Callable[[str, str], None], +) -> None: + """Mock UniFi requests responses with default host and site.""" + mock_requests(DEFAULT_HOST, DEFAULT_SITE) + + +@pytest.fixture(name="config_entry_factory") +async def fixture_config_entry_factory( + hass: HomeAssistant, + config_entry: ConfigEntry, + mock_requests: Callable[[str, str], None], +) -> Callable[[], ConfigEntry]: + """Fixture factory that can set up UniFi network integration.""" + + async def __mock_setup_config_entry() -> ConfigEntry: + mock_requests(config_entry.data[CONF_HOST], config_entry.data[CONF_SITE_ID]) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry + + return __mock_setup_config_entry + + +@pytest.fixture(name="config_entry_setup") +async def fixture_config_entry_setup( + hass: HomeAssistant, config_entry_factory: Callable[[], ConfigEntry] +) -> ConfigEntry: + """Fixture providing a set up instance of UniFi network integration.""" + return await config_entry_factory() + + +# Websocket fixtures + + +class WebsocketStateManager(asyncio.Event): + """Keep an async event that simules websocket context manager. + + Prepares disconnect and reconnect flows. + """ + + def __init__( + self, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + ) -> None: + """Store hass object and initialize asyncio.Event.""" + self.hass = hass + self.aioclient_mock = aioclient_mock + super().__init__() + + async def waiter(self, input: Callable[[bytes], None]) -> None: + """Consume message_handler new_data callback.""" + await self.wait() + + async def disconnect(self) -> None: + """Mark future as done to make 'await self.api.start_websocket' return.""" + self.set() + await self.hass.async_block_till_done() + + async def reconnect(self, fail: bool = False) -> None: + """Set up new future to make 'await self.api.start_websocket' block. + + Mock api calls done by 'await self.api.login'. + Fail will make 'await self.api.start_websocket' return immediately. + """ + # Check UniFi OS + self.aioclient_mock.get(f"https://{DEFAULT_HOST}:1234", status=302) + self.aioclient_mock.post( + f"https://{DEFAULT_HOST}:1234/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + if not fail: + self.clear() + new_time = dt_util.utcnow() + timedelta(seconds=RETRY_TIMER) + async_fire_time_changed(self.hass, new_time) + await self.hass.async_block_till_done() + + +@pytest.fixture(autouse=True, name="_mock_websocket") +def fixture_aiounifi_websocket_method() -> Generator[AsyncMock]: + """Mock aiounifi websocket context manager.""" + with patch("aiounifi.controller.Connectivity.websocket") as ws_mock: + yield ws_mock + + +@pytest.fixture(autouse=True, name="mock_websocket_state") +def fixture_aiounifi_websocket_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, _mock_websocket: AsyncMock +) -> WebsocketStateManager: + """Provide a state manager for UniFi websocket.""" + websocket_state_manager = WebsocketStateManager(hass, aioclient_mock) + _mock_websocket.side_effect = websocket_state_manager.waiter + return websocket_state_manager + + +@pytest.fixture(name="mock_websocket_message") +def fixture_aiounifi_websocket_message(_mock_websocket: AsyncMock): + """No real websocket allowed.""" + + def make_websocket_call( + *, + message: MessageKey | None = None, + data: list[dict] | dict | None = None, + ) -> None: + """Generate a websocket call.""" + message_handler = _mock_websocket.call_args[0][0] + + if data and not message: + message_handler(orjson.dumps(data)) + elif data and message: + if not isinstance(data, list): + data = [data] + message_handler( + orjson.dumps({"meta": {"message": message.value}, "data": data}) + ) + else: + raise NotImplementedError + + return make_websocket_call diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..fb7415c59ab --- /dev/null +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -0,0 +1,129 @@ +# serializer version: 1 +# name: test_entry_diagnostics[dpi_group_payload0-dpi_app_payload0-device_payload0-client_payload0-config_entry_options0] + dict({ + 'clients': dict({ + '00:00:00:00:00:00': dict({ + 'blocked': False, + 'hostname': 'client_1', + 'ip': '10.0.0.1', + 'is_wired': True, + 'last_seen': 1562600145, + 'mac': '00:00:00:00:00:00', + 'name': 'POE Client 1', + 'oui': 'Producer', + 'sw_mac': '00:00:00:00:00:01', + 'sw_port': 1, + 'wired-rx_bytes': 1234000000, + 'wired-tx_bytes': 5678000000, + }), + }), + 'config': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'password': '**REDACTED**', + 'port': 1234, + 'site': 'site_id', + 'username': '**REDACTED**', + 'verify_ssl': False, + }), + 'disabled_by': None, + 'domain': 'unifi', + 'entry_id': '1', + 'minor_version': 1, + 'options': dict({ + 'allow_bandwidth_sensors': True, + 'allow_uptime_sensors': True, + 'block_client': list([ + '00:00:00:00:00:00', + ]), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '1', + 'version': 1, + }), + 'devices': dict({ + '00:00:00:00:00:01': dict({ + 'board_rev': '1.2.3', + 'device_id': 'mock-id', + 'ethernet_table': list([ + dict({ + 'mac': '00:00:00:00:00:02', + 'name': 'eth0', + 'num_port': 2, + }), + ]), + 'ip': '10.0.1.1', + 'last_seen': 1562600145, + 'mac': '00:00:00:00:00:01', + 'model': 'US16P150', + 'name': 'mock-name', + 'port_overrides': list([ + ]), + 'port_table': list([ + dict({ + 'mac_table': list([ + dict({ + 'age': 1, + 'mac': '00:00:00:00:00:00', + 'static': False, + 'uptime': 3971792, + 'vlan': 1, + }), + dict({ + 'age': 1, + 'mac': '**REDACTED**', + 'static': True, + 'uptime': 0, + 'vlan': 0, + }), + ]), + 'media': 'GE', + 'name': 'Port 1', + 'poe_class': 'Class 4', + 'poe_enable': True, + 'poe_mode': 'auto', + 'poe_power': '2.56', + 'poe_voltage': '53.40', + 'port_idx': 1, + 'port_poe': True, + 'portconf_id': '1a1', + 'up': True, + }), + ]), + 'state': 1, + 'type': 'usw', + 'version': '4.0.42.10433', + }), + }), + 'dpi_apps': dict({ + '5f976f62e3c58f018ec7e17d': dict({ + '_id': '5f976f62e3c58f018ec7e17d', + 'apps': list([ + ]), + 'blocked': True, + 'cats': list([ + '4', + ]), + 'enabled': True, + 'log': True, + 'site_id': 'name', + }), + }), + 'dpi_groups': dict({ + '5f976f4ae3c58f018ec7dff6': dict({ + '_id': '5f976f4ae3c58f018ec7dff6', + 'dpiapp_ids': list([ + '5f976f62e3c58f018ec7e17d', + ]), + 'name': 'Block Media Streaming', + 'site_id': 'name', + }), + }), + 'role_is_admin': True, + 'wlans': dict({ + }), + }) +# --- diff --git a/tests/components/unifi/snapshots/test_image.ambr b/tests/components/unifi/snapshots/test_image.ambr index 77b171118a1..83d76688ea3 100644 --- a/tests/components/unifi/snapshots/test_image.ambr +++ b/tests/components/unifi/snapshots/test_image.ambr @@ -5,3 +5,9 @@ # name: test_wlan_qr_code.1 b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x94\x00\x00\x00\x94\x01\x00\x00\x00\x00]G=y\x00\x00\x00\xfdIDATx\xda\xedV1\x8e\x041\x0cB\xf7\x01\xff\xff\x97\xfc\xc0\x0bd\xb6\xda\xe6\xeeB\xb9V\xa4dR \xc7`<\xd8\x8f \xbew\x7f\xb9\x030\x98!\xb5\xe9\xb8\xfc\xc1g\xfc\xf6Nx\xa3%\x9c\x84\xbf\xae\xf1\x84\xb5 \xe796\xf0\\\npjx~1[xZ\\\xbfy+\xf5\xc3\x9b\x8c\xe9\xf0\xeb\xd0k]\xbe\xa3\xa1\xeb\xfaI\x850\xa2Ex\x9f\x1f-\xeb\xe46!\xba\xc0G\x18\xde\xb0|\x8f\x07e8\xca\xd0\xc0,\xd4/\xed&PA\x1a\xf5\xbe~R2m\x07\x8fa\\\xe3\x9d\xc4DnG\x7f\xb0F&\xc4L\xa3~J\xcciy\xdfF\xff\x9a`i\xda$w\xfcom\xcc\x02Kw\x14\xf4\xc2\xd3fn\xba-\xf0A&A\xe2\x0c\x92\x8e\xbfL<\xcb.\xd8\xf1?0~o\xc14\xfcy\xdc\xc48\xa6\xd0\x98\x1f\x99\xbd\xfb\xd0\xd3\x98o\xd1tFR\x07\x8f\xe95lo\xbeE\x88`\x8f\xdf\x8c`lE\x7f\xdf\xff\xc4\x7f\xde\xbd\x00\xfc\xb3\x80\x95k\x06#\x19\x00\x00\x00\x00IEND\xaeB`\x82' # --- +# name: test_wlan_qr_code[wlan_payload0] + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x84\x00\x00\x00\x84\x01\x00\x00\x00\x00y?\xbe\n\x00\x00\x00\xcaIDATx\xda\xedV[\n\xc30\x0c\x13\xbb\x80\xef\x7fK\xdd\xc0\x93\x94\xfd\xac\x1fcL\xfbl(\xc4\x04*\xacG\xdcb/\x8b\xb8O\xdeO\x00\xccP\x95\x8b\xe5\x03\xd7\xf5\xcd\x89pF\xcf\x8c \\48\x08\nS\x948\x03p\xfe\x80C\xa8\x9d\x16\xc7P\xabvJ}\xe2\xd7\x84[\xe5W\xfc7\xbbS\xfd\xde\xcfB\xf115\xa2\xe3%\x99\xad\x93\xa0:\xbf6\xbeS\xec\x1a^\xb4\xed\xfb\xb2\xab\xd1\x99\xc9\xcdAjx\x89\x0e\xc5\xea\xf4T\xf9\xee\xe40m58\xb6<\x1b\xab~\xf4\xban\xd7:\xceu\x9e\x05\xc4I\xa6\xbb\xfb%q<7:\xbf\xa2\x90wo\xf5 +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-config_entry_options0].3 + +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-config_entry_options0].4 + '1234.0' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-rx--config_entry_options0] + 'rx-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-rx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-rx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-rx--config_entry_options0].3 + 'Wired client RX' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-rx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-rx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_rx-rx--config_entry_options0].6 + '1234.0' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_uptime-uptime--config_entry_options0] + 'uptime-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_uptime-uptime--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_uptime-uptime--config_entry_options0].2 + 'timestamp' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_uptime-uptime--config_entry_options0].3 + 'Wired client Uptime' +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_uptime-uptime--config_entry_options0].4 + None +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_uptime-uptime--config_entry_options0].5 + None +# --- +# name: test_sensor_sources[client_payload0-sensor.wired_client_uptime-uptime--config_entry_options0].6 + '2020-09-14T14:41:45+00:00' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_rx-rx--config_entry_options0] + 'rx-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_rx-rx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_rx-rx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_rx-rx--config_entry_options0].3 + 'Wired client RX' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_rx-rx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_rx-rx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_rx-rx--config_entry_options0].6 + '1234.0' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-config_entry_options0] + 'data_rate' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-config_entry_options0].1 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-config_entry_options0].2 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-config_entry_options0].3 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-config_entry_options0].4 + '5678.0' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-tx--config_entry_options0] + 'tx-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-tx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-tx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-tx--config_entry_options0].3 + 'Wired client TX' +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-tx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-tx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload1-sensor.wired_client_tx-tx--config_entry_options0].6 + '5678.0' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_tx-tx--config_entry_options0] + 'tx-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_tx-tx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_tx-tx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_tx-tx--config_entry_options0].3 + 'Wired client TX' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_tx-tx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_tx-tx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_tx-tx--config_entry_options0].6 + '5678.0' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_uptime-uptime--config_entry_options0] + 'uptime-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_uptime-uptime--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_uptime-uptime--config_entry_options0].2 + 'timestamp' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_uptime-uptime--config_entry_options0].3 + 'Wired client Uptime' +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_uptime-uptime--config_entry_options0].4 + None +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_uptime-uptime--config_entry_options0].5 + None +# --- +# name: test_sensor_sources[client_payload2-sensor.wired_client_uptime-uptime--config_entry_options0].6 + '2020-09-14T14:41:45+00:00' +# --- +# name: test_sensor_sources[client_payload2-sensor.wireless_client_rx-rx--config_entry_options0] + 'rx-00:00:00:00:00:02' +# --- +# name: test_sensor_sources[client_payload2-sensor.wireless_client_rx-rx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload2-sensor.wireless_client_rx-rx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload2-sensor.wireless_client_rx-rx--config_entry_options0].3 + 'Wireless client RX' +# --- +# name: test_sensor_sources[client_payload2-sensor.wireless_client_rx-rx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload2-sensor.wireless_client_rx-rx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload2-sensor.wireless_client_rx-rx--config_entry_options0].6 + '2345.0' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_rx-rx--config_entry_options0] + 'rx-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_rx-rx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_rx-rx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_rx-rx--config_entry_options0].3 + 'Wireless client RX' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_rx-rx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_rx-rx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_rx-rx--config_entry_options0].6 + '2345.0' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_tx-tx--config_entry_options0] + 'tx-00:00:00:00:00:02' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_tx-tx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_tx-tx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_tx-tx--config_entry_options0].3 + 'Wireless client TX' +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_tx-tx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_tx-tx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload3-sensor.wireless_client_tx-tx--config_entry_options0].6 + '6789.0' +# --- +# name: test_sensor_sources[client_payload4-sensor.wireless_client_tx-tx--config_entry_options0] + 'tx-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload4-sensor.wireless_client_tx-tx--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload4-sensor.wireless_client_tx-tx--config_entry_options0].2 + 'data_rate' +# --- +# name: test_sensor_sources[client_payload4-sensor.wireless_client_tx-tx--config_entry_options0].3 + 'Wireless client TX' +# --- +# name: test_sensor_sources[client_payload4-sensor.wireless_client_tx-tx--config_entry_options0].4 + +# --- +# name: test_sensor_sources[client_payload4-sensor.wireless_client_tx-tx--config_entry_options0].5 + +# --- +# name: test_sensor_sources[client_payload4-sensor.wireless_client_tx-tx--config_entry_options0].6 + '6789.0' +# --- +# name: test_sensor_sources[client_payload5-sensor.wireless_client_uptime-uptime--config_entry_options0] + 'uptime-00:00:00:00:00:01' +# --- +# name: test_sensor_sources[client_payload5-sensor.wireless_client_uptime-uptime--config_entry_options0].1 + +# --- +# name: test_sensor_sources[client_payload5-sensor.wireless_client_uptime-uptime--config_entry_options0].2 + 'timestamp' +# --- +# name: test_sensor_sources[client_payload5-sensor.wireless_client_uptime-uptime--config_entry_options0].3 + 'Wireless client Uptime' +# --- +# name: test_sensor_sources[client_payload5-sensor.wireless_client_uptime-uptime--config_entry_options0].4 + None +# --- +# name: test_sensor_sources[client_payload5-sensor.wireless_client_uptime-uptime--config_entry_options0].5 + None +# --- +# name: test_sensor_sources[client_payload5-sensor.wireless_client_uptime-uptime--config_entry_options0].6 + '2021-01-01T01:00:00+00:00' +# --- diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 8f9838e3e37..b58d01e7724 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -1,10 +1,14 @@ """UniFi Network button platform tests.""" from datetime import timedelta +from typing import Any +from unittest.mock import patch + +import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass from homeassistant.components.unifi.const import CONF_SITE_ID -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, CONF_HOST, @@ -17,266 +21,301 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util -from .test_hub import setup_unifi_integration - from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -WLAN_ID = "_id" -WLAN = { - WLAN_ID: "012345678910111213141516", - "bc_filter_enabled": False, - "bc_filter_list": [], - "dtim_mode": "default", - "dtim_na": 1, - "dtim_ng": 1, - "enabled": True, - "group_rekey": 3600, - "mac_filter_enabled": False, - "mac_filter_list": [], - "mac_filter_policy": "allow", - "minrate_na_advertising_rates": False, - "minrate_na_beacon_rate_kbps": 6000, - "minrate_na_data_rate_kbps": 6000, - "minrate_na_enabled": False, - "minrate_na_mgmt_rate_kbps": 6000, - "minrate_ng_advertising_rates": False, - "minrate_ng_beacon_rate_kbps": 1000, - "minrate_ng_data_rate_kbps": 1000, - "minrate_ng_enabled": False, - "minrate_ng_mgmt_rate_kbps": 1000, - "name": "SSID 1", - "no2ghz_oui": False, - "schedule": [], - "security": "wpapsk", - "site_id": "5a32aa4ee4b0412345678910", - "usergroup_id": "012345678910111213141518", - "wep_idx": 1, - "wlangroup_id": "012345678910111213141519", - "wpa_enc": "ccmp", - "wpa_mode": "wpa2", - "x_iapp_key": "01234567891011121314151617181920", - "x_passphrase": "password", -} +RANDOM_TOKEN = "random_token" -async def test_restart_device_button( +@pytest.fixture(autouse=True) +def mock_secret(): + """Mock secret.""" + with patch("secrets.token_urlsafe", return_value=RANDOM_TOKEN): + yield + + +DEVICE_RESTART = [ + { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } +] + +DEVICE_POWER_CYCLE_POE = [ + { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_caps": 7, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + } +] + +WLAN_REGENERATE_PASSWORD = [ + { + "_id": "012345678910111213141516", + "bc_filter_enabled": False, + "bc_filter_list": [], + "dtim_mode": "default", + "dtim_na": 1, + "dtim_ng": 1, + "enabled": True, + "group_rekey": 3600, + "mac_filter_enabled": False, + "mac_filter_list": [], + "mac_filter_policy": "allow", + "minrate_na_advertising_rates": False, + "minrate_na_beacon_rate_kbps": 6000, + "minrate_na_data_rate_kbps": 6000, + "minrate_na_enabled": False, + "minrate_na_mgmt_rate_kbps": 6000, + "minrate_ng_advertising_rates": False, + "minrate_ng_beacon_rate_kbps": 1000, + "minrate_ng_data_rate_kbps": 1000, + "minrate_ng_enabled": False, + "minrate_ng_mgmt_rate_kbps": 1000, + "name": "SSID 1", + "no2ghz_oui": False, + "schedule": [], + "security": "wpapsk", + "site_id": "5a32aa4ee4b0412345678910", + "usergroup_id": "012345678910111213141518", + "wep_idx": 1, + "wlangroup_id": "012345678910111213141519", + "wpa_enc": "ccmp", + "wpa_mode": "wpa2", + "x_iapp_key": "01234567891011121314151617181920", + "x_passphrase": "password", + } +] + + +async def _test_button_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - websocket_mock, + mock_websocket_state, + config_entry: ConfigEntry, + entity_count: int, + entity_id: str, + unique_id: str, + device_class: ButtonDeviceClass, + request_method: str, + request_path: str, + request_data: dict[str, Any], + call: dict[str, str], ) -> None: - """Test restarting device button.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[ - { - "board_rev": 3, - "device_id": "mock-id", - "ip": "10.0.0.1", - "last_seen": 1562600145, - "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "switch", - "state": 1, - "type": "usw", - "version": "4.0.42.10433", - } - ], - ) - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 + """Test button entity.""" + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == entity_count - ent_reg_entry = entity_registry.async_get("button.switch_restart") - assert ent_reg_entry.unique_id == "device_restart-00:00:00:00:01:01" + ent_reg_entry = entity_registry.async_get(entity_id) + assert ent_reg_entry.unique_id == unique_id assert ent_reg_entry.entity_category is EntityCategory.CONFIG # Validate state object - button = hass.states.get("button.switch_restart") + button = hass.states.get(entity_id) assert button is not None - assert button.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART + assert button.attributes.get(ATTR_DEVICE_CLASS) == device_class - # Send restart device command + # Send and validate device command aioclient_mock.clear_requests() - aioclient_mock.post( + aioclient_mock.request( + request_method, f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/devmgr", + f"/api/s/{config_entry.data[CONF_SITE_ID]}{request_path}", + **request_data, ) await hass.services.async_call( - BUTTON_DOMAIN, - "press", - {"entity_id": "button.switch_restart"}, - blocking=True, + BUTTON_DOMAIN, "press", {"entity_id": entity_id}, blocking=True ) assert aioclient_mock.call_count == 1 - assert aioclient_mock.mock_calls[0][2] == { - "cmd": "restart", - "mac": "00:00:00:00:01:01", - "reboot_type": "soft", - } + assert aioclient_mock.mock_calls[0][2] == call # Availability signalling # Controller disconnects - await websocket_mock.disconnect() - assert hass.states.get("button.switch_restart").state == STATE_UNAVAILABLE + await mock_websocket_state.disconnect() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # Controller reconnects - await websocket_mock.reconnect() - assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE + await mock_websocket_state.reconnect() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE -async def test_power_cycle_poe( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - websocket_mock, -) -> None: - """Test restarting device button.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[ +@pytest.mark.parametrize( + ( + "device_payload", + "entity_count", + "entity_id", + "unique_id", + "device_class", + "request_method", + "request_path", + "call", + ), + [ + ( + DEVICE_RESTART, + 1, + "button.switch_restart", + "device_restart-00:00:00:00:01:01", + ButtonDeviceClass.RESTART, + "post", + "/cmd/devmgr", { - "board_rev": 3, - "device_id": "mock-id", - "ip": "10.0.0.1", - "last_seen": 1562600145, + "cmd": "restart", "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "switch", - "state": 1, - "type": "usw", - "version": "4.0.42.10433", - "port_table": [ - { - "media": "GE", - "name": "Port 1", - "port_idx": 1, - "poe_caps": 7, - "poe_class": "Class 4", - "poe_enable": True, - "poe_mode": "auto", - "poe_power": "2.56", - "poe_voltage": "53.40", - "portconf_id": "1a1", - "port_poe": True, - "up": True, - }, - ], - } - ], - ) - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 - - ent_reg_entry = entity_registry.async_get("button.switch_port_1_power_cycle") - assert ent_reg_entry.unique_id == "power_cycle-00:00:00:00:01:01_1" - assert ent_reg_entry.entity_category is EntityCategory.CONFIG - - # Validate state object - button = hass.states.get("button.switch_port_1_power_cycle") - assert button is not None - assert button.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART - - # Send restart device command - aioclient_mock.clear_requests() - aioclient_mock.post( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/devmgr", - ) - - await hass.services.async_call( - BUTTON_DOMAIN, - "press", - {"entity_id": "button.switch_port_1_power_cycle"}, - blocking=True, - ) - assert aioclient_mock.call_count == 1 - assert aioclient_mock.mock_calls[0][2] == { - "cmd": "power-cycle", - "mac": "00:00:00:00:01:01", - "port_idx": 1, - } - - # Availability signalling - - # Controller disconnects - await websocket_mock.disconnect() - assert ( - hass.states.get("button.switch_port_1_power_cycle").state == STATE_UNAVAILABLE - ) - - # Controller reconnects - await websocket_mock.reconnect() - assert ( - hass.states.get("button.switch_port_1_power_cycle").state != STATE_UNAVAILABLE - ) - - -async def test_wlan_regenerate_password( + "reboot_type": "soft", + }, + ), + ( + DEVICE_POWER_CYCLE_POE, + 2, + "button.switch_port_1_power_cycle", + "power_cycle-00:00:00:00:01:01_1", + ButtonDeviceClass.RESTART, + "post", + "/cmd/devmgr", + { + "cmd": "power-cycle", + "mac": "00:00:00:00:01:01", + "port_idx": 1, + }, + ), + ], +) +async def test_device_button_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - websocket_mock, + config_entry_setup: ConfigEntry, + mock_websocket_state, + entity_count: int, + entity_id: str, + unique_id: str, + device_class: ButtonDeviceClass, + request_method: str, + request_path: str, + call: dict[str, str], ) -> None: - """Test WLAN regenerate password button.""" - - config_entry = await setup_unifi_integration( - hass, aioclient_mock, wlans_response=[WLAN] + """Test button entities based on device sources.""" + await _test_button_entity( + hass, + entity_registry, + aioclient_mock, + mock_websocket_state, + config_entry_setup, + entity_count, + entity_id, + unique_id, + device_class, + request_method, + request_path, + {}, + call, ) + + +@pytest.mark.parametrize( + ( + "wlan_payload", + "entity_count", + "entity_id", + "unique_id", + "device_class", + "request_method", + "request_path", + "request_data", + "call", + ), + [ + ( + WLAN_REGENERATE_PASSWORD, + 1, + "button.ssid_1_regenerate_password", + "regenerate_password-012345678910111213141516", + ButtonDeviceClass.UPDATE, + "put", + f"/rest/wlanconf/{WLAN_REGENERATE_PASSWORD[0]["_id"]}", + { + "json": {"data": "password changed successfully", "meta": {"rc": "ok"}}, + "headers": {"content-type": CONTENT_TYPE_JSON}, + }, + {"x_passphrase": RANDOM_TOKEN}, + ), + ], +) +async def test_wlan_button_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, + mock_websocket_state, + entity_count: int, + entity_id: str, + unique_id: str, + device_class: ButtonDeviceClass, + request_method: str, + request_path: str, + request_data: dict[str, Any], + call: dict[str, str], +) -> None: + """Test button entities based on WLAN sources.""" assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 0 - button_regenerate_password = "button.ssid_1_regenerate_password" - - ent_reg_entry = entity_registry.async_get(button_regenerate_password) - assert ent_reg_entry.unique_id == "regenerate_password-012345678910111213141516" + ent_reg_entry = entity_registry.async_get(entity_id) assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION - assert ent_reg_entry.entity_category is EntityCategory.CONFIG # Enable entity - entity_registry.async_update_entity( - entity_id=button_regenerate_password, disabled_by=None - ) - await hass.async_block_till_done() - + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 - - # Validate state object - button = hass.states.get(button_regenerate_password) - assert button is not None - assert button.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.UPDATE - - aioclient_mock.clear_requests() - aioclient_mock.put( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/rest/wlanconf/{WLAN[WLAN_ID]}", - json={"data": "password changed successfully", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, + await _test_button_entity( + hass, + entity_registry, + aioclient_mock, + mock_websocket_state, + config_entry_setup, + entity_count, + entity_id, + unique_id, + device_class, + request_method, + request_path, + request_data, + call, ) - - # Send WLAN regenerate password command - await hass.services.async_call( - BUTTON_DOMAIN, - "press", - {"entity_id": button_regenerate_password}, - blocking=True, - ) - assert aioclient_mock.call_count == 1 - assert next(iter(aioclient_mock.mock_calls[0][2])) == "x_passphrase" - - # Availability signalling - - # Controller disconnects - await websocket_mock.disconnect() - assert hass.states.get(button_regenerate_password).state == STATE_UNAVAILABLE - - # Controller reconnects - await websocket_mock.reconnect() - assert hass.states.get(button_regenerate_password).state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index b269392f707..7b37437cd1d 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -1,9 +1,10 @@ """Test UniFi Network config flow.""" import socket -from unittest.mock import patch +from unittest.mock import PropertyMock, patch import aiounifi +import pytest from homeassistant import config_entries from homeassistant.components import ssdp @@ -23,20 +24,17 @@ from homeassistant.components.unifi.const import ( CONF_TRACK_WIRED_CLIENTS, DOMAIN as UNIFI_DOMAIN, ) -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL, - CONTENT_TYPE_JSON, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .test_hub import setup_unifi_integration - from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -97,9 +95,8 @@ DPI_GROUPS = [ ] -async def test_flow_works( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_discovery -) -> None: +@pytest.mark.usefixtures("mock_default_requests") +async def test_flow_works(hass: HomeAssistant, mock_discovery) -> None: """Test config flow.""" mock_discovery.return_value = "1" result = await hass.config_entries.flow.async_init( @@ -116,25 +113,6 @@ async def test_flow_works( CONF_VERIFY_SSL: False, } - aioclient_mock.get("https://1.2.3.4:1234", status=302) - - aioclient_mock.post( - "https://1.2.3.4:1234/api/login", - json={"data": "login successful", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - "https://1.2.3.4:1234/api/self/sites", - json={ - "data": [ - {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} - ], - "meta": {"rc": "ok"}, - }, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -159,7 +137,7 @@ async def test_flow_works( async def test_flow_works_negative_discovery( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_discovery + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test config flow with a negative outcome of async_discovery_unifi.""" result = await hass.config_entries.flow.async_init( @@ -177,9 +155,17 @@ async def test_flow_works_negative_discovery( } -async def test_flow_multiple_sites( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.parametrize( + "site_payload", + [ + [ + {"name": "default", "role": "admin", "desc": "site name", "_id": "1"}, + {"name": "site2", "role": "admin", "desc": "site2 name", "_id": "2"}, + ] + ], +) +@pytest.mark.usefixtures("mock_default_requests") +async def test_flow_multiple_sites(hass: HomeAssistant) -> None: """Test config flow works when finding multiple sites.""" result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -188,26 +174,6 @@ async def test_flow_multiple_sites( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.get("https://1.2.3.4:1234", status=302) - - aioclient_mock.post( - "https://1.2.3.4:1234/api/login", - json={"data": "login successful", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - "https://1.2.3.4:1234/api/self/sites", - json={ - "data": [ - {"name": "default", "role": "admin", "desc": "site name", "_id": "1"}, - {"name": "site2", "role": "admin", "desc": "site2 name", "_id": "2"}, - ], - "meta": {"rc": "ok"}, - }, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -225,12 +191,9 @@ async def test_flow_multiple_sites( assert result["data_schema"]({"site": "2"}) -async def test_flow_raise_already_configured( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.usefixtures("config_entry_setup") +async def test_flow_raise_already_configured(hass: HomeAssistant) -> None: """Test config flow aborts since a connected config entry already exists.""" - await setup_unifi_integration(hass, aioclient_mock) - result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -238,27 +201,6 @@ async def test_flow_raise_already_configured( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.clear_requests() - - aioclient_mock.get("https://1.2.3.4:1234", status=302) - - aioclient_mock.post( - "https://1.2.3.4:1234/api/login", - json={"data": "login successful", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - "https://1.2.3.4:1234/api/self/sites", - json={ - "data": [ - {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} - ], - "meta": {"rc": "ok"}, - }, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -274,20 +216,9 @@ async def test_flow_raise_already_configured( assert result["reason"] == "already_configured" -async def test_flow_aborts_configuration_updated( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.usefixtures("config_entry_setup") +async def test_flow_aborts_configuration_updated(hass: HomeAssistant) -> None: """Test config flow aborts since a connected config entry already exists.""" - entry = MockConfigEntry( - domain=UNIFI_DOMAIN, data={"host": "1.2.3.4", "site": "office"}, unique_id="2" - ) - entry.add_to_hass(hass) - - entry = MockConfigEntry( - domain=UNIFI_DOMAIN, data={"host": "1.2.3.4", "site": "site_id"}, unique_id="1" - ) - entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -295,33 +226,17 @@ async def test_flow_aborts_configuration_updated( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.get("https://1.2.3.4:1234", status=302) - - aioclient_mock.post( - "https://1.2.3.4:1234/api/login", - json={"data": "login successful", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - "https://1.2.3.4:1234/api/self/sites", - json={ - "data": [ - {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} - ], - "meta": {"rc": "ok"}, - }, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - with patch("homeassistant.components.unifi.async_setup_entry"): + with patch("homeassistant.components.unifi.async_setup_entry") and patch( + "homeassistant.components.unifi.UnifiHub.available", new_callable=PropertyMock + ) as ws_mock: + ws_mock.return_value = False result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "1.2.3.4", CONF_USERNAME: "username", CONF_PASSWORD: "password", - CONF_PORT: 1234, + CONF_PORT: 12345, CONF_VERIFY_SSL: True, }, ) @@ -330,9 +245,8 @@ async def test_flow_aborts_configuration_updated( assert result["reason"] == "configuration_updated" -async def test_flow_fails_user_credentials_faulty( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.usefixtures("mock_default_requests") +async def test_flow_fails_user_credentials_faulty(hass: HomeAssistant) -> None: """Test config flow.""" result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -341,8 +255,6 @@ async def test_flow_fails_user_credentials_faulty( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.get("https://1.2.3.4:1234", status=302) - with patch("aiounifi.Controller.login", side_effect=aiounifi.errors.Unauthorized): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -359,9 +271,8 @@ async def test_flow_fails_user_credentials_faulty( assert result["errors"] == {"base": "faulty_credentials"} -async def test_flow_fails_hub_unavailable( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.usefixtures("mock_default_requests") +async def test_flow_fails_hub_unavailable(hass: HomeAssistant) -> None: """Test config flow.""" result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -370,8 +281,6 @@ async def test_flow_fails_hub_unavailable( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.get("https://1.2.3.4:1234", status=302) - with patch("aiounifi.Controller.login", side_effect=aiounifi.errors.RequestError): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -389,12 +298,10 @@ async def test_flow_fails_hub_unavailable( async def test_reauth_flow_update_configuration( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, config_entry_setup: ConfigEntry ) -> None: """Verify reauth flow can update hub configuration.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - hub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - hub.websocket.available = False + config_entry = config_entry_setup result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, @@ -409,37 +316,20 @@ async def test_reauth_flow_update_configuration( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - aioclient_mock.clear_requests() - - aioclient_mock.get("https://1.2.3.4:1234", status=302) - - aioclient_mock.post( - "https://1.2.3.4:1234/api/login", - json={"data": "login successful", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - "https://1.2.3.4:1234/api/self/sites", - json={ - "data": [ - {"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"} - ], - "meta": {"rc": "ok"}, - }, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "new_name", - CONF_PASSWORD: "new_pass", - CONF_PORT: 1234, - CONF_VERIFY_SSL: True, - }, - ) + with patch( + "homeassistant.components.unifi.UnifiHub.available", new_callable=PropertyMock + ) as ws_mock: + ws_mock.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + CONF_PORT: 1234, + CONF_VERIFY_SSL: True, + }, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -448,19 +338,15 @@ async def test_reauth_flow_update_configuration( assert config_entry.data[CONF_PASSWORD] == "new_pass" +@pytest.mark.parametrize("client_payload", [CLIENTS]) +@pytest.mark.parametrize("device_payload", [DEVICES]) +@pytest.mark.parametrize("wlan_payload", [WLANS]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) async def test_advanced_option_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, config_entry_setup: ConfigEntry ) -> None: """Test advanced config flow options.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=CLIENTS, - devices_response=DEVICES, - wlans_response=WLANS, - dpigroup_response=DPI_GROUPS, - dpiapp_response=[], - ) + config_entry = config_entry_setup result = await hass.config_entries.options.async_init( config_entry.entry_id, context={"show_advanced_options": True} @@ -539,13 +425,12 @@ async def test_advanced_option_flow( } +@pytest.mark.parametrize("client_payload", [CLIENTS]) async def test_simple_option_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, config_entry_setup: ConfigEntry ) -> None: """Test simple config flow options.""" - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=CLIENTS - ) + config_entry = config_entry_setup result = await hass.config_entries.options.async_init( config_entry.entry_id, context={"show_advanced_options": False} @@ -572,22 +457,8 @@ async def test_simple_option_flow( } -async def test_option_flow_integration_not_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test advanced config flow options.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - - hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "integration_not_setup" - - async def test_form_ssdp(hass: HomeAssistant) -> None: """Test we get the form with ssdp source.""" - result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -625,21 +496,17 @@ async def test_form_ssdp(hass: HomeAssistant) -> None: } -async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> None: +async def test_form_ssdp_aborts_if_host_already_exists( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Test we abort if the host is already configured.""" - - entry = MockConfigEntry( - domain=UNIFI_DOMAIN, - data={"host": "192.168.208.1", "site": "site_id"}, - ) - entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=ssdp.SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", - ssdp_location="http://192.168.208.1:41417/rootDesc.xml", + ssdp_location="http://1.2.3.4:1234/rootDesc.xml", upnp={ "friendlyName": "UniFi Dream Machine", "modelDescription": "UniFi Dream Machine Pro", @@ -651,26 +518,22 @@ async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> N assert result["reason"] == "already_configured" -async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> None: +async def test_form_ssdp_aborts_if_serial_already_exists( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Test we abort if the serial is already configured.""" - entry = MockConfigEntry( - domain=UNIFI_DOMAIN, - data={"controller": {"host": "1.2.3.4", "site": "site_id"}}, - unique_id="e0:63:da:20:14:a9", - ) - entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( UNIFI_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=ssdp.SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", - ssdp_location="http://192.168.208.1:41417/rootDesc.xml", + ssdp_location="http://1.2.3.4:1234/rootDesc.xml", upnp={ "friendlyName": "UniFi Dream Machine", "modelDescription": "UniFi Dream Machine Pro", - "serialNumber": "e0:63:da:20:14:a9", + "serialNumber": "1", }, ), ) @@ -679,8 +542,7 @@ async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> None: - """Test we can still setup if there is an ignored entry.""" - + """Test we can still setup if there is an ignored never configured entry.""" entry = MockConfigEntry( domain=UNIFI_DOMAIN, data={"not_controller_key": None}, @@ -693,11 +555,11 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No data=ssdp.SsdpServiceInfo( ssdp_usn="mock_usn", ssdp_st="mock_st", - ssdp_location="http://1.2.3.4:41417/rootDesc.xml", + ssdp_location="http://1.2.3.4:1234/rootDesc.xml", upnp={ "friendlyName": "UniFi Dream Machine New", "modelDescription": "UniFi Dream Machine Pro", - "serialNumber": "e0:63:da:20:14:a9", + "serialNumber": "1", }, ), ) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index b22767a2914..984fe50753f 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,11 +1,15 @@ """The tests for the UniFi Network device tracker platform.""" +from collections.abc import Callable from datetime import timedelta +from types import MappingProxyType +from typing import Any +from aiounifi.models.event import EventKey from aiounifi.models.message import MessageKey from freezegun.api import FrozenDateTimeFactory, freeze_time +import pytest -from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.unifi.const import ( CONF_BLOCK_CLIENT, @@ -19,355 +23,259 @@ from homeassistant.components.unifi.const import ( DEFAULT_DETECTION_TIME, DOMAIN as UNIFI_DOMAIN, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from .test_hub import ENTRY_CONFIG, setup_unifi_integration - from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker + +WIRED_CLIENT_1 = { + "hostname": "wd_client_1", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:02", +} + +WIRELESS_CLIENT_1 = { + "ap_mac": "00:00:00:00:02:01", + "essid": "ssid", + "hostname": "ws_client_1", + "ip": "10.0.0.1", + "is_wired": False, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:01", +} + +WIRED_BUG_CLIENT = { + "essid": "ssid", + "hostname": "wd_bug_client", + "ip": "10.0.0.3", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:03", +} + +UNSEEN_CLIENT = { + "essid": "ssid", + "hostname": "unseen_client", + "ip": "10.0.0.4", + "is_wired": True, + "last_seen": None, + "mac": "00:00:00:00:00:04", +} + +SWITCH_1 = { + "board_rev": 3, + "device_id": "mock-id-1", + "has_fan": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Switch 1", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "version": "4.0.42.10433", +} -async def test_no_entities( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the update_clients function when no clients are found.""" - await setup_unifi_integration(hass, aioclient_mock) - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 0 - - -async def test_tracked_wireless_clients( +@pytest.mark.parametrize( + "client_payload", [[WIRELESS_CLIENT_1, WIRED_BUG_CLIENT, UNSEEN_CLIENT]] +) +@pytest.mark.parametrize("known_wireless_clients", [[WIRED_BUG_CLIENT["mac"]]]) +@pytest.mark.usefixtures("mock_device_registry") +async def test_client_state_update( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_device_registry, - mock_unifi_websocket, + mock_websocket_message, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], ) -> None: """Verify tracking of wireless clients.""" - client = { - "ap_mac": "00:00:00:00:02:01", - "essid": "ssid", - "hostname": "client", - "ip": "10.0.0.1", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client] + # A normal client with current timestamp should have STATE_HOME, this is wired bug + client_payload[1] |= {"last_seen": dt_util.as_timestamp(dt_util.utcnow())} + await config_entry_factory() + + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3 + + assert hass.states.get("device_tracker.ws_client_1").state == STATE_NOT_HOME + assert ( + hass.states.get("device_tracker.ws_client_1").attributes["host_name"] + == "ws_client_1" ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 - assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME + # Wireless client with wired bug, if bug active on restart mark device away + assert hass.states.get("device_tracker.wd_bug_client").state == STATE_NOT_HOME + + # A client that has never been seen should be marked away. + assert hass.states.get("device_tracker.unseen_client").state == STATE_NOT_HOME # Updated timestamp marks client as home - - client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + ws_client_1 = client_payload[0] | { + "last_seen": dt_util.as_timestamp(dt_util.utcnow()) + } + mock_websocket_message(message=MessageKey.CLIENT, data=ws_client_1) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME + assert hass.states.get("device_tracker.ws_client_1").state == STATE_HOME # Change time to mark client as away - - new_time = dt_util.utcnow() + timedelta( - seconds=config_entry.options.get(CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME) - ) + new_time = dt_util.utcnow() + timedelta(seconds=DEFAULT_DETECTION_TIME) with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME + assert hass.states.get("device_tracker.ws_client_1").state == STATE_NOT_HOME # Same timestamp doesn't explicitly mark client as away - - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=ws_client_1) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME + assert hass.states.get("device_tracker.ws_client_1").state == STATE_HOME -async def test_tracked_clients( +@pytest.mark.parametrize("client_payload", [[WIRELESS_CLIENT_1]]) +@pytest.mark.usefixtures("config_entry_setup") +@pytest.mark.usefixtures("mock_device_registry") +async def test_client_state_from_event_source( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - mock_device_registry, -) -> None: - """Test the update_items function with some clients.""" - client_1 = { - "ap_mac": "00:00:00:00:02:01", - "essid": "ssid", - "hostname": "client_1", - "ip": "10.0.0.1", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - client_2 = { - "ip": "10.0.0.2", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - "name": "Client 2", - } - client_3 = { - "essid": "ssid2", - "hostname": "client_3", - "ip": "10.0.0.3", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:03", - } - client_4 = { - "essid": "ssid", - "hostname": "client_4", - "ip": "10.0.0.4", - "is_wired": True, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:04", - } - client_5 = { - "essid": "ssid", - "hostname": "client_5", - "ip": "10.0.0.5", - "is_wired": True, - "last_seen": None, - "mac": "00:00:00:00:00:05", - } - client_6 = { - "hostname": "client_6", - "ip": "10.0.0.6", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:06", - } - - await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_SSID_FILTER: ["ssid"], CONF_CLIENT_SOURCE: [client_6["mac"]]}, - clients_response=[client_1, client_2, client_3, client_4, client_5, client_6], - known_wireless_clients=(client_4["mac"],), - ) - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 5 - assert hass.states.get("device_tracker.client_1").state == STATE_NOT_HOME - assert hass.states.get("device_tracker.client_2").state == STATE_NOT_HOME - assert ( - hass.states.get("device_tracker.client_5").attributes["host_name"] == "client_5" - ) - assert hass.states.get("device_tracker.client_6").state == STATE_NOT_HOME - - # Client on SSID not in SSID filter - assert not hass.states.get("device_tracker.client_3") - - # Wireless client with wired bug, if bug active on restart mark device away - assert hass.states.get("device_tracker.client_4").state == STATE_NOT_HOME - - # A client that has never been seen should be marked away. - assert hass.states.get("device_tracker.client_5").state == STATE_NOT_HOME - - # State change signalling works - - client_1["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket(message=MessageKey.CLIENT, data=client_1) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.client_1").state == STATE_HOME - - -async def test_tracked_wireless_clients_event_source( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, freezer: FrozenDateTimeFactory, - mock_unifi_websocket, - mock_device_registry, + mock_websocket_message, + client_payload: list[dict[str, Any]], ) -> None: - """Verify tracking of wireless clients based on event source.""" - client = { - "ap_mac": "00:00:00:00:02:01", - "essid": "ssid", - "hostname": "client", - "ip": "10.0.0.1", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client] - ) + """Verify update state of client based on event source.""" + + async def mock_event(client: dict[str, Any], event_key: EventKey) -> dict[str, Any]: + """Create and send event based on client payload.""" + event = { + "user": client["mac"], + "ssid": client["essid"], + "hostname": client["hostname"], + "ap": client["ap_mac"], + "duration": 467, + "bytes": 459039, + "key": event_key, + "subsystem": "wlan", + "site_id": "name", + "time": 1587752927000, + "datetime": "2020-04-24T18:28:47Z", + "_id": "5ea32ff730c49e00f90dca1a", + } + mock_websocket_message(message=MessageKey.EVENT, data=event) + await hass.async_block_till_done() assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 - assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME + assert hass.states.get("device_tracker.ws_client_1").state == STATE_NOT_HOME # State change signalling works with events # Connected event - - event = { - "user": client["mac"], - "ssid": client["essid"], - "ap": client["ap_mac"], - "radio": "na", - "channel": "44", - "hostname": client["hostname"], - "key": "EVT_WU_Connected", - "subsystem": "wlan", - "site_id": "name", - "time": 1587753456179, - "datetime": "2020-04-24T18:37:36Z", - "msg": f'User{[client["mac"]]} has connected to AP[{client["ap_mac"]}] with SSID "{client["essid"]}" on "channel 44(na)"', - "_id": "5ea331fa30c49e00f90ddc1a", - } - mock_unifi_websocket(message=MessageKey.EVENT, data=event) - await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME + await mock_event(client_payload[0], EventKey.WIRELESS_CLIENT_CONNECTED) + assert hass.states.get("device_tracker.ws_client_1").state == STATE_HOME # Disconnected event - - event = { - "user": client["mac"], - "ssid": client["essid"], - "hostname": client["hostname"], - "ap": client["ap_mac"], - "duration": 467, - "bytes": 459039, - "key": "EVT_WU_Disconnected", - "subsystem": "wlan", - "site_id": "name", - "time": 1587752927000, - "datetime": "2020-04-24T18:28:47Z", - "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', - "_id": "5ea32ff730c49e00f90dca1a", - } - mock_unifi_websocket(message=MessageKey.EVENT, data=event) - await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME + await mock_event(client_payload[0], EventKey.WIRELESS_CLIENT_DISCONNECTED) + assert hass.states.get("device_tracker.ws_client_1").state == STATE_HOME # Change time to mark client as away - freezer.tick( - timedelta( - seconds=( - config_entry.options.get(CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME) - + 1 - ) - ) - ) + freezer.tick(timedelta(seconds=(DEFAULT_DETECTION_TIME + 1))) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME + assert hass.states.get("device_tracker.ws_client_1").state == STATE_NOT_HOME # To limit false positives in client tracker # data sources are prioritized when available # once real data is received events will be ignored. # New data - - client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + ws_client_1 = client_payload[0] | { + "last_seen": dt_util.as_timestamp(dt_util.utcnow()) + } + mock_websocket_message(message=MessageKey.CLIENT, data=ws_client_1) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME + assert hass.states.get("device_tracker.ws_client_1").state == STATE_HOME # Disconnection event will be ignored - - event = { - "user": client["mac"], - "ssid": client["essid"], - "hostname": client["hostname"], - "ap": client["ap_mac"], - "duration": 467, - "bytes": 459039, - "key": "EVT_WU_Disconnected", - "subsystem": "wlan", - "site_id": "name", - "time": 1587752927000, - "datetime": "2020-04-24T18:28:47Z", - "msg": f'User{[client["mac"]]} disconnected from "{client["essid"]}" (7m 47s connected, 448.28K bytes, last AP[{client["ap_mac"]}])', - "_id": "5ea32ff730c49e00f90dca1a", - } - mock_unifi_websocket(message=MessageKey.EVENT, data=event) - await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME + await mock_event(client_payload[0], EventKey.WIRELESS_CLIENT_DISCONNECTED) + assert hass.states.get("device_tracker.ws_client_1").state == STATE_HOME # Change time to mark client as away - freezer.tick( - timedelta( - seconds=( - config_entry.options.get(CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME) - + 1 - ) - ) - ) + freezer.tick(timedelta(seconds=(DEFAULT_DETECTION_TIME + 1))) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME + assert hass.states.get("device_tracker.ws_client_1").state == STATE_NOT_HOME +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device 1", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "version": "4.0.42.10433", + }, + { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": True, + "ip": "10.0.1.2", + "mac": "00:00:00:00:01:02", + "model": "US16P150", + "name": "Device 2", + "next_interval": 20, + "state": 0, + "type": "usw", + "version": "4.0.42.10433", + }, + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") +@pytest.mark.usefixtures("mock_device_registry") async def test_tracked_devices( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, freezer: FrozenDateTimeFactory, - mock_unifi_websocket, - mock_device_registry, + mock_websocket_message, + device_payload: list[dict[str, Any]], ) -> None: """Test the update_items function with some devices.""" - device_1 = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": True, - "fan_level": 0, - "ip": "10.0.1.1", - "last_seen": 1562600145, - "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "Device 1", - "next_interval": 20, - "overheating": True, - "state": 1, - "type": "usw", - "upgradable": True, - "version": "4.0.42.10433", - } - device_2 = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": True, - "ip": "10.0.1.2", - "mac": "00:00:00:00:01:02", - "model": "US16P150", - "name": "Device 2", - "next_interval": 20, - "state": 0, - "type": "usw", - "version": "4.0.42.10433", - } - await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[device_1, device_2], - ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 assert hass.states.get("device_tracker.device_1").state == STATE_HOME assert hass.states.get("device_tracker.device_2").state == STATE_NOT_HOME # State change signalling work - + device_1 = device_payload[0] device_1["next_interval"] = 20 + device_2 = device_payload[1] device_2["state"] = 1 device_2["next_interval"] = 50 - mock_unifi_websocket(message=MessageKey.DEVICE, data=[device_1, device_2]) + mock_websocket_message(message=MessageKey.DEVICE, data=[device_1, device_2]) await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_HOME assert hass.states.get("device_tracker.device_2").state == STATE_HOME # Change of time can mark device not_home outside of expected reporting interval - new_time = dt_util.utcnow() + timedelta(seconds=90) freezer.move_to(new_time) async_fire_time_changed(hass, new_time) @@ -377,329 +285,120 @@ async def test_tracked_devices( assert hass.states.get("device_tracker.device_2").state == STATE_HOME # Disabled device is unavailable - device_1["disabled"] = True - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device_2").state == STATE_HOME +@pytest.mark.parametrize("client_payload", [[WIRELESS_CLIENT_1, WIRED_CLIENT_1]]) +@pytest.mark.usefixtures("config_entry_setup") +@pytest.mark.usefixtures("mock_device_registry") async def test_remove_clients( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - mock_device_registry, + hass: HomeAssistant, mock_websocket_message, client_payload: list[dict[str, Any]] ) -> None: """Test the remove_items function with some clients.""" - client_1 = { - "essid": "ssid", - "hostname": "client_1", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - client_2 = { - "hostname": "client_2", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - } - await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client_1, client_2] - ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 - assert hass.states.get("device_tracker.client_1") - assert hass.states.get("device_tracker.client_2") + assert hass.states.get("device_tracker.ws_client_1") + assert hass.states.get("device_tracker.wd_client_1") # Remove client - - mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=client_1) - await hass.async_block_till_done() + mock_websocket_message(message=MessageKey.CLIENT_REMOVED, data=client_payload[0]) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 - assert not hass.states.get("device_tracker.client_1") - assert hass.states.get("device_tracker.client_2") + assert not hass.states.get("device_tracker.ws_client_1") + assert hass.states.get("device_tracker.wd_client_1") -async def test_hub_state_change( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - websocket_mock, - mock_device_registry, -) -> None: +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "essid": "ssid", + "hostname": "client", + "is_wired": False, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:01", + } + ] + ], +) +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": 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, + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") +@pytest.mark.usefixtures("mock_device_registry") +async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> None: """Verify entities state reflect on hub connection becoming unavailable.""" - client = { - "essid": "ssid", - "hostname": "client", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - device = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": 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, - "version": "4.0.42.10433", - } - - await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[client], - devices_response=[device], - ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device").state == STATE_HOME # Controller unavailable - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() assert hass.states.get("device_tracker.client").state == STATE_UNAVAILABLE assert hass.states.get("device_tracker.device").state == STATE_UNAVAILABLE # Controller available - await websocket_mock.reconnect() + await mock_websocket_state.reconnect() assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device").state == STATE_HOME -async def test_option_track_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry -) -> None: - """Test the tracking of clients can be turned off.""" - wireless_client = { - "essid": "ssid", - "hostname": "wireless_client", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - wired_client = { - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - "name": "Wired Client", - } - device = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": 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, - "version": "4.0.42.10433", - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[wireless_client, wired_client], - devices_response=[device], - ) - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3 - assert hass.states.get("device_tracker.wireless_client") - assert hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_CLIENTS: False}, - ) - await hass.async_block_till_done() - - assert not hass.states.get("device_tracker.wireless_client") - assert not hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_CLIENTS: True}, - ) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.wireless_client") - assert hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") - - -async def test_option_track_wired_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry -) -> None: - """Test the tracking of wired clients can be turned off.""" - wireless_client = { - "essid": "ssid", - "hostname": "wireless_client", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - wired_client = { - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - "name": "Wired Client", - } - device = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": 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, - "version": "4.0.42.10433", - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[wireless_client, wired_client], - devices_response=[device], - ) - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3 - assert hass.states.get("device_tracker.wireless_client") - assert hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_WIRED_CLIENTS: False}, - ) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.wireless_client") - assert not hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_WIRED_CLIENTS: True}, - ) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.wireless_client") - assert hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") - - -async def test_option_track_devices( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry -) -> None: - """Test the tracking of devices can be turned off.""" - client = { - "hostname": "client", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - device = { - "board_rev": 3, - "device_id": "mock-id", - "last_seen": 1562600145, - "ip": "10.0.1.1", - "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "Device", - "next_interval": 20, - "overheating": True, - "state": 1, - "type": "usw", - "upgradable": True, - "version": "4.0.42.10433", - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[client], - devices_response=[device], - ) - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 - assert hass.states.get("device_tracker.client") - assert hass.states.get("device_tracker.device") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_DEVICES: False}, - ) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.client") - assert not hass.states.get("device_tracker.device") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_DEVICES: True}, - ) - await hass.async_block_till_done() - - assert hass.states.get("device_tracker.client") - assert hass.states.get("device_tracker.device") - - +@pytest.mark.usefixtures("mock_device_registry") async def test_option_ssid_filter( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - mock_device_registry, + mock_websocket_message, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], ) -> None: """Test the SSID filter works. Client will travel from a supported SSID to an unsupported ssid. Client on SSID2 will be removed on change of options. """ - client = { - "essid": "ssid", - "hostname": "client", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - client_on_ssid2 = { - "essid": "ssid2", - "hostname": "client_on_ssid2", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - } - - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client, client_on_ssid2] - ) + client_payload += [ + { + "essid": "ssid", + "hostname": "client", + "is_wired": False, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:01", + }, + { + "essid": "ssid2", + "hostname": "client_on_ssid2", + "is_wired": False, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:02", + }, + ] + config_entry = await config_entry_factory() assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -707,8 +406,7 @@ async def test_option_ssid_filter( # Setting SSID filter will remove clients outside of filter hass.config_entries.async_update_entry( - config_entry, - options={CONF_SSID_FILTER: ["ssid"]}, + config_entry, options={CONF_SSID_FILTER: ["ssid"]} ) await hass.async_block_till_done() @@ -719,12 +417,14 @@ async def test_option_ssid_filter( assert not hass.states.get("device_tracker.client_on_ssid2") # Roams to SSID outside of filter + client = client_payload[0] client["essid"] = "other_ssid" - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=client) # Data update while SSID filter is in effect shouldn't create the client + client_on_ssid2 = client_payload[1] client_on_ssid2["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) - mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) + mock_websocket_message(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() new_time = dt_util.utcnow() + timedelta( @@ -743,22 +443,18 @@ async def test_option_ssid_filter( assert not hass.states.get("device_tracker.client_on_ssid2") # Remove SSID filter - hass.config_entries.async_update_entry( - config_entry, - options={CONF_SSID_FILTER: []}, - ) + hass.config_entries.async_update_entry(config_entry, options={CONF_SSID_FILTER: []}) await hass.async_block_till_done() client["last_seen"] += 1 client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket(message=MessageKey.CLIENT, data=[client, client_on_ssid2]) + mock_websocket_message(message=MessageKey.CLIENT, data=[client, client_on_ssid2]) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME assert hass.states.get("device_tracker.client_on_ssid2").state == STATE_HOME # Time pass to mark client as away - new_time += timedelta( seconds=( config_entry.options.get(CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME) + 1 @@ -771,7 +467,7 @@ async def test_option_ssid_filter( assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) + mock_websocket_message(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() # Client won't go away until after next update @@ -779,7 +475,7 @@ async def test_option_ssid_filter( # Trigger update to get client marked as away client_on_ssid2["last_seen"] += 1 - mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) + mock_websocket_message(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() new_time += timedelta( @@ -792,28 +488,28 @@ async def test_option_ssid_filter( assert hass.states.get("device_tracker.client_on_ssid2").state == STATE_NOT_HOME +@pytest.mark.usefixtures("mock_device_registry") async def test_wireless_client_go_wired_issue( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - mock_device_registry, + mock_websocket_message, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], ) -> None: """Test the solution to catch wireless device go wired UniFi issue. UniFi Network has a known issue that when a wireless device goes away it sometimes gets marked as wired. """ - client = { - "essid": "ssid", - "hostname": "client", - "ip": "10.0.0.1", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client] + client_payload.append( + { + "essid": "ssid", + "hostname": "client", + "ip": "10.0.0.1", + "is_wired": False, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:01", + } ) + config_entry = await config_entry_factory() assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 @@ -822,9 +518,10 @@ async def test_wireless_client_go_wired_issue( assert client_state.state == STATE_HOME # Trigger wired bug + client = client_payload[0] client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) client["is_wired"] = True - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Wired bug fix keeps client marked as wireless @@ -845,7 +542,7 @@ async def test_wireless_client_go_wired_issue( # Try to mark client as connected client["last_seen"] += 1 - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Make sure it don't go online again until wired bug disappears @@ -855,7 +552,7 @@ async def test_wireless_client_go_wired_issue( # Make client wireless client["last_seen"] += 1 client["is_wired"] = False - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Client is no longer affected by wired bug and can be marked online @@ -863,29 +560,28 @@ async def test_wireless_client_go_wired_issue( assert client_state.state == STATE_HOME +@pytest.mark.parametrize("config_entry_options", [{CONF_IGNORE_WIRED_BUG: True}]) +@pytest.mark.usefixtures("mock_device_registry") async def test_option_ignore_wired_bug( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - mock_device_registry, + mock_websocket_message, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], ) -> None: """Test option to ignore wired bug.""" - client = { - "ap_mac": "00:00:00:00:02:01", - "essid": "ssid", - "hostname": "client", - "ip": "10.0.0.1", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_IGNORE_WIRED_BUG: True}, - clients_response=[client], + client_payload.append( + { + "ap_mac": "00:00:00:00:02:01", + "essid": "ssid", + "hostname": "client", + "ip": "10.0.0.1", + "is_wired": False, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:01", + } ) + config_entry = await config_entry_factory() + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 # Client is wireless @@ -893,8 +589,9 @@ async def test_option_ignore_wired_bug( assert client_state.state == STATE_HOME # Trigger wired bug + client = client_payload[0] client["is_wired"] = True - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Wired bug in effect @@ -915,7 +612,7 @@ async def test_option_ignore_wired_bug( # Mark client as connected again client["last_seen"] += 1 - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Ignoring wired bug allows client to go home again even while affected @@ -925,7 +622,7 @@ async def test_option_ignore_wired_bug( # Make client wireless client["last_seen"] += 1 client["is_wired"] = False - mock_unifi_websocket(message=MessageKey.CLIENT, data=client) + mock_websocket_message(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() # Client is wireless and still connected @@ -933,220 +630,142 @@ async def test_option_ignore_wired_bug( assert client_state.state == STATE_HOME +@pytest.mark.parametrize( + "config_entry_options", [{CONF_BLOCK_CLIENT: ["00:00:00:00:00:03"]}] +) +@pytest.mark.parametrize("client_payload", [[WIRED_CLIENT_1]]) +@pytest.mark.parametrize( + "clients_all_payload", + [ + [ + { + "hostname": "restored", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:03", + }, + { # Not previously seen by integration, will not be restored + "hostname": "not_restored", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:04", + }, + ] + ], +) +@pytest.mark.usefixtures("mock_device_registry") async def test_restoring_client( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_device_registry, + config_entry: ConfigEntry, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], + clients_all_payload: list[dict[str, Any]], ) -> None: """Verify clients are restored from clients_all if they ever was registered to entity registry.""" - client = { - "hostname": "client", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - restored = { - "hostname": "restored", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - } - not_restored = { - "hostname": "not_restored", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:03", - } - - config_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, - domain=UNIFI_DOMAIN, - title="Mock Title", - data=ENTRY_CONFIG, - source="test", - options={}, - entry_id="1", - ) - - entity_registry.async_get_or_create( # Unique ID updated + entity_registry.async_get_or_create( # Make sure unique ID converts to site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'{restored["mac"]}-site_id', - suggested_object_id=restored["hostname"], + f'{clients_all_payload[0]["mac"]}-site_id', + suggested_object_id=clients_all_payload[0]["hostname"], config_entry=config_entry, ) - entity_registry.async_get_or_create( # Unique ID already updated + entity_registry.async_get_or_create( # Unique ID already follow format site_id-mac TRACKER_DOMAIN, UNIFI_DOMAIN, - f'site_id-{client["mac"]}', - suggested_object_id=client["hostname"], + f'site_id-{client_payload[0]["mac"]}', + suggested_object_id=client_payload[0]["hostname"], config_entry=config_entry, ) - await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_BLOCK_CLIENT: [restored["mac"]]}, - clients_response=[client], - clients_all_response=[restored, not_restored], - ) + await config_entry_factory() assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 - assert hass.states.get("device_tracker.client") + assert hass.states.get("device_tracker.wd_client_1") assert hass.states.get("device_tracker.restored") assert not hass.states.get("device_tracker.not_restored") -async def test_dont_track_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry +@pytest.mark.parametrize( + ("config_entry_options", "counts", "expected"), + [ + ( + {CONF_TRACK_CLIENTS: True}, + (3, 1), + ((True, True, True), (None, None, True)), + ), + ( + {CONF_TRACK_CLIENTS: True, CONF_SSID_FILTER: ["ssid"]}, + (3, 1), + ((True, True, True), (None, None, True)), + ), + ( + {CONF_TRACK_CLIENTS: True, CONF_SSID_FILTER: ["ssid-2"]}, + (2, 1), + ((None, True, True), (None, None, True)), + ), + ( + {CONF_TRACK_CLIENTS: False, CONF_CLIENT_SOURCE: ["00:00:00:00:00:01"]}, + (2, 1), + ((True, None, True), (None, None, True)), + ), + ( + {CONF_TRACK_CLIENTS: False, CONF_CLIENT_SOURCE: ["00:00:00:00:00:02"]}, + (2, 1), + ((None, True, True), (None, None, True)), + ), + ( + {CONF_TRACK_WIRED_CLIENTS: True}, + (3, 2), + ((True, True, True), (True, None, True)), + ), + ( + {CONF_TRACK_DEVICES: True}, + (3, 2), + ((True, True, True), (True, True, None)), + ), + ], +) +@pytest.mark.parametrize("client_payload", [[WIRELESS_CLIENT_1, WIRED_CLIENT_1]]) +@pytest.mark.parametrize("device_payload", [[SWITCH_1]]) +@pytest.mark.usefixtures("mock_device_registry") +async def test_config_entry_options_track( + hass: HomeAssistant, + config_entry_setup: ConfigEntry, + config_entry_options: MappingProxyType[str, Any], + counts: tuple[int], + expected: dict[tuple[bool | None]], ) -> None: - """Test don't track clients config works.""" - wireless_client = { - "essid": "ssid", - "hostname": "Wireless client", - "ip": "10.0.0.1", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - wired_client = { - "hostname": "Wired client", - "ip": "10.0.0.2", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - } - device = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": 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, - "version": "4.0.42.10433", - } + """Test the different config entry options. - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_TRACK_CLIENTS: False}, - clients_response=[wireless_client, wired_client], - devices_response=[device], - ) + Validates how many entities are created + and that the specific ones exist as expected. + """ + option = next(iter(config_entry_options)) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 - assert not hass.states.get("device_tracker.wireless_client") - assert not hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") + def assert_state(state: State | None, expected: bool | None): + """Assert if state expected.""" + assert state is None if expected is None else state - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_CLIENTS: True}, - ) + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == counts[0] + assert_state(hass.states.get("device_tracker.ws_client_1"), expected[0][0]) + assert_state(hass.states.get("device_tracker.wd_client_1"), expected[0][1]) + assert_state(hass.states.get("device_tracker.switch_1"), expected[0][2]) + + # Keep only the primary option and turn it off, everything else uses default + hass.config_entries.async_update_entry(config_entry_setup, options={option: False}) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == counts[1] + assert_state(hass.states.get("device_tracker.ws_client_1"), expected[1][0]) + assert_state(hass.states.get("device_tracker.wd_client_1"), expected[1][1]) + assert_state(hass.states.get("device_tracker.switch_1"), expected[1][2]) + + # Turn on the primary option, everything else uses default + hass.config_entries.async_update_entry(config_entry_setup, options={option: True}) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3 - assert hass.states.get("device_tracker.wireless_client") - assert hass.states.get("device_tracker.wired_client") - assert hass.states.get("device_tracker.device") - - -async def test_dont_track_devices( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry -) -> None: - """Test don't track devices config works.""" - client = { - "hostname": "client", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - device = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": 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, - "version": "4.0.42.10433", - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_TRACK_DEVICES: False}, - clients_response=[client], - devices_response=[device], - ) - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 - assert hass.states.get("device_tracker.client") - assert not hass.states.get("device_tracker.device") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_DEVICES: True}, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 - assert hass.states.get("device_tracker.client") - assert hass.states.get("device_tracker.device") - - -async def test_dont_track_wired_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry -) -> None: - """Test don't track wired clients config works.""" - wireless_client = { - "essid": "ssid", - "hostname": "Wireless Client", - "is_wired": False, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:01", - } - wired_client = { - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:02", - "name": "Wired Client", - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_TRACK_WIRED_CLIENTS: False}, - clients_response=[wireless_client, wired_client], - ) - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 1 - assert hass.states.get("device_tracker.wireless_client") - assert not hass.states.get("device_tracker.wired_client") - - hass.config_entries.async_update_entry( - config_entry, - options={CONF_TRACK_WIRED_CLIENTS: True}, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 - assert hass.states.get("device_tracker.wireless_client") - assert hass.states.get("device_tracker.wired_client") + assert_state(hass.states.get("device_tracker.ws_client_1"), True) + assert_state(hass.states.get("device_tracker.wd_client_1"), True) + assert_state(hass.states.get("device_tracker.switch_1"), True) diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 792512683d3..fcaba59cbad 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -1,27 +1,21 @@ """Test UniFi Network diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .test_hub import setup_unifi_integration - 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_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test config entry diagnostics.""" - client = { +CLIENT_DATA = [ + { "blocked": False, "hostname": "client_1", "ip": "10.0.0.1", @@ -35,7 +29,9 @@ async def test_entry_diagnostics( "wired-rx_bytes": 1234000000, "wired-tx_bytes": 5678000000, } - device = { +] +DEVICE_DATA = [ + { "board_rev": "1.2.3", "ethernet_table": [ { @@ -86,7 +82,9 @@ async def test_entry_diagnostics( "type": "usw", "version": "4.0.42.10433", } - dpi_app = { +] +DPI_APP_DATA = [ + { "_id": "5f976f62e3c58f018ec7e17d", "apps": [], "blocked": True, @@ -95,142 +93,39 @@ async def test_entry_diagnostics( "log": True, "site_id": "name", } - dpi_group = { +] +DPI_GROUP_DATA = [ + { "_id": "5f976f4ae3c58f018ec7dff6", "name": "Block Media Streaming", "site_id": "name", "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], } +] - options = { - CONF_ALLOW_BANDWIDTH_SENSORS: True, - CONF_ALLOW_UPTIME_SENSORS: True, - CONF_BLOCK_CLIENT: ["00:00:00:00:00:01"], - } - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[client], - devices_response=[device], - dpiapp_response=[dpi_app], - dpigroup_response=[dpi_group], + +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_ALLOW_UPTIME_SENSORS: True, + CONF_BLOCK_CLIENT: ["00:00:00:00:00:01"], + } + ], +) +@pytest.mark.parametrize("client_payload", [CLIENT_DATA]) +@pytest.mark.parametrize("device_payload", [DEVICE_DATA]) +@pytest.mark.parametrize("dpi_app_payload", [DPI_APP_DATA]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUP_DATA]) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry_setup: ConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry_setup) + == snapshot ) - - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "config": { - "data": { - "host": REDACTED, - "password": REDACTED, - "port": 1234, - "site": "site_id", - "username": REDACTED, - "verify_ssl": False, - }, - "disabled_by": None, - "domain": "unifi", - "entry_id": "1", - "minor_version": 1, - "options": { - "allow_bandwidth_sensors": True, - "allow_uptime_sensors": True, - "block_client": ["00:00:00:00:00:00"], - }, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "title": "Mock Title", - "unique_id": "1", - "version": 1, - }, - "role_is_admin": True, - "clients": { - "00:00:00:00:00:00": { - "blocked": False, - "hostname": "client_1", - "ip": "10.0.0.1", - "is_wired": True, - "last_seen": 1562600145, - "mac": "00:00:00:00:00:00", - "name": "POE Client 1", - "oui": "Producer", - "sw_mac": "00:00:00:00:00:01", - "sw_port": 1, - "wired-rx_bytes": 1234000000, - "wired-tx_bytes": 5678000000, - } - }, - "devices": { - "00:00:00:00:00:01": { - "board_rev": "1.2.3", - "ethernet_table": [ - { - "mac": "00:00:00:00:00:02", - "num_port": 2, - "name": "eth0", - } - ], - "device_id": "mock-id", - "ip": "10.0.1.1", - "mac": "00:00:00:00:00:01", - "last_seen": 1562600145, - "model": "US16P150", - "name": "mock-name", - "port_overrides": [], - "port_table": [ - { - "mac_table": [ - { - "age": 1, - "mac": "00:00:00:00:00:00", - "static": False, - "uptime": 3971792, - "vlan": 1, - }, - { - "age": 1, - "mac": REDACTED, - "static": True, - "uptime": 0, - "vlan": 0, - }, - ], - "media": "GE", - "name": "Port 1", - "port_idx": 1, - "poe_class": "Class 4", - "poe_enable": True, - "poe_mode": "auto", - "poe_power": "2.56", - "poe_voltage": "53.40", - "portconf_id": "1a1", - "port_poe": True, - "up": True, - }, - ], - "state": 1, - "type": "usw", - "version": "4.0.42.10433", - } - }, - "dpi_apps": { - "5f976f62e3c58f018ec7e17d": { - "_id": "5f976f62e3c58f018ec7e17d", - "apps": [], - "blocked": True, - "cats": ["4"], - "enabled": True, - "log": True, - "site_id": "name", - } - }, - "dpi_groups": { - "5f976f4ae3c58f018ec7dff6": { - "_id": "5f976f4ae3c58f018ec7dff6", - "name": "Block Media Streaming", - "site_id": "name", - "dpiapp_ids": ["5f976f62e3c58f018ec7e17d"], - } - }, - "wlans": {}, - } diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 1fddb623930..0d75a83c5f5 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -1,291 +1,50 @@ """Test UniFi Network.""" -from copy import deepcopy -from datetime import timedelta +from collections.abc import Callable from http import HTTPStatus -from unittest.mock import Mock, patch +from types import MappingProxyType +from typing import Any +from unittest.mock import patch import aiounifi import pytest -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.unifi.const import ( - CONF_SITE_ID, - CONF_TRACK_CLIENTS, - CONF_TRACK_DEVICES, - DEFAULT_ALLOW_BANDWIDTH_SENSORS, - DEFAULT_ALLOW_UPTIME_SENSORS, - DEFAULT_DETECTION_TIME, - DEFAULT_TRACK_CLIENTS, - DEFAULT_TRACK_DEVICES, - DEFAULT_TRACK_WIRED_CLIENTS, - DOMAIN as UNIFI_DOMAIN, - UNIFI_WIRELESS_CLIENTS, -) +from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect from homeassistant.components.unifi.hub import get_unifi_api -from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, - CONTENT_TYPE_JSON, -) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -DEFAULT_CONFIG_ENTRY_ID = "1" -DEFAULT_HOST = "1.2.3.4" -DEFAULT_SITE = "site_id" - -CONTROLLER_HOST = { - "hostname": "controller_host", - "ip": DEFAULT_HOST, - "is_wired": True, - "last_seen": 1562600145, - "mac": "10:00:00:00:00:01", - "name": "Controller host", - "oui": "Producer", - "sw_mac": "00:00:00:00:01:01", - "sw_port": 1, - "wired-rx_bytes": 1234000000, - "wired-tx_bytes": 5678000000, - "uptime": 1562600160, -} - -ENTRY_CONFIG = { - CONF_HOST: DEFAULT_HOST, - CONF_USERNAME: "username", - CONF_PASSWORD: "password", - CONF_PORT: 1234, - CONF_SITE_ID: DEFAULT_SITE, - CONF_VERIFY_SSL: False, -} -ENTRY_OPTIONS = {} - -CONFIGURATION = [] - -SITE = [{"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"}] - -SYSTEM_INFORMATION = [ - { - "anonymous_controller_id": "24f81231-a456-4c32-abcd-f5612345385f", - "build": "atag_7.4.162_21057", - "console_display_version": "3.1.15", - "hostname": "UDMP", - "name": "UDMP", - "previous_version": "7.4.156", - "timezone": "Europe/Stockholm", - "ubnt_device_type": "UDMPRO", - "udm_version": "3.0.20.9281", - "update_available": False, - "update_downloaded": False, - "uptime": 1196290, - "version": "7.4.162", - } -] - - -def mock_default_unifi_requests( - aioclient_mock, - host, - site_id, - sites=None, - clients_response=None, - clients_all_response=None, - devices_response=None, - dpiapp_response=None, - dpigroup_response=None, - port_forward_response=None, - system_information_response=None, - wlans_response=None, -): - """Mock default UniFi requests responses.""" - aioclient_mock.get(f"https://{host}:1234", status=302) # Check UniFi OS - - aioclient_mock.post( - f"https://{host}:1234/api/login", - json={"data": "login successful", "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - f"https://{host}:1234/api/self/sites", - json={"data": sites or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/stat/sta", - json={"data": clients_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/rest/user", - json={"data": clients_all_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/stat/device", - json={"data": devices_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/rest/dpiapp", - json={"data": dpiapp_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/rest/dpigroup", - json={"data": dpigroup_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/rest/portforward", - json={"data": port_forward_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/stat/sysinfo", - json={"data": system_information_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/rest/wlanconf", - json={"data": wlans_response or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/v2/api/site/{site_id}/trafficroutes", - json=[{}], - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( - f"https://{host}:1234/v2/api/site/{site_id}/trafficrules", - json=[{}], - headers={"content-type": CONTENT_TYPE_JSON}, - ) - - -async def setup_unifi_integration( - hass, - aioclient_mock=None, - *, - config=ENTRY_CONFIG, - options=ENTRY_OPTIONS, - sites=SITE, - clients_response=None, - clients_all_response=None, - devices_response=None, - dpiapp_response=None, - dpigroup_response=None, - port_forward_response=None, - system_information_response=None, - wlans_response=None, - known_wireless_clients=None, - unique_id="1", - config_entry_id=DEFAULT_CONFIG_ENTRY_ID, -): - """Create the UniFi Network instance.""" - assert await async_setup_component(hass, UNIFI_DOMAIN, {}) - - config_entry = MockConfigEntry( - domain=UNIFI_DOMAIN, - data=deepcopy(config), - options=deepcopy(options), - unique_id=unique_id, - entry_id=config_entry_id, - version=1, - ) - config_entry.add_to_hass(hass) - - if known_wireless_clients: - hass.data[UNIFI_WIRELESS_CLIENTS].wireless_clients.update( - known_wireless_clients - ) - - if aioclient_mock: - mock_default_unifi_requests( - aioclient_mock, - host=config_entry.data[CONF_HOST], - site_id=config_entry.data[CONF_SITE_ID], - sites=sites, - clients_response=clients_response, - clients_all_response=clients_all_response, - devices_response=devices_response, - dpiapp_response=dpiapp_response, - dpigroup_response=dpigroup_response, - port_forward_response=port_forward_response, - system_information_response=system_information_response, - wlans_response=wlans_response, - ) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - if config_entry.entry_id not in hass.data[UNIFI_DOMAIN]: - return None - - return config_entry - async def test_hub_setup( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, - aioclient_mock: AiohttpClientMocker, + config_entry_factory: Callable[[], ConfigEntry], ) -> None: """Successful setup.""" with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", return_value=True, ) as forward_entry_setup: - config_entry = await setup_unifi_integration( - hass, aioclient_mock, system_information_response=SYSTEM_INFORMATION - ) - hub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + config_entry = await config_entry_factory() - entry = hub.config.entry assert len(forward_entry_setup.mock_calls) == 1 assert forward_entry_setup.mock_calls[0][1] == ( - entry, + config_entry, [ - BUTTON_DOMAIN, - TRACKER_DOMAIN, - IMAGE_DOMAIN, - SENSOR_DOMAIN, - SWITCH_DOMAIN, - UPDATE_DOMAIN, + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.IMAGE, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, ], ) - assert hub.config.host == ENTRY_CONFIG[CONF_HOST] - assert hub.is_admin == (SITE[0]["role"] == "admin") - - assert hub.config.option_allow_bandwidth_sensors == DEFAULT_ALLOW_BANDWIDTH_SENSORS - assert hub.config.option_allow_uptime_sensors == DEFAULT_ALLOW_UPTIME_SENSORS - assert isinstance(hub.config.option_block_clients, list) - assert hub.config.option_track_clients == DEFAULT_TRACK_CLIENTS - assert hub.config.option_track_devices == DEFAULT_TRACK_DEVICES - assert hub.config.option_track_wired_clients == DEFAULT_TRACK_WIRED_CLIENTS - assert hub.config.option_detection_time == timedelta(seconds=DEFAULT_DETECTION_TIME) - assert isinstance(hub.config.option_ssid_filter, set) - - assert hub.signal_reachable == "unifi-reachable-1" - assert hub.signal_options_update == "unifi-options-1" - assert hub.signal_heartbeat_missed == "unifi-heartbeat-missed" - device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(UNIFI_DOMAIN, config_entry.unique_id)}, @@ -294,137 +53,80 @@ async def test_hub_setup( assert device_entry.sw_version == "7.4.162" -async def test_hub_not_accessible(hass: HomeAssistant) -> None: - """Retry to login gets scheduled when connection fails.""" - with patch( - "homeassistant.components.unifi.hub.get_unifi_api", - side_effect=CannotConnect, - ): - await setup_unifi_integration(hass) - assert hass.data[UNIFI_DOMAIN] == {} - - -async def test_hub_trigger_reauth_flow(hass: HomeAssistant) -> None: - """Failed authentication trigger a reauthentication flow.""" - with ( - patch( - "homeassistant.components.unifi.get_unifi_api", - side_effect=AuthenticationRequired, - ), - patch.object(hass.config_entries.flow, "async_init") as mock_flow_init, - ): - await setup_unifi_integration(hass) - mock_flow_init.assert_called_once() - assert hass.data[UNIFI_DOMAIN] == {} - - -async def test_hub_unknown_error(hass: HomeAssistant) -> None: - """Unknown errors are handled.""" - with patch( - "homeassistant.components.unifi.hub.get_unifi_api", - side_effect=Exception, - ): - await setup_unifi_integration(hass) - assert hass.data[UNIFI_DOMAIN] == {} - - -async def test_config_entry_updated( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Calling reset when the entry has been setup.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - hub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - event_call = Mock() - unsub = async_dispatcher_connect(hass, hub.signal_options_update, event_call) - - hass.config_entries.async_update_entry( - config_entry, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False} - ) - await hass.async_block_till_done() - - assert config_entry.options[CONF_TRACK_CLIENTS] is False - assert config_entry.options[CONF_TRACK_DEVICES] is False - - event_call.assert_called_once() - - unsub() - - async def test_reset_after_successful_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, config_entry_setup: ConfigEntry ) -> None: """Calling reset when the entry has been setup.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - hub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + assert config_entry_setup.state is ConfigEntryState.LOADED - result = await hub.async_reset() - await hass.async_block_till_done() - - assert result is True + assert await hass.config_entries.async_unload(config_entry_setup.entry_id) + assert config_entry_setup.state is ConfigEntryState.NOT_LOADED async def test_reset_fails( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, config_entry_setup: ConfigEntry ) -> None: """Calling reset when the entry has been setup can return false.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - hub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + assert config_entry_setup.state is ConfigEntryState.LOADED with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_unload", return_value=False, ): - result = await hub.async_reset() - await hass.async_block_till_done() - - assert result is False + assert not await hass.config_entries.async_unload(config_entry_setup.entry_id) + assert config_entry_setup.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("mock_device_registry") async def test_connection_state_signalling( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_device_registry, - websocket_mock, + mock_websocket_state, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], ) -> None: """Verify connection statesignalling and connection state are working.""" - client = { - "hostname": "client", - "ip": "10.0.0.1", - "is_wired": True, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - await setup_unifi_integration(hass, aioclient_mock, clients_response=[client]) + client_payload.append( + { + "hostname": "client", + "ip": "10.0.0.1", + "is_wired": True, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:01", + } + ) + await config_entry_factory() # Controller is connected assert hass.states.get("device_tracker.client").state == "home" - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() # Controller is disconnected assert hass.states.get("device_tracker.client").state == "unavailable" - await websocket_mock.reconnect() + await mock_websocket_state.reconnect() # Controller is once again connected assert hass.states.get("device_tracker.client").state == "home" async def test_reconnect_mechanism( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock + aioclient_mock: AiohttpClientMocker, + mock_websocket_state, + config_entry_setup: ConfigEntry, ) -> None: """Verify reconnect prints only on first reconnection try.""" - await setup_unifi_integration(hass, aioclient_mock) - aioclient_mock.clear_requests() - aioclient_mock.get(f"https://{DEFAULT_HOST}:1234/", status=HTTPStatus.BAD_GATEWAY) + aioclient_mock.get( + f"https://{config_entry_setup.data[CONF_HOST]}:1234/", + status=HTTPStatus.BAD_GATEWAY, + ) - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() assert aioclient_mock.call_count == 0 - await websocket_mock.reconnect(fail=True) + await mock_websocket_state.reconnect(fail=True) assert aioclient_mock.call_count == 1 - await websocket_mock.reconnect(fail=True) + await mock_websocket_state.reconnect(fail=True) assert aioclient_mock.call_count == 2 @@ -437,38 +139,21 @@ async def test_reconnect_mechanism( aiounifi.AiounifiException, ], ) -async def test_reconnect_mechanism_exceptions( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock, exception -) -> None: +@pytest.mark.usefixtures("config_entry_setup") +async def test_reconnect_mechanism_exceptions(mock_websocket_state, exception) -> None: """Verify async_reconnect calls expected methods.""" - await setup_unifi_integration(hass, aioclient_mock) - with ( patch("aiounifi.Controller.login", side_effect=exception), patch( "homeassistant.components.unifi.hub.hub.UnifiWebsocket.reconnect" ) as mock_reconnect, ): - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() - await websocket_mock.reconnect() + await mock_websocket_state.reconnect() mock_reconnect.assert_called_once() -async def test_get_unifi_api(hass: HomeAssistant) -> None: - """Successful call.""" - with patch("aiounifi.Controller.login", return_value=True): - assert await get_unifi_api(hass, ENTRY_CONFIG) - - -async def test_get_unifi_api_verify_ssl_false(hass: HomeAssistant) -> None: - """Successful call with verify ssl set to false.""" - hub_data = dict(ENTRY_CONFIG) - hub_data[CONF_VERIFY_SSL] = False - with patch("aiounifi.Controller.login", return_value=True): - assert await get_unifi_api(hass, hub_data) - - @pytest.mark.parametrize( ("side_effect", "raised_exception"), [ @@ -484,11 +169,14 @@ async def test_get_unifi_api_verify_ssl_false(hass: HomeAssistant) -> None: ], ) async def test_get_unifi_api_fails_to_connect( - hass: HomeAssistant, side_effect, raised_exception + hass: HomeAssistant, + side_effect, + raised_exception, + config_entry_data: MappingProxyType[str, Any], ) -> None: """Check that get_unifi_api can handle UniFi Network being unavailable.""" with ( patch("aiounifi.Controller.login", side_effect=side_effect), pytest.raises(raised_exception), ): - await get_unifi_api(hass, ENTRY_CONFIG) + await get_unifi_api(hass, config_entry_data) diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py index 8efef579e9c..75d2f02900d 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -5,6 +5,7 @@ from datetime import timedelta from http import HTTPStatus from aiounifi.models.message import MessageKey +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN @@ -15,10 +16,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_hub import setup_unifi_integration - from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator WLAN = { @@ -58,17 +56,17 @@ WLAN = { } +@pytest.mark.parametrize("wlan_payload", [[WLAN]]) +@pytest.mark.usefixtures("config_entry_setup") async def test_wlan_qr_code( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, - mock_unifi_websocket, - websocket_mock, + mock_websocket_message, + mock_websocket_state, ) -> None: """Test the update_clients function when no clients are found.""" - await setup_unifi_integration(hass, aioclient_mock, wlans_response=[WLAN]) assert len(hass.states.async_entity_ids(IMAGE_DOMAIN)) == 0 ent_reg_entry = entity_registry.async_get("image.ssid_1_qr_code") @@ -80,8 +78,6 @@ async def test_wlan_qr_code( entity_registry.async_update_entity( entity_id="image.ssid_1_qr_code", disabled_by=None ) - await hass.async_block_till_done() - async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), @@ -100,7 +96,7 @@ async def test_wlan_qr_code( assert body == snapshot # Update state object - same password - no change to state - mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=WLAN) + mock_websocket_message(message=MessageKey.WLAN_CONF_UPDATED, data=WLAN) await hass.async_block_till_done() image_state_2 = hass.states.get("image.ssid_1_qr_code") assert image_state_1.state == image_state_2.state @@ -108,7 +104,7 @@ async def test_wlan_qr_code( # Update state object - changed password - new state data = deepcopy(WLAN) data["x_passphrase"] = "new password" - mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=data) + mock_websocket_message(message=MessageKey.WLAN_CONF_UPDATED, data=data) await hass.async_block_till_done() image_state_3 = hass.states.get("image.ssid_1_qr_code") assert image_state_1.state != image_state_3.state @@ -123,22 +119,22 @@ async def test_wlan_qr_code( # Availability signalling # Controller disconnects - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() assert hass.states.get("image.ssid_1_qr_code").state == STATE_UNAVAILABLE # Controller reconnects - await websocket_mock.reconnect() + await mock_websocket_state.reconnect() assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE # WLAN gets disabled wlan_1 = deepcopy(WLAN) wlan_1["enabled"] = False - mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + mock_websocket_message(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) await hass.async_block_till_done() assert hass.states.get("image.ssid_1_qr_code").state == STATE_UNAVAILABLE # WLAN gets re-enabled wlan_1["enabled"] = True - mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + mock_websocket_message(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) await hass.async_block_till_done() assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index f358c03d98d..7cd203ab8fd 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -1,9 +1,11 @@ """Test UniFi Network integration setup process.""" +from collections.abc import Callable from typing import Any from unittest.mock import patch from aiounifi.models.message import MessageKey +import pytest from homeassistant.components import unifi from homeassistant.components.unifi.const import ( @@ -14,11 +16,12 @@ from homeassistant.components.unifi.const import ( DOMAIN as UNIFI_DOMAIN, ) from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .test_hub import DEFAULT_CONFIG_ENTRY_ID, setup_unifi_integration +from .conftest import DEFAULT_CONFIG_ENTRY_ID from tests.common import flush_store from tests.test_util.aiohttp import AiohttpClientMocker @@ -31,26 +34,22 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None: assert UNIFI_DOMAIN not in hass.data -async def test_successful_config_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +async def test_setup_entry_fails_config_entry_not_ready( + hass: HomeAssistant, config_entry_factory: Callable[[], ConfigEntry] ) -> None: - """Test that configured options for a host are loaded via config entry.""" - await setup_unifi_integration(hass, aioclient_mock) - assert hass.data[UNIFI_DOMAIN] - - -async def test_setup_entry_fails_config_entry_not_ready(hass: HomeAssistant) -> None: """Failed authentication trigger a reauthentication flow.""" with patch( "homeassistant.components.unifi.get_unifi_api", side_effect=CannotConnect, ): - await setup_unifi_integration(hass) + config_entry = await config_entry_factory() - assert hass.data[UNIFI_DOMAIN] == {} + assert config_entry.state == ConfigEntryState.SETUP_RETRY -async def test_setup_entry_fails_trigger_reauth_flow(hass: HomeAssistant) -> None: +async def test_setup_entry_fails_trigger_reauth_flow( + hass: HomeAssistant, config_entry_factory: Callable[[], ConfigEntry] +) -> None: """Failed authentication trigger a reauthentication flow.""" with ( patch( @@ -59,27 +58,35 @@ async def test_setup_entry_fails_trigger_reauth_flow(hass: HomeAssistant) -> Non ), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init, ): - await setup_unifi_integration(hass) + config_entry = await config_entry_factory() mock_flow_init.assert_called_once() - assert hass.data[UNIFI_DOMAIN] == {} - - -async def test_unload_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test being able to unload an entry.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - assert hass.data[UNIFI_DOMAIN] - - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert not hass.data[UNIFI_DOMAIN] + assert config_entry.state == ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "hostname": "client_1", + "ip": "10.0.0.1", + "is_wired": False, + "mac": "00:00:00:00:00:01", + }, + { + "hostname": "client_2", + "ip": "10.0.0.2", + "is_wired": False, + "mac": "00:00:00:00:00:02", + }, + ] + ], +) async def test_wireless_clients( hass: HomeAssistant, hass_storage: dict[str, Any], - aioclient_mock: AiohttpClientMocker, + config_entry_factory: Callable[[], ConfigEntry], ) -> None: """Verify wireless clients class.""" hass_storage[unifi.STORAGE_KEY] = { @@ -91,21 +98,7 @@ async def test_wireless_clients( }, } - client_1 = { - "hostname": "client_1", - "ip": "10.0.0.1", - "is_wired": False, - "mac": "00:00:00:00:00:01", - } - client_2 = { - "hostname": "client_2", - "ip": "10.0.0.2", - "is_wired": False, - "mac": "00:00:00:00:00:02", - } - await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client_1, client_2] - ) + await config_entry_factory() await flush_store(hass.data[unifi.UNIFI_WIRELESS_CLIENTS]._store) assert sorted(hass_storage[unifi.STORAGE_KEY]["data"]["wireless_clients"]) == [ @@ -115,98 +108,113 @@ async def test_wireless_clients( ] +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "hostname": "Wired client", + "is_wired": True, + "mac": "00:00:00:00:00:01", + "oui": "Producer", + "wired-rx_bytes": 1234000000, + "wired-tx_bytes": 5678000000, + "uptime": 1600094505, + }, + { + "is_wired": False, + "mac": "00:00:00:00:00:02", + "name": "Wireless client", + "oui": "Producer", + "rx_bytes": 2345000000, + "tx_bytes": 6789000000, + "uptime": 60, + }, + ] + ], +) +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device 1", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_ALLOW_UPTIME_SENSORS: True, + CONF_TRACK_CLIENTS: True, + CONF_TRACK_DEVICES: True, + } + ], +) async def test_remove_config_entry_device( hass: HomeAssistant, hass_storage: dict[str, Any], aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, - mock_unifi_websocket, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], + device_payload: list[dict[str, Any]], + mock_websocket_message, hass_ws_client: WebSocketGenerator, ) -> None: """Verify removing a device manually.""" - client_1 = { - "hostname": "Wired client", - "is_wired": True, - "mac": "00:00:00:00:00:01", - "oui": "Producer", - "wired-rx_bytes": 1234000000, - "wired-tx_bytes": 5678000000, - "uptime": 1600094505, - } - client_2 = { - "is_wired": False, - "mac": "00:00:00:00:00:02", - "name": "Wireless client", - "oui": "Producer", - "rx_bytes": 2345000000, - "tx_bytes": 6789000000, - "uptime": 60, - } - device_1 = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": True, - "fan_level": 0, - "ip": "10.0.1.1", - "last_seen": 1562600145, - "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "Device 1", - "next_interval": 20, - "overheating": True, - "state": 1, - "type": "usw", - "upgradable": True, - "version": "4.0.42.10433", - } - options = { - CONF_ALLOW_BANDWIDTH_SENSORS: True, - CONF_ALLOW_UPTIME_SENSORS: True, - CONF_TRACK_CLIENTS: True, - CONF_TRACK_DEVICES: True, - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[client_1, client_2], - devices_response=[device_1], - ) + config_entry = await config_entry_factory() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) # Try to remove an active client from UI: not allowed device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])} ) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert not response["success"] assert device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])} ) # Try to remove an active device from UI: not allowed device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, device_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, device_payload[0]["mac"])} ) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert not response["success"] assert device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, device_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, device_payload[0]["mac"])} ) # Remove a client from Unifi API - mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[client_2]) + mock_websocket_message(message=MessageKey.CLIENT_REMOVED, data=[client_payload[1]]) await hass.async_block_till_done() # Try to remove an inactive client from UI: allowed device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_2["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[1]["mac"])} ) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert response["success"] assert not device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_2["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[1]["mac"])} ) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 26eadfa498e..960a5d3e529 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -1,13 +1,17 @@ """UniFi Network sensor platform tests.""" +from collections.abc import Callable from copy import deepcopy from datetime import datetime, timedelta +from types import MappingProxyType +from typing import Any from unittest.mock import patch from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey from freezegun.api import FrozenDateTimeFactory, freeze_time import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import ( ATTR_STATE_CLASS, @@ -25,17 +29,20 @@ from homeassistant.components.unifi.const import ( DEFAULT_DETECTION_TIME, DEVICE_STATES, ) -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util -from .test_hub import setup_unifi_integration - from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker DEVICE_1 = { "board_rev": 2, @@ -309,56 +316,58 @@ PDU_OUTLETS_UPDATE_DATA = [ ] -async def test_no_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.parametrize( + "config_entry_options", + [{CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True}], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_no_clients(hass: HomeAssistant) -> None: """Test the update_clients function when no clients are found.""" - await setup_unifi_integration( - hass, - aioclient_mock, - options={ - CONF_ALLOW_BANDWIDTH_SENSORS: True, - CONF_ALLOW_UPTIME_SENSORS: True, - }, - ) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_ALLOW_UPTIME_SENSORS: False, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "hostname": "Wired client", + "is_wired": True, + "mac": "00:00:00:00:00:01", + "oui": "Producer", + "wired-rx_bytes-r": 1234000000, + "wired-tx_bytes-r": 5678000000, + }, + { + "is_wired": False, + "mac": "00:00:00:00:00:02", + "name": "Wireless client", + "oui": "Producer", + "rx_bytes-r": 2345000000.0, + "tx_bytes-r": 6789000000.0, + }, + ] + ], +) async def test_bandwidth_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + mock_websocket_message, + config_entry_options: MappingProxyType[str, Any], + config_entry_setup: ConfigEntry, + client_payload: list[dict[str, Any]], ) -> None: """Verify that bandwidth sensors are working as expected.""" - wired_client = { - "hostname": "Wired client", - "is_wired": True, - "mac": "00:00:00:00:00:01", - "oui": "Producer", - "wired-rx_bytes-r": 1234000000, - "wired-tx_bytes-r": 5678000000, - } - wireless_client = { - "is_wired": False, - "mac": "00:00:00:00:00:02", - "name": "Wireless client", - "oui": "Producer", - "rx_bytes-r": 2345000000.0, - "tx_bytes-r": 6789000000.0, - } - options = { - CONF_ALLOW_BANDWIDTH_SENSORS: True, - CONF_ALLOW_UPTIME_SENSORS: False, - CONF_TRACK_CLIENTS: False, - CONF_TRACK_DEVICES: False, - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[wired_client, wireless_client], - ) - assert len(hass.states.async_all()) == 5 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 @@ -385,11 +394,11 @@ async def test_bandwidth_sensors( assert wltx_sensor.state == "6789.0" # Verify state update - + wireless_client = client_payload[1] wireless_client["rx_bytes-r"] = 3456000000 wireless_client["tx_bytes-r"] = 7891000000 - mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client) + mock_websocket_message(message=MessageKey.CLIENT, data=wireless_client) await hass.async_block_till_done() assert hass.states.get("sensor.wireless_client_rx").state == "3456.0" @@ -400,7 +409,7 @@ async def test_bandwidth_sensors( new_time = dt_util.utcnow() wireless_client["last_seen"] = dt_util.as_timestamp(new_time) - mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client) + mock_websocket_message(message=MessageKey.CLIENT, data=wireless_client) await hass.async_block_till_done() with freeze_time(new_time): @@ -412,7 +421,8 @@ async def test_bandwidth_sensors( new_time += timedelta( seconds=( - config_entry.options.get(CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME) + 1 + config_entry_setup.options.get(CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME) + + 1 ) ) with freeze_time(new_time): @@ -423,9 +433,9 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wireless_client_tx").state == STATE_UNAVAILABLE # Disable option - + options = deepcopy(config_entry_options) options[CONF_ALLOW_BANDWIDTH_SENSORS] = False - hass.config_entries.async_update_entry(config_entry, options=options.copy()) + hass.config_entries.async_update_entry(config_entry_setup, options=options) await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 @@ -436,9 +446,9 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wired_client_tx") is None # Enable option - + options = deepcopy(config_entry_options) options[CONF_ALLOW_BANDWIDTH_SENSORS] = True - hass.config_entries.async_update_entry(config_entry, options=options.copy()) + hass.config_entries.async_update_entry(config_entry_setup, options=options) await hass.async_block_till_done() assert len(hass.states.async_all()) == 5 @@ -449,6 +459,30 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wired_client_tx") +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: False, + CONF_ALLOW_UPTIME_SENSORS: True, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "mac": "00:00:00:00:00:01", + "name": "client1", + "oui": "Producer", + "uptime": 0, + } + ] + ], +) @pytest.mark.parametrize( ("initial_uptime", "event_uptime", "new_uptime"), [ @@ -458,44 +492,27 @@ async def test_bandwidth_sensors( (60, 64, 60), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_uptime_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, freezer: FrozenDateTimeFactory, - mock_unifi_websocket, - entity_registry_enabled_by_default: None, + mock_websocket_message, + config_entry_options: MappingProxyType[str, Any], + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], initial_uptime, event_uptime, new_uptime, ) -> None: """Verify that uptime sensors are working as expected.""" - uptime_client = { - "mac": "00:00:00:00:00:01", - "name": "client1", - "oui": "Producer", - "uptime": initial_uptime, - } - options = { - CONF_ALLOW_BANDWIDTH_SENSORS: False, - CONF_ALLOW_UPTIME_SENSORS: True, - CONF_TRACK_CLIENTS: False, - CONF_TRACK_DEVICES: False, - } + uptime_client = client_payload[0] + uptime_client["uptime"] = initial_uptime + freezer.move_to(datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC)) + config_entry = await config_entry_factory() - now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) - 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 assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T01:00:00+00:00" - assert ( entity_registry.async_get("sensor.client1_uptime").entity_category is EntityCategory.DIAGNOSTIC @@ -503,84 +520,79 @@ async def test_uptime_sensors( # Verify normal new event doesn't change uptime # 4 seconds has passed - uptime_client["uptime"] = event_uptime now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket(message=MessageKey.CLIENT, data=uptime_client) + mock_websocket_message(message=MessageKey.CLIENT, data=uptime_client) await hass.async_block_till_done() assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T01:00:00+00:00" # Verify new event change uptime # 1 month has passed - uptime_client["uptime"] = new_uptime now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket(message=MessageKey.CLIENT, data=uptime_client) + mock_websocket_message(message=MessageKey.CLIENT, data=uptime_client) await hass.async_block_till_done() assert hass.states.get("sensor.client1_uptime").state == "2021-02-01T01:00:00+00:00" # Disable option - + options = deepcopy(config_entry_options) options[CONF_ALLOW_UPTIME_SENSORS] = False - hass.config_entries.async_update_entry(config_entry, options=options.copy()) + hass.config_entries.async_update_entry(config_entry, options=options) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 assert hass.states.get("sensor.client1_uptime") is None # Enable option - + options = deepcopy(config_entry_options) options[CONF_ALLOW_UPTIME_SENSORS] = True with patch("homeassistant.util.dt.now", return_value=now): - hass.config_entries.async_update_entry(config_entry, options=options.copy()) + hass.config_entries.async_update_entry(config_entry, options=options) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.client1_uptime") +@pytest.mark.parametrize( + "config_entry_options", + [{CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True}], +) +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "hostname": "Wired client", + "is_wired": True, + "mac": "00:00:00:00:00:01", + "oui": "Producer", + "wired-rx_bytes": 1234000000, + "wired-tx_bytes": 5678000000, + "uptime": 1600094505, + }, + { + "is_wired": False, + "mac": "00:00:00:00:00:02", + "name": "Wireless client", + "oui": "Producer", + "rx_bytes": 2345000000, + "tx_bytes": 6789000000, + "uptime": 60, + }, + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_remove_sensors( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - entity_registry_enabled_by_default: None, + hass: HomeAssistant, mock_websocket_message, client_payload: list[dict[str, Any]] ) -> None: """Verify removing of clients work as expected.""" - wired_client = { - "hostname": "Wired client", - "is_wired": True, - "mac": "00:00:00:00:00:01", - "oui": "Producer", - "wired-rx_bytes": 1234000000, - "wired-tx_bytes": 5678000000, - "uptime": 1600094505, - } - wireless_client = { - "is_wired": False, - "mac": "00:00:00:00:00:02", - "name": "Wireless client", - "oui": "Producer", - "rx_bytes": 2345000000, - "tx_bytes": 6789000000, - "uptime": 60, - } - - await setup_unifi_integration( - hass, - aioclient_mock, - options={ - CONF_ALLOW_BANDWIDTH_SENSORS: True, - CONF_ALLOW_UPTIME_SENSORS: True, - }, - clients_response=[wired_client, wireless_client], - ) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 assert hass.states.get("sensor.wired_client_rx") assert hass.states.get("sensor.wired_client_tx") @@ -590,8 +602,7 @@ async def test_remove_sensors( assert hass.states.get("sensor.wireless_client_uptime") # Remove wired client - - mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=wired_client) + mock_websocket_message(message=MessageKey.CLIENT_REMOVED, data=client_payload[0]) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 @@ -603,15 +614,15 @@ async def test_remove_sensors( assert hass.states.get("sensor.wireless_client_uptime") +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) +@pytest.mark.usefixtures("config_entry_setup") async def test_poe_port_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - websocket_mock, + mock_websocket_message, + mock_websocket_state, ) -> 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)) == 2 ent_reg_entry = entity_registry.async_get("sensor.mock_name_port_1_poe_power") @@ -638,34 +649,34 @@ async def test_poe_port_switches( # Update state object device_1 = deepcopy(DEVICE_1) device_1["port_table"][0]["poe_power"] = "5.12" - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("sensor.mock_name_port_1_poe_power").state == "5.12" # PoE is disabled device_1 = deepcopy(DEVICE_1) device_1["port_table"][0]["poe_mode"] = "off" - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("sensor.mock_name_port_1_poe_power").state == "0" # Availability signalling # Controller disconnects - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() assert ( hass.states.get("sensor.mock_name_port_1_poe_power").state == STATE_UNAVAILABLE ) # Controller reconnects - await websocket_mock.reconnect() + await mock_websocket_state.reconnect() assert ( hass.states.get("sensor.mock_name_port_1_poe_power").state != STATE_UNAVAILABLE ) # Device gets disabled device_1["disabled"] = True - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert ( hass.states.get("sensor.mock_name_port_1_poe_power").state == STATE_UNAVAILABLE @@ -673,46 +684,44 @@ async def test_poe_port_switches( # Device gets re-enabled device_1["disabled"] = False - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("sensor.mock_name_port_1_poe_power") +@pytest.mark.parametrize("wlan_payload", [[WLAN]]) async def test_wlan_client_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - websocket_mock, + mock_websocket_message, + mock_websocket_state, + config_entry_factory: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], ) -> None: """Verify that WLAN client sensors are working as expected.""" - wireless_client_1 = { - "essid": "SSID 1", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - "name": "Wireless client", - "oui": "Producer", - "rx_bytes-r": 2345000000, - "tx_bytes-r": 6789000000, - } - wireless_client_2 = { - "essid": "SSID 2", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:02", - "name": "Wireless client2", - "oui": "Producer2", - "rx_bytes-r": 2345000000, - "tx_bytes-r": 6789000000, - } - - await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[wireless_client_1, wireless_client_2], - wlans_response=[WLAN], - ) + client_payload += [ + { + "essid": "SSID 1", + "is_wired": False, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:01", + "name": "Wireless client", + "oui": "Producer", + "rx_bytes-r": 2345000000, + "tx_bytes-r": 6789000000, + }, + { + "essid": "SSID 2", + "is_wired": False, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:02", + "name": "Wireless client2", + "oui": "Producer2", + "rx_bytes-r": 2345000000, + "tx_bytes-r": 6789000000, + }, + ] + await config_entry_factory() assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 @@ -726,12 +735,12 @@ async def test_wlan_client_sensors( assert ssid_1.state == "1" # Verify state update - increasing number - + wireless_client_1 = client_payload[0] wireless_client_1["essid"] = "SSID 1" + mock_websocket_message(message=MessageKey.CLIENT, data=wireless_client_1) + wireless_client_2 = client_payload[1] wireless_client_2["essid"] = "SSID 1" - - mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_1) - mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_2) + mock_websocket_message(message=MessageKey.CLIENT, data=wireless_client_2) await hass.async_block_till_done() ssid_1 = hass.states.get("sensor.ssid_1") @@ -746,7 +755,7 @@ async def test_wlan_client_sensors( # Verify state update - decreasing number wireless_client_1["essid"] = "SSID" - mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_1) + mock_websocket_message(message=MessageKey.CLIENT, data=wireless_client_1) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() @@ -757,7 +766,7 @@ async def test_wlan_client_sensors( # Verify state update - decreasing number wireless_client_2["last_seen"] = 0 - mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_2) + mock_websocket_message(message=MessageKey.CLIENT, data=wireless_client_2) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() @@ -768,23 +777,23 @@ async def test_wlan_client_sensors( # Availability signalling # Controller disconnects - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() assert hass.states.get("sensor.ssid_1").state == STATE_UNAVAILABLE # Controller reconnects - await websocket_mock.reconnect() + await mock_websocket_state.reconnect() assert hass.states.get("sensor.ssid_1").state == "0" # WLAN gets disabled wlan_1 = deepcopy(WLAN) wlan_1["enabled"] = False - mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + mock_websocket_message(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) await hass.async_block_till_done() assert hass.states.get("sensor.ssid_1").state == STATE_UNAVAILABLE # WLAN gets re-enabled wlan_1["enabled"] = True - mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + mock_websocket_message(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) await hass.async_block_till_done() assert hass.states.get("sensor.ssid_1").state == "0" @@ -821,11 +830,13 @@ async def test_wlan_client_sensors( ), ], ) +@pytest.mark.parametrize("device_payload", [[PDU_DEVICE_1]]) +@pytest.mark.usefixtures("config_entry_setup") async def test_outlet_power_readings( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + mock_websocket_message, + device_payload: list[dict[str, Any]], entity_id: str, expected_unique_id: str, expected_value: any, @@ -833,8 +844,6 @@ async def test_outlet_power_readings( expected_update_value: any, ) -> None: """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()) == 13 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 7 @@ -847,45 +856,52 @@ async def test_outlet_power_readings( assert sensor_data.state == expected_value if changed_data is not None: - updated_device_data = deepcopy(PDU_DEVICE_1) + updated_device_data = deepcopy(device_payload[0]) updated_device_data.update(changed_data) - mock_unifi_websocket(message=MessageKey.DEVICE, data=updated_device_data) + mock_websocket_message(message=MessageKey.DEVICE, data=updated_device_data) await hass.async_block_till_done() sensor_data = hass.states.get(f"sensor.{entity_id}") assert sensor_data.state == expected_update_value +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": 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", + } + ] + ], +) async def test_device_uptime( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + mock_websocket_message, + config_entry_factory: Callable[[], ConfigEntry], + device_payload: list[dict[str, Any]], ) -> None: """Verify that uptime sensors are working as expected.""" - device = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": 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", - } - 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]) + await config_entry_factory() 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" @@ -896,11 +912,11 @@ async def test_device_uptime( # Verify normal new event doesn't change uptime # 4 seconds has passed - + device = device_payload[0] device["uptime"] = 64 now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + mock_websocket_message(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" @@ -910,116 +926,132 @@ async def test_device_uptime( device["uptime"] = 60 now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): - mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + mock_websocket_message(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_uptime").state == "2021-02-01T01:00:00+00:00" +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "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", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") async def test_device_temperature( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + mock_websocket_message, + device_payload: list[dict[str, Any]], ) -> None: """Verify that temperature 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 assert hass.states.get("sensor.device_temperature").state == "30" - assert ( entity_registry.async_get("sensor.device_temperature").entity_category is EntityCategory.DIAGNOSTIC ) # Verify new event change temperature + device = device_payload[0] device["general_temperature"] = 60 - mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + mock_websocket_message(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_temperature").state == "60" +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "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", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") async def test_device_state( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + mock_websocket_message, + device_payload: list[dict[str, Any]], ) -> 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 - assert ( entity_registry.async_get("sensor.device_state").entity_category is EntityCategory.DIAGNOSTIC ) + device = device_payload[0] for i in list(map(int, DeviceState)): device["state"] = i - mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + mock_websocket_message(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_state").state == DEVICE_STATES[i] +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "device_id": "mock-id", + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "state": 1, + "version": "4.0.42.10433", + "system-stats": {"cpu": 5.8, "mem": 31.1, "uptime": 7316}, + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") async def test_device_system_stats( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + mock_websocket_message, + device_payload: list[dict[str, Any]], ) -> None: """Verify that device stats sensors are working as expected.""" - - device = { - "device_id": "mock-id", - "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "Device", - "state": 1, - "version": "4.0.42.10433", - "system-stats": {"cpu": 5.8, "mem": 31.1, "uptime": 7316}, - } - - await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_all()) == 8 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 @@ -1037,79 +1069,86 @@ async def test_device_system_stats( ) # Verify new event change system-stats + device = device_payload[0] device["system-stats"] = {"cpu": 7.7, "mem": 33.3, "uptime": 7316} - mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + mock_websocket_message(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_cpu_utilization").state == "7.7" assert hass.states.get("sensor.device_memory_utilization").state == "33.3" +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_ALLOW_UPTIME_SENSORS: False, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 2, + "device_id": "mock-id", + "ip": "10.0.1.1", + "mac": "10:00:00:00:01:01", + "last_seen": 1562600145, + "model": "US16P150", + "name": "mock-name", + "port_overrides": [], + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_class": "Class 4", + "poe_enable": False, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": False, + "up": True, + "rx_bytes-r": 1151, + "tx_bytes-r": 5111, + }, + { + "media": "GE", + "name": "Port 2", + "port_idx": 2, + "poe_class": "Class 4", + "poe_enable": False, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a2", + "port_poe": False, + "up": True, + "rx_bytes-r": 1536, + "tx_bytes-r": 3615, + }, + ], + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + ] + ], +) async def test_bandwidth_port_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, + mock_websocket_message, + config_entry_setup: ConfigEntry, + config_entry_options: MappingProxyType[str, Any], + device_payload: list[dict[str, Any]], ) -> None: """Verify that port bandwidth sensors are working as expected.""" - device_reponse = { - "board_rev": 2, - "device_id": "mock-id", - "ip": "10.0.1.1", - "mac": "10:00:00:00:01:01", - "last_seen": 1562600145, - "model": "US16P150", - "name": "mock-name", - "port_overrides": [], - "port_table": [ - { - "media": "GE", - "name": "Port 1", - "port_idx": 1, - "poe_class": "Class 4", - "poe_enable": False, - "poe_mode": "auto", - "poe_power": "2.56", - "poe_voltage": "53.40", - "portconf_id": "1a1", - "port_poe": False, - "up": True, - "rx_bytes-r": 1151, - "tx_bytes-r": 5111, - }, - { - "media": "GE", - "name": "Port 2", - "port_idx": 2, - "poe_class": "Class 4", - "poe_enable": False, - "poe_mode": "auto", - "poe_power": "2.56", - "poe_voltage": "53.40", - "portconf_id": "1a2", - "port_poe": False, - "up": True, - "rx_bytes-r": 1536, - "tx_bytes-r": 3615, - }, - ], - "state": 1, - "type": "usw", - "version": "4.0.42.10433", - } - options = { - CONF_ALLOW_BANDWIDTH_SENSORS: True, - CONF_ALLOW_UPTIME_SENSORS: False, - CONF_TRACK_CLIENTS: False, - CONF_TRACK_DEVICES: False, - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - devices_response=[device_reponse], - ) - assert len(hass.states.async_all()) == 5 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 @@ -1168,18 +1207,20 @@ async def test_bandwidth_port_sensors( assert p2tx_sensor.state == "0.02892" # Verify state update - device_reponse["port_table"][0]["rx_bytes-r"] = 3456000000 - device_reponse["port_table"][0]["tx_bytes-r"] = 7891000000 + device_1 = device_payload[0] + device_1["port_table"][0]["rx_bytes-r"] = 3456000000 + device_1["port_table"][0]["tx_bytes-r"] = 7891000000 - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_reponse) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("sensor.mock_name_port_1_rx").state == "27648.00000" assert hass.states.get("sensor.mock_name_port_1_tx").state == "63128.00000" # Disable option + options = config_entry_options.copy() options[CONF_ALLOW_BANDWIDTH_SENSORS] = False - hass.config_entries.async_update_entry(config_entry, options=options.copy()) + hass.config_entries.async_update_entry(config_entry_setup, options=options) await hass.async_block_till_done() assert len(hass.states.async_all()) == 5 @@ -1191,3 +1232,176 @@ async def test_bandwidth_port_sensors( assert hass.states.get("sensor.mock_name_port_1_tx") is None assert hass.states.get("sensor.mock_name_port_2_rx") is None assert hass.states.get("sensor.mock_name_port_2_tx") is None + + +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "device_id": "mock-id1", + "mac": "01:00:00:00:00:00", + "model": "US16P150", + "name": "Wired Device", + "state": 1, + "version": "4.0.42.10433", + }, + { + "device_id": "mock-id2", + "mac": "02:00:00:00:00:00", + "model": "US16P150", + "name": "Wireless Device", + "state": 1, + "version": "4.0.42.10433", + }, + ] + ], +) +async def test_device_client_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_factory, + mock_websocket_message, + client_payload, +) -> None: + """Verify that WLAN client sensors are working as expected.""" + client_payload += [ + { + "hostname": "Wired client 1", + "is_wired": True, + "mac": "00:00:00:00:00:01", + "oui": "Producer", + "sw_mac": "01:00:00:00:00:00", + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + }, + { + "hostname": "Wired client 2", + "is_wired": True, + "mac": "00:00:00:00:00:02", + "oui": "Producer", + "sw_mac": "01:00:00:00:00:00", + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + }, + { + "is_wired": False, + "mac": "00:00:00:00:00:03", + "name": "Wireless client 1", + "oui": "Producer", + "ap_mac": "02:00:00:00:00:00", + "sw_mac": "01:00:00:00:00:00", + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + }, + ] + await config_entry_factory() + + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + + ent_reg_entry = entity_registry.async_get("sensor.wired_device_clients") + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC + assert ent_reg_entry.unique_id == "device_clients-01:00:00:00:00:00" + + ent_reg_entry = entity_registry.async_get("sensor.wireless_device_clients") + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC + assert ent_reg_entry.unique_id == "device_clients-02:00:00:00:00:00" + + # Enable entity + entity_registry.async_update_entity( + entity_id="sensor.wired_device_clients", disabled_by=None + ) + entity_registry.async_update_entity( + entity_id="sensor.wireless_device_clients", disabled_by=None + ) + + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Validate state object + assert len(hass.states.async_all()) == 13 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6 + + assert hass.states.get("sensor.wired_device_clients").state == "2" + assert hass.states.get("sensor.wireless_device_clients").state == "1" + + # Verify state update - decreasing number + wireless_client_1 = client_payload[2] + wireless_client_1["last_seen"] = 0 + mock_websocket_message(message=MessageKey.CLIENT, data=wireless_client_1) + + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + assert hass.states.get("sensor.wired_device_clients").state == "2" + assert hass.states.get("sensor.wireless_device_clients").state == "0" + + +WIRED_CLIENT = { + "hostname": "Wired client", + "is_wired": True, + "mac": "00:00:00:00:00:01", + "oui": "Producer", + "wired-rx_bytes-r": 1234000000, + "wired-tx_bytes-r": 5678000000, + "uptime": 1600094505, +} +WIRELESS_CLIENT = { + "is_wired": False, + "mac": "00:00:00:00:00:01", + "name": "Wireless client", + "oui": "Producer", + "rx_bytes-r": 2345000000.0, + "tx_bytes-r": 6789000000.0, + "uptime": 60, +} + + +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_ALLOW_UPTIME_SENSORS: True, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, + } + ], +) +@pytest.mark.parametrize( + ("client_payload", "entity_id", "unique_id_prefix"), + [ + ([WIRED_CLIENT], "sensor.wired_client_rx", "rx-"), + ([WIRED_CLIENT], "sensor.wired_client_tx", "tx-"), + ([WIRED_CLIENT], "sensor.wired_client_uptime", "uptime-"), + ([WIRELESS_CLIENT], "sensor.wireless_client_rx", "rx-"), + ([WIRELESS_CLIENT], "sensor.wireless_client_tx", "tx-"), + ([WIRELESS_CLIENT], "sensor.wireless_client_uptime", "uptime-"), + ], +) +@pytest.mark.usefixtures("config_entry_setup") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time("2021-01-01 01:01:00") +async def test_sensor_sources( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, + unique_id_prefix: str, +) -> None: + """Test sensor sources and the entity description.""" + ent_reg_entry = entity_registry.async_get(entity_id) + assert ent_reg_entry.unique_id.startswith(unique_id_prefix) + assert ent_reg_entry.unique_id == snapshot + assert ent_reg_entry.entity_category == snapshot + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_DEVICE_CLASS) == snapshot + assert state.attributes.get(ATTR_FRIENDLY_NAME) == snapshot + assert state.attributes.get(ATTR_STATE_CLASS) == snapshot + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == snapshot + assert state.state == snapshot diff --git a/tests/components/unifi/test_services.py b/tests/components/unifi/test_services.py index 3f7da7a63ae..e3b03bc868d 100644 --- a/tests/components/unifi/test_services.py +++ b/tests/components/unifi/test_services.py @@ -1,82 +1,43 @@ """deCONZ service tests.""" -from unittest.mock import patch +from typing import Any +from unittest.mock import PropertyMock, patch + +import pytest from homeassistant.components.unifi.const import CONF_SITE_ID, DOMAIN as UNIFI_DOMAIN from homeassistant.components.unifi.services import ( SERVICE_RECONNECT_CLIENT, SERVICE_REMOVE_CLIENTS, - SUPPORTED_SERVICES, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .test_hub import setup_unifi_integration - from tests.test_util.aiohttp import AiohttpClientMocker -async def test_service_setup_and_unload( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Verify service setup works.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - for service in SUPPORTED_SERVICES: - assert hass.services.has_service(UNIFI_DOMAIN, service) - - assert await hass.config_entries.async_unload(config_entry.entry_id) - for service in SUPPORTED_SERVICES: - assert not hass.services.has_service(UNIFI_DOMAIN, service) - - -@patch("homeassistant.core.ServiceRegistry.async_remove") -@patch("homeassistant.core.ServiceRegistry.async_register") -async def test_service_setup_and_unload_not_called_if_multiple_integrations_detected( - register_service_mock, - remove_service_mock, - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Make sure that services are only setup and removed once.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - register_service_mock.reset_mock() - config_entry_2 = await setup_unifi_integration( - hass, aioclient_mock, config_entry_id=2 - ) - register_service_mock.assert_not_called() - - assert await hass.config_entries.async_unload(config_entry_2.entry_id) - remove_service_mock.assert_not_called() - assert await hass.config_entries.async_unload(config_entry.entry_id) - assert remove_service_mock.call_count == 2 - - +@pytest.mark.parametrize( + "client_payload", [[{"is_wired": False, "mac": "00:00:00:00:00:01"}]] +) async def test_reconnect_client( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, + client_payload: list[dict[str, Any]], ) -> None: """Verify call to reconnect client is performed as expected.""" - clients = [ - { - "is_wired": False, - "mac": "00:00:00:00:00:01", - } - ] - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=clients - ) - aioclient_mock.clear_requests() aioclient_mock.post( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/stamgr", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/cmd/stamgr", ) device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, clients[0]["mac"])}, + config_entry_id=config_entry_setup.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])}, ) await hass.services.async_call( @@ -88,12 +49,11 @@ async def test_reconnect_client( assert aioclient_mock.call_count == 1 +@pytest.mark.usefixtures("config_entry_setup") async def test_reconnect_non_existant_device( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Verify no call is made if device does not exist.""" - await setup_unifi_integration(hass, aioclient_mock) - aioclient_mock.clear_requests() await hass.services.async_call( @@ -109,14 +69,13 @@ async def test_reconnect_device_without_mac( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, ) -> None: """Verify no call is made if device does not have a known mac.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - aioclient_mock.clear_requests() device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, + config_entry_id=config_entry_setup.entry_id, connections={("other connection", "not mac")}, ) @@ -129,41 +88,38 @@ async def test_reconnect_device_without_mac( assert aioclient_mock.call_count == 0 +@pytest.mark.parametrize( + "client_payload", [[{"is_wired": False, "mac": "00:00:00:00:00:01"}]] +) async def test_reconnect_client_hub_unavailable( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, + client_payload: list[dict[str, Any]], ) -> None: """Verify no call is made if hub is unavailable.""" - clients = [ - { - "is_wired": False, - "mac": "00:00:00:00:00:01", - } - ] - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=clients - ) - hub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - hub.websocket.available = False - aioclient_mock.clear_requests() aioclient_mock.post( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/stamgr", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/cmd/stamgr", ) device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, clients[0]["mac"])}, + config_entry_id=config_entry_setup.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])}, ) - await hass.services.async_call( - UNIFI_DOMAIN, - SERVICE_RECONNECT_CLIENT, - service_data={ATTR_DEVICE_ID: device_entry.id}, - blocking=True, - ) + with patch( + "homeassistant.components.unifi.UnifiHub.available", new_callable=PropertyMock + ) as ws_mock: + ws_mock.return_value = False + await hass.services.async_call( + UNIFI_DOMAIN, + SERVICE_RECONNECT_CLIENT, + service_data={ATTR_DEVICE_ID: device_entry.id}, + blocking=True, + ) assert aioclient_mock.call_count == 0 @@ -171,14 +127,12 @@ async def test_reconnect_client_unknown_mac( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, ) -> None: """Verify no call is made if trying to reconnect a mac unknown to hub.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - aioclient_mock.clear_requests() - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, + config_entry_id=config_entry_setup.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "mac unknown to hub")}, ) @@ -191,27 +145,21 @@ async def test_reconnect_client_unknown_mac( assert aioclient_mock.call_count == 0 +@pytest.mark.parametrize( + "client_payload", [[{"is_wired": True, "mac": "00:00:00:00:00:01"}]] +) async def test_reconnect_wired_client( hass: HomeAssistant, device_registry: dr.DeviceRegistry, aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, + client_payload: list[dict[str, Any]], ) -> None: """Verify no call is made if client is wired.""" - clients = [ - { - "is_wired": True, - "mac": "00:00:00:00:00:01", - } - ] - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=clients - ) - aioclient_mock.clear_requests() - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, clients[0]["mac"])}, + config_entry_id=config_entry_setup.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])}, ) await hass.services.async_call( @@ -223,51 +171,46 @@ async def test_reconnect_wired_client( assert aioclient_mock.call_count == 0 +@pytest.mark.parametrize( + "clients_all_payload", + [ + [ + { + "mac": "00:00:00:00:00:00", + }, + {"first_seen": 100, "last_seen": 500, "mac": "00:00:00:00:00:01"}, + {"first_seen": 100, "last_seen": 1100, "mac": "00:00:00:00:00:02"}, + { + "first_seen": 100, + "last_seen": 500, + "fixed_ip": "1.2.3.4", + "mac": "00:00:00:00:00:03", + }, + { + "first_seen": 100, + "last_seen": 500, + "hostname": "hostname", + "mac": "00:00:00:00:00:04", + }, + { + "first_seen": 100, + "last_seen": 500, + "name": "name", + "mac": "00:00:00:00:00:05", + }, + ] + ], +) async def test_remove_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, ) -> None: """Verify removing different variations of clients work.""" - clients = [ - { - "mac": "00:00:00:00:00:00", - }, - { - "first_seen": 100, - "last_seen": 500, - "mac": "00:00:00:00:00:01", - }, - { - "first_seen": 100, - "last_seen": 1100, - "mac": "00:00:00:00:00:02", - }, - { - "first_seen": 100, - "last_seen": 500, - "fixed_ip": "1.2.3.4", - "mac": "00:00:00:00:00:03", - }, - { - "first_seen": 100, - "last_seen": 500, - "hostname": "hostname", - "mac": "00:00:00:00:00:04", - }, - { - "first_seen": 100, - "last_seen": 500, - "name": "name", - "mac": "00:00:00:00:00:05", - }, - ] - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_all_response=clients - ) - aioclient_mock.clear_requests() aioclient_mock.post( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/stamgr", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/cmd/stamgr", ) await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) @@ -276,46 +219,95 @@ async def test_remove_clients( "macs": ["00:00:00:00:00:00", "00:00:00:00:00:01"], } - assert await hass.config_entries.async_unload(config_entry.entry_id) + assert await hass.config_entries.async_unload(config_entry_setup.entry_id) +@pytest.mark.parametrize( + "clients_all_payload", + [ + [ + { + "first_seen": 100, + "last_seen": 500, + "mac": "00:00:00:00:00:01", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") async def test_remove_clients_hub_unavailable( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Verify no call is made if UniFi Network is unavailable.""" - clients = [ - { - "first_seen": 100, - "last_seen": 500, - "mac": "00:00:00:00:00:01", - } - ] - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_all_response=clients - ) - hub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - hub.websocket.available = False - aioclient_mock.clear_requests() - - await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + with patch( + "homeassistant.components.unifi.UnifiHub.available", new_callable=PropertyMock + ) as ws_mock: + ws_mock.return_value = False + await hass.services.async_call( + UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True + ) assert aioclient_mock.call_count == 0 +@pytest.mark.parametrize( + "clients_all_payload", + [ + [ + { + "first_seen": 100, + "last_seen": 1100, + "mac": "00:00:00:00:00:01", + } + ] + ], +) +@pytest.mark.usefixtures("config_entry_setup") async def test_remove_clients_no_call_on_empty_list( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Verify no call is made if no fitting client has been added to the list.""" - clients = [ - { - "first_seen": 100, - "last_seen": 1100, - "mac": "00:00:00:00:00:01", - } - ] - await setup_unifi_integration(hass, aioclient_mock, clients_all_response=clients) + aioclient_mock.clear_requests() + await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) + assert aioclient_mock.call_count == 0 + + +@pytest.mark.parametrize( + "clients_all_payload", + [ + [ + { + "first_seen": 100, + "last_seen": 500, + "mac": "00:00:00:00:00:01", + } + ] + ], +) +async def test_services_handle_unloaded_config_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + config_entry_setup: ConfigEntry, + clients_all_payload, +) -> None: + """Verify no call is made if config entry is unloaded.""" + await hass.config_entries.async_unload(config_entry_setup.entry_id) + await hass.async_block_till_done() aioclient_mock.clear_requests() await hass.services.async_call(UNIFI_DOMAIN, SERVICE_REMOVE_CLIENTS, blocking=True) assert aioclient_mock.call_count == 0 + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_setup.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, clients_all_payload[0]["mac"])}, + ) + await hass.services.async_call( + UNIFI_DOMAIN, + SERVICE_RECONNECT_CLIENT, + service_data={ATTR_DEVICE_ID: device_entry.id}, + blocking=True, + ) + assert aioclient_mock.call_count == 0 diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index a6b787045bd..b0ae8bde445 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1,12 +1,13 @@ """UniFi Network switch platform tests.""" +from collections.abc import Callable from copy import deepcopy from datetime import timedelta +from typing import Any from aiounifi.models.message import MessageKey import pytest -from homeassistant import config_entries from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -21,7 +22,7 @@ from homeassistant.components.unifi.const import ( CONF_TRACK_DEVICES, DOMAIN as UNIFI_DOMAIN, ) -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -36,7 +37,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_hub import CONTROLLER_HOST, ENTRY_CONFIG, SITE, setup_unifi_integration +from .conftest import CONTROLLER_HOST from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -760,79 +761,63 @@ WLAN = { "x_passphrase": "password", } +PORT_FORWARD_PLEX = { + "_id": "5a32aa4ee4b0412345678911", + "dst_port": "12345", + "enabled": True, + "fwd_port": "23456", + "fwd": "10.0.0.2", + "name": "plex", + "pfwd_interface": "wan", + "proto": "tcp_udp", + "site_id": "5a32aa4ee4b0412345678910", + "src": "any", +} -async def test_no_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the update_clients function when no clients are found.""" - await setup_unifi_integration( - hass, - aioclient_mock, - options={ + +@pytest.mark.parametrize("client_payload", [[CONTROLLER_HOST]]) +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_hub_not_client(hass: HomeAssistant) -> None: + """Test that the cloud key doesn't become a switch.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + assert hass.states.get("switch.cloud_key") is None + + +@pytest.mark.parametrize("client_payload", [[CLIENT_1]]) +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) +@pytest.mark.parametrize( + "site_payload", + [[{"desc": "Site name", "name": "site_id", "role": "not admin", "_id": "1"}]], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_not_admin(hass: HomeAssistant) -> None: + """Test that switch platform only work on an admin account.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]], CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False, - CONF_DPI_RESTRICTIONS: False, - }, - ) - - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 - - -async def test_hub_not_client( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that the cloud key doesn't become a switch.""" - await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, - clients_response=[CONTROLLER_HOST], - devices_response=[DEVICE_1], - ) - - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 - cloudkey = hass.states.get("switch.cloud_key") - assert cloudkey is None - - -async def test_not_admin( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that switch platform only work on an admin account.""" - site = deepcopy(SITE) - site[0]["role"] = "not admin" - await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, - sites=site, - clients_response=[CLIENT_1], - devices_response=[DEVICE_1], - ) - - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 - - + } + ], +) +@pytest.mark.parametrize("client_payload", [[CLIENT_4]]) +@pytest.mark.parametrize("clients_all_payload", [[BLOCKED, UNBLOCKED, CLIENT_1]]) +@pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) +@pytest.mark.usefixtures("config_entry_setup") async def test_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, ) -> None: """Test the update_items function with some clients.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={ - CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]], - CONF_TRACK_CLIENTS: False, - CONF_TRACK_DEVICES: False, - }, - clients_response=[CLIENT_4], - clients_all_response=[BLOCKED, UNBLOCKED, CLIENT_1], - dpigroup_response=DPI_GROUPS, - dpiapp_response=DPI_APPS, - ) - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 3 switch_4 = hass.states.get("switch.poe_client_4") @@ -859,8 +844,8 @@ async def test_switches( # Block and unblock client aioclient_mock.clear_requests() aioclient_mock.post( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/stamgr", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/cmd/stamgr", ) await hass.services.async_call( @@ -884,8 +869,8 @@ async def test_switches( # Enable and disable DPI aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/rest/dpiapp/{DPI_APPS[0]['_id']}", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/dpiapp/{DPI_APPS[0]['_id']}", ) await hass.services.async_call( @@ -907,25 +892,21 @@ async def test_switches( assert aioclient_mock.mock_calls[1][2] == {"enabled": True} -async def test_remove_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket -) -> None: +@pytest.mark.parametrize( + "config_entry_options", [{CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}] +) +@pytest.mark.parametrize("client_payload", [[UNBLOCKED]]) +@pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_remove_switches(hass: HomeAssistant, mock_websocket_message) -> None: """Test the update_items function with some clients.""" - await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}, - clients_response=[UNBLOCKED], - dpigroup_response=DPI_GROUPS, - dpiapp_response=DPI_APPS, - ) - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 assert hass.states.get("switch.block_client_2") is not None assert hass.states.get("switch.block_media_streaming") is not None - mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[UNBLOCKED]) + mock_websocket_message(message=MessageKey.CLIENT_REMOVED, data=[UNBLOCKED]) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 @@ -933,29 +914,32 @@ async def test_remove_switches( assert hass.states.get("switch.block_client_2") is None assert hass.states.get("switch.block_media_streaming") is not None - mock_unifi_websocket(data=DPI_GROUP_REMOVED_EVENT) + mock_websocket_message(data=DPI_GROUP_REMOVED_EVENT) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming") is None assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 -async def test_block_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket -) -> None: - """Test the update_items function with some clients.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={ +@pytest.mark.parametrize( + "config_entry_options", + [ + { CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]], CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False, - }, - clients_response=[UNBLOCKED], - clients_all_response=[BLOCKED], - ) - + } + ], +) +@pytest.mark.parametrize("client_payload", [[UNBLOCKED]]) +@pytest.mark.parametrize("clients_all_payload", [[BLOCKED]]) +async def test_block_switches( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_websocket_message, + config_entry_setup: ConfigEntry, +) -> None: + """Test the update_items function with some clients.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 blocked = hass.states.get("switch.block_client_1") @@ -966,7 +950,9 @@ async def test_block_switches( assert unblocked is not None assert unblocked.state == "on" - mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_UNBLOCKED) + mock_websocket_message( + message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_UNBLOCKED + ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -974,7 +960,7 @@ async def test_block_switches( assert blocked is not None assert blocked.state == "on" - mock_unifi_websocket(message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_BLOCKED) + mock_websocket_message(message=MessageKey.EVENT, data=EVENT_BLOCKED_CLIENT_BLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -984,8 +970,8 @@ async def test_block_switches( aioclient_mock.clear_requests() aioclient_mock.post( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/stamgr", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/cmd/stamgr", ) await hass.services.async_call( @@ -1007,20 +993,11 @@ async def test_block_switches( } -async def test_dpi_switches( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - websocket_mock, -) -> None: +@pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_dpi_switches(hass: HomeAssistant, mock_websocket_message) -> None: """Test the update_items function with some clients.""" - await setup_unifi_integration( - hass, - aioclient_mock, - dpigroup_response=DPI_GROUPS, - dpiapp_response=DPI_APPS, - ) - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 dpi_switch = hass.states.get("switch.block_media_streaming") @@ -1028,40 +1005,26 @@ async def test_dpi_switches( assert dpi_switch.state == STATE_ON assert dpi_switch.attributes["icon"] == "mdi:network" - mock_unifi_websocket(data=DPI_APP_DISABLED_EVENT) + mock_websocket_message(data=DPI_APP_DISABLED_EVENT) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_OFF - # Availability signalling - - # Controller disconnects - await websocket_mock.disconnect() - assert hass.states.get("switch.block_media_streaming").state == STATE_UNAVAILABLE - - # Controller reconnects - await websocket_mock.reconnect() - assert hass.states.get("switch.block_media_streaming").state == STATE_OFF - # Remove app - mock_unifi_websocket(data=DPI_GROUP_REMOVE_APP) + mock_websocket_message(data=DPI_GROUP_REMOVE_APP) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming") is None assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 +@pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) +@pytest.mark.usefixtures("config_entry_setup") async def test_dpi_switches_add_second_app( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, mock_websocket_message ) -> None: """Test the update_items function with some clients.""" - await setup_unifi_integration( - hass, - aioclient_mock, - dpigroup_response=DPI_GROUPS, - dpiapp_response=DPI_APPS, - ) - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 assert hass.states.get("switch.block_media_streaming").state == STATE_ON @@ -1074,7 +1037,7 @@ async def test_dpi_switches_add_second_app( "site_id": "name", "_id": "61783e89c1773a18c0c61f00", } - mock_unifi_websocket(message=MessageKey.DPI_APP_ADDED, data=second_app_event) + mock_websocket_message(message=MessageKey.DPI_APP_ADDED, data=second_app_event) await hass.async_block_till_done() assert hass.states.get("switch.block_media_streaming").state == STATE_ON @@ -1085,7 +1048,7 @@ async def test_dpi_switches_add_second_app( "site_id": "name", "dpiapp_ids": ["5f976f62e3c58f018ec7e17d", "61783e89c1773a18c0c61f00"], } - mock_unifi_websocket( + mock_websocket_message( message=MessageKey.DPI_GROUP_UPDATED, data=add_second_app_to_group ) await hass.async_block_till_done() @@ -1101,7 +1064,7 @@ async def test_dpi_switches_add_second_app( "site_id": "name", "_id": "61783e89c1773a18c0c61f00", } - mock_unifi_websocket( + mock_websocket_message( message=MessageKey.DPI_APP_UPDATED, data=second_app_event_enabled ) await hass.async_block_till_done() @@ -1110,43 +1073,26 @@ async def test_dpi_switches_add_second_app( @pytest.mark.parametrize( - ("entity_id", "test_data", "outlet_index", "expected_switches"), + ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ - ( - "plug_outlet_1", - OUTLET_UP1, - 1, - 1, - ), - ( - "dummy_usp_pdu_pro_usb_outlet_1", - PDU_DEVICE_1, - 1, - 2, - ), - ( - "dummy_usp_pdu_pro_outlet_2", - PDU_DEVICE_1, - 2, - 2, - ), + ([OUTLET_UP1], "plug_outlet_1", 1, 1), + ([PDU_DEVICE_1], "dummy_usp_pdu_pro_usb_outlet_1", 1, 2), + ([PDU_DEVICE_1], "dummy_usp_pdu_pro_outlet_2", 2, 2), ], ) async def test_outlet_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - websocket_mock, + mock_websocket_message, + config_entry_setup: ConfigEntry, + device_payload: list[dict[str, Any]], entity_id: str, - test_data: any, outlet_index: int, expected_switches: int, ) -> None: """Test the outlet entities.""" - config_entry = await setup_unifi_integration( - hass, aioclient_mock, devices_response=[test_data] - ) assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == expected_switches + # Validate state object switch_1 = hass.states.get(f"switch.{entity_id}") assert switch_1 is not None @@ -1154,18 +1100,18 @@ async def test_outlet_switches( assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET # Update state object - device_1 = deepcopy(test_data) + device_1 = deepcopy(device_payload[0]) device_1["outlet_table"][outlet_index - 1]["relay_state"] = False - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Turn off outlet - device_id = test_data["device_id"] + device_id = device_payload[0]["device_id"] aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/rest/device/{device_id}", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/{device_id}", ) await hass.services.async_call( @@ -1198,146 +1144,130 @@ async def test_outlet_switches( "outlet_overrides": expected_on_overrides } - # Availability signalling - - # Controller disconnects - await websocket_mock.disconnect() - assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE - - # Controller reconnects - await websocket_mock.reconnect() - assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF - # Device gets disabled device_1["disabled"] = True - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Device gets re-enabled device_1["disabled"] = False - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Unload config entry - await hass.config_entries.async_unload(config_entry.entry_id) + await hass.config_entries.async_unload(config_entry_setup.entry_id) assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Remove config entry - await hass.config_entries.async_remove(config_entry.entry_id) + await hass.config_entries.async_remove(config_entry_setup.entry_id) await hass.async_block_till_done() assert hass.states.get(f"switch.{entity_id}") is None -async def test_new_client_discovered_on_block_control( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket -) -> None: - """Test if 2nd update has a new client.""" - await setup_unifi_integration( - hass, - aioclient_mock, - options={ +@pytest.mark.parametrize( + "config_entry_options", + [ + { CONF_BLOCK_CLIENT: [BLOCKED["mac"]], CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False, CONF_DPI_RESTRICTIONS: False, - }, - ) - + } + ], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_new_client_discovered_on_block_control( + hass: HomeAssistant, mock_websocket_message +) -> None: + """Test if 2nd update has a new client.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 assert hass.states.get("switch.block_client_1") is None - mock_unifi_websocket(message=MessageKey.CLIENT, data=BLOCKED) + mock_websocket_message(message=MessageKey.CLIENT, data=BLOCKED) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 assert hass.states.get("switch.block_client_1") is not None +@pytest.mark.parametrize( + "config_entry_options", [{CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}] +) +@pytest.mark.parametrize("clients_all_payload", [[BLOCKED, UNBLOCKED]]) async def test_option_block_clients( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, config_entry_setup: ConfigEntry, clients_all_payload ) -> None: """Test the changes to option reflects accordingly.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, - clients_all_response=[BLOCKED, UNBLOCKED], - ) assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 # Add a second switch hass.config_entries.async_update_entry( - config_entry, - options={CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]]}, + config_entry_setup, + options={ + CONF_BLOCK_CLIENT: [ + clients_all_payload[0]["mac"], + clients_all_payload[1]["mac"], + ] + }, ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 # Remove the second switch again hass.config_entries.async_update_entry( - config_entry, - options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, + config_entry_setup, options={CONF_BLOCK_CLIENT: [clients_all_payload[0]["mac"]]} ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - # Enable one and remove another one + # Enable one and remove the other one hass.config_entries.async_update_entry( - config_entry, - options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}, + config_entry_setup, options={CONF_BLOCK_CLIENT: [clients_all_payload[1]["mac"]]} ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 # Remove one hass.config_entries.async_update_entry( - config_entry, - options={CONF_BLOCK_CLIENT: []}, + config_entry_setup, options={CONF_BLOCK_CLIENT: []} ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 +@pytest.mark.parametrize( + "config_entry_options", + [{CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}], +) +@pytest.mark.parametrize("client_payload", [[CLIENT_1]]) +@pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) async def test_option_remove_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, config_entry_setup: ConfigEntry ) -> None: """Test removal of DPI switch when options updated.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options={ - CONF_TRACK_CLIENTS: False, - CONF_TRACK_DEVICES: False, - }, - clients_response=[CLIENT_1], - dpigroup_response=DPI_GROUPS, - dpiapp_response=DPI_APPS, - ) assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 # Disable DPI Switches hass.config_entries.async_update_entry( - config_entry, - options={CONF_DPI_RESTRICTIONS: False}, + config_entry_setup, options={CONF_DPI_RESTRICTIONS: False} ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) async def test_poe_port_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - websocket_mock, + mock_websocket_message, + config_entry_setup: ConfigEntry, + device_payload: list[dict[str, Any]], ) -> None: - """Test the update_items function with some clients.""" - config_entry = await setup_unifi_integration( - hass, aioclient_mock, devices_response=[DEVICE_1] - ) - + """Test PoE port entities work.""" assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 ent_reg_entry = entity_registry.async_get("switch.mock_name_port_1_poe") @@ -1351,7 +1281,6 @@ async def test_poe_port_switches( entity_registry.async_update_entity( entity_id="switch.mock_name_port_2_poe", disabled_by=None ) - await hass.async_block_till_done() async_fire_time_changed( hass, @@ -1366,17 +1295,17 @@ async def test_poe_port_switches( assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET # Update state object - device_1 = deepcopy(DEVICE_1) + device_1 = deepcopy(device_payload[0]) device_1["port_table"][0]["poe_mode"] = "off" - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF # Turn off PoE aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/rest/device/mock-id", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/device/mock-id", ) await hass.services.async_call( @@ -1415,41 +1344,29 @@ async def test_poe_port_switches( ] } - # Availability signalling - - # Controller disconnects - await websocket_mock.disconnect() - assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE - - # Controller reconnects - await websocket_mock.reconnect() - assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF - # Device gets disabled device_1["disabled"] = True - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE # Device gets re-enabled device_1["disabled"] = False - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF +@pytest.mark.parametrize("wlan_payload", [[WLAN]]) async def test_wlan_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - websocket_mock, + mock_websocket_message, + config_entry_setup: ConfigEntry, + wlan_payload: list[dict[str, Any]], ) -> None: """Test control of UniFi WLAN availability.""" - config_entry = await setup_unifi_integration( - hass, aioclient_mock, wlans_response=[WLAN] - ) - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 ent_reg_entry = entity_registry.async_get("switch.ssid_1") @@ -1463,17 +1380,17 @@ async def test_wlan_switches( assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.SWITCH # Update state object - wlan = deepcopy(WLAN) + wlan = deepcopy(wlan_payload[0]) wlan["enabled"] = False - mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan) + mock_websocket_message(message=MessageKey.WLAN_CONF_UPDATED, data=wlan) await hass.async_block_till_done() assert hass.states.get("switch.ssid_1").state == STATE_OFF # Disable WLAN aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/rest/wlanconf/{WLAN['_id']}", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/wlanconf/{wlan['_id']}", ) await hass.services.async_call( @@ -1495,41 +1412,17 @@ async def test_wlan_switches( assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[1][2] == {"enabled": True} - # Availability signalling - - # Controller disconnects - await websocket_mock.disconnect() - assert hass.states.get("switch.ssid_1").state == STATE_UNAVAILABLE - - # Controller reconnects - await websocket_mock.reconnect() - assert hass.states.get("switch.ssid_1").state == STATE_OFF - +@pytest.mark.parametrize("port_forward_payload", [[PORT_FORWARD_PLEX]]) async def test_port_forwarding_switches( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, - mock_unifi_websocket, - websocket_mock, + mock_websocket_message, + config_entry_setup: ConfigEntry, + port_forward_payload: list[dict[str, Any]], ) -> None: """Test control of UniFi port forwarding.""" - _data = { - "_id": "5a32aa4ee4b0412345678911", - "dst_port": "12345", - "enabled": True, - "fwd_port": "23456", - "fwd": "10.0.0.2", - "name": "plex", - "pfwd_interface": "wan", - "proto": "tcp_udp", - "site_id": "5a32aa4ee4b0412345678910", - "src": "any", - } - config_entry = await setup_unifi_integration( - hass, aioclient_mock, port_forward_response=[_data.copy()] - ) - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 ent_reg_entry = entity_registry.async_get("switch.unifi_network_plex") @@ -1543,17 +1436,17 @@ async def test_port_forwarding_switches( assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.SWITCH # Update state object - data = _data.copy() + data = port_forward_payload[0].copy() data["enabled"] = False - mock_unifi_websocket(message=MessageKey.PORT_FORWARD_UPDATED, data=data) + mock_websocket_message(message=MessageKey.PORT_FORWARD_UPDATED, data=data) await hass.async_block_till_done() assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF # Disable port forward aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/rest/portforward/{data['_id']}", + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/rest/portforward/{data['_id']}", ) await hass.services.async_call( @@ -1563,7 +1456,7 @@ async def test_port_forwarding_switches( blocking=True, ) assert aioclient_mock.call_count == 1 - data = _data.copy() + data = port_forward_payload[0].copy() data["enabled"] = False assert aioclient_mock.mock_calls[0][2] == data @@ -1575,88 +1468,112 @@ async def test_port_forwarding_switches( blocking=True, ) assert aioclient_mock.call_count == 2 - assert aioclient_mock.mock_calls[1][2] == _data - - # Availability signalling - - # Controller disconnects - await websocket_mock.disconnect() - assert hass.states.get("switch.unifi_network_plex").state == STATE_UNAVAILABLE - - # Controller reconnects - await websocket_mock.reconnect() - assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF + assert aioclient_mock.mock_calls[1][2] == port_forward_payload[0] # Remove entity on deleted message - mock_unifi_websocket(message=MessageKey.PORT_FORWARD_DELETED, data=_data) + mock_websocket_message( + message=MessageKey.PORT_FORWARD_DELETED, data=port_forward_payload[0] + ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 +@pytest.mark.parametrize( + "device_payload", + [ + [ + OUTLET_UP1, + { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + "port_table": [ + { + "media": "GE", + "name": "Port 1", + "port_idx": 1, + "poe_caps": 7, + "poe_class": "Class 4", + "poe_enable": True, + "poe_mode": "auto", + "poe_power": "2.56", + "poe_voltage": "53.40", + "portconf_id": "1a1", + "port_poe": True, + "up": True, + }, + ], + }, + ] + ], +) async def test_updating_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, + config_entry_factory: Callable[[], ConfigEntry], + config_entry: ConfigEntry, + device_payload, ) -> None: """Verify outlet control and poe control unique ID update works.""" - poe_device = { - "board_rev": 3, - "device_id": "mock-id", - "ip": "10.0.0.1", - "last_seen": 1562600145, - "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "switch", - "state": 1, - "type": "usw", - "version": "4.0.42.10433", - "port_table": [ - { - "media": "GE", - "name": "Port 1", - "port_idx": 1, - "poe_caps": 7, - "poe_class": "Class 4", - "poe_enable": True, - "poe_mode": "auto", - "poe_power": "2.56", - "poe_voltage": "53.40", - "portconf_id": "1a1", - "port_poe": True, - "up": True, - }, - ], - } - - config_entry = config_entries.ConfigEntry( - version=1, - minor_version=1, - domain=UNIFI_DOMAIN, - title="Mock Title", - data=ENTRY_CONFIG, - source="test", - options={}, - entry_id="1", - ) - entity_registry.async_get_or_create( SWITCH_DOMAIN, UNIFI_DOMAIN, - f'{poe_device["mac"]}-poe-1', - suggested_object_id="switch_port_1_poe", - config_entry=config_entry, - ) - entity_registry.async_get_or_create( - SWITCH_DOMAIN, - UNIFI_DOMAIN, - f'{OUTLET_UP1["mac"]}-outlet-1', + f'{device_payload[0]["mac"]}-outlet-1', suggested_object_id="plug_outlet_1", config_entry=config_entry, ) - - await setup_unifi_integration( - hass, aioclient_mock, devices_response=[poe_device, OUTLET_UP1] + entity_registry.async_get_or_create( + SWITCH_DOMAIN, + UNIFI_DOMAIN, + f'{device_payload[1]["mac"]}-poe-1', + suggested_object_id="switch_port_1_poe", + config_entry=config_entry, ) + + await config_entry_factory() + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 - assert hass.states.get("switch.switch_port_1_poe") assert hass.states.get("switch.plug_outlet_1") + assert hass.states.get("switch.switch_port_1_poe") + + +@pytest.mark.parametrize( + "config_entry_options", [{CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}] +) +@pytest.mark.parametrize("client_payload", [[UNBLOCKED]]) +@pytest.mark.parametrize("device_payload", [[DEVICE_1, OUTLET_UP1]]) +@pytest.mark.parametrize("dpi_app_payload", [DPI_APPS]) +@pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) +@pytest.mark.parametrize("port_forward_payload", [[PORT_FORWARD_PLEX]]) +@pytest.mark.parametrize("wlan_payload", [[WLAN]]) +@pytest.mark.usefixtures("config_entry_setup") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> None: + """Verify entities state reflect on hub connection becoming unavailable.""" + entity_ids = ( + "switch.block_client_2", + "switch.mock_name_port_1_poe", + "switch.plug_outlet_1", + "switch.block_media_streaming", + "switch.unifi_network_plex", + "switch.ssid_1", + ) + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_ON + + # Controller disconnects + await mock_websocket_state.disconnect() + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # Controller reconnects + await mock_websocket_state.reconnect() + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 4094c544431..3b1de6c4456 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -3,6 +3,7 @@ from copy import deepcopy from aiounifi.models.message import MessageKey +import pytest from yarl import URL from homeassistant.components.unifi.const import CONF_SITE_ID @@ -15,6 +16,7 @@ from homeassistant.components.update import ( UpdateDeviceClass, UpdateEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -26,8 +28,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_hub import SITE, setup_unifi_integration - from tests.test_util.aiohttp import AiohttpClientMocker DEVICE_1 = { @@ -60,28 +60,14 @@ DEVICE_2 = { } -async def test_no_entities( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the update_clients function when no clients are found.""" - await setup_unifi_integration(hass, aioclient_mock) - - assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 0 - - -async def test_device_updates( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket -) -> None: +@pytest.mark.parametrize("device_payload", [[DEVICE_1, DEVICE_2]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_device_updates(hass: HomeAssistant, mock_websocket_message) -> None: """Test the update_items function with some devices.""" - device_1 = deepcopy(DEVICE_1) - await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[device_1, DEVICE_2], - ) - assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 2 + # Device with new firmware available + device_1_state = hass.states.get("update.device_1") assert device_1_state.state == STATE_ON assert device_1_state.attributes[ATTR_INSTALLED_VERSION] == "4.0.42.10433" @@ -93,6 +79,8 @@ async def test_device_updates( == UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL ) + # Device without new firmware available + device_2_state = hass.states.get("update.device_2") assert device_2_state.state == STATE_OFF assert device_2_state.attributes[ATTR_INSTALLED_VERSION] == "4.0.42.10433" @@ -106,8 +94,9 @@ async def test_device_updates( # Simulate start of update + device_1 = deepcopy(DEVICE_1) device_1["state"] = 4 - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() device_1_state = hass.states.get("update.device_1") @@ -122,7 +111,7 @@ async def test_device_updates( device_1["version"] = "4.3.17.11279" device_1["upgradable"] = False del device_1["upgrade_to_firmware"] - mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) + mock_websocket_message(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() device_1_state = hass.states.get("update.device_1") @@ -132,17 +121,14 @@ async def test_device_updates( assert device_1_state.attributes[ATTR_IN_PROGRESS] is False -async def test_not_admin( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) +@pytest.mark.parametrize( + "site_payload", + [[{"desc": "Site name", "name": "site_id", "role": "not admin", "_id": "1"}]], +) +@pytest.mark.usefixtures("config_entry_setup") +async def test_not_admin(hass: HomeAssistant) -> None: """Test that the INSTALL feature is not available on a non-admin account.""" - site = deepcopy(SITE) - site[0]["role"] = "not admin" - - await setup_unifi_integration( - hass, aioclient_mock, sites=site, devices_response=[DEVICE_1] - ) - assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 device_state = hass.states.get("update.device_1") assert device_state.state == STATE_ON @@ -151,21 +137,20 @@ async def test_not_admin( ) +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) async def test_install( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: ConfigEntry, ) -> None: """Test the device update install call.""" - config_entry = await setup_unifi_integration( - hass, aioclient_mock, devices_response=[DEVICE_1] - ) - assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 device_state = hass.states.get("update.device_1") assert device_state.state == STATE_ON url = ( - f"https://{config_entry.data[CONF_HOST]}:1234" - f"/api/s/{config_entry.data[CONF_SITE_ID]}/cmd/devmgr" + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/api/s/{config_entry_setup.data[CONF_SITE_ID]}/cmd/devmgr" ) aioclient_mock.clear_requests() aioclient_mock.post(url) @@ -187,19 +172,17 @@ async def test_install( ) -async def test_hub_state_change( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock -) -> None: +@pytest.mark.parametrize("device_payload", [[DEVICE_1]]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_hub_state_change(hass: HomeAssistant, mock_websocket_state) -> None: """Verify entities state reflect on hub becoming unavailable.""" - await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) - assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 assert hass.states.get("update.device_1").state == STATE_ON # Controller unavailable - await websocket_mock.disconnect() + await mock_websocket_state.disconnect() assert hass.states.get("update.device_1").state == STATE_UNAVAILABLE # Controller available - await websocket_mock.reconnect() + await mock_websocket_state.reconnect() assert hass.states.get("update.device_1").state == STATE_ON diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 5b3f9653d75..6366a4f9244 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -13,8 +13,8 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect import ProtectApiClient +from uiprotect.data import ( NVR, Bootstrap, Camera, @@ -216,6 +216,8 @@ def doorbell_fixture(camera: Camera, fixed_now: datetime): doorbell.feature_flags.smart_detect_types = [ SmartDetectObjectType.PERSON, SmartDetectObjectType.VEHICLE, + SmartDetectObjectType.ANIMAL, + SmartDetectObjectType.PACKAGE, ] doorbell.has_speaker = True doorbell.feature_flags.has_hdr = True diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 2c6a7c90065..42782d10429 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -5,15 +5,27 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import Mock -from pyunifiprotect.data import Camera, Event, EventType, Light, MountType, Sensor -from pyunifiprotect.data.nvr import EventMetadata +import pytest +from uiprotect.data import ( + Camera, + Event, + EventType, + Light, + ModelType, + MountType, + Sensor, + SmartDetectObjectType, +) +from uiprotect.data.nvr import EventMetadata from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.unifiprotect.binary_sensor import ( CAMERA_SENSORS, EVENT_SENSORS, LIGHT_SENSORS, + MOUNTABLE_SENSE_SENSORS, SENSE_SENSORS, + SMART_EVENT_SENSORS, ) from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_SCORE, @@ -22,12 +34,13 @@ from homeassistant.components.unifiprotect.const import ( from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event as HAEvent, EventStateChangedData, HomeAssistant from homeassistant.helpers import entity_registry as er from .utils import ( @@ -39,8 +52,10 @@ from .utils import ( remove_entities, ) +from tests.common import async_capture_events + LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] -SENSE_SENSORS_WRITE = SENSE_SENSORS[:4] +SENSE_SENSORS_WRITE = SENSE_SENSORS[:3] async def test_binary_sensor_camera_remove( @@ -50,11 +65,11 @@ async def test_binary_sensor_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 7) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 6) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 7) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 6) async def test_binary_sensor_light_remove( @@ -86,15 +101,16 @@ async def test_binary_sensor_sensor_remove( async def test_binary_sensor_setup_light( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + light: Light, ) -> None: """Test binary_sensor entity setup for light devices.""" await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) - entity_registry = er.async_get(hass) - for description in LIGHT_SENSOR_WRITE: unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, light, description @@ -112,6 +128,7 @@ async def test_binary_sensor_setup_light( async def test_binary_sensor_setup_camera_all( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera, @@ -120,9 +137,7 @@ async def test_binary_sensor_setup_camera_all( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 7) - - entity_registry = er.async_get(hass) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 6) description = EVENT_SENSORS[0] unique_id, entity_id = ids_from_device_description( @@ -170,7 +185,10 @@ async def test_binary_sensor_setup_camera_all( async def test_binary_sensor_setup_camera_none( - hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera: Camera, ) -> None: """Test binary_sensor entity setup for camera devices (no features).""" @@ -178,7 +196,6 @@ async def test_binary_sensor_setup_camera_none( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) - entity_registry = er.async_get(hass) description = CAMERA_SENSORS[0] unique_id, entity_id = ids_from_device_description( @@ -196,17 +213,17 @@ async def test_binary_sensor_setup_camera_none( async def test_binary_sensor_setup_sensor( - hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, ) -> None: """Test binary_sensor entity setup for sensor devices.""" await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - entity_registry = er.async_get(hass) - expected = [ - STATE_OFF, STATE_UNAVAILABLE, STATE_OFF, STATE_OFF, @@ -228,7 +245,10 @@ async def test_binary_sensor_setup_sensor( async def test_binary_sensor_setup_sensor_leak( - hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor: Sensor, ) -> None: """Test binary_sensor entity setup for sensor with most leak mounting type.""" @@ -236,10 +256,7 @@ async def test_binary_sensor_setup_sensor_leak( await init_entry(hass, ufp, [sensor]) assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) - entity_registry = er.async_get(hass) - expected = [ - STATE_UNAVAILABLE, STATE_OFF, STATE_OFF, STATE_UNAVAILABLE, @@ -270,13 +287,14 @@ async def test_binary_sensor_update_motion( """Test binary_sensor motion entity.""" await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.BINARY_SENSOR, 13, 13) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 12) _, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1] ) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=1), @@ -285,19 +303,21 @@ async def test_binary_sensor_update_motion( smart_detect_types=[], smart_detect_event_ids=[], camera_id=doorbell.id, + api=ufp.api, ) new_camera = doorbell.copy() new_camera.is_motion_detected = True new_camera.last_motion_event_id = event.id - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = new_camera - ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event ufp.ws_msg(mock_msg) + await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -321,6 +341,7 @@ async def test_binary_sensor_update_light_motion( event_metadata = EventMetadata(light_id=light.id) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION_LIGHT, start=fixed_now - timedelta(seconds=1), @@ -359,7 +380,7 @@ async def test_binary_sensor_update_mount_type_window( assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] + Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -391,7 +412,7 @@ async def test_binary_sensor_update_mount_type_garage( assert_entity_counts(hass, Platform.BINARY_SENSOR, 11, 11) _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] + Platform.BINARY_SENSOR, sensor_all, MOUNTABLE_SENSE_SENSORS[0] ) state = hass.states.get(entity_id) @@ -414,3 +435,144 @@ async def test_binary_sensor_update_mount_type_garage( assert ( state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.GARAGE_DOOR.value ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_package_detected( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test binary_sensor package detection entity.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 15) + + doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PACKAGE) + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, doorbell, SMART_EVENT_SENSORS[4] + ) + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[SmartDetectObjectType.PACKAGE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 100 + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=50, + smart_detect_types=[SmartDetectObjectType.PACKAGE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + # Event is already seen and has end, should now be off + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + # Now send an event that has an end right away + event = Event( + model=ModelType.EVENT, + id="new_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=80, + smart_detect_types=[SmartDetectObjectType.PACKAGE], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PACKAGE] = event.id + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + assert len(state_changes) == 2 + + on_event = state_changes[0] + state = on_event.data["new_state"] + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 80 + + off_event = state_changes[1] + state = off_event.data["new_state"] + assert state + assert state.state == STATE_OFF + assert ATTR_EVENT_SCORE not in state.attributes + + # replay and ensure ignored + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index fd4fa7b0386..3a283093179 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data.devices import Camera, Chime, Doorlock +from uiprotect.data.devices import Camera, Chime, Doorlock from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform @@ -36,6 +36,7 @@ async def test_button_chime_remove( async def test_reboot_button( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, chime: Chime, ) -> None: @@ -49,7 +50,6 @@ async def test_reboot_button( unique_id = f"{chime.mac}_reboot" entity_id = "button.test_chime_reboot_device" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert entity.disabled @@ -68,6 +68,7 @@ async def test_reboot_button( async def test_chime_button( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, chime: Chime, ) -> None: @@ -81,7 +82,6 @@ async def test_chime_button( unique_id = f"{chime.mac}_play" entity_id = "button.test_chime_play_chime" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled @@ -98,7 +98,11 @@ async def test_chime_button( async def test_adopt_button( - hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock, doorbell: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorlock: Doorlock, + doorbell: Camera, ) -> None: """Test button entity.""" @@ -122,7 +126,6 @@ async def test_adopt_button( unique_id = f"{doorlock.mac}_adopt" entity_id = "button.test_lock_adopt_device" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert not entity.disabled @@ -139,12 +142,15 @@ async def test_adopt_button( async def test_adopt_button_removed( - hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock, doorbell: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorlock: Doorlock, + doorbell: Camera, ) -> None: """Test button entity.""" entity_id = "button.test_lock_adopt_device" - entity_registry = er.async_get(hass) doorlock._api = ufp.api doorlock.is_adopted = False diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index d374f61c2b0..444898fbd85 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -4,8 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Camera as ProtectCamera, CameraChannel, StateType -from pyunifiprotect.exceptions import NvrError +from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType +from uiprotect.exceptions import NvrError from homeassistant.components.camera import ( CameraEntityFeature, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 845766809b2..5d02e1cf098 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -7,8 +7,8 @@ import socket from unittest.mock import patch import pytest -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient -from pyunifiprotect.data import NVR, Bootstrap, CloudAccount +from uiprotect import NotAuthorized, NvrError, ProtectApiClient +from uiprotect.data import NVR, Bootstrap, CloudAccount from homeassistant import config_entries from homeassistant.components import dhcp, ssdp diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index b13c069b37c..fd882929e96 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -1,6 +1,6 @@ """Test UniFi Protect diagnostics.""" -from pyunifiprotect.data import NVR, Light +from uiprotect.data import NVR, Light from homeassistant.components.unifiprotect.const import CONF_ALLOW_EA from homeassistant.core import HomeAssistant diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 69374fd19d4..3b75afaace8 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -4,8 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient -from pyunifiprotect.data import NVR, Bootstrap, CloudAccount, Light +from uiprotect import NotAuthorized, NvrError, ProtectApiClient +from uiprotect.data import NVR, Bootstrap, CloudAccount, Light from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, @@ -241,6 +241,8 @@ async def test_setup_starts_discovery( async def test_device_remove_devices( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, light: Light, hass_ws_client: WebSocketGenerator, @@ -252,10 +254,8 @@ async def test_device_remove_devices( entity_id = "light.test_light" entry_id = ufp.entry.entry_id - registry: er.EntityRegistry = er.async_get(hass) - entity = registry.async_get(entity_id) + entity = entity_registry.async_get(entity_id) assert entity is not None - device_registry = dr.async_get(hass) live_device_entry = device_registry.async_get(entity.device_id) client = await hass_ws_client(hass) @@ -272,6 +272,7 @@ async def test_device_remove_devices( async def test_device_remove_devices_nvr( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, ufp: MockUFPFixture, hass_ws_client: WebSocketGenerator, ) -> None: @@ -283,8 +284,6 @@ async def test_device_remove_devices_nvr( await hass.async_block_till_done() entry_id = ufp.entry.entry_id - device_registry = dr.async_get(hass) - live_device_entry = list(device_registry.devices.values())[0] client = await hass_ws_client(hass) response = await client.remove_device(live_device_entry.id, entry_id) diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index c2718561cb4..bb0b6992e4e 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -4,8 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Light -from pyunifiprotect.data.types import LEDLevel +from uiprotect.data import Light +from uiprotect.data.types import LEDLevel from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -42,7 +42,11 @@ async def test_light_remove( async def test_light_setup( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + light: Light, + unadopted_light: Light, ) -> None: """Test light entity setup.""" @@ -52,7 +56,6 @@ async def test_light_setup( unique_id = light.mac entity_id = "light.test_light" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert entity.unique_id == unique_id diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index fcca2072e83..62a1cb9ff46 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Doorlock, LockStatusType +from uiprotect.data import Doorlock, LockStatusType from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.const import ( @@ -45,6 +45,7 @@ async def test_lock_remove( async def test_lock_setup( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, doorlock: Doorlock, unadopted_doorlock: Doorlock, @@ -57,7 +58,6 @@ async def test_lock_setup( unique_id = f"{doorlock.mac}_lock" entity_id = "lock.test_lock_lock" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert entity.unique_id == unique_id diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index 5d58267e500..642a3a1e372 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -5,8 +5,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect.data import Camera -from pyunifiprotect.exceptions import StreamError +from uiprotect.data import Camera +from uiprotect.exceptions import StreamError from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_TYPE, @@ -49,6 +49,7 @@ async def test_media_player_camera_remove( async def test_media_player_setup( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera, @@ -61,7 +62,6 @@ async def test_media_player_setup( unique_id = f"{doorbell.mac}_speaker" entity_id = "media_player.test_camera_speaker" - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert entity.unique_id == unique_id diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index e767909d47e..60cd3150884 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -5,15 +5,16 @@ from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect.data import ( +from uiprotect.data import ( Bootstrap, Camera, Event, EventType, + ModelType, Permission, SmartDetectObjectType, ) -from pyunifiprotect.exceptions import NvrError +from uiprotect.exceptions import NvrError from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source import MediaSourceItem @@ -72,6 +73,7 @@ async def test_resolve_media_thumbnail( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -103,6 +105,7 @@ async def test_resolve_media_event( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -172,6 +175,7 @@ async def test_browse_media_event_ongoing( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -344,7 +348,11 @@ async def test_browse_media_root_single_console( async def test_browse_media_camera( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, camera: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, + camera: Camera, ) -> None: """Test browsing camera selector level media.""" @@ -360,7 +368,6 @@ async def test_browse_media_camera( ), ] - entity_registry = er.async_get(hass) entity_registry.async_update_entity( "camera.test_camera_high_resolution_channel", disabled_by=er.RegistryEntryDisabler("user"), @@ -588,6 +595,7 @@ async def test_browse_media_recent( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -625,6 +633,7 @@ async def test_browse_media_recent_truncated( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -657,6 +666,7 @@ async def test_browse_media_recent_truncated( [ ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.RING, start=datetime(1000, 1, 1, 0, 0, 0), @@ -670,6 +680,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=datetime(1000, 1, 1, 0, 0, 0), @@ -683,6 +694,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -705,6 +717,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -718,6 +731,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -731,6 +745,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -754,6 +769,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -783,6 +799,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -817,6 +834,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -849,6 +867,7 @@ async def test_browse_media_recent_truncated( ), ( Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_AUDIO_DETECT, start=datetime(1000, 1, 1, 0, 0, 0), @@ -903,6 +922,7 @@ async def test_browse_media_eventthumb( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=fixed_now - timedelta(seconds=20), @@ -966,6 +986,7 @@ async def test_browse_media_browse_day( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -1007,6 +1028,7 @@ async def test_browse_media_browse_whole_month( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -1049,6 +1071,7 @@ async def test_browse_media_browse_whole_month_december( await init_entry(hass, ufp, [doorbell], regenerate_ids=False) event1 = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=fixed_now - timedelta(seconds=3663), @@ -1060,6 +1083,7 @@ async def test_browse_media_browse_whole_month_december( ) event1._api = ufp.api event2 = Event( + model=ModelType.EVENT, id="test_event_id2", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -1071,6 +1095,7 @@ async def test_browse_media_browse_whole_month_december( ) event2._api = ufp.api event3 = Event( + model=ModelType.EVENT, id="test_event_id3", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), @@ -1082,6 +1107,7 @@ async def test_browse_media_browse_whole_month_december( ) event3._api = ufp.api event4 = Event( + model=ModelType.EVENT, id="test_event_id4", type=EventType.MOTION, start=fixed_now - timedelta(seconds=20), diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 7e736c39e6a..4e1bf8bd418 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from pyunifiprotect.data import Camera +from uiprotect.data import Camera from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.repairs.issue_handler import ( @@ -23,8 +23,11 @@ from tests.typing import WebSocketGenerator async def test_deprecated_entity( - hass: HomeAssistant, ufp: MockUFPFixture, hass_ws_client, doorbell: Camera -): + hass: HomeAssistant, + ufp: MockUFPFixture, + hass_ws_client: WebSocketGenerator, + doorbell: Camera, +) -> None: """Test Deprecate entity repair does not exist by default (new installs).""" await init_entry(hass, ufp, [doorbell]) @@ -44,12 +47,14 @@ async def test_deprecated_entity( async def test_deprecated_entity_no_automations( - hass: HomeAssistant, ufp: MockUFPFixture, hass_ws_client, doorbell: Camera -): + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + hass_ws_client: WebSocketGenerator, + doorbell: Camera, +) -> None: """Test Deprecate entity repair exists for existing installs.""" - - registry = er.async_get(hass) - registry.async_get_or_create( + entity_registry.async_get_or_create( Platform.SWITCH, DOMAIN, f"{doorbell.mac}_hdr_mode", @@ -107,14 +112,13 @@ async def _load_automation(hass: HomeAssistant, entity_id: str): async def test_deprecate_entity_automation( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, hass_ws_client: WebSocketGenerator, doorbell: Camera, ) -> None: """Test Deprecate entity repair exists for existing installs.""" - - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( Platform.SWITCH, DOMAIN, f"{doorbell.mac}_hdr_mode", @@ -176,14 +180,13 @@ async def _load_script(hass: HomeAssistant, entity_id: str): async def test_deprecate_entity_script( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, hass_ws_client: WebSocketGenerator, doorbell: Camera, ) -> None: """Test Deprecate entity repair exists for existing installs.""" - - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( Platform.SWITCH, DOMAIN, f"{doorbell.mac}_hdr_mode", diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 5eeb5308d62..77a409551b1 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -6,7 +6,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Doorlock, IRLEDMode, Light +from uiprotect.data import Camera, Doorlock, IRLEDMode, Light from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.number import ( @@ -69,14 +69,16 @@ async def test_number_lock_remove( async def test_number_setup_light( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + light: Light, ) -> None: """Test number entity setup for light devices.""" await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.NUMBER, 2, 2) - entity_registry = er.async_get(hass) for description in LIGHT_NUMBERS: unique_id, entity_id = ids_from_device_description( Platform.NUMBER, light, description @@ -93,7 +95,10 @@ async def test_number_setup_light( async def test_number_setup_camera_all( - hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera: Camera, ) -> None: """Test number entity setup for camera devices (all features).""" @@ -105,8 +110,6 @@ async def test_number_setup_camera_all( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.NUMBER, 5, 5) - entity_registry = er.async_get(hass) - for description in CAMERA_NUMBERS: unique_id, entity_id = ids_from_device_description( Platform.NUMBER, camera, description diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 3e1a8599ea7..fe102c2fdbc 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import Mock -from pyunifiprotect.data import Camera, Event, EventType +from uiprotect.data import Camera, Event, EventType, ModelType from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states @@ -40,6 +40,7 @@ async def test_exclude_attributes( ) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, start=fixed_now - timedelta(seconds=1), diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index f4be3164fd5..bdfcd6ff475 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -6,7 +6,7 @@ from copy import copy, deepcopy from http import HTTPStatus from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Camera, CloudAccount, ModelType, Version +from uiprotect.data import Camera, CloudAccount, ModelType, Version from homeassistant.components.repairs.issue_handler import ( async_process_repairs_platforms, @@ -61,7 +61,7 @@ async def test_ea_warning_ignore( flow_id = data["flow_id"] assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", "version": str(version), } assert data["step_id"] == "start" @@ -73,7 +73,7 @@ async def test_ea_warning_ignore( flow_id = data["flow_id"] assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", "version": str(version), } assert data["step_id"] == "confirm" @@ -123,7 +123,7 @@ async def test_ea_warning_fix( flow_id = data["flow_id"] assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", "version": str(version), } assert data["step_id"] == "start" @@ -357,3 +357,64 @@ async def test_rtsp_writable_fix( ufp.api.update_device.assert_called_with( ModelType.CAMERA, doorbell.id, {"channels": channels} ) + + +async def test_rtsp_writable_fix_when_not_setup( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test RTSP disabled warning if the integration is no longer set up.""" + + for channel in doorbell.channels: + channel.is_rtsp_enabled = False + + await init_entry(hass, ufp, [doorbell]) + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + client = await hass_client() + + new_doorbell = deepcopy(doorbell) + new_doorbell.channels[0].is_rtsp_enabled = True + ufp.api.get_camera = AsyncMock(side_effect=[doorbell, new_doorbell]) + ufp.api.update_device = AsyncMock() + issue_id = f"rtsp_disabled_{doorbell.id}" + + 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"] == issue_id: + issue = i + assert issue is not None + + # Unload the integration to ensure the fix flow still works + # if the integration is no longer set up + await hass.config_entries.async_unload(ufp.entry.entry_id) + await hass.async_block_till_done() + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "start" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + channels = doorbell.unifi_dict()["channels"] + channels[0]["isRtspEnabled"] = True + ufp.api.update_device.assert_called_with( + ModelType.CAMERA, doorbell.id, {"channels": channels} + ) diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 7c6e449be5e..8795af57214 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -5,7 +5,7 @@ from __future__ import annotations from copy import copy from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, DoorbellMessageType, IRLEDMode, @@ -17,7 +17,7 @@ from pyunifiprotect.data import ( RecordingMode, Viewer, ) -from pyunifiprotect.data.nvr import DoorbellMessage +from uiprotect.data.nvr import DoorbellMessage from homeassistant.components.select import ATTR_OPTIONS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -84,7 +84,10 @@ async def test_select_viewer_remove( async def test_select_setup_light( - hass: HomeAssistant, ufp: MockUFPFixture, light: Light + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + light: Light, ) -> None: """Test select entity setup for light devices.""" @@ -92,7 +95,6 @@ async def test_select_setup_light( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SELECT, 2, 2) - entity_registry = er.async_get(hass) expected_values = ("On Motion - When Dark", "Not Paired") for index, description in enumerate(LIGHT_SELECTS): @@ -111,7 +113,11 @@ async def test_select_setup_light( async def test_select_setup_viewer( - hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer, liveview: Liveview + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + viewer: Viewer, + liveview: Liveview, ) -> None: """Test select entity setup for light devices.""" @@ -119,7 +125,6 @@ async def test_select_setup_viewer( await init_entry(hass, ufp, [viewer]) assert_entity_counts(hass, Platform.SELECT, 1, 1) - entity_registry = er.async_get(hass) description = VIEWER_SELECTS[0] unique_id, entity_id = ids_from_device_description( @@ -137,14 +142,16 @@ async def test_select_setup_viewer( async def test_select_setup_camera_all( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, ) -> None: """Test select entity setup for camera devices (all features).""" await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SELECT, 5, 5) - entity_registry = er.async_get(hass) expected_values = ( "Always", "Auto", @@ -169,14 +176,16 @@ async def test_select_setup_camera_all( async def test_select_setup_camera_none( - hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + camera: Camera, ) -> None: """Test select entity setup for camera devices (no features).""" await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SELECT, 2, 2) - entity_registry = er.async_get(hass) expected_values = ("Always", "Auto", "Default Message (Welcome)") for index, description in enumerate(CAMERA_SELECTS): diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 1e5eca47b9b..bc5f372c598 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -5,22 +5,27 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import Mock -from pyunifiprotect.data import ( +import pytest +from uiprotect.data import ( NVR, Camera, Event, EventType, + ModelType, Sensor, SmartDetectObjectType, ) -from pyunifiprotect.data.nvr import EventMetadata, LicensePlateMetadata +from uiprotect.data.nvr import EventMetadata, LicensePlateMetadata -from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION +from homeassistant.components.unifiprotect.const import ( + ATTR_EVENT_SCORE, + DEFAULT_ATTRIBUTION, +) from homeassistant.components.unifiprotect.sensor import ( ALL_DEVICES_SENSORS, CAMERA_DISABLED_SENSORS, CAMERA_SENSORS, - EVENT_SENSORS, + LICENSE_PLATE_EVENT_SENSORS, MOTION_TRIP_SENSORS, NVR_DISABLED_SENSORS, NVR_SENSORS, @@ -28,11 +33,12 @@ from homeassistant.components.unifiprotect.sensor import ( ) from homeassistant.const import ( ATTR_ATTRIBUTION, + EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event as HAEvent, EventStateChangedData, HomeAssistant from homeassistant.helpers import entity_registry as er from .utils import ( @@ -47,6 +53,8 @@ from .utils import ( time_changed, ) +from tests.common import async_capture_events + CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] @@ -80,15 +88,16 @@ async def test_sensor_sensor_remove( async def test_sensor_setup_sensor( - hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, ) -> None: """Test sensor entity setup for sensor devices.""" await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) - entity_registry = er.async_get(hass) - expected_values = ( "10", "10.0", @@ -131,15 +140,16 @@ async def test_sensor_setup_sensor( async def test_sensor_setup_sensor_none( - hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor: Sensor, ) -> None: """Test sensor entity setup for sensor devices with no sensors enabled.""" await init_entry(hass, ufp, [sensor]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) - entity_registry = er.async_get(hass) - expected_values = ( "10", STATE_UNAVAILABLE, @@ -165,7 +175,10 @@ async def test_sensor_setup_sensor_none( async def test_sensor_setup_nvr( - hass: HomeAssistant, ufp: MockUFPFixture, fixed_now: datetime + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + fixed_now: datetime, ) -> None: """Test sensor entity setup for NVR device.""" @@ -190,8 +203,6 @@ async def test_sensor_setup_nvr( assert_entity_counts(hass, Platform.SENSOR, 12, 9) - entity_registry = er.async_get(hass) - expected_values = ( fixed_now.replace(second=0, microsecond=0).isoformat(), "50.0", @@ -241,7 +252,7 @@ async def test_sensor_setup_nvr( async def test_sensor_nvr_missing_values( - hass: HomeAssistant, ufp: MockUFPFixture + hass: HomeAssistant, entity_registry: er.EntityRegistry, ufp: MockUFPFixture ) -> None: """Test NVR sensor sensors if no data available.""" @@ -257,8 +268,6 @@ async def test_sensor_nvr_missing_values( assert_entity_counts(hass, Platform.SENSOR, 12, 9) - entity_registry = er.async_get(hass) - # Uptime description = NVR_SENSORS[0] unique_id, entity_id = ids_from_device_description( @@ -311,19 +320,21 @@ async def test_sensor_nvr_missing_values( async def test_sensor_setup_camera( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, + fixed_now: datetime, ) -> None: """Test sensor entity setup for camera devices.""" await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SENSOR, 24, 12) - entity_registry = er.async_get(hass) - expected_values = ( fixed_now.replace(microsecond=0).isoformat(), - "100", - "100.0", + "0.0001", + "0.0001", "20.0", ) for index, description in enumerate(CAMERA_SENSORS_WRITE): @@ -343,7 +354,7 @@ async def test_sensor_setup_camera( assert state.state == expected_values[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - expected_values = ("100", "100") + expected_values = ("0.0001", "0.0001") for index, description in enumerate(CAMERA_DISABLED_SENSORS): unique_id, entity_id = ids_from_device_description( Platform.SENSOR, doorbell, description @@ -396,9 +407,10 @@ async def test_sensor_setup_camera( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_setup_camera_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: None, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime, @@ -408,8 +420,6 @@ async def test_sensor_setup_camera_with_last_trip_time( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SENSOR, 24, 24) - entity_registry = er.async_get(hass) - # Last Trip Time unique_id, entity_id = ids_from_device_description( Platform.SENSOR, doorbell, MOTION_TRIP_SENSORS[0] @@ -442,6 +452,7 @@ async def test_sensor_update_alarm( event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SENSOR_ALARM, start=fixed_now - timedelta(seconds=1), @@ -472,9 +483,10 @@ async def test_sensor_update_alarm( await time_changed(hass, 10) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_update_alarm_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: None, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime, @@ -488,7 +500,6 @@ async def test_sensor_update_alarm_with_last_trip_time( unique_id, entity_id = ids_from_device_description( Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[-3] ) - entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity @@ -503,10 +514,10 @@ async def test_sensor_update_alarm_with_last_trip_time( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_camera_update_licenseplate( +async def test_camera_update_license_plate( hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera, fixed_now: datetime ) -> None: - """Test sensor motion entity.""" + """Test license plate sensor.""" camera.feature_flags.smart_detect_types.append(SmartDetectObjectType.LICENSE_PLATE) camera.feature_flags.has_smart_detect = True @@ -518,13 +529,14 @@ async def test_camera_update_licenseplate( assert_entity_counts(hass, Platform.SENSOR, 23, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, EVENT_SENSORS[0] + Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] ) event_metadata = EventMetadata( license_plate=LicensePlateMetadata(name="ABCD1234", confidence_level=95) ) event = Event( + model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, start=fixed_now - timedelta(seconds=1), @@ -548,9 +560,392 @@ async def test_camera_update_licenseplate( ufp.api.bootstrap.cameras = {new_camera.id: new_camera} ufp.api.bootstrap.events = {event.id: event} + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) assert state assert state.state == "ABCD1234" + + assert len(state_changes) == 1 + + # ensure reply is ignored + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 1 + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=100, + smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], + smart_detect_event_ids=[], + metadata=event_metadata, + api=ufp.api, + ) + + ufp.api.bootstrap.events = {event.id: event} + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( + event.id + ) + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + # Now send a new event with end already set + event = Event( + model=ModelType.EVENT, + id="new_event", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=100, + smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], + smart_detect_event_ids=[], + metadata=event_metadata, + api=ufp.api, + ) + + ufp.api.bootstrap.events = {event.id: event} + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( + event.id + ) + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 4 + assert state_changes[2].data["new_state"].state == "ABCD1234" + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + +async def test_camera_update_license_plate_changes_number_during_detect( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera, fixed_now: datetime +) -> None: + """Test license plate sensor that changes number during detect.""" + + camera.feature_flags.smart_detect_types.append(SmartDetectObjectType.LICENSE_PLATE) + camera.feature_flags.has_smart_detect = True + camera.smart_detect_settings.object_types.append( + SmartDetectObjectType.LICENSE_PLATE + ) + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.SENSOR, 23, 13) + + _, entity_id = ids_from_device_description( + Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + ) + + event_metadata = EventMetadata( + license_plate=LicensePlateMetadata(name="ABCD1234", confidence_level=95) + ) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], + smart_detect_event_ids=[], + metadata=event_metadata, + api=ufp.api, + ) + + new_camera = camera.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( + event.id + ) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "ABCD1234" + + assert len(state_changes) == 1 + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 1 + + # Now mutate the original event so it ends + # Also change the metadata to a different license plate + # since the model may not get the plate correct on + # the first update. + event.score = 99 + event.end = fixed_now + timedelta(seconds=1) + event_metadata.license_plate.name = "DCBA4321" + ufp.api.bootstrap.events = {event.id: event} + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 3 + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + assert state_changes[0].data["new_state"].state == "ABCD1234" + assert state_changes[1].data["new_state"].state == "DCBA4321" + assert state_changes[2].data["new_state"].state == "none" + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + +async def test_camera_update_license_plate_multiple_updates( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera, fixed_now: datetime +) -> None: + """Test license plate sensor that updates multiple times.""" + + camera.feature_flags.smart_detect_types.append(SmartDetectObjectType.LICENSE_PLATE) + camera.feature_flags.has_smart_detect = True + camera.smart_detect_settings.object_types.append( + SmartDetectObjectType.LICENSE_PLATE + ) + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.SENSOR, 23, 13) + + _, entity_id = ids_from_device_description( + Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + ) + + event_metadata = EventMetadata( + license_plate=LicensePlateMetadata(name="ABCD1234", confidence_level=95) + ) + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=None, + score=100, + smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], + smart_detect_event_ids=[], + metadata=event_metadata, + api=ufp.api, + ) + + new_camera = camera.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( + event.id + ) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "ABCD1234" + assert state.attributes[ATTR_EVENT_SCORE] == 100 + + assert len(state_changes) == 1 + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 1 + + # Now mutate the original event so the score changes + event.score = 99 + event_metadata.license_plate.name = "DCBA4321" + ufp.api.bootstrap.events = {event.id: event} + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 + state = hass.states.get(entity_id) + assert state + assert state.state == "DCBA4321" + assert state.attributes[ATTR_EVENT_SCORE] == 99 + + # Now mutate the original event so the score changes again + event.score = 40 + ufp.api.bootstrap.events = {event.id: event} + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 3 + state = hass.states.get(entity_id) + assert state + assert state.state == "DCBA4321" + assert state.attributes[ATTR_EVENT_SCORE] == 40 + + # Now send the event again + ufp.api.bootstrap.events = {event.id: event} + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 3 + state = hass.states.get(entity_id) + assert state + assert state.state == "DCBA4321" + assert state.attributes[ATTR_EVENT_SCORE] == 40 + + # Now mutate the original event to add an end time + event.end = fixed_now + timedelta(seconds=1) + ufp.api.bootstrap.events = {event.id: event} + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 4 + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + # Now send the event again + event.end = fixed_now + timedelta(seconds=1) + ufp.api.bootstrap.events = {event.id: event} + + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 4 + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + +async def test_camera_update_license_no_dupes( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera, fixed_now: datetime +) -> None: + """Test license plate sensor does not generate duplicate reads.""" + + camera.feature_flags.smart_detect_types.append(SmartDetectObjectType.LICENSE_PLATE) + camera.feature_flags.has_smart_detect = True + camera.smart_detect_settings.object_types.append( + SmartDetectObjectType.LICENSE_PLATE + ) + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.SENSOR, 23, 13) + + _, entity_id = ids_from_device_description( + Platform.SENSOR, camera, LICENSE_PLATE_EVENT_SENSORS[0] + ) + + event_metadata = EventMetadata( + license_plate=LicensePlateMetadata(name="FPR2238", confidence_level=91) + ) + event = Event( + model=ModelType.EVENT, + id="6675e36400de8c03e40bd5e3", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=None, + score=83, + smart_detect_types=[SmartDetectObjectType.LICENSE_PLATE], + smart_detect_event_ids=[], + metadata=event_metadata, + api=ufp.api, + ) + + new_camera = camera.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.LICENSE_PLATE] = ( + event.id + ) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == "FPR2238" + assert state.attributes[ATTR_EVENT_SCORE] == 83 + + assert len(state_changes) == 1 + + # Now send it again + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 1 + + # Again send it again + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 1 + + # Now add the end time and change the confidence level + event.end = fixed_now + timedelta(seconds=1) + event.metadata.license_plate.confidence_level = 96 + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 + + state = hass.states.get(entity_id) + assert state + assert state.state == "none" + + # Now send it 3 more times + for _ in range(3): + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 + + # Now clear the event + ufp.api.bootstrap.events = {} + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 + + +async def test_sensor_precision( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime +) -> None: + """Test sensor precision value is respected.""" + + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 22, 14) + nvr: NVR = ufp.api.bootstrap.nvr + + _, entity_id = ids_from_device_description(Platform.SENSOR, nvr, NVR_SENSORS[6]) + + assert hass.states.get(entity_id).state == "17.49" diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 508a143c522..6808bacb40c 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -5,9 +5,9 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Chime, Color, Light, ModelType -from pyunifiprotect.data.devices import CameraZone -from pyunifiprotect.exceptions import BadRequest +from uiprotect.data import Camera, Chime, Color, Light, ModelType +from uiprotect.data.devices import CameraZone +from uiprotect.exceptions import BadRequest from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN from homeassistant.components.unifiprotect.services import ( @@ -15,8 +15,8 @@ from homeassistant.components.unifiprotect.services import ( SERVICE_REMOVE_DOORBELL_TEXT, SERVICE_REMOVE_PRIVACY_ZONE, SERVICE_SET_CHIME_PAIRED, - SERVICE_SET_DEFAULT_DOORBELL_TEXT, ) +from homeassistant.config_entries import ConfigEntryDisabler from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -26,24 +26,27 @@ from .utils import MockUFPFixture, init_entry @pytest.fixture(name="device") -async def device_fixture(hass: HomeAssistant, ufp: MockUFPFixture): +async def device_fixture( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, ufp: MockUFPFixture +): """Fixture with entry setup to call services with.""" await init_entry(hass, ufp, []) - device_registry = dr.async_get(hass) - return list(device_registry.devices.values())[0] @pytest.fixture(name="subdevice") -async def subdevice_fixture(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): +async def subdevice_fixture( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, + light: Light, +): """Fixture with entry setup to call services with.""" await init_entry(hass, ufp, [light]) - device_registry = dr.async_get(hass) - return [d for d in device_registry.devices.values() if d.name != "UnifiProtect"][0] @@ -121,26 +124,32 @@ async def test_remove_doorbell_text( nvr.remove_custom_doorbell_message.assert_called_once_with("Test Message") -async def test_set_default_doorbell_text( +async def test_add_doorbell_text_disabled_config_entry( hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture ) -> None: - """Test set_default_doorbell_text service.""" - + """Test add_doorbell_text service.""" nvr = ufp.api.bootstrap.nvr - nvr.__fields__["set_default_doorbell_message"] = Mock(final=False) - nvr.set_default_doorbell_message = AsyncMock() + nvr.__fields__["add_custom_doorbell_message"] = Mock(final=False) + nvr.add_custom_doorbell_message = AsyncMock() - await hass.services.async_call( - DOMAIN, - SERVICE_SET_DEFAULT_DOORBELL_TEXT, - {ATTR_DEVICE_ID: device.id, ATTR_MESSAGE: "Test Message"}, - blocking=True, + await hass.config_entries.async_set_disabled_by( + ufp.entry.entry_id, ConfigEntryDisabler.USER ) - nvr.set_default_doorbell_message.assert_called_once_with("Test Message") + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_DOORBELL_TEXT, + {ATTR_DEVICE_ID: device.id, ATTR_MESSAGE: "Test Message"}, + blocking=True, + ) + assert not nvr.add_custom_doorbell_message.called async def test_set_chime_paired_doorbells( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, chime: Chime, doorbell: Camera, @@ -157,9 +166,8 @@ async def test_set_chime_paired_doorbells( await init_entry(hass, ufp, [camera1, camera2, chime]) - registry = er.async_get(hass) - chime_entry = registry.async_get("button.test_chime_play_chime") - camera_entry = registry.async_get("binary_sensor.test_camera_2_doorbell") + chime_entry = entity_registry.async_get("button.test_chime_play_chime") + camera_entry = entity_registry.async_get("binary_sensor.test_camera_2_doorbell") assert chime_entry is not None assert camera_entry is not None @@ -183,6 +191,7 @@ async def test_set_chime_paired_doorbells( async def test_remove_privacy_zone_no_zone( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, doorbell: Camera, ) -> None: @@ -193,8 +202,7 @@ async def test_remove_privacy_zone_no_zone( await init_entry(hass, ufp, [doorbell]) - registry = er.async_get(hass) - camera_entry = registry.async_get("binary_sensor.test_camera_doorbell") + camera_entry = entity_registry.async_get("binary_sensor.test_camera_doorbell") with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -208,6 +216,7 @@ async def test_remove_privacy_zone_no_zone( async def test_remove_privacy_zone( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, doorbell: Camera, ) -> None: @@ -220,8 +229,7 @@ async def test_remove_privacy_zone( await init_entry(hass, ufp, [doorbell]) - registry = er.async_get(hass) - camera_entry = registry.async_get("binary_sensor.test_camera_doorbell") + camera_entry = entity_registry.async_get("binary_sensor.test_camera_doorbell") await hass.services.async_call( DOMAIN, @@ -230,4 +238,4 @@ async def test_remove_privacy_zone( blocking=True, ) ufp.api.update_device.assert_called() - assert not len(doorbell.privacy_zones) + assert not doorbell.privacy_zones diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 562eec8c5d0..6e5c83ef237 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode +from uiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.switch import ( @@ -35,19 +35,20 @@ CAMERA_SWITCHES_BASIC = [ for d in CAMERA_SWITCHES if ( not d.name.startswith("Detections:") - and d.name != "SSH Enabled" - and d.name != "Color Night Vision" - and d.name != "Tracking: Person" - and d.name != "HDR Mode" + and d.name != "SSH enabled" + and d.name != "Color night vision" + and d.name != "Tracking: person" + and d.name != "HDR mode" ) - or d.name == "Detections: Motion" - or d.name == "Detections: Person" - or d.name == "Detections: Vehicle" + or d.name == "Detections: motion" + or d.name == "Detections: person" + or d.name == "Detections: vehicle" + or d.name == "Detections: animal" ] CAMERA_SWITCHES_NO_EXTRA = [ d for d in CAMERA_SWITCHES_BASIC - if d.name not in ("High FPS", "Privacy Mode", "HDR Mode") + if d.name not in ("High FPS", "Privacy mode", "HDR mode") ] @@ -58,11 +59,11 @@ async def test_switch_camera_remove( ufp.api.bootstrap.nvr.system_info.ustorage = None await init_entry(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) await remove_entities(hass, ufp, [doorbell, unadopted_camera]) assert_entity_counts(hass, Platform.SWITCH, 2, 2) await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) async def test_switch_light_remove( @@ -123,6 +124,7 @@ async def test_switch_setup_no_perm( async def test_switch_setup_light( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, light: Light, ) -> None: @@ -131,8 +133,6 @@ async def test_switch_setup_light( await init_entry(hass, ufp, [light]) assert_entity_counts(hass, Platform.SWITCH, 4, 3) - entity_registry = er.async_get(hass) - description = LIGHT_SWITCHES[1] unique_id, entity_id = ids_from_device_description( @@ -168,15 +168,14 @@ async def test_switch_setup_light( async def test_switch_setup_camera_all( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, doorbell: Camera, ) -> None: """Test switch entity setup for camera devices (all enabled feature flags).""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) - - entity_registry = er.async_get(hass) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) for description in CAMERA_SWITCHES_BASIC: unique_id, entity_id = ids_from_device_description( @@ -215,6 +214,7 @@ async def test_switch_setup_camera_all( async def test_switch_setup_camera_none( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, camera: Camera, ) -> None: @@ -223,8 +223,6 @@ async def test_switch_setup_camera_none( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.SWITCH, 8, 7) - entity_registry = er.async_get(hass) - for description in CAMERA_SWITCHES_BASIC: if description.ufp_required_field is not None: continue @@ -297,7 +295,7 @@ async def test_switch_camera_ssh( """Tests SSH switch for cameras.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = CAMERA_SWITCHES[0] @@ -330,7 +328,7 @@ async def test_switch_camera_simple( """Tests all simple switches for cameras.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) assert description.ufp_set_method is not None @@ -359,7 +357,7 @@ async def test_switch_camera_highfps( """Tests High FPS switch for cameras.""" await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = CAMERA_SWITCHES[3] @@ -390,7 +388,7 @@ async def test_switch_camera_privacy( previous_record = doorbell.recording_settings.mode = RecordingMode.DETECTIONS await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = PRIVACY_MODE_SWITCH @@ -442,7 +440,7 @@ async def test_switch_camera_privacy_already_on( doorbell.add_privacy_zone() await init_entry(hass, ufp, [doorbell]) - assert_entity_counts(hass, Platform.SWITCH, 15, 13) + assert_entity_counts(hass, Platform.SWITCH, 17, 15) description = PRIVACY_MODE_SWITCH diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index 28575423ab7..3ca11744abb 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Camera, DoorbellMessageType, LCDMessage +from uiprotect.data import Camera, DoorbellMessageType, LCDMessage from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.text import CAMERA @@ -37,7 +37,10 @@ async def test_text_camera_remove( async def test_text_camera_setup( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + doorbell: Camera, ) -> None: """Test text entity setup for camera devices.""" @@ -47,8 +50,6 @@ async def test_text_camera_setup( await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.TEXT, 1, 1) - entity_registry = er.async_get(hass) - description = CAMERA[0] unique_id, entity_id = ids_from_device_description( Platform.TEXT, doorbell, description diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index f7930e5ff9a..fed0a98552d 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, Mock from aiohttp import ClientResponse import pytest -from pyunifiprotect.data import Camera, Event, EventType -from pyunifiprotect.exceptions import ClientError +from uiprotect.data import Camera, Event, EventType, ModelType +from uiprotect.exceptions import ClientError from homeassistant.components.unifiprotect.views import ( async_generate_event_video_url, @@ -149,6 +149,25 @@ async def test_thumbnail_entry_id( ufp.api.get_event_thumbnail.assert_called_with("test_id", width=None, height=None) +async def test_thumbnail_invalid_entry_entry_id( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ufp: MockUFPFixture, + camera: Camera, +) -> None: + """Test invalid config entry ID in URL.""" + + ufp.api.get_event_thumbnail = AsyncMock(return_value=b"testtest") + + await init_entry(hass, ufp, [camera]) + url = async_generate_thumbnail_url("test_id", "invalid") + + http_client = await hass_client() + response = cast(ClientResponse, await http_client.get(url)) + + assert response.status == 404 + + async def test_video_bad_event( hass: HomeAssistant, ufp: MockUFPFixture, @@ -160,6 +179,7 @@ async def test_video_bad_event( await init_entry(hass, ufp, [camera]) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id="test_id", start=fixed_now - timedelta(seconds=30), @@ -186,6 +206,7 @@ async def test_video_bad_event_ongoing( await init_entry(hass, ufp, [camera]) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=fixed_now - timedelta(seconds=30), @@ -213,6 +234,7 @@ async def test_video_bad_perms( await init_entry(hass, ufp, [camera]) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=fixed_now - timedelta(seconds=30), @@ -241,6 +263,7 @@ async def test_video_bad_nvr_id( await init_entry(hass, ufp, [camera]) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=fixed_now - timedelta(seconds=30), @@ -275,6 +298,7 @@ async def test_video_bad_camera_id( await init_entry(hass, ufp, [camera]) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=fixed_now - timedelta(seconds=30), @@ -309,6 +333,7 @@ async def test_video_bad_camera_perms( await init_entry(hass, ufp, [camera]) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=fixed_now - timedelta(seconds=30), @@ -349,6 +374,7 @@ async def test_video_bad_params( event_start = fixed_now - timedelta(seconds=30) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=event_start, @@ -386,6 +412,7 @@ async def test_video_bad_video( event_start = fixed_now - timedelta(seconds=30) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=event_start, @@ -428,6 +455,7 @@ async def test_video( event_start = fixed_now - timedelta(seconds=30) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=event_start, @@ -471,6 +499,7 @@ async def test_video_entity_id( event_start = fixed_now - timedelta(seconds=30) event = Event( + model=ModelType.EVENT, api=ufp.api, camera_id=camera.id, start=event_start, diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 1ade39dafca..ab3aefaa09d 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -8,8 +8,8 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect import ProtectApiClient +from uiprotect.data import ( Bootstrap, Camera, Event, @@ -18,8 +18,8 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, WSSubscriptionMessage, ) -from pyunifiprotect.data.bootstrap import ProtectDeviceRef -from pyunifiprotect.test_util.anonymize import random_hex +from uiprotect.data.bootstrap import ProtectDeviceRef +from uiprotect.test_util.anonymize import random_hex from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 008f7aa5162..814fa34a125 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -70,6 +70,7 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): self._media_image_url = None self._shuffle = False self._sound_mode = None + self._repeat = None self.service_calls = { "turn_on": async_mock_service( @@ -1270,7 +1271,10 @@ async def test_master_state_with_template(hass: HomeAssistant) -> None: events = [] async_track_state_change_event( - hass, "media_player.tv", callback(lambda event: events.append(event)) + hass, + "media_player.tv", + # pylint: disable-next=unnecessary-lambda + callback(lambda event: events.append(event)), ) context = Context() diff --git a/tests/components/upb/test_config_flow.py b/tests/components/upb/test_config_flow.py index 5eaed2e3a24..d5d6d70bb68 100644 --- a/tests/components/upb/test_config_flow.py +++ b/tests/components/upb/test_config_flow.py @@ -1,5 +1,6 @@ """Test the UPB Control config flow.""" +from asyncio import TimeoutError from unittest.mock import MagicMock, PropertyMock, patch from homeassistant import config_entries @@ -84,7 +85,6 @@ async def test_form_user_with_tcp_upb(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" - from asyncio import TimeoutError with patch( "homeassistant.components.upb.config_flow.asyncio.timeout", diff --git a/tests/components/upcloud/test_config_flow.py b/tests/components/upcloud/test_config_flow.py index 4ce87bf38ab..51ee8875ec3 100644 --- a/tests/components/upcloud/test_config_flow.py +++ b/tests/components/upcloud/test_config_flow.py @@ -110,7 +110,9 @@ async def test_options(hass: HomeAssistant) -> None: ) -async def test_already_configured(hass: HomeAssistant, requests_mock) -> None: +async def test_already_configured( + hass: HomeAssistant, requests_mock: requests_mock.Mocker +) -> None: """Test duplicate entry aborts and updates data.""" config_entry = MockConfigEntry( diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index 6ece4f818d1..fa9af863f56 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -14,6 +14,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .common import MockUpdateEntity + from tests.common import ( MockConfigEntry, async_fire_time_changed, @@ -22,7 +24,6 @@ from tests.common import ( async_mock_service, setup_test_component_platform, ) -from tests.components.update.common import MockUpdateEntity @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -60,7 +61,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -108,7 +109,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["changed_states", "turned_off", "turned_on"] + for trigger in ("changed_states", "turned_off", "turned_on") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 02ca605eed4..b37abc2263a 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -1,9 +1,9 @@ """The tests for the Update component.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.update import ( ATTR_BACKUP, @@ -586,7 +586,10 @@ async def test_entity_without_progress_support( events = [] async_track_state_change_event( - hass, "update.update_available", callback(lambda event: events.append(event)) + hass, + "update.update_available", + # pylint: disable-next=unnecessary-lambda + callback(lambda event: events.append(event)), ) await hass.services.async_call( @@ -624,7 +627,10 @@ async def test_entity_without_progress_support_raising( events = [] async_track_state_change_event( - hass, "update.update_available", callback(lambda event: events.append(event)) + hass, + "update.update_available", + # pylint: disable-next=unnecessary-lambda + callback(lambda event: events.append(event)), ) with ( @@ -767,7 +773,7 @@ class MockFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -782,7 +788,7 @@ async def test_name(hass: HomeAssistant) -> None: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -890,7 +896,7 @@ async def test_deprecated_supported_features_ints_with_service_call( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) return True mock_platform(hass, f"{TEST_DOMAIN}.config_flow") diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index da63518009e..0bd209ce1c2 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -17,9 +17,10 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .common import MockUpdateEntity + from tests.common import async_fire_time_changed, setup_test_component_platform from tests.components.recorder.common import async_wait_recording_done -from tests.components.update.common import MockUpdateEntity async def test_exclude_attributes( diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 0959e8e31da..00e8db124f0 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -228,7 +228,6 @@ async def ssdp_no_discovery(): @pytest.fixture async def mock_config_entry( hass: HomeAssistant, - mock_get_source_ip, ssdp_instant_discovery, mock_igd_device: IgdDevice, mock_mac_address_from_host, diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index a3d2b97f3ed..b8a08d3f592 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -38,7 +38,6 @@ from tests.common import MockConfigEntry @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", - "mock_get_source_ip", "mock_mac_address_from_host", ) async def test_flow_ssdp(hass: HomeAssistant) -> None: @@ -72,7 +71,6 @@ async def test_flow_ssdp(hass: HomeAssistant) -> None: @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", - "mock_get_source_ip", "mock_mac_address_from_host", ) async def test_flow_ssdp_ignore(hass: HomeAssistant) -> None: @@ -104,7 +102,6 @@ async def test_flow_ssdp_ignore(hass: HomeAssistant) -> None: } -@pytest.mark.usefixtures("mock_get_source_ip") async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant) -> None: """Test config flow: incomplete discovery through ssdp.""" # Discovered via step ssdp. @@ -126,7 +123,6 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant) -> None: assert result["reason"] == "incomplete_discovery" -@pytest.mark.usefixtures("mock_get_source_ip") async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: """Test config flow: incomplete discovery through ssdp.""" # Discovered via step ssdp. @@ -151,7 +147,6 @@ async def test_flow_ssdp_non_igd_device(hass: HomeAssistant) -> None: @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", - "mock_get_source_ip", "mock_no_mac_address_from_host", ) async def test_flow_ssdp_no_mac_address(hass: HomeAssistant) -> None: @@ -196,7 +191,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_mac(hass: HomeAssistant) -> CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -228,7 +223,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_host(hass: HomeAssistant) - CONFIG_ENTRY_HOST: TEST_HOST, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -249,7 +244,6 @@ async def test_flow_ssdp_discovery_changed_udn_match_host(hass: HomeAssistant) - @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", - "mock_get_source_ip", ) async def test_flow_ssdp_discovery_changed_udn_but_st_differs( hass: HomeAssistant, @@ -266,7 +260,7 @@ async def test_flow_ssdp_discovery_changed_udn_but_st_differs( CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -320,7 +314,7 @@ async def test_flow_ssdp_discovery_changed_location(hass: HomeAssistant) -> None CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, }, source=config_entries.SOURCE_SSDP, - state=config_entries.ConfigEntryState.LOADED, + state=config_entries.ConfigEntryState.NOT_LOADED, ) entry.add_to_hass(hass) @@ -403,7 +397,6 @@ async def test_flow_ssdp_discovery_changed_udn_ignored_entry( @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", - "mock_get_source_ip", "mock_mac_address_from_host", ) async def test_flow_user(hass: HomeAssistant) -> None: @@ -435,7 +428,6 @@ async def test_flow_user(hass: HomeAssistant) -> None: @pytest.mark.usefixtures( "ssdp_no_discovery", "mock_setup_entry", - "mock_get_source_ip", "mock_mac_address_from_host", ) async def test_flow_user_no_discovery(hass: HomeAssistant) -> None: @@ -450,7 +442,6 @@ async def test_flow_user_no_discovery(hass: HomeAssistant) -> None: @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", - "mock_get_source_ip", "mock_mac_address_from_host", ) async def test_flow_ssdp_with_mismatched_udn(hass: HomeAssistant) -> None: diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py index eab279b479e..4b5e375f8e0 100644 --- a/tests/components/upnp/test_init.py +++ b/tests/components/upnp/test_init.py @@ -30,9 +30,7 @@ from .conftest import ( from tests.common import MockConfigEntry -@pytest.mark.usefixtures( - "ssdp_instant_discovery", "mock_get_source_ip", "mock_mac_address_from_host" -) +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_mac_address_from_host") async def test_async_setup_entry_default(hass: HomeAssistant) -> None: """Test async_setup_entry.""" entry = MockConfigEntry( @@ -52,9 +50,7 @@ async def test_async_setup_entry_default(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) is True -@pytest.mark.usefixtures( - "ssdp_instant_discovery", "mock_get_source_ip", "mock_no_mac_address_from_host" -) +@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_no_mac_address_from_host") async def test_async_setup_entry_default_no_mac_address(hass: HomeAssistant) -> None: """Test async_setup_entry.""" entry = MockConfigEntry( @@ -76,7 +72,6 @@ async def test_async_setup_entry_default_no_mac_address(hass: HomeAssistant) -> @pytest.mark.usefixtures( "ssdp_instant_discovery_multi_location", - "mock_get_source_ip", "mock_mac_address_from_host", ) async def test_async_setup_entry_multi_location( @@ -106,7 +101,7 @@ async def test_async_setup_entry_multi_location( mock_async_create_device.assert_called_once_with(TEST_LOCATION) -@pytest.mark.usefixtures("mock_get_source_ip", "mock_mac_address_from_host") +@pytest.mark.usefixtures("mock_mac_address_from_host") async def test_async_setup_udn_mismatch( hass: HomeAssistant, mock_async_create_device: AsyncMock ) -> None: diff --git a/tests/components/uptime/conftest.py b/tests/components/uptime/conftest.py index a681fb40173..2fe96b91b63 100644 --- a/tests/components/uptime/conftest.py +++ b/tests/components/uptime/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import patch import pytest +from typing_extensions import Generator from homeassistant.components.uptime.const import DOMAIN from homeassistant.core import HomeAssistant @@ -23,7 +23,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.uptime.async_setup_entry", return_value=True): yield diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py index c2d154cd967..01f003327c1 100644 --- a/tests/components/uptimerobot/common.py +++ b/tests/components/uptimerobot/common.py @@ -81,10 +81,10 @@ class MockApiResponseKey(str, Enum): def mock_uptimerobot_api_response( data: dict[str, Any] - | None | list[UptimeRobotMonitor] | UptimeRobotAccount - | UptimeRobotApiError = None, + | UptimeRobotApiError + | None = None, status: APIStatus = APIStatus.OK, key: MockApiResponseKey = MockApiResponseKey.MONITORS, ) -> UptimeRobotApiResponse: diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index c0583eddb7d..187178de78d 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -197,13 +197,13 @@ async def test_update_errors( async def test_device_management( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test that we are adding and removing devices for monitors returned from the API.""" mock_entry = await setup_uptimerobot_integration(hass) - dev_reg = dr.async_get(hass) - devices = dr.async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + devices = dr.async_entries_for_config_entry(device_registry, mock_entry.entry_id) assert len(devices) == 1 assert devices[0].identifiers == {(DOMAIN, "1234")} @@ -222,7 +222,7 @@ async def test_device_management( async_fire_time_changed(hass) await hass.async_block_till_done() - devices = dr.async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + devices = dr.async_entries_for_config_entry(device_registry, mock_entry.entry_id) assert len(devices) == 2 assert devices[0].identifiers == {(DOMAIN, "1234")} assert devices[1].identifiers == {(DOMAIN, "12345")} @@ -241,7 +241,7 @@ async def test_device_management( await hass.async_block_till_done() await hass.async_block_till_done() - devices = dr.async_entries_for_config_entry(dev_reg, mock_entry.entry_id) + devices = dr.async_entries_for_config_entry(device_registry, mock_entry.entry_id) assert len(devices) == 1 assert devices[0].identifiers == {(DOMAIN, "1234")} diff --git a/tests/components/usb/test_init.py b/tests/components/usb/test_init.py index c3f7817527c..bbd802afc95 100644 --- a/tests/components/usb/test_init.py +++ b/tests/components/usb/test_init.py @@ -108,6 +108,7 @@ async def test_observer_discovery( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() + # pylint:disable-next=unnecessary-dunder-call assert mock_observer.mock_calls == [call.start(), call.__bool__(), call.stop()] @@ -771,16 +772,17 @@ def test_get_serial_by_id_no_dir() -> None: def test_get_serial_by_id() -> None: """Test serial by id conversion.""" - p1 = patch("os.path.isdir", MagicMock(return_value=True)) - p2 = patch("os.scandir") def _realpath(path): if path is sentinel.matched_link: return sentinel.path return sentinel.serial_link_path - p3 = patch("os.path.realpath", side_effect=_realpath) - with p1 as is_dir_mock, p2 as scan_mock, p3: + with ( + patch("os.path.isdir", MagicMock(return_value=True)) as is_dir_mock, + patch("os.scandir") as scan_mock, + patch("os.path.realpath", side_effect=_realpath), + ): res = usb.get_serial_by_id(sentinel.path) assert res is sentinel.path assert is_dir_mock.call_count == 1 @@ -1052,3 +1054,109 @@ async def test_resolve_serial_by_id( assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "test1" assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/serial/by-id/bla" + + +@pytest.mark.parametrize( + "ports", + [ + [ + MagicMock( + device="/dev/cu.usbserial-2120", + vid=0x3039, + pid=0x3039, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ), + MagicMock( + device="/dev/cu.usbserial-1120", + vid=0x3039, + pid=0x3039, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ), + MagicMock( + device="/dev/cu.SLAB_USBtoUART", + vid=0x3039, + pid=0x3039, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ), + MagicMock( + device="/dev/cu.SLAB_USBtoUART2", + vid=0x3039, + pid=0x3039, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ), + ], + [ + MagicMock( + device="/dev/cu.SLAB_USBtoUART2", + vid=0x3039, + pid=0x3039, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ), + MagicMock( + device="/dev/cu.SLAB_USBtoUART", + vid=0x3039, + pid=0x3039, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ), + MagicMock( + device="/dev/cu.usbserial-1120", + vid=0x3039, + pid=0x3039, + serial_number=slae_sh_device.serial_number, + manufacturer=slae_sh_device.manufacturer, + description=slae_sh_device.description, + ), + MagicMock( + device="/dev/cu.usbserial-2120", + vid=0x3039, + pid=0x3039, + serial_number=conbee_device.serial_number, + manufacturer=conbee_device.manufacturer, + description=conbee_device.description, + ), + ], + ], +) +async def test_cp2102n_ordering_on_macos( + ports: list[MagicMock], hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test CP2102N ordering on macOS.""" + + new_usb = [ + {"domain": "test1", "vid": "3039", "pid": "3039", "description": "*2652*"} + ] + + with ( + patch("sys.platform", "darwin"), + patch("pyudev.Context", side_effect=ImportError), + patch("homeassistant.components.usb.async_get_usb", return_value=new_usb), + patch("homeassistant.components.usb.comports", return_value=ports), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + ): + assert await async_setup_component(hass, "usb", {"usb": {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "usb/scan"}) + response = await ws_client.receive_json() + assert response["success"] + await hass.async_block_till_done() + + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "test1" + + # We always use `cu.SLAB_USBtoUART` + assert mock_config_flow.mock_calls[0][2]["data"].device == "/dev/cu.SLAB_USBtoUART2" diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..28841854766 --- /dev/null +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'utility_meter', + 'minor_version': 1, + 'options': dict({ + 'cycle': 'monthly', + 'delta_values': False, + 'name': 'Energy Bill', + 'net_consumption': False, + 'offset': 0, + 'periodically_resetting': True, + 'source': 'sensor.input1', + 'tariffs': list([ + 'tariff0', + 'tariff1', + ]), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Energy Bill', + 'unique_id': None, + 'version': 2, + }), + 'tariff_sensors': list([ + dict({ + 'cron': '0 0 1 * *', + 'entity_id': 'sensor.energy_bill_tariff0', + 'extra_attributes': dict({ + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 'None', + 'next_reset': '2024-05-01T00:00:00-07:00', + 'status': 'collecting', + 'tariff': 'tariff0', + }), + 'last_sensor_data': None, + 'name': 'Energy Bill tariff0', + 'period': 'monthly', + 'source': 'sensor.input1', + }), + dict({ + 'cron': '0 0 1 * *', + 'entity_id': 'sensor.energy_bill_tariff1', + 'extra_attributes': dict({ + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 'None', + 'next_reset': '2024-05-01T00:00:00-07:00', + 'status': 'paused', + 'tariff': 'tariff1', + }), + 'last_sensor_data': None, + 'name': 'Energy Bill tariff1', + 'period': 'monthly', + 'source': 'sensor.input1', + }), + ]), + }) +# --- diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index b5553b1efe7..560566d7c49 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -261,7 +261,7 @@ def get_suggested(schema, key): return None return k.description["suggested_value"] # Wanted key absent from schema - raise Exception + raise KeyError("Wanted key absent from schema") async def test_options(hass: HomeAssistant) -> None: @@ -332,16 +332,14 @@ async def test_options(hass: HomeAssistant) -> None: # Check config entry is reloaded with new options await hass.async_block_till_done() - state = hass.states.get("sensor.electricity_meter") - assert state.attributes["source"] == input_sensor2_entity_id -async def test_change_device_source(hass: HomeAssistant) -> None: +async def test_change_device_source( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test remove the device registry configuration entry when the source entity changes.""" - - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - # Configure source entity 1 (with a linked device) source_config_entry_1 = MockConfigEntry() source_config_entry_1.add_to_hass(hass) diff --git a/tests/components/utility_meter/test_diagnostics.py b/tests/components/utility_meter/test_diagnostics.py new file mode 100644 index 00000000000..cefd17fc7e4 --- /dev/null +++ b/tests/components/utility_meter/test_diagnostics.py @@ -0,0 +1,128 @@ +"""Test Utility Meter diagnostics.""" + +from aiohttp.test_utils import TestClient +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.auth.models import Credentials +from homeassistant.components.utility_meter.const import DOMAIN +from homeassistant.components.utility_meter.sensor import ATTR_LAST_RESET +from homeassistant.core import HomeAssistant, State + +from tests.common import ( + CLIENT_ID, + MockConfigEntry, + MockUser, + mock_restore_cache_with_extra_data, +) +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def generate_new_hass_access_token( + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> str: + """Return an access token to access Home Assistant.""" + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + return hass.auth.async_create_access_token(refresh_token) + + +def _get_test_client_generator( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str +): + """Return a test client generator."".""" + + async def auth_client() -> TestClient: + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {new_token}"} + ) + + return auth_client + + +def limit_diagnostic_attrs(prop, path) -> bool: + """Mark attributes to exclude from diagnostic snapshot.""" + return prop in {"entry_id"} + + +@freeze_time("2024-04-06 00:00:00+00:00") +@pytest.mark.usefixtures("socket_enabled") +async def test_diagnostics( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Energy Bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.input1", + "tariffs": [ + "tariff0", + "tariff1", + ], + }, + title="Energy Bill", + ) + + last_reset = "2024-04-05T00:00:00+00:00" + + # Set up the sensors restore data + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.energy_bill_tariff0", + "3", + attributes={ + ATTR_LAST_RESET: last_reset, + }, + ), + {}, + ), + ( + State( + "sensor.energy_bill_tariff1", + "7", + attributes={ + ATTR_LAST_RESET: last_reset, + }, + ), + {}, + ), + ], + ) + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + + diag = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) + + assert diag == snapshot(exclude=limit_diagnostic_attrs) diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index a89cbe352a0..cd549c77913 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfEnergy, ) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -401,11 +401,13 @@ async def test_setup_missing_discovery(hass: HomeAssistant) -> None: ], ) async def test_setup_and_remove_config_entry( - hass: HomeAssistant, tariffs: str, expected_entities: list[str] + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + tariffs: str, + expected_entities: list[str], ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - registry = er.async_get(hass) # Setup the config entry config_entry = MockConfigEntry( @@ -428,10 +430,10 @@ async def test_setup_and_remove_config_entry( await hass.async_block_till_done() assert len(hass.states.async_all()) == len(expected_entities) - assert len(registry.entities) == len(expected_entities) + assert len(entity_registry.entities) == len(expected_entities) for entity in expected_entities: assert hass.states.get(entity) - assert entity in registry.entities + assert entity in entity_registry.entities # Remove the config entry assert await hass.config_entries.async_remove(config_entry.entry_id) @@ -439,4 +441,93 @@ async def test_setup_and_remove_config_entry( # Check the state and entity registry entry are removed assert len(hass.states.async_all()) == 0 - assert len(registry.entities) == 0 + assert len(entity_registry.entities) == 0 + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for source entity device for Utility Meter.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + # Configure the configuration entry for Utility Meter + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test_source", + "tariffs": [], + }, + title="Meter", + ) + utility_meter_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the meter sensor + utility_meter_entity = entity_registry.async_get("sensor.meter") + assert utility_meter_entity is not None + assert utility_meter_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to Utility Meter config entry + device_registry.async_get_or_create( + config_entry_id=utility_meter_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=utility_meter_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, two devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + utility_meter_config_entry.entry_id + ) + assert len(devices_before_reload) == 3 + + # Config entry reload + await hass.config_entries.async_reload(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the meter sensor after reload + utility_meter_entity = entity_registry.async_get("sensor.meter") + assert utility_meter_entity is not None + assert utility_meter_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + utility_meter_config_entry.entry_id + ) + assert len(devices_after_reload) == 1 diff --git a/tests/components/utility_meter/test_select.py b/tests/components/utility_meter/test_select.py new file mode 100644 index 00000000000..61f6cbe75b9 --- /dev/null +++ b/tests/components/utility_meter/test_select.py @@ -0,0 +1,56 @@ +"""The tests for the utility_meter select platform.""" + +from homeassistant.components.utility_meter.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for source entity device for Utility Meter.""" + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + source_device_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test")}, + connections={("mac", "30:31:32:33:34:35")}, + ) + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=source_config_entry, + device_id=source_device_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "Energy", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test_source", + "tariffs": ["peak", "offpeak"], + }, + title="Energy", + ) + + utility_meter_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + utility_meter_entity_select = entity_registry.async_get("select.energy") + assert utility_meter_entity_select is not None + assert utility_meter_entity_select.device_id == source_entity.device_id diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index cd0a8082578..745bf0ce012 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -55,9 +55,9 @@ from tests.common import ( @pytest.fixture(autouse=True) -def set_utc(hass: HomeAssistant): +async def set_utc(hass: HomeAssistant): """Set timezone to UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") @pytest.mark.parametrize( @@ -351,7 +351,7 @@ async def test_state_always_available( ], ) async def test_not_unique_tariffs(hass: HomeAssistant, yaml_config) -> None: - """Test utility sensor state initializtion.""" + """Test utility sensor state initialization.""" assert not await async_setup_component(hass, DOMAIN, yaml_config) @@ -385,7 +385,7 @@ async def test_not_unique_tariffs(hass: HomeAssistant, yaml_config) -> None: ], ) async def test_init(hass: HomeAssistant, yaml_config, config_entry_config) -> None: - """Test utility sensor state initializtion.""" + """Test utility sensor state initialization.""" if yaml_config: assert await async_setup_component(hass, DOMAIN, yaml_config) await hass.async_block_till_done() @@ -497,7 +497,7 @@ async def test_unique_id( ], ) async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> None: - """Test utility sensor state initializtion.""" + """Test utility sensor state initialization.""" assert await async_setup_component(hass, DOMAIN, yaml_config) await hass.async_block_till_done() @@ -1950,11 +1950,12 @@ async def test_unit_of_measurement_missing_invalid_new_state( ) -async def test_device_id(hass: HomeAssistant) -> None: +async def test_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for source entity device for Utility Meter.""" - device_registry = dr.async_get(hass) - entity_registry = er.async_get(hass) - source_config_entry = MockConfigEntry() source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py index 522448ecfc4..5ce8baf9919 100644 --- a/tests/components/uvc/test_camera.py +++ b/tests/components/uvc/test_camera.py @@ -18,7 +18,7 @@ from homeassistant.components.camera import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -111,7 +111,9 @@ def camera_v313_fixture(): yield camera -async def test_setup_full_config(hass: HomeAssistant, mock_remote, camera_info) -> None: +async def test_setup_full_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_remote, camera_info +) -> None: """Test the setup with full configuration.""" config = { "platform": "uvc", @@ -153,7 +155,6 @@ async def test_setup_full_config(hass: HomeAssistant, mock_remote, camera_info) assert state assert state.name == "Back" - entity_registry = async_get_entity_registry(hass) entity_entry = entity_registry.async_get("camera.front") assert entity_entry.unique_id == "id1" @@ -163,7 +164,9 @@ async def test_setup_full_config(hass: HomeAssistant, mock_remote, camera_info) assert entity_entry.unique_id == "id2" -async def test_setup_partial_config(hass: HomeAssistant, mock_remote) -> None: +async def test_setup_partial_config( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_remote +) -> None: """Test the setup with partial configuration.""" config = {"platform": "uvc", "nvr": "foo", "key": "secret"} @@ -187,7 +190,6 @@ async def test_setup_partial_config(hass: HomeAssistant, mock_remote) -> None: assert state assert state.name == "Back" - entity_registry = async_get_entity_registry(hass) entity_entry = entity_registry.async_get("camera.front") assert entity_entry.unique_id == "id1" @@ -197,7 +199,9 @@ async def test_setup_partial_config(hass: HomeAssistant, mock_remote) -> None: assert entity_entry.unique_id == "id2" -async def test_setup_partial_config_v31x(hass: HomeAssistant, mock_remote) -> None: +async def test_setup_partial_config_v31x( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_remote +) -> None: """Test the setup with a v3.1.x server.""" config = {"platform": "uvc", "nvr": "foo", "key": "secret"} mock_remote.return_value.server_version = (3, 1, 3) @@ -222,7 +226,6 @@ async def test_setup_partial_config_v31x(hass: HomeAssistant, mock_remote) -> No assert state assert state.name == "Back" - entity_registry = async_get_entity_registry(hass) entity_entry = entity_registry.async_get("camera.front") assert entity_entry.unique_id == "one" diff --git a/tests/components/v2c/__init__.py b/tests/components/v2c/__init__.py index fdb29e58644..02f8ade6179 100644 --- a/tests/components/v2c/__init__.py +++ b/tests/components/v2c/__init__.py @@ -1 +1,15 @@ """Tests for the V2C integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the V2C integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/v2c/conftest.py b/tests/components/v2c/conftest.py index 2bdfc405e2d..1803298be28 100644 --- a/tests/components/v2c/conftest.py +++ b/tests/components/v2c/conftest.py @@ -1,15 +1,58 @@ """Common fixtures for the V2C tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from pytrydan.models.trydan import TrydanData +from typing_extensions import Generator + +from homeassistant.components.v2c.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.helpers.json import json_dumps + +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.v2c.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Define a config entry fixture.""" + return MockConfigEntry( + domain=DOMAIN, + entry_id="da58ee91f38c2406c2a36d0a1a7f8569", + title="EVSE 1.1.1.1", + data={CONF_HOST: "1.1.1.1"}, + ) + + +@pytest.fixture +def mock_v2c_client() -> Generator[AsyncMock]: + """Mock a V2C client.""" + with ( + patch( + "homeassistant.components.v2c.Trydan", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.v2c.config_flow.Trydan", + new=mock_client, + ), + ): + client = mock_client.return_value + get_data_json = load_json_object_fixture("get_data.json", DOMAIN) + client.raw_data = { + "content": json_dumps(get_data_json).encode("utf-8"), + "status_code": 200, + } + client.get_data.return_value = TrydanData.from_api(get_data_json) + client.data = client.get_data.return_value + client.firmware_version = get_data_json["FirmwareVersion"] + yield client diff --git a/tests/components/v2c/fixtures/get_data.json b/tests/components/v2c/fixtures/get_data.json new file mode 100644 index 00000000000..7c250dee021 --- /dev/null +++ b/tests/components/v2c/fixtures/get_data.json @@ -0,0 +1,23 @@ +{ + "ID": "ABC123", + "ChargeState": 2, + "ReadyState": 0, + "ChargePower": 1500.27, + "ChargeEnergy": 1.8, + "SlaveError": 4, + "ChargeTime": 4355, + "HousePower": 0.0, + "FVPower": 0.0, + "BatteryPower": 0.0, + "Paused": 0, + "Locked": 0, + "Timer": 0, + "Intensity": 6, + "Dynamic": 0, + "MinIntensity": 6, + "MaxIntensity": 16, + "PauseDynamic": 0, + "FirmwareVersion": "2.1.7", + "DynamicPowerMode": 2, + "ContractedPower": 4600 +} diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a4f6cad4cc8 --- /dev/null +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'v2c', + 'entry_id': 'da58ee91f38c2406c2a36d0a1a7f8569', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': 'ABC123', + 'version': 1, + }), + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, charge_energy=1.8, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7')", + 'host_status': 200, + 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"ChargeEnergy":1.8,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', + }) +# --- diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..cc8077333cb --- /dev/null +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -0,0 +1,430 @@ +# serializer version: 1 +# name: test_sensor[sensor.evse_1_1_1_1_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVSE 1.1.1.1 Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_charge_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_charge_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge energy', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_energy', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_charge_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_charge_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'EVSE 1.1.1.1 Charge energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_charge_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.8', + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_charge_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_charge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Charge power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_power', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_charge_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_charge_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVSE 1.1.1.1 Charge power', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_charge_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500.27', + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_charge_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_charge_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge time', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_time', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_charge_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_charge_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'EVSE 1.1.1.1 Charge time', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_charge_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4355', + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_house_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_house_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'House power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'house_power', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_house_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_house_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVSE 1.1.1.1 House power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_house_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_meter_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'communication', + 'reading', + 'meter', + 'waiting_wifi', + 'waiting_communication', + 'wrong_ip', + 'meter_not_found', + 'wrong_meter', + 'no_response', + 'clamp_not_connected', + 'illegal_function', + 'illegal_data_address', + 'illegal_data_value', + 'server_device_failure', + 'acknowledge', + 'server_device_busy', + 'negative_acknowledge', + 'memory_parity_error', + 'gateway_path_unavailable', + 'gateway_target_no_resp', + 'server_rtu_inactive244_timeout', + 'invalid_server', + 'crc_error', + 'fc_mismatch', + 'server_id_mismatch', + 'packet_length_error', + 'parameter_count_error', + 'parameter_limit_error', + 'request_queue_full', + 'illegal_ip_or_port', + 'ip_connection_failed', + 'tcp_head_mismatch', + 'empty_message', + 'undefined_error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_meter_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter error', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_error', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_meter_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_meter_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'EVSE 1.1.1.1 Meter error', + 'options': list([ + 'no_error', + 'communication', + 'reading', + 'meter', + 'waiting_wifi', + 'waiting_communication', + 'wrong_ip', + 'meter_not_found', + 'wrong_meter', + 'no_response', + 'clamp_not_connected', + 'illegal_function', + 'illegal_data_address', + 'illegal_data_value', + 'server_device_failure', + 'acknowledge', + 'server_device_busy', + 'negative_acknowledge', + 'memory_parity_error', + 'gateway_path_unavailable', + 'gateway_target_no_resp', + 'server_rtu_inactive244_timeout', + 'invalid_server', + 'crc_error', + 'fc_mismatch', + 'server_id_mismatch', + 'packet_length_error', + 'parameter_count_error', + 'parameter_limit_error', + 'request_queue_full', + 'illegal_ip_or_port', + 'ip_connection_failed', + 'tcp_head_mismatch', + 'empty_message', + 'undefined_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_meter_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting_wifi', + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_photovoltaic_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_photovoltaic_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Photovoltaic power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fv_power', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_fv_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_photovoltaic_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVSE 1.1.1.1 Photovoltaic power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_photovoltaic_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/v2c/test_config_flow.py b/tests/components/v2c/test_config_flow.py index 04cf66d1d58..993fcaccc58 100644 --- a/tests/components/v2c/test_config_flow.py +++ b/tests/components/v2c/test_config_flow.py @@ -1,41 +1,36 @@ """Test the V2C config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest from pytrydan.exceptions import TrydanError -from homeassistant import config_entries from homeassistant.components.v2c.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test we get the form.""" +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_v2c_client: AsyncMock +) -> None: + """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "pytrydan.Trydan.get_data", - return_value={}, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - }, - ) - await hass.async_block_till_done() + result = 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"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "EVSE 1.1.1.1" - assert result2["data"] == { - "host": "1.1.1.1", - } + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "EVSE 1.1.1.1" + assert result["data"] == {CONF_HOST: "1.1.1.1"} assert len(mock_setup_entry.mock_calls) == 1 @@ -47,41 +42,32 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: ], ) async def test_form_cannot_connect( - hass: HomeAssistant, mock_setup_entry: AsyncMock, side_effect: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, + mock_v2c_client: AsyncMock, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} + ) + mock_v2c_client.get_data.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, ) - with patch( - "pytrydan.Trydan.get_data", - side_effect=side_effect, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - }, - ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + mock_v2c_client.get_data.side_effect = None - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": error} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + await hass.async_block_till_done() - with patch( - "pytrydan.Trydan.get_data", - return_value={}, - ): - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "host": "1.1.1.1", - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "EVSE 1.1.1.1" - assert result3["data"] == { - "host": "1.1.1.1", - } + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "EVSE 1.1.1.1" + assert result["data"] == {CONF_HOST: "1.1.1.1"} diff --git a/tests/components/v2c/test_diagnostics.py b/tests/components/v2c/test_diagnostics.py new file mode 100644 index 00000000000..770b00e988b --- /dev/null +++ b/tests/components/v2c/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test V2C diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + mock_v2c_client: AsyncMock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + + await init_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot() + ) diff --git a/tests/components/v2c/test_sensor.py b/tests/components/v2c/test_sensor.py new file mode 100644 index 00000000000..9e7e3800767 --- /dev/null +++ b/tests/components/v2c/test_sensor.py @@ -0,0 +1,67 @@ +"""Test the V2C sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.v2c.sensor import _METER_ERROR_OPTIONS +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_v2c_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the sensor.""" + with patch("homeassistant.components.v2c.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + assert [ + "no_error", + "communication", + "reading", + "meter", + "waiting_wifi", + "waiting_communication", + "wrong_ip", + "meter_not_found", + "wrong_meter", + "no_response", + "clamp_not_connected", + "illegal_function", + "illegal_data_address", + "illegal_data_value", + "server_device_failure", + "acknowledge", + "server_device_busy", + "negative_acknowledge", + "memory_parity_error", + "gateway_path_unavailable", + "gateway_target_no_resp", + "server_rtu_inactive244_timeout", + "invalid_server", + "crc_error", + "fc_mismatch", + "server_id_mismatch", + "packet_length_error", + "parameter_count_error", + "parameter_limit_error", + "request_queue_full", + "illegal_ip_or_port", + "ip_connection_failed", + "tcp_head_mismatch", + "empty_message", + "undefined_error", + ] == _METER_ERROR_OPTIONS diff --git a/tests/components/vacuum/__init__.py b/tests/components/vacuum/__init__.py index b62949e6e8a..0a681730cb2 100644 --- a/tests/components/vacuum/__init__.py +++ b/tests/components/vacuum/__init__.py @@ -1 +1,84 @@ """The tests for vacuum platforms.""" + +from typing import Any + +from homeassistant.components.vacuum import ( + DOMAIN, + STATE_CLEANING, + STATE_DOCKED, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockEntity + + +class MockVacuum(MockEntity, StateVacuumEntity): + """Mock vacuum class.""" + + _attr_supported_features = ( + VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.BATTERY + | VacuumEntityFeature.CLEAN_SPOT + | VacuumEntityFeature.MAP + | VacuumEntityFeature.STATE + | VacuumEntityFeature.START + ) + _attr_battery_level = 99 + _attr_fan_speed_list = ["slow", "fast"] + + def __init__(self, **values: Any) -> None: + """Initialize a mock vacuum entity.""" + super().__init__(**values) + self._attr_state = STATE_DOCKED + self._attr_fan_speed = "slow" + + def stop(self, **kwargs: Any) -> None: + """Stop cleaning.""" + self._attr_state = STATE_IDLE + + def return_to_base(self, **kwargs: Any) -> None: + """Return to base.""" + self._attr_state = STATE_RETURNING + + def clean_spot(self, **kwargs: Any) -> None: + """Clean a spot.""" + self._attr_state = STATE_CLEANING + + def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set the fan speed.""" + self._attr_fan_speed = fan_speed + + def start(self) -> None: + """Start cleaning.""" + self._attr_state = STATE_CLEANING + + def pause(self) -> None: + """Pause cleaning.""" + self._attr_state = STATE_PAUSED + + +async def help_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 help_async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Unload test config emntry.""" + return await hass.config_entries.async_unload_platforms( + config_entry, [Platform.VACUUM] + ) diff --git a/tests/components/vacuum/conftest.py b/tests/components/vacuum/conftest.py new file mode 100644 index 00000000000..5167c868f9f --- /dev/null +++ b/tests/components/vacuum/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for Vacuum platform tests.""" + +import pytest +from typing_extensions import Generator + +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]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index fec2ca1bf12..08459e05571 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -47,7 +47,7 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in ["clean", "dock"] + for action in ("clean", "dock") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -95,7 +95,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["clean", "dock"] + for action in ("clean", "dock") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 850c69c1757..5cc222a1833 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -12,7 +12,7 @@ from homeassistant.components.vacuum import ( STATE_RETURNING, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -59,7 +59,7 @@ async def test_get_conditions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for condition in ["is_cleaning", "is_docked"] + for condition in ("is_cleaning", "is_docked") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -107,7 +107,7 @@ async def test_get_conditions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for condition in ["is_cleaning", "is_docked"] + for condition in ("is_cleaning", "is_docked") ] conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id @@ -119,7 +119,7 @@ async def test_if_state( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -204,7 +204,7 @@ async def test_if_state_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off conditions.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index bae57b1941f..56e351a6446 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -9,7 +9,7 @@ from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.vacuum import DOMAIN, STATE_CLEANING, STATE_DOCKED from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component @@ -30,7 +30,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -59,7 +59,7 @@ async def test_get_triggers( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for trigger in ["cleaning", "docked"] + for trigger in ("cleaning", "docked") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -107,7 +107,7 @@ async def test_get_triggers_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for trigger in ["cleaning", "docked"] + for trigger in ("cleaning", "docked") ] triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -182,7 +182,7 @@ async def test_if_fires_on_state_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -267,7 +267,7 @@ async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for turn_on and turn_off triggers firing.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -324,7 +324,7 @@ async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - calls, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" config_entry = MockConfigEntry(domain="test", data={}) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 7a42913afbf..efd2a63f0f7 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -2,9 +2,210 @@ from __future__ import annotations -from homeassistant.components.vacuum import StateVacuumEntity, VacuumEntityFeature +from typing import Any + +import pytest + +from homeassistant.components.vacuum import ( + DOMAIN, + SERVICE_CLEAN_SPOT, + SERVICE_LOCATE, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_SEND_COMMAND, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, + STATE_CLEANING, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) from homeassistant.core import HomeAssistant +from . import MockVacuum, help_async_setup_entry_init, help_async_unload_entry + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_integration, + setup_test_component_platform, +) + + +@pytest.mark.parametrize( + ("service", "expected_state"), + [ + (SERVICE_CLEAN_SPOT, STATE_CLEANING), + (SERVICE_PAUSE, STATE_PAUSED), + (SERVICE_RETURN_TO_BASE, STATE_RETURNING), + (SERVICE_START, STATE_CLEANING), + (SERVICE_STOP, STATE_IDLE), + ], +) +async def test_state_services( + hass: HomeAssistant, config_flow_fixture: None, service: str, expected_state: str +) -> None: + """Test get vacuum service that affect state.""" + mock_vacuum = MockVacuum( + name="Testing", + entity_id="vacuum.testing", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + service, + {"entity_id": mock_vacuum.entity_id}, + blocking=True, + ) + vacuum_state = hass.states.get(mock_vacuum.entity_id) + + assert vacuum_state.state == expected_state + + +async def test_fan_speed(hass: HomeAssistant, config_flow_fixture: None) -> None: + """Test set vacuum fan speed.""" + mock_vacuum = MockVacuum( + name="Testing", + entity_id="vacuum.testing", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_SPEED, + {"entity_id": mock_vacuum.entity_id, "fan_speed": "high"}, + blocking=True, + ) + + assert mock_vacuum.fan_speed == "high" + + +async def test_locate(hass: HomeAssistant, config_flow_fixture: None) -> None: + """Test vacuum locate.""" + + calls = [] + + class MockVacuumWithLocation(MockVacuum): + def __init__(self, calls: list[str], **kwargs) -> None: + super().__init__() + self._attr_supported_features = ( + self.supported_features | VacuumEntityFeature.LOCATE + ) + self._calls = calls + + def locate(self, **kwargs: Any) -> None: + self._calls.append("locate") + + mock_vacuum = MockVacuumWithLocation( + name="Testing", entity_id="vacuum.testing", calls=calls + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + SERVICE_LOCATE, + {"entity_id": mock_vacuum.entity_id}, + blocking=True, + ) + + assert "locate" in calls + + +async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> None: + """Test Vacuum send command.""" + + strings = [] + + class MockVacuumWithSendCommand(MockVacuum): + def __init__(self, strings: list[str], **kwargs) -> None: + super().__init__() + self._attr_supported_features = ( + self.supported_features | VacuumEntityFeature.SEND_COMMAND + ) + self._strings = strings + + def send_command( + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: + if command == "add_str": + self._strings.append(params["str"]) + + mock_vacuum = MockVacuumWithSendCommand( + name="Testing", entity_id="vacuum.testing", strings=strings + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, DOMAIN, [mock_vacuum], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_COMMAND, + { + "entity_id": mock_vacuum.entity_id, + "command": "add_str", + "params": {"str": "test"}, + }, + blocking=True, + ) + + assert "test" in strings + async def test_supported_features_compat(hass: HomeAssistant) -> None: """Test StateVacuumEntity using deprecated feature constants features.""" diff --git a/tests/components/vallox/conftest.py b/tests/components/vallox/conftest.py index 08c020c1982..9f65734b926 100644 --- a/tests/components/vallox/conftest.py +++ b/tests/components/vallox/conftest.py @@ -5,21 +5,47 @@ from unittest.mock import AsyncMock, patch import pytest from vallox_websocket_api import MetricData +from homeassistant import config_entries from homeassistant.components.vallox.const import DOMAIN +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +DEFAULT_HOST = "192.168.100.50" +DEFAULT_NAME = "Vallox" + @pytest.fixture -def mock_entry(hass: HomeAssistant) -> MockConfigEntry: +def default_host() -> str: + """Return the default host used in the default mock entry.""" + return DEFAULT_HOST + + +@pytest.fixture +def default_name() -> str: + """Return the default name used in the default mock entry.""" + return DEFAULT_NAME + + +@pytest.fixture +def mock_entry( + hass: HomeAssistant, default_host: str, default_name: str +) -> MockConfigEntry: + """Create mocked Vallox config entry fixture.""" + return create_mock_entry(hass, default_host, default_name) + + +def create_mock_entry(hass: HomeAssistant, host: str, name: str) -> MockConfigEntry: """Create mocked Vallox config entry.""" vallox_mock_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_HOST: "192.168.100.50", - CONF_NAME: "Vallox", + CONF_HOST: host, + CONF_NAME: name, }, ) vallox_mock_entry.add_to_hass(hass) @@ -27,6 +53,49 @@ def mock_entry(hass: HomeAssistant) -> MockConfigEntry: return vallox_mock_entry +@pytest.fixture +async def setup_vallox_entry( + hass: HomeAssistant, default_host: str, default_name: str +) -> None: + """Define a fixture to set up Vallox.""" + await do_setup_vallox_entry(hass, default_host, default_name) + + +async def do_setup_vallox_entry(hass: HomeAssistant, host: str, name: str) -> None: + """Set up the Vallox component.""" + assert await async_setup_component( + hass, + DOMAIN, + { + CONF_HOST: host, + CONF_NAME: name, + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +async def init_reconfigure_flow( + hass: HomeAssistant, mock_entry, setup_vallox_entry +) -> tuple[MockConfigEntry, ConfigFlowResult]: + """Initialize a config entry and a reconfigure flow for it.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + # original entry + assert mock_entry.data["host"] == "192.168.100.50" + + return (mock_entry, result) + + @pytest.fixture def default_metrics(): """Return default Vallox metrics.""" diff --git a/tests/components/vallox/test_config_flow.py b/tests/components/vallox/test_config_flow.py index cfeb7152b17..b0c3412c579 100644 --- a/tests/components/vallox/test_config_flow.py +++ b/tests/components/vallox/test_config_flow.py @@ -6,11 +6,10 @@ from vallox_websocket_api import ValloxApiException, ValloxWebsocketException from homeassistant.components.vallox.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from .conftest import create_mock_entry, do_setup_vallox_entry async def test_form_no_input(hass: HomeAssistant) -> None: @@ -70,6 +69,26 @@ async def test_form_invalid_ip(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"host": "invalid_host"} + with ( + patch( + "homeassistant.components.vallox.config_flow.Vallox.fetch_metric_data", + return_value=None, + ), + patch( + "homeassistant.components.vallox.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "1.2.3.4"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Vallox" + assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} + async def test_form_vallox_api_exception_cannot_connect(hass: HomeAssistant) -> None: """Test that cannot connect error is handled.""" @@ -90,6 +109,26 @@ async def test_form_vallox_api_exception_cannot_connect(hass: HomeAssistant) -> assert result["type"] is FlowResultType.FORM assert result["errors"] == {"host": "cannot_connect"} + with ( + patch( + "homeassistant.components.vallox.config_flow.Vallox.fetch_metric_data", + return_value=None, + ), + patch( + "homeassistant.components.vallox.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "1.2.3.4"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Vallox" + assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} + async def test_form_os_error_cannot_connect(hass: HomeAssistant) -> None: """Test that cannot connect error is handled.""" @@ -110,6 +149,26 @@ async def test_form_os_error_cannot_connect(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"host": "cannot_connect"} + with ( + patch( + "homeassistant.components.vallox.config_flow.Vallox.fetch_metric_data", + return_value=None, + ), + patch( + "homeassistant.components.vallox.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "1.2.3.4"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Vallox" + assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} + async def test_form_unknown_exception(hass: HomeAssistant) -> None: """Test that unknown exceptions are handled.""" @@ -130,6 +189,26 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {"host": "unknown"} + with ( + patch( + "homeassistant.components.vallox.config_flow.Vallox.fetch_metric_data", + return_value=None, + ), + patch( + "homeassistant.components.vallox.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + init["flow_id"], + {"host": "1.2.3.4"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Vallox" + assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} + async def test_form_already_configured(hass: HomeAssistant) -> None: """Test that already configured error is handled.""" @@ -137,14 +216,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "20.40.10.30", - CONF_NAME: "Vallox 110 MV", - }, - ) - mock_entry.add_to_hass(hass) + create_mock_entry(hass, "20.40.10.30", "Vallox 110 MV") result = await hass.config_entries.flow.async_configure( init["flow_id"], @@ -154,3 +226,157 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reconfigure_host(hass: HomeAssistant, init_reconfigure_flow) -> None: + """Test that the host can be reconfigured.""" + entry, init_flow_result = init_reconfigure_flow + + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "192.168.100.60", + }, + ) + await hass.async_block_till_done() + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert entry.data["host"] == "192.168.100.60" + + +async def test_reconfigure_host_to_same_host_as_another_fails( + hass: HomeAssistant, init_reconfigure_flow +) -> None: + """Test that changing host to a host that already exists fails.""" + entry, init_flow_result = init_reconfigure_flow + + # Create second device + create_mock_entry(hass=hass, host="192.168.100.70", name="Vallox 2") + await do_setup_vallox_entry(hass=hass, host="192.168.100.70", name="Vallox 2") + + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "192.168.100.70", + }, + ) + await hass.async_block_till_done() + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "already_configured" + + # entry not changed + assert entry.data["host"] == "192.168.100.50" + + +async def test_reconfigure_host_to_invalid_ip_fails( + hass: HomeAssistant, init_reconfigure_flow +) -> None: + """Test that an invalid IP error is handled by the reconfigure step.""" + entry, init_flow_result = init_reconfigure_flow + + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "test.host.com", + }, + ) + await hass.async_block_till_done() + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["errors"] == {"host": "invalid_host"} + + # entry not changed + assert entry.data["host"] == "192.168.100.50" + + # makes sure we can recover and continue + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "192.168.100.60", + }, + ) + await hass.async_block_till_done() + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert entry.data["host"] == "192.168.100.60" + + +async def test_reconfigure_host_vallox_api_exception_cannot_connect( + hass: HomeAssistant, init_reconfigure_flow +) -> None: + """Test that cannot connect error is handled by the reconfigure step.""" + entry, init_flow_result = init_reconfigure_flow + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.fetch_metric_data", + side_effect=ValloxApiException, + ): + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "192.168.100.80", + }, + ) + await hass.async_block_till_done() + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["errors"] == {"host": "cannot_connect"} + + # entry not changed + assert entry.data["host"] == "192.168.100.50" + + # makes sure we can recover and continue + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "192.168.100.60", + }, + ) + await hass.async_block_till_done() + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert entry.data["host"] == "192.168.100.60" + + +async def test_reconfigure_host_unknown_exception( + hass: HomeAssistant, init_reconfigure_flow +) -> None: + """Test that cannot connect error is handled by the reconfigure step.""" + entry, init_flow_result = init_reconfigure_flow + + with patch( + "homeassistant.components.vallox.config_flow.Vallox.fetch_metric_data", + side_effect=Exception, + ): + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "192.168.100.90", + }, + ) + await hass.async_block_till_done() + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["errors"] == {"host": "unknown"} + + # entry not changed + assert entry.data["host"] == "192.168.100.50" + + # makes sure we can recover and continue + reconfigure_result = await hass.config_entries.flow.async_configure( + init_flow_result["flow_id"], + { + "host": "192.168.100.60", + }, + ) + await hass.async_block_till_done() + assert reconfigure_result["type"] is FlowResultType.ABORT + assert reconfigure_result["reason"] == "reconfigure_successful" + + # changed entry + assert entry.data["host"] == "192.168.100.60" diff --git a/tests/components/vallox/test_date.py b/tests/components/vallox/test_date.py index 1572e9b205c..bd4e1487bd5 100644 --- a/tests/components/vallox/test_date.py +++ b/tests/components/vallox/test_date.py @@ -4,7 +4,7 @@ from datetime import date from vallox_websocket_api import MetricData -from homeassistant.components.date.const import DOMAIN as DATE_DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.date import DOMAIN as DATE_DOMAIN, SERVICE_SET_VALUE from homeassistant.const import ATTR_DATE, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant diff --git a/tests/components/vallox/test_number.py b/tests/components/vallox/test_number.py index 2e440c5e304..1f8b05f21d8 100644 --- a/tests/components/vallox/test_number.py +++ b/tests/components/vallox/test_number.py @@ -2,7 +2,7 @@ import pytest -from homeassistant.components.number.const import ( +from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/vallox/test_sensor.py b/tests/components/vallox/test_sensor.py index d35c33a0305..d7af7bbb576 100644 --- a/tests/components/vallox/test_sensor.py +++ b/tests/components/vallox/test_sensor.py @@ -1,6 +1,7 @@ """Tests for Vallox sensor platform.""" from datetime import datetime, timedelta, tzinfo +from typing import Any import pytest from vallox_websocket_api import MetricData @@ -12,27 +13,27 @@ from tests.common import MockConfigEntry @pytest.fixture -def set_tz(request): +def set_tz(request: pytest.FixtureRequest) -> Any: """Set the default TZ to the one requested.""" request.getfixturevalue(request.param) @pytest.fixture -def utc(hass: HomeAssistant) -> None: +async def utc(hass: HomeAssistant) -> None: """Set the default TZ to UTC.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") @pytest.fixture -def helsinki(hass: HomeAssistant) -> None: +async def helsinki(hass: HomeAssistant) -> None: """Set the default TZ to Europe/Helsinki.""" - hass.config.set_time_zone("Europe/Helsinki") + await hass.config.async_set_time_zone("Europe/Helsinki") @pytest.fixture -def new_york(hass: HomeAssistant) -> None: +async def new_york(hass: HomeAssistant) -> None: """Set the default TZ to America/New_York.""" - hass.config.set_time_zone("America/New_York") + await hass.config.async_set_time_zone("America/New_York") def _sensor_to_datetime(sensor): diff --git a/tests/components/vallox/test_switch.py b/tests/components/vallox/test_switch.py index 294d4b00385..61290ea89ce 100644 --- a/tests/components/vallox/test_switch.py +++ b/tests/components/vallox/test_switch.py @@ -2,7 +2,7 @@ import pytest -from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py index eee215d2e29..3ef3b1ff4b0 100644 --- a/tests/components/valve/test_init.py +++ b/tests/components/valve/test_init.py @@ -1,9 +1,8 @@ """The tests for Valve.""" -from collections.abc import Generator - import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant.components.valve import ( DOMAIN, @@ -123,7 +122,7 @@ class MockBinaryValveEntity(ValveEntity): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -132,7 +131,7 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture -def mock_config_entry(hass) -> tuple[MockConfigEntry, list[ValveEntity]]: +def mock_config_entry(hass: HomeAssistant) -> tuple[MockConfigEntry, list[ValveEntity]]: """Mock a config entry which sets up a couple of valve entities.""" entities = [ MockBinaryValveEntity( @@ -152,8 +151,8 @@ def mock_config_entry(hass) -> tuple[MockConfigEntry, list[ValveEntity]]: hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup( - config_entry, Platform.VALVE + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.VALVE] ) return True diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index f393ebb819d..3d59ad615c6 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -1,9 +1,9 @@ """Fixtures for the Velbus tests.""" -from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.velbus.const import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -16,7 +16,7 @@ from tests.common import MockConfigEntry @pytest.fixture(name="controller") -def mock_controller() -> Generator[MagicMock, None, None]: +def mock_controller() -> Generator[MagicMock]: """Mock a successful velbus controller.""" with patch("homeassistant.components.velbus.Velbus", autospec=True) as controller: yield controller diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index 79d67415c4f..59effcae706 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -1,10 +1,10 @@ """Tests for the Velbus config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest import serial.tools.list_ports +from typing_extensions import Generator from velbusaio.exceptions import VelbusConnectionFailed from homeassistant.components import usb @@ -39,7 +39,7 @@ def com_port(): @pytest.fixture(name="controller") -def mock_controller() -> Generator[MagicMock, None, None]: +def mock_controller() -> Generator[MagicMock]: """Mock a successful velbus controller.""" with patch( "homeassistant.components.velbus.config_flow.velbusaio.controller.Velbus", @@ -49,7 +49,7 @@ def mock_controller() -> Generator[MagicMock, None, None]: @pytest.fixture(autouse=True) -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: +def override_async_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.velbus.async_setup_entry", return_value=True diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index a3ebaf51d7a..692216827b2 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -1,13 +1,13 @@ """Configuration for Velux tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.velux.async_setup_entry", return_value=True diff --git a/tests/components/venstar/test_climate.py b/tests/components/venstar/test_climate.py index c090fadb445..7107729d148 100644 --- a/tests/components/venstar/test_climate.py +++ b/tests/components/venstar/test_climate.py @@ -20,7 +20,7 @@ EXPECTED_BASE_SUPPORTED_FEATURES = ( async def test_colortouch(hass: HomeAssistant) -> None: """Test interfacing with a venstar colortouch with attached humidifier.""" - with patch("homeassistant.components.venstar.VENSTAR_SLEEP", new=0): + with patch("homeassistant.components.venstar.coordinator.VENSTAR_SLEEP", new=0): await async_init_integration(hass) state = hass.states.get("climate.colortouch") @@ -56,7 +56,7 @@ async def test_colortouch(hass: HomeAssistant) -> None: async def test_t2000(hass: HomeAssistant) -> None: """Test interfacing with a venstar T2000 presently turned off.""" - with patch("homeassistant.components.venstar.VENSTAR_SLEEP", new=0): + with patch("homeassistant.components.venstar.coordinator.VENSTAR_SLEEP", new=0): await async_init_integration(hass) state = hass.states.get("climate.t2000") diff --git a/tests/components/venstar/test_init.py b/tests/components/venstar/test_init.py index bc8d400df6c..3a03c4c4b88 100644 --- a/tests/components/venstar/test_init.py +++ b/tests/components/venstar/test_init.py @@ -47,7 +47,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: new=VenstarColorTouchMock.get_runtimes, ), patch( - "homeassistant.components.venstar.VENSTAR_SLEEP", + "homeassistant.components.venstar.coordinator.VENSTAR_SLEEP", new=0, ), ): diff --git a/tests/components/vera/common.py b/tests/components/vera/common.py index af21bf5d3a3..5e0fac6c84a 100644 --- a/tests/components/vera/common.py +++ b/tests/components/vera/common.py @@ -20,7 +20,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -SetupCallback = Callable[[pv.VeraController, dict], None] +type SetupCallback = Callable[[pv.VeraController, dict], None] class ControllerData(NamedTuple): diff --git a/tests/components/vera/test_init.py b/tests/components/vera/test_init.py index 666af780283..47890c4e70a 100644 --- a/tests/components/vera/test_init.py +++ b/tests/components/vera/test_init.py @@ -22,7 +22,9 @@ from tests.common import MockConfigEntry async def test_init( - hass: HomeAssistant, vera_component_factory: ComponentFactory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + vera_component_factory: ComponentFactory, ) -> None: """Test function.""" vera_device1: pv.VeraBinarySensor = MagicMock(spec=pv.VeraBinarySensor) @@ -42,14 +44,15 @@ async def test_init( ), ) - entity_registry = er.async_get(hass) entry1 = entity_registry.async_get(entity1_id) assert entry1 assert entry1.unique_id == "vera_first_serial_1" async def test_init_from_file( - hass: HomeAssistant, vera_component_factory: ComponentFactory + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + vera_component_factory: ComponentFactory, ) -> None: """Test function.""" vera_device1: pv.VeraBinarySensor = MagicMock(spec=pv.VeraBinarySensor) @@ -69,7 +72,6 @@ async def test_init_from_file( ), ) - entity_registry = er.async_get(hass) entry1 = entity_registry.async_get(entity1_id) assert entry1 assert entry1.unique_id == "vera_first_serial_1" @@ -77,8 +79,8 @@ async def test_init_from_file( async def test_multiple_controllers_with_legacy_one( hass: HomeAssistant, - vera_component_factory: ComponentFactory, entity_registry: er.EntityRegistry, + vera_component_factory: ComponentFactory, ) -> None: """Test multiple controllers with one legacy controller.""" vera_device1: pv.VeraBinarySensor = MagicMock(spec=pv.VeraBinarySensor) @@ -120,8 +122,6 @@ async def test_multiple_controllers_with_legacy_one( ), ) - entity_registry = er.async_get(hass) - entry1 = entity_registry.async_get(entity1_id) assert entry1 assert entry1.unique_id == "1" diff --git a/tests/components/verisure/conftest.py b/tests/components/verisure/conftest.py index 445b7b95300..03086ac2ead 100644 --- a/tests/components/verisure/conftest.py +++ b/tests/components/verisure/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.verisure.const import CONF_GIID, DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD @@ -29,7 +29,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.verisure.async_setup_entry", return_value=True @@ -38,7 +38,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_verisure_config_flow() -> Generator[None, MagicMock, None]: +def mock_verisure_config_flow() -> Generator[MagicMock]: """Return a mocked Tailscale client.""" with patch( "homeassistant.components.verisure.config_flow.Verisure", autospec=True diff --git a/tests/components/version/common.py b/tests/components/version/common.py index cd9469d08a1..5cecdf3d26f 100644 --- a/tests/components/version/common.py +++ b/tests/components/version/common.py @@ -42,13 +42,12 @@ async def mock_get_version_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, version: str = MOCK_VERSION, - data: dict[str, Any] = MOCK_VERSION_DATA, side_effect: Exception | None = None, ) -> None: """Mock getting version.""" with patch( "pyhaversion.HaVersion.get_version", - return_value=(version, data), + return_value=(version, MOCK_VERSION_DATA), side_effect=side_effect, ): freezer.tick(UPDATE_COORDINATOR_UPDATE_INTERVAL) diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index 04696f01631..b948053c3a0 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -66,6 +66,7 @@ async def test_async_get_config_entry_diagnostics__single_humidifier( async def test_async_get_device_diagnostics__single_fan( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, hass_client: ClientSessionGenerator, config_entry: ConfigEntry, config: ConfigType, @@ -77,7 +78,6 @@ async def test_async_get_device_diagnostics__single_fan( assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - device_registry = dr.async_get(hass) device = device_registry.async_get_device( identifiers={(DOMAIN, "abcdefghabcdefghabcdefghabcdefgh")}, ) diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index fac85b5052a..6899839a0e1 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -2,13 +2,13 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass from unittest.mock import AsyncMock, Mock, patch import pytest from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareService import ViCareDeviceAccessor, readFeature +from typing_extensions import AsyncGenerator, Generator from homeassistant.components.vicare.const import DOMAIN from homeassistant.core import HomeAssistant @@ -80,7 +80,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_vicare_gas_boiler( hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> AsyncGenerator[MockConfigEntry, None]: +) -> AsyncGenerator[MockConfigEntry]: """Return a mocked ViCare API representing a single gas boiler device.""" fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] with patch( @@ -96,7 +96,7 @@ async def mock_vicare_gas_boiler( @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry: yield mock_setup_entry diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index edef1606572..b823bb72dc9 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -81,7 +81,7 @@ async def test_user_create_entry( with patch( f"{MODULE}.config_flow.vicare_login", return_value=None, - ) as mock_setup_entry: + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], VALID_CONFIG, diff --git a/tests/components/vilfo/conftest.py b/tests/components/vilfo/conftest.py index 75ed352c839..11b620b82e0 100644 --- a/tests/components/vilfo/conftest.py +++ b/tests/components/vilfo/conftest.py @@ -1,9 +1,9 @@ """Vilfo tests conftest.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.vilfo import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST @@ -12,7 +12,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.vilfo.async_setup_entry", @@ -22,7 +22,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_vilfo_client() -> Generator[AsyncMock, None, None]: +def mock_vilfo_client() -> Generator[AsyncMock]: """Mock a Vilfo client.""" with patch( "homeassistant.components.vilfo.config_flow.VilfoClient", @@ -38,7 +38,7 @@ def mock_vilfo_client() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_is_valid_host() -> Generator[AsyncMock, None, None]: +def mock_is_valid_host() -> Generator[AsyncMock]: """Mock is_valid_host.""" with patch( "homeassistant.components.vilfo.config_flow.is_host_valid", diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 783ed8b4585..b06ce2e1eb7 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -54,7 +54,7 @@ def vizio_get_unique_id_fixture(): def vizio_data_coordinator_update_fixture(): """Mock get data coordinator update.""" with patch( - "homeassistant.components.vizio.gen_apps_list_from_url", + "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", return_value=APP_LIST, ): yield @@ -64,7 +64,7 @@ def vizio_data_coordinator_update_fixture(): def vizio_data_coordinator_update_failure_fixture(): """Mock get data coordinator update failure.""" with patch( - "homeassistant.components.vizio.gen_apps_list_from_url", + "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", return_value=None, ): yield diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 1f35cc16385..3e7b0c83c70 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -196,8 +196,7 @@ MOCK_INCLUDE_NO_APPS = { VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local." ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" -ZEROCONF_HOST = HOST.split(":")[0] -ZEROCONF_PORT = HOST.split(":")[1] +ZEROCONF_HOST, ZEROCONF_PORT = HOST.split(":", maxsplit=2) MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( ip_address=ip_address(ZEROCONF_HOST), diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py index edab40444b6..eba5af437b1 100644 --- a/tests/components/vizio/test_init.py +++ b/tests/components/vizio/test_init.py @@ -43,7 +43,7 @@ async def test_tv_load_and_unload( assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 assert DOMAIN in hass.data - assert await config_entry.async_unload(hass) + assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() entities = hass.states.async_entity_ids(Platform.MEDIA_PLAYER) assert len(entities) == 1 @@ -67,7 +67,7 @@ async def test_speaker_load_and_unload( assert len(hass.states.async_entity_ids(Platform.MEDIA_PLAYER)) == 1 assert DOMAIN in hass.data - assert await config_entry.async_unload(hass) + assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() entities = hass.states.async_entity_ids(Platform.MEDIA_PLAYER) assert len(entities) == 1 diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 8cc734b9188..52a5732706d 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -745,7 +745,7 @@ async def test_apps_update( ) -> None: """Test device setup with apps where no app is running.""" with patch( - "homeassistant.components.vizio.gen_apps_list_from_url", + "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", return_value=None, ): async with _cm_for_test_setup_tv_with_apps( @@ -758,7 +758,7 @@ async def test_apps_update( assert len(apps) == len(APPS) with patch( - "homeassistant.components.vizio.gen_apps_list_from_url", + "homeassistant.components.vizio.coordinator.gen_apps_list_from_url", return_value=APP_LIST, ): async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index d1e7ba3c62f..1a2ad002586 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -1,6 +1,8 @@ """The tests for the VoiceRSS speech platform.""" from http import HTTPStatus +from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -29,12 +31,12 @@ FORM_DATA = { @pytest.fixture(autouse=True) -def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index bcd9becbc5a..b039a49e0f0 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -async def load_homeassistant(hass) -> None: +async def load_homeassistant(hass: HomeAssistant) -> None: """Load the homeassistant integration.""" assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/voip/test_sip.py b/tests/components/voip/test_sip.py index 769be768261..8c070df7247 100644 --- a/tests/components/voip/test_sip.py +++ b/tests/components/voip/test_sip.py @@ -9,7 +9,8 @@ from homeassistant.components import voip from homeassistant.core import HomeAssistant -async def test_create_sip_server(hass: HomeAssistant, socket_enabled) -> None: +@pytest.mark.usefixtures("socket_enabled") +async def test_create_sip_server(hass: HomeAssistant) -> None: """Tests starting/stopping SIP server.""" result = await hass.config_entries.flow.async_init( voip.DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index f5c5fde2518..6c292241237 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -2,6 +2,7 @@ import asyncio import io +from pathlib import Path import time from unittest.mock import AsyncMock, Mock, patch import wave @@ -18,7 +19,7 @@ _MEDIA_ID = "12345" @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py index 01c6bf3edaf..3311f3c71b2 100644 --- a/tests/components/vulcan/test_config_flow.py +++ b/tests/components/vulcan/test_config_flow.py @@ -89,10 +89,10 @@ async def test_config_flow_auth_success_with_multiple_students( mock_account.return_value = fake_account mock_student.return_value = [ Student.load(student) - for student in [ + for student in ( load_fixture("fake_student_1.json", "vulcan"), load_fixture("fake_student_2.json", "vulcan"), - ] + ) ] result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/vultr/conftest.py b/tests/components/vultr/conftest.py index f8ecd1cf321..ae0ce9d6886 100644 --- a/tests/components/vultr/conftest.py +++ b/tests/components/vultr/conftest.py @@ -4,6 +4,7 @@ import json from unittest.mock import patch import pytest +from requests_mock import Mocker from homeassistant.components import vultr from homeassistant.core import HomeAssistant @@ -14,7 +15,7 @@ from tests.common import load_fixture @pytest.fixture(name="valid_config") -def valid_config(hass: HomeAssistant, requests_mock): +def valid_config(hass: HomeAssistant, requests_mock: Mocker) -> None: """Load a valid config.""" requests_mock.get( "https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567", diff --git a/tests/components/vultr/test_switch.py b/tests/components/vultr/test_switch.py index f75021efa05..14c88d1e878 100644 --- a/tests/components/vultr/test_switch.py +++ b/tests/components/vultr/test_switch.py @@ -50,7 +50,7 @@ def load_hass_devices(hass: HomeAssistant): @pytest.mark.usefixtures("valid_config") -def test_switch(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): +def test_switch(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: """Test successful instance.""" assert len(hass_devices) == 3 @@ -97,7 +97,7 @@ def test_switch(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): @pytest.mark.usefixtures("valid_config") -def test_turn_on(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): +def test_turn_on(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: """Test turning a subscription on.""" with ( patch( @@ -116,7 +116,7 @@ def test_turn_on(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): @pytest.mark.usefixtures("valid_config") -def test_turn_off(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]): +def test_turn_off(hass: HomeAssistant, hass_devices: list[vultr.VultrSwitch]) -> None: """Test turning a subscription off.""" with ( patch( diff --git a/tests/components/wake_on_lan/conftest.py b/tests/components/wake_on_lan/conftest.py index 66782531ef1..cec3076d83e 100644 --- a/tests/components/wake_on_lan/conftest.py +++ b/tests/components/wake_on_lan/conftest.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest +from typing_extensions import Generator @pytest.fixture @@ -22,9 +22,7 @@ def subprocess_call_return_value() -> int | None: @pytest.fixture(autouse=True) -def mock_subprocess_call( - subprocess_call_return_value: int, -) -> Generator[None, None, MagicMock]: +def mock_subprocess_call(subprocess_call_return_value: int) -> Generator[MagicMock]: """Mock magic packet.""" with patch("homeassistant.components.wake_on_lan.switch.sp.call") as mock_sp: mock_sp.return_value = subprocess_call_return_value diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 1e957ad7a2c..c19d3e7032f 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -1,13 +1,14 @@ """Test wake_word component setup.""" import asyncio -from collections.abc import AsyncIterable, Generator +from collections.abc import AsyncIterable from functools import partial from pathlib import Path from unittest.mock import patch from freezegun import freeze_time import pytest +from typing_extensions import Generator from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow @@ -88,7 +89,7 @@ class WakeWordFlow(ConfigFlow): @pytest.fixture(autouse=True) -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -117,8 +118,8 @@ async def mock_config_entry_setup( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup( - config_entry, wake_word.DOMAIN + await hass.config_entries.async_forward_entry_setups( + config_entry, [wake_word.DOMAIN] ) return True diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py index f42c8be6097..b2e1a7d77d4 100644 --- a/tests/components/waqi/conftest.py +++ b/tests/components/waqi/conftest.py @@ -1,9 +1,9 @@ """Common fixtures for the World Air Quality Index (WAQI) tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import CONF_API_KEY @@ -12,7 +12,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.waqi.async_setup_entry", return_value=True diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index f476514a6c7..3d00f1cff26 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -4,18 +4,8 @@ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', 'device_class': 'aqi', - 'dominentpol': , 'friendly_name': 'de Jongweg, Utrecht Air quality index', - 'humidity': 80, - 'nitrogen_dioxide': 2.3, - 'ozone': 29.4, - 'pm_10': 12, - 'pm_2_5': 17, - 'pressure': 1008.8, 'state_class': , - 'sulfur_dioxide': 2.3, - 'temperature': 16, - 'time': datetime.datetime(2023, 8, 7, 17, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200))), }), 'context': , 'entity_id': 'sensor.de_jongweg_utrecht_air_quality_index', diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 0825d65cc20..0cd2aa67233 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -20,7 +20,10 @@ from tests.common import MockConfigEntry, load_fixture @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test failed update.""" mock_config_entry.add_to_hass(hass) @@ -32,7 +35,6 @@ async def test_sensor( ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - entity_registry = er.async_get(hass) for sensor in SENSORS: entity_id = entity_registry.async_get_entity_id( SENSOR_DOMAIN, DOMAIN, f"4584_{sensor.key}" diff --git a/tests/components/water_heater/common.py b/tests/components/water_heater/common.py index 9e47af4a19f..e0a8075f4cc 100644 --- a/tests/components/water_heater/common.py +++ b/tests/components/water_heater/common.py @@ -35,11 +35,11 @@ async def async_set_temperature( """Set new target temperature.""" kwargs = { key: value - for key, value in [ + for key, value in ( (ATTR_TEMPERATURE, temperature), (ATTR_ENTITY_ID, entity_id), (ATTR_OPERATION_MODE, operation_mode), - ] + ) if value is not None } _LOGGER.debug("set_temperature start data=%s", kwargs) diff --git a/tests/components/water_heater/conftest.py b/tests/components/water_heater/conftest.py index d6858fe08e1..619d5e5c359 100644 --- a/tests/components/water_heater/conftest.py +++ b/tests/components/water_heater/conftest.py @@ -1,8 +1,7 @@ """Fixtures for water heater platform tests.""" -from collections.abc import Generator - import pytest +from typing_extensions import Generator from homeassistant.config_entries import ConfigFlow from homeassistant.core import HomeAssistant @@ -15,7 +14,7 @@ class MockFlow(ConfigFlow): @pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, "test.config_flow") diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index e08721d3e10..943aa3373a0 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -47,7 +47,7 @@ async def test_get_actions( "entity_id": entity_entry.id, "metadata": {"secondary": False}, } - for action in ["turn_on", "turn_off"] + for action in ("turn_on", "turn_off") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id @@ -95,7 +95,7 @@ async def test_get_actions_hidden_auxiliary( "entity_id": entity_entry.id, "metadata": {"secondary": True}, } - for action in ["turn_on", "turn_off"] + for action in ("turn_on", "turn_off") ] actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index 01642ace86a..c929fc219f9 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -5,6 +5,25 @@ from unittest.mock import patch import pytest from pywaze.route_calculator import CalcRoutesResponse, WRCError +from homeassistant.components.waze_travel_time.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_config") +async def mock_config_fixture(hass: HomeAssistant, data, options): + """Mock a Waze Travel Time config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options=options, + entry_id="test", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + @pytest.fixture(name="mock_update") def mock_update_fixture(): diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py new file mode 100644 index 00000000000..58aaa8983a7 --- /dev/null +++ b/tests/components/waze_travel_time/test_init.py @@ -0,0 +1,45 @@ +"""Test waze_travel_time services.""" + +import pytest + +from homeassistant.components.waze_travel_time.const import DEFAULT_OPTIONS +from homeassistant.core import HomeAssistant + +from .const import MOCK_CONFIG + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.usefixtures("mock_update", "mock_config") +async def test_service_get_travel_times(hass: HomeAssistant) -> None: + """Test service get_travel_times.""" + response_data = await hass.services.async_call( + "waze_travel_time", + "get_travel_times", + { + "origin": "location1", + "destination": "location2", + "vehicle_type": "car", + "region": "us", + }, + blocking=True, + return_response=True, + ) + assert response_data == { + "routes": [ + { + "distance": 300, + "duration": 150, + "name": "E1337 - Teststreet", + "street_names": ["E1337", "IncludeThis", "Teststreet"], + }, + { + "distance": 500, + "duration": 600, + "name": "E0815 - Otherstreet", + "street_names": ["E0815", "ExcludeThis", "Otherstreet"], + }, + ] + } diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index db0ece32cae..e09a7199ff4 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -24,20 +24,6 @@ from .const import MOCK_CONFIG from tests.common import MockConfigEntry -@pytest.fixture(name="mock_config") -async def mock_config_fixture(hass: HomeAssistant, data, options): - """Mock a Waze Travel Time config entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=data, - options=options, - entry_id="test", - ) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - @pytest.fixture(name="mock_update_wrcerror") def mock_update_wrcerror_fixture(mock_update): """Mock an update to the sensor failed with WRCError.""" diff --git a/tests/components/weather/conftest.py b/tests/components/weather/conftest.py index 073af7ab8ef..e3e790300a0 100644 --- a/tests/components/weather/conftest.py +++ b/tests/components/weather/conftest.py @@ -1,8 +1,7 @@ """Fixtures for Weather platform tests.""" -from collections.abc import Generator - import pytest +from typing_extensions import Generator from homeassistant.config_entries import ConfigFlow from homeassistant.core import HomeAssistant @@ -15,7 +14,7 @@ class MockFlow(ConfigFlow): @pytest.fixture -def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: """Mock config flow.""" mock_platform(hass, "test.config_flow") diff --git a/tests/components/weather/snapshots/test_init.ambr b/tests/components/weather/snapshots/test_init.ambr index 1aa78f6bf35..dbb18d5485a 100644 --- a/tests/components/weather/snapshots/test_init.ambr +++ b/tests/components/weather/snapshots/test_init.ambr @@ -1,18 +1,5 @@ # serializer version: 1 -# name: test_get_forecast[daily-1-get_forecast] - dict({ - 'forecast': list([ - dict({ - 'cloud_coverage': None, - 'temperature': 38.0, - 'templow': 38.0, - 'uv_index': None, - 'wind_bearing': None, - }), - ]), - }) -# --- -# name: test_get_forecast[daily-1-get_forecasts] +# name: test_get_forecast[daily-1] dict({ 'weather.testing': dict({ 'forecast': list([ @@ -27,20 +14,7 @@ }), }) # --- -# name: test_get_forecast[hourly-2-get_forecast] - dict({ - 'forecast': list([ - dict({ - 'cloud_coverage': None, - 'temperature': 38.0, - 'templow': 38.0, - 'uv_index': None, - 'wind_bearing': None, - }), - ]), - }) -# --- -# name: test_get_forecast[hourly-2-get_forecasts] +# name: test_get_forecast[hourly-2] dict({ 'weather.testing': dict({ 'forecast': list([ @@ -55,21 +29,7 @@ }), }) # --- -# name: test_get_forecast[twice_daily-4-get_forecast] - dict({ - 'forecast': list([ - dict({ - 'cloud_coverage': None, - 'is_daytime': True, - 'temperature': 38.0, - 'templow': 38.0, - 'uv_index': None, - 'wind_bearing': None, - }), - ]), - }) -# --- -# name: test_get_forecast[twice_daily-4-get_forecasts] +# name: test_get_forecast[twice_daily-4] dict({ 'weather.testing': dict({ 'forecast': list([ diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 195a4c9ef67..8ea8895a2a3 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -22,7 +22,6 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN, - LEGACY_SERVICE_GET_FORECAST, ROUNDING_PRECISION, SERVICE_GET_FORECASTS, Forecast, @@ -47,7 +46,6 @@ 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.util import dt as dt_util from homeassistant.util.unit_conversion import ( DistanceConverter, @@ -413,7 +411,9 @@ async def test_humidity( assert float(state.attributes[ATTR_WEATHER_HUMIDITY]) == 80 -async def test_custom_units(hass: HomeAssistant, config_flow_fixture: None) -> None: +async def test_custom_units( + hass: HomeAssistant, entity_registry: er.EntityRegistry, config_flow_fixture: None +) -> None: """Test custom unit.""" wind_speed_value = 5 wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND @@ -434,8 +434,6 @@ async def test_custom_units(hass: HomeAssistant, config_flow_fixture: None) -> N "visibility_unit": UnitOfLength.MILES, } - entity_registry = er.async_get(hass) - entry = entity_registry.async_get_or_create("weather", "test", "very_unique") entity_registry.async_update_entity_options(entry.entity_id, "weather", set_options) await hass.async_block_till_done() @@ -604,13 +602,6 @@ async def test_forecast_twice_daily_missing_is_daytime( assert msg["type"] == "result" -@pytest.mark.parametrize( - ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], -) @pytest.mark.parametrize( ("forecast_type", "supported_features"), [ @@ -628,7 +619,6 @@ async def test_get_forecast( forecast_type: str, supported_features: int, snapshot: SnapshotAssertion, - service: str, ) -> None: """Test get forecast service.""" @@ -659,7 +649,7 @@ async def test_get_forecast( response = await hass.services.async_call( DOMAIN, - service, + SERVICE_GET_FORECASTS, { "entity_id": entity0.entity_id, "type": forecast_type, @@ -670,30 +660,9 @@ async def test_get_forecast( assert response == snapshot -@pytest.mark.parametrize( - ("service", "expected"), - [ - ( - SERVICE_GET_FORECASTS, - { - "weather.testing": { - "forecast": [], - } - }, - ), - ( - LEGACY_SERVICE_GET_FORECAST, - { - "forecast": [], - }, - ), - ], -) async def test_get_forecast_no_forecast( hass: HomeAssistant, config_flow_fixture: None, - service: str, - expected: dict[str, list | dict[str, list]], ) -> None: """Test get forecast service.""" @@ -714,7 +683,7 @@ async def test_get_forecast_no_forecast( response = await hass.services.async_call( DOMAIN, - service, + SERVICE_GET_FORECASTS, { "entity_id": entity0.entity_id, "type": "daily", @@ -722,16 +691,13 @@ async def test_get_forecast_no_forecast( blocking=True, return_response=True, ) - assert response == expected + assert response == { + "weather.testing": { + "forecast": [], + } + } -@pytest.mark.parametrize( - ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], -) @pytest.mark.parametrize( ("supported_features", "forecast_types"), [ @@ -745,7 +711,6 @@ async def test_get_forecast_unsupported( config_flow_fixture: None, forecast_types: list[str], supported_features: int, - service: str, ) -> None: """Test get forecast service.""" @@ -775,7 +740,7 @@ async def test_get_forecast_unsupported( with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - service, + SERVICE_GET_FORECASTS, { "entity_id": weather_entity.entity_id, "type": forecast_type, @@ -786,52 +751,3 @@ async def test_get_forecast_unsupported( ISSUE_TRACKER = "https://blablabla.com" - - -async def test_issue_deprecated_service_weather_get_forecast( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - config_flow_fixture: None, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the issue is raised on deprecated service weather.get_forecast.""" - - class MockWeatherMock(MockWeatherTest): - """Mock weather class.""" - - async def async_forecast_daily(self) -> list[Forecast] | None: - """Return the forecast_daily.""" - return self.forecast_list - - kwargs = { - "native_temperature": 38, - "native_temperature_unit": UnitOfTemperature.CELSIUS, - "supported_features": WeatherEntityFeature.FORECAST_DAILY, - } - - entity0 = await create_entity(hass, MockWeatherMock, None, **kwargs) - - _ = await hass.services.async_call( - DOMAIN, - LEGACY_SERVICE_GET_FORECAST, - { - "entity_id": entity0.entity_id, - "type": "daily", - }, - blocking=True, - return_response=True, - ) - - issue = issue_registry.async_get_issue( - "weather", "deprecated_service_weather_get_forecast" - ) - assert issue - assert issue.issue_domain == "test" - assert issue.issue_id == "deprecated_service_weather_get_forecast" - assert issue.translation_key == "deprecated_service_weather_get_forecast" - - assert ( - "Detected use of service 'weather.get_forecast'. " - "This is deprecated and will stop working in Home Assistant 2024.6. " - "Use 'weather.get_forecasts' instead which supports multiple entities" - ) in caplog.text diff --git a/tests/components/weather/test_intent.py b/tests/components/weather/test_intent.py index 1fde5882d6e..0f9884791a5 100644 --- a/tests/components/weather/test_intent.py +++ b/tests/components/weather/test_intent.py @@ -1,9 +1,9 @@ """Test weather intents.""" -from unittest.mock import patch - import pytest +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.weather import ( DOMAIN, WeatherEntity, @@ -16,15 +16,18 @@ from homeassistant.setup import async_setup_component async def test_get_weather(hass: HomeAssistant) -> None: """Test get weather for first entity and by name.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) entity1 = WeatherEntity() entity1._attr_name = "Weather 1" entity1.entity_id = "weather.test_1" + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True) entity2 = WeatherEntity() entity2._attr_name = "Weather 2" entity2.entity_id = "weather.test_2" + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, True) await hass.data[DOMAIN].async_add_entities([entity1, entity2]) @@ -45,15 +48,31 @@ async def test_get_weather(hass: HomeAssistant) -> None: "test", weather_intent.INTENT_GET_WEATHER, {"name": {"value": "Weather 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 state = response.matched_states[0] assert state.entity_id == entity2.entity_id + # Should fail if not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + for name in (entity1.name, entity2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: """Test get weather with the wrong name.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) entity1 = WeatherEntity() @@ -63,48 +82,43 @@ async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: await hass.data[DOMAIN].async_add_entities([entity1]) await weather_intent.async_setup_intents(hass) + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True) # Incorrect name - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError) as err: await intent.async_handle( hass, "test", weather_intent.INTENT_GET_WEATHER, {"name": {"value": "not the right name"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME + + # Empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, ) async def test_get_weather_no_entities(hass: HomeAssistant) -> None: """Test get weather with no weather entities.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) await weather_intent.async_setup_intents(hass) # No weather entities - with pytest.raises(intent.IntentHandleError): - await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) - - -async def test_get_weather_no_state(hass: HomeAssistant) -> None: - """Test get weather when state is not returned.""" - assert await async_setup_component(hass, "weather", {"weather": {}}) - - entity1 = WeatherEntity() - entity1._attr_name = "Weather 1" - entity1.entity_id = "weather.test_1" - - await hass.data[DOMAIN].async_add_entities([entity1]) - - await weather_intent.async_setup_intents(hass) - - # Success with state - response = await intent.async_handle( - hass, "test", weather_intent.INTENT_GET_WEATHER, {} - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - - # Failure without state - with ( - patch("homeassistant.core.StateMachine.get", return_value=None), - pytest.raises(intent.IntentHandleError), - ): - await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN diff --git a/tests/components/weatherflow/conftest.py b/tests/components/weatherflow/conftest.py index dc533f153e2..c0811597228 100644 --- a/tests/components/weatherflow/conftest.py +++ b/tests/components/weatherflow/conftest.py @@ -1,12 +1,12 @@ """Fixtures for Weatherflow integration tests.""" import asyncio -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest from pyweatherflowudp.client import EVENT_DEVICE_DISCOVERED from pyweatherflowudp.device import WeatherFlowDevice +from typing_extensions import Generator from homeassistant.components.weatherflow.const import DOMAIN @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.weatherflow.async_setup_entry", return_value=True @@ -29,7 +29,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_has_devices() -> Generator[AsyncMock, None, None]: +def mock_has_devices() -> Generator[AsyncMock]: """Return a mock has_devices function.""" with patch( "homeassistant.components.weatherflow.config_flow.WeatherFlowListener.on", @@ -39,7 +39,7 @@ def mock_has_devices() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_stop() -> Generator[AsyncMock, None, None]: +def mock_stop() -> Generator[AsyncMock]: """Return a fixture to handle the stop of udp.""" async def mock_stop_listening(self): @@ -54,7 +54,7 @@ def mock_stop() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_start() -> Generator[AsyncMock, None, None]: +def mock_start() -> Generator[AsyncMock]: """Return fixture for starting upd.""" device = WeatherFlowDevice( diff --git a/tests/components/weatherflow_cloud/conftest.py b/tests/components/weatherflow_cloud/conftest.py index e07abe2b924..d47da3c7d1b 100644 --- a/tests/components/weatherflow_cloud/conftest.py +++ b/tests/components/weatherflow_cloud/conftest.py @@ -1,14 +1,14 @@ """Common fixtures for the WeatherflowCloud tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientResponseError import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.weatherflow_cloud.async_setup_entry", @@ -18,7 +18,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_get_stations() -> Generator[AsyncMock, None, None]: +def mock_get_stations() -> Generator[AsyncMock]: """Mock get_stations with a sequence of responses.""" side_effects = [ True, @@ -32,7 +32,7 @@ def mock_get_stations() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_get_stations_500_error() -> Generator[AsyncMock, None, None]: +def mock_get_stations_500_error() -> Generator[AsyncMock]: """Mock get_stations with a sequence of responses.""" side_effects = [ ClientResponseError(Mock(), (), status=500), @@ -47,7 +47,7 @@ def mock_get_stations_500_error() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_get_stations_401_error() -> Generator[AsyncMock, None, None]: +def mock_get_stations_401_error() -> Generator[AsyncMock]: """Mock get_stations with a sequence of responses.""" side_effects = [ClientResponseError(Mock(), (), status=401), True, True, True] diff --git a/tests/components/weatherflow_cloud/test_config_flow.py b/tests/components/weatherflow_cloud/test_config_flow.py index b111ef462e6..7ade007ceac 100644 --- a/tests/components/weatherflow_cloud/test_config_flow.py +++ b/tests/components/weatherflow_cloud/test_config_flow.py @@ -56,14 +56,18 @@ async def test_config_flow_abort(hass: HomeAssistant, mock_get_stations) -> None @pytest.mark.parametrize( - "mock_fixture, expected_error", # noqa: PT006 + ("mock_fixture", "expected_error"), [ ("mock_get_stations_500_error", "cannot_connect"), ("mock_get_stations_401_error", "invalid_api_key"), ], ) async def test_config_errors( - hass: HomeAssistant, request, expected_error, mock_fixture, mock_get_stations + hass: HomeAssistant, + request: pytest.FixtureRequest, + expected_error: str, + mock_fixture: str, + mock_get_stations, ) -> None: """Test the config flow for various error scenarios.""" mock_get_stations_bad = request.getfixturevalue(mock_fixture) diff --git a/tests/components/weatherkit/conftest.py b/tests/components/weatherkit/conftest.py index ac1dab76a86..d4b849115f6 100644 --- a/tests/components/weatherkit/conftest.py +++ b/tests/components/weatherkit/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the Apple WeatherKit tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.weatherkit.async_setup_entry", return_value=True diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py index 3b3a9a50d7f..ba20276c22e 100644 --- a/tests/components/weatherkit/test_weather.py +++ b/tests/components/weatherkit/test_weather.py @@ -16,10 +16,9 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, - LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, + WeatherEntityFeature, ) -from homeassistant.components.weather.const import WeatherEntityFeature from homeassistant.components.weatherkit.const import ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant @@ -81,10 +80,7 @@ async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_hourly_forecast( hass: HomeAssistant, snapshot: SnapshotAssertion, service: str @@ -107,10 +103,7 @@ async def test_hourly_forecast( @pytest.mark.parametrize( ("service"), - [ - SERVICE_GET_FORECASTS, - LEGACY_SERVICE_GET_FORECAST, - ], + [SERVICE_GET_FORECASTS], ) async def test_daily_forecast( hass: HomeAssistant, snapshot: SnapshotAssertion, service: str diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index b92e9795432..6f4ae1ebefc 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -5,6 +5,7 @@ from ipaddress import ip_address from unittest.mock import Mock, patch from aiohttp import web +from aiohttp.test_utils import TestClient import pytest from homeassistant.components import webhook @@ -12,11 +13,11 @@ 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 WebSocketGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -def mock_client(hass, hass_client): +def mock_client(hass: HomeAssistant, hass_client: ClientSessionGenerator) -> TestClient: """Create http client for webhooks.""" hass.loop.run_until_complete(async_setup_component(hass, "webhook", {})) return hass.loop.run_until_complete(hass_client()) @@ -55,6 +56,22 @@ async def test_generate_webhook_url(hass: HomeAssistant) -> None: assert url == "https://example.com/api/webhook/some_id" +async def test_generate_webhook_url_internal(hass: HomeAssistant) -> None: + """Test we can get the internal URL.""" + await async_process_ha_core_config( + hass, + { + "internal_url": "http://192.168.1.100:8123", + "external_url": "https://example.com", + }, + ) + url = webhook.async_generate_url( + hass, "some_id", allow_external=False, allow_ip=True + ) + + assert url == "http://192.168.1.100:8123/api/webhook/some_id" + + async def test_async_generate_path(hass: HomeAssistant) -> None: """Test generating just the path component of the url correctly.""" path = webhook.async_generate_path("some_id") @@ -248,11 +265,11 @@ async def test_webhook_local_only(hass: HomeAssistant, mock_client) -> None: assert len(hooks) == 1 +@pytest.mark.usefixtures("enable_custom_integrations") async def test_listing_webhook( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_access_token: str, - enable_custom_integrations: None, ) -> None: """Test unregistering a webhook.""" assert await async_setup_component(hass, "webhook", {}) diff --git a/tests/components/webmin/conftest.py b/tests/components/webmin/conftest.py index 4fd674c66c8..c3ad43510d5 100644 --- a/tests/components/webmin/conftest.py +++ b/tests/components/webmin/conftest.py @@ -1,9 +1,9 @@ """Fixtures for Webmin integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components.webmin.const import DEFAULT_PORT, DOMAIN from homeassistant.const import ( @@ -29,7 +29,7 @@ TEST_USER_INPUT = { @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.webmin.async_setup_entry", return_value=True diff --git a/tests/components/webmin/snapshots/test_diagnostics.ambr b/tests/components/webmin/snapshots/test_diagnostics.ambr index 9c666938f56..a56d6b35641 100644 --- a/tests/components/webmin/snapshots/test_diagnostics.ambr +++ b/tests/components/webmin/snapshots/test_diagnostics.ambr @@ -111,8 +111,8 @@ }), ]), 'disk_free': 7749321486336, - 'disk_fs': list([ - dict({ + 'disk_fs': dict({ + '/': dict({ 'device': '**REDACTED**', 'dir': '**REDACTED**', 'free': 49060442112, @@ -125,20 +125,7 @@ 'used': 186676502528, 'used_percent': 80, }), - dict({ - 'device': '**REDACTED**', - 'dir': '**REDACTED**', - 'free': 7028764823552, - 'ifree': 362656466, - 'itotal': 366198784, - 'iused': 3542318, - 'iused_percent': 1, - 'total': 11903838912512, - 'type': 'ext4', - 'used': 4275077644288, - 'used_percent': 38, - }), - dict({ + '/media/disk1': dict({ 'device': '**REDACTED**', 'dir': '**REDACTED**', 'free': 671496220672, @@ -151,7 +138,20 @@ 'used': 4981066997760, 'used_percent': 89, }), - ]), + '/media/disk2': dict({ + 'device': '**REDACTED**', + 'dir': '**REDACTED**', + 'free': 7028764823552, + 'ifree': 362656466, + 'itotal': 366198784, + 'iused': 3542318, + 'iused_percent': 1, + 'total': 11903838912512, + 'type': 'ext4', + 'used': 4275077644288, + 'used_percent': 38, + }), + }), 'disk_total': 18104905818112, 'disk_used': 9442821144576, 'drivetemps': list([ diff --git a/tests/components/webmin/snapshots/test_sensor.ambr b/tests/components/webmin/snapshots/test_sensor.ambr index 1813dd354d3..8803ee684ae 100644 --- a/tests/components/webmin/snapshots/test_sensor.ambr +++ b/tests/components/webmin/snapshots/test_sensor.ambr @@ -1,4 +1,2113 @@ # serializer version: 1 +# name: test_sensor[sensor.192_168_1_1_data_size-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_total', + 'unique_id': '12:34:56:78:9a:bc_disk_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16861.5074996948', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_total', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5543.82404708862', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_11-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_11', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_11-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_11', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4638.98014068604', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_12-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_12', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_free', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_12-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_12', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '625.379589080811', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_free', + 'unique_id': '12:34:56:78:9a:bc_disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7217.11803817749', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_used', + 'unique_id': '12:34:56:78:9a:bc_disk_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8794.3125', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_total', + 'unique_id': '12:34:56:78:9a:bc_/_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.369548797607', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used', + 'unique_id': '12:34:56:78:9a:bc_/_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '173.85604095459', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_free', + 'unique_id': '12:34:56:78:9a:bc_/_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.6910972595215', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_total', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11086.3139038086', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3981.47631835938', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_data_size_9', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data size', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_free', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_data_size_9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Data size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_data_size_9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6546.04735183716', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_inodes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_free_inodes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk free inodes /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_ifree', + 'unique_id': '12:34:56:78:9a:bc_/_ifree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_inodes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk free inodes /', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_free_inodes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14927206', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_inodes_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_free_inodes_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk free inodes /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_ifree', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_ifree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_inodes_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk free inodes /media/disk1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_free_inodes_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '183130757', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_inodes_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_free_inodes_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk free inodes /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_ifree', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_ifree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_inodes_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk free inodes /media/disk2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_free_inodes_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '362656466', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_free_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk free space /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_free', + 'unique_id': '12:34:56:78:9a:bc_/_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk free space /', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_free_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.6910972595215', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_space_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_free_space_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk free space /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_free', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_space_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk free space /media/disk1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_free_space_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '625.379589080811', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_space_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_free_space_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk free space /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_free', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_free_space_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk free space /media/disk2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_free_space_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6546.04735183716', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_inode_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_inode_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk inode usage /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused_percent', + 'unique_id': '12:34:56:78:9a:bc_/_iused_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_inode_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk inode usage /', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_inode_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_inode_usage_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_inode_usage_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk inode usage /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_iused_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_inode_usage_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk inode usage /media/disk1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_inode_usage_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_inode_usage_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_inode_usage_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk inode usage /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_iused_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_inode_usage_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk inode usage /media/disk2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_inode_usage_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_inodes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_total_inodes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk total inodes /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_itotal', + 'unique_id': '12:34:56:78:9a:bc_/_itotal', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_inodes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk total inodes /', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_total_inodes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15482880', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_inodes_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_total_inodes_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk total inodes /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_itotal', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_itotal', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_inodes_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk total inodes /media/disk1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_total_inodes_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '183140352', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_inodes_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_total_inodes_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk total inodes /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_itotal', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_itotal', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_inodes_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk total inodes /media/disk2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_total_inodes_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '366198784', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_total_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk total space /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_total', + 'unique_id': '12:34:56:78:9a:bc_/_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk total space /', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_total_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.369548797607', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_space_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_total_space_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk total space /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_total', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_space_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk total space /media/disk1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_total_space_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5543.82404708862', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_space_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_total_space_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk total space /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_total', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_total_space_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk total space /media/disk2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_total_space_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11086.3139038086', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk usage /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used_percent', + 'unique_id': '12:34:56:78:9a:bc_/_used_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk usage /', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_usage_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_usage_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk usage /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_used_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_usage_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk usage /media/disk1', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_usage_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_usage_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_usage_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk usage /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_used_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_usage_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk usage /media/disk2', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_usage_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_inodes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_used_inodes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk used inodes /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused', + 'unique_id': '12:34:56:78:9a:bc_/_iused', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_inodes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk used inodes /', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_used_inodes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '555674', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_inodes_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_used_inodes_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk used inodes /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_iused', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_inodes_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk used inodes /media/disk1', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_used_inodes_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9595', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_inodes_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_used_inodes_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disk used inodes /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_iused', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_inodes_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 Disk used inodes /media/disk2', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_used_inodes_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3542318', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_used_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk used space /', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used', + 'unique_id': '12:34:56:78:9a:bc_/_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk used space /', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_used_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '173.85604095459', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_space_media_disk1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_used_space_media_disk1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk used space /media/disk1', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_space_media_disk1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk used space /media/disk1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_used_space_media_disk1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4638.98014068604', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_space_media_disk2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disk_used_space_media_disk2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disk used space /media/disk2', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disk_used_space_media_disk2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disk used space /media/disk2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disk_used_space_media_disk2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3981.47631835938', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disks_free_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disks_free_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disks free space', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_free', + 'unique_id': '12:34:56:78:9a:bc_disk_free', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disks_free_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disks free space', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disks_free_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7217.11803817749', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disks_total_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disks_total_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disks total space', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_total', + 'unique_id': '12:34:56:78:9a:bc_disk_total', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disks_total_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disks total space', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disks_total_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16861.5074996948', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disks_used_space-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_disks_used_space', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disks used space', + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_used', + 'unique_id': '12:34:56:78:9a:bc_disk_used', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.192_168_1_1_disks_used_space-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': '192.168.1.1 Disks used space', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_disks_used_space', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8794.3125', + }) +# --- # name: test_sensor[sensor.192_168_1_1_load_15m-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -260,6 +2369,747 @@ 'state': '31.248420715332', }) # --- +# name: test_sensor[sensor.192_168_1_1_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_itotal', + 'unique_id': '12:34:56:78:9a:bc_/_itotal', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15482880', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_iused_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_11-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_11', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_itotal', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_itotal', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_11-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_11', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '183140352', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_12-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_12', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_iused', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_12-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_12', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9595', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_13-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_13', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_ifree', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_ifree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_13-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_13', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '183130757', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_14-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_14', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_used_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_14-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_14', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_15-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_15', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk1_iused_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_15-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_15', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused', + 'unique_id': '12:34:56:78:9a:bc_/_iused', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '555674', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_ifree', + 'unique_id': '12:34:56:78:9a:bc_/_ifree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14927206', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used_percent', + 'unique_id': '12:34:56:78:9a:bc_/_used_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused_percent', + 'unique_id': '12:34:56:78:9a:bc_/_iused_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_itotal', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_itotal', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '366198784', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_iused', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_iused', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3542318', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_ifree', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_ifree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '362656466', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.192_168_1_1_none_9', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'webmin', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disk_fs_used_percent', + 'unique_id': '12:34:56:78:9a:bc_/media/disk2_used_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.192_168_1_1_none_9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '192.168.1.1 None', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.192_168_1_1_none_9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38', + }) +# --- # name: test_sensor[sensor.192_168_1_1_swap_free-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index a21b10d0d9d..2b5d701f899 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -1,11 +1,12 @@ """Common fixtures and objects for the LG webOS integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.webostv.const import LIVE_TV_APP_ID +from homeassistant.core import HomeAssistant, ServiceCall from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_INPUTS @@ -13,7 +14,7 @@ from tests.common import async_mock_service @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.webostv.async_setup_entry", return_value=True @@ -22,7 +23,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") diff --git a/tests/components/webostv/test_device_trigger.py b/tests/components/webostv/test_device_trigger.py index 5205f6ae7a1..29c75d4440b 100644 --- a/tests/components/webostv/test_device_trigger.py +++ b/tests/components/webostv/test_device_trigger.py @@ -9,9 +9,9 @@ from homeassistant.components.device_automation.exceptions import ( ) from homeassistant.components.webostv import DOMAIN, device_trigger from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_webostv @@ -20,12 +20,13 @@ from .const import ENTITY_ID, FAKE_UUID from tests.common import MockConfigEntry, async_get_device_automations -async def test_get_triggers(hass: HomeAssistant, client) -> None: +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, client +) -> None: """Test we get the expected triggers.""" await setup_webostv(hass) - device_reg = get_dev_reg(hass) - device = device_reg.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) turn_on_trigger = { "platform": "device", @@ -41,12 +42,16 @@ async def test_get_triggers(hass: HomeAssistant, client) -> None: assert turn_on_trigger in triggers -async def test_if_fires_on_turn_on_request(hass: HomeAssistant, calls, client) -> None: +async def test_if_fires_on_turn_on_request( + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + client, +) -> None: """Test for turn_on and turn_off triggers firing.""" await setup_webostv(hass) - device_reg = get_dev_reg(hass) - device = device_reg.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) assert await async_setup_component( hass, @@ -92,7 +97,6 @@ async def test_if_fires_on_turn_on_request(hass: HomeAssistant, calls, client) - blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 2 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 @@ -100,7 +104,9 @@ async def test_if_fires_on_turn_on_request(hass: HomeAssistant, calls, client) - assert calls[1].data["id"] == 0 -async def test_failure_scenarios(hass: HomeAssistant, client) -> None: +async def test_failure_scenarios( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, client +) -> None: """Test failure scenarios.""" await setup_webostv(hass) @@ -124,9 +130,8 @@ async def test_failure_scenarios(hass: HomeAssistant, client) -> None: entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) entry.add_to_hass(hass) - device_reg = get_dev_reg(hass) - device = device_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={("fake", "fake")} ) diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index dd119bd0d5a..918666cf4bf 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -7,9 +7,9 @@ import pytest from homeassistant.components import automation from homeassistant.components.webostv import DOMAIN from homeassistant.const import SERVICE_RELOAD -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import setup_webostv @@ -19,13 +19,15 @@ from tests.common import MockEntity, MockEntityPlatform async def test_webostv_turn_on_trigger_device_id( - hass: HomeAssistant, calls, client + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + client, ) -> None: """Test for turn_on triggers by device_id firing.""" await setup_webostv(hass) - device_reg = get_dev_reg(hass) - device = device_reg.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, FAKE_UUID)}) assert await async_setup_component( hass, @@ -56,7 +58,6 @@ async def test_webostv_turn_on_trigger_device_id( blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 @@ -74,12 +75,11 @@ async def test_webostv_turn_on_trigger_device_id( blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 0 async def test_webostv_turn_on_trigger_entity_id( - hass: HomeAssistant, calls, client + hass: HomeAssistant, calls: list[ServiceCall], client ) -> None: """Test for turn_on triggers by entity_id firing.""" await setup_webostv(hass) @@ -113,7 +113,6 @@ async def test_webostv_turn_on_trigger_entity_id( blocking=True, ) - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == ENTITY_ID assert calls[0].data["id"] == 0 diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py index 7cfd0e204a7..3ec3e85a92d 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -1,5 +1,6 @@ """Fixtures for websocket tests.""" +from aiohttp.test_utils import TestClient import pytest from homeassistant.components.websocket_api.auth import TYPE_AUTH_REQUIRED @@ -7,7 +8,11 @@ from homeassistant.components.websocket_api.http import URL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.typing import MockHAClientWebSocket, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) @pytest.fixture @@ -19,7 +24,9 @@ async def websocket_client( @pytest.fixture -async def no_auth_websocket_client(hass, hass_client_no_auth): +async def no_auth_websocket_client( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> TestClient: """Websocket connection that requires authentication.""" assert await async_setup_component(hass, "websocket_api", {}) await hass.async_block_till_done() diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 595dc7dcc32..62298098adc 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -6,9 +6,7 @@ import aiohttp from aiohttp import WSMsgType import pytest -from homeassistant.auth.providers.legacy_api_password import ( - LegacyApiPasswordAuthProvider, -) +from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_INVALID, @@ -51,7 +49,7 @@ def track_connected(hass): async def test_auth_events( hass: HomeAssistant, no_auth_websocket_client, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, hass_access_token: str, track_connected, ) -> None: @@ -174,7 +172,7 @@ async def test_auth_active_with_password_not_allow( async def test_auth_legacy_support_with_password( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, ) -> None: """Test authenticating with a token.""" assert await async_setup_component(hass, "websocket_api", {}) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 655d8adf1ea..276a383d9e9 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -3,6 +3,7 @@ import asyncio from copy import deepcopy import logging +from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch import pytest @@ -2390,7 +2391,7 @@ async def test_execute_script_complex_response( "type": "execute_script", "sequence": [ { - "service": "calendar.list_events", + "service": "calendar.get_events", "data": {"duration": {"hours": 24, "minutes": 0, "seconds": 0}}, "target": {"entity_id": "calendar.calendar_1"}, "response_variable": "service_result", @@ -2404,15 +2405,17 @@ async def test_execute_script_complex_response( assert msg_no_var["type"] == const.TYPE_RESULT assert msg_no_var["success"] assert msg_no_var["result"]["response"] == { - "events": [ - { - "start": ANY, - "end": ANY, - "summary": "Future Event", - "description": "Future Description", - "location": "Future Location", - } - ] + "calendar.calendar_1": { + "events": [ + { + "start": ANY, + "end": ANY, + "summary": "Future Event", + "description": "Future Description", + "location": "Future Location", + } + ] + } } @@ -2529,13 +2532,14 @@ async def test_integration_setup_info( ], ) async def test_validate_config_works( - websocket_client: MockHAClientWebSocket, key, config + websocket_client: MockHAClientWebSocket, + key: str, + config: dict[str, Any] | list[dict[str, Any]], ) -> None: """Test config validation.""" - await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) + await websocket_client.send_json_auto_id({"type": "validate_config", key: config}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == {key: {"valid": True, "error": None}} @@ -2544,11 +2548,13 @@ async def test_validate_config_works( @pytest.mark.parametrize( ("key", "config", "error"), [ + # Raises vol.Invalid ( "trigger", {"platform": "non_existing", "event_type": "hello"}, "Invalid platform 'non_existing' specified", ), + # Raises vol.Invalid ( "condition", { @@ -2562,6 +2568,20 @@ async def test_validate_config_works( "@ data[0]" ), ), + # Raises HomeAssistantError + ( + "condition", + { + "above": 50, + "condition": "device", + "device_id": "a51a57e5af051eb403d56eb9e6fd691c", + "domain": "sensor", + "entity_id": "7d18a157b7c00adbf2982ea7de0d0362", + "type": "is_carbon_dioxide", + }, + "Unknown device 'a51a57e5af051eb403d56eb9e6fd691c'", + ), + # Raises vol.Invalid ( "action", {"non_existing": "domain_test.test_service"}, @@ -2570,13 +2590,15 @@ async def test_validate_config_works( ], ) async def test_validate_config_invalid( - websocket_client: MockHAClientWebSocket, key, config, error + websocket_client: MockHAClientWebSocket, + key: str, + config: dict[str, Any], + error: str, ) -> None: """Test config validation.""" - await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) + await websocket_client.send_json_auto_id({"type": "validate_config", key: config}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == {key: {"valid": False, "error": error}} diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 6ce46a5d9fe..794dd410661 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -294,8 +294,6 @@ async def test_pending_msg_peak_recovery( instance._send_message({}) instance._handle_task.cancel() - msg = await websocket_client.receive() - assert msg.type == WSMsgType.TEXT msg = await websocket_client.receive() assert msg.type is WSMsgType.CLOSE assert "Client unable to keep up with pending messages" not in caplog.text diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 6294b6a2628..cb8a026fe0d 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -96,9 +96,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: message = _state_diff_event(last_state_event) assert message == { "c": { - "light.window": { - "+": {"lc": new_state.last_changed.timestamp(), "s": "off"} - } + "light.window": {"+": {"lc": new_state.last_changed_timestamp, "s": "off"}} } } @@ -117,7 +115,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: "light.window": { "+": { "c": {"parent_id": "new-parent-id"}, - "lc": new_state.last_changed.timestamp(), + "lc": new_state.last_changed_timestamp, "s": "red", } } @@ -144,7 +142,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: "parent_id": "another-new-parent-id", "user_id": "new-user-id", }, - "lc": new_state.last_changed.timestamp(), + "lc": new_state.last_changed_timestamp, "s": "green", } } @@ -168,7 +166,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: "light.window": { "+": { "c": {"user_id": "another-new-user-id"}, - "lc": new_state.last_changed.timestamp(), + "lc": new_state.last_changed_timestamp, "s": "blue", } } @@ -194,7 +192,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: "light.window": { "+": { "c": "id-new", - "lc": new_state.last_changed.timestamp(), + "lc": new_state.last_changed_timestamp, "s": "yellow", } } @@ -216,7 +214,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: "+": { "a": {"new": "attr"}, "c": {"id": new_context.id, "parent_id": None, "user_id": None}, - "lc": new_state.last_changed.timestamp(), + "lc": new_state.last_changed_timestamp, "s": "purple", } } @@ -232,7 +230,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: assert message == { "c": { "light.window": { - "+": {"lc": new_state.last_changed.timestamp(), "s": "green"}, + "+": {"lc": new_state.last_changed_timestamp, "s": "green"}, "-": {"a": ["new"]}, } } @@ -254,7 +252,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: "light.window": { "+": { "a": {"list_attr": ["a", "b", "c", "d"], "list_attr_2": ["a", "b"]}, - "lu": new_state.last_updated.timestamp(), + "lu": new_state.last_updated_timestamp, } } } @@ -275,7 +273,7 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: "light.window": { "+": { "a": {"list_attr": ["a", "b", "c", "e"]}, - "lu": new_state.last_updated.timestamp(), + "lu": new_state.last_updated_timestamp, }, "-": {"a": ["list_attr_2"]}, } diff --git a/tests/components/websocket_api/test_sensor.py b/tests/components/websocket_api/test_sensor.py index 72b39b39354..3af02dc8f2b 100644 --- a/tests/components/websocket_api/test_sensor.py +++ b/tests/components/websocket_api/test_sensor.py @@ -1,8 +1,6 @@ """Test cases for the API stream sensor.""" -from homeassistant.auth.providers.legacy_api_password import ( - LegacyApiPasswordAuthProvider, -) +from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.bootstrap import async_setup_component from homeassistant.components.websocket_api.auth import TYPE_AUTH_REQUIRED from homeassistant.components.websocket_api.http import URL @@ -17,7 +15,7 @@ async def test_websocket_api( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, hass_access_token: str, - legacy_auth: LegacyApiPasswordAuthProvider, + local_auth: HassAuthProvider, ) -> None: """Test API streams.""" await async_setup_component( diff --git a/tests/components/wemo/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index fd2bbed4371..6700b00ec38 100644 --- a/tests/components/wemo/entity_test_helpers.py +++ b/tests/components/wemo/entity_test_helpers.py @@ -7,7 +7,7 @@ import asyncio import threading from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN -from homeassistant.components.wemo import wemo_device +from homeassistant.components.wemo.coordinator import async_get_coordinator from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -94,7 +94,7 @@ async def test_async_update_locked_callback_and_update( When a state update is received via a callback from the device at the same time as hass is calling `async_update`, verify that only one of the updates proceeds. """ - coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + coordinator = async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) callback = _perform_registry_callback(coordinator) update = _perform_async_update(coordinator) @@ -105,7 +105,7 @@ async def test_async_update_locked_multiple_updates( hass: HomeAssistant, pywemo_device, wemo_entity ) -> None: """Test that two hass async_update state updates do not proceed at the same time.""" - coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + coordinator = async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) update = _perform_async_update(coordinator) await _async_multiple_call_helper(hass, pywemo_device, update, update) @@ -115,7 +115,7 @@ async def test_async_update_locked_multiple_callbacks( hass: HomeAssistant, pywemo_device, wemo_entity ) -> None: """Test that two device callback state updates do not proceed at the same time.""" - coordinator = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + coordinator = async_get_coordinator(hass, wemo_entity.device_id) await async_setup_component(hass, HA_DOMAIN, {}) callback = _perform_registry_callback(coordinator) await _async_multiple_call_helper(hass, pywemo_device, callback, callback) diff --git a/tests/components/wemo/test_config_flow.py b/tests/components/wemo/test_config_flow.py index 6eaa32b960e..1f89c26e4d1 100644 --- a/tests/components/wemo/test_config_flow.py +++ b/tests/components/wemo/test_config_flow.py @@ -3,7 +3,7 @@ from dataclasses import asdict from homeassistant.components.wemo.const import DOMAIN -from homeassistant.components.wemo.wemo_device import Options +from homeassistant.components.wemo.coordinator import Options from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_coordinator.py similarity index 90% rename from tests/components/wemo/test_wemo_device.py rename to tests/components/wemo/test_coordinator.py index 7d23b590b57..198b132bbd0 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_coordinator.py @@ -10,8 +10,9 @@ from pywemo.exceptions import ActionException, PyWeMoException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant import runner -from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, wemo_device +from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC from homeassistant.components.wemo.const import DOMAIN, WEMO_SUBSCRIPTION_EVENT +from homeassistant.components.wemo.coordinator import Options, async_get_coordinator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import UpdateFailed @@ -50,7 +51,7 @@ async def test_async_register_device_longpress_fails( await hass.async_block_till_done() device_entries = list(device_registry.devices.values()) assert len(device_entries) == 1 - device = wemo_device.async_get_coordinator(hass, device_entries[0].id) + device = async_get_coordinator(hass, device_entries[0].id) assert device.supports_long_press is False @@ -58,7 +59,7 @@ async def test_long_press_event( hass: HomeAssistant, pywemo_registry, wemo_entity ) -> None: """Device fires a long press event.""" - device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device = async_get_coordinator(hass, wemo_entity.device_id) got_event = asyncio.Event() event_data = {} @@ -93,7 +94,7 @@ async def test_subscription_callback( hass: HomeAssistant, pywemo_registry, wemo_entity ) -> None: """Device processes a registry subscription callback.""" - device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device = async_get_coordinator(hass, wemo_entity.device_id) device.last_update_success = False got_callback = asyncio.Event() @@ -117,7 +118,7 @@ async def test_subscription_update_action_exception( hass: HomeAssistant, pywemo_device, wemo_entity ) -> None: """Device handles ActionException on get_state properly.""" - device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device = async_get_coordinator(hass, wemo_entity.device_id) device.last_update_success = True pywemo_device.subscription_update.return_value = False @@ -137,7 +138,7 @@ async def test_subscription_update_exception( hass: HomeAssistant, pywemo_device, wemo_entity ) -> None: """Device handles Exception on get_state properly.""" - device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device = async_get_coordinator(hass, wemo_entity.device_id) device.last_update_success = True pywemo_device.subscription_update.return_value = False @@ -157,7 +158,7 @@ async def test_async_update_data_subscribed( hass: HomeAssistant, pywemo_registry, pywemo_device, wemo_entity ) -> None: """No update happens when the device is subscribed.""" - device = wemo_device.async_get_coordinator(hass, wemo_entity.device_id) + device = async_get_coordinator(hass, wemo_entity.device_id) pywemo_registry.is_subscribed.return_value = True pywemo_device.get_state.reset_mock() await device._async_update_data() @@ -190,25 +191,25 @@ async def test_dli_device_info( async def test_options_enable_subscription_false( - hass, pywemo_registry, pywemo_device, wemo_entity -): + hass: HomeAssistant, pywemo_registry, pywemo_device, wemo_entity +) -> None: """Test setting Options.enable_subscription = False.""" config_entry = hass.config_entries.async_get_entry(wemo_entity.config_entry_id) assert hass.config_entries.async_update_entry( config_entry, - options=asdict( - wemo_device.Options(enable_subscription=False, enable_long_press=False) - ), + options=asdict(Options(enable_subscription=False, enable_long_press=False)), ) await hass.async_block_till_done() pywemo_registry.unregister.assert_called_once_with(pywemo_device) -async def test_options_enable_long_press_false(hass, pywemo_device, wemo_entity): +async def test_options_enable_long_press_false( + hass: HomeAssistant, pywemo_device, wemo_entity +) -> None: """Test setting Options.enable_long_press = False.""" config_entry = hass.config_entries.async_get_entry(wemo_entity.config_entry_id) assert hass.config_entries.async_update_entry( - config_entry, options=asdict(wemo_device.Options(enable_long_press=False)) + config_entry, options=asdict(Options(enable_long_press=False)) ) await hass.async_block_till_done() pywemo_device.remove_long_press_virtual_device.assert_called_once_with() diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index bf41e703190..48d8f8eac03 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -42,7 +42,7 @@ async def test_config_no_static(hass: HomeAssistant) -> None: async def test_static_duplicate_static_entry( - hass: HomeAssistant, pywemo_device + hass: HomeAssistant, entity_registry: er.EntityRegistry, pywemo_device ) -> None: """Duplicate static entries are merged into a single entity.""" static_config_entry = f"{MOCK_HOST}:{MOCK_PORT}" @@ -60,12 +60,13 @@ async def test_static_duplicate_static_entry( }, ) await hass.async_block_till_done() - entity_reg = er.async_get(hass) - entity_entries = list(entity_reg.entities.values()) + entity_entries = list(entity_registry.entities.values()) assert len(entity_entries) == 1 -async def test_static_config_with_port(hass: HomeAssistant, pywemo_device) -> None: +async def test_static_config_with_port( + hass: HomeAssistant, entity_registry: er.EntityRegistry, pywemo_device +) -> None: """Static device with host and port is added and removed.""" assert await async_setup_component( hass, @@ -78,12 +79,13 @@ async def test_static_config_with_port(hass: HomeAssistant, pywemo_device) -> No }, ) await hass.async_block_till_done() - entity_reg = er.async_get(hass) - entity_entries = list(entity_reg.entities.values()) + entity_entries = list(entity_registry.entities.values()) assert len(entity_entries) == 1 -async def test_static_config_without_port(hass: HomeAssistant, pywemo_device) -> None: +async def test_static_config_without_port( + hass: HomeAssistant, entity_registry: er.EntityRegistry, pywemo_device +) -> None: """Static device with host and no port is added and removed.""" assert await async_setup_component( hass, @@ -96,13 +98,13 @@ async def test_static_config_without_port(hass: HomeAssistant, pywemo_device) -> }, ) await hass.async_block_till_done() - entity_reg = er.async_get(hass) - entity_entries = list(entity_reg.entities.values()) + entity_entries = list(entity_registry.entities.values()) assert len(entity_entries) == 1 async def test_reload_config_entry( hass: HomeAssistant, + entity_registry: er.EntityRegistry, pywemo_device: pywemo.WeMoDevice, pywemo_registry: pywemo.SubscriptionRegistry, ) -> None: @@ -127,7 +129,6 @@ async def test_reload_config_entry( pywemo_registry.register.assert_called_once_with(pywemo_device) pywemo_registry.register.reset_mock() - entity_registry = er.async_get(hass) entity_entries = list(entity_registry.entities.values()) assert len(entity_entries) == 1 await entity_test_helpers.test_turn_off_state( @@ -165,7 +166,9 @@ async def test_static_config_with_invalid_host(hass: HomeAssistant) -> None: async def test_static_with_upnp_failure( - hass: HomeAssistant, pywemo_device: pywemo.WeMoDevice + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + pywemo_device: pywemo.WeMoDevice, ) -> None: """Device that fails to get state is not added.""" pywemo_device.get_state.side_effect = pywemo.exceptions.ActionException("Failed") @@ -180,13 +183,14 @@ async def test_static_with_upnp_failure( }, ) await hass.async_block_till_done() - entity_reg = er.async_get(hass) - entity_entries = list(entity_reg.entities.values()) + entity_entries = list(entity_registry.entities.values()) assert len(entity_entries) == 0 pywemo_device.get_state.assert_called_once() -async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None: +async def test_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry, pywemo_registry +) -> None: """Verify that discovery dispatches devices to the platform for setup.""" def create_device(counter): @@ -240,8 +244,7 @@ async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None: assert mock_discover_statics.call_count == 3 # Verify that the expected number of devices were setup. - entity_reg = er.async_get(hass) - entity_entries = list(entity_reg.entities.values()) + entity_entries = list(entity_registry.entities.values()) assert len(entity_entries) == 3 # Verify that hass stops cleanly. diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index e386012265c..a5926f55a94 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -18,7 +18,7 @@ MOCK_SAID4 = "said4" name="region", params=[("EU", Region.EU), ("US", Region.US)], ) -def fixture_region(request): +def fixture_region(request: pytest.FixtureRequest) -> tuple[str, Region]: """Return a region for input.""" return request.param @@ -31,7 +31,7 @@ def fixture_region(request): ("Maytag", Brand.Maytag), ], ) -def fixture_brand(request): +def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: """Return a brand for input.""" return request.param diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 21c4501e6d0..18016bd9c67 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -74,6 +74,7 @@ async def test_no_appliances( async def test_static_attributes( hass: HomeAssistant, + entity_registry: er.EntityRegistry, mock_aircon1_api: MagicMock, mock_aircon_api_instances: MagicMock, ) -> None: @@ -81,7 +82,7 @@ async def test_static_attributes( await init_integration(hass) for entity_id in ("climate.said1", "climate.said2"): - entry = er.async_get(hass).async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == entity_id.split(".")[1] diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index fc509f264c5..6af88c8a9f3 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -81,7 +81,7 @@ async def test_dryer_sensor_values( state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None - state_id = f"{entity_id.split('_')[0]}_end_time" + state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" state = hass.states.get(state_id) assert state.state == thetimestamp.isoformat() @@ -151,11 +151,11 @@ async def test_washer_sensor_values( state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None - state_id = f"{entity_id.split('_')[0]}_end_time" + state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" state = hass.states.get(state_id) assert state.state == thetimestamp.isoformat() - state_id = f"{entity_id.split('_')[0]}_detergent_level" + state_id = f"{entity_id.split('_', maxsplit=1)[0]}_detergent_level" entry = entity_registry.async_get(state_id) assert entry assert entry.disabled diff --git a/tests/components/whois/conftest.py b/tests/components/whois/conftest.py index 457c06db598..5fe420abb92 100644 --- a/tests/components/whois/conftest.py +++ b/tests/components/whois/conftest.py @@ -2,11 +2,11 @@ from __future__ import annotations -from collections.abc import Generator from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant.components.whois.const import DOMAIN from homeassistant.const import CONF_DOMAIN @@ -30,7 +30,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.whois.async_setup_entry", return_value=True @@ -39,7 +39,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_whois() -> Generator[MagicMock, None, None]: +def mock_whois() -> Generator[MagicMock]: """Return a mocked query.""" with ( patch( @@ -68,7 +68,7 @@ def mock_whois() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_whois_missing_some_attrs() -> Generator[Mock, None, None]: +def mock_whois_missing_some_attrs() -> Generator[Mock]: """Return a mocked query that only sets admin.""" class LimitedWhoisMock: diff --git a/tests/components/wiffi/conftest.py b/tests/components/wiffi/conftest.py index 644c3c460ed..5f16d676e81 100644 --- a/tests/components/wiffi/conftest.py +++ b/tests/components/wiffi/conftest.py @@ -1,13 +1,13 @@ """Configuration for Wiffi tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.wiffi.async_setup_entry", return_value=True diff --git a/tests/components/wilight/test_cover.py b/tests/components/wilight/test_cover.py index 93da57a7f7f..5b89293032f 100644 --- a/tests/components/wilight/test_cover.py +++ b/tests/components/wilight/test_cover.py @@ -58,6 +58,7 @@ def mock_dummy_device_from_host_light_fan(): async def test_loading_cover( hass: HomeAssistant, + entity_registry: er.EntityRegistry, dummy_device_from_host_cover, ) -> None: """Test the WiLight configuration entry loading.""" @@ -66,8 +67,6 @@ async def test_loading_cover( assert entry assert entry.unique_id == WILIGHT_ID - entity_registry = er.async_get(hass) - # First segment of the strip state = hass.states.get("cover.wl000000000099_1") assert state diff --git a/tests/components/wilight/test_fan.py b/tests/components/wilight/test_fan.py index 7b2e9550c53..7eb555460a6 100644 --- a/tests/components/wilight/test_fan.py +++ b/tests/components/wilight/test_fan.py @@ -58,6 +58,7 @@ def mock_dummy_device_from_host_light_fan(): async def test_loading_light_fan( hass: HomeAssistant, + entity_registry: er.EntityRegistry, dummy_device_from_host_light_fan, ) -> None: """Test the WiLight configuration entry loading.""" @@ -66,8 +67,6 @@ async def test_loading_light_fan( assert entry assert entry.unique_id == WILIGHT_ID - entity_registry = er.async_get(hass) - # First segment of the strip state = hass.states.get("fan.wl000000000099_2") assert state diff --git a/tests/components/wilight/test_light.py b/tests/components/wilight/test_light.py index 44c0060c5bb..67476848a5c 100644 --- a/tests/components/wilight/test_light.py +++ b/tests/components/wilight/test_light.py @@ -131,6 +131,7 @@ def mock_dummy_device_from_host_color(): async def test_loading_light( hass: HomeAssistant, + entity_registry: er.EntityRegistry, dummy_device_from_host_light_fan, dummy_get_components_from_model_light, ) -> None: @@ -142,8 +143,6 @@ async def test_loading_light( assert entry assert entry.unique_id == WILIGHT_ID - entity_registry = er.async_get(hass) - # First segment of the strip state = hass.states.get("light.wl000000000099_1") assert state diff --git a/tests/components/wilight/test_switch.py b/tests/components/wilight/test_switch.py index 6026cec9847..7140a0780ef 100644 --- a/tests/components/wilight/test_switch.py +++ b/tests/components/wilight/test_switch.py @@ -64,6 +64,7 @@ def mock_dummy_device_from_host_switch(): async def test_loading_switch( hass: HomeAssistant, + entity_registry: er.EntityRegistry, dummy_device_from_host_switch, ) -> None: """Test the WiLight configuration entry loading.""" @@ -72,8 +73,6 @@ async def test_loading_switch( assert entry assert entry.unique_id == WILIGHT_ID - entity_registry = er.async_get(hass) - # First segment of the strip state = hass.states.get("switch.wl000000000099_1_watering") assert state @@ -261,5 +260,4 @@ async def test_switch_services( blocking=True, ) - await hass.async_block_till_done() assert str(exc_info.value) == "Entity is not a WiLight valve switch" diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 66dd65efccb..dfb0658b64a 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -16,8 +16,7 @@ from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_array_fixture -from tests.components.withings import ( +from . import ( load_activity_fixture, load_goals_fixture, load_measurements_fixture, @@ -25,6 +24,8 @@ from tests.components.withings import ( load_workout_fixture, ) +from tests.common import MockConfigEntry, load_json_array_fixture + CLIENT_ID = "1234" CLIENT_SECRET = "5678" SCOPES = [ diff --git a/tests/components/withings/fixtures/measurements.json b/tests/components/withings/fixtures/measurements.json index 03222521877..5ce14b6c774 100644 --- a/tests/components/withings/fixtures/measurements.json +++ b/tests/components/withings/fixtures/measurements.json @@ -323,5 +323,484 @@ "modelid": 45, "model": "BPM Connect", "comment": null + }, + + { + "grpid": 5149666502, + "attrib": 0, + "date": 1560000000, + "created": 1560000000, + "modified": 1560000000, + "category": 1, + "deviceid": "51e8d0e7d16da9ff673accf10958ca94fea55d6e", + "hash_deviceid": "51e8d0e7d16da9ff673accf10958ca94fea55d6e", + "measures": [ + { + "value": 95854, + "type": 1, + "unit": -3, + "algo": 218235904, + "fm": 3 + }, + { + "value": 7718, + "type": 5, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 7718, + "type": 5, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 1866, + "type": 8, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 1866, + "type": 8, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 7338, + "type": 76, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 5205, + "type": 77, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 380, + "type": 88, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 2162, + "type": 168, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 3043, + "type": 169, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 32, + "type": 170, + "unit": -1, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 4000, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 12 + }, + { + "value": 1350, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 10 + }, + { + "value": 469, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 3 + }, + { + "value": 1406, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 11 + }, + { + "value": 491, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 2 + }, + { + "value": 1209, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 12 + }, + { + "value": 241, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 10 + }, + { + "value": 107, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 3 + }, + { + "value": 207, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 11 + }, + { + "value": 99, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 2 + }, + { + "value": 3823, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 12 + }, + { + "value": 1277, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 10 + }, + { + "value": 442, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 3 + }, + { + "value": 1330, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 11 + }, + { + "value": 463, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 2 + }, + { + "value": 2263, + "type": 226, + "unit": 0, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 19467, + "type": 6, + "unit": -3 + } + ], + "modelid": 10, + "model": "Body Scan", + "comment": null + }, + { + "grpid": 5156052100, + "attrib": 0, + "date": 1560000000, + "created": 1560000000, + "modified": 1560000000, + "category": 1, + "deviceid": "51e8d0e7d16da9ff673accf10958ca94fea55d6e", + "hash_deviceid": "51e8d0e7d16da9ff673accf10958ca94fea55d6e", + "measures": [ + { + "value": 96440, + "type": 1, + "unit": -3, + "algo": 218235904, + "fm": 3 + }, + { + "value": 7863, + "type": 5, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 7863, + "type": 5, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 1780, + "type": 8, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 1780, + "type": 8, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 7475, + "type": 76, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 5296, + "type": 77, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 387, + "type": 88, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 2175, + "type": 168, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 3120, + "type": 169, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 31, + "type": 170, + "unit": -1, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 4049, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 12 + }, + { + "value": 1384, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 10 + }, + { + "value": 505, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 3 + }, + { + "value": 1405, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 11 + }, + { + "value": 518, + "type": 173, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 2 + }, + { + "value": 1099, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 12 + }, + { + "value": 245, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 10 + }, + { + "value": 103, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 3 + }, + { + "value": 233, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 11 + }, + { + "value": 99, + "type": 174, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 2 + }, + { + "value": 3870, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 12 + }, + { + "value": 1309, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 10 + }, + { + "value": 477, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 3 + }, + { + "value": 1329, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 11 + }, + { + "value": 489, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 2 + }, + { + "value": 489, + "type": 175, + "unit": -2, + "algo": 218235904, + "fm": 3, + "position": 1 + }, + { + "value": 2308, + "type": 226, + "unit": 0, + "algo": 218235904, + "fm": 3, + "position": 7 + }, + { + "value": 18457, + "type": 6, + "unit": -3 + } + ], + "modelid": 10, + "model": "Body Scan", + "comment": null } ] diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index 3dc7e824230..df2a3b95388 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -4,32 +4,59 @@ 'has_cloudhooks': True, 'has_valid_external_webhook_url': True, 'received_activity_data': False, - 'received_measurements': list([ - 1, - 8, - 5, - 76, - 88, - 4, - 12, - 71, - 73, - 6, - 9, - 10, - 11, - 54, - 77, - 91, - 123, - 155, - 168, - 169, - 198, - 197, - 196, - 170, - ]), + 'received_measurements': dict({ + 'non_positional': list([ + 'weight', + 'fat_free_mass', + 'fat_mass_weight', + 'muscle_mass', + 'hydration', + 'bone_mass', + 'extracellular_water', + 'intracellular_water', + 'visceral_fat', + 'unknown', + 'fat_ratio', + 'height', + 'temperature', + 'body_temperature', + 'skin_temperature', + 'diastolic_blood_pressure', + 'systolic_blood_pressure', + 'heart_rate', + 'sp02', + 'pulse_wave_velocity', + 'vo2', + 'vascular_age', + 'electrodermal_activity_right_foot', + 'electrodermal_activity_left_foot', + 'electrodermal_activity_feet', + ]), + 'positional': dict({ + 'fat_free_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + ]), + 'fat_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + ]), + 'muscle_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + 'left_wrist', + ]), + }), + }), 'received_sleep_data': True, 'received_workout_data': True, 'webhooks_connected': True, @@ -40,32 +67,59 @@ 'has_cloudhooks': False, 'has_valid_external_webhook_url': False, 'received_activity_data': False, - 'received_measurements': list([ - 1, - 8, - 5, - 76, - 88, - 4, - 12, - 71, - 73, - 6, - 9, - 10, - 11, - 54, - 77, - 91, - 123, - 155, - 168, - 169, - 198, - 197, - 196, - 170, - ]), + 'received_measurements': dict({ + 'non_positional': list([ + 'weight', + 'fat_free_mass', + 'fat_mass_weight', + 'muscle_mass', + 'hydration', + 'bone_mass', + 'extracellular_water', + 'intracellular_water', + 'visceral_fat', + 'unknown', + 'fat_ratio', + 'height', + 'temperature', + 'body_temperature', + 'skin_temperature', + 'diastolic_blood_pressure', + 'systolic_blood_pressure', + 'heart_rate', + 'sp02', + 'pulse_wave_velocity', + 'vo2', + 'vascular_age', + 'electrodermal_activity_right_foot', + 'electrodermal_activity_left_foot', + 'electrodermal_activity_feet', + ]), + 'positional': dict({ + 'fat_free_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + ]), + 'fat_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + ]), + 'muscle_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + 'left_wrist', + ]), + }), + }), 'received_sleep_data': True, 'received_workout_data': True, 'webhooks_connected': False, @@ -76,32 +130,59 @@ 'has_cloudhooks': False, 'has_valid_external_webhook_url': True, 'received_activity_data': False, - 'received_measurements': list([ - 1, - 8, - 5, - 76, - 88, - 4, - 12, - 71, - 73, - 6, - 9, - 10, - 11, - 54, - 77, - 91, - 123, - 155, - 168, - 169, - 198, - 197, - 196, - 170, - ]), + 'received_measurements': dict({ + 'non_positional': list([ + 'weight', + 'fat_free_mass', + 'fat_mass_weight', + 'muscle_mass', + 'hydration', + 'bone_mass', + 'extracellular_water', + 'intracellular_water', + 'visceral_fat', + 'unknown', + 'fat_ratio', + 'height', + 'temperature', + 'body_temperature', + 'skin_temperature', + 'diastolic_blood_pressure', + 'systolic_blood_pressure', + 'heart_rate', + 'sp02', + 'pulse_wave_velocity', + 'vo2', + 'vascular_age', + 'electrodermal_activity_right_foot', + 'electrodermal_activity_left_foot', + 'electrodermal_activity_feet', + ]), + 'positional': dict({ + 'fat_free_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + ]), + 'fat_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + ]), + 'muscle_mass_for_segments': list([ + 'torso', + 'left_leg', + 'left_arm', + 'right_leg', + 'right_arm', + 'left_wrist', + ]), + }), + }), 'received_sleep_data': True, 'received_workout_data': True, 'webhooks_connected': True, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 37635ece403..70a86c79038 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -965,6 +965,276 @@ 'state': '60', }) # --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_left_arm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_free_mass_in_left_arm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat free mass in left arm', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_free_mass_for_segments_left_arm', + 'unique_id': 'withings_12345_fat_free_mass_for_segments_left_arm', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_left_arm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass in left arm', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass_in_left_arm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.05', + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_left_leg-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_free_mass_in_left_leg', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat free mass in left leg', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_free_mass_for_segments_left_leg', + 'unique_id': 'withings_12345_fat_free_mass_for_segments_left_leg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_left_leg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass in left leg', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass_in_left_leg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.84', + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_right_arm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_free_mass_in_right_arm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat free mass in right arm', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_free_mass_for_segments_right_arm', + 'unique_id': 'withings_12345_fat_free_mass_for_segments_right_arm', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_right_arm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass in right arm', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass_in_right_arm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.18', + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_right_leg-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_free_mass_in_right_leg', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat free mass in right leg', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_free_mass_for_segments_right_leg', + 'unique_id': 'withings_12345_fat_free_mass_for_segments_right_leg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_right_leg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass in right leg', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass_in_right_leg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.05', + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_torso-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_free_mass_in_torso', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat free mass in torso', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_free_mass_for_segments_torso', + 'unique_id': 'withings_12345_fat_free_mass_for_segments_torso', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass_in_torso-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass in torso', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass_in_torso', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.49', + }) +# --- # name: test_all_entities[sensor.henk_fat_mass-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1019,6 +1289,276 @@ 'state': '5', }) # --- +# name: test_all_entities[sensor.henk_fat_mass_in_left_arm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_mass_in_left_arm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat mass in left arm', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_mass_for_segments_left_arm', + 'unique_id': 'withings_12345_fat_mass_for_segments_left_arm', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_left_arm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass in left arm', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_in_left_arm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.03', + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_left_leg-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_mass_in_left_leg', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat mass in left leg', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_mass_for_segments_left_leg', + 'unique_id': 'withings_12345_fat_mass_for_segments_left_leg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_left_leg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass in left leg', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_in_left_leg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.45', + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_right_arm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_mass_in_right_arm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat mass in right arm', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_mass_for_segments_right_arm', + 'unique_id': 'withings_12345_fat_mass_for_segments_right_arm', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_right_arm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass in right arm', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_in_right_arm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.99', + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_right_leg-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_mass_in_right_leg', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat mass in right leg', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_mass_for_segments_right_leg', + 'unique_id': 'withings_12345_fat_mass_for_segments_right_leg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_right_leg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass in right leg', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_in_right_leg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.33', + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_torso-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_fat_mass_in_torso', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fat mass in torso', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fat_mass_for_segments_torso', + 'unique_id': 'withings_12345_fat_mass_for_segments_torso', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass_in_torso-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass in torso', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_in_torso', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.99', + }) +# --- # name: test_all_entities[sensor.henk_fat_ratio-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1940,6 +2480,276 @@ 'state': '50', }) # --- +# name: test_all_entities[sensor.henk_muscle_mass_in_left_arm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_muscle_mass_in_left_arm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Muscle mass in left arm', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muscle_mass_for_segments_left_arm', + 'unique_id': 'withings_12345_muscle_mass_for_segments_left_arm', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_left_arm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass in left arm', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass_in_left_arm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.77', + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_left_leg-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_muscle_mass_in_left_leg', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Muscle mass in left leg', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muscle_mass_for_segments_left_leg', + 'unique_id': 'withings_12345_muscle_mass_for_segments_left_leg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_left_leg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass in left leg', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass_in_left_leg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.09', + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_right_arm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_muscle_mass_in_right_arm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Muscle mass in right arm', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muscle_mass_for_segments_right_arm', + 'unique_id': 'withings_12345_muscle_mass_for_segments_right_arm', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_right_arm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass in right arm', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass_in_right_arm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.89', + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_right_leg-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_muscle_mass_in_right_leg', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Muscle mass in right leg', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muscle_mass_for_segments_right_leg', + 'unique_id': 'withings_12345_muscle_mass_for_segments_right_leg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_right_leg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass in right leg', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass_in_right_leg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.29', + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_torso-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.henk_muscle_mass_in_torso', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Muscle mass in torso', + 'platform': 'withings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muscle_mass_for_segments_torso', + 'unique_id': 'withings_12345_muscle_mass_for_segments_torso', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass_in_torso-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass in torso', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass_in_torso', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.7', + }) +# --- # name: test_all_entities[sensor.henk_pause_during_last_workout-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/withings/test_calendar.py b/tests/components/withings/test_calendar.py index 060a1baa54d..c04a93ba43d 100644 --- a/tests/components/withings/test_calendar.py +++ b/tests/components/withings/test_calendar.py @@ -9,10 +9,9 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import load_workout_fixture +from . import load_workout_fixture, setup_integration from tests.common import MockConfigEntry, async_fire_time_changed -from tests.components.withings import setup_integration from tests.typing import ClientSessionGenerator diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 9f4b265ed4f..20bef90a31e 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, patch +import pytest + from homeassistant.components.withings.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant @@ -16,10 +18,10 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, aioclient_mock: AiohttpClientMocker, ) -> None: """Check full flow.""" @@ -79,10 +81,10 @@ async def test_full_flow( assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, withings: AsyncMock, polling_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, @@ -132,13 +134,13 @@ async def test_config_non_unique_profile( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_reauth_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, polling_config_entry: MockConfigEntry, withings: AsyncMock, - current_request_with_host, ) -> None: """Test reauth an existing profile reauthenticates the config entry.""" await setup_integration(hass, polling_config_entry) @@ -194,13 +196,13 @@ async def test_config_reauth_profile( assert result["reason"] == "reauth_successful" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, polling_config_entry: MockConfigEntry, withings: AsyncMock, - current_request_with_host, ) -> None: """Test reauth with wrong account.""" await setup_integration(hass, polling_config_entry) @@ -256,13 +258,13 @@ async def test_config_reauth_wrong_account( assert result["reason"] == "wrong_account" +@pytest.mark.usefixtures("current_request_with_host") async def test_config_flow_with_invalid_credentials( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, polling_config_entry: MockConfigEntry, withings: AsyncMock, - current_request_with_host, ) -> None: """Test flow with invalid credentials.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/withings/test_diagnostics.py b/tests/components/withings/test_diagnostics.py index d607584df7b..51f54b2ab17 100644 --- a/tests/components/withings/test_diagnostics.py +++ b/tests/components/withings/test_diagnostics.py @@ -7,9 +7,10 @@ from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant +from . import prepare_webhook_setup, setup_integration + from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.components.withings import prepare_webhook_setup, setup_integration from tests.typing import ClientSessionGenerator diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 3ade0fb7c3a..0375d1869d9 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -544,6 +544,7 @@ async def test_cloud_disconnect_retry( ), # Success, we ignore the user_id ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_webhook_post( hass: HomeAssistant, withings: AsyncMock, @@ -551,7 +552,6 @@ async def test_webhook_post( hass_client_no_auth: ClientSessionGenerator, body: dict[str, Any], expected_code: int, - current_request_with_host: None, freezer: FrozenDateTimeFactory, ) -> None: """Test webhook callback.""" diff --git a/tests/components/wiz/test_binary_sensor.py b/tests/components/wiz/test_binary_sensor.py index d9e8d7170c7..c7e5541d91e 100644 --- a/tests/components/wiz/test_binary_sensor.py +++ b/tests/components/wiz/test_binary_sensor.py @@ -21,14 +21,15 @@ from . import ( from tests.common import MockConfigEntry -async def test_binary_sensor_created_from_push_updates(hass: HomeAssistant) -> None: +async def test_binary_sensor_created_from_push_updates( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a binary sensor created from push updates.""" bulb, _ = await async_setup_integration(hass) await async_push_update(hass, bulb, {"mac": FAKE_MAC, "src": "pir", "state": True}) entity_id = "binary_sensor.mock_title_occupancy" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == f"{FAKE_MAC}_occupancy" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -39,7 +40,9 @@ async def test_binary_sensor_created_from_push_updates(hass: HomeAssistant) -> N assert state.state == STATE_OFF -async def test_binary_sensor_restored_from_registry(hass: HomeAssistant) -> None: +async def test_binary_sensor_restored_from_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a binary sensor restored from registry with state unknown.""" entry = MockConfigEntry( domain=wiz.DOMAIN, @@ -49,7 +52,6 @@ async def test_binary_sensor_restored_from_registry(hass: HomeAssistant) -> None entry.add_to_hass(hass) bulb = _mocked_wizlight(None, None, None) - entity_registry = er.async_get(hass) reg_ent = entity_registry.async_get_or_create( Platform.BINARY_SENSOR, wiz.DOMAIN, OCCUPANCY_UNIQUE_ID.format(bulb.mac) ) diff --git a/tests/components/wiz/test_init.py b/tests/components/wiz/test_init.py index c3438aed1b2..78a60c34fdc 100644 --- a/tests/components/wiz/test_init.py +++ b/tests/components/wiz/test_init.py @@ -44,7 +44,7 @@ async def test_cleanup_on_shutdown(hass: HomeAssistant) -> None: _, entry = await async_setup_integration(hass, wizlight=bulb) assert entry.state is ConfigEntryState.LOADED hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) bulb.async_close.assert_called_once() @@ -63,7 +63,7 @@ async def test_cleanup_on_failed_first_update(hass: HomeAssistant) -> None: _patch_wizlight(device=bulb), ): await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.SETUP_RETRY bulb.async_close.assert_called_once() @@ -74,6 +74,7 @@ async def test_wrong_device_now_has_our_ip(hass: HomeAssistant) -> None: bulb.mac = "dddddddddddd" _, entry = await async_setup_integration(hass, wizlight=bulb) assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.async_block_till_done(wait_background_tasks=True) async def test_reload_on_title_change(hass: HomeAssistant) -> None: @@ -81,12 +82,12 @@ async def test_reload_on_title_change(hass: HomeAssistant) -> None: bulb = _mocked_wizlight(None, None, FAKE_SOCKET) _, entry = await async_setup_integration(hass, wizlight=bulb) assert entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with _patch_discovery(), _patch_wizlight(device=bulb): hass.config_entries.async_update_entry(entry, title="Shop Switch") assert entry.title == "Shop Switch" - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( hass.states.get("switch.mock_title").attributes[ATTR_FRIENDLY_NAME] diff --git a/tests/components/wiz/test_light.py b/tests/components/wiz/test_light.py index 48166e941d4..1fb87b30a5f 100644 --- a/tests/components/wiz/test_light.py +++ b/tests/components/wiz/test_light.py @@ -31,21 +31,23 @@ from . import ( ) -async def test_light_unique_id(hass: HomeAssistant) -> None: +async def test_light_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light unique id.""" await async_setup_integration(hass) entity_id = "light.mock_title" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == FAKE_MAC state = hass.states.get(entity_id) assert state.state == STATE_ON -async def test_light_operation(hass: HomeAssistant) -> None: +async def test_light_operation( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test a light operation.""" bulb, _ = await async_setup_integration(hass) entity_id = "light.mock_title" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == FAKE_MAC state = hass.states.get(entity_id) assert state.state == STATE_ON diff --git a/tests/components/wiz/test_number.py b/tests/components/wiz/test_number.py index 9cf10d31904..6bbbdd559cc 100644 --- a/tests/components/wiz/test_number.py +++ b/tests/components/wiz/test_number.py @@ -17,12 +17,13 @@ from . import ( ) -async def test_speed_operation(hass: HomeAssistant) -> None: +async def test_speed_operation( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test changing a speed.""" bulb, _ = await async_setup_integration(hass, bulb_type=FAKE_DUAL_HEAD_RGBWW_BULB) await async_push_update(hass, bulb, {"mac": FAKE_MAC}) entity_id = "number.mock_title_effect_speed" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == f"{FAKE_MAC}_effect_speed" assert hass.states.get(entity_id).state == STATE_UNAVAILABLE @@ -40,12 +41,13 @@ async def test_speed_operation(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == "30.0" -async def test_ratio_operation(hass: HomeAssistant) -> None: +async def test_ratio_operation( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test changing a dual head ratio.""" bulb, _ = await async_setup_integration(hass, bulb_type=FAKE_DUAL_HEAD_RGBWW_BULB) await async_push_update(hass, bulb, {"mac": FAKE_MAC}) entity_id = "number.mock_title_dual_head_ratio" - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"{FAKE_MAC}_dual_head_ratio" ) diff --git a/tests/components/wiz/test_sensor.py b/tests/components/wiz/test_sensor.py index 522eb5c7cba..cafc602541f 100644 --- a/tests/components/wiz/test_sensor.py +++ b/tests/components/wiz/test_sensor.py @@ -17,13 +17,14 @@ from . import ( ) -async def test_signal_strength(hass: HomeAssistant) -> None: +async def test_signal_strength( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test signal strength.""" bulb, entry = await async_setup_integration( hass, bulb_type=FAKE_DUAL_HEAD_RGBWW_BULB ) entity_id = "sensor.mock_title_signal_strength" - entity_registry = er.async_get(hass) reg_entry = entity_registry.async_get(entity_id) assert reg_entry.unique_id == f"{FAKE_MAC}_rssi" updated_entity = entity_registry.async_update_entity( @@ -41,7 +42,9 @@ async def test_signal_strength(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == "-50" -async def test_power_monitoring(hass: HomeAssistant) -> None: +async def test_power_monitoring( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test power monitoring.""" socket = _mocked_wizlight(None, None, FAKE_SOCKET_WITH_POWER_MONITORING) socket.power_monitoring = None @@ -50,7 +53,6 @@ async def test_power_monitoring(hass: HomeAssistant) -> None: hass, wizlight=socket, bulb_type=FAKE_SOCKET_WITH_POWER_MONITORING ) entity_id = "sensor.mock_title_power" - entity_registry = er.async_get(hass) reg_entry = entity_registry.async_get(entity_id) assert reg_entry.unique_id == f"{FAKE_MAC}_power" updated_entity = entity_registry.async_update_entity( diff --git a/tests/components/wiz/test_switch.py b/tests/components/wiz/test_switch.py index e728ff4a645..d77588bbd6b 100644 --- a/tests/components/wiz/test_switch.py +++ b/tests/components/wiz/test_switch.py @@ -20,11 +20,12 @@ from . import FAKE_MAC, FAKE_SOCKET, async_push_update, async_setup_integration from tests.common import async_fire_time_changed -async def test_switch_operation(hass: HomeAssistant) -> None: +async def test_switch_operation( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test switch operation.""" switch, _ = await async_setup_integration(hass, bulb_type=FAKE_SOCKET) entity_id = "switch.mock_title" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == FAKE_MAC assert hass.states.get(entity_id).state == STATE_ON @@ -45,11 +46,12 @@ async def test_switch_operation(hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_ON -async def test_update_fails(hass: HomeAssistant) -> None: +async def test_update_fails( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test switch update fails when push updates are not working.""" switch, _ = await async_setup_integration(hass, bulb_type=FAKE_SOCKET) entity_id = "switch.mock_title" - entity_registry = er.async_get(hass) assert entity_registry.async_get(entity_id).unique_id == FAKE_MAC assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index d2f124a517b..0d839fc8666 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -1,10 +1,10 @@ """Fixtures for WLED integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from typing_extensions import Generator from wled import Device as WLEDDevice from homeassistant.components.wled.const import DOMAIN @@ -26,7 +26,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.wled.async_setup_entry", return_value=True @@ -35,7 +35,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_onboarding() -> Generator[MagicMock, None, None]: +def mock_onboarding() -> Generator[MagicMock]: """Mock that Home Assistant is currently onboarding.""" with patch( "homeassistant.components.onboarding.async_is_onboarded", @@ -51,7 +51,7 @@ def device_fixture() -> str: @pytest.fixture -def mock_wled(device_fixture: str) -> Generator[MagicMock, None, None]: +def mock_wled(device_fixture: str) -> Generator[MagicMock]: """Return a mocked WLED client.""" with ( patch( diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr deleted file mode 100644 index b9a083336d2..00000000000 --- a/tests/components/wled/snapshots/test_binary_sensor.ambr +++ /dev/null @@ -1,82 +0,0 @@ -# serializer version: 1 -# name: test_update_available - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'update', - 'friendly_name': 'WLED RGB Light Firmware', - }), - 'context': , - 'entity_id': 'binary_sensor.wled_rgb_light_firmware', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- -# name: test_update_available.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.wled_rgb_light_firmware', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Firmware', - 'platform': 'wled', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'firmware', - 'unique_id': 'aabbccddeeff_update', - 'unit_of_measurement': None, - }) -# --- -# name: test_update_available.2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'http://127.0.0.1', - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': 'esp8266', - 'id': , - 'identifiers': set({ - tuple( - 'wled', - 'aabbccddeeff', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'WLED', - 'model': 'DIY light', - 'name': 'WLED RGB Light', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '0.8.5', - 'via_device_id': None, - }) -# --- diff --git a/tests/components/wled/test_binary_sensor.py b/tests/components/wled/test_binary_sensor.py deleted file mode 100644 index aa75b0c6696..00000000000 --- a/tests/components/wled/test_binary_sensor.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for the WLED binary sensor platform.""" - -import pytest -from syrupy.assertion import SnapshotAssertion - -from homeassistant.const import STATE_OFF -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.usefixtures("entity_registry_enabled_by_default") -async def test_update_available( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, -) -> None: - """Test the firmware update binary sensor.""" - assert (state := hass.states.get("binary_sensor.wled_rgb_light_firmware")) - 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 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) -async def test_no_update_available(hass: HomeAssistant) -> None: - """Test the update binary sensor. There is no update available.""" - assert (state := hass.states.get("binary_sensor.wled_websocket_firmware")) - assert state.state == STATE_OFF - - -async def test_disabled_by_default( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test that the binary update sensor is disabled by default.""" - assert not hass.states.get("binary_sensor.wled_rgb_light_firmware") - - assert (entry := entity_registry.async_get("binary_sensor.wled_rgb_light_firmware")) - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py index ef662fb4ded..b3061e6594a 100644 --- a/tests/components/wled/test_button.py +++ b/tests/components/wled/test_button.py @@ -58,7 +58,6 @@ async def test_button_restart( {ATTR_ENTITY_ID: "button.wled_rgb_light_restart"}, blocking=True, ) - await hass.async_block_till_done() # Ensure this didn't made the entity unavailable assert (state := hass.states.get("button.wled_rgb_light_restart")) diff --git a/tests/components/wled/test_init.py b/tests/components/wled/test_init.py index 6f4c47ec201..f6f1da0d41e 100644 --- a/tests/components/wled/test_init.py +++ b/tests/components/wled/test_init.py @@ -67,7 +67,7 @@ async def test_setting_unique_id( hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test we set unique ID if not set yet.""" - assert hass.data[DOMAIN] + assert init_integration.runtime_data assert init_integration.unique_id == "aabbccddeeff" diff --git a/tests/components/workday/conftest.py b/tests/components/workday/conftest.py index 1f3d9bcaabc..33bf98f90c3 100644 --- a/tests/components/workday/conftest.py +++ b/tests/components/workday/conftest.py @@ -1,13 +1,13 @@ """Fixtures for Workday integration tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.workday.async_setup_entry", return_value=True diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index e9f0e8023bc..e973a9f9c28 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,6 +1,6 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from typing import Any from freezegun.api import FrozenDateTimeFactory @@ -10,6 +10,7 @@ from homeassistant.components.workday.binary_sensor import SERVICE_CHECK_DATE from homeassistant.components.workday.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.dt import UTC from . import ( @@ -68,7 +69,9 @@ async def test_setup( freezer: FrozenDateTimeFactory, ) -> None: """Test setup from various configs.""" - freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Friday + # Start on a Friday + await hass.config.async_set_time_zone("Europe/Paris") + freezer.move_to(datetime(2022, 4, 15, 0, tzinfo=timezone(timedelta(hours=1)))) await init_integration(hass, config) state = hass.states.get("binary_sensor.workday_sensor") @@ -142,18 +145,59 @@ async def test_setup_add_holiday( assert state.state == "off" +@pytest.mark.parametrize( + "time_zone", ["Asia/Tokyo", "Europe/Berlin", "America/Chicago", "US/Hawaii"] +) async def test_setup_no_country_weekend( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + time_zone: str, ) -> None: """Test setup shows weekend as non-workday with no country.""" - freezer.move_to(datetime(2020, 2, 23, 12, tzinfo=UTC)) # Sunday + await hass.config.async_set_time_zone(time_zone) + zone = await dt_util.async_get_time_zone(time_zone) + freezer.move_to(datetime(2020, 2, 22, 0, 1, 1, tzinfo=zone)) # Saturday await init_integration(hass, TEST_CONFIG_NO_COUNTRY) state = hass.states.get("binary_sensor.workday_sensor") assert state is not None assert state.state == "off" + freezer.move_to(datetime(2020, 2, 24, 23, 59, 59, tzinfo=zone)) # Monday + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "on" + + +@pytest.mark.parametrize( + "time_zone", ["Asia/Tokyo", "Europe/Berlin", "America/Chicago", "US/Hawaii"] +) +async def test_setup_no_country_weekday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + time_zone: str, +) -> None: + """Test setup shows a weekday as a workday with no country.""" + await hass.config.async_set_time_zone(time_zone) + zone = await dt_util.async_get_time_zone(time_zone) + freezer.move_to(datetime(2020, 2, 21, 23, 59, 59, tzinfo=zone)) # Friday + await init_integration(hass, TEST_CONFIG_NO_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "on" + + freezer.move_to(datetime(2020, 2, 22, 23, 59, 59, tzinfo=zone)) # Saturday + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "off" + async def test_setup_remove_holiday( hass: HomeAssistant, diff --git a/tests/components/ws66i/test_init.py b/tests/components/ws66i/test_init.py index 9938ed84303..e9ec78b54da 100644 --- a/tests/components/ws66i/test_init.py +++ b/tests/components/ws66i/test_init.py @@ -74,7 +74,7 @@ async def test_unload_config_entry(hass: HomeAssistant) -> None: assert hass.data[DOMAIN][config_entry.entry_id] with patch.object(MockWs66i, "close") as method_call: - await config_entry.async_unload(hass) + await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert method_call.called diff --git a/tests/components/ws66i/test_media_player.py b/tests/components/ws66i/test_media_player.py index c13f6cbd738..a66e79bf9e0 100644 --- a/tests/components/ws66i/test_media_player.py +++ b/tests/components/ws66i/test_media_player.py @@ -138,7 +138,7 @@ async def test_setup_success(hass: HomeAssistant) -> None: assert hass.states.get(ZONE_1_ID) is not None -async def _setup_ws66i(hass, ws66i) -> MockConfigEntry: +async def _setup_ws66i(hass: HomeAssistant, ws66i) -> MockConfigEntry: config_entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_DEFAULT_OPTIONS ) @@ -154,7 +154,7 @@ async def _setup_ws66i(hass, ws66i) -> MockConfigEntry: return config_entry -async def _setup_ws66i_with_options(hass, ws66i) -> MockConfigEntry: +async def _setup_ws66i_with_options(hass: HomeAssistant, ws66i) -> MockConfigEntry: config_entry = MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS ) @@ -457,59 +457,59 @@ async def test_volume_while_mute(hass: HomeAssistant) -> None: assert not ws66i.zones[11].mute -async def test_first_run_with_available_zones(hass: HomeAssistant) -> None: +async def test_first_run_with_available_zones( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with all zones available.""" ws66i = MockWs66i() await _setup_ws66i(hass, ws66i) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert not entry.disabled -async def test_first_run_with_failing_zones(hass: HomeAssistant) -> None: +async def test_first_run_with_failing_zones( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test first run with failed zones.""" ws66i = MockWs66i() with patch.object(MockWs66i, "zone_status", return_value=None): await _setup_ws66i(hass, ws66i) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_1_ID) + entry = entity_registry.async_get(ZONE_1_ID) assert entry is None - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert entry is None -async def test_register_all_entities(hass: HomeAssistant) -> None: +async def test_register_all_entities( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test run with all entities registered.""" ws66i = MockWs66i() await _setup_ws66i(hass, ws66i) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_1_ID) + entry = entity_registry.async_get(ZONE_1_ID) assert not entry.disabled - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert not entry.disabled -async def test_register_entities_in_1_amp_only(hass: HomeAssistant) -> None: +async def test_register_entities_in_1_amp_only( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test run with only zones 11-16 registered.""" ws66i = MockWs66i(fail_zone_check=[21]) await _setup_ws66i(hass, ws66i) - registry = er.async_get(hass) - - entry = registry.async_get(ZONE_1_ID) + entry = entity_registry.async_get(ZONE_1_ID) assert not entry.disabled - entry = registry.async_get(ZONE_2_ID) + entry = entity_registry.async_get(ZONE_2_ID) assert not entry.disabled - entry = registry.async_get(ZONE_7_ID) + entry = entity_registry.async_get(ZONE_7_ID) assert entry is None diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 4be12312c7a..47ef0566dc6 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -1,9 +1,10 @@ """Common fixtures for the Wyoming tests.""" -from collections.abc import Generator +from pathlib import Path from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator from homeassistant.components import stt from homeassistant.components.wyoming import DOMAIN @@ -18,7 +19,7 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir @@ -30,7 +31,7 @@ async def init_components(hass: HomeAssistant): @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.wyoming.async_setup_entry", return_value=True diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index a9d1e73e153..1a291153ad0 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -17,15 +17,16 @@ from wyoming.info import Info from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite +from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection from homeassistant.components import assist_pipeline, wyoming -from homeassistant.components.wyoming.data import WyomingService from homeassistant.components.wyoming.devices import SatelliteDevice -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import intent as intent_helper from homeassistant.setup import async_setup_component from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient @@ -111,6 +112,18 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.ping_event = asyncio.Event() self.ping: Ping | None = None + self.timer_started_event = asyncio.Event() + self.timer_started: TimerStarted | None = None + + self.timer_updated_event = asyncio.Event() + self.timer_updated: TimerUpdated | None = None + + self.timer_cancelled_event = asyncio.Event() + self.timer_cancelled: TimerCancelled | None = None + + self.timer_finished_event = asyncio.Event() + self.timer_finished: TimerFinished | None = None + self._mic_audio_chunk = AudioChunk( rate=16000, width=2, channels=1, audio=b"chunk" ).event() @@ -159,6 +172,18 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): elif Ping.is_type(event.type): self.ping = Ping.from_event(event) self.ping_event.set() + elif TimerStarted.is_type(event.type): + self.timer_started = TimerStarted.from_event(event) + self.timer_started_event.set() + elif TimerUpdated.is_type(event.type): + self.timer_updated = TimerUpdated.from_event(event) + self.timer_updated_event.set() + elif TimerCancelled.is_type(event.type): + self.timer_cancelled = TimerCancelled.from_event(event) + self.timer_cancelled_event.set() + elif TimerFinished.is_type(event.type): + self.timer_finished = TimerFinished.from_event(event) + self.timer_finished_event.set() async def read_event(self) -> Event | None: """Receive.""" @@ -298,9 +323,6 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.detection is not None assert mock_client.detection.name == "test_wake_word" - # "Assist in progress" sensor should be active now - assert device.is_active - # Speech-to-text started pipeline_event_callback( assist_pipeline.PipelineEvent( @@ -314,6 +336,9 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.transcribe is not None assert mock_client.transcribe.language == "en" + # "Assist in progress" sensor should be active now + assert device.is_active + # Push in some audio mock_client.inject_event( AudioChunk(rate=16000, width=2, channels=1, audio=bytes(1024)).event() @@ -418,17 +443,8 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: """Test callback for a satellite that has been muted.""" on_muted_event = asyncio.Event() - original_make_satellite = wyoming._make_satellite original_on_muted = wyoming.satellite.WyomingSatellite.on_muted - def make_muted_satellite( - hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService - ): - satellite = original_make_satellite(hass, config_entry, service) - satellite.device.set_is_muted(True) - - return satellite - async def on_muted(self): # Trigger original function self._muted_changed_event.set() @@ -446,7 +462,10 @@ async def test_satellite_muted(hass: HomeAssistant) -> None: "homeassistant.components.wyoming.data.load_wyoming_info", return_value=SATELLITE_INFO, ), - patch("homeassistant.components.wyoming._make_satellite", make_muted_satellite), + patch( + "homeassistant.components.wyoming.switch.WyomingSatelliteMuteSwitch.async_get_last_state", + return_value=State("switch.test_mute", STATE_ON), + ), patch( "homeassistant.components.wyoming.satellite.WyomingSatellite.on_muted", on_muted, @@ -1083,3 +1102,287 @@ async def test_wake_word_phrase(hass: HomeAssistant) -> None: assert ( mock_run_pipeline.call_args.kwargs.get("wake_word_phrase") == "Test Phrase" ) + + +async def test_timers(hass: HomeAssistant) -> None: + """Test timer events.""" + assert await async_setup_component(hass, "intent", {}) + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient([]), + ) as mock_client, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + # Start timer + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_started_event.wait() + timer_started = mock_client.timer_started + assert timer_started is not None + assert timer_started.id + assert timer_started.name == "test timer" + assert timer_started.start_hours == 1 + assert timer_started.start_minutes == 2 + assert timer_started.start_seconds == 3 + assert timer_started.total_seconds == (1 * 60 * 60) + (2 * 60) + 3 + + # Pause + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_PAUSE_TIMER, + {}, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert not timer_updated.is_active + + # Resume + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_UNPAUSE_TIMER, + {}, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert timer_updated.is_active + + # Add time + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_INCREASE_TIMER, + { + "hours": {"value": 2}, + "minutes": {"value": 3}, + "seconds": {"value": 4}, + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert timer_updated.total_seconds > timer_started.total_seconds + + # Remove time + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_DECREASE_TIMER, + { + "hours": {"value": 2}, + "minutes": {"value": 3}, + "seconds": {"value": 5}, # remove 1 extra second + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert timer_updated.total_seconds < timer_started.total_seconds + + # Cancel + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_CANCEL_TIMER, + {}, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_cancelled_event.wait() + timer_cancelled = mock_client.timer_cancelled + assert timer_cancelled is not None + assert timer_cancelled.id == timer_started.id + + # Start a new timer + mock_client.timer_started_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "minutes": {"value": 1}, + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_started_event.wait() + timer_started = mock_client.timer_started + assert timer_started is not None + + # Finished + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_DECREASE_TIMER, + { + "minutes": {"value": 1}, # force finish + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_finished_event.wait() + timer_finished = mock_client.timer_finished + assert timer_finished is not None + assert timer_finished.id == timer_started.id + + +async def test_satellite_conversation_id(hass: HomeAssistant) -> None: + """Test that the same conversation id is used until timeout.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, + end_stage=PipelineStage.TTS, + restart_on_end=True, + ).event(), + ] + + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, + patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("wav", get_test_wav()), + ), + patch("homeassistant.components.wyoming.satellite._PING_SEND_DELAY", 0), + ): + entry = await setup_config_entry(hass) + satellite: wyoming.WyomingSatellite = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + + # A conversation id should have been generated + conversation_id = pipeline_kwargs.get("conversation_id") + assert conversation_id + + # Reset and run again + run_pipeline_called.clear() + pipeline_kwargs.clear() + + pipeline_event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Should be the same conversation id + assert pipeline_kwargs.get("conversation_id") == conversation_id + + # Reset and run again, but this time "time out" + satellite._conversation_id_time = None + run_pipeline_called.clear() + pipeline_kwargs.clear() + + pipeline_event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Should be a different conversation id + new_conversation_id = pipeline_kwargs.get("conversation_id") + assert new_conversation_id + assert new_conversation_id != conversation_id diff --git a/tests/components/wyoming/test_stt.py b/tests/components/wyoming/test_stt.py index 900ee8d544c..bd83c31c561 100644 --- a/tests/components/wyoming/test_stt.py +++ b/tests/components/wyoming/test_stt.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import patch +from syrupy import SnapshotAssertion from wyoming.asr import Transcript from homeassistant.components import stt @@ -29,7 +30,7 @@ async def test_support(hass: HomeAssistant, init_wyoming_stt) -> None: async def test_streaming_audio( - hass: HomeAssistant, init_wyoming_stt, metadata, snapshot + hass: HomeAssistant, init_wyoming_stt, metadata, snapshot: SnapshotAssertion ) -> None: """Test streaming audio.""" entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py index 160712bf3de..284aba2bd05 100644 --- a/tests/components/wyoming/test_switch.py +++ b/tests/components/wyoming/test_switch.py @@ -40,3 +40,4 @@ async def test_muted( state = hass.states.get(muted_id) assert state is not None assert state.state == STATE_ON + assert satellite_device.is_muted diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 4063418e566..263804787b1 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -7,6 +7,7 @@ from unittest.mock import patch import wave import pytest +from syrupy import SnapshotAssertion from wyoming.audio import AudioChunk, AudioStop from homeassistant.components import tts, wyoming @@ -38,7 +39,9 @@ async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: assert not entity.async_get_supported_voices("de-DE") -async def test_get_tts_audio(hass: HomeAssistant, init_wyoming_tts, snapshot) -> None: +async def test_get_tts_audio( + hass: HomeAssistant, init_wyoming_tts, snapshot: SnapshotAssertion +) -> None: """Test get audio.""" audio = bytes(100) audio_events = [ @@ -79,7 +82,7 @@ async def test_get_tts_audio(hass: HomeAssistant, init_wyoming_tts, snapshot) -> async def test_get_tts_audio_different_formats( - hass: HomeAssistant, init_wyoming_tts, snapshot + hass: HomeAssistant, init_wyoming_tts, snapshot: SnapshotAssertion ) -> None: """Test changing preferred audio format.""" audio = bytes(16000 * 2 * 1) # one second @@ -190,7 +193,9 @@ async def test_get_tts_audio_audio_oserror( ) -async def test_voice_speaker(hass: HomeAssistant, init_wyoming_tts, snapshot) -> None: +async def test_voice_speaker( + hass: HomeAssistant, init_wyoming_tts, snapshot: SnapshotAssertion +) -> None: """Test using a different voice and speaker.""" audio = bytes(100) audio_events = [ diff --git a/tests/components/xbox/test_config_flow.py b/tests/components/xbox/test_config_flow.py index e547909f946..8c2e6df6f89 100644 --- a/tests/components/xbox/test_config_flow.py +++ b/tests/components/xbox/test_config_flow.py @@ -3,6 +3,8 @@ from http import HTTPStatus from unittest.mock import patch +import pytest + from homeassistant import config_entries, setup from homeassistant.components.application_credentials import ( ClientCredential, @@ -32,11 +34,11 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: assert result["reason"] == "single_instance_allowed" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" assert await setup.async_setup_component(hass, "application_credentials", {}) diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 1b1d898add1..975e666af68 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -28,7 +28,7 @@ def mocked_requests(*args, **kwargs): class MockResponse: """Class to represent a mocked response.""" - def __init__(self, json_data, status_code): + def __init__(self, json_data, status_code) -> None: """Initialize the mock response class.""" self.json_data = json_data self.status_code = status_code @@ -48,6 +48,7 @@ def mocked_requests(*args, **kwargs): raise requests.HTTPError(self.status_code) data = kwargs.get("data") + # pylint: disable-next=global-statement global FIRST_CALL # noqa: PLW0603 if data and data.get("username", None) == INVALID_USERNAME: diff --git a/tests/components/xiaomi_ble/conftest.py b/tests/components/xiaomi_ble/conftest.py index 3d68d78e27e..bb74b3c7af3 100644 --- a/tests/components/xiaomi_ble/conftest.py +++ b/tests/components/xiaomi_ble/conftest.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +from typing_extensions import Generator class MockServices: @@ -44,7 +45,7 @@ class MockBleakClientBattery5(MockBleakClient): @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> Generator[None]: """Auto mock bluetooth.""" with mock.patch("xiaomi_ble.parser.BleakClient", MockBleakClientBattery5): diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index 714f061ecd6..87a4d340d8c 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -3,15 +3,12 @@ import pytest from homeassistant.components import automation -from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN +from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.xiaomi_ble.const import CONF_SUBTYPE, DOMAIN from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - async_get as async_get_dev_reg, -) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import make_advertisement @@ -33,12 +30,14 @@ def get_device_id(mac: str) -> tuple[str, str]: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") -async def _async_setup_xiaomi_device(hass, mac: str, data: Any | None = None): +async def _async_setup_xiaomi_device( + hass: HomeAssistant, mac: str, data: Any | None = None +): config_entry = MockConfigEntry(domain=DOMAIN, unique_id=mac, data=data) config_entry.add_to_hass(hass) @@ -176,7 +175,9 @@ async def test_event_dimmer_rotate(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_button(hass: HomeAssistant) -> None: +async def test_get_triggers_button( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we get the expected triggers from a Xiaomi BLE button sensor.""" mac = "54:EF:44:E3:9C:BC" data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} @@ -196,8 +197,7 @@ async def test_get_triggers_button(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -216,7 +216,9 @@ async def test_get_triggers_button(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_double_button(hass: HomeAssistant) -> None: +async def test_get_triggers_double_button( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we get the expected triggers from a Xiaomi BLE switch with 2 buttons.""" mac = "DC:ED:83:87:12:73" data = {"bindkey": "b93eb3787eabda352edd94b667f5d5a9"} @@ -236,8 +238,7 @@ async def test_get_triggers_double_button(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -256,7 +257,9 @@ async def test_get_triggers_double_button(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_lock(hass: HomeAssistant) -> None: +async def test_get_triggers_lock( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we get the expected triggers from a Xiaomi BLE lock with fingerprint scanner.""" mac = "98:0C:33:A3:04:3D" data = {"bindkey": "54d84797cb77f9538b224b305c877d1e"} @@ -277,8 +280,7 @@ async def test_get_triggers_lock(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -297,7 +299,9 @@ async def test_get_triggers_lock(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_motion(hass: HomeAssistant) -> None: +async def test_get_triggers_motion( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we get the expected triggers from a Xiaomi BLE motion sensor.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -313,8 +317,7 @@ async def test_get_triggers_motion(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -333,7 +336,9 @@ async def test_get_triggers_motion(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> None: +async def test_get_triggers_for_invalid_xiami_ble_device( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we don't get triggers for an device that does not emit events.""" mac = "C4:7C:8D:6A:3E:7A" entry = await _async_setup_xiaomi_device(hass, mac) @@ -349,8 +354,7 @@ async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> await hass.async_block_till_done() assert len(events) == 0 - dev_reg = async_get_dev_reg(hass) - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "invdevmac")}, ) @@ -364,7 +368,9 @@ async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> await hass.async_block_till_done() -async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: +async def test_get_triggers_for_invalid_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test that we don't get triggers when using an invalid device_id.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -378,11 +384,9 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert invalid_device triggers = await async_get_device_automations( @@ -394,7 +398,9 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_if_fires_on_button_press(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_button_press( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls: list[ServiceCall] +) -> None: """Test for button press event trigger firing.""" mac = "54:EF:44:E3:9C:BC" data = {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} @@ -412,8 +418,7 @@ async def test_if_fires_on_button_press(hass: HomeAssistant, calls) -> None: # wait for the device being created await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -454,7 +459,9 @@ async def test_if_fires_on_button_press(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() -async def test_if_fires_on_double_button_long_press(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_double_button_long_press( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls: list[ServiceCall] +) -> None: """Test for button press event trigger firing.""" mac = "DC:ED:83:87:12:73" data = {"bindkey": "b93eb3787eabda352edd94b667f5d5a9"} @@ -472,8 +479,7 @@ async def test_if_fires_on_double_button_long_press(hass: HomeAssistant, calls) # wait for the device being created await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -514,7 +520,9 @@ async def test_if_fires_on_double_button_long_press(hass: HomeAssistant, calls) await hass.async_block_till_done() -async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_motion_detected( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls: list[ServiceCall] +) -> None: """Test for motion event trigger firing.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -528,8 +536,7 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: # wait for the device being created await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -569,6 +576,7 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: async def test_automation_with_invalid_trigger_type( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test for automation with invalid trigger type.""" @@ -584,8 +592,7 @@ async def test_automation_with_invalid_trigger_type( # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -618,6 +625,7 @@ async def test_automation_with_invalid_trigger_type( async def test_automation_with_invalid_trigger_event_property( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test for automation with invalid trigger event property.""" @@ -633,8 +641,7 @@ async def test_automation_with_invalid_trigger_event_property( # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -668,7 +675,9 @@ async def test_automation_with_invalid_trigger_event_property( await hass.async_block_till_done() -async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: +async def test_triggers_for_invalid__model( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, calls: list[ServiceCall] +) -> None: """Test invalid model doesn't return triggers.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -683,8 +692,7 @@ async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() # modify model to invalid model - dev_reg = async_get_dev_reg(hass) - invalid_model = dev_reg.async_get_or_create( + invalid_model = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, mac)}, model="invalid model", diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 2cfc3a4f294..462145d16ab 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from miio import DeviceException import pytest +from typing_extensions import Generator from homeassistant.components.vacuum import ( ATTR_BATTERY_ICON, @@ -56,8 +57,6 @@ from . import TEST_MAC from tests.common import MockConfigEntry, async_fire_time_changed -# pylint: disable=consider-using-tuple - # calls made when device status is requested STATUS_CALLS = [ mock.call.status(), @@ -140,7 +139,9 @@ new_fanspeeds = { @pytest.fixture(name="mock_mirobo_fanspeeds", params=[old_fanspeeds, new_fanspeeds]) -def mirobo_old_speeds_fixture(request): +def mirobo_old_speeds_fixture( + request: pytest.FixtureRequest, +) -> Generator[MagicMock]: """Fixture for testing both types of fanspeeds.""" mock_vacuum = MagicMock() mock_vacuum.status().battery = 32 @@ -420,7 +421,7 @@ async def test_xiaomi_vacuum_services( "segments": ["1", "2"], }, "segment_clean", - mock.call(segments=[int(i) for i in ["1", "2"]]), + mock.call(segments=[int(i) for i in ("1", "2")]), ), ( SERVICE_CLEAN_SEGMENT, @@ -492,7 +493,7 @@ async def test_xiaomi_vacuum_fanspeeds( state = hass.states.get(entity_id) assert state.attributes.get(ATTR_FAN_SPEED) == "Silent" fanspeeds = state.attributes.get(ATTR_FAN_SPEED_LIST) - for speed in ["Silent", "Standard", "Medium", "Turbo"]: + for speed in ("Silent", "Standard", "Medium", "Turbo"): assert speed in fanspeeds # Set speed service: diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py index 211367a2922..9583df5faa6 100644 --- a/tests/components/yale_smart_alarm/conftest.py +++ b/tests/components/yale_smart_alarm/conftest.py @@ -9,8 +9,9 @@ from unittest.mock import Mock, patch import pytest from yalesmartalarmclient.const import YALE_STATE_ARM_FULL -from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.components.yale_smart_alarm.const import DOMAIN, PLATFORMS from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -24,36 +25,43 @@ ENTRY_CONFIG = { OPTIONS_CONFIG = {"lock_code_digits": 6} +@pytest.fixture(name="load_platforms") +async def patch_platform_constant() -> list[Platform]: + """Return list of platforms to load.""" + return PLATFORMS + + @pytest.fixture async def load_config_entry( - hass: HomeAssistant, load_json: dict[str, Any] + hass: HomeAssistant, load_json: dict[str, Any], load_platforms: list[Platform] ) -> tuple[MockConfigEntry, Mock]: """Set up the Yale Smart Living integration in Home Assistant.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - source=SOURCE_USER, - data=ENTRY_CONFIG, - options=OPTIONS_CONFIG, - entry_id="1", - unique_id="username", - version=1, - ) + with patch("homeassistant.components.yale_smart_alarm.PLATFORMS", load_platforms): + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="username", + version=1, + ) - config_entry.add_to_hass(hass) + config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", - autospec=True, - ) as mock_client_class: - client = mock_client_class.return_value - client.auth = None - client.lock_api = None - client.get_all.return_value = load_json - client.get_armed_status.return_value = YALE_STATE_ARM_FULL - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + client.auth = Mock() + client.lock_api = Mock() + client.get_all.return_value = load_json + client.get_armed_status.return_value = YALE_STATE_ARM_FULL + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - return (config_entry, client) + return (config_entry, client) @pytest.fixture(name="load_json", scope="package") diff --git a/tests/components/yale_smart_alarm/fixtures/get_all.json b/tests/components/yale_smart_alarm/fixtures/get_all.json index 0878cbf9c6a..e85a93f3c3e 100644 --- a/tests/components/yale_smart_alarm/fixtures/get_all.json +++ b/tests/components/yale_smart_alarm/fixtures/get_all.json @@ -503,6 +503,62 @@ "status_fault": [], "status_open": ["device_status.error"], "trigger_by_zone": [] + }, + { + "area": "1", + "no": "8", + "rf": null, + "address": "3456", + "type": "device_type.temperature_sensor", + "name": "Smoke alarm", + "status1": "", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": 21, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:1C", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "3456", + "status_temp_format": "C", + "type_no": "40", + "device_group": "001", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] } ], "MODE": [ @@ -1035,6 +1091,62 @@ "status_fault": [], "status_open": ["device_status.error"], "trigger_by_zone": [] + }, + { + "area": "1", + "no": "8", + "rf": null, + "address": "3456", + "type": "device_type.temperature_sensor", + "name": "Smoke alarm", + "status1": "", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": 21, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:1C", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "3456", + "status_temp_format": "C", + "type_no": "40", + "device_group": "001", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] } ], "capture_latest": null, diff --git a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..749e62252f3 --- /dev/null +++ b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.yale_smart_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.yale_smart_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1', + 'unit_of_measurement': None, + }) +# --- +# name: test_alarm_control_panel[load_platforms0][alarm_control_panel.yale_smart_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Yale Smart Alarm', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.yale_smart_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'armed_away', + }) +# --- diff --git a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..7bb144e8d2a --- /dev/null +++ b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr @@ -0,0 +1,330 @@ +# serializer version: 1 +# name: test_binary_sensor[load_platforms0][binary_sensor.device4_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.device4_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'RF4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.device4_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Device4 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.device4_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.device5_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.device5_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'RF5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.device5_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Device5 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.device5_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.device6_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.device6_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'RF6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.device6_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Device6 Door', + }), + 'context': , + 'entity_id': 'binary_sensor.device6_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_battery-entry] + 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.yale_smart_alarm_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '1-battery', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Yale Smart Alarm Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.yale_smart_alarm_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_jam-entry] + 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.yale_smart_alarm_jam', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Jam', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'jam', + 'unique_id': '1-jam', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_jam-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Yale Smart Alarm Jam', + }), + 'context': , + 'entity_id': 'binary_sensor.yale_smart_alarm_jam', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_power_loss-entry] + 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.yale_smart_alarm_power_loss', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power loss', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_loss', + 'unique_id': '1-acfail', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_power_loss-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Yale Smart Alarm Power loss', + }), + 'context': , + 'entity_id': 'binary_sensor.yale_smart_alarm_power_loss', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_tamper-entry] + 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.yale_smart_alarm_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tamper', + 'unique_id': '1-tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[load_platforms0][binary_sensor.yale_smart_alarm_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Yale Smart Alarm Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.yale_smart_alarm_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/yale_smart_alarm/snapshots/test_button.ambr b/tests/components/yale_smart_alarm/snapshots/test_button.ambr new file mode 100644 index 00000000000..8abceb0affa --- /dev/null +++ b/tests/components/yale_smart_alarm/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_button[load_platforms0][button.yale_smart_alarm_panic_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.yale_smart_alarm_panic_button', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Panic button', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'panic', + 'unique_id': 'yale_smart_alarm-panic', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[load_platforms0][button.yale_smart_alarm_panic_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Yale Smart Alarm Panic button', + }), + 'context': , + 'entity_id': 'button.yale_smart_alarm_panic_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-04-29T18:00:00.612351+00:00', + }) +# --- diff --git a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr index ae720a611e3..a5dfe4b50dd 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr @@ -572,6 +572,65 @@ 'type': 'device_type.door_lock', 'type_no': '72', }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '001', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': '', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': 21, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.temperature_sensor', + 'type_no': '40', + }), ]), 'model': list([ dict({ @@ -1130,6 +1189,65 @@ 'type': 'device_type.door_lock', 'type_no': '72', }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '001', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': '', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': 21, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.temperature_sensor', + 'type_no': '40', + }), ]), 'HISTORY': list([ dict({ diff --git a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr new file mode 100644 index 00000000000..da9c11e01d2 --- /dev/null +++ b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr @@ -0,0 +1,289 @@ +# serializer version: 1 +# name: test_lock[load_platforms0][lock.device1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.device1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1111', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[load_platforms0][lock.device1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code_format': '^\\d{6}$', + 'friendly_name': 'Device1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.device1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_lock[load_platforms0][lock.device2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.device2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2222', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[load_platforms0][lock.device2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code_format': '^\\d{6}$', + 'friendly_name': 'Device2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.device2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock[load_platforms0][lock.device3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.device3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3333', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[load_platforms0][lock.device3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code_format': '^\\d{6}$', + 'friendly_name': 'Device3', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.device3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_lock[load_platforms0][lock.device7-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.device7', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7777', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[load_platforms0][lock.device7-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code_format': '^\\d{6}$', + 'friendly_name': 'Device7', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.device7', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock[load_platforms0][lock.device8-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.device8', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '8888', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[load_platforms0][lock.device8-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code_format': '^\\d{6}$', + 'friendly_name': 'Device8', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.device8', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- +# name: test_lock[load_platforms0][lock.device9-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.device9', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '9999', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[load_platforms0][lock.device9-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'code_format': '^\\d{6}$', + 'friendly_name': 'Device9', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.device9', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/yale_smart_alarm/test_alarm_control_panel.py b/tests/components/yale_smart_alarm/test_alarm_control_panel.py new file mode 100644 index 00000000000..4e8330df071 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_alarm_control_panel.py @@ -0,0 +1,29 @@ +"""The test for the Yale Smart ALarm alarm control panel platform.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.ALARM_CONTROL_PANEL]], +) +async def test_alarm_control_panel( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Alarm alarm_control_panel.""" + entry = load_config_entry[0] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/yale_smart_alarm/test_binary_sensor.py b/tests/components/yale_smart_alarm/test_binary_sensor.py new file mode 100644 index 00000000000..dc503a00e97 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""The test for the Yale Smart Alarm binary sensor platform.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.BINARY_SENSOR]], +) +async def test_binary_sensor( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Alarm binary sensor.""" + entry = load_config_entry[0] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/yale_smart_alarm/test_button.py b/tests/components/yale_smart_alarm/test_button.py new file mode 100644 index 00000000000..ad6074345d3 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_button.py @@ -0,0 +1,57 @@ +"""The test for the Yale Smart ALarm button platform.""" + +from __future__ import annotations + +from unittest.mock import Mock + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion +from yalesmartalarmclient.exceptions import UnknownError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@freeze_time("2024-04-29T18:00:00.612351+00:00") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.BUTTON]], +) +async def test_button( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Alarm button.""" + entry = load_config_entry[0] + client = load_config_entry[1] + client.trigger_panic_button = Mock(return_value=True) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.yale_smart_alarm_panic_button", + }, + blocking=True, + ) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + client.trigger_panic_button.assert_called_once() + client.trigger_panic_button.reset_mock() + client.trigger_panic_button = Mock(side_effect=UnknownError("test_side_effect")) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.yale_smart_alarm_panic_button", + }, + blocking=True, + ) + client.trigger_panic_button.assert_called_once() diff --git a/tests/components/yale_smart_alarm/test_lock.py b/tests/components/yale_smart_alarm/test_lock.py new file mode 100644 index 00000000000..09ce8529084 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_lock.py @@ -0,0 +1,178 @@ +"""The test for the Yale Smart ALarm lock platform.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from yalesmartalarmclient.exceptions import UnknownError +from yalesmartalarmclient.lock import YaleDoorManAPI + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_UNLOCK, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.LOCK]], +) +async def test_lock( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Alarm lock.""" + entry = load_config_entry[0] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.LOCK]], +) +async def test_lock_service_calls( + hass: HomeAssistant, + load_json: dict[str, Any], + load_config_entry: tuple[MockConfigEntry, Mock], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Alarm lock.""" + + client = load_config_entry[1] + + data = deepcopy(load_json) + data["data"] = data.pop("DEVICES") + + client.auth.get_authenticated = Mock(return_value=data) + client.auth.post_authenticated = Mock(return_value={"code": "000"}) + client.lock_api = YaleDoorManAPI(client.auth) + + state = hass.states.get("lock.device1") + assert state.state == "locked" + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.device1", ATTR_CODE: "123456"}, + blocking=True, + ) + client.auth.post_authenticated.assert_called_once() + state = hass.states.get("lock.device1") + assert state.state == "unlocked" + client.auth.post_authenticated.reset_mock() + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.device1", ATTR_CODE: "123456"}, + blocking=True, + ) + client.auth.post_authenticated.assert_called_once() + state = hass.states.get("lock.device1") + assert state.state == "locked" + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.LOCK]], +) +async def test_lock_service_call_fails( + hass: HomeAssistant, + load_json: dict[str, Any], + load_config_entry: tuple[MockConfigEntry, Mock], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Alarm lock service call fails.""" + + client = load_config_entry[1] + + data = deepcopy(load_json) + data["data"] = data.pop("DEVICES") + + client.auth.get_authenticated = Mock(return_value=data) + client.auth.post_authenticated = Mock(side_effect=UnknownError("test_side_effect")) + client.lock_api = YaleDoorManAPI(client.auth) + + state = hass.states.get("lock.device1") + assert state.state == "locked" + + with pytest.raises( + HomeAssistantError, + match="Could not set lock for Device1: test_side_effect", + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.device1", ATTR_CODE: "123456"}, + blocking=True, + ) + client.auth.post_authenticated.assert_called_once() + state = hass.states.get("lock.device1") + assert state.state == "locked" + client.auth.post_authenticated.reset_mock() + with pytest.raises( + HomeAssistantError, + match="Could not set lock for Device1: test_side_effect", + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: "lock.device1", ATTR_CODE: "123456"}, + blocking=True, + ) + client.auth.post_authenticated.assert_called_once() + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.LOCK]], +) +async def test_lock_service_call_fails_with_incorrect_status( + hass: HomeAssistant, + load_json: dict[str, Any], + load_config_entry: tuple[MockConfigEntry, Mock], + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Alarm lock service call fails with incorrect return state.""" + + client = load_config_entry[1] + + data = deepcopy(load_json) + data["data"] = data.pop("DEVICES") + + client.auth.get_authenticated = Mock(return_value=data) + client.auth.post_authenticated = Mock(return_value={"code": "FFF"}) + client.lock_api = YaleDoorManAPI(client.auth) + + state = hass.states.get("lock.device1") + assert state.state == "locked" + + with pytest.raises( + HomeAssistantError, match="Could not set lock, check system ready for lock" + ): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: "lock.device1", ATTR_CODE: "123456"}, + blocking=True, + ) + client.auth.post_authenticated.assert_called_once() + state = hass.states.get("lock.device1") + assert state.state == "locked" diff --git a/tests/components/yale_smart_alarm/test_sensor.py b/tests/components/yale_smart_alarm/test_sensor.py new file mode 100644 index 00000000000..d91ddc0e6ce --- /dev/null +++ b/tests/components/yale_smart_alarm/test_sensor.py @@ -0,0 +1,21 @@ +"""The test for the sensibo sensor.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_coordinator_setup_and_update_errors( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + load_json: dict[str, Any], +) -> None: + """Test the Yale Smart Living coordinator with errors.""" + + state = hass.states.get("sensor.smoke_alarm_temperature") + assert state.state == "21" diff --git a/tests/components/yalexs_ble/conftest.py b/tests/components/yalexs_ble/conftest.py index c2b947cc863..27c45b9110c 100644 --- a/tests/components/yalexs_ble/conftest.py +++ b/tests/components/yalexs_ble/conftest.py @@ -4,5 +4,5 @@ import pytest @pytest.fixture(autouse=True) -def mock_bluetooth(enable_bluetooth): +def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index 73885bc8ac7..02246e69269 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, PropertyMock, call, patch import pytest -import homeassistant.components.media_player as mp +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.yamaha import media_player as yamaha from homeassistant.components.yamaha.const import DOMAIN from homeassistant.core import HomeAssistant @@ -52,7 +52,7 @@ def device_fixture(main_zone): async def test_setup_host(hass: HomeAssistant, device, main_zone) -> None: """Test set up integration with host.""" - assert await async_setup_component(hass, mp.DOMAIN, CONFIG) + assert await async_setup_component(hass, MP_DOMAIN, CONFIG) await hass.async_block_till_done() state = hass.states.get("media_player.yamaha_receiver_main_zone") @@ -65,7 +65,7 @@ async def test_setup_no_host(hass: HomeAssistant, device, main_zone) -> None: """Test set up integration without host.""" with patch("rxv.find", return_value=[device]): assert await async_setup_component( - hass, mp.DOMAIN, {"media_player": {"platform": "yamaha"}} + hass, MP_DOMAIN, {"media_player": {"platform": "yamaha"}} ) await hass.async_block_till_done() @@ -84,7 +84,7 @@ async def test_setup_discovery(hass: HomeAssistant, device, main_zone) -> None: "description_url": "http://receiver/description", } await async_load_platform( - hass, mp.DOMAIN, "yamaha", discovery_info, {mp.DOMAIN: {}} + hass, MP_DOMAIN, "yamaha", discovery_info, {MP_DOMAIN: {}} ) await hass.async_block_till_done() @@ -98,7 +98,7 @@ async def test_setup_zone_ignore(hass: HomeAssistant, device, main_zone) -> None """Test set up integration without host.""" assert await async_setup_component( hass, - mp.DOMAIN, + MP_DOMAIN, { "media_player": { "platform": "yamaha", @@ -116,7 +116,7 @@ async def test_setup_zone_ignore(hass: HomeAssistant, device, main_zone) -> None async def test_enable_output(hass: HomeAssistant, device, main_zone) -> None: """Test enable output service.""" - assert await async_setup_component(hass, mp.DOMAIN, CONFIG) + assert await async_setup_component(hass, MP_DOMAIN, CONFIG) await hass.async_block_till_done() port = "hdmi1" @@ -147,7 +147,7 @@ async def test_enable_output(hass: HomeAssistant, device, main_zone) -> None: @pytest.mark.usefixtures("device") async def test_menu_cursor(hass: HomeAssistant, main_zone, cursor, method) -> None: """Verify that the correct menu method is called for the menu_cursor service.""" - assert await async_setup_component(hass, mp.DOMAIN, CONFIG) + assert await async_setup_component(hass, MP_DOMAIN, CONFIG) await hass.async_block_till_done() data = { @@ -166,7 +166,7 @@ async def test_select_scene( scene_prop = PropertyMock(return_value=None) type(main_zone).scene = scene_prop - assert await async_setup_component(hass, mp.DOMAIN, CONFIG) + assert await async_setup_component(hass, MP_DOMAIN, CONFIG) await hass.async_block_till_done() scene = "TV Viewing" diff --git a/tests/components/yamaha_musiccast/test_config_flow.py b/tests/components/yamaha_musiccast/test_config_flow.py index 1c51b315a5a..321e7250e5a 100644 --- a/tests/components/yamaha_musiccast/test_config_flow.py +++ b/tests/components/yamaha_musiccast/test_config_flow.py @@ -129,7 +129,7 @@ def mock_empty_discovery_information(): async def test_user_input_device_not_found( - hass: HomeAssistant, mock_get_device_info_mc_exception, mock_get_source_ip + hass: HomeAssistant, mock_get_device_info_mc_exception ) -> None: """Test when user specifies a non-existing device.""" result = await hass.config_entries.flow.async_init( @@ -147,7 +147,7 @@ async def test_user_input_device_not_found( async def test_user_input_non_yamaha_device_found( - hass: HomeAssistant, mock_get_device_info_invalid, mock_get_source_ip + hass: HomeAssistant, mock_get_device_info_invalid ) -> None: """Test when user specifies an existing device, which does not provide the musiccast API.""" result = await hass.config_entries.flow.async_init( @@ -165,7 +165,7 @@ async def test_user_input_non_yamaha_device_found( async def test_user_input_device_already_existing( - hass: HomeAssistant, mock_get_device_info_valid, mock_get_source_ip + hass: HomeAssistant, mock_get_device_info_valid ) -> None: """Test when user specifies an existing device.""" mock_entry = MockConfigEntry( @@ -189,7 +189,7 @@ async def test_user_input_device_already_existing( async def test_user_input_unknown_error( - hass: HomeAssistant, mock_get_device_info_exception, mock_get_source_ip + hass: HomeAssistant, mock_get_device_info_exception ) -> None: """Test when user specifies an existing device, which does not provide the musiccast API.""" result = await hass.config_entries.flow.async_init( @@ -210,7 +210,6 @@ async def test_user_input_device_found( hass: HomeAssistant, mock_get_device_info_valid, mock_valid_discovery_information, - mock_get_source_ip, ) -> None: """Test when user specifies an existing device.""" result = await hass.config_entries.flow.async_init( @@ -236,7 +235,6 @@ async def test_user_input_device_found_no_ssdp( hass: HomeAssistant, mock_get_device_info_valid, mock_empty_discovery_information, - mock_get_source_ip, ) -> None: """Test when user specifies an existing device, which no discovery data are present for.""" result = await hass.config_entries.flow.async_init( @@ -261,9 +259,7 @@ async def test_user_input_device_found_no_ssdp( # SSDP Flows -async def test_ssdp_discovery_failed( - hass: HomeAssistant, mock_ssdp_no_yamaha, mock_get_source_ip -) -> None: +async def test_ssdp_discovery_failed(hass: HomeAssistant, mock_ssdp_no_yamaha) -> None: """Test when an SSDP discovered device is not a musiccast device.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -284,7 +280,7 @@ async def test_ssdp_discovery_failed( async def test_ssdp_discovery_successful_add_device( - hass: HomeAssistant, mock_ssdp_yamaha, mock_get_source_ip + hass: HomeAssistant, mock_ssdp_yamaha ) -> None: """Test when the SSDP discovered device is a musiccast device and the user confirms it.""" result = await hass.config_entries.flow.async_init( @@ -320,7 +316,7 @@ async def test_ssdp_discovery_successful_add_device( async def test_ssdp_discovery_existing_device_update( - hass: HomeAssistant, mock_ssdp_yamaha, mock_get_source_ip + hass: HomeAssistant, mock_ssdp_yamaha ) -> None: """Test when the SSDP discovered device is a musiccast device, but it already exists with another IP.""" mock_entry = MockConfigEntry( diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index 6a4b7e11ce6..496c187469a 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -1,6 +1,8 @@ """The tests for the Yandex SpeechKit speech platform.""" from http import HTTPStatus +from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -22,12 +24,12 @@ URL = "https://tts.voicetech.yandex.net/generate?" @pytest.fixture(autouse=True) -def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> Path: """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir diff --git a/tests/components/yardian/conftest.py b/tests/components/yardian/conftest.py index 985d2303fdf..26a01f889b7 100644 --- a/tests/components/yardian/conftest.py +++ b/tests/components/yardian/conftest.py @@ -1,13 +1,13 @@ """Common fixtures for the Yardian tests.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest +from typing_extensions import Generator @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.yardian.async_setup_entry", return_value=True diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index 6c940b0b229..2de064cf567 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -115,6 +115,7 @@ class MockAsyncBulb: self.bulb_type = bulb_type self._async_callback = None self._cannot_connect = cannot_connect + self.capabilities = None async def async_listen(self, callback): """Mock the listener.""" @@ -132,6 +133,7 @@ class MockAsyncBulb: def _mocked_bulb(cannot_connect=False): + # pylint: disable=attribute-defined-outside-init bulb = MockAsyncBulb(MODEL, BulbType.Color, cannot_connect) type(bulb).async_get_properties = AsyncMock( side_effect=BulbException if cannot_connect else None diff --git a/tests/components/yeelight/conftest.py b/tests/components/yeelight/conftest.py index e4ce0afc9bf..46a0ebb1bd5 100644 --- a/tests/components/yeelight/conftest.py +++ b/tests/components/yeelight/conftest.py @@ -1,10 +1,3 @@ """yeelight conftest.""" -import pytest - from tests.components.light.conftest import mock_light_profiles # noqa: F401 - - -@pytest.fixture(autouse=True) -def yeelight_mock_get_source_ip(mock_get_source_ip): - """Mock network util's async_get_source_ip.""" diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 0bff635fb6e..09064162eb0 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -51,7 +51,9 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_ip_changes_fallback_discovery(hass: HomeAssistant) -> None: +async def test_ip_changes_fallback_discovery( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test Yeelight ip changes and we fallback to discovery.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_ID: ID, CONF_HOST: "5.5.5.5"}, unique_id=ID @@ -84,7 +86,6 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant) -> None: binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( f"yeelight_color_{SHORT_ID}" ) - entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None # Make sure we can still reload with the new ip right after we change it @@ -93,7 +94,6 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_registry = er.async_get(hass) assert entity_registry.async_get(binary_sensor_entity_id) is not None @@ -278,7 +278,9 @@ async def test_setup_import(hass: HomeAssistant) -> None: assert entry.data[CONF_ID] == "0x000000000015243f" -async def test_unique_ids_device(hass: HomeAssistant) -> None: +async def test_unique_ids_device( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test Yeelight unique IDs from yeelight device IDs.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -293,7 +295,6 @@ async def test_unique_ids_device(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(ENTITY_BINARY_SENSOR).unique_id == f"{ID}-nightlight_sensor" @@ -303,7 +304,9 @@ async def test_unique_ids_device(hass: HomeAssistant) -> None: assert entity_registry.async_get(ENTITY_AMBILIGHT).unique_id == f"{ID}-ambilight" -async def test_unique_ids_entry(hass: HomeAssistant) -> None: +async def test_unique_ids_entry( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test Yeelight unique IDs from entry IDs.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -318,7 +321,6 @@ async def test_unique_ids_entry(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entity_registry = er.async_get(hass) assert ( entity_registry.async_get(ENTITY_BINARY_SENSOR).unique_id == f"{config_entry.entry_id}-nightlight_sensor" diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index ff80c2b55b2..eba4d4fe284 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -776,7 +776,9 @@ async def test_state_already_set_avoid_ratelimit(hass: HomeAssistant) -> None: async def test_device_types( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test different device types.""" mocked_bulb = _mocked_bulb() @@ -824,9 +826,8 @@ async def test_device_types( target_properties["music_mode"] = False assert dict(state.attributes) == target_properties await hass.config_entries.async_unload(config_entry.entry_id) - await config_entry.async_remove(hass) - registry = er.async_get(hass) - registry.async_clear_config_entry(config_entry.entry_id) + await hass.config_entries.async_remove(config_entry.entry_id) + entity_registry.async_clear_config_entry(config_entry.entry_id) mocked_bulb.last_properties["nl_br"] = original_nightlight_brightness # nightlight as a setting of the main entity @@ -846,8 +847,8 @@ async def test_device_types( assert dict(state.attributes) == nightlight_mode_properties await hass.config_entries.async_unload(config_entry.entry_id) - await config_entry.async_remove(hass) - registry.async_clear_config_entry(config_entry.entry_id) + await hass.config_entries.async_remove(config_entry.entry_id) + entity_registry.async_clear_config_entry(config_entry.entry_id) await hass.async_block_till_done() mocked_bulb.last_properties.pop("active_mode") @@ -869,8 +870,8 @@ async def test_device_types( assert dict(state.attributes) == nightlight_entity_properties await hass.config_entries.async_unload(config_entry.entry_id) - await config_entry.async_remove(hass) - registry.async_clear_config_entry(config_entry.entry_id) + await hass.config_entries.async_remove(config_entry.entry_id) + entity_registry.async_clear_config_entry(config_entry.entry_id) await hass.async_block_till_done() bright = round(255 * int(PROPERTIES["bright"]) / 100) diff --git a/tests/components/yolink/test_config_flow.py b/tests/components/yolink/test_config_flow.py index f62bd3ac1ac..d7ba09e4269 100644 --- a/tests/components/yolink/test_config_flow.py +++ b/tests/components/yolink/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from unittest.mock import patch +import pytest from yolink.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant import config_entries, setup @@ -40,11 +41,11 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" assert await setup.async_setup_component( @@ -115,9 +116,8 @@ async def test_full_flow( assert len(mock_setup.mock_calls) == 1 -async def test_abort_if_authorization_timeout( - hass: HomeAssistant, current_request_with_host: None -) -> None: +@pytest.mark.usefixtures("current_request_with_host") +async def test_abort_if_authorization_timeout(hass: HomeAssistant) -> None: """Check yolink authorization timeout.""" assert await setup.async_setup_component( hass, @@ -142,11 +142,11 @@ async def test_abort_if_authorization_timeout( assert result["reason"] == "authorize_url_timeout" +@pytest.mark.usefixtures("current_request_with_host") async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Test yolink reauthentication.""" await setup.async_setup_component( diff --git a/tests/components/yolink/test_device_trigger.py b/tests/components/yolink/test_device_trigger.py index 678fe6e35cc..f6aa9a28ac0 100644 --- a/tests/components/yolink/test_device_trigger.py +++ b/tests/components/yolink/test_device_trigger.py @@ -7,7 +7,7 @@ from yolink.const import ATTR_DEVICE_DIMMER, ATTR_DEVICE_SMART_REMOTER from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.yolink import DOMAIN, YOLINK_EVENT -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -19,7 +19,7 @@ from tests.common import ( @pytest.fixture -def calls(hass: HomeAssistant): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "yolink", "automation") @@ -120,7 +120,7 @@ async def test_get_triggers_exception( async def test_if_fires_on_event( - hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry ) -> None: """Test for event triggers firing.""" mac_address = "12:34:56:AB:CD:EF" diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py index 62808bc7ad9..1b559f0f1c4 100644 --- a/tests/components/youtube/__init__.py +++ b/tests/components/youtube/__init__.py @@ -1,8 +1,8 @@ """Tests for the YouTube integration.""" -from collections.abc import AsyncGenerator import json +from typing_extensions import AsyncGenerator from youtubeaio.models import YouTubeChannel, YouTubePlaylistItem, YouTubeSubscription from youtubeaio.types import AuthScope @@ -19,7 +19,7 @@ class MockYouTube: channel_fixture: str = "youtube/get_channel.json", playlist_items_fixture: str = "youtube/get_playlist_items.json", subscriptions_fixture: str = "youtube/get_subscriptions.json", - ): + ) -> None: """Initialize mock service.""" self._channel_fixture = channel_fixture self._playlist_items_fixture = playlist_items_fixture @@ -30,7 +30,7 @@ class MockYouTube: ) -> None: """Authenticate the user.""" - async def get_user_channels(self) -> AsyncGenerator[YouTubeChannel, None]: + async def get_user_channels(self) -> AsyncGenerator[YouTubeChannel]: """Get channels for authenticated user.""" channels = json.loads(load_fixture(self._channel_fixture)) for item in channels["items"]: @@ -38,7 +38,7 @@ class MockYouTube: async def get_channels( self, channel_ids: list[str] - ) -> AsyncGenerator[YouTubeChannel, None]: + ) -> AsyncGenerator[YouTubeChannel]: """Get channels.""" if self._thrown_error is not None: raise self._thrown_error @@ -48,13 +48,13 @@ class MockYouTube: async def get_playlist_items( self, playlist_id: str, amount: int - ) -> AsyncGenerator[YouTubePlaylistItem, None]: + ) -> AsyncGenerator[YouTubePlaylistItem]: """Get channels.""" channels = json.loads(load_fixture(self._playlist_items_fixture)) for item in channels["items"]: yield YouTubePlaylistItem(**item) - async def get_user_subscriptions(self) -> AsyncGenerator[YouTubeSubscription, None]: + async def get_user_subscriptions(self) -> AsyncGenerator[YouTubeSubscription]: """Get channels for authenticated user.""" channels = json.loads(load_fixture(self._subscriptions_fixture)) for item in channels["items"]: diff --git a/tests/components/youtube/conftest.py b/tests/components/youtube/conftest.py index a90dbba8aaa..7f1caef47b5 100644 --- a/tests/components/youtube/conftest.py +++ b/tests/components/youtube/conftest.py @@ -15,11 +15,12 @@ from homeassistant.components.youtube.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import MockYouTube + from tests.common import MockConfigEntry -from tests.components.youtube import MockYouTube from tests.test_util.aiohttp import AiohttpClientMocker -ComponentSetup = Callable[[], Awaitable[MockYouTube]] +type ComponentSetup = Callable[[], Awaitable[MockYouTube]] CLIENT_ID = "1234" CLIENT_SECRET = "5678" diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 95a56155980..73652d9b239 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -26,10 +26,10 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -85,10 +85,10 @@ async def test_full_flow( assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} +@pytest.mark.usefixtures("current_request_with_host") async def test_flow_abort_without_channel( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, ) -> None: """Check abort flow if user has no channel.""" result = await hass.config_entries.flow.async_init( @@ -126,10 +126,10 @@ async def test_flow_abort_without_channel( assert result["reason"] == "no_channel" +@pytest.mark.usefixtures("current_request_with_host") async def test_flow_abort_without_subscriptions( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, ) -> None: """Check abort flow if user has no subscriptions.""" result = await hass.config_entries.flow.async_init( @@ -167,10 +167,10 @@ async def test_flow_abort_without_subscriptions( assert result["reason"] == "no_subscriptions" +@pytest.mark.usefixtures("current_request_with_host") async def test_flow_http_error( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -211,7 +211,7 @@ async def test_flow_http_error( @pytest.mark.parametrize( - ("fixture", "abort_reason", "placeholders", "calls", "access_token"), + ("fixture", "abort_reason", "placeholders", "call_count", "access_token"), [ ( "get_channel", @@ -229,16 +229,16 @@ async def test_flow_http_error( ), ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_reauth( hass: HomeAssistant, - hass_client_no_auth, + hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host, config_entry: MockConfigEntry, fixture: str, abort_reason: str, placeholders: dict[str, str], - calls: int, + call_count: int, access_token: str, ) -> None: """Test the re-authentication case updates the correct config entry. @@ -303,7 +303,7 @@ async def test_reauth( assert result["type"] is FlowResultType.ABORT assert result["reason"] == abort_reason assert result["description_placeholders"] == placeholders - assert len(mock_setup.mock_calls) == calls + assert len(mock_setup.mock_calls) == call_count assert config_entry.unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" assert "token" in config_entry.data @@ -312,10 +312,10 @@ async def test_reauth( assert config_entry.data["token"]["refresh_token"] == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") async def test_flow_exception( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - current_request_with_host: None, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/youtube/test_init.py b/tests/components/youtube/test_init.py index a6c3acbdd3b..400ce515176 100644 --- a/tests/components/youtube/test_init.py +++ b/tests/components/youtube/test_init.py @@ -118,11 +118,12 @@ async def test_expired_token_refresh_client_error( async def test_device_info( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, ) -> None: """Test device info.""" await setup_integration() - device_registry = dr.async_get(hass) entry = hass.config_entries.async_entries(DOMAIN)[0] channel_id = entry.options[CONF_CHANNELS][0] diff --git a/tests/components/zamg/conftest.py b/tests/components/zamg/conftest.py index 0598e2adfb4..1795baa7fad 100644 --- a/tests/components/zamg/conftest.py +++ b/tests/components/zamg/conftest.py @@ -1,10 +1,10 @@ """Fixtures for Zamg integration tests.""" -from collections.abc import Generator import json from unittest.mock import MagicMock, patch import pytest +from typing_extensions import Generator from zamg import ZamgData as ZamgDevice from homeassistant.components.zamg.const import CONF_STATION_ID, DOMAIN @@ -30,16 +30,14 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" with patch("homeassistant.components.zamg.async_setup_entry", return_value=True): yield @pytest.fixture -def mock_zamg_config_flow( - request: pytest.FixtureRequest, -) -> Generator[None, MagicMock, None]: +def mock_zamg_config_flow() -> Generator[MagicMock]: """Return a mocked Zamg client.""" with patch( "homeassistant.components.zamg.sensor.ZamgData", autospec=True @@ -53,7 +51,7 @@ def mock_zamg_config_flow( @pytest.fixture -def mock_zamg(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: +def mock_zamg() -> Generator[MagicMock]: """Return a mocked Zamg client.""" with patch( @@ -72,9 +70,7 @@ def mock_zamg(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None @pytest.fixture -def mock_zamg_coordinator( - request: pytest.FixtureRequest, -) -> Generator[None, MagicMock, None]: +def mock_zamg_coordinator() -> Generator[MagicMock]: """Return a mocked Zamg client.""" with patch( @@ -93,24 +89,7 @@ def mock_zamg_coordinator( @pytest.fixture -def mock_zamg_stations( - request: pytest.FixtureRequest, -) -> Generator[None, MagicMock, None]: - """Return a mocked Zamg client.""" - with patch( - "homeassistant.components.zamg.config_flow.ZamgData.zamg_stations" - ) as zamg_mock: - zamg_mock.return_value = { - "11240": (46.99305556, 15.43916667, "GRAZ-FLUGHAFEN"), - "11244": (46.87222222, 15.90361111, "BAD GLEICHENBERG"), - } - yield zamg_mock - - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, -) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Zamg integration for testing.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/zamg/test_config_flow.py b/tests/components/zamg/test_config_flow.py index f67eda67a49..949f14df89c 100644 --- a/tests/components/zamg/test_config_flow.py +++ b/tests/components/zamg/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +import pytest from zamg.exceptions import ZamgApiError from homeassistant.components.zamg.const import CONF_STATION_ID, DOMAIN, LOGGER @@ -12,11 +13,8 @@ from homeassistant.data_entry_flow import FlowResultType from .conftest import TEST_STATION_ID -async def test_full_user_flow_implementation( - hass: HomeAssistant, - mock_zamg: MagicMock, - mock_setup_entry: None, -) -> None: +@pytest.mark.usefixtures("mock_zamg", "mock_setup_entry") +async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -37,11 +35,8 @@ async def test_full_user_flow_implementation( assert result["result"].unique_id == TEST_STATION_ID -async def test_error_closest_station( - hass: HomeAssistant, - mock_zamg: MagicMock, - mock_setup_entry: None, -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_error_closest_station(hass: HomeAssistant, mock_zamg: MagicMock) -> None: """Test with error of reading from Zamg.""" mock_zamg.closest_station.side_effect = ZamgApiError result = await hass.config_entries.flow.async_init( @@ -52,11 +47,8 @@ async def test_error_closest_station( assert result.get("reason") == "cannot_connect" -async def test_error_update( - hass: HomeAssistant, - mock_zamg: MagicMock, - mock_setup_entry: None, -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_error_update(hass: HomeAssistant, mock_zamg: MagicMock) -> None: """Test with error of reading from Zamg.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -75,11 +67,8 @@ async def test_error_update( assert result.get("reason") == "cannot_connect" -async def test_user_flow_duplicate( - hass: HomeAssistant, - mock_zamg: MagicMock, - mock_setup_entry: None, -) -> None: +@pytest.mark.usefixtures("mock_zamg", "mock_setup_entry") +async def test_user_flow_duplicate(hass: HomeAssistant) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/zamg/test_init.py b/tests/components/zamg/test_init.py index cda17268478..9f05882853a 100644 --- a/tests/components/zamg/test_init.py +++ b/tests/components/zamg/test_init.py @@ -1,7 +1,5 @@ """Test Zamg component init.""" -from unittest.mock import MagicMock - import pytest from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -62,9 +60,10 @@ from tests.common import MockConfigEntry ), ], ) +@pytest.mark.usefixtures("mock_zamg_coordinator") async def test_migrate_unique_ids( hass: HomeAssistant, - mock_zamg_coordinator: MagicMock, + entity_registry: er.EntityRegistry, entitydata: dict, old_unique_id: str, new_unique_id: str, @@ -75,7 +74,6 @@ async def test_migrate_unique_ids( mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, @@ -108,9 +106,10 @@ async def test_migrate_unique_ids( ), ], ) +@pytest.mark.usefixtures("mock_zamg_coordinator") async def test_dont_migrate_unique_ids( hass: HomeAssistant, - mock_zamg_coordinator: MagicMock, + entity_registry: er.EntityRegistry, entitydata: dict, old_unique_id: str, new_unique_id: str, @@ -121,8 +120,6 @@ async def test_dont_migrate_unique_ids( mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - # create existing entry with new_unique_id existing_entity = entity_registry.async_get_or_create( WEATHER_DOMAIN, @@ -168,9 +165,10 @@ async def test_dont_migrate_unique_ids( ), ], ) +@pytest.mark.usefixtures("mock_zamg_coordinator") async def test_unload_entry( hass: HomeAssistant, - mock_zamg_coordinator: MagicMock, + entity_registry: er.EntityRegistry, entitydata: dict, unique_id: str, ) -> None: @@ -178,8 +176,6 @@ async def test_unload_entry( mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) - entity_registry.async_get_or_create( WEATHER_DOMAIN, ZAMG_DOMAIN, diff --git a/tests/components/zeroconf/conftest.py b/tests/components/zeroconf/conftest.py index d52f8234922..d702ef482d6 100644 --- a/tests/components/zeroconf/conftest.py +++ b/tests/components/zeroconf/conftest.py @@ -1,9 +1 @@ """Tests for the Zeroconf component.""" - -import pytest - - -@pytest.fixture(autouse=True) -def zc_mock_get_source_ip(mock_get_source_ip): - """Enable the mock_get_source_ip fixture for all zeroconf tests.""" - return mock_get_source_ip diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 6a21212ed6e..0a552f37aa9 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,7 +1,7 @@ """Test Zeroconf component setup process.""" from typing import Any -from unittest.mock import call, patch +from unittest.mock import MagicMock, call, patch import pytest from zeroconf import ( @@ -148,7 +148,7 @@ def get_zeroconf_info_mock_model(model): return mock_zc_info -async def test_setup(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +async def test_setup(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> None: """Test configured options for a device are loaded via config entry.""" mock_zc = { "_http._tcp.local.": [ @@ -194,8 +194,9 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: None) -> None: assert await zeroconf.async_get_async_instance(hass) is mock_async_zeroconf +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_setup_with_overly_long_url_and_name( - hass: HomeAssistant, mock_async_zeroconf: None, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we still setup with long urls and names.""" with ( @@ -237,8 +238,9 @@ async def test_setup_with_overly_long_url_and_name( assert "German Umlaut" in caplog.text +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_setup_with_defaults( - hass: HomeAssistant, mock_zeroconf: None, mock_async_zeroconf: None + hass: HomeAssistant, mock_zeroconf: MagicMock ) -> None: """Test default interface config.""" with ( @@ -258,9 +260,8 @@ async def test_setup_with_defaults( ) -async def test_zeroconf_match_macaddress( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_zeroconf_match_macaddress(hass: HomeAssistant) -> None: """Test configured options for a device are loaded via config entry.""" def http_only_service_update_mock(zeroconf, services, handlers): @@ -305,9 +306,8 @@ async def test_zeroconf_match_macaddress( assert mock_config_flow.mock_calls[0][2]["context"] == {"source": "zeroconf"} -async def test_zeroconf_match_manufacturer( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_zeroconf_match_manufacturer(hass: HomeAssistant) -> None: """Test configured options for a device are loaded via config entry.""" def http_only_service_update_mock(zeroconf, services, handlers): @@ -347,9 +347,8 @@ async def test_zeroconf_match_manufacturer( assert mock_config_flow.mock_calls[0][1][0] == "samsungtv" -async def test_zeroconf_match_model( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_zeroconf_match_model(hass: HomeAssistant) -> None: """Test matching a specific model in zeroconf.""" def http_only_service_update_mock(zeroconf, services, handlers): @@ -389,9 +388,8 @@ async def test_zeroconf_match_model( assert mock_config_flow.mock_calls[0][1][0] == "appletv" -async def test_zeroconf_match_manufacturer_not_present( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_zeroconf_match_manufacturer_not_present(hass: HomeAssistant) -> None: """Test matchers reject when a property is missing.""" def http_only_service_update_mock(zeroconf, services, handlers): @@ -430,9 +428,8 @@ async def test_zeroconf_match_manufacturer_not_present( assert len(mock_config_flow.mock_calls) == 0 -async def test_zeroconf_no_match( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_zeroconf_no_match(hass: HomeAssistant) -> None: """Test configured options for a device are loaded via config entry.""" def http_only_service_update_mock(zeroconf, services, handlers): @@ -467,9 +464,8 @@ async def test_zeroconf_no_match( assert len(mock_config_flow.mock_calls) == 0 -async def test_zeroconf_no_match_manufacturer( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_zeroconf_no_match_manufacturer(hass: HomeAssistant) -> None: """Test configured options for a device are loaded via config entry.""" def http_only_service_update_mock(zeroconf, services, handlers): @@ -508,9 +504,8 @@ async def test_zeroconf_no_match_manufacturer( assert len(mock_config_flow.mock_calls) == 0 -async def test_homekit_match_partial_space( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_match_partial_space(hass: HomeAssistant) -> None: """Test configured options for a device are loaded via config entry.""" with ( patch.dict( @@ -550,8 +545,9 @@ async def test_homekit_match_partial_space( } +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_device_with_invalid_name( - hass: HomeAssistant, mock_async_zeroconf: None, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we ignore devices with an invalid name.""" with ( @@ -587,9 +583,8 @@ async def test_device_with_invalid_name( assert "Bad name in zeroconf record" in caplog.text -async def test_homekit_match_partial_dash( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_match_partial_dash(hass: HomeAssistant) -> None: """Test configured options for a device are loaded via config entry.""" with ( patch.dict( @@ -626,9 +621,8 @@ async def test_homekit_match_partial_dash( assert mock_config_flow.mock_calls[0][1][0] == "lutron_caseta" -async def test_homekit_match_partial_fnmatch( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_match_partial_fnmatch(hass: HomeAssistant) -> None: """Test matching homekit devices with fnmatch.""" with ( patch.dict( @@ -663,9 +657,8 @@ async def test_homekit_match_partial_fnmatch( assert mock_config_flow.mock_calls[0][1][0] == "yeelight" -async def test_homekit_match_full( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_match_full(hass: HomeAssistant) -> None: """Test configured options for a device are loaded via config entry.""" with ( patch.dict( @@ -700,9 +693,8 @@ async def test_homekit_match_full( assert mock_config_flow.mock_calls[0][1][0] == "hue" -async def test_homekit_already_paired( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_already_paired(hass: HomeAssistant) -> None: """Test that an already paired device is sent to homekit_controller.""" with ( patch.dict( @@ -741,9 +733,8 @@ async def test_homekit_already_paired( assert mock_config_flow.mock_calls[1][1][0] == "homekit_controller" -async def test_homekit_invalid_paring_status( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_invalid_paring_status(hass: HomeAssistant) -> None: """Test that missing paring data is not sent to homekit_controller.""" with ( patch.dict( @@ -778,9 +769,8 @@ async def test_homekit_invalid_paring_status( assert mock_config_flow.mock_calls[0][1][0] == "lutron_caseta" -async def test_homekit_not_paired( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_not_paired(hass: HomeAssistant) -> None: """Test that an not paired device is sent to homekit_controller.""" with ( patch.dict( @@ -808,8 +798,9 @@ async def test_homekit_not_paired( assert mock_config_flow.mock_calls[0][1][0] == "homekit_controller" +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_controller_still_discovered_unpaired_for_cloud( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test discovery is still passed to homekit controller when unpaired. @@ -852,8 +843,9 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( assert mock_config_flow.mock_calls[1][1][0] == "homekit_controller" +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_homekit_controller_still_discovered_unpaired_for_polling( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test discovery is still passed to homekit controller when unpaired. @@ -994,7 +986,9 @@ async def test_info_from_service_can_return_ipv6(hass: HomeAssistant) -> None: assert info.host == "fd11:1111:1111:0:1234:1234:1234:1234" -async def test_get_instance(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +async def test_get_instance( + hass: HomeAssistant, mock_async_zeroconf: MagicMock +) -> None: """Test we get an instance.""" assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) assert await zeroconf.async_get_async_instance(hass) is mock_async_zeroconf @@ -1008,7 +1002,8 @@ async def test_get_instance(hass: HomeAssistant, mock_async_zeroconf: None) -> N assert len(mock_async_zeroconf.ha_async_close.mock_calls) == 1 -async def test_removed_ignored(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_removed_ignored(hass: HomeAssistant) -> None: """Test we remove it when a zeroconf entry is removed.""" def service_update_mock(zeroconf, services, handlers): @@ -1060,8 +1055,9 @@ _ADAPTER_WITH_DEFAULT_ENABLED = [ ] +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_async_detect_interfaces_setting_non_loopback_route( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test without default interface and the route returns a non-loopback address.""" with ( @@ -1146,8 +1142,9 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ ] +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_async_detect_interfaces_setting_empty_route_linux( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test without default interface config and the route returns nothing on linux.""" with ( @@ -1179,8 +1176,9 @@ async def test_async_detect_interfaces_setting_empty_route_linux( ) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_async_detect_interfaces_setting_empty_route_freebsd( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test without default interface and the route returns nothing on freebsd.""" with ( @@ -1229,8 +1227,9 @@ _ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6 = [ ] +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_async_detect_interfaces_explicitly_set_ipv6_linux( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test interfaces are explicitly set when IPv6 is present on linux.""" with ( @@ -1257,8 +1256,9 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( ) +@pytest.mark.usefixtures("mock_async_zeroconf") async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, ) -> None: """Test interfaces are explicitly set when IPv6 is present on freebsd.""" with ( @@ -1285,7 +1285,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( ) -async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> None: """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = "" with patch("homeassistant.components.zeroconf.HaZeroconf"): @@ -1299,7 +1299,7 @@ async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: None) -> None: async def test_setup_with_disallowed_characters_in_local_name( - hass: HomeAssistant, mock_async_zeroconf: None, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test we still setup with disallowed characters in the location name.""" with ( @@ -1323,7 +1323,7 @@ async def test_setup_with_disallowed_characters_in_local_name( async def test_start_with_frontend( - hass: HomeAssistant, mock_async_zeroconf: None + hass: HomeAssistant, mock_async_zeroconf: MagicMock ) -> None: """Test we start with the frontend.""" with patch("homeassistant.components.zeroconf.HaZeroconf"): @@ -1334,7 +1334,8 @@ async def test_start_with_frontend( mock_async_zeroconf.async_register_service.assert_called_once() -async def test_zeroconf_removed(hass: HomeAssistant, mock_async_zeroconf: None) -> None: +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_zeroconf_removed(hass: HomeAssistant) -> None: """Test we dismiss flows when a PTR record is removed.""" def _device_removed_mock(zeroconf, services, handlers): diff --git a/tests/components/zeroconf/test_usage.py b/tests/components/zeroconf/test_usage.py index 9f5b68c2956..e79f2319915 100644 --- a/tests/components/zeroconf/test_usage.py +++ b/tests/components/zeroconf/test_usage.py @@ -15,11 +15,9 @@ from tests.common import extract_stack_to_frame DOMAIN = "zeroconf" +@pytest.mark.usefixtures("mock_async_zeroconf", "mock_zeroconf") async def test_multiple_zeroconf_instances( - hass: HomeAssistant, - mock_async_zeroconf: None, - mock_zeroconf: None, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test creating multiple zeroconf throws without an integration.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) @@ -34,11 +32,9 @@ async def test_multiple_zeroconf_instances( assert "Zeroconf" in caplog.text +@pytest.mark.usefixtures("mock_async_zeroconf", "mock_zeroconf") async def test_multiple_zeroconf_instances_gives_shared( - hass: HomeAssistant, - mock_async_zeroconf: None, - mock_zeroconf: None, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test creating multiple zeroconf gives the shared instance to an integration.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/zeversolar/__init__.py b/tests/components/zeversolar/__init__.py index c7e65bc62fd..9beaad38e3c 100644 --- a/tests/components/zeversolar/__init__.py +++ b/tests/components/zeversolar/__init__.py @@ -1 +1,52 @@ """Tests for the Zeversolar integration.""" + +from unittest.mock import patch + +from zeversolar import StatusEnum, ZeverSolarData + +from homeassistant.components.zeversolar.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_HOST_ZEVERSOLAR = "zeversolar-fake-host" +MOCK_PORT_ZEVERSOLAR = 10200 +MOCK_SERIAL_NUMBER = "123456778" + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock integration setup.""" + + zeverData = ZeverSolarData( + wifi_enabled=False, + serial_or_registry_id="EAB9615C0001", + registry_key="WSMQKHTQ3JVYQWA9", + hardware_version="M10", + software_version="19703-826R+17511-707R", + reported_datetime="19900101 23:01:45", + communication_status=StatusEnum.OK, + num_inverters=1, + serial_number=MOCK_SERIAL_NUMBER, + pac=1234, + energy_today=123.4, + status=StatusEnum.OK, + meter_status=StatusEnum.OK, + ) + + with ( + patch("zeversolar.ZeverSolarClient.get_data", return_value=zeverData), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCK_HOST_ZEVERSOLAR, + CONF_PORT: MOCK_PORT_ZEVERSOLAR, + }, + entry_id="my_id", + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/zeversolar/conftest.py b/tests/components/zeversolar/conftest.py new file mode 100644 index 00000000000..55d84f50a1b --- /dev/null +++ b/tests/components/zeversolar/conftest.py @@ -0,0 +1,47 @@ +"""Define mocks and test objects.""" + +import pytest +from zeversolar import StatusEnum, ZeverSolarData + +from homeassistant.components.zeversolar.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +MOCK_HOST_ZEVERSOLAR = "zeversolar-fake-host" +MOCK_PORT_ZEVERSOLAR = 10200 + + +@pytest.fixture +def config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + + return MockConfigEntry( + data={ + CONF_HOST: MOCK_HOST_ZEVERSOLAR, + CONF_PORT: MOCK_PORT_ZEVERSOLAR, + }, + domain=DOMAIN, + unique_id="my_id_2", + ) + + +@pytest.fixture +def zeversolar_data() -> ZeverSolarData: + """Create a ZeverSolarData structure for tests.""" + + return ZeverSolarData( + wifi_enabled=False, + serial_or_registry_id="1223", + registry_key="A-2", + hardware_version="M10", + software_version="123-23", + reported_datetime="19900101 23:00", + communication_status=StatusEnum.OK, + num_inverters=1, + serial_number="123456778", + pac=1234, + energy_today=123, + status=StatusEnum.OK, + meter_status=StatusEnum.OK, + ) diff --git a/tests/components/zeversolar/snapshots/test_diagnostics.ambr b/tests/components/zeversolar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..eebc8468076 --- /dev/null +++ b/tests/components/zeversolar/snapshots/test_diagnostics.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_device_diagnostics + dict({ + 'always_update': True, + 'last_update_success': True, + 'name': 'zeversolar', + 'update_interval': 60.0, + }) +# --- +# name: test_entry_diagnostics + dict({ + 'communication_status': 'OK', + 'hardware_version': 'M10', + 'meter_status': 'OK', + 'num_inverters': 1, + 'pac': 1234, + 'registry_key': 'WSMQKHTQ3JVYQWA9', + 'reported_datetime': '19900101 23:01:45', + 'serial_number': '123456778', + 'serial_or_registry_id': 'EAB9615C0001', + 'software_version': '19703-826R+17511-707R', + 'status': 'OK', + 'wifi_enabled': False, + }) +# --- diff --git a/tests/components/zeversolar/snapshots/test_sensor.ambr b/tests/components/zeversolar/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bee522133a5 --- /dev/null +++ b/tests/components/zeversolar/snapshots/test_sensor.ambr @@ -0,0 +1,123 @@ +# serializer version: 1 +# name: test_sensors + ConfigEntrySnapshot({ + 'data': dict({ + 'host': 'zeversolar-fake-host', + 'port': 10200, + }), + 'disabled_by': None, + 'domain': 'zeversolar', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }) +# --- +# name: test_sensors[sensor.zeversolar_sensor_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zeversolar_sensor_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy today', + 'platform': 'zeversolar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '123456778_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.zeversolar_sensor_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Zeversolar Sensor Energy today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zeversolar_sensor_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.4', + }) +# --- +# name: test_sensors[sensor.zeversolar_sensor_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zeversolar_sensor_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'zeversolar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pac', + 'unique_id': '123456778_pac', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.zeversolar_sensor_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Zeversolar Sensor Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.zeversolar_sensor_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234', + }) +# --- diff --git a/tests/components/zeversolar/test_diagnostics.py b/tests/components/zeversolar/test_diagnostics.py new file mode 100644 index 00000000000..0d7a919b023 --- /dev/null +++ b/tests/components/zeversolar/test_diagnostics.py @@ -0,0 +1,46 @@ +"""Tests for the diagnostics data provided by the Zeversolar integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.components.zeversolar import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MOCK_SERIAL_NUMBER, init_integration + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + + entry = await init_integration(hass) + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot + + +async def test_device_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics.""" + + entry = await init_integration(hass) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)} + ) + + assert ( + await get_diagnostics_for_device(hass, hass_client, entry, device) == snapshot + ) diff --git a/tests/components/zeversolar/test_init.py b/tests/components/zeversolar/test_init.py new file mode 100644 index 00000000000..3eee530a9a2 --- /dev/null +++ b/tests/components/zeversolar/test_init.py @@ -0,0 +1,39 @@ +"""Test the init file code.""" + +from unittest.mock import patch + +from zeversolar import ZeverSolarData +from zeversolar.exceptions import ZeverSolarTimeout + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry_fails( + hass: HomeAssistant, config_entry: MockConfigEntry, zeversolar_data: ZeverSolarData +) -> None: + """Test to load/unload the integration.""" + + config_entry.add_to_hass(hass) + + with ( + patch("zeversolar.ZeverSolarClient.get_data", side_effect=ZeverSolarTimeout), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + with ( + patch("homeassistant.components.zeversolar.PLATFORMS", []), + patch("zeversolar.ZeverSolarClient.get_data", return_value=zeversolar_data), + ): + hass.config_entries.async_schedule_reload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED + + with ( + patch("homeassistant.components.zeversolar.PLATFORMS", []), + ): + result = await hass.config_entries.async_unload(config_entry.entry_id) + assert result is True + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/zeversolar/test_sensor.py b/tests/components/zeversolar/test_sensor.py new file mode 100644 index 00000000000..b2b8edb08fa --- /dev/null +++ b/tests/components/zeversolar/test_sensor.py @@ -0,0 +1,27 @@ +"""Test the sensor classes.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test sensors.""" + + with patch( + "homeassistant.components.zeversolar.PLATFORMS", + [Platform.SENSOR], + ): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index addf1e24ea9..a8bec33a23a 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -14,6 +14,7 @@ from homeassistant.components.zha.core.helpers import ( async_get_zha_config_value, get_zha_gateway, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -102,7 +103,9 @@ def send_attribute_report(hass, cluster, attrid, value): return send_attributes_report(hass, cluster, {attrid: value}) -async def send_attributes_report(hass, cluster: zigpy.zcl.Cluster, attributes: dict): +async def send_attributes_report( + hass: HomeAssistant, cluster: zigpy.zcl.Cluster, attributes: dict +): """Cause the sensor to receive an attribute report from the network. This is to simulate the normal device communication that happens when a diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 54440a0f75b..410eaceda76 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,6 +1,6 @@ """Test configuration for the ZHA component.""" -from collections.abc import Callable, Generator +from collections.abc import Callable import itertools import time from typing import Any @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch import warnings import pytest +from typing_extensions import Generator import zigpy from zigpy.application import ControllerApplication import zigpy.backups @@ -28,6 +29,7 @@ import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.helpers import get_zha_gateway +from homeassistant.core import HomeAssistant from homeassistant.helpers import restore_state from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -62,7 +64,7 @@ def globally_load_quirks(): run. """ - import zhaquirks + import zhaquirks # pylint: disable=import-outside-toplevel zhaquirks.setup() @@ -197,7 +199,7 @@ async def zigpy_app_controller(): @pytest.fixture(name="config_entry") -async def config_entry_fixture(hass) -> MockConfigEntry: +async def config_entry_fixture() -> MockConfigEntry: """Fixture representing a config entry.""" return MockConfigEntry( version=3, @@ -225,7 +227,7 @@ async def config_entry_fixture(hass) -> MockConfigEntry: @pytest.fixture def mock_zigpy_connect( zigpy_app_controller: ControllerApplication, -) -> Generator[ControllerApplication, None, None]: +) -> Generator[ControllerApplication]: """Patch the zigpy radio connection with our mock application.""" with ( patch( @@ -242,7 +244,9 @@ def mock_zigpy_connect( @pytest.fixture def setup_zha( - hass, config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, ): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} @@ -385,7 +389,7 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha): @pytest.fixture(params=["zha_device_joined", "zha_device_restored"]) -def zha_device_joined_restored(request): +def zha_device_joined_restored(request: pytest.FixtureRequest): """Join or restore ZHA device.""" named_method = request.getfixturevalue(request.param) named_method.name = request.param @@ -394,7 +398,7 @@ def zha_device_joined_restored(request): @pytest.fixture def zha_device_mock( - hass, config_entry, zigpy_device_mock + hass: HomeAssistant, config_entry, zigpy_device_mock ) -> Callable[..., zha_core_device.ZHADevice]: """Return a ZHA Device factory.""" @@ -519,10 +523,10 @@ def network_backup() -> zigpy.backups.NetworkBackup: @pytest.fixture -def core_rs(hass_storage): +def core_rs(hass_storage: dict[str, Any]) -> Callable[[str, Any, dict[str, Any]], None]: """Core.restore_state fixture.""" - def _storage(entity_id, state, attributes={}): + def _storage(entity_id: str, state: str, attributes: dict[str, Any]) -> None: now = dt_util.utcnow().isoformat() hass_storage[restore_state.STORAGE_KEY] = { diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 9e35e482fcf..ed3394aafba 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -9,7 +9,6 @@ import pytest import zigpy.backups import zigpy.state -from homeassistant.components import zha from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType from homeassistant.components.zha.core.helpers import get_zha_gateway @@ -43,7 +42,7 @@ async def test_async_get_network_settings_inactive( await setup_zha() gateway = get_zha_gateway(hass) - await zha.async_unload_entry(hass, gateway.config_entry) + await hass.config_entries.async_unload(gateway.config_entry.entry_id) backup = zigpy.backups.NetworkBackup() backup.network_info.channel = 20 @@ -70,7 +69,7 @@ async def test_async_get_network_settings_missing( await setup_zha() gateway = get_zha_gateway(hass) - await gateway.config_entry.async_unload(hass) + await hass.config_entries.async_unload(gateway.config_entry.entry_id) # Network settings were never loaded for whatever reason zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() diff --git a/tests/components/zha/test_backup.py b/tests/components/zha/test_backup.py index 9cf88df1707..dc6c5dc29cb 100644 --- a/tests/components/zha/test_backup.py +++ b/tests/components/zha/test_backup.py @@ -1,6 +1,6 @@ """Unit tests for ZHA backup platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from zigpy.application import ControllerApplication @@ -22,6 +22,13 @@ async def test_pre_backup( ) +@patch("homeassistant.components.zha.backup.get_zha_gateway", side_effect=ValueError()) +async def test_pre_backup_no_gateway(hass: HomeAssistant, setup_zha) -> None: + """Test graceful backup failure when no gateway exists.""" + await setup_zha() + await async_pre_backup(hass) + + async def test_post_backup(hass: HomeAssistant, setup_zha) -> None: """Test no-op `async_post_backup`.""" await setup_zha() diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py index e9c5a0a8e9c..203df2ffda5 100644 --- a/tests/components/zha/test_base.py +++ b/tests/components/zha/test_base.py @@ -2,18 +2,18 @@ from homeassistant.components.zha.core.cluster_handlers import parse_and_log_command -from tests.components.zha.test_cluster_handlers import ( # noqa: F401 +from .test_cluster_handlers import ( # noqa: F401 endpoint, poll_control_ch, zigpy_coordinator_device, ) -def test_parse_and_log_command(poll_control_ch): # noqa: F811 +def test_parse_and_log_command(poll_control_ch) -> None: # noqa: F811 """Test that `parse_and_log_command` correctly parses a known command.""" assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" -def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 +def test_parse_and_log_command_unknown(poll_control_ch) -> None: # noqa: F811 """Test that `parse_and_log_command` correctly parses an unknown command.""" assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index bd9262a41ce..8276223926d 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -1,5 +1,7 @@ """Test ZHA binary sensor.""" +from collections.abc import Callable +from typing import Any from unittest.mock import patch import pytest @@ -158,9 +160,9 @@ async def test_binary_sensor( async def test_onoff_binary_sensor_restore_state( hass: HomeAssistant, zigpy_device_mock, - core_rs, + core_rs: Callable[[str, Any, dict[str, Any]], None], zha_device_restored, - restored_state, + restored_state: str, ) -> None: """Test ZHA OnOff binary_sensor restores last state from HA.""" diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index 97aaf2bd871..fdcc0d7271c 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -136,10 +136,11 @@ async def tuya_water_valve( @freeze_time("2021-11-04 17:37:00", tz_offset=-1) -async def test_button(hass: HomeAssistant, contact_sensor) -> None: +async def test_button( + hass: HomeAssistant, entity_registry: er.EntityRegistry, contact_sensor +) -> None: """Test ZHA button platform.""" - entity_registry = er.async_get(hass) zha_device, cluster = contact_sensor assert cluster is not None entity_id = find_entity_id(DOMAIN, zha_device, hass) @@ -176,10 +177,11 @@ async def test_button(hass: HomeAssistant, contact_sensor) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.IDENTIFY -async def test_frost_unlock(hass: HomeAssistant, tuya_water_valve) -> None: +async def test_frost_unlock( + hass: HomeAssistant, entity_registry: er.EntityRegistry, tuya_water_valve +) -> None: """Test custom frost unlock ZHA button.""" - entity_registry = er.async_get(hass) zha_device, cluster = tuya_water_valve assert cluster is not None entity_id = find_entity_id(DOMAIN, zha_device, hass, qualifier="frost_lock_reset") diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 16563f62e06..32ef08fcd96 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -1262,7 +1262,7 @@ async def test_set_fan_mode_not_supported( {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert fan_cluster.write_attributes.await_count == 0 + assert fan_cluster.write_attributes.await_count == 0 async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None: @@ -1458,6 +1458,7 @@ async def test_set_moes_operation_mode( [ (0, PRESET_AWAY), (1, PRESET_SCHEDULE), + # pylint: disable-next=fixme # (2, PRESET_NONE), # TODO: why does this not work? (4, PRESET_ECO), (5, PRESET_BOOST), diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index ca21b74e106..655a36a2492 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -3,6 +3,7 @@ from collections.abc import Callable import logging import math +import threading from types import NoneType from unittest import mock from unittest.mock import AsyncMock, patch @@ -86,6 +87,7 @@ def endpoint(zigpy_coordinator_device): type(endpoint_mock.device).skip_configuration = mock.PropertyMock( return_value=False ) + endpoint_mock.device.hass.loop_thread_id = threading.get_ident() endpoint_mock.id = 1 return endpoint_mock @@ -345,6 +347,7 @@ def test_cluster_handler_registry() -> None: all_quirk_ids = {} for cluster_id in CLUSTERS_BY_ID: all_quirk_ids[cluster_id] = {None} + # pylint: disable-next=too-many-nested-blocks for manufacturer in zigpy_quirks._DEVICE_REGISTRY.registry.values(): for model_quirk_list in manufacturer.values(): for quirk in model_quirk_list: @@ -366,6 +369,7 @@ def test_cluster_handler_registry() -> None: all_quirk_ids[cluster_id] = {None} all_quirk_ids[cluster_id].add(quirk_id) + # pylint: disable-next=undefined-loop-variable del quirk, model_quirk_list, manufacturer for ( @@ -583,7 +587,7 @@ async def test_ep_cluster_handlers_configure(cluster_handler) -> None: await endpoint.async_configure() await endpoint.async_initialize(mock.sentinel.from_cache) - for ch in [*claimed.values(), *client_handlers.values()]: + for ch in (*claimed.values(), *client_handlers.values()): assert ch.async_initialize.call_count == 1 assert ch.async_initialize.await_count == 1 assert ch.async_initialize.call_args[0][0] is mock.sentinel.from_cache @@ -839,7 +843,9 @@ async def test_configure_reporting(hass: HomeAssistant, endpoint) -> None: ] -async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: +async def test_invalid_cluster_handler( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test setting up a cluster handler that fails to match properly.""" class TestZigbeeClusterHandler(cluster_handlers.ClusterHandler): @@ -881,7 +887,9 @@ async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: assert "missing_attr" in caplog.text -async def test_standard_cluster_handler(hass: HomeAssistant, caplog) -> None: +async def test_standard_cluster_handler( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test setting up a cluster handler that matches a standard cluster.""" class TestZigbeeClusterHandler(ColorClusterHandler): @@ -916,7 +924,9 @@ async def test_standard_cluster_handler(hass: HomeAssistant, caplog) -> None: ) -async def test_quirk_id_cluster_handler(hass: HomeAssistant, caplog) -> None: +async def test_quirk_id_cluster_handler( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test setting up a cluster handler that matches a standard cluster.""" class TestZigbeeClusterHandler(ColorClusterHandler): diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index fefc68a8d94..87acdc5fd1c 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -247,12 +247,13 @@ async def test_check_available_no_basic_cluster_handler( assert "does not have a mandatory basic cluster" in caplog.text -async def test_ota_sw_version(hass: HomeAssistant, ota_zha_device) -> None: +async def test_ota_sw_version( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, ota_zha_device +) -> None: """Test device entry gets sw_version updated via OTA cluster handler.""" ota_ch = ota_zha_device._endpoints[1].client_cluster_handlers["1:0x0019"] - dev_registry = dr.async_get(hass) - entry = dev_registry.async_get(ota_zha_device.device_id) + entry = device_registry.async_get(ota_zha_device.device_id) assert entry.sw_version is None cluster = ota_ch.cluster @@ -260,7 +261,7 @@ async def test_ota_sw_version(hass: HomeAssistant, ota_zha_device) -> None: sw_version = 0x2345 cluster.handle_message(hdr, [1, 2, 3, sw_version, None]) await hass.async_block_till_done() - entry = dev_registry.async_get(ota_zha_device.device_id) + entry = device_registry.async_get(ota_zha_device.device_id) assert int(entry.sw_version, base=16) == sw_version @@ -308,7 +309,7 @@ async def test_ota_sw_version(hass: HomeAssistant, ota_zha_device) -> None: ) async def test_device_restore_availability( hass: HomeAssistant, - request, + request: pytest.FixtureRequest, device, last_seen_delta, is_available, diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index bc478532859..13e9d789191 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -103,14 +103,17 @@ async def device_inovelli(hass, zigpy_device_mock, zha_device_joined): return zigpy_device, zha_device -async def test_get_actions(hass: HomeAssistant, device_ias) -> None: +async def test_get_actions( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_ias, +) -> None: """Test we get the expected actions from a ZHA device.""" ieee_address = str(device_ias[0].ieee) - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) - entity_registry = er.async_get(hass) siren_level_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_level" ) @@ -146,34 +149,37 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: "entity_id": entity_id, "metadata": {"secondary": True}, } - for action in [ + for action in ( "select_first", "select_last", "select_next", "select_option", "select_previous", - ] - for entity_id in [ + ) + for entity_id in ( siren_level_select.id, siren_tone_select.id, strobe_level_select.id, strobe_select.id, - ] + ) ] ) assert actions == unordered(expected_actions) -async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> None: +async def test_get_inovelli_actions( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_inovelli, +) -> None: """Test we get the expected actions from a ZHA device.""" inovelli_ieee_address = str(device_inovelli[0].ieee) - device_registry = dr.async_get(hass) inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} ) - entity_registry = er.async_get(hass) inovelli_button = entity_registry.async_get("button.inovelli_vzm31_sn_identify") inovelli_light = entity_registry.async_get("light.inovelli_vzm31_sn_light") @@ -248,7 +254,9 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: +async def test_action( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, device_ias, device_inovelli +) -> None: """Test for executing a ZHA device action.""" zigpy_device, zha_device = device_ias inovelli_zigpy_device, inovelli_zha_device = device_inovelli @@ -260,7 +268,6 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: ieee_address = str(zha_device.ieee) inovelli_ieee_address = str(inovelli_zha_device.ieee) - device_registry = dr.async_get(hass) reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 2cb7c8c94e7..b43392af61a 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.components.device_automation.exceptions import ( ) from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -76,7 +76,7 @@ def _same_lists(list_a, list_b): @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -93,7 +93,9 @@ async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored): return zigpy_device, zha_device -async def test_triggers(hass: HomeAssistant, mock_devices) -> None: +async def test_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_devices +) -> None: """Test ZHA device triggers.""" zigpy_device, zha_device = mock_devices @@ -108,10 +110,7 @@ async def test_triggers(hass: HomeAssistant, mock_devices) -> None: ieee_address = str(zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={("zha", ieee_address)} - ) + reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, reg_device.id @@ -170,16 +169,15 @@ async def test_triggers(hass: HomeAssistant, mock_devices) -> None: assert _same_lists(triggers, expected_triggers) -async def test_no_triggers(hass: HomeAssistant, mock_devices) -> None: +async def test_no_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_devices +) -> None: """Test ZHA device with no triggers.""" _, zha_device = mock_devices ieee_address = str(zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={("zha", ieee_address)} - ) + reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, reg_device.id @@ -196,7 +194,12 @@ async def test_no_triggers(hass: HomeAssistant, mock_devices) -> None: ] -async def test_if_fires_on_event(hass: HomeAssistant, mock_devices, calls) -> None: +async def test_if_fires_on_event( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_devices, + calls: list[ServiceCall], +) -> None: """Test for remote triggers firing.""" zigpy_device, zha_device = mock_devices @@ -210,10 +213,7 @@ async def test_if_fires_on_event(hass: HomeAssistant, mock_devices, calls) -> No } ieee_address = str(zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={("zha", ieee_address)} - ) + reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) assert await async_setup_component( hass, @@ -248,7 +248,10 @@ async def test_if_fires_on_event(hass: HomeAssistant, mock_devices, calls) -> No async def test_device_offline_fires( - hass: HomeAssistant, zigpy_device_mock, zha_device_restored, calls + hass: HomeAssistant, + zigpy_device_mock, + zha_device_restored, + calls: list[ServiceCall], ) -> None: """Test for device offline triggers firing.""" @@ -314,17 +317,18 @@ async def test_device_offline_fires( async def test_exception_no_triggers( - hass: HomeAssistant, mock_devices, calls, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_devices, + calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, ) -> None: """Test for exception when validating device triggers.""" _, zha_device = mock_devices ieee_address = str(zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={("zha", ieee_address)} - ) + reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) await async_setup_component( hass, @@ -355,7 +359,11 @@ async def test_exception_no_triggers( async def test_exception_bad_trigger( - hass: HomeAssistant, mock_devices, calls, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_devices, + calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, ) -> None: """Test for exception when validating device triggers.""" @@ -370,10 +378,7 @@ async def test_exception_bad_trigger( } ieee_address = str(zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={("zha", ieee_address)} - ) + reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) await async_setup_component( hass, @@ -405,6 +410,7 @@ async def test_exception_bad_trigger( async def test_validate_trigger_config_missing_info( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, zigpy_device_mock, mock_zigpy_connect: ControllerApplication, @@ -421,8 +427,7 @@ async def test_validate_trigger_config_missing_info( # it be pulled from the current device, making it impossible to validate triggers await hass.config_entries.async_unload(config_entry.entry_id) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( + reg_device = device_registry.async_get_device( identifiers={("zha", str(switch.ieee))} ) @@ -458,6 +463,7 @@ async def test_validate_trigger_config_missing_info( async def test_validate_trigger_config_unloaded_bad_info( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, zigpy_device_mock, mock_zigpy_connect: ControllerApplication, @@ -479,8 +485,7 @@ async def test_validate_trigger_config_unloaded_bad_info( await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( + reg_device = device_registry.async_get_device( identifiers={("zha", str(switch.ieee))} ) diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 50b07b70e8d..4bb30a5fc8c 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -12,7 +12,7 @@ from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers import device_registry as dr from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -104,6 +104,7 @@ async def test_diagnostics_for_config_entry( async def test_diagnostics_for_device( hass: HomeAssistant, hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, zha_device_joined, zigpy_device, @@ -126,8 +127,9 @@ async def test_diagnostics_for_device( } ) - dev_reg = async_get(hass) - device = dev_reg.async_get_device(identifiers={("zha", str(zha_device.ieee))}) + device = device_registry.async_get_device( + identifiers={("zha", str(zha_device.ieee))} + ) assert device diagnostics_data = await get_diagnostics_for_device( hass, hass_client, config_entry, device diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index a7e466f1caa..c59acc3395f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -487,13 +487,13 @@ async def test_group_probe_cleanup_called( """Test cleanup happens when ZHA is unloaded.""" await setup_zha() disc.GROUP_PROBE.cleanup = mock.Mock(wraps=disc.GROUP_PROBE.cleanup) - await config_entry.async_unload(hass_disable_services) + await hass_disable_services.config_entries.async_unload(config_entry.entry_id) await hass_disable_services.async_block_till_done() disc.GROUP_PROBE.cleanup.assert_called() async def test_quirks_v2_entity_discovery( - hass, + hass: HomeAssistant, zigpy_device_mock, zha_device_joined, ) -> None: @@ -561,7 +561,7 @@ async def test_quirks_v2_entity_discovery( async def test_quirks_v2_entity_discovery_e1_curtain( - hass, + hass: HomeAssistant, zigpy_device_mock, zha_device_joined, ) -> None: @@ -789,7 +789,7 @@ async def test_quirks_v2_entity_no_metadata( setattr(zigpy_device, "_exposes_metadata", {}) zha_device = await zha_device_joined(zigpy_device) assert ( - f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not expose any quirks v2 entities" + f"Device: {zigpy_device.ieee!s}-{zha_device.name} does not expose any quirks v2 entities" in caplog.text ) @@ -807,14 +807,14 @@ async def test_quirks_v2_entity_discovery_errors( ) zha_device = await zha_device_joined(zigpy_device) - m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not have an" + m1 = f"Device: {zigpy_device.ieee!s}-{zha_device.name} does not have an" m2 = " endpoint with id: 3 - unable to create entity with cluster" m3 = " details: (3, 6, )" assert f"{m1}{m2}{m3}" in caplog.text time_cluster_id = zigpy.zcl.clusters.general.Time.cluster_id - m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not have a" + m1 = f"Device: {zigpy_device.ieee!s}-{zha_device.name} does not have a" m2 = f" cluster with id: {time_cluster_id} - unable to create entity with " m3 = f"cluster details: (1, {time_cluster_id}, )" assert f"{m1}{m2}{m3}" in caplog.text @@ -831,7 +831,7 @@ async def test_quirks_v2_entity_discovery_errors( ) # fmt: on - m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} has an entity with " + m1 = f"Device: {zigpy_device.ieee!s}-{zha_device.name} has an entity with " m2 = f"details: {entity_details} that does not have an entity class mapping - " m3 = "unable to create entity" assert f"{m1}{m2}{m3}" in caplog.text @@ -986,30 +986,30 @@ async def test_quirks_v2_metadata_errors( validate_metadata(validate_method) # ensure the error is caught and raised - with pytest.raises(ValueError, match=expected_exception_string): - try: - # introduce an error - zigpy_device = _get_test_device( - zigpy_device_mock, + try: + # introduce an error + zigpy_device = _get_test_device( + zigpy_device_mock, + "Ikea of Sweden4", + "TRADFRI remote control4", + augment_method=augment_method, + ) + await zha_device_joined(zigpy_device) + + validate_metadata(validate_method) + # if the device was created we remove it + # so we don't pollute the rest of the tests + zigpy.quirks._DEVICE_REGISTRY.remove(zigpy_device) + except ValueError: + # if the device was not created we remove it + # so we don't pollute the rest of the tests + zigpy.quirks._DEVICE_REGISTRY._registry_v2.pop( + ( "Ikea of Sweden4", "TRADFRI remote control4", - augment_method=augment_method, - ) - await zha_device_joined(zigpy_device) - - validate_metadata(validate_method) - # if the device was created we remove it - # so we don't pollute the rest of the tests - zigpy.quirks._DEVICE_REGISTRY.remove(zigpy_device) - except ValueError: - # if the device was not created we remove it - # so we don't pollute the rest of the tests - zigpy.quirks._DEVICE_REGISTRY._registry_v2.pop( - ( - "Ikea of Sweden4", - "TRADFRI remote control4", - ) ) + ) + with pytest.raises(ValueError, match=expected_exception_string): raise diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 5ed7c7bfeed..095f505876e 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -238,7 +238,7 @@ async def async_turn_on(hass, entity_id, percentage=None): """Turn fan on.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)) if value is not None } @@ -256,7 +256,7 @@ async def async_set_percentage(hass, entity_id, percentage=None): """Set percentage for specified fan.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)) if value is not None } @@ -269,7 +269,7 @@ async def async_set_preset_mode(hass, entity_id, preset_mode=None): """Set preset_mode for specified fan.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)] + for key, value in ((ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)) if value is not None } diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 666594bd854..3a576ed6e55 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -300,7 +300,7 @@ async def test_single_reload_on_multiple_connection_loss( hass: HomeAssistant, zigpy_app_controller: ControllerApplication, config_entry: MockConfigEntry, -): +) -> None: """Test that we only reload once when we lose the connection multiple times.""" config_entry.add_to_hass(hass) @@ -333,7 +333,7 @@ async def test_startup_concurrency_limit( zigpy_app_controller: ControllerApplication, config_entry: MockConfigEntry, zigpy_device_mock, -): +) -> None: """Test ZHA gateway limits concurrency on startup.""" config_entry.add_to_hass(hass) zha_gateway = ZHAGateway(hass, {}, config_entry) diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 70ba88ee6e7..4d4956d3978 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -233,7 +233,7 @@ async def test_zha_retry_unique_ids( config_entry: MockConfigEntry, zigpy_device_mock, mock_zigpy_connect: ControllerApplication, - caplog, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that ZHA retrying creates unique entity IDs.""" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 762ab14cbaa..a9d32362863 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -1,6 +1,8 @@ """Test ZHA light.""" +from collections.abc import Callable from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, call, patch, sentinel import pytest @@ -454,6 +456,7 @@ async def test_light_initialization( assert entity_id is not None + # pylint: disable-next=fixme # TODO ensure hue and saturation are properly set on startup @@ -1463,7 +1466,11 @@ async def async_test_off_from_hass(hass, cluster, entity_id): async def async_test_level_on_off_from_hass( - hass, on_off_cluster, level_cluster, entity_id, expected_default_transition: int = 0 + hass: HomeAssistant, + on_off_cluster, + level_cluster, + entity_id, + expected_default_transition: int = 0, ): """Test on off functionality from hass.""" @@ -1601,7 +1608,12 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash): new=0, ) async def test_zha_group_light_entity( - hass: HomeAssistant, device_light_1, device_light_2, device_light_3, coordinator + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_light_1, + device_light_2, + device_light_3, + coordinator, ) -> None: """Test the light entity for a ZHA group.""" zha_gateway = get_zha_gateway(hass) @@ -1782,7 +1794,6 @@ async def test_zha_group_light_entity( assert device_3_entity_id not in zha_group.member_entity_ids # make sure the entity registry entry is still there - entity_registry = er.async_get(hass) assert entity_registry.async_get(group_entity_id) is not None # add a member back and ensure that the group entity was created again @@ -1829,6 +1840,7 @@ async def test_zha_group_light_entity( ) async def test_group_member_assume_state( hass: HomeAssistant, + entity_registry: er.EntityRegistry, zigpy_device_mock, zha_device_joined, coordinator, @@ -1916,7 +1928,6 @@ async def test_group_member_assume_state( assert hass.states.get(group_entity_id).state == STATE_OFF # remove the group and ensure that there is no entity and that the entity registry is cleaned up - entity_registry = er.async_get(hass) assert entity_registry.async_get(group_entity_id) is not None await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None @@ -1957,10 +1968,10 @@ async def test_group_member_assume_state( async def test_restore_light_state( hass: HomeAssistant, zigpy_device_mock, - core_rs, + core_rs: Callable[[str, Any, dict[str, Any]], None], zha_device_restored, - restored_state, - expected_state, + restored_state: str, + expected_state: dict[str, Any], ) -> None: """Test ZHA light restores without throwing an error when attributes are None.""" diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py index 317e10346f0..19a6f9d359f 100644 --- a/tests/components/zha/test_logbook.py +++ b/tests/components/zha/test_logbook.py @@ -61,7 +61,7 @@ async def mock_devices(hass, zigpy_device_mock, zha_device_joined): async def test_zha_logbook_event_device_with_triggers( - hass: HomeAssistant, mock_devices + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_devices ) -> None: """Test ZHA logbook events with device and triggers.""" @@ -78,10 +78,7 @@ async def test_zha_logbook_event_device_with_triggers( ieee_address = str(zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={("zha", ieee_address)} - ) + reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -96,7 +93,7 @@ async def test_zha_logbook_event_device_with_triggers( CONF_DEVICE_ID: reg_device.id, COMMAND: COMMAND_SHAKE, "device_ieee": str(ieee_address), - CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + CONF_UNIQUE_ID: f"{ieee_address!s}:1:0x0006", "endpoint_id": 1, "cluster_id": 6, "params": { @@ -110,7 +107,7 @@ async def test_zha_logbook_event_device_with_triggers( CONF_DEVICE_ID: reg_device.id, COMMAND: COMMAND_DOUBLE, "device_ieee": str(ieee_address), - CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + CONF_UNIQUE_ID: f"{ieee_address!s}:1:0x0006", "endpoint_id": 1, "cluster_id": 6, "params": { @@ -124,7 +121,7 @@ async def test_zha_logbook_event_device_with_triggers( CONF_DEVICE_ID: reg_device.id, COMMAND: COMMAND_DOUBLE, "device_ieee": str(ieee_address), - CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + CONF_UNIQUE_ID: f"{ieee_address!s}:1:0x0006", "endpoint_id": 2, "cluster_id": 6, "params": { @@ -151,16 +148,13 @@ async def test_zha_logbook_event_device_with_triggers( async def test_zha_logbook_event_device_no_triggers( - hass: HomeAssistant, mock_devices + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_devices ) -> None: """Test ZHA logbook events with device and without triggers.""" zigpy_device, zha_device = mock_devices ieee_address = str(zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={("zha", ieee_address)} - ) + reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -175,7 +169,7 @@ async def test_zha_logbook_event_device_no_triggers( CONF_DEVICE_ID: reg_device.id, COMMAND: COMMAND_SHAKE, "device_ieee": str(ieee_address), - CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + CONF_UNIQUE_ID: f"{ieee_address!s}:1:0x0006", "endpoint_id": 1, "cluster_id": 6, "params": { @@ -188,7 +182,7 @@ async def test_zha_logbook_event_device_no_triggers( { CONF_DEVICE_ID: reg_device.id, "device_ieee": str(ieee_address), - CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + CONF_UNIQUE_ID: f"{ieee_address!s}:1:0x0006", "endpoint_id": 1, "cluster_id": 6, "params": { @@ -201,7 +195,7 @@ async def test_zha_logbook_event_device_no_triggers( { CONF_DEVICE_ID: reg_device.id, "device_ieee": str(ieee_address), - CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + CONF_UNIQUE_ID: f"{ieee_address!s}:1:0x0006", "endpoint_id": 1, "cluster_id": 6, "params": {}, @@ -212,7 +206,7 @@ async def test_zha_logbook_event_device_no_triggers( { CONF_DEVICE_ID: reg_device.id, "device_ieee": str(ieee_address), - CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + CONF_UNIQUE_ID: f"{ieee_address!s}:1:0x0006", "endpoint_id": 1, "cluster_id": 6, }, diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index b3fc42c35df..6b302f9cbd9 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -200,6 +200,7 @@ async def test_number( ) async def test_level_control_number( hass: HomeAssistant, + entity_registry: er.EntityRegistry, light: ZHADevice, zha_device_joined, attr: str, @@ -207,8 +208,6 @@ async def test_level_control_number( new_value: int, ) -> None: """Test ZHA level control number entities - new join.""" - - entity_registry = er.async_get(hass) level_control_cluster = light.endpoints[1].level level_control_cluster.PLUGGED_ATTR_READS = { attr: initial_value, @@ -325,6 +324,7 @@ async def test_level_control_number( ) async def test_color_number( hass: HomeAssistant, + entity_registry: er.EntityRegistry, light: ZHADevice, zha_device_joined, attr: str, @@ -332,8 +332,6 @@ async def test_color_number( new_value: int, ) -> None: """Test ZHA color number entities - new join.""" - - entity_registry = er.async_get(hass) color_cluster = light.endpoints[1].light_color color_cluster.PLUGGED_ATTR_READS = { attr: initial_value, diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 0363821ac47..280b3d05daf 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -1,10 +1,10 @@ """Tests for ZHA config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, create_autospec, patch import pytest import serial.tools.list_ports +from typing_extensions import Generator from zigpy.backups import BackupManager import zigpy.config from zigpy.config import CONF_DEVICE_PATH @@ -87,7 +87,7 @@ def com_port(device="/dev/ttyUSB1234"): @pytest.fixture -def mock_connect_zigpy_app() -> Generator[MagicMock, None, None]: +def mock_connect_zigpy_app() -> Generator[MagicMock]: """Mock the radio connection.""" mock_connect_app = MagicMock() diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 279975a260f..2b1c0dcc561 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -2,20 +2,18 @@ from __future__ import annotations -import typing from unittest import mock import pytest +from typing_extensions import Generator import zigpy.quirks as zigpy_quirks from homeassistant.components.zha.binary_sensor import IASZone from homeassistant.components.zha.core import registries from homeassistant.components.zha.core.const import ATTR_QUIRK_ID +from homeassistant.components.zha.entity import ZhaEntity from homeassistant.helpers import entity_registry as er -if typing.TYPE_CHECKING: - from homeassistant.components.zha.core.entity import ZhaEntity - MANUFACTURER = "mock manufacturer" MODEL = "mock model" QUIRK_CLASS = "mock.test.quirk.class" @@ -532,7 +530,7 @@ def test_multi_sensor_match( } -def iter_all_rules() -> typing.Iterable[registries.MatchRule, list[type[ZhaEntity]]]: +def iter_all_rules() -> Generator[tuple[registries.MatchRule, list[type[ZhaEntity]]]]: """Iterate over all match rules and their corresponding entities.""" for rules in registries.ZHA_ENTITIES._strict_registry.values(): @@ -576,6 +574,7 @@ def test_quirk_classes() -> None: quirk_id = getattr(quirk, ATTR_QUIRK_ID, None) if quirk_id is not None and quirk_id not in all_quirk_ids: all_quirk_ids.append(quirk_id) + # pylint: disable-next=undefined-loop-variable del quirk, model_quirk_list, manufacturer # validate all quirk IDs used in component match rules diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 5b57ec7fcc2..c093fe266bd 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -12,7 +12,7 @@ from zigpy.application import ControllerApplication import zigpy.backups from zigpy.exceptions import NetworkSettingsInconsistent -from homeassistant.components.homeassistant_sky_connect.const import ( +from homeassistant.components.homeassistant_sky_connect.const import ( # pylint: disable=hass-component-root-import DOMAIN as SKYCONNECT_DOMAIN, ) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN @@ -134,6 +134,7 @@ async def test_multipan_firmware_repair( expected_learn_more_url: str, config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication, + issue_registry: ir.IssueRegistry, ) -> None: """Test creating a repair when multi-PAN firmware is installed and probed.""" @@ -162,8 +163,6 @@ async def test_multipan_firmware_repair( await hass.config_entries.async_unload(config_entry.entry_id) - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( domain=DOMAIN, issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, @@ -186,7 +185,7 @@ async def test_multipan_firmware_repair( async def test_multipan_firmware_no_repair_on_probe_failure( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry ) -> None: """Test that a repair is not created when multi-PAN firmware cannot be probed.""" @@ -212,7 +211,6 @@ async def test_multipan_firmware_no_repair_on_probe_failure( await hass.config_entries.async_unload(config_entry.entry_id) # No repair is created - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue( domain=DOMAIN, issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, @@ -224,6 +222,7 @@ async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication, + issue_registry: ir.IssueRegistry, ) -> None: """Test that ZHA is reloaded when EZSP firmware is probed.""" @@ -250,7 +249,6 @@ async def test_multipan_firmware_retry_on_probe_ezsp( await hass.config_entries.async_unload(config_entry.entry_id) # No repair is created - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue( domain=DOMAIN, issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, @@ -299,6 +297,7 @@ async def test_inconsistent_settings_keep_new( config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication, network_backup: zigpy.backups.NetworkBackup, + issue_registry: ir.IssueRegistry, ) -> None: """Test inconsistent ZHA network settings: keep new settings.""" @@ -326,8 +325,6 @@ async def test_inconsistent_settings_keep_new( await hass.config_entries.async_unload(config_entry.entry_id) - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( domain=DOMAIN, issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, @@ -379,6 +376,7 @@ async def test_inconsistent_settings_restore_old( config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication, network_backup: zigpy.backups.NetworkBackup, + issue_registry: ir.IssueRegistry, ) -> None: """Test inconsistent ZHA network settings: restore last backup.""" @@ -406,8 +404,6 @@ async def test_inconsistent_settings_restore_old( await hass.config_entries.async_unload(config_entry.entry_id) - issue_registry = ir.async_get(hass) - issue = issue_registry.async_get_issue( domain=DOMAIN, issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index 1d3811d0293..70f58ee4e6d 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -1,5 +1,6 @@ """Test ZHA select entities.""" +from typing import Any from unittest.mock import call, patch import pytest @@ -90,7 +91,7 @@ async def light(hass, zigpy_device_mock): @pytest.fixture -def core_rs(hass_storage): +def core_rs(hass_storage: dict[str, Any]): """Core.restore_state fixture.""" def _storage(entity_id, state): @@ -119,10 +120,10 @@ def core_rs(hass_storage): return _storage -async def test_select(hass: HomeAssistant, siren) -> None: +async def test_select( + hass: HomeAssistant, entity_registry: er.EntityRegistry, siren +) -> None: """Test ZHA select platform.""" - - entity_registry = er.async_get(hass) zha_device, cluster = siren assert cluster is not None entity_id = find_entity_id( @@ -206,11 +207,9 @@ async def test_select_restore_state( async def test_on_off_select_new_join( - hass: HomeAssistant, light, zha_device_joined + hass: HomeAssistant, entity_registry: er.EntityRegistry, light, zha_device_joined ) -> None: """Test ZHA on off select - new join.""" - - entity_registry = er.async_get(hass) on_off_cluster = light.endpoints[1].on_off on_off_cluster.PLUGGED_ATTR_READS = { "start_up_on_off": general.OnOff.StartUpOnOff.On @@ -267,11 +266,9 @@ async def test_on_off_select_new_join( async def test_on_off_select_restored( - hass: HomeAssistant, light, zha_device_restored + hass: HomeAssistant, entity_registry: er.EntityRegistry, light, zha_device_restored ) -> None: """Test ZHA on off select - restored.""" - - entity_registry = er.async_get(hass) on_off_cluster = light.endpoints[1].on_off on_off_cluster.PLUGGED_ATTR_READS = { "start_up_on_off": general.OnOff.StartUpOnOff.On @@ -464,7 +461,9 @@ async def zigpy_device_aqara_sensor_v2( async def test_on_off_select_attribute_report_v2( - hass: HomeAssistant, zigpy_device_aqara_sensor_v2 + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zigpy_device_aqara_sensor_v2, ) -> None: """Test ZHA attribute report parsing for select platform.""" @@ -487,7 +486,6 @@ async def test_on_off_select_attribute_report_v2( ) assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name - entity_registry = er.async_get(hass) entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.entity_category == EntityCategory.CONFIG diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 59da8332b27..8443c4ced07 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,10 +1,13 @@ """Test ZHA sensor.""" +from collections.abc import Callable from datetime import timedelta import math +from typing import Any from unittest.mock import MagicMock, patch import pytest +from zhaquirks.danfoss import thermostat as danfoss_thermostat import zigpy.profiles.zha from zigpy.quirks import CustomCluster from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2 @@ -22,8 +25,6 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, LIGHT_LUX, PERCENTAGE, STATE_UNAVAILABLE, @@ -632,10 +633,10 @@ def assert_state(hass: HomeAssistant, entity_id, state, unit_of_measurement): @pytest.fixture -def hass_ms(hass: HomeAssistant): +def hass_ms(hass: HomeAssistant) -> Callable[[str], HomeAssistant]: """Hass instance with measurement system.""" - async def _hass_ms(meas_sys): + async def _hass_ms(meas_sys: str) -> HomeAssistant: await config_util.async_process_ha_core_config( hass, {CONF_UNIT_SYSTEM: meas_sys} ) @@ -646,7 +647,7 @@ def hass_ms(hass: HomeAssistant): @pytest.fixture -def core_rs(hass_storage): +def core_rs(hass_storage: dict[str, Any]): """Core.restore_state fixture.""" def _storage(entity_id, uom, state): @@ -687,11 +688,11 @@ def core_rs(hass_storage): ) async def test_temp_uom( hass: HomeAssistant, - uom, - raw_temp, - expected, - restore, - hass_ms, + uom: UnitOfTemperature, + raw_temp: int, + expected: int, + restore: bool, + hass_ms: Callable[[str], HomeAssistant], core_rs, zigpy_device_mock, zha_device_restored, @@ -703,11 +704,7 @@ async def test_temp_uom( core_rs(entity_id, uom, state=(expected - 2)) await async_mock_load_restore_state_from_storage(hass) - hass = await hass_ms( - CONF_UNIT_SYSTEM_METRIC - if uom == UnitOfTemperature.CELSIUS - else CONF_UNIT_SYSTEM_IMPERIAL - ) + hass = await hass_ms("metric" if uom == UnitOfTemperature.CELSIUS else "imperial") zigpy_device = zigpy_device_mock( { @@ -1320,3 +1317,61 @@ async def test_device_counter_sensors( state = hass.states.get(entity_id) assert state is not None assert state.state == "2" + + +@pytest.fixture +async def zigpy_device_danfoss_thermostat( + hass: HomeAssistant, zigpy_device_mock, zha_device_joined_restored +): + """Device tracker zigpy danfoss thermostat device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.PowerConfiguration.cluster_id, + general.Identify.cluster_id, + general.Time.cluster_id, + general.PollControl.cluster_id, + Thermostat.cluster_id, + hvac.UserInterface.cluster_id, + homeautomation.Diagnostic.cluster_id, + ], + SIG_EP_OUTPUT: [general.Basic.cluster_id, general.Ota.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + } + }, + manufacturer="Danfoss", + model="eTRV0100", + ) + + zha_device = await zha_device_joined_restored(zigpy_device) + return zha_device, zigpy_device + + +async def test_danfoss_thermostat_sw_error( + hass: HomeAssistant, zigpy_device_danfoss_thermostat +) -> None: + """Test quirks defined thermostat.""" + + zha_device, zigpy_device = zigpy_device_danfoss_thermostat + + entity_id = find_entity_id( + Platform.SENSOR, zha_device, hass, qualifier="software_error" + ) + assert entity_id is not None + + cluster = zigpy_device.endpoints[1].diagnostic + + await send_attributes_report( + hass, + cluster, + { + danfoss_thermostat.DanfossDiagnosticCluster.AttributeDefs.sw_error_code.id: 0x0001 + }, + ) + + hass_state = hass.states.get(entity_id) + assert hass_state.state == "something" + assert hass_state.attributes["Top_pcb_sensor_error"] diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 927da4ed2c0..80b9f6accd0 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -19,7 +19,11 @@ from zigpy.zcl.clusters import general, security from zigpy.zcl.clusters.general import Groups import zigpy.zdo.types as zdo_types -from homeassistant.components.websocket_api import const +from homeassistant.components.websocket_api import ( + ERR_INVALID_FORMAT, + ERR_NOT_FOUND, + TYPE_RESULT, +) from homeassistant.components.zha import DOMAIN from homeassistant.components.zha.core.const import ( ATTR_CLUSTER_ID, @@ -64,6 +68,7 @@ from .conftest import ( from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS from tests.common import MockConfigEntry, MockUser +from tests.typing import MockHAClientWebSocket, WebSocketGenerator IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -151,7 +156,12 @@ async def device_groupable(hass, zigpy_device_mock, zha_device_joined): @pytest.fixture -async def zha_client(hass, hass_ws_client, device_switch, device_groupable): +async def zha_client( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_switch, + device_groupable, +) -> MockHAClientWebSocket: """Get ZHA WebSocket client.""" # load the ZHA API @@ -330,9 +340,9 @@ async def test_device_not_found(zha_client) -> None: ) msg = await zha_client.receive_json() assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND + assert msg["error"]["code"] == ERR_NOT_FOUND async def test_list_groups(zha_client) -> None: @@ -341,7 +351,7 @@ async def test_list_groups(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT groups = msg["result"] assert len(groups) == 1 @@ -358,7 +368,7 @@ async def test_get_group(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT group = msg["result"] assert group is not None @@ -374,9 +384,9 @@ async def test_get_group_not_found(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 9 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND + assert msg["error"]["code"] == ERR_NOT_FOUND async def test_list_groupable_devices( @@ -391,7 +401,7 @@ async def test_list_groupable_devices( msg = await zha_client.receive_json() assert msg["id"] == 10 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT device_endpoints = msg["result"] assert len(device_endpoints) == 1 @@ -421,7 +431,7 @@ async def test_list_groupable_devices( msg = await zha_client.receive_json() assert msg["id"] == 11 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT device_endpoints = msg["result"] assert len(device_endpoints) == 0 @@ -433,7 +443,7 @@ async def test_add_group(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 12 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT added_group = msg["result"] @@ -444,7 +454,7 @@ async def test_add_group(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 13 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT groups = msg["result"] assert len(groups) == 2 @@ -460,7 +470,7 @@ async def test_remove_group(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT groups = msg["result"] assert len(groups) == 1 @@ -471,7 +481,7 @@ async def test_remove_group(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 15 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT groups_remaining = msg["result"] assert len(groups_remaining) == 0 @@ -480,7 +490,7 @@ async def test_remove_group(zha_client) -> None: msg = await zha_client.receive_json() assert msg["id"] == 16 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT groups = msg["result"] assert len(groups) == 0 @@ -704,14 +714,14 @@ async def test_ws_permit_with_qr_code( ) msg_type = None - while msg_type != const.TYPE_RESULT: + while msg_type != TYPE_RESULT: # There will be logging events coming over the websocket # as well so we want to ignore those msg = await zha_client.receive_json() msg_type = msg["type"] assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert app_controller.permit.await_count == 0 @@ -733,7 +743,7 @@ async def test_ws_permit_with_install_code_fail( msg = await zha_client.receive_json() assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] is False assert app_controller.permit.await_count == 0 @@ -767,14 +777,14 @@ async def test_ws_permit_ha12( ) msg_type = None - while msg_type != const.TYPE_RESULT: + while msg_type != TYPE_RESULT: # There will be logging events coming over the websocket # as well so we want to ignore those msg = await zha_client.receive_json() msg_type = msg["type"] assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert app_controller.permit.await_count == 1 @@ -794,7 +804,7 @@ async def test_get_network_settings( msg = await zha_client.receive_json() assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert "radio_type" in msg["result"] assert "network_info" in msg["result"]["settings"] @@ -812,7 +822,7 @@ async def test_list_network_backups( msg = await zha_client.receive_json() assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert "network_info" in msg["result"][0] @@ -828,7 +838,7 @@ async def test_create_network_backup( assert len(app_controller.backups.backups) == 1 assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert "backup" in msg["result"] and "is_complete" in msg["result"] @@ -854,7 +864,7 @@ async def test_restore_network_backup_success( assert "ezsp" not in backup.network_info.stack_specific assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] @@ -886,7 +896,7 @@ async def test_restore_network_backup_force_write_eui64( ) assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] @@ -909,9 +919,9 @@ async def test_restore_network_backup_failure( p.assert_called_once_with("a backup") assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert not msg["success"] - assert msg["error"]["code"] == const.ERR_INVALID_FORMAT + assert msg["error"]["code"] == ERR_INVALID_FORMAT @pytest.mark.parametrize("new_channel", ["auto", 15]) @@ -934,7 +944,7 @@ async def test_websocket_change_channel( msg = await zha_client.receive_json() assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] change_channel_mock.assert_has_calls([call(ANY, new_channel)]) @@ -967,7 +977,7 @@ async def test_websocket_bind_unbind_devices( msg = await zha_client.receive_json() assert msg["id"] == 27 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] assert binding_operation_mock.mock_calls == [ call( @@ -1021,7 +1031,7 @@ async def test_websocket_bind_unbind_group( msg = await zha_client.receive_json() assert msg["id"] == 27 - assert msg["type"] == const.TYPE_RESULT + assert msg["type"] == TYPE_RESULT assert msg["success"] if command_type == "bind": assert bind_mock.mock_calls == [call(test_group_id, ANY)] diff --git a/tests/components/zodiac/test_sensor.py b/tests/components/zodiac/test_sensor.py index 3d43fe60a5a..19b9733e4f5 100644 --- a/tests/components/zodiac/test_sensor.py +++ b/tests/components/zodiac/test_sensor.py @@ -41,10 +41,15 @@ DAY3 = datetime(2020, 4, 21, tzinfo=dt_util.UTC) ], ) async def test_zodiac_day( - hass: HomeAssistant, now: datetime, sign: str, element: str, modality: str + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + now: datetime, + sign: str, + element: str, + modality: str, ) -> None: """Test the zodiac sensor.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") MockConfigEntry( domain=DOMAIN, ).add_to_hass(hass) @@ -75,7 +80,6 @@ async def test_zodiac_day( "virgo", ] - entity_registry = er.async_get(hass) entry = entity_registry.async_get("sensor.zodiac") assert entry assert entry.unique_id == "zodiac" diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index 08e96c104d2..434ec9ccd2f 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -1,5 +1,6 @@ """Test zone component.""" +from typing import Any from unittest.mock import patch import pytest @@ -24,7 +25,7 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): """Storage setup.""" async def _storage(items=None, config=None): @@ -289,11 +290,13 @@ async def test_core_config_update(hass: HomeAssistant) -> None: async def test_reload( - hass: HomeAssistant, hass_admin_user: MockUser, hass_read_only_user: MockUser + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_admin_user: MockUser, + hass_read_only_user: MockUser, ) -> None: """Test reload service.""" count_start = len(hass.states.async_entity_ids()) - ent_reg = er.async_get(hass) assert await setup.async_setup_component( hass, @@ -319,7 +322,7 @@ async def test_reload( assert state_2.attributes["latitude"] == 3 assert state_2.attributes["longitude"] == 4 assert state_3 is None - assert len(ent_reg.entities) == 0 + assert len(entity_registry.entities) == 0 with patch( "homeassistant.config.load_yaml_config_file", @@ -411,18 +414,20 @@ async def test_ws_list( async def test_ws_delete( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test WS delete cleans up entity registry.""" assert await storage_setup() input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is not None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -434,11 +439,14 @@ async def test_ws_delete( state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None async def test_update( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test updating min/max updates the state.""" @@ -456,12 +464,11 @@ async def test_update( input_id = "from_storage" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state.attributes["latitude"] == 1 assert state.attributes["longitude"] == 2 - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None client = await hass_ws_client(hass) @@ -485,18 +492,20 @@ async def test_update( async def test_ws_create( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, + storage_setup, ) -> None: """Test create WS.""" assert await storage_setup(items=[]) input_id = "new_input" input_entity_id = f"{DOMAIN}.{input_id}" - ent_reg = er.async_get(hass) state = hass.states.get(input_entity_id) assert state is None - assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None client = await hass_ws_client(hass) diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 7e42f41f119..6ec5e2fd894 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -4,7 +4,7 @@ import pytest from homeassistant.components import automation, zone from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -17,7 +17,7 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -42,7 +42,9 @@ def setup_comp(hass): ) -async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_enter( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on zone enter.""" context = Context() hass.states.async_set( @@ -111,12 +113,13 @@ async def test_if_fires_on_zone_enter(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_on_zone_enter_uuid(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_enter_uuid( + hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] +) -> None: """Test for firing on zone enter when device is specified by entity registry id.""" context = Context() - registry = er.async_get(hass) - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "test", "hue", "1234", suggested_object_id="entity" ) assert entry.entity_id == "test.entity" @@ -187,7 +190,9 @@ async def test_if_fires_on_zone_enter_uuid(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_not_fires_for_enter_on_zone_leave(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_for_enter_on_zone_leave( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for not firing on zone leave.""" hass.states.async_set( "test.entity", "hello", {"latitude": 32.880586, "longitude": -117.237564} @@ -218,7 +223,9 @@ async def test_if_not_fires_for_enter_on_zone_leave(hass: HomeAssistant, calls) assert len(calls) == 0 -async def test_if_fires_on_zone_leave(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_zone_leave( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for firing on zone leave.""" hass.states.async_set( "test.entity", "hello", {"latitude": 32.880586, "longitude": -117.237564} @@ -249,7 +256,9 @@ async def test_if_fires_on_zone_leave(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_not_fires_for_leave_on_zone_enter(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_for_leave_on_zone_enter( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: """Test for not firing on zone enter.""" hass.states.async_set( "test.entity", "hello", {"latitude": 32.881011, "longitude": -117.234758} @@ -280,7 +289,7 @@ async def test_if_not_fires_for_leave_on_zone_enter(hass: HomeAssistant, calls) assert len(calls) == 0 -async def test_zone_condition(hass: HomeAssistant, calls) -> None: +async def test_zone_condition(hass: HomeAssistant, calls: list[ServiceCall]) -> None: """Test for zone condition.""" hass.states.async_set( "test.entity", "hello", {"latitude": 32.880586, "longitude": -117.237564} @@ -309,7 +318,7 @@ async def test_zone_condition(hass: HomeAssistant, calls) -> None: async def test_unknown_zone( - hass: HomeAssistant, calls, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, calls: list[ServiceCall], caplog: pytest.LogCaptureFixture ) -> None: """Test for firing on zone enter.""" context = Context() diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 81ebd1acd6c..a2a4c217b8b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -681,6 +681,18 @@ def central_scene_node_state_fixture(): return json.loads(load_fixture("zwave_js/central_scene_node_state.json")) +@pytest.fixture(name="light_device_class_is_null_state", scope="package") +def light_device_class_is_null_state_fixture(): + """Load node with device class is None state fixture data.""" + return json.loads(load_fixture("zwave_js/light_device_class_is_null_state.json")) + + +@pytest.fixture(name="basic_cc_sensor_state", scope="package") +def basic_cc_sensor_state_fixture(): + """Load node with Basic CC sensor fixture data.""" + return json.loads(load_fixture("zwave_js/basic_cc_sensor_state.json")) + + # model fixtures @@ -1341,3 +1353,19 @@ def central_scene_node_fixture(client, central_scene_node_state): node = Node(client, copy.deepcopy(central_scene_node_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="light_device_class_is_null") +def light_device_class_is_null_fixture(client, light_device_class_is_null_state): + """Mock a node when device class is null.""" + node = Node(client, copy.deepcopy(light_device_class_is_null_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="basic_cc_sensor") +def basic_cc_sensor_fixture(client, basic_cc_sensor_state): + """Mock a node with a Basic CC.""" + node = Node(client, copy.deepcopy(basic_cc_sensor_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/basic_cc_sensor_state.json b/tests/components/zwave_js/fixtures/basic_cc_sensor_state.json new file mode 100644 index 00000000000..1d749af2021 --- /dev/null +++ b/tests/components/zwave_js/fixtures/basic_cc_sensor_state.json @@ -0,0 +1,87 @@ +{ + "nodeId": 52, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "status": 1, + "ready": true, + "deviceClass": { + "basic": { "key": 2, "label": "Static Controller" }, + "generic": { "key": 21, "label": "Multilevel Sensor" }, + "specific": { "key": 1, "label": "Routing Multilevel Sensor" }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 134, + "productId": 100, + "productType": 258, + "firmwareVersion": "1.12", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 5, + "deviceConfig": { + "manufacturerId": 134, + "manufacturer": "Test", + "label": "test", + "description": "foo", + "devices": [ + { + "productType": "0xffff", + "productId": "0xffff" + } + ], + "firmwareVersion": { + "min": "1.10", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "test", + "neighbors": [1, 32], + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 52, + "index": 0, + "installerIcon": 3079, + "userIcon": 3079, + "commandClasses": [ + { + "id": 32, + "name": "Basic", + "version": 8, + "isSecure": false + } + ] + } + ], + "values": [ + { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "currentValue", + "propertyName": "currentValue", + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 99, + "label": "Current value" + }, + "value": 255 + } + ], + "highestSecurityClass": 7, + "isControllerNode": false +} diff --git a/tests/components/zwave_js/fixtures/light_device_class_is_null_state.json b/tests/components/zwave_js/fixtures/light_device_class_is_null_state.json new file mode 100644 index 00000000000..e736c432062 --- /dev/null +++ b/tests/components/zwave_js/fixtures/light_device_class_is_null_state.json @@ -0,0 +1,10611 @@ +{ + "nodeId": 45, + "index": 0, + "installerIcon": 1536, + "userIcon": 1536, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 29, + "productId": 1, + "productType": 12801, + "firmwareVersion": "1.20", + "zwavePlusVersion": 1, + "name": "Bar Display Cases", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/Users/spike/zwavestore/.config-db/devices/0x001d/dz6hd.json", + "isEmbedded": true, + "manufacturer": "Leviton", + "manufacturerId": 29, + "label": "DZ6HD", + "description": "In-Wall 600W Dimmer", + "devices": [ + { + "productType": 12801, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Enter programming mode by holding down the top of the paddle for 7 seconds, the LED will blink Amber. Tap the top of the paddle one time. The LED will flash green. Upon successful addition to network, the LED will blink 3 times.", + "exclusion": "Enter programming mode by holding down the top of the paddle for 7 seconds, the LED will blink Amber. Tap the top of the paddle one time. The LED will flash green. Upon successful removal from network, the LED will blink 3 times.", + "reset": "Hold the top of the paddle down for 14 seconds. Upon successful reset, the LED with blink red/amber.", + "manual": "https://www.leviton.com/fr/docs/DI-000-DZ6HD-02A-W.pdf" + } + }, + "label": "DZ6HD", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": null, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x001d:0x3201:0x0001:1.20", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 31.5, + "lastSeen": "2024-05-10T21:42:42.472Z", + "lwr": { + "repeaters": [], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-05-10T21:42:42.472Z", + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 1, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 1, + "propertyName": "level", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (1)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 1, + "propertyName": "dimmingDuration", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (1)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 2, + "propertyName": "level", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (2)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 2, + "propertyName": "dimmingDuration", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (2)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 3, + "propertyName": "level", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (3)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 3, + "propertyName": "dimmingDuration", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (3)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 4, + "propertyName": "level", + "propertyKeyName": "4", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (4)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 4, + "propertyName": "dimmingDuration", + "propertyKeyName": "4", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (4)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 5, + "propertyName": "level", + "propertyKeyName": "5", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (5)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 5, + "propertyName": "dimmingDuration", + "propertyKeyName": "5", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (5)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 6, + "propertyName": "level", + "propertyKeyName": "6", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (6)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 6, + "propertyName": "dimmingDuration", + "propertyKeyName": "6", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (6)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 7, + "propertyName": "level", + "propertyKeyName": "7", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (7)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 7, + "propertyName": "dimmingDuration", + "propertyKeyName": "7", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (7)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 8, + "propertyName": "level", + "propertyKeyName": "8", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (8)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 8, + "propertyName": "dimmingDuration", + "propertyKeyName": "8", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (8)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 9, + "propertyName": "level", + "propertyKeyName": "9", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (9)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 9, + "propertyName": "dimmingDuration", + "propertyKeyName": "9", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (9)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 10, + "propertyName": "level", + "propertyKeyName": "10", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (10)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 10, + "propertyName": "dimmingDuration", + "propertyKeyName": "10", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (10)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 11, + "propertyName": "level", + "propertyKeyName": "11", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (11)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 11, + "propertyName": "dimmingDuration", + "propertyKeyName": "11", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (11)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 12, + "propertyName": "level", + "propertyKeyName": "12", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (12)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 12, + "propertyName": "dimmingDuration", + "propertyKeyName": "12", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (12)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 13, + "propertyName": "level", + "propertyKeyName": "13", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (13)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 13, + "propertyName": "dimmingDuration", + "propertyKeyName": "13", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (13)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 14, + "propertyName": "level", + "propertyKeyName": "14", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (14)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 14, + "propertyName": "dimmingDuration", + "propertyKeyName": "14", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (14)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 15, + "propertyName": "level", + "propertyKeyName": "15", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (15)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 15, + "propertyName": "dimmingDuration", + "propertyKeyName": "15", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (15)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 16, + "propertyName": "level", + "propertyKeyName": "16", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (16)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 16, + "propertyName": "dimmingDuration", + "propertyKeyName": "16", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (16)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 17, + "propertyName": "level", + "propertyKeyName": "17", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (17)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 17, + "propertyName": "dimmingDuration", + "propertyKeyName": "17", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (17)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 18, + "propertyName": "level", + "propertyKeyName": "18", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (18)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 18, + "propertyName": "dimmingDuration", + "propertyKeyName": "18", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (18)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 19, + "propertyName": "level", + "propertyKeyName": "19", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (19)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 19, + "propertyName": "dimmingDuration", + "propertyKeyName": "19", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (19)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 20, + "propertyName": "level", + "propertyKeyName": "20", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (20)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 20, + "propertyName": "dimmingDuration", + "propertyKeyName": "20", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (20)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 21, + "propertyName": "level", + "propertyKeyName": "21", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (21)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 21, + "propertyName": "dimmingDuration", + "propertyKeyName": "21", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (21)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 22, + "propertyName": "level", + "propertyKeyName": "22", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (22)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 22, + "propertyName": "dimmingDuration", + "propertyKeyName": "22", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (22)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 23, + "propertyName": "level", + "propertyKeyName": "23", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (23)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 23, + "propertyName": "dimmingDuration", + "propertyKeyName": "23", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (23)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 24, + "propertyName": "level", + "propertyKeyName": "24", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (24)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 24, + "propertyName": "dimmingDuration", + "propertyKeyName": "24", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (24)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 25, + "propertyName": "level", + "propertyKeyName": "25", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (25)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 25, + "propertyName": "dimmingDuration", + "propertyKeyName": "25", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (25)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 26, + "propertyName": "level", + "propertyKeyName": "26", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (26)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 26, + "propertyName": "dimmingDuration", + "propertyKeyName": "26", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (26)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 27, + "propertyName": "level", + "propertyKeyName": "27", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (27)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 27, + "propertyName": "dimmingDuration", + "propertyKeyName": "27", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (27)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 28, + "propertyName": "level", + "propertyKeyName": "28", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (28)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 28, + "propertyName": "dimmingDuration", + "propertyKeyName": "28", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (28)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 29, + "propertyName": "level", + "propertyKeyName": "29", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (29)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 29, + "propertyName": "dimmingDuration", + "propertyKeyName": "29", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (29)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 30, + "propertyName": "level", + "propertyKeyName": "30", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (30)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 30, + "propertyName": "dimmingDuration", + "propertyKeyName": "30", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (30)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 31, + "propertyName": "level", + "propertyKeyName": "31", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (31)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 31, + "propertyName": "dimmingDuration", + "propertyKeyName": "31", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (31)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 32, + "propertyName": "level", + "propertyKeyName": "32", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (32)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 32, + "propertyName": "dimmingDuration", + "propertyKeyName": "32", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (32)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 33, + "propertyName": "level", + "propertyKeyName": "33", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (33)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 33, + "propertyName": "dimmingDuration", + "propertyKeyName": "33", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (33)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 34, + "propertyName": "level", + "propertyKeyName": "34", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (34)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 34, + "propertyName": "dimmingDuration", + "propertyKeyName": "34", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (34)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 35, + "propertyName": "level", + "propertyKeyName": "35", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (35)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 35, + "propertyName": "dimmingDuration", + "propertyKeyName": "35", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (35)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 36, + "propertyName": "level", + "propertyKeyName": "36", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (36)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 36, + "propertyName": "dimmingDuration", + "propertyKeyName": "36", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (36)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 37, + "propertyName": "level", + "propertyKeyName": "37", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (37)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 37, + "propertyName": "dimmingDuration", + "propertyKeyName": "37", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (37)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 38, + "propertyName": "level", + "propertyKeyName": "38", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (38)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 38, + "propertyName": "dimmingDuration", + "propertyKeyName": "38", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (38)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 39, + "propertyName": "level", + "propertyKeyName": "39", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (39)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 39, + "propertyName": "dimmingDuration", + "propertyKeyName": "39", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (39)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 40, + "propertyName": "level", + "propertyKeyName": "40", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (40)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 40, + "propertyName": "dimmingDuration", + "propertyKeyName": "40", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (40)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 41, + "propertyName": "level", + "propertyKeyName": "41", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (41)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 41, + "propertyName": "dimmingDuration", + "propertyKeyName": "41", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (41)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 42, + "propertyName": "level", + "propertyKeyName": "42", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (42)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 42, + "propertyName": "dimmingDuration", + "propertyKeyName": "42", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (42)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 43, + "propertyName": "level", + "propertyKeyName": "43", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (43)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 43, + "propertyName": "dimmingDuration", + "propertyKeyName": "43", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (43)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 44, + "propertyName": "level", + "propertyKeyName": "44", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (44)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 44, + "propertyName": "dimmingDuration", + "propertyKeyName": "44", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (44)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 45, + "propertyName": "level", + "propertyKeyName": "45", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (45)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 45, + "propertyName": "dimmingDuration", + "propertyKeyName": "45", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (45)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 46, + "propertyName": "level", + "propertyKeyName": "46", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (46)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 46, + "propertyName": "dimmingDuration", + "propertyKeyName": "46", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (46)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 47, + "propertyName": "level", + "propertyKeyName": "47", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (47)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 47, + "propertyName": "dimmingDuration", + "propertyKeyName": "47", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (47)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 48, + "propertyName": "level", + "propertyKeyName": "48", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (48)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 48, + "propertyName": "dimmingDuration", + "propertyKeyName": "48", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (48)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 49, + "propertyName": "level", + "propertyKeyName": "49", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (49)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 49, + "propertyName": "dimmingDuration", + "propertyKeyName": "49", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (49)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 50, + "propertyName": "level", + "propertyKeyName": "50", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (50)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 50, + "propertyName": "dimmingDuration", + "propertyKeyName": "50", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (50)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 51, + "propertyName": "level", + "propertyKeyName": "51", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (51)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 51, + "propertyName": "dimmingDuration", + "propertyKeyName": "51", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (51)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 52, + "propertyName": "level", + "propertyKeyName": "52", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (52)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 52, + "propertyName": "dimmingDuration", + "propertyKeyName": "52", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (52)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 53, + "propertyName": "level", + "propertyKeyName": "53", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (53)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 53, + "propertyName": "dimmingDuration", + "propertyKeyName": "53", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (53)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 54, + "propertyName": "level", + "propertyKeyName": "54", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (54)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 54, + "propertyName": "dimmingDuration", + "propertyKeyName": "54", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (54)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 55, + "propertyName": "level", + "propertyKeyName": "55", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (55)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 55, + "propertyName": "dimmingDuration", + "propertyKeyName": "55", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (55)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 56, + "propertyName": "level", + "propertyKeyName": "56", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (56)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 56, + "propertyName": "dimmingDuration", + "propertyKeyName": "56", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (56)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 57, + "propertyName": "level", + "propertyKeyName": "57", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (57)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 57, + "propertyName": "dimmingDuration", + "propertyKeyName": "57", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (57)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 58, + "propertyName": "level", + "propertyKeyName": "58", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (58)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 58, + "propertyName": "dimmingDuration", + "propertyKeyName": "58", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (58)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 59, + "propertyName": "level", + "propertyKeyName": "59", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (59)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 59, + "propertyName": "dimmingDuration", + "propertyKeyName": "59", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (59)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 60, + "propertyName": "level", + "propertyKeyName": "60", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (60)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 60, + "propertyName": "dimmingDuration", + "propertyKeyName": "60", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (60)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 61, + "propertyName": "level", + "propertyKeyName": "61", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (61)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 61, + "propertyName": "dimmingDuration", + "propertyKeyName": "61", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (61)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 62, + "propertyName": "level", + "propertyKeyName": "62", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (62)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 62, + "propertyName": "dimmingDuration", + "propertyKeyName": "62", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (62)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 63, + "propertyName": "level", + "propertyKeyName": "63", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (63)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 63, + "propertyName": "dimmingDuration", + "propertyKeyName": "63", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (63)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 64, + "propertyName": "level", + "propertyKeyName": "64", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (64)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 64, + "propertyName": "dimmingDuration", + "propertyKeyName": "64", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (64)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 65, + "propertyName": "level", + "propertyKeyName": "65", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (65)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 65, + "propertyName": "dimmingDuration", + "propertyKeyName": "65", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (65)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 66, + "propertyName": "level", + "propertyKeyName": "66", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (66)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 66, + "propertyName": "dimmingDuration", + "propertyKeyName": "66", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (66)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 67, + "propertyName": "level", + "propertyKeyName": "67", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (67)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 67, + "propertyName": "dimmingDuration", + "propertyKeyName": "67", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (67)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 68, + "propertyName": "level", + "propertyKeyName": "68", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (68)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 68, + "propertyName": "dimmingDuration", + "propertyKeyName": "68", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (68)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 69, + "propertyName": "level", + "propertyKeyName": "69", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (69)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 69, + "propertyName": "dimmingDuration", + "propertyKeyName": "69", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (69)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 70, + "propertyName": "level", + "propertyKeyName": "70", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (70)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 70, + "propertyName": "dimmingDuration", + "propertyKeyName": "70", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (70)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 71, + "propertyName": "level", + "propertyKeyName": "71", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (71)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 71, + "propertyName": "dimmingDuration", + "propertyKeyName": "71", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (71)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 72, + "propertyName": "level", + "propertyKeyName": "72", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (72)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 72, + "propertyName": "dimmingDuration", + "propertyKeyName": "72", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (72)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 73, + "propertyName": "level", + "propertyKeyName": "73", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (73)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 73, + "propertyName": "dimmingDuration", + "propertyKeyName": "73", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (73)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 74, + "propertyName": "level", + "propertyKeyName": "74", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (74)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 74, + "propertyName": "dimmingDuration", + "propertyKeyName": "74", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (74)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 75, + "propertyName": "level", + "propertyKeyName": "75", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (75)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 75, + "propertyName": "dimmingDuration", + "propertyKeyName": "75", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (75)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 76, + "propertyName": "level", + "propertyKeyName": "76", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (76)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 76, + "propertyName": "dimmingDuration", + "propertyKeyName": "76", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (76)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 77, + "propertyName": "level", + "propertyKeyName": "77", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (77)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 77, + "propertyName": "dimmingDuration", + "propertyKeyName": "77", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (77)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 78, + "propertyName": "level", + "propertyKeyName": "78", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (78)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 78, + "propertyName": "dimmingDuration", + "propertyKeyName": "78", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (78)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 79, + "propertyName": "level", + "propertyKeyName": "79", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (79)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 79, + "propertyName": "dimmingDuration", + "propertyKeyName": "79", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (79)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 80, + "propertyName": "level", + "propertyKeyName": "80", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (80)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 80, + "propertyName": "dimmingDuration", + "propertyKeyName": "80", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (80)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 81, + "propertyName": "level", + "propertyKeyName": "81", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (81)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 81, + "propertyName": "dimmingDuration", + "propertyKeyName": "81", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (81)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 82, + "propertyName": "level", + "propertyKeyName": "82", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (82)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 82, + "propertyName": "dimmingDuration", + "propertyKeyName": "82", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (82)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 83, + "propertyName": "level", + "propertyKeyName": "83", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (83)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 83, + "propertyName": "dimmingDuration", + "propertyKeyName": "83", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (83)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 84, + "propertyName": "level", + "propertyKeyName": "84", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (84)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 84, + "propertyName": "dimmingDuration", + "propertyKeyName": "84", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (84)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 85, + "propertyName": "level", + "propertyKeyName": "85", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (85)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 85, + "propertyName": "dimmingDuration", + "propertyKeyName": "85", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (85)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 86, + "propertyName": "level", + "propertyKeyName": "86", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (86)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 86, + "propertyName": "dimmingDuration", + "propertyKeyName": "86", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (86)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 87, + "propertyName": "level", + "propertyKeyName": "87", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (87)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 87, + "propertyName": "dimmingDuration", + "propertyKeyName": "87", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (87)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 88, + "propertyName": "level", + "propertyKeyName": "88", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (88)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 88, + "propertyName": "dimmingDuration", + "propertyKeyName": "88", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (88)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 89, + "propertyName": "level", + "propertyKeyName": "89", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (89)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 89, + "propertyName": "dimmingDuration", + "propertyKeyName": "89", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (89)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 90, + "propertyName": "level", + "propertyKeyName": "90", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (90)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 90, + "propertyName": "dimmingDuration", + "propertyKeyName": "90", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (90)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 91, + "propertyName": "level", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (91)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 91, + "propertyName": "dimmingDuration", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (91)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 92, + "propertyName": "level", + "propertyKeyName": "92", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (92)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 92, + "propertyName": "dimmingDuration", + "propertyKeyName": "92", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (92)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 93, + "propertyName": "level", + "propertyKeyName": "93", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (93)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 93, + "propertyName": "dimmingDuration", + "propertyKeyName": "93", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (93)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 94, + "propertyName": "level", + "propertyKeyName": "94", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (94)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 94, + "propertyName": "dimmingDuration", + "propertyKeyName": "94", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (94)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 95, + "propertyName": "level", + "propertyKeyName": "95", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (95)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 95, + "propertyName": "dimmingDuration", + "propertyKeyName": "95", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (95)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 96, + "propertyName": "level", + "propertyKeyName": "96", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (96)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 96, + "propertyName": "dimmingDuration", + "propertyKeyName": "96", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (96)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 97, + "propertyName": "level", + "propertyKeyName": "97", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (97)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 97, + "propertyName": "dimmingDuration", + "propertyKeyName": "97", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (97)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 98, + "propertyName": "level", + "propertyKeyName": "98", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (98)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 98, + "propertyName": "dimmingDuration", + "propertyKeyName": "98", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (98)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 99, + "propertyName": "level", + "propertyKeyName": "99", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (99)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 99, + "propertyName": "dimmingDuration", + "propertyKeyName": "99", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (99)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 100, + "propertyName": "level", + "propertyKeyName": "100", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (100)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 100, + "propertyName": "dimmingDuration", + "propertyKeyName": "100", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (100)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 101, + "propertyName": "level", + "propertyKeyName": "101", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (101)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 101, + "propertyName": "dimmingDuration", + "propertyKeyName": "101", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (101)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 102, + "propertyName": "level", + "propertyKeyName": "102", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (102)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 102, + "propertyName": "dimmingDuration", + "propertyKeyName": "102", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (102)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 103, + "propertyName": "level", + "propertyKeyName": "103", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (103)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 103, + "propertyName": "dimmingDuration", + "propertyKeyName": "103", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (103)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 104, + "propertyName": "level", + "propertyKeyName": "104", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (104)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 104, + "propertyName": "dimmingDuration", + "propertyKeyName": "104", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (104)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 105, + "propertyName": "level", + "propertyKeyName": "105", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (105)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 105, + "propertyName": "dimmingDuration", + "propertyKeyName": "105", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (105)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 106, + "propertyName": "level", + "propertyKeyName": "106", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (106)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 106, + "propertyName": "dimmingDuration", + "propertyKeyName": "106", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (106)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 107, + "propertyName": "level", + "propertyKeyName": "107", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (107)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 107, + "propertyName": "dimmingDuration", + "propertyKeyName": "107", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (107)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 108, + "propertyName": "level", + "propertyKeyName": "108", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (108)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 108, + "propertyName": "dimmingDuration", + "propertyKeyName": "108", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (108)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 109, + "propertyName": "level", + "propertyKeyName": "109", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (109)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 109, + "propertyName": "dimmingDuration", + "propertyKeyName": "109", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (109)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 110, + "propertyName": "level", + "propertyKeyName": "110", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (110)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 110, + "propertyName": "dimmingDuration", + "propertyKeyName": "110", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (110)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 111, + "propertyName": "level", + "propertyKeyName": "111", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (111)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 111, + "propertyName": "dimmingDuration", + "propertyKeyName": "111", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (111)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 112, + "propertyName": "level", + "propertyKeyName": "112", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (112)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 112, + "propertyName": "dimmingDuration", + "propertyKeyName": "112", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (112)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 113, + "propertyName": "level", + "propertyKeyName": "113", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (113)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 113, + "propertyName": "dimmingDuration", + "propertyKeyName": "113", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (113)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 114, + "propertyName": "level", + "propertyKeyName": "114", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (114)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 114, + "propertyName": "dimmingDuration", + "propertyKeyName": "114", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (114)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 115, + "propertyName": "level", + "propertyKeyName": "115", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (115)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 115, + "propertyName": "dimmingDuration", + "propertyKeyName": "115", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (115)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 116, + "propertyName": "level", + "propertyKeyName": "116", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (116)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 116, + "propertyName": "dimmingDuration", + "propertyKeyName": "116", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (116)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 117, + "propertyName": "level", + "propertyKeyName": "117", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (117)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 117, + "propertyName": "dimmingDuration", + "propertyKeyName": "117", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (117)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 118, + "propertyName": "level", + "propertyKeyName": "118", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (118)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 118, + "propertyName": "dimmingDuration", + "propertyKeyName": "118", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (118)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 119, + "propertyName": "level", + "propertyKeyName": "119", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (119)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 119, + "propertyName": "dimmingDuration", + "propertyKeyName": "119", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (119)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 120, + "propertyName": "level", + "propertyKeyName": "120", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (120)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 120, + "propertyName": "dimmingDuration", + "propertyKeyName": "120", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (120)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 121, + "propertyName": "level", + "propertyKeyName": "121", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (121)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 121, + "propertyName": "dimmingDuration", + "propertyKeyName": "121", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (121)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 122, + "propertyName": "level", + "propertyKeyName": "122", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (122)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 122, + "propertyName": "dimmingDuration", + "propertyKeyName": "122", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (122)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 123, + "propertyName": "level", + "propertyKeyName": "123", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (123)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 123, + "propertyName": "dimmingDuration", + "propertyKeyName": "123", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (123)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 124, + "propertyName": "level", + "propertyKeyName": "124", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (124)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 124, + "propertyName": "dimmingDuration", + "propertyKeyName": "124", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (124)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 125, + "propertyName": "level", + "propertyKeyName": "125", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (125)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 125, + "propertyName": "dimmingDuration", + "propertyKeyName": "125", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (125)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 126, + "propertyName": "level", + "propertyKeyName": "126", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (126)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 126, + "propertyName": "dimmingDuration", + "propertyKeyName": "126", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (126)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 127, + "propertyName": "level", + "propertyKeyName": "127", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (127)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 127, + "propertyName": "dimmingDuration", + "propertyKeyName": "127", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (127)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 128, + "propertyName": "level", + "propertyKeyName": "128", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (128)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 128, + "propertyName": "dimmingDuration", + "propertyKeyName": "128", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (128)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 129, + "propertyName": "level", + "propertyKeyName": "129", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (129)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 129, + "propertyName": "dimmingDuration", + "propertyKeyName": "129", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (129)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 130, + "propertyName": "level", + "propertyKeyName": "130", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (130)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 130, + "propertyName": "dimmingDuration", + "propertyKeyName": "130", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (130)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 131, + "propertyName": "level", + "propertyKeyName": "131", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (131)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 131, + "propertyName": "dimmingDuration", + "propertyKeyName": "131", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (131)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 132, + "propertyName": "level", + "propertyKeyName": "132", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (132)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 132, + "propertyName": "dimmingDuration", + "propertyKeyName": "132", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (132)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 133, + "propertyName": "level", + "propertyKeyName": "133", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (133)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 133, + "propertyName": "dimmingDuration", + "propertyKeyName": "133", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (133)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 134, + "propertyName": "level", + "propertyKeyName": "134", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (134)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 134, + "propertyName": "dimmingDuration", + "propertyKeyName": "134", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (134)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 135, + "propertyName": "level", + "propertyKeyName": "135", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (135)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 135, + "propertyName": "dimmingDuration", + "propertyKeyName": "135", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (135)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 136, + "propertyName": "level", + "propertyKeyName": "136", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (136)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 136, + "propertyName": "dimmingDuration", + "propertyKeyName": "136", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (136)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 137, + "propertyName": "level", + "propertyKeyName": "137", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (137)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 137, + "propertyName": "dimmingDuration", + "propertyKeyName": "137", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (137)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 138, + "propertyName": "level", + "propertyKeyName": "138", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (138)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 138, + "propertyName": "dimmingDuration", + "propertyKeyName": "138", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (138)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 139, + "propertyName": "level", + "propertyKeyName": "139", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (139)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 139, + "propertyName": "dimmingDuration", + "propertyKeyName": "139", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (139)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 140, + "propertyName": "level", + "propertyKeyName": "140", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (140)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 140, + "propertyName": "dimmingDuration", + "propertyKeyName": "140", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (140)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 141, + "propertyName": "level", + "propertyKeyName": "141", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (141)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 141, + "propertyName": "dimmingDuration", + "propertyKeyName": "141", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (141)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 142, + "propertyName": "level", + "propertyKeyName": "142", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (142)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 142, + "propertyName": "dimmingDuration", + "propertyKeyName": "142", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (142)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 143, + "propertyName": "level", + "propertyKeyName": "143", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (143)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 143, + "propertyName": "dimmingDuration", + "propertyKeyName": "143", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (143)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 144, + "propertyName": "level", + "propertyKeyName": "144", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (144)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 144, + "propertyName": "dimmingDuration", + "propertyKeyName": "144", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (144)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 145, + "propertyName": "level", + "propertyKeyName": "145", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (145)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 145, + "propertyName": "dimmingDuration", + "propertyKeyName": "145", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (145)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 146, + "propertyName": "level", + "propertyKeyName": "146", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (146)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 146, + "propertyName": "dimmingDuration", + "propertyKeyName": "146", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (146)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 147, + "propertyName": "level", + "propertyKeyName": "147", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (147)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 147, + "propertyName": "dimmingDuration", + "propertyKeyName": "147", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (147)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 148, + "propertyName": "level", + "propertyKeyName": "148", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (148)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 148, + "propertyName": "dimmingDuration", + "propertyKeyName": "148", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (148)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 149, + "propertyName": "level", + "propertyKeyName": "149", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (149)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 149, + "propertyName": "dimmingDuration", + "propertyKeyName": "149", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (149)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 150, + "propertyName": "level", + "propertyKeyName": "150", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (150)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 150, + "propertyName": "dimmingDuration", + "propertyKeyName": "150", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (150)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 151, + "propertyName": "level", + "propertyKeyName": "151", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (151)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 151, + "propertyName": "dimmingDuration", + "propertyKeyName": "151", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (151)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 152, + "propertyName": "level", + "propertyKeyName": "152", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (152)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 152, + "propertyName": "dimmingDuration", + "propertyKeyName": "152", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (152)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 153, + "propertyName": "level", + "propertyKeyName": "153", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (153)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 153, + "propertyName": "dimmingDuration", + "propertyKeyName": "153", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (153)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 154, + "propertyName": "level", + "propertyKeyName": "154", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (154)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 154, + "propertyName": "dimmingDuration", + "propertyKeyName": "154", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (154)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 155, + "propertyName": "level", + "propertyKeyName": "155", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (155)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 155, + "propertyName": "dimmingDuration", + "propertyKeyName": "155", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (155)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 156, + "propertyName": "level", + "propertyKeyName": "156", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (156)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 156, + "propertyName": "dimmingDuration", + "propertyKeyName": "156", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (156)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 157, + "propertyName": "level", + "propertyKeyName": "157", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (157)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 157, + "propertyName": "dimmingDuration", + "propertyKeyName": "157", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (157)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 158, + "propertyName": "level", + "propertyKeyName": "158", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (158)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 158, + "propertyName": "dimmingDuration", + "propertyKeyName": "158", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (158)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 159, + "propertyName": "level", + "propertyKeyName": "159", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (159)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 159, + "propertyName": "dimmingDuration", + "propertyKeyName": "159", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (159)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 160, + "propertyName": "level", + "propertyKeyName": "160", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (160)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 160, + "propertyName": "dimmingDuration", + "propertyKeyName": "160", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (160)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 161, + "propertyName": "level", + "propertyKeyName": "161", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (161)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 161, + "propertyName": "dimmingDuration", + "propertyKeyName": "161", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (161)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 162, + "propertyName": "level", + "propertyKeyName": "162", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (162)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 162, + "propertyName": "dimmingDuration", + "propertyKeyName": "162", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (162)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 163, + "propertyName": "level", + "propertyKeyName": "163", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (163)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 163, + "propertyName": "dimmingDuration", + "propertyKeyName": "163", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (163)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 164, + "propertyName": "level", + "propertyKeyName": "164", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (164)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 164, + "propertyName": "dimmingDuration", + "propertyKeyName": "164", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (164)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 165, + "propertyName": "level", + "propertyKeyName": "165", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (165)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 165, + "propertyName": "dimmingDuration", + "propertyKeyName": "165", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (165)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 166, + "propertyName": "level", + "propertyKeyName": "166", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (166)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 166, + "propertyName": "dimmingDuration", + "propertyKeyName": "166", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (166)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 167, + "propertyName": "level", + "propertyKeyName": "167", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (167)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 167, + "propertyName": "dimmingDuration", + "propertyKeyName": "167", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (167)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 168, + "propertyName": "level", + "propertyKeyName": "168", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (168)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 168, + "propertyName": "dimmingDuration", + "propertyKeyName": "168", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (168)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 169, + "propertyName": "level", + "propertyKeyName": "169", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (169)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 169, + "propertyName": "dimmingDuration", + "propertyKeyName": "169", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (169)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 170, + "propertyName": "level", + "propertyKeyName": "170", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (170)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 170, + "propertyName": "dimmingDuration", + "propertyKeyName": "170", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (170)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 171, + "propertyName": "level", + "propertyKeyName": "171", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (171)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 171, + "propertyName": "dimmingDuration", + "propertyKeyName": "171", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (171)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 172, + "propertyName": "level", + "propertyKeyName": "172", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (172)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 172, + "propertyName": "dimmingDuration", + "propertyKeyName": "172", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (172)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 173, + "propertyName": "level", + "propertyKeyName": "173", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (173)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 173, + "propertyName": "dimmingDuration", + "propertyKeyName": "173", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (173)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 174, + "propertyName": "level", + "propertyKeyName": "174", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (174)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 174, + "propertyName": "dimmingDuration", + "propertyKeyName": "174", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (174)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 175, + "propertyName": "level", + "propertyKeyName": "175", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (175)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 175, + "propertyName": "dimmingDuration", + "propertyKeyName": "175", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (175)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 176, + "propertyName": "level", + "propertyKeyName": "176", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (176)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 176, + "propertyName": "dimmingDuration", + "propertyKeyName": "176", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (176)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 177, + "propertyName": "level", + "propertyKeyName": "177", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (177)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 177, + "propertyName": "dimmingDuration", + "propertyKeyName": "177", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (177)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 178, + "propertyName": "level", + "propertyKeyName": "178", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (178)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 178, + "propertyName": "dimmingDuration", + "propertyKeyName": "178", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (178)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 179, + "propertyName": "level", + "propertyKeyName": "179", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (179)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 179, + "propertyName": "dimmingDuration", + "propertyKeyName": "179", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (179)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 180, + "propertyName": "level", + "propertyKeyName": "180", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (180)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 180, + "propertyName": "dimmingDuration", + "propertyKeyName": "180", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (180)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 181, + "propertyName": "level", + "propertyKeyName": "181", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (181)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 181, + "propertyName": "dimmingDuration", + "propertyKeyName": "181", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (181)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 182, + "propertyName": "level", + "propertyKeyName": "182", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (182)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 182, + "propertyName": "dimmingDuration", + "propertyKeyName": "182", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (182)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 183, + "propertyName": "level", + "propertyKeyName": "183", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (183)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 183, + "propertyName": "dimmingDuration", + "propertyKeyName": "183", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (183)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 184, + "propertyName": "level", + "propertyKeyName": "184", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (184)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 184, + "propertyName": "dimmingDuration", + "propertyKeyName": "184", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (184)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 185, + "propertyName": "level", + "propertyKeyName": "185", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (185)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 185, + "propertyName": "dimmingDuration", + "propertyKeyName": "185", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (185)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 186, + "propertyName": "level", + "propertyKeyName": "186", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (186)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 186, + "propertyName": "dimmingDuration", + "propertyKeyName": "186", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (186)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 187, + "propertyName": "level", + "propertyKeyName": "187", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (187)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 187, + "propertyName": "dimmingDuration", + "propertyKeyName": "187", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (187)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 188, + "propertyName": "level", + "propertyKeyName": "188", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (188)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 188, + "propertyName": "dimmingDuration", + "propertyKeyName": "188", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (188)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 189, + "propertyName": "level", + "propertyKeyName": "189", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (189)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 189, + "propertyName": "dimmingDuration", + "propertyKeyName": "189", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (189)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 190, + "propertyName": "level", + "propertyKeyName": "190", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (190)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 190, + "propertyName": "dimmingDuration", + "propertyKeyName": "190", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (190)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 191, + "propertyName": "level", + "propertyKeyName": "191", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (191)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 191, + "propertyName": "dimmingDuration", + "propertyKeyName": "191", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (191)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 192, + "propertyName": "level", + "propertyKeyName": "192", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (192)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 192, + "propertyName": "dimmingDuration", + "propertyKeyName": "192", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (192)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 193, + "propertyName": "level", + "propertyKeyName": "193", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (193)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 193, + "propertyName": "dimmingDuration", + "propertyKeyName": "193", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (193)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 194, + "propertyName": "level", + "propertyKeyName": "194", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (194)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 194, + "propertyName": "dimmingDuration", + "propertyKeyName": "194", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (194)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 195, + "propertyName": "level", + "propertyKeyName": "195", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (195)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 195, + "propertyName": "dimmingDuration", + "propertyKeyName": "195", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (195)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 196, + "propertyName": "level", + "propertyKeyName": "196", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (196)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 196, + "propertyName": "dimmingDuration", + "propertyKeyName": "196", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (196)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 197, + "propertyName": "level", + "propertyKeyName": "197", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (197)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 197, + "propertyName": "dimmingDuration", + "propertyKeyName": "197", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (197)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 198, + "propertyName": "level", + "propertyKeyName": "198", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (198)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 198, + "propertyName": "dimmingDuration", + "propertyKeyName": "198", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (198)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 199, + "propertyName": "level", + "propertyKeyName": "199", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (199)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 199, + "propertyName": "dimmingDuration", + "propertyKeyName": "199", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (199)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 200, + "propertyName": "level", + "propertyKeyName": "200", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (200)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 200, + "propertyName": "dimmingDuration", + "propertyKeyName": "200", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (200)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 201, + "propertyName": "level", + "propertyKeyName": "201", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (201)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 201, + "propertyName": "dimmingDuration", + "propertyKeyName": "201", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (201)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 202, + "propertyName": "level", + "propertyKeyName": "202", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (202)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 202, + "propertyName": "dimmingDuration", + "propertyKeyName": "202", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (202)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 203, + "propertyName": "level", + "propertyKeyName": "203", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (203)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 203, + "propertyName": "dimmingDuration", + "propertyKeyName": "203", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (203)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 204, + "propertyName": "level", + "propertyKeyName": "204", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (204)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 204, + "propertyName": "dimmingDuration", + "propertyKeyName": "204", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (204)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 205, + "propertyName": "level", + "propertyKeyName": "205", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (205)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 205, + "propertyName": "dimmingDuration", + "propertyKeyName": "205", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (205)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 206, + "propertyName": "level", + "propertyKeyName": "206", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (206)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 206, + "propertyName": "dimmingDuration", + "propertyKeyName": "206", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (206)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 207, + "propertyName": "level", + "propertyKeyName": "207", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (207)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 207, + "propertyName": "dimmingDuration", + "propertyKeyName": "207", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (207)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 208, + "propertyName": "level", + "propertyKeyName": "208", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (208)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 208, + "propertyName": "dimmingDuration", + "propertyKeyName": "208", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (208)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 209, + "propertyName": "level", + "propertyKeyName": "209", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (209)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 209, + "propertyName": "dimmingDuration", + "propertyKeyName": "209", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (209)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 210, + "propertyName": "level", + "propertyKeyName": "210", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (210)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 210, + "propertyName": "dimmingDuration", + "propertyKeyName": "210", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (210)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 211, + "propertyName": "level", + "propertyKeyName": "211", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (211)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 211, + "propertyName": "dimmingDuration", + "propertyKeyName": "211", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (211)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 212, + "propertyName": "level", + "propertyKeyName": "212", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (212)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 212, + "propertyName": "dimmingDuration", + "propertyKeyName": "212", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (212)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 213, + "propertyName": "level", + "propertyKeyName": "213", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (213)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 213, + "propertyName": "dimmingDuration", + "propertyKeyName": "213", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (213)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 214, + "propertyName": "level", + "propertyKeyName": "214", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (214)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 214, + "propertyName": "dimmingDuration", + "propertyKeyName": "214", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (214)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 215, + "propertyName": "level", + "propertyKeyName": "215", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (215)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 215, + "propertyName": "dimmingDuration", + "propertyKeyName": "215", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (215)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 216, + "propertyName": "level", + "propertyKeyName": "216", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (216)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 216, + "propertyName": "dimmingDuration", + "propertyKeyName": "216", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (216)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 217, + "propertyName": "level", + "propertyKeyName": "217", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (217)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 217, + "propertyName": "dimmingDuration", + "propertyKeyName": "217", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (217)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 218, + "propertyName": "level", + "propertyKeyName": "218", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (218)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 218, + "propertyName": "dimmingDuration", + "propertyKeyName": "218", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (218)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 219, + "propertyName": "level", + "propertyKeyName": "219", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (219)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 219, + "propertyName": "dimmingDuration", + "propertyKeyName": "219", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (219)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 220, + "propertyName": "level", + "propertyKeyName": "220", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (220)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 220, + "propertyName": "dimmingDuration", + "propertyKeyName": "220", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (220)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 221, + "propertyName": "level", + "propertyKeyName": "221", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (221)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 221, + "propertyName": "dimmingDuration", + "propertyKeyName": "221", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (221)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 222, + "propertyName": "level", + "propertyKeyName": "222", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (222)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 222, + "propertyName": "dimmingDuration", + "propertyKeyName": "222", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (222)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 223, + "propertyName": "level", + "propertyKeyName": "223", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (223)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 223, + "propertyName": "dimmingDuration", + "propertyKeyName": "223", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (223)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 224, + "propertyName": "level", + "propertyKeyName": "224", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (224)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 224, + "propertyName": "dimmingDuration", + "propertyKeyName": "224", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (224)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 225, + "propertyName": "level", + "propertyKeyName": "225", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (225)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 225, + "propertyName": "dimmingDuration", + "propertyKeyName": "225", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (225)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 226, + "propertyName": "level", + "propertyKeyName": "226", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (226)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 226, + "propertyName": "dimmingDuration", + "propertyKeyName": "226", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (226)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 227, + "propertyName": "level", + "propertyKeyName": "227", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (227)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 227, + "propertyName": "dimmingDuration", + "propertyKeyName": "227", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (227)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 228, + "propertyName": "level", + "propertyKeyName": "228", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (228)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 228, + "propertyName": "dimmingDuration", + "propertyKeyName": "228", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (228)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 229, + "propertyName": "level", + "propertyKeyName": "229", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (229)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 229, + "propertyName": "dimmingDuration", + "propertyKeyName": "229", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (229)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 230, + "propertyName": "level", + "propertyKeyName": "230", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (230)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 230, + "propertyName": "dimmingDuration", + "propertyKeyName": "230", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (230)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 231, + "propertyName": "level", + "propertyKeyName": "231", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (231)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 231, + "propertyName": "dimmingDuration", + "propertyKeyName": "231", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (231)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 232, + "propertyName": "level", + "propertyKeyName": "232", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (232)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 232, + "propertyName": "dimmingDuration", + "propertyKeyName": "232", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (232)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 233, + "propertyName": "level", + "propertyKeyName": "233", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (233)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 233, + "propertyName": "dimmingDuration", + "propertyKeyName": "233", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (233)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 234, + "propertyName": "level", + "propertyKeyName": "234", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (234)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 234, + "propertyName": "dimmingDuration", + "propertyKeyName": "234", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (234)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 235, + "propertyName": "level", + "propertyKeyName": "235", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (235)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 235, + "propertyName": "dimmingDuration", + "propertyKeyName": "235", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (235)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 236, + "propertyName": "level", + "propertyKeyName": "236", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (236)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 236, + "propertyName": "dimmingDuration", + "propertyKeyName": "236", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (236)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 237, + "propertyName": "level", + "propertyKeyName": "237", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (237)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 237, + "propertyName": "dimmingDuration", + "propertyKeyName": "237", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (237)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 238, + "propertyName": "level", + "propertyKeyName": "238", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (238)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 238, + "propertyName": "dimmingDuration", + "propertyKeyName": "238", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (238)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 239, + "propertyName": "level", + "propertyKeyName": "239", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (239)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 239, + "propertyName": "dimmingDuration", + "propertyKeyName": "239", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (239)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 240, + "propertyName": "level", + "propertyKeyName": "240", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (240)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 240, + "propertyName": "dimmingDuration", + "propertyKeyName": "240", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (240)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 241, + "propertyName": "level", + "propertyKeyName": "241", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (241)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 241, + "propertyName": "dimmingDuration", + "propertyKeyName": "241", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (241)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 242, + "propertyName": "level", + "propertyKeyName": "242", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (242)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 242, + "propertyName": "dimmingDuration", + "propertyKeyName": "242", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (242)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 243, + "propertyName": "level", + "propertyKeyName": "243", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (243)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 243, + "propertyName": "dimmingDuration", + "propertyKeyName": "243", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (243)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 244, + "propertyName": "level", + "propertyKeyName": "244", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (244)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 244, + "propertyName": "dimmingDuration", + "propertyKeyName": "244", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (244)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 245, + "propertyName": "level", + "propertyKeyName": "245", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (245)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 245, + "propertyName": "dimmingDuration", + "propertyKeyName": "245", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (245)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 246, + "propertyName": "level", + "propertyKeyName": "246", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (246)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 246, + "propertyName": "dimmingDuration", + "propertyKeyName": "246", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (246)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 247, + "propertyName": "level", + "propertyKeyName": "247", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (247)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 247, + "propertyName": "dimmingDuration", + "propertyKeyName": "247", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (247)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 248, + "propertyName": "level", + "propertyKeyName": "248", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (248)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 248, + "propertyName": "dimmingDuration", + "propertyKeyName": "248", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (248)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 249, + "propertyName": "level", + "propertyKeyName": "249", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (249)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 249, + "propertyName": "dimmingDuration", + "propertyKeyName": "249", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (249)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 250, + "propertyName": "level", + "propertyKeyName": "250", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (250)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 250, + "propertyName": "dimmingDuration", + "propertyKeyName": "250", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (250)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 251, + "propertyName": "level", + "propertyKeyName": "251", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (251)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 251, + "propertyName": "dimmingDuration", + "propertyKeyName": "251", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (251)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 252, + "propertyName": "level", + "propertyKeyName": "252", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (252)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 252, + "propertyName": "dimmingDuration", + "propertyKeyName": "252", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (252)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 253, + "propertyName": "level", + "propertyKeyName": "253", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (253)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 253, + "propertyName": "dimmingDuration", + "propertyKeyName": "253", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (253)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 254, + "propertyName": "level", + "propertyKeyName": "254", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (254)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 254, + "propertyName": "dimmingDuration", + "propertyKeyName": "254", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (254)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "level", + "propertyKey": 255, + "propertyName": "level", + "propertyKeyName": "255", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Level (255)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 44, + "commandClassName": "Scene Actuator Configuration", + "property": "dimmingDuration", + "propertyKey": 255, + "propertyName": "dimmingDuration", + "propertyKeyName": "255", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration (255)", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Fade On Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Values 1-127 = seconds; 128-253 = minutes (minus 127)", + "label": "Fade On Time", + "default": 2, + "min": 0, + "max": 253, + "states": { + "0": "Instant on" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Fade Off Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Values 1-127 = seconds; 128-253 = minutes (minus 127)", + "label": "Fade Off Time", + "default": 2, + "min": 0, + "max": 253, + "states": { + "0": "Instant off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Minimum Dim Level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Minimum Dim Level", + "default": 10, + "min": 1, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Maximum Dim Level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Maximum Dim Level", + "default": 100, + "min": 0, + "max": 100, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Initial Dim Level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Initial Dim Level", + "default": 0, + "min": 0, + "max": 100, + "states": { + "0": "Last dim level" + }, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "LED Dim Level Indicator Timeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "How long the level indicators should stay illuminated after the dimming level is changed", + "label": "LED Dim Level Indicator Timeout", + "default": 3, + "min": 0, + "max": 255, + "states": { + "0": "Always Off", + "255": "Always On" + }, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Locator LED Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Locator LED Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "LED always off", + "254": "LED on when switch is on", + "255": "LED on when switch is off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Load Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Load Type", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Incandescent", + "1": "LED", + "2": "CFL" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 29 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 12801 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "4.33" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.20"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 255 + } + ], + "endpoints": [ + { + "nodeId": 45, + "index": 0, + "installerIcon": 1536, + "userIcon": 1536, + "deviceClass": null, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 44, + "name": "Scene Actuator Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 6295dbed8f1..0437f9d9085 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2,6 +2,7 @@ from copy import deepcopy from http import HTTPStatus +from io import BytesIO import json from typing import Any from unittest.mock import patch @@ -37,7 +38,6 @@ from zwave_js_server.model.value import ConfigurationValue, get_value_id_str from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( - ADDITIONAL_PROPERTIES, APPLICATION_VERSION, CLIENT_SIDE_AUTH, COMMAND_CLASS_ID, @@ -58,6 +58,7 @@ from homeassistant.components.zwave_js.api import ( LEVEL, LOG_TO_FILE, MANUFACTURER_ID, + MAX_INCLUSION_REQUEST_INTERVAL, NODE_ID, OPTED_IN, PIN, @@ -73,7 +74,9 @@ from homeassistant.components.zwave_js.api import ( SPECIFIC_DEVICE_CLASS, STATUS, STRATEGY, + SUPPORTED_PROTOCOLS, TYPE, + UUID, VALUE, VERSION, ) @@ -125,6 +128,7 @@ async def test_no_driver( async def test_network_status( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, multisensor_6, controller_state, client, @@ -157,8 +161,7 @@ async def test_network_status( assert result["controller"]["inclusion_state"] == InclusionState.IDLE # Try API call with device ID - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, "3245146787-52")}, ) assert device @@ -250,6 +253,7 @@ async def test_network_status( async def test_subscribe_node_status( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, multisensor_6_state, client, integration, @@ -264,8 +268,7 @@ async def test_subscribe_node_status( driver = client.driver driver.controller.nodes[node.node_id] = node - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={get_device_id(driver, node)} ) @@ -460,6 +463,7 @@ async def test_node_metadata( async def test_node_alerts( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, wallmote_central_scene, integration, hass_ws_client: WebSocketGenerator, @@ -467,8 +471,7 @@ async def test_node_alerts( """Test the node comments websocket command.""" ws_client = await hass_ws_client(hass) - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device(identifiers={(DOMAIN, "3245146787-35")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "3245146787-35")}) assert device await ws_client.send_json( @@ -529,6 +532,25 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="node found", + data={ + "source": "controller", + "event": "node found", + "node": { + "nodeId": 67, + }, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "node found" + node_details = { + "node_id": 67, + } + assert msg["event"]["node"] == node_details + event = Event( type="grant security classes", data={ @@ -1071,7 +1093,7 @@ async def test_provision_smart_start_node( PRODUCT_TYPE: 1, PRODUCT_ID: 1, APPLICATION_VERSION: "test", - ADDITIONAL_PROPERTIES: {"name": "test"}, + "name": "test", }, } ) @@ -1330,13 +1352,7 @@ async def test_get_provisioning_entries( msg = await ws_client.receive_json() assert msg["success"] assert msg["result"] == [ - { - "dsk": "test", - "security_classes": [SecurityClass.S2_UNAUTHENTICATED], - "requested_security_classes": None, - "status": 0, - "additional_properties": {"fake": "test"}, - } + {DSK: "test", SECURITY_CLASSES: [0], STATUS: 0, "fake": "test"} ] assert len(client.async_send_command.call_args_list) == 1 @@ -1412,22 +1428,20 @@ async def test_parse_qr_code_string( msg = await ws_client.receive_json() assert msg["success"] assert msg["result"] == { - "version": 0, - "security_classes": [SecurityClass.S2_UNAUTHENTICATED], - "dsk": "test", - "generic_device_class": 1, - "specific_device_class": 1, - "installer_icon_type": 1, - "manufacturer_id": 1, - "product_type": 1, - "product_id": 1, - "application_version": "test", - "max_inclusion_request_interval": 1, - "uuid": "test", - "supported_protocols": [Protocols.ZWAVE], - "status": 0, - "requested_security_classes": None, - "additional_properties": {}, + VERSION: 0, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + MAX_INCLUSION_REQUEST_INTERVAL: 1, + UUID: "test", + SUPPORTED_PROTOCOLS: [Protocols.ZWAVE], + STATUS: 0, } assert len(client.async_send_command.call_args_list) == 1 @@ -1647,6 +1661,7 @@ async def test_cancel_inclusion_exclusion( async def test_remove_node( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, integration, client, hass_ws_client: WebSocketGenerator, @@ -1683,10 +1698,8 @@ async def test_remove_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "exclusion started" - dev_reg = dr.async_get(hass) - # Create device registry entry for mock node - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "3245146787-67")}, name="Node 67", @@ -1698,7 +1711,7 @@ async def test_remove_node( assert msg["event"]["event"] == "node removed" # Verify device was removed from device registry - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, "3245146787-67")}, ) assert device is None @@ -1758,6 +1771,7 @@ async def test_remove_node( async def test_replace_failed_node( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, nortek_thermostat, integration, client, @@ -1769,10 +1783,8 @@ async def test_replace_failed_node( entry = integration ws_client = await hass_ws_client(hass) - dev_reg = dr.async_get(hass) - # Create device registry entry for mock node - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "3245146787-67")}, name="Node 67", @@ -1818,6 +1830,25 @@ async def test_replace_failed_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "inclusion started" + event = Event( + type="node found", + data={ + "source": "controller", + "event": "node found", + "node": { + "nodeId": 67, + }, + }, + ) + client.driver.receive_event(event) + + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "node found" + node_details = { + "node_id": 67, + } + assert msg["event"]["node"] == node_details + event = Event( type="grant security classes", data={ @@ -1868,7 +1899,7 @@ async def test_replace_failed_node( # Verify device was removed from device registry assert ( - dev_reg.async_get_device( + device_registry.async_get_device( identifiers={(DOMAIN, "3245146787-67")}, ) is None @@ -2107,6 +2138,7 @@ async def test_replace_failed_node( async def test_remove_failed_node( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, nortek_thermostat, integration, client, @@ -2150,10 +2182,8 @@ async def test_remove_failed_node( msg = await ws_client.receive_json() assert msg["success"] - dev_reg = dr.async_get(hass) - # Create device registry entry for mock node - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "3245146787-67")}, name="Node 67", @@ -2166,7 +2196,7 @@ async def test_remove_failed_node( # Verify device was removed from device registry assert ( - dev_reg.async_get_device( + device_registry.async_get_device( identifiers={(DOMAIN, "3245146787-67")}, ) is None @@ -3089,7 +3119,9 @@ async def test_firmware_upload_view( f"/api/zwave_js/firmware/upload/{device.id}", data=data ) - update_data = NodeFirmwareUpdateData("file", bytes(10)) + update_data = NodeFirmwareUpdateData( + "file", b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ) for attr, value in expected_data.items(): setattr(update_data, attr, value) @@ -3129,7 +3161,9 @@ async def test_firmware_upload_view_controller( ) mock_node_cmd.assert_not_called() assert mock_controller_cmd.call_args[0][1:2] == ( - ControllerFirmwareUpdateData("file", bytes(10)), + ControllerFirmwareUpdateData( + "file", b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ), ) assert mock_controller_cmd.call_args[1] == { "additional_user_agent_components": {"HomeAssistant": "0.0.0"}, @@ -3166,7 +3200,7 @@ async def test_firmware_upload_view_invalid_payload( client = await hass_client() resp = await client.post( f"/api/zwave_js/firmware/upload/{device.id}", - data={"wrong_key": bytes(10)}, + data={"wrong_key": BytesIO(bytes(10))}, ) assert resp.status == HTTPStatus.BAD_REQUEST @@ -3184,7 +3218,7 @@ async def test_firmware_upload_view_no_driver( aiohttp_client = await hass_client() resp = await aiohttp_client.post( f"/api/zwave_js/firmware/upload/{device.id}", - data={"wrong_key": bytes(10)}, + data={"wrong_key": BytesIO(bytes(10))}, ) assert resp.status == HTTPStatus.NOT_FOUND @@ -4667,6 +4701,7 @@ async def test_subscribe_node_statistics( async def test_hard_reset_controller( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, client, integration, listen_block, @@ -4676,8 +4711,7 @@ async def test_hard_reset_controller( entry = integration ws_client = await hass_ws_client(hass) - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} ) diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 3f78e23a50c..0054439ef1d 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -27,7 +27,7 @@ from tests.common import MockConfigEntry async def test_low_battery_sensor( - hass: HomeAssistant, multisensor_6, integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration ) -> None: """Test boolean binary sensor of type low battery.""" state = hass.states.get(LOW_BATTERY_BINARY_SENSOR) @@ -36,8 +36,7 @@ async def test_low_battery_sensor( assert state.state == STATE_OFF assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY - registry = er.async_get(hass) - entity_entry = registry.async_get(LOW_BATTERY_BINARY_SENSOR) + entity_entry = entity_registry.async_get(LOW_BATTERY_BINARY_SENSOR) assert entity_entry assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC @@ -104,28 +103,29 @@ async def test_enabled_legacy_sensor( async def test_disabled_legacy_sensor( - hass: HomeAssistant, multisensor_6, integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration ) -> None: """Test disabled legacy boolean binary sensor.""" # this node has Notification CC implemented so legacy binary sensor should be disabled - registry = er.async_get(hass) entity_id = DISABLED_LEGACY_BINARY_SENSOR state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling legacy entity - updated_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity( + entry.entity_id, disabled_by=None + ) assert updated_entry != entry assert updated_entry.disabled is False async def test_notification_sensor( - hass: HomeAssistant, multisensor_6, integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration ) -> None: """Test binary sensor created from Notification CC.""" state = hass.states.get(NOTIFICATION_MOTION_BINARY_SENSOR) @@ -140,8 +140,7 @@ async def test_notification_sensor( assert state.state == STATE_OFF assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.TAMPER - registry = er.async_get(hass) - entity_entry = registry.async_get(TAMPER_SENSOR) + entity_entry = entity_registry.async_get(TAMPER_SENSOR) assert entity_entry assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC @@ -261,17 +260,19 @@ async def test_property_sensor_door_status( async def test_config_parameter_binary_sensor( - hass: HomeAssistant, climate_adc_t3000, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + climate_adc_t3000, + integration, ) -> None: """Test config parameter binary sensor is created.""" binary_sensor_entity_id = "binary_sensor.adc_t3000_system_configuration_override" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(binary_sensor_entity_id) + entity_entry = entity_registry.async_get(binary_sensor_entity_id) assert entity_entry assert entity_entry.disabled assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC - updated_entry = ent_reg.async_update_entity( + updated_entry = entity_registry.async_update_entity( binary_sensor_entity_id, disabled_by=None ) assert updated_entry != entity_entry diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index e1a1c6d665a..b0c06668926 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -7,11 +7,12 @@ from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALU from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er async def test_ping_entity( hass: HomeAssistant, + entity_registry: er.EntityRegistry, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, @@ -56,7 +57,7 @@ async def test_ping_entity( node = driver.controller.nodes[1] assert node.is_controller_node assert ( - async_get(hass).async_get_entity_id( + entity_registry.async_get_entity_id( DOMAIN, "sensor", f"{get_valueless_base_unique_id(driver, node)}.ping" ) is None diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 8da17e228be..10fd5edfabb 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Z-Wave JS config flow.""" import asyncio -from collections.abc import Generator from copy import copy from ipaddress import ip_address from unittest.mock import DEFAULT, MagicMock, call, patch @@ -9,6 +8,7 @@ from unittest.mock import DEFAULT, MagicMock, call, patch import aiohttp import pytest from serial.tools.list_ports_common import ListPortInfo +from typing_extensions import Generator from zwave_js_server.version import VersionInfo from homeassistant import config_entries @@ -159,7 +159,7 @@ def serial_port_fixture() -> ListPortInfo: @pytest.fixture(name="mock_list_ports", autouse=True) -def mock_list_ports_fixture(serial_port) -> Generator[MagicMock, None, None]: +def mock_list_ports_fixture(serial_port) -> Generator[MagicMock]: """Mock list ports.""" with patch( "homeassistant.components.zwave_js.config_flow.list_ports.comports" @@ -179,7 +179,7 @@ def mock_list_ports_fixture(serial_port) -> Generator[MagicMock, None, None]: @pytest.fixture(name="mock_usb_serial_by_id", autouse=True) -def mock_usb_serial_by_id_fixture() -> Generator[MagicMock, None, None]: +def mock_usb_serial_by_id_fixture() -> Generator[MagicMock]: """Mock usb serial by id.""" with patch( "homeassistant.components.zwave_js.config_flow.usb.get_serial_by_id" @@ -222,6 +222,8 @@ async def test_manual(hass: HomeAssistant) -> None: "s2_access_control_key": None, "s2_authenticated_key": None, "s2_unauthenticated_key": None, + "lr_s2_access_control_key": None, + "lr_s2_authenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -343,6 +345,8 @@ async def test_supervisor_discovery( addon_options["s2_access_control_key"] = "new456" addon_options["s2_authenticated_key"] = "new789" addon_options["s2_unauthenticated_key"] = "new987" + addon_options["lr_s2_access_control_key"] = "new654" + addon_options["lr_s2_authenticated_key"] = "new321" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -376,6 +380,8 @@ async def test_supervisor_discovery( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": False, } @@ -422,6 +428,8 @@ async def test_clean_discovery_on_user_create( addon_options["s2_access_control_key"] = "new456" addon_options["s2_authenticated_key"] = "new789" addon_options["s2_unauthenticated_key"] = "new987" + addon_options["lr_s2_access_control_key"] = "new654" + addon_options["lr_s2_authenticated_key"] = "new321" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -477,6 +485,8 @@ async def test_clean_discovery_on_user_create( "s2_access_control_key": None, "s2_authenticated_key": None, "s2_unauthenticated_key": None, + "lr_s2_access_control_key": None, + "lr_s2_authenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -606,6 +616,8 @@ async def test_usb_discovery( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -619,6 +631,8 @@ async def test_usb_discovery( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -650,6 +664,8 @@ async def test_usb_discovery( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": True, } @@ -690,6 +706,8 @@ async def test_usb_discovery_addon_not_running( "s2_access_control_key": "", "s2_authenticated_key": "", "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", } result = await hass.config_entries.flow.async_configure( @@ -699,6 +717,8 @@ async def test_usb_discovery_addon_not_running( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -712,6 +732,8 @@ async def test_usb_discovery_addon_not_running( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -743,6 +765,8 @@ async def test_usb_discovery_addon_not_running( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": False, } @@ -788,6 +812,8 @@ async def test_discovery_addon_not_running( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -801,6 +827,8 @@ async def test_discovery_addon_not_running( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -832,6 +860,8 @@ async def test_discovery_addon_not_running( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": False, } @@ -885,6 +915,8 @@ async def test_discovery_addon_not_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -898,6 +930,8 @@ async def test_discovery_addon_not_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -929,6 +963,8 @@ async def test_discovery_addon_not_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": True, } @@ -1068,6 +1104,8 @@ async def test_not_addon(hass: HomeAssistant, supervisor) -> None: "s2_access_control_key": None, "s2_authenticated_key": None, "s2_unauthenticated_key": None, + "lr_s2_access_control_key": None, + "lr_s2_authenticated_key": None, "use_addon": False, "integration_created_addon": False, } @@ -1089,6 +1127,8 @@ async def test_addon_running( addon_options["s2_access_control_key"] = "new456" addon_options["s2_authenticated_key"] = "new789" addon_options["s2_unauthenticated_key"] = "new987" + addon_options["lr_s2_access_control_key"] = "new654" + addon_options["lr_s2_authenticated_key"] = "new321" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -1120,6 +1160,8 @@ async def test_addon_running( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": False, } @@ -1207,6 +1249,9 @@ async def test_addon_running_already_configured( addon_options["s2_access_control_key"] = "new456" addon_options["s2_authenticated_key"] = "new789" addon_options["s2_unauthenticated_key"] = "new987" + addon_options["lr_s2_access_control_key"] = "new654" + addon_options["lr_s2_authenticated_key"] = "new321" + entry = MockConfigEntry( domain=DOMAIN, data={ @@ -1217,6 +1262,8 @@ async def test_addon_running_already_configured( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", }, title=TITLE, unique_id=1234, # Unique ID is purposely set to int to test migration logic @@ -1243,6 +1290,8 @@ async def test_addon_running_already_configured( assert entry.data["s2_access_control_key"] == "new456" assert entry.data["s2_authenticated_key"] == "new789" assert entry.data["s2_unauthenticated_key"] == "new987" + assert entry.data["lr_s2_access_control_key"] == "new654" + assert entry.data["lr_s2_authenticated_key"] == "new321" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -1279,6 +1328,8 @@ async def test_addon_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -1292,6 +1343,8 @@ async def test_addon_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -1323,6 +1376,8 @@ async def test_addon_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": False, } @@ -1367,6 +1422,8 @@ async def test_addon_installed_start_failure( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -1380,6 +1437,8 @@ async def test_addon_installed_start_failure( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -1442,6 +1501,8 @@ async def test_addon_installed_failures( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -1455,6 +1516,8 @@ async def test_addon_installed_failures( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -1508,6 +1571,8 @@ async def test_addon_installed_set_options_failure( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -1521,6 +1586,8 @@ async def test_addon_installed_set_options_failure( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -1552,6 +1619,8 @@ async def test_addon_installed_already_configured( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", }, title=TITLE, unique_id="1234", @@ -1580,6 +1649,8 @@ async def test_addon_installed_already_configured( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -1593,6 +1664,8 @@ async def test_addon_installed_already_configured( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -1613,6 +1686,8 @@ async def test_addon_installed_already_configured( assert entry.data["s2_access_control_key"] == "new456" assert entry.data["s2_authenticated_key"] == "new789" assert entry.data["s2_unauthenticated_key"] == "new987" + assert entry.data["lr_s2_access_control_key"] == "new654" + assert entry.data["lr_s2_authenticated_key"] == "new321" @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) @@ -1659,6 +1734,8 @@ async def test_addon_not_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", }, ) @@ -1672,6 +1749,8 @@ async def test_addon_not_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", } }, ) @@ -1703,6 +1782,8 @@ async def test_addon_not_installed( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "use_addon": True, "integration_created_addon": True, } @@ -1844,6 +1925,8 @@ async def test_options_not_addon( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", }, { "usb_path": "/new", @@ -1851,6 +1934,8 @@ async def test_options_not_addon( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -1866,6 +1951,8 @@ async def test_options_not_addon( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", }, { "usb_path": "/new", @@ -1873,6 +1960,8 @@ async def test_options_not_addon( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -1956,6 +2045,14 @@ async def test_options_addon_running( entry.data["s2_unauthenticated_key"] == new_addon_options["s2_unauthenticated_key"] ) + assert ( + entry.data["lr_s2_access_control_key"] + == new_addon_options["lr_s2_access_control_key"] + ) + assert ( + entry.data["lr_s2_authenticated_key"] + == new_addon_options["lr_s2_authenticated_key"] + ) assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is False assert client.connect.call_count == 2 @@ -1975,6 +2072,8 @@ async def test_options_addon_running( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", "emulate_hardware": False, }, @@ -1984,6 +2083,8 @@ async def test_options_addon_running( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", "emulate_hardware": False, }, @@ -2053,6 +2154,14 @@ async def test_options_addon_running_no_changes( entry.data["s2_unauthenticated_key"] == new_addon_options["s2_unauthenticated_key"] ) + assert ( + entry.data["lr_s2_access_control_key"] + == new_addon_options["lr_s2_access_control_key"] + ) + assert ( + entry.data["lr_s2_authenticated_key"] + == new_addon_options["lr_s2_authenticated_key"] + ) assert entry.data["use_addon"] is True assert entry.data["integration_created_addon"] is False assert client.connect.call_count == 2 @@ -2090,6 +2199,8 @@ async def different_device_server_version(*args): "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", "emulate_hardware": False, }, @@ -2099,6 +2210,8 @@ async def different_device_server_version(*args): "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -2115,6 +2228,8 @@ async def different_device_server_version(*args): "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", }, { @@ -2123,6 +2238,8 @@ async def different_device_server_version(*args): "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -2244,6 +2361,8 @@ async def test_options_different_device( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", "emulate_hardware": False, }, @@ -2253,6 +2372,8 @@ async def test_options_different_device( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -2269,6 +2390,8 @@ async def test_options_different_device( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", "emulate_hardware": False, }, @@ -2278,6 +2401,8 @@ async def test_options_different_device( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -2399,6 +2524,8 @@ async def test_options_addon_restart_failed( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", "emulate_hardware": False, }, @@ -2408,6 +2535,8 @@ async def test_options_addon_restart_failed( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", "log_level": "info", "emulate_hardware": False, }, @@ -2488,6 +2617,8 @@ async def test_options_addon_running_server_info_failure( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", }, { "usb_path": "/new", @@ -2495,6 +2626,8 @@ async def test_options_addon_running_server_info_failure( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -2510,6 +2643,8 @@ async def test_options_addon_running_server_info_failure( "s2_access_control_key": "old456", "s2_authenticated_key": "old789", "s2_unauthenticated_key": "old987", + "lr_s2_access_control_key": "old654", + "lr_s2_authenticated_key": "old321", }, { "usb_path": "/new", @@ -2517,6 +2652,8 @@ async def test_options_addon_running_server_info_failure( "s2_access_control_key": "new456", "s2_authenticated_key": "new789", "s2_unauthenticated_key": "new987", + "lr_s2_access_control_key": "new654", + "lr_s2_authenticated_key": "new321", "log_level": "info", "emulate_hardware": False, }, @@ -2647,6 +2784,8 @@ async def test_import_addon_installed( "s2_access_control_key": "", "s2_authenticated_key": "", "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", } result = await hass.config_entries.flow.async_configure( @@ -2663,6 +2802,8 @@ async def test_import_addon_installed( "s2_access_control_key": "", "s2_authenticated_key": "", "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", } }, ) @@ -2694,6 +2835,8 @@ async def test_import_addon_installed( "s2_access_control_key": "", "s2_authenticated_key": "", "s2_unauthenticated_key": "", + "lr_s2_access_control_key": "", + "lr_s2_authenticated_key": "", "use_addon": True, "integration_created_addon": False, } @@ -2742,6 +2885,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: "s2_access_control_key": None, "s2_authenticated_key": None, "s2_unauthenticated_key": None, + "lr_s2_access_control_key": None, + "lr_s2_authenticated_key": None, "use_addon": False, "integration_created_addon": False, } diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 24f756c5042..61ed2bb35fb 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -20,7 +20,7 @@ from homeassistant.components.zwave_js.helpers import ( get_device_id, get_zwave_value_from_config, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.setup import async_setup_component @@ -29,7 +29,7 @@ from tests.common import async_get_device_automations, async_mock_service @pytest.fixture -def calls(hass: HomeAssistant): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -99,7 +99,7 @@ async def test_node_status_state( client, lock_schlage_be469, integration, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, ) -> None: """Test for node_status conditions.""" @@ -264,7 +264,7 @@ async def test_config_parameter_state( client, lock_schlage_be469, integration, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, ) -> None: """Test for config_parameter conditions.""" @@ -384,7 +384,7 @@ async def test_value_state( client, lock_schlage_be469, integration, - calls, + calls: list[ServiceCall], device_registry: dr.DeviceRegistry, ) -> None: """Test for value conditions.""" diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 6818b2d73af..0fa228288ec 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -19,26 +19,29 @@ from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, get_device_id, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import async_get as async_get_dev_reg -from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations, async_mock_service @pytest.fixture -def calls(hass: HomeAssistant): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") -async def test_no_controller_triggers(hass: HomeAssistant, client, integration) -> None: +async def test_no_controller_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, client, integration +) -> None: """Test that we do not get triggers for the controller.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} ) assert device @@ -51,11 +54,14 @@ async def test_no_controller_triggers(hass: HomeAssistant, client, integration) async def test_get_notification_notification_triggers( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected triggers from a zwave_js device with the Notification CC.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -74,12 +80,16 @@ async def test_get_notification_notification_triggers( async def test_if_notification_notification_fires( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, + calls: list[ServiceCall], ) -> None: """Test for event.notification.notification trigger firing.""" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -170,11 +180,14 @@ async def test_if_notification_notification_fires( async def test_get_trigger_capabilities_notification_notification( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected capabilities from a notification.notification trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -203,12 +216,16 @@ async def test_get_trigger_capabilities_notification_notification( async def test_if_entry_control_notification_fires( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, + calls: list[ServiceCall], ) -> None: """Test for notification.entry_control trigger firing.""" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -298,11 +315,14 @@ async def test_if_entry_control_notification_fires( async def test_get_trigger_capabilities_entry_control_notification( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected capabilities from a notification.entry_control trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -329,19 +349,22 @@ async def test_get_trigger_capabilities_entry_control_notification( async def test_get_node_status_triggers( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected triggers from a device with node status sensor enabled.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device - ent_reg = async_get_ent_reg(hass) entity_id = async_get_node_status_sensor_entity_id( - hass, device.id, ent_reg, dev_reg + hass, device.id, entity_registry, device_registry ) - entity = ent_reg.async_update_entity(entity_id, disabled_by=None) + entity = entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() @@ -360,20 +383,24 @@ async def test_get_node_status_triggers( async def test_if_node_status_change_fires( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client, + lock_schlage_be469, + integration, + calls: list[ServiceCall], ) -> None: """Test for node_status trigger firing.""" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device - ent_reg = async_get_ent_reg(hass) entity_id = async_get_node_status_sensor_entity_id( - hass, device.id, ent_reg, dev_reg + hass, device.id, entity_registry, device_registry ) - entity = ent_reg.async_update_entity(entity_id, disabled_by=None) + entity = entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() @@ -439,20 +466,24 @@ async def test_if_node_status_change_fires( async def test_if_node_status_change_fires_legacy( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client, + lock_schlage_be469, + integration, + calls: list[ServiceCall], ) -> None: """Test for node_status trigger firing.""" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device - ent_reg = async_get_ent_reg(hass) entity_id = async_get_node_status_sensor_entity_id( - hass, device.id, ent_reg, dev_reg + hass, device.id, entity_registry, device_registry ) - ent_reg.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() @@ -518,19 +549,22 @@ async def test_if_node_status_change_fires_legacy( async def test_get_trigger_capabilities_node_status( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected capabilities from a node_status trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device - ent_reg = async_get_ent_reg(hass) entity_id = async_get_node_status_sensor_entity_id( - hass, device.id, ent_reg, dev_reg + hass, device.id, entity_registry, device_registry ) - ent_reg.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() @@ -576,11 +610,14 @@ async def test_get_trigger_capabilities_node_status( async def test_get_basic_value_notification_triggers( - hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + ge_in_wall_dimmer_switch, + integration, ) -> None: """Test we get the expected triggers from a zwave_js device with the Basic CC.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, ge_in_wall_dimmer_switch)} ) assert device @@ -603,12 +640,16 @@ async def test_get_basic_value_notification_triggers( async def test_if_basic_value_notification_fires( - hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + ge_in_wall_dimmer_switch, + integration, + calls: list[ServiceCall], ) -> None: """Test for event.value_notification.basic trigger firing.""" node: Node = ge_in_wall_dimmer_switch - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, ge_in_wall_dimmer_switch)} ) assert device @@ -713,11 +754,14 @@ async def test_if_basic_value_notification_fires( async def test_get_trigger_capabilities_basic_value_notification( - hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + ge_in_wall_dimmer_switch, + integration, ) -> None: """Test we get the expected capabilities from a value_notification.basic trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, ge_in_wall_dimmer_switch)} ) assert device @@ -751,11 +795,14 @@ async def test_get_trigger_capabilities_basic_value_notification( async def test_get_central_scene_value_notification_triggers( - hass: HomeAssistant, client, wallmote_central_scene, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + wallmote_central_scene, + integration, ) -> None: """Test we get the expected triggers from a zwave_js device with the Central Scene CC.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, wallmote_central_scene)} ) assert device @@ -778,12 +825,16 @@ async def test_get_central_scene_value_notification_triggers( async def test_if_central_scene_value_notification_fires( - hass: HomeAssistant, client, wallmote_central_scene, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + wallmote_central_scene, + integration, + calls: list[ServiceCall], ) -> None: """Test for event.value_notification.central_scene trigger firing.""" node: Node = wallmote_central_scene - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, wallmote_central_scene)} ) assert device @@ -894,11 +945,14 @@ async def test_if_central_scene_value_notification_fires( async def test_get_trigger_capabilities_central_scene_value_notification( - hass: HomeAssistant, client, wallmote_central_scene, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + wallmote_central_scene, + integration, ) -> None: """Test we get the expected capabilities from a value_notification.central_scene trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, wallmote_central_scene)} ) assert device @@ -931,11 +985,14 @@ async def test_get_trigger_capabilities_central_scene_value_notification( async def test_get_scene_activation_value_notification_triggers( - hass: HomeAssistant, client, hank_binary_switch, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + hank_binary_switch, + integration, ) -> None: """Test we get the expected triggers from a zwave_js device with the SceneActivation CC.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device @@ -958,12 +1015,16 @@ async def test_get_scene_activation_value_notification_triggers( async def test_if_scene_activation_value_notification_fires( - hass: HomeAssistant, client, hank_binary_switch, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + hank_binary_switch, + integration, + calls: list[ServiceCall], ) -> None: """Test for event.value_notification.scene_activation trigger firing.""" node: Node = hank_binary_switch - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device @@ -1068,11 +1129,14 @@ async def test_if_scene_activation_value_notification_fires( async def test_get_trigger_capabilities_scene_activation_value_notification( - hass: HomeAssistant, client, hank_binary_switch, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + hank_binary_switch, + integration, ) -> None: """Test we get the expected capabilities from a value_notification.scene_activation trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device @@ -1106,11 +1170,14 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( async def test_get_value_updated_value_triggers( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the zwave_js.value_updated.value trigger from a zwave_js device.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1128,12 +1195,16 @@ async def test_get_value_updated_value_triggers( async def test_if_value_updated_value_fires( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, + calls: list[ServiceCall], ) -> None: """Test for zwave_js.value_updated.value trigger firing.""" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1220,12 +1291,16 @@ async def test_if_value_updated_value_fires( async def test_value_updated_value_no_driver( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, + calls: list[ServiceCall], ) -> None: """Test zwave_js.value_updated.value trigger with missing driver.""" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1291,11 +1366,14 @@ async def test_value_updated_value_no_driver( async def test_get_trigger_capabilities_value_updated_value( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected capabilities from a zwave_js.value_updated.value trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1342,11 +1420,14 @@ async def test_get_trigger_capabilities_value_updated_value( async def test_get_value_updated_config_parameter_triggers( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the zwave_js.value_updated.config_parameter trigger from a zwave_js device.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1369,12 +1450,16 @@ async def test_get_value_updated_config_parameter_triggers( async def test_if_value_updated_config_parameter_fires( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, + calls: list[ServiceCall], ) -> None: """Test for zwave_js.value_updated.config_parameter trigger firing.""" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1440,11 +1525,14 @@ async def test_if_value_updated_config_parameter_fires( async def test_get_trigger_capabilities_value_updated_config_parameter_range( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected capabilities from a range zwave_js.value_updated.config_parameter trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1485,11 +1573,14 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_range( async def test_get_trigger_capabilities_value_updated_config_parameter_enumerated( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test we get the expected capabilities from an enumerated zwave_js.value_updated.config_parameter trigger.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1528,7 +1619,11 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_enumerate async def test_failure_scenarios( - hass: HomeAssistant, client, hank_binary_switch, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + hank_binary_switch, + integration, ) -> None: """Test failure scenarios.""" with pytest.raises(HomeAssistantError): @@ -1544,8 +1639,7 @@ async def test_failure_scenarios( {}, ) - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index ea354ab80d3..0e6645d9d61 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -51,6 +51,8 @@ async def test_config_entry_diagnostics( async def test_device_diagnostics( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, client, multisensor_6, integration, @@ -58,8 +60,7 @@ async def test_device_diagnostics( version_state, ) -> None: """Test the device level diagnostics data dump.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, multisensor_6)} ) assert device @@ -69,8 +70,7 @@ async def test_device_diagnostics( mock_config_entry.add_to_hass(hass) # Add an entity entry to the device that is not part of this config entry - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( + entity_registry.async_get_or_create( "test", "test_integration", "test_unique_id", @@ -78,7 +78,7 @@ async def test_device_diagnostics( config_entry=mock_config_entry, device_id=device.id, ) - assert ent_reg.async_get("test.unrelated_entity") + assert entity_registry.async_get("test.unrelated_entity") # Update a value and ensure it is reflected in the node state event = Event( @@ -118,7 +118,7 @@ async def test_device_diagnostics( ) assert any( entity.entity_id == "test.unrelated_entity" - for entity in er.async_entries_for_device(ent_reg, device.id) + for entity in er.async_entries_for_device(entity_registry, device.id) ) # Explicitly check that the entity that is not part of this config entry is not # in the dump. @@ -137,10 +137,11 @@ async def test_device_diagnostics( } -async def test_device_diagnostics_error(hass: HomeAssistant, integration) -> None: +async def test_device_diagnostics_error( + hass: HomeAssistant, device_registry: dr.DeviceRegistry, integration +) -> None: """Test the device diagnostics raises exception when an invalid device is used.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={("test", "test")} ) with pytest.raises(ValueError): @@ -155,21 +156,21 @@ async def test_empty_zwave_value_matcher() -> None: async def test_device_diagnostics_missing_primary_value( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, client, multisensor_6, integration, hass_client: ClientSessionGenerator, ) -> None: """Test that device diagnostics handles an entity with a missing primary value.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, multisensor_6)} ) assert device entity_id = "sensor.multisensor_6_air_temperature" - ent_reg = er.async_get(hass) - entry = ent_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) # check that the primary value for the entity exists in the diagnostics diagnostics_data = await get_diagnostics_for_device( @@ -227,6 +228,7 @@ async def test_device_diagnostics_missing_primary_value( async def test_device_diagnostics_secret_value( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, client, multisensor_6_state, integration, @@ -256,8 +258,9 @@ async def test_device_diagnostics_secret_value( client.driver.controller.nodes[node.node_id] = node client.driver.controller.emit("node added", {"node": node}) await hass.async_block_till_done() - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, node)} + ) assert device diagnostics_data = await get_diagnostics_for_device( diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 6612b04f4e7..1179d8e843c 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -29,6 +29,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +async def test_aeon_smart_switch_6_state( + hass: HomeAssistant, client, aeon_smart_switch_6, integration +) -> None: + """Test that Smart Switch 6 has a meter reset button.""" + state = hass.states.get("button.smart_switch_6_reset_accumulated_values") + assert state + + async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) -> None: """Test that an iBlinds v2.0 multilevel switch value is discovered as a cover.""" node = iblinds_v2 @@ -135,23 +143,28 @@ async def test_merten_507801( async def test_shelly_001p10_disabled_entities( - hass: HomeAssistant, client, shelly_qnsh_001P10_shutter, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + shelly_qnsh_001P10_shutter, + integration, ) -> None: """Test that Shelly 001P10 entity created by endpoint 2 is disabled.""" - registry = er.async_get(hass) entity_ids = [ "cover.wave_shutter_2", ] for entity_id in entity_ids: state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity( + entry.entity_id, disabled_by=None + ) assert updated_entry != entry assert updated_entry.disabled is False @@ -161,10 +174,13 @@ async def test_shelly_001p10_disabled_entities( async def test_merten_507801_disabled_enitites( - hass: HomeAssistant, client, merten_507801, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + merten_507801, + integration, ) -> None: """Test that Merten 507801 entities created by endpoint 2 are disabled.""" - registry = er.async_get(hass) entity_ids = [ "cover.connect_roller_shutter_2", "select.connect_roller_shutter_local_protection_state_2", @@ -173,26 +189,31 @@ async def test_merten_507801_disabled_enitites( for entity_id in entity_ids: state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity( + entry.entity_id, disabled_by=None + ) assert updated_entry != entry assert updated_entry.disabled is False async def test_zooz_zen72( - hass: HomeAssistant, client, switch_zooz_zen72, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + switch_zooz_zen72, + integration, ) -> None: """Test that Zooz ZEN72 Indicators are discovered as number entities.""" - ent_reg = er.async_get(hass) assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" - entry = ent_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.entity_category == EntityCategory.CONFIG state = hass.states.get(entity_id) @@ -222,7 +243,7 @@ async def test_zooz_zen72( client.async_send_command.reset_mock() entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" - entry = ent_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.entity_category == EntityCategory.CONFIG await hass.services.async_call( @@ -244,18 +265,22 @@ async def test_zooz_zen72( async def test_indicator_test( - hass: HomeAssistant, client, indicator_test, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client, + indicator_test, + integration, ) -> None: """Test that Indicators are discovered properly. This test covers indicators that we don't already have device fixtures for. """ - device = dr.async_get(hass).async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, indicator_test)} ) assert device - ent_reg = er.async_get(hass) - entities = er.async_entries_for_device(ent_reg, device.id) + entities = er.async_entries_for_device(entity_registry, device.id) def len_domain(domain): return len([entity for entity in entities if entity.domain == domain]) @@ -267,7 +292,7 @@ async def test_indicator_test( assert len_domain(SWITCH_DOMAIN) == 1 entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" - entry = ent_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC state = hass.states.get(entity_id) @@ -277,7 +302,7 @@ async def test_indicator_test( client.async_send_command.reset_mock() entity_id = "sensor.this_is_a_fake_device_sensor" - entry = ent_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC state = hass.states.get(entity_id) @@ -287,7 +312,7 @@ async def test_indicator_test( client.async_send_command.reset_mock() entity_id = "switch.this_is_a_fake_device_switch" - entry = ent_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.entity_category == EntityCategory.CONFIG state = hass.states.get(entity_id) @@ -331,3 +356,15 @@ async def test_indicator_test( "propertyKey": "Switch", } assert args["value"] is False + + +async def test_light_device_class_is_null( + hass: HomeAssistant, client, light_device_class_is_null, integration +) -> None: + """Test that a Multilevel Switch CC value with a null device class is discovered as a light. + + Tied to #117121. + """ + node = light_device_class_is_null + assert node.device_class is None + assert hass.states.get("light.bar_display_cases") diff --git a/tests/components/zwave_js/test_helpers.py b/tests/components/zwave_js/test_helpers.py index 38e15df52cc..016a2d718ac 100644 --- a/tests/components/zwave_js/test_helpers.py +++ b/tests/components/zwave_js/test_helpers.py @@ -13,22 +13,24 @@ from homeassistant.helpers import area_registry as ar, device_registry as dr from tests.common import MockConfigEntry -async def test_async_get_node_status_sensor_entity_id(hass: HomeAssistant) -> None: +async def test_async_get_node_status_sensor_entity_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test async_get_node_status_sensor_entity_id for non zwave_js device.""" - dev_reg = dr.async_get(hass) config_entry = MockConfigEntry() config_entry.add_to_hass(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("test", "test")}, ) assert async_get_node_status_sensor_entity_id(hass, device.id) is None -async def test_async_get_nodes_from_area_id(hass: HomeAssistant) -> None: +async def test_async_get_nodes_from_area_id( + hass: HomeAssistant, area_registry: ar.AreaRegistry +) -> None: """Test async_get_nodes_from_area_id.""" - area_reg = ar.async_get(hass) - area = area_reg.async_create("test") + area = area_registry.async_create("test") assert not async_get_nodes_from_area_id(hass, area.id) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 66c2c05e530..51aeee72c1d 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -181,10 +181,13 @@ async def test_new_entity_on_value_added( async def test_on_node_added_ready( - hass: HomeAssistant, multisensor_6_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state, + client, + integration, ) -> None: """Test we handle a node added event with a ready node.""" - dev_reg = dr.async_get(hass) node = Node(client, deepcopy(multisensor_6_state)) event = {"node": node} air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" @@ -192,7 +195,7 @@ async def test_on_node_added_ready( state = hass.states.get(AIR_TEMPERATURE_SENSOR) assert not state # entity and device not yet added - assert not dev_reg.async_get_device( + assert not device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id)} ) @@ -203,18 +206,24 @@ async def test_on_node_added_ready( assert state # entity and device added assert state.state != STATE_UNAVAILABLE - assert dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + assert device_registry.async_get_device( + identifiers={(DOMAIN, air_temperature_device_id)} + ) async def test_on_node_added_not_ready( - hass: HomeAssistant, zp3111_not_ready_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + zp3111_not_ready_state, + client, + integration, ) -> None: """Test we handle a node added event with a non-ready node.""" - dev_reg = dr.async_get(hass) device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" assert len(hass.states.async_all()) == 1 - assert len(dev_reg.devices) == 1 + assert len(device_registry.devices) == 1 node_state = deepcopy(zp3111_not_ready_state) node_state["isSecure"] = False @@ -231,22 +240,24 @@ async def test_on_node_added_not_ready( client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 - ent_reg = er.async_get(hass) - entities = er.async_entries_for_device(ent_reg, device.id) + entities = er.async_entries_for_device(entity_registry, device.id) # the only entities are the node status sensor, last_seen sensor, and ping button assert len(entities) == 3 async def test_existing_node_ready( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + multisensor_6, + integration, ) -> None: """Test we handle a ready node that exists during integration setup.""" - dev_reg = dr.async_get(hass) node = multisensor_6 air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" air_temperature_device_id_ext = ( @@ -259,22 +270,24 @@ async def test_existing_node_ready( assert state # entity and device added assert state.state != STATE_UNAVAILABLE - device = dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, air_temperature_device_id)} + ) assert device - assert device == dev_reg.async_get_device( + assert device == device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id_ext)} ) async def test_existing_node_reinterview( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, client: Client, multisensor_6_state: dict, multisensor_6: Node, integration: MockConfigEntry, ) -> None: """Test we handle a node re-interview firing a node ready event.""" - dev_reg = dr.async_get(hass) node = multisensor_6 assert client.driver is not None air_temperature_device_id = f"{client.driver.controller.home_id}-{node.node_id}" @@ -288,9 +301,11 @@ async def test_existing_node_reinterview( assert state # entity and device added assert state.state != STATE_UNAVAILABLE - device = dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, air_temperature_device_id)} + ) assert device - assert device == dev_reg.async_get_device( + assert device == device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id_ext)} ) assert device.sw_version == "1.12" @@ -313,41 +328,49 @@ async def test_existing_node_reinterview( assert state assert state.state != STATE_UNAVAILABLE - device = dev_reg.async_get_device(identifiers={(DOMAIN, air_temperature_device_id)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, air_temperature_device_id)} + ) assert device - assert device == dev_reg.async_get_device( + assert device == device_registry.async_get_device( identifiers={(DOMAIN, air_temperature_device_id_ext)} ) assert device.sw_version == "1.13" async def test_existing_node_not_ready( - hass: HomeAssistant, zp3111_not_ready, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + zp3111_not_ready, + client, + integration, ) -> None: """Test we handle a non-ready node that exists during integration setup.""" - dev_reg = dr.async_get(hass) node = zp3111_not_ready device_id = f"{client.driver.controller.home_id}-{node.node_id}" - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device.name == f"Node {node.node_id}" assert not device.manufacturer assert not device.model assert not device.sw_version - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device # no extended device identifier yet assert len(device.identifiers) == 1 - ent_reg = er.async_get(hass) - entities = er.async_entries_for_device(ent_reg, device.id) + entities = er.async_entries_for_device(entity_registry, device.id) # the only entities are the node status sensor, last_seen sensor, and ping button assert len(entities) == 3 async def test_existing_node_not_replaced_when_not_ready( hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, zp3111, zp3111_not_ready_state, zp3111_state, @@ -358,9 +381,7 @@ async def test_existing_node_not_replaced_when_not_ready( The existing node should not be replaced, and no customization should be lost. """ - dev_reg = dr.async_get(hass) - er_reg = er.async_get(hass) - kitchen_area = ar.async_get(hass).async_create("Kitchen") + kitchen_area = area_registry.async_create("Kitchen") device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" device_id_ext = ( @@ -368,7 +389,7 @@ async def test_existing_node_not_replaced_when_not_ready( f"{zp3111.product_type}:{zp3111.product_id}" ) - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device.name == "4-in-1 Sensor" assert not device.name_by_user @@ -376,18 +397,20 @@ async def test_existing_node_not_replaced_when_not_ready( assert device.model == "ZP3111-5" assert device.sw_version == "5.1" assert not device.area_id - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device == device_registry.async_get_device( + identifiers={(DOMAIN, device_id_ext)} + ) motion_entity = "binary_sensor.4_in_1_sensor_motion_detection" state = hass.states.get(motion_entity) assert state assert state.name == "4-in-1 Sensor Motion detection" - dev_reg.async_update_device( + device_registry.async_update_device( device.id, name_by_user="Custom Device Name", area_id=kitchen_area.id ) - custom_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + custom_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert custom_device assert custom_device.name == "4-in-1 Sensor" assert custom_device.name_by_user == "Custom Device Name" @@ -395,12 +418,12 @@ async def test_existing_node_not_replaced_when_not_ready( assert custom_device.model == "ZP3111-5" assert device.sw_version == "5.1" assert custom_device.area_id == kitchen_area.id - assert custom_device == dev_reg.async_get_device( + assert custom_device == device_registry.async_get_device( identifiers={(DOMAIN, device_id_ext)} ) custom_entity = "binary_sensor.custom_motion_sensor" - er_reg.async_update_entity( + entity_registry.async_update_entity( motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" ) await hass.async_block_till_done() @@ -424,9 +447,11 @@ async def test_existing_node_not_replaced_when_not_ready( client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device == device_registry.async_get_device( + identifiers={(DOMAIN, device_id_ext)} + ) assert device.id == custom_device.id assert device.identifiers == custom_device.identifiers assert device.name == f"Node {zp3111.node_id}" @@ -452,9 +477,11 @@ async def test_existing_node_not_replaced_when_not_ready( client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device == device_registry.async_get_device( + identifiers={(DOMAIN, device_id_ext)} + ) assert device.id == custom_device.id assert device.identifiers == custom_device.identifiers assert device.name == "4-in-1 Sensor" @@ -492,12 +519,16 @@ async def test_start_addon( s2_access_control_key = "s2_access_control" s2_authenticated_key = "s2_authenticated" s2_unauthenticated_key = "s2_unauthenticated" + lr_s2_access_control_key = "lr_s2_access_control" + lr_s2_authenticated_key = "lr_s2_authenticated" addon_options = { "device": device, "s0_legacy_key": s0_legacy_key, "s2_access_control_key": s2_access_control_key, "s2_authenticated_key": s2_authenticated_key, "s2_unauthenticated_key": s2_unauthenticated_key, + "lr_s2_access_control_key": lr_s2_access_control_key, + "lr_s2_authenticated_key": lr_s2_authenticated_key, } entry = MockConfigEntry( domain=DOMAIN, @@ -509,6 +540,8 @@ async def test_start_addon( "s2_access_control_key": s2_access_control_key, "s2_authenticated_key": s2_authenticated_key, "s2_unauthenticated_key": s2_unauthenticated_key, + "lr_s2_access_control_key": lr_s2_access_control_key, + "lr_s2_authenticated_key": lr_s2_authenticated_key, }, ) entry.add_to_hass(hass) @@ -614,6 +647,10 @@ async def test_addon_info_failure( "new_s2_authenticated_key", "old_s2_unauthenticated_key", "new_s2_unauthenticated_key", + "old_lr_s2_access_control_key", + "new_lr_s2_access_control_key", + "old_lr_s2_authenticated_key", + "new_lr_s2_authenticated_key", ), [ ( @@ -627,6 +664,10 @@ async def test_addon_info_failure( "new789", "old987", "new987", + "old654", + "new654", + "old321", + "new321", ) ], ) @@ -648,6 +689,10 @@ async def test_addon_options_changed( new_s2_authenticated_key, old_s2_unauthenticated_key, new_s2_unauthenticated_key, + old_lr_s2_access_control_key, + new_lr_s2_access_control_key, + old_lr_s2_authenticated_key, + new_lr_s2_authenticated_key, ) -> None: """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device @@ -655,6 +700,8 @@ async def test_addon_options_changed( addon_options["s2_access_control_key"] = new_s2_access_control_key addon_options["s2_authenticated_key"] = new_s2_authenticated_key addon_options["s2_unauthenticated_key"] = new_s2_unauthenticated_key + addon_options["lr_s2_access_control_key"] = new_lr_s2_access_control_key + addon_options["lr_s2_authenticated_key"] = new_lr_s2_authenticated_key entry = MockConfigEntry( domain=DOMAIN, title="Z-Wave JS", @@ -666,6 +713,8 @@ async def test_addon_options_changed( "s2_access_control_key": old_s2_access_control_key, "s2_authenticated_key": old_s2_authenticated_key, "s2_unauthenticated_key": old_s2_unauthenticated_key, + "lr_s2_access_control_key": old_lr_s2_access_control_key, + "lr_s2_authenticated_key": old_lr_s2_authenticated_key, }, ) entry.add_to_hass(hass) @@ -679,6 +728,8 @@ async def test_addon_options_changed( assert entry.data["s2_access_control_key"] == new_s2_access_control_key assert entry.data["s2_authenticated_key"] == new_s2_authenticated_key assert entry.data["s2_unauthenticated_key"] == new_s2_unauthenticated_key + assert entry.data["lr_s2_access_control_key"] == new_lr_s2_access_control_key + assert entry.data["lr_s2_authenticated_key"] == new_lr_s2_authenticated_key assert install_addon.call_count == 0 assert start_addon.call_count == 0 @@ -748,7 +799,9 @@ async def test_update_addon( assert update_addon.call_count == update_calls -async def test_issue_registry(hass: HomeAssistant, client, version_state) -> None: +async def test_issue_registry( + hass: HomeAssistant, client, version_state, issue_registry: ir.IssueRegistry +) -> None: """Test issue registry.""" device = "/test" network_key = "abc123" @@ -774,8 +827,7 @@ async def test_issue_registry(hass: HomeAssistant, client, version_state) -> Non assert entry.state is ConfigEntryState.SETUP_RETRY - issue_reg = ir.async_get(hass) - assert issue_reg.async_get_issue(DOMAIN, "invalid_server_version") + assert issue_registry.async_get_issue(DOMAIN, "invalid_server_version") async def connect(): await asyncio.sleep(0) @@ -786,7 +838,7 @@ async def test_issue_registry(hass: HomeAssistant, client, version_state) -> Non await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert not issue_reg.async_get_issue(DOMAIN, "invalid_server_version") + assert not issue_registry.async_get_issue(DOMAIN, "invalid_server_version") @pytest.mark.parametrize( @@ -957,6 +1009,7 @@ async def test_remove_entry( async def test_removed_device( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, client, climate_radio_thermostat_ct100_plus, lock_schlage_be469, @@ -969,8 +1022,9 @@ async def test_removed_device( assert len(driver.controller.nodes) == 3 # Make sure there are the same number of devices - dev_reg = dr.async_get(hass) - device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) + device_entries = dr.async_entries_for_config_entry( + device_registry, integration.entry_id + ) assert len(device_entries) == 3 # Remove a node and reload the entry @@ -979,32 +1033,41 @@ async def test_removed_device( await hass.async_block_till_done() # Assert that the node was removed from the device registry - device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) + device_entries = dr.async_entries_for_config_entry( + device_registry, integration.entry_id + ) assert len(device_entries) == 2 assert ( - dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None + device_registry.async_get_device(identifiers={get_device_id(driver, old_node)}) + is None ) -async def test_suggested_area(hass: HomeAssistant, client, eaton_rf9640_dimmer) -> None: +async def test_suggested_area( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + client, + eaton_rf9640_dimmer, +) -> None: """Test that suggested area works.""" - dev_reg = dr.async_get(hass) - ent_reg = er.async_get(hass) - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity = ent_reg.async_get(EATON_RF9640_ENTITY) - assert dev_reg.async_get(entity.device_id).area_id is not None + entity = entity_registry.async_get(EATON_RF9640_ENTITY) + assert device_registry.async_get(entity.device_id).area_id is not None async def test_node_removed( - hass: HomeAssistant, multisensor_6_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6_state, + client, + integration, ) -> None: """Test that device gets removed when node gets removed.""" - dev_reg = dr.async_get(hass) node = Node(client, deepcopy(multisensor_6_state)) device_id = f"{client.driver.controller.home_id}-{node.node_id}" event = { @@ -1016,7 +1079,7 @@ async def test_node_removed( client.driver.controller.receive_event(Event("node added", event)) await hass.async_block_till_done() - old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + old_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert old_device assert old_device.id @@ -1025,14 +1088,18 @@ async def test_node_removed( client.driver.controller.emit("node removed", event) await hass.async_block_till_done() # Assert device has been removed - assert not dev_reg.async_get(old_device.id) + assert not device_registry.async_get(old_device.id) async def test_replace_same_node( - hass: HomeAssistant, multisensor_6, multisensor_6_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + multisensor_6, + multisensor_6_state, + client, + integration, ) -> None: """Test when a node is replaced with itself that the device remains.""" - dev_reg = dr.async_get(hass) node_id = multisensor_6.node_id multisensor_6_state = deepcopy(multisensor_6_state) @@ -1042,9 +1109,9 @@ async def test_replace_same_node( f"{multisensor_6.product_type}:{multisensor_6.product_id}" ) - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device - assert device == dev_reg.async_get_device( + assert device == device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id)} ) assert device.manufacturer == "AEON Labs" @@ -1068,7 +1135,7 @@ async def test_replace_same_node( await hass.async_block_till_done() # Device should still be there after the node was removed - device = dev_reg.async_get(dev_id) + device = device_registry.async_get(dev_id) assert device # When the node is replaced, a non-ready node added event is emitted @@ -1106,7 +1173,7 @@ async def test_replace_same_node( client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get(dev_id) + device = device_registry.async_get(dev_id) assert device event = Event( @@ -1122,10 +1189,10 @@ async def test_replace_same_node( await hass.async_block_till_done() # Device is the same - device = dev_reg.async_get(dev_id) + device = device_registry.async_get(dev_id) assert device - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) - assert device == dev_reg.async_get_device( + assert device == device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device == device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id)} ) assert device.manufacturer == "AEON Labs" @@ -1136,6 +1203,7 @@ async def test_replace_same_node( async def test_replace_different_node( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, multisensor_6, multisensor_6_state, hank_binary_switch_state, @@ -1144,7 +1212,6 @@ async def test_replace_different_node( hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" - dev_reg = dr.async_get(hass) node_id = multisensor_6.node_id state = deepcopy(hank_binary_switch_state) state["nodeId"] = node_id @@ -1160,9 +1227,9 @@ async def test_replace_different_node( f"{state['productId']}" ) - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device - assert device == dev_reg.async_get_device( + assert device == device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert device.manufacturer == "AEON Labs" @@ -1185,7 +1252,7 @@ async def test_replace_different_node( await hass.async_block_till_done() # Device should still be there after the node was removed - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert device @@ -1228,7 +1295,7 @@ async def test_replace_different_node( client.driver.receive_event(event) await hass.async_block_till_done() - device = dev_reg.async_get(dev_id) + device = device_registry.async_get(dev_id) assert device event = Event( @@ -1245,16 +1312,18 @@ async def test_replace_different_node( # node ID based device identifier should be moved from the old multisensor device # to the new hank device and both the old and new devices should exist. - new_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + new_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert new_device - hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + hank_device = device_registry.async_get_device( + identifiers={(DOMAIN, hank_device_id_ext)} + ) assert hank_device assert hank_device == new_device assert hank_device.identifiers == { (DOMAIN, device_id), (DOMAIN, hank_device_id_ext), } - multisensor_6_device = dev_reg.async_get_device( + multisensor_6_device = device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert multisensor_6_device @@ -1285,7 +1354,9 @@ async def test_replace_different_node( await hass.async_block_till_done() # Device should still be there after the node was removed - device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, hank_device_id_ext)} + ) assert device assert len(device.identifiers) == 2 @@ -1342,13 +1413,15 @@ async def test_replace_different_node( # node ID based device identifier should be moved from the new hank device # to the old multisensor device and both the old and new devices should exist. - old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + old_device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert old_device - hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)}) + hank_device = device_registry.async_get_device( + identifiers={(DOMAIN, hank_device_id_ext)} + ) assert hank_device assert hank_device != old_device assert hank_device.identifiers == {(DOMAIN, hank_device_id_ext)} - multisensor_6_device = dev_reg.async_get_device( + multisensor_6_device = device_registry.async_get_device( identifiers={(DOMAIN, multisensor_6_device_id_ext)} ) assert multisensor_6_device @@ -1381,15 +1454,17 @@ async def test_replace_different_node( async def test_node_model_change( - hass: HomeAssistant, zp3111, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + zp3111, + client, + integration, ) -> None: """Test when a node's model is changed due to an updated device config file. The device and entities should not be removed. """ - dev_reg = dr.async_get(hass) - er_reg = er.async_get(hass) - device_id = f"{client.driver.controller.home_id}-{zp3111.node_id}" device_id_ext = ( f"{device_id}-{zp3111.manufacturer_id}:" @@ -1397,9 +1472,11 @@ async def test_node_model_change( ) # Verify device and entities have default names/ids - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device == device_registry.async_get_device( + identifiers={(DOMAIN, device_id_ext)} + ) assert device.manufacturer == "Vision Security" assert device.model == "ZP3111-5" assert device.name == "4-in-1 Sensor" @@ -1413,18 +1490,20 @@ async def test_node_model_change( assert state.name == "4-in-1 Sensor Motion detection" # Customize device and entity names/ids - dev_reg.async_update_device(device.id, name_by_user="Custom Device Name") - device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) + device_registry.async_update_device(device.id, name_by_user="Custom Device Name") + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device.id == dev_id - assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id_ext)}) + assert device == device_registry.async_get_device( + identifiers={(DOMAIN, device_id_ext)} + ) assert device.manufacturer == "Vision Security" assert device.model == "ZP3111-5" assert device.name == "4-in-1 Sensor" assert device.name_by_user == "Custom Device Name" custom_entity = "binary_sensor.custom_motion_sensor" - er_reg.async_update_entity( + entity_registry.async_update_entity( motion_entity, new_entity_id=custom_entity, name="Custom Entity Name" ) await hass.async_block_till_done() @@ -1450,7 +1529,7 @@ async def test_node_model_change( await hass.async_block_till_done() # Device name changes, but the customization is the same - device = dev_reg.async_get(dev_id) + device = device_registry.async_get(dev_id) assert device assert device.id == dev_id assert device.manufacturer == "New Device Manufacturer" @@ -1491,17 +1570,15 @@ async def test_disabled_node_status_entity_on_node_replaced( async def test_disabled_entity_on_value_removed( - hass: HomeAssistant, zp3111, client, integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, client, integration ) -> None: """Test that when entity primary values are removed the entity is removed.""" - er_reg = er.async_get(hass) - # re-enable this default-disabled entity sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status" idle_cover_status_button_entity = ( "button.4_in_1_sensor_idle_home_security_cover_status" ) - er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) + entity_registry.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) await hass.async_block_till_done() # must reload the integration when enabling an entity @@ -1776,10 +1853,14 @@ async def test_server_logging(hass: HomeAssistant, client) -> None: async def test_factory_reset_node( - hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + 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", @@ -1801,7 +1882,7 @@ async def test_factory_reset_node( 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}) + assert not device_registry.async_get_device(identifiers={dev_id}) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 0f41ae7dbaa..376bd700a2a 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -865,13 +865,16 @@ async def test_black_is_off_zdb5100( async def test_basic_cc_light( - hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + ge_in_wall_dimmer_switch, + integration, ) -> None: """Test light is created from Basic CC.""" node = ge_in_wall_dimmer_switch - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(BASIC_LIGHT_ENTITY) + entity_entry = entity_registry.async_get(BASIC_LIGHT_ENTITY) assert entity_entry assert not entity_entry.disabled diff --git a/tests/components/zwave_js/test_logbook.py b/tests/components/zwave_js/test_logbook.py index e42a2b2c56e..79d5a143edb 100644 --- a/tests/components/zwave_js/test_logbook.py +++ b/tests/components/zwave_js/test_logbook.py @@ -15,11 +15,14 @@ from tests.components.logbook.common import MockRow, mock_humanify async def test_humanifying_zwave_js_notification_event( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test humanifying Z-Wave JS notification events.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -99,11 +102,14 @@ async def test_humanifying_zwave_js_notification_event( async def test_humanifying_zwave_js_value_notification_event( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test humanifying Z-Wave JS value notification events.""" - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device diff --git a/tests/components/zwave_js/test_migrate.py b/tests/components/zwave_js/test_migrate.py index 41fa507a3a0..4e15bd4a295 100644 --- a/tests/components/zwave_js/test_migrate.py +++ b/tests/components/zwave_js/test_migrate.py @@ -14,18 +14,20 @@ from .common import AIR_TEMPERATURE_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR async def test_unique_id_migration_dupes( - hass: HomeAssistant, multisensor_6_state, client, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + multisensor_6_state, + client, + integration, ) -> None: """Test we remove an entity when .""" - ent_reg = er.async_get(hass) - entity_name = AIR_TEMPERATURE_SENSOR.split(".")[1] # Create entity RegistryEntry using old unique ID format old_unique_id_1 = ( f"{client.driver.controller.home_id}.52.52-49-00-Air temperature-00" ) - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id_1, @@ -40,7 +42,7 @@ async def test_unique_id_migration_dupes( old_unique_id_2 = ( f"{client.driver.controller.home_id}.52.52-49-0-Air temperature-00-00" ) - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id_2, @@ -59,11 +61,15 @@ async def test_unique_id_migration_dupes( await hass.async_block_till_done() # Check that new RegistryEntry is using new unique ID format - entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) + entity_entry = entity_registry.async_get(AIR_TEMPERATURE_SENSOR) new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature" assert entity_entry.unique_id == new_unique_id - assert ent_reg.async_get_entity_id("sensor", DOMAIN, old_unique_id_1) is None - assert ent_reg.async_get_entity_id("sensor", DOMAIN, old_unique_id_2) is None + assert ( + entity_registry.async_get_entity_id("sensor", DOMAIN, old_unique_id_1) is None + ) + assert ( + entity_registry.async_get_entity_id("sensor", DOMAIN, old_unique_id_2) is None + ) @pytest.mark.parametrize( @@ -75,17 +81,20 @@ async def test_unique_id_migration_dupes( ], ) async def test_unique_id_migration( - hass: HomeAssistant, multisensor_6_state, client, integration, id + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + multisensor_6_state, + client, + integration, + id, ) -> None: """Test unique ID is migrated from old format to new.""" - ent_reg = er.async_get(hass) - # Migrate version 1 entity_name = AIR_TEMPERATURE_SENSOR.split(".")[1] # Create entity RegistryEntry using old unique ID format old_unique_id = f"{client.driver.controller.home_id}.{id}" - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id, @@ -104,10 +113,10 @@ async def test_unique_id_migration( await hass.async_block_till_done() # Check that new RegistryEntry is using new unique ID format - entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) + entity_entry = entity_registry.async_get(AIR_TEMPERATURE_SENSOR) new_unique_id = f"{client.driver.controller.home_id}.52-49-0-Air temperature" assert entity_entry.unique_id == new_unique_id - assert ent_reg.async_get_entity_id("sensor", DOMAIN, old_unique_id) is None + assert entity_registry.async_get_entity_id("sensor", DOMAIN, old_unique_id) is None @pytest.mark.parametrize( @@ -119,17 +128,20 @@ async def test_unique_id_migration( ], ) async def test_unique_id_migration_property_key( - hass: HomeAssistant, hank_binary_switch_state, client, integration, id + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hank_binary_switch_state, + client, + integration, + id, ) -> None: """Test unique ID with property key is migrated from old format to new.""" - ent_reg = er.async_get(hass) - SENSOR_NAME = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" entity_name = SENSOR_NAME.split(".")[1] # Create entity RegistryEntry using old unique ID format old_unique_id = f"{client.driver.controller.home_id}.{id}" - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id, @@ -148,18 +160,20 @@ async def test_unique_id_migration_property_key( await hass.async_block_till_done() # Check that new RegistryEntry is using new unique ID format - entity_entry = ent_reg.async_get(SENSOR_NAME) + entity_entry = entity_registry.async_get(SENSOR_NAME) new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049" assert entity_entry.unique_id == new_unique_id - assert ent_reg.async_get_entity_id("sensor", DOMAIN, old_unique_id) is None + assert entity_registry.async_get_entity_id("sensor", DOMAIN, old_unique_id) is None async def test_unique_id_migration_notification_binary_sensor( - hass: HomeAssistant, multisensor_6_state, client, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + multisensor_6_state, + client, + integration, ) -> None: """Test unique ID is migrated from old format to new for a notification binary sensor.""" - ent_reg = er.async_get(hass) - entity_name = NOTIFICATION_MOTION_BINARY_SENSOR.split(".")[1] # Create entity RegistryEntry using old unique ID format @@ -167,7 +181,7 @@ async def test_unique_id_migration_notification_binary_sensor( f"{client.driver.controller.home_id}.52.52-113-00-Home Security-Motion sensor" " status.8" ) - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "binary_sensor", DOMAIN, old_unique_id, @@ -186,26 +200,32 @@ async def test_unique_id_migration_notification_binary_sensor( await hass.async_block_till_done() # Check that new RegistryEntry is using new unique ID format - entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_BINARY_SENSOR) + entity_entry = entity_registry.async_get(NOTIFICATION_MOTION_BINARY_SENSOR) new_unique_id = ( f"{client.driver.controller.home_id}.52-113-0-Home Security-Motion sensor" " status.8" ) assert entity_entry.unique_id == new_unique_id - assert ent_reg.async_get_entity_id("binary_sensor", DOMAIN, old_unique_id) is None + assert ( + entity_registry.async_get_entity_id("binary_sensor", DOMAIN, old_unique_id) + is None + ) async def test_old_entity_migration( - hass: HomeAssistant, hank_binary_switch_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hank_binary_switch_state, + client, + integration, ) -> None: """Test old entity on a different endpoint is migrated to a new one.""" node = Node(client, copy.deepcopy(hank_binary_switch_state)) driver = client.driver assert driver - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={get_device_id(driver, node)}, manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], @@ -217,7 +237,7 @@ async def test_old_entity_migration( # Create entity RegistryEntry using fake endpoint old_unique_id = f"{driver.controller.home_id}.32-50-1-value-66049" - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id, @@ -237,23 +257,28 @@ async def test_old_entity_migration( await hass.async_block_till_done() # Check that new RegistryEntry is using new unique ID format - entity_entry = ent_reg.async_get(SENSOR_NAME) + entity_entry = entity_registry.async_get(SENSOR_NAME) new_unique_id = f"{client.driver.controller.home_id}.32-50-0-value-66049" assert entity_entry.unique_id == new_unique_id - assert ent_reg.async_get_entity_id("sensor", DOMAIN, old_unique_id) is None + assert ( + entity_registry.async_get_entity_id("sensor", DOMAIN, old_unique_id) is None + ) async def test_different_endpoint_migration_status_sensor( - hass: HomeAssistant, hank_binary_switch_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hank_binary_switch_state, + client, + integration, ) -> None: """Test that the different endpoint migration logic skips over the status sensor.""" node = Node(client, copy.deepcopy(hank_binary_switch_state)) driver = client.driver assert driver - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={get_device_id(driver, node)}, manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], @@ -265,7 +290,7 @@ async def test_different_endpoint_migration_status_sensor( # Create entity RegistryEntry using fake endpoint old_unique_id = f"{driver.controller.home_id}.32.node_status" - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id, @@ -285,21 +310,24 @@ async def test_different_endpoint_migration_status_sensor( await hass.async_block_till_done() # Check that the RegistryEntry is using the same unique ID - entity_entry = ent_reg.async_get(SENSOR_NAME) + entity_entry = entity_registry.async_get(SENSOR_NAME) assert entity_entry.unique_id == old_unique_id async def test_skip_old_entity_migration_for_multiple( - hass: HomeAssistant, hank_binary_switch_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hank_binary_switch_state, + client, + integration, ) -> None: """Test that multiple entities of the same value but on a different endpoint get skipped.""" node = Node(client, copy.deepcopy(hank_binary_switch_state)) driver = client.driver assert driver - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={get_device_id(driver, node)}, manufacturer=hank_binary_switch_state["deviceConfig"]["manufacturer"], @@ -311,7 +339,7 @@ async def test_skip_old_entity_migration_for_multiple( # Create two entity entrrys using different endpoints old_unique_id_1 = f"{driver.controller.home_id}.32-50-1-value-66049" - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id_1, @@ -325,7 +353,7 @@ async def test_skip_old_entity_migration_for_multiple( # Create two entity entrrys using different endpoints old_unique_id_2 = f"{driver.controller.home_id}.32-50-2-value-66049" - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "sensor", DOMAIN, old_unique_id_2, @@ -342,26 +370,29 @@ async def test_skip_old_entity_migration_for_multiple( await hass.async_block_till_done() # Check that new RegistryEntry is created using new unique ID format - entity_entry = ent_reg.async_get(SENSOR_NAME) + entity_entry = entity_registry.async_get(SENSOR_NAME) new_unique_id = f"{driver.controller.home_id}.32-50-0-value-66049" assert entity_entry.unique_id == new_unique_id # Check that the old entities stuck around because we skipped the migration step - assert ent_reg.async_get_entity_id("sensor", DOMAIN, old_unique_id_1) - assert ent_reg.async_get_entity_id("sensor", DOMAIN, old_unique_id_2) + assert entity_registry.async_get_entity_id("sensor", DOMAIN, old_unique_id_1) + assert entity_registry.async_get_entity_id("sensor", DOMAIN, old_unique_id_2) async def test_old_entity_migration_notification_binary_sensor( - hass: HomeAssistant, multisensor_6_state, client, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + multisensor_6_state, + client, + integration, ) -> None: """Test old entity on a different endpoint is migrated to a new one for a notification binary sensor.""" node = Node(client, copy.deepcopy(multisensor_6_state)) driver = client.driver assert driver - ent_reg = er.async_get(hass) - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={get_device_id(driver, node)}, manufacturer=multisensor_6_state["deviceConfig"]["manufacturer"], @@ -374,7 +405,7 @@ async def test_old_entity_migration_notification_binary_sensor( old_unique_id = ( f"{driver.controller.home_id}.52-113-1-Home Security-Motion sensor status.8" ) - entity_entry = ent_reg.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "binary_sensor", DOMAIN, old_unique_id, @@ -394,11 +425,12 @@ async def test_old_entity_migration_notification_binary_sensor( await hass.async_block_till_done() # Check that new RegistryEntry is using new unique ID format - entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_BINARY_SENSOR) + entity_entry = entity_registry.async_get(NOTIFICATION_MOTION_BINARY_SENSOR) new_unique_id = ( f"{driver.controller.home_id}.52-113-0-Home Security-Motion sensor status.8" ) assert entity_entry.unique_id == new_unique_id assert ( - ent_reg.async_get_entity_id("binary_sensor", DOMAIN, old_unique_id) is None + entity_registry.async_get_entity_id("binary_sensor", DOMAIN, old_unique_id) + is None ) diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 38a582762cb..f5d7bf28169 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -219,20 +219,22 @@ async def test_volume_number( async def test_config_parameter_number( - hass: HomeAssistant, climate_adc_t3000, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + climate_adc_t3000, + integration, ) -> None: """Test config parameter number is created.""" number_entity_id = "number.adc_t3000_heat_staging_delay" number_with_states_entity_id = "number.adc_t3000_calibration_temperature" - ent_reg = er.async_get(hass) for entity_id in (number_entity_id, number_with_states_entity_id): - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.disabled assert entity_entry.entity_category == EntityCategory.CONFIG for entity_id in (number_entity_id, number_with_states_entity_id): - updated_entry = ent_reg.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entity_entry assert updated_entry.disabled is False diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index 77191982b6e..c103a06c5fa 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -55,17 +55,19 @@ async def test_device_config_file_changed_confirm_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, client, multisensor_6_state, integration, ) -> None: """Test the device_config_file_changed issue confirm step.""" - dev_reg = dr.async_get(hass) node = await _trigger_repair_issue(hass, client, multisensor_6_state) client.async_send_command_no_wait.reset_mock() - device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, node)} + ) assert device issue_id = f"device_config_file_changed.{device.id}" @@ -128,17 +130,19 @@ async def test_device_config_file_changed_ignore_step( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, client, multisensor_6_state, integration, ) -> None: """Test the device_config_file_changed issue ignore step.""" - dev_reg = dr.async_get(hass) node = await _trigger_repair_issue(hass, client, multisensor_6_state) client.async_send_command_no_wait.reset_mock() - device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, node)} + ) assert device issue_id = f"device_config_file_changed.{device.id}" @@ -256,15 +260,17 @@ async def test_abort_confirm( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, client, multisensor_6_state, integration, ) -> None: """Test aborting device_config_file_changed issue in confirm step.""" - dev_reg = dr.async_get(hass) node = await _trigger_repair_issue(hass, client, multisensor_6_state) - device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + device = device_registry.async_get_device( + identifiers={get_device_id(client.driver, node)} + ) assert device issue_id = f"device_config_file_changed.{device.id}" diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index f1a1f8796d0..ddfd205b017 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -21,6 +21,7 @@ MULTILEVEL_SWITCH_SELECT_ENTITY = "select.front_door_siren" async def test_default_tone_select( hass: HomeAssistant, + entity_registry: er.EntityRegistry, client: MagicMock, aeotec_zw164_siren: Node, integration: ConfigEntry, @@ -64,7 +65,6 @@ async def test_default_tone_select( "30DOOR~1 (27 sec)", ] - entity_registry = er.async_get(hass) entity_entry = entity_registry.async_get(DEFAULT_TONE_SELECT_ENTITY) assert entity_entry @@ -118,6 +118,7 @@ async def test_default_tone_select( async def test_protection_select( hass: HomeAssistant, + entity_registry: er.EntityRegistry, client: MagicMock, inovelli_lzw36: Node, integration: ConfigEntry, @@ -135,7 +136,6 @@ async def test_protection_select( "NoOperationPossible", ] - entity_registry = er.async_get(hass) entity_entry = entity_registry.async_get(PROTECTION_SELECT_ENTITY) assert entity_entry @@ -298,17 +298,21 @@ async def test_multilevel_switch_select_no_value( async def test_config_parameter_select( - hass: HomeAssistant, climate_adc_t3000, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + climate_adc_t3000, + integration, ) -> None: """Test config parameter select is created.""" select_entity_id = "select.adc_t3000_hvac_system_type" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(select_entity_id) + entity_entry = entity_registry.async_get(select_entity_id) assert entity_entry assert entity_entry.disabled assert entity_entry.entity_category == EntityCategory.CONFIG - updated_entry = ent_reg.async_update_entity(select_entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity( + select_entity_id, disabled_by=None + ) assert updated_entry != entity_entry assert updated_entry.disabled is False diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 417b57aaaaa..02b3df17e22 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -57,7 +57,11 @@ from .common import ( async def test_numeric_sensor( - hass: HomeAssistant, multisensor_6, express_controls_ezmultipli, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + multisensor_6, + express_controls_ezmultipli, + integration, ) -> None: """Test the numeric sensor.""" state = hass.states.get(AIR_TEMPERATURE_SENSOR) @@ -76,8 +80,7 @@ async def test_numeric_sensor( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(BATTERY_SENSOR) + entity_entry = entity_registry.async_get(BATTERY_SENSOR) assert entity_entry assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC @@ -209,19 +212,27 @@ async def test_energy_sensors( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.CURRENT +async def test_basic_cc_sensor( + hass: HomeAssistant, client, basic_cc_sensor, integration +) -> None: + """Test a Basic CC sensor gets discovered correctly.""" + state = hass.states.get("sensor.foo_basic") + assert state is not None + assert state.state == "255.0" + + async def test_disabled_notification_sensor( - hass: HomeAssistant, multisensor_6, integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration ) -> None: """Test sensor is created from Notification CC and is disabled.""" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_SENSOR) + entity_entry = entity_registry.async_get(NOTIFICATION_MOTION_SENSOR) assert entity_entry assert entity_entry.disabled assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = ent_reg.async_update_entity( + updated_entry = entity_registry.async_update_entity( entity_entry.entity_id, disabled_by=None ) assert updated_entry != entity_entry @@ -265,20 +276,23 @@ async def test_disabled_notification_sensor( async def test_config_parameter_sensor( - hass: HomeAssistant, climate_adc_t3000, lock_id_lock_as_id150, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + climate_adc_t3000, + lock_id_lock_as_id150, + integration, ) -> None: """Test config parameter sensor is created.""" sensor_entity_id = "sensor.adc_t3000_system_configuration_cool_stages" sensor_with_states_entity_id = "sensor.adc_t3000_power_source" - ent_reg = er.async_get(hass) for entity_id in (sensor_entity_id, sensor_with_states_entity_id): - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.disabled assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC for entity_id in (sensor_entity_id, sensor_with_states_entity_id): - updated_entry = ent_reg.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entity_entry assert updated_entry.disabled is False @@ -294,7 +308,7 @@ async def test_config_parameter_sensor( assert state assert state.state == "C-Wire" - updated_entry = ent_reg.async_update_entity( + updated_entry = entity_registry.async_update_entity( entity_entry.entity_id, disabled_by=None ) assert updated_entry != entity_entry @@ -306,12 +320,11 @@ async def test_config_parameter_sensor( async def test_controller_status_sensor( - hass: HomeAssistant, client, integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, client, integration ) -> None: """Test controller status sensor is created and gets updated on controller state changes.""" entity_id = "sensor.z_stick_gen5_usb_controller_status" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert not entity_entry.disabled assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC @@ -344,13 +357,16 @@ async def test_controller_status_sensor( async def test_node_status_sensor( - hass: HomeAssistant, client, lock_id_lock_as_id150, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + lock_id_lock_as_id150, + integration, ) -> None: """Test node status sensor is created and gets updated on node state changes.""" node_status_entity_id = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(node_status_entity_id) + entity_entry = entity_registry.async_get(node_status_entity_id) assert not entity_entry.disabled assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC @@ -390,7 +406,7 @@ async def test_node_status_sensor( node = driver.controller.nodes[1] assert node.is_controller_node assert ( - ent_reg.async_get_entity_id( + entity_registry.async_get_entity_id( DOMAIN, "sensor", f"{get_valueless_base_unique_id(driver, node)}.node_status", @@ -400,7 +416,7 @@ async def test_node_status_sensor( # Assert a controller status sensor entity is not created for a node assert ( - ent_reg.async_get_entity_id( + entity_registry.async_get_entity_id( DOMAIN, "sensor", f"{get_valueless_base_unique_id(driver, node)}.controller_status", @@ -411,6 +427,7 @@ async def test_node_status_sensor( async def test_node_status_sensor_not_ready( hass: HomeAssistant, + entity_registry: er.EntityRegistry, client, lock_id_lock_as_id150_not_ready, lock_id_lock_as_id150_state, @@ -421,8 +438,7 @@ async def test_node_status_sensor_not_ready( node_status_entity_id = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150_not_ready assert not node.ready - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(node_status_entity_id) + entity_entry = entity_registry.async_get(node_status_entity_id) assert not entity_entry.disabled assert hass.states.get(node_status_entity_id) @@ -736,10 +752,14 @@ NODE_STATISTICS_SUFFIXES_UNKNOWN = { async def test_statistics_sensors_no_last_seen( - hass: HomeAssistant, zp3111, client, integration, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zp3111, + client, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test all statistics sensors but last seen which is enabled by default.""" - ent_reg = er.async_get(hass) for prefix, suffixes in ( (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), @@ -748,12 +768,12 @@ async def test_statistics_sensors_no_last_seen( (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), ): for suffix_key in suffixes: - entry = ent_reg.async_get(f"{prefix}{suffix_key}") + entry = entity_registry.async_get(f"{prefix}{suffix_key}") assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - ent_reg.async_update_entity(entry.entity_id, disabled_by=None) + entity_registry.async_update_entity(entry.entity_id, disabled_by=None) # reload integration and check if entity is correctly there await hass.config_entries.async_reload(integration.entry_id) @@ -774,7 +794,7 @@ async def test_statistics_sensors_no_last_seen( ), ): for suffix_key in suffixes: - entry = ent_reg.async_get(f"{prefix}{suffix_key}") + entry = entity_registry.async_get(f"{prefix}{suffix_key}") assert entry assert not entry.disabled assert entry.disabled_by is None @@ -881,13 +901,11 @@ async def test_statistics_sensors_no_last_seen( async def test_last_seen_statistics_sensors( - hass: HomeAssistant, zp3111, client, integration + hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, client, integration ) -> None: """Test last_seen statistics sensors.""" - ent_reg = er.async_get(hass) - entity_id = f"{NODE_STATISTICS_ENTITY_PREFIX}last_seen" - entry = ent_reg.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert not entry.disabled diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 5462bcf9946..ec13d0262f8 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -41,9 +41,11 @@ from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.area_registry import async_get as async_get_area_reg -from homeassistant.helpers.device_registry import async_get as async_get_dev_reg -from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from .common import ( @@ -61,6 +63,9 @@ from tests.common import MockConfigEntry async def test_set_config_parameter( hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, client, multisensor_6, aeotec_zw164_siren, @@ -68,9 +73,7 @@ async def test_set_config_parameter( caplog: pytest.LogCaptureFixture, ) -> None: """Test the set_config_parameter service.""" - dev_reg = async_get_dev_reg(hass) - ent_reg = async_get_ent_reg(hass) - entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR) + entity_entry = entity_registry.async_get(AIR_TEMPERATURE_SENSOR) # Test setting config parameter by property and property_key await hass.services.async_call( @@ -179,9 +182,8 @@ async def test_set_config_parameter( client.async_send_command_no_wait.reset_mock() # Test using area ID - area_reg = async_get_area_reg(hass) - area = area_reg.async_get_or_create("test") - ent_reg.async_update_entity(entity_entry.entity_id, area_id=area.id) + area = area_registry.async_get_or_create("test") + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area.id) await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -345,16 +347,16 @@ async def test_set_config_parameter( non_zwave_js_config_entry = MockConfigEntry(entry_id="fake_entry_id") non_zwave_js_config_entry.add_to_hass(hass) - non_zwave_js_device = dev_reg.async_get_or_create( + non_zwave_js_device = device_registry.async_get_or_create( config_entry_id=non_zwave_js_config_entry.entry_id, identifiers={("test", "test")}, ) - zwave_js_device_with_invalid_node_id = dev_reg.async_get_or_create( + zwave_js_device_with_invalid_node_id = device_registry.async_get_or_create( config_entry_id=integration.entry_id, identifiers={(DOMAIN, "500-500")} ) - non_zwave_js_entity = ent_reg.async_get_or_create( + non_zwave_js_entity = entity_registry.async_get_or_create( "test", "sensor", "test_sensor", @@ -601,11 +603,15 @@ async def test_set_config_parameter_gather( async def test_bulk_set_config_parameters( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + client, + multisensor_6, + integration, ) -> None: """Test the bulk_set_partial_config_parameters service.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, multisensor_6)} ) assert device @@ -636,9 +642,8 @@ async def test_bulk_set_config_parameters( client.async_send_command_no_wait.reset_mock() # Test using area ID - area_reg = async_get_area_reg(hass) - area = area_reg.async_get_or_create("test") - dev_reg.async_update_device(device.id, area_id=area.id) + area = area_registry.async_get_or_create("test") + device_registry.async_update_device(device.id, area_id=area.id) await hass.services.async_call( DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -968,11 +973,15 @@ async def test_refresh_value( async def test_set_value( - hass: HomeAssistant, client, climate_danfoss_lc_13, integration + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + client, + climate_danfoss_lc_13, + integration, ) -> None: """Test set_value service.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device @@ -1030,9 +1039,8 @@ async def test_set_value( client.async_send_command.reset_mock() # Test using area ID - area_reg = async_get_area_reg(hass) - area = area_reg.async_get_or_create("test") - dev_reg.async_update_device(device.id, area_id=area.id) + area = area_registry.async_get_or_create("test") + device_registry.async_update_device(device.id, area_id=area.id) await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, @@ -1254,6 +1262,8 @@ async def test_set_value_gather( async def test_multicast_set_value( hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, client, climate_danfoss_lc_13, climate_eurotronic_spirit_z, @@ -1327,19 +1337,17 @@ async def test_multicast_set_value( client.async_send_command.reset_mock() # Test using area ID - dev_reg = async_get_dev_reg(hass) - device_eurotronic = dev_reg.async_get_device( + device_eurotronic = device_registry.async_get_device( identifiers={get_device_id(client.driver, climate_eurotronic_spirit_z)} ) assert device_eurotronic - device_danfoss = dev_reg.async_get_device( + device_danfoss = device_registry.async_get_device( identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device_danfoss - area_reg = async_get_area_reg(hass) - area = area_reg.async_get_or_create("test") - dev_reg.async_update_device(device_eurotronic.id, area_id=area.id) - dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + area = area_registry.async_get_or_create("test") + device_registry.async_update_device(device_eurotronic.id, area_id=area.id) + device_registry.async_update_device(device_danfoss.id, area_id=area.id) await hass.services.async_call( DOMAIN, SERVICE_MULTICAST_SET_VALUE, @@ -1646,14 +1654,15 @@ async def test_multicast_set_value_string( async def test_ping( hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, client, climate_danfoss_lc_13, climate_radio_thermostat_ct100_plus_different_endpoints, integration, ) -> None: """Test ping service.""" - dev_reg = async_get_dev_reg(hass) - device_radio_thermostat = dev_reg.async_get_device( + device_radio_thermostat = device_registry.async_get_device( identifiers={ get_device_id( client.driver, climate_radio_thermostat_ct100_plus_different_endpoints @@ -1661,7 +1670,7 @@ async def test_ping( } ) assert device_radio_thermostat - device_danfoss = dev_reg.async_get_device( + device_danfoss = device_registry.async_get_device( identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device_danfoss @@ -1721,10 +1730,9 @@ async def test_ping( client.async_send_command.reset_mock() # Test successful ping call with area - area_reg = async_get_area_reg(hass) - area = area_reg.async_get_or_create("test") - dev_reg.async_update_device(device_radio_thermostat.id, area_id=area.id) - dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + area = area_registry.async_get_or_create("test") + device_registry.async_update_device(device_radio_thermostat.id, area_id=area.id) + device_registry.async_update_device(device_danfoss.id, area_id=area.id) await hass.services.async_call( DOMAIN, SERVICE_PING, @@ -1803,14 +1811,15 @@ async def test_ping( async def test_invoke_cc_api( hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, client, climate_danfoss_lc_13, climate_radio_thermostat_ct100_plus_different_endpoints, integration, ) -> None: """Test invoke_cc_api service.""" - dev_reg = async_get_dev_reg(hass) - device_radio_thermostat = dev_reg.async_get_device( + device_radio_thermostat = device_registry.async_get_device( identifiers={ get_device_id( client.driver, climate_radio_thermostat_ct100_plus_different_endpoints @@ -1818,7 +1827,7 @@ async def test_invoke_cc_api( } ) assert device_radio_thermostat - device_danfoss = dev_reg.async_get_device( + device_danfoss = device_registry.async_get_device( identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device_danfoss @@ -1868,9 +1877,8 @@ async def test_invoke_cc_api( client.async_send_command_no_wait.reset_mock() # Test successful invoke_cc_api call without an endpoint (include area) - area_reg = async_get_area_reg(hass) - area = area_reg.async_get_or_create("test") - dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + area = area_registry.async_get_or_create("test") + device_registry.async_update_device(device_danfoss.id, area_id=area.id) client.async_send_command.return_value = {"response": True} client.async_send_command_no_wait.return_value = {"response": True} @@ -1969,22 +1977,26 @@ async def test_invoke_cc_api( async def test_refresh_notifications( - hass: HomeAssistant, client, zen_31, multisensor_6, integration + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + client, + zen_31, + multisensor_6, + integration, ) -> None: """Test refresh_notifications service.""" - dev_reg = async_get_dev_reg(hass) - zen_31_device = dev_reg.async_get_device( + zen_31_device = device_registry.async_get_device( identifiers={get_device_id(client.driver, zen_31)} ) assert zen_31_device - multisensor_6_device = dev_reg.async_get_device( + multisensor_6_device = device_registry.async_get_device( identifiers={get_device_id(client.driver, multisensor_6)} ) assert multisensor_6_device - area_reg = async_get_area_reg(hass) - area = area_reg.async_get_or_create("test") - dev_reg.async_update_device(zen_31_device.id, area_id=area.id) + area = area_registry.async_get_or_create("test") + device_registry.async_update_device(zen_31_device.id, area_id=area.id) # Test successful refresh_notifications call client.async_send_command.return_value = {"response": True} diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index 5a5ad0821eb..c18c0c4359e 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -219,16 +219,21 @@ async def test_switch_no_value( async def test_config_parameter_switch( - hass: HomeAssistant, hank_binary_switch, integration, client + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hank_binary_switch, + integration, + client, ) -> None: """Test config parameter switch is created.""" switch_entity_id = "switch.smart_plug_with_two_usb_ports_overload_protection" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(switch_entity_id) + entity_entry = entity_registry.async_get(switch_entity_id) assert entity_entry assert entity_entry.disabled - updated_entry = ent_reg.async_update_entity(switch_entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity( + switch_entity_id, disabled_by=None + ) assert updated_entry != entity_entry assert updated_entry.disabled is False assert entity_entry.entity_category == EntityCategory.CONFIG diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 23c97913400..5822afe7b9f 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -20,7 +20,7 @@ from homeassistant.components.zwave_js.triggers.trigger_helpers import ( ) from homeassistant.const import CONF_PLATFORM, SERVICE_RELOAD from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get as async_get_dev_reg +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .common import SCHLAGE_BE469_LOCK_ENTITY @@ -29,13 +29,16 @@ from tests.common import async_capture_events async def test_zwave_js_value_updated( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test for zwave_js.value_updated automation trigger.""" trigger_type = f"{DOMAIN}.value_updated" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -453,13 +456,16 @@ async def test_zwave_js_value_updated_bypass_dynamic_validation_no_driver( async def test_zwave_js_event( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test for zwave_js.event automation trigger.""" trigger_type = f"{DOMAIN}.event" node: Node = lock_schlage_be469 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1009,11 +1015,14 @@ async def test_invalid_trigger_configs(hass: HomeAssistant) -> None: async def test_zwave_js_trigger_config_entry_unloaded( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + client, + lock_schlage_be469, + integration, ) -> None: """Test zwave_js triggers bypass dynamic validation when needed.""" - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 338d1511fc3..abdceb155f7 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -25,7 +25,7 @@ from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import ( @@ -113,6 +113,7 @@ FIRMWARE_UPDATES = { async def test_update_entity_states( hass: HomeAssistant, + entity_registry: er.EntityRegistry, client, climate_radio_thermostat_ct100_plus_different_endpoints, integration, @@ -194,7 +195,7 @@ async def test_update_entity_states( node = driver.controller.nodes[1] assert node.is_controller_node assert ( - async_get(hass).async_get_entity_id( + entity_registry.async_get_entity_id( DOMAIN, "sensor", f"{get_valueless_base_unique_id(driver, node)}.firmware_update", diff --git a/tests/conftest.py b/tests/conftest.py index 031469848ca..14e6f97d7c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Callable, Coroutine, Generator +from collections.abc import Callable, Coroutine from contextlib import asynccontextmanager, contextmanager import functools import gc @@ -14,8 +14,8 @@ import reprlib import sqlite3 import ssl import threading -from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch from aiohttp import client from aiohttp.test_utils import ( @@ -26,12 +26,16 @@ from aiohttp.test_utils import ( ) from aiohttp.typedefs import JSONDecoder from aiohttp.web import Application +import bcrypt import freezegun import multidict import pytest import pytest_socket import requests_mock from syrupy.assertion import SnapshotAssertion +from typing_extensions import AsyncGenerator, Generator + +from homeassistant import block_async_io # Setup patching if dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip @@ -39,7 +43,7 @@ from . import patch_time # noqa: F401, isort:skip from homeassistant import core as ha, loader, runner 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.auth.providers import homeassistant from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, @@ -48,9 +52,15 @@ from homeassistant.components.websocket_api.auth import ( ) from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE -from homeassistant.config_entries import ConfigEntries, ConfigEntry +from homeassistant.config_entries import ConfigEntries, ConfigEntry, ConfigEntryState from homeassistant.const import HASSIO_USER_NAME -from homeassistant.core import CoreState, HassJob, HomeAssistant +from homeassistant.core import ( + CoreState, + HassJob, + HomeAssistant, + ServiceCall, + ServiceResponse, +) from homeassistant.helpers import ( area_registry as ar, category_registry as cr, @@ -63,6 +73,7 @@ from homeassistant.helpers import ( recorder as recorder_helper, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.translation import _TranslationsCacheData from homeassistant.helpers.typing import ConfigType from homeassistant.setup import BASE_PLATFORMS, async_setup_component from homeassistant.util import location @@ -204,11 +215,7 @@ class HAFakeDatetime(freezegun.api.FakeDatetime): # type: ignore[name-defined] return ha_datetime_to_fakedatetime(result) -_R = TypeVar("_R") -_P = ParamSpec("_P") - - -def check_real(func: Callable[_P, Coroutine[Any, Any, _R]]): +def check_real[**_P, _R](func: Callable[_P, Coroutine[Any, Any, _R]]): """Force a function to require a keyword _test_real to be passed in.""" @functools.wraps(func) @@ -296,7 +303,7 @@ def wait_for_stop_scripts_after_shutdown() -> bool: @pytest.fixture(autouse=True) def skip_stop_scripts( wait_for_stop_scripts_after_shutdown: bool, -) -> Generator[None, None, None]: +) -> Generator[None]: """Add ability to bypass _schedule_stop_scripts_after_shutdown.""" if wait_for_stop_scripts_after_shutdown: yield @@ -309,7 +316,7 @@ def skip_stop_scripts( @contextmanager -def long_repr_strings() -> Generator[None, None, None]: +def long_repr_strings() -> Generator[None]: """Increase reprlib maxstring and maxother to 300.""" arepr = reprlib.aRepr original_maxstring = arepr.maxstring @@ -334,7 +341,7 @@ def verify_cleanup( event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool, expected_lingering_timers: bool, -) -> Generator[None, None, None]: +) -> Generator[None]: """Verify that the test has cleaned up resources correctly.""" threads_before = frozenset(threading.enumerate()) tasks_before = asyncio.all_tasks(event_loop) @@ -355,7 +362,7 @@ def verify_cleanup( if expected_lingering_tasks: _LOGGER.warning("Lingering task after test %r", task) else: - pytest.fail(f"Lingering task after test {repr(task)}") + pytest.fail(f"Lingering task after test {task!r}") task.cancel() if tasks: event_loop.run_until_complete(asyncio.wait(tasks)) @@ -368,9 +375,9 @@ def verify_cleanup( elif handle._args and isinstance(job := handle._args[-1], HassJob): if job.cancel_on_shutdown: continue - pytest.fail(f"Lingering timer after job {repr(job)}") + pytest.fail(f"Lingering timer after job {job!r}") else: - pytest.fail(f"Lingering timer after test {repr(handle)}") + pytest.fail(f"Lingering timer after test {handle!r}") handle.cancel() # Verify no threads where left behind. @@ -382,17 +389,15 @@ def verify_cleanup( @pytest.fixture(autouse=True) -def reset_hass_threading_local_object() -> Generator[None, None, None]: +def reset_hass_threading_local_object() -> Generator[None]: """Reset the _Hass threading.local object for every test case.""" yield ha._hass.__dict__.clear() @pytest.fixture(scope="session", autouse=True) -def bcrypt_cost() -> Generator[None, None, None]: +def bcrypt_cost() -> Generator[None]: """Run with reduced rounds during tests, to speed up uses.""" - import bcrypt - gensalt_orig = bcrypt.gensalt def gensalt_mock(rounds=12, prefix=b"2b"): @@ -404,7 +409,7 @@ def bcrypt_cost() -> Generator[None, None, None]: @pytest.fixture -def hass_storage() -> Generator[dict[str, Any], None, None]: +def hass_storage() -> Generator[dict[str, Any]]: """Fixture to mock storage.""" with mock_storage() as stored_data: yield stored_data @@ -462,7 +467,7 @@ def aiohttp_client_cls() -> type[CoalescingClient]: @pytest.fixture def aiohttp_client( event_loop: asyncio.AbstractEventLoop, -) -> Generator[ClientSessionGenerator, None, None]: +) -> Generator[ClientSessionGenerator]: """Override the default aiohttp_client since 3.x does not support aiohttp_client_cls. Remove this when upgrading to 4.x as aiohttp_client_cls @@ -527,7 +532,7 @@ async def hass( hass_storage: dict[str, Any], request: pytest.FixtureRequest, mock_recorder_before_hass: None, -) -> AsyncGenerator[HomeAssistant, None]: +) -> AsyncGenerator[HomeAssistant]: """Create a test instance of Home Assistant.""" loop = asyncio.get_running_loop() @@ -558,12 +563,21 @@ async def hass( # Config entries are not normally unloaded on HA shutdown. They are unloaded here # to ensure that they could, and to help track lingering tasks and timers. - await asyncio.gather( - *( - create_eager_task(config_entry.async_unload(hass)) - for config_entry in hass.config_entries.async_entries() + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries() + if entry.state is ConfigEntryState.LOADED + ] + if loaded_entries: + await asyncio.gather( + *( + create_eager_task( + hass.config_entries.async_unload(config_entry.entry_id), + loop=hass.loop, + ) + for config_entry in loaded_entries + ) ) - ) await hass.async_stop(force=True) @@ -577,7 +591,7 @@ async def hass( @pytest.fixture -async def stop_hass() -> AsyncGenerator[None, None]: +async def stop_hass() -> AsyncGenerator[None]: """Make sure all hass are stopped.""" orig_hass = ha.HomeAssistant @@ -603,21 +617,21 @@ async def stop_hass() -> AsyncGenerator[None, None]: @pytest.fixture(name="requests_mock") -def requests_mock_fixture() -> Generator[requests_mock.Mocker, None, None]: +def requests_mock_fixture() -> Generator[requests_mock.Mocker]: """Fixture to provide a requests mocker.""" with requests_mock.mock() as m: yield m @pytest.fixture -def aioclient_mock() -> Generator[AiohttpClientMocker, None, None]: +def aioclient_mock() -> Generator[AiohttpClientMocker]: """Fixture to mock aioclient calls.""" with mock_aiohttp_client() as mock_session: yield mock_session @pytest.fixture -def mock_device_tracker_conf() -> Generator[list[Device], None, None]: +def mock_device_tracker_conf() -> Generator[list[Device]]: """Prevent device tracker from reading/writing data.""" devices: list[Device] = [] @@ -729,7 +743,7 @@ async def hass_supervisor_user( @pytest.fixture async def hass_supervisor_access_token( hass: HomeAssistant, - hass_supervisor_user, + hass_supervisor_user: MockUser, local_auth: homeassistant.HassAuthProvider, ) -> str: """Return a Home Assistant Supervisor access token.""" @@ -737,20 +751,6 @@ async def hass_supervisor_access_token( return hass.auth.async_create_access_token(refresh_token) -@pytest.fixture -def legacy_auth( - hass: HomeAssistant, -) -> legacy_api_password.LegacyApiPasswordAuthProvider: - """Load legacy API password provider.""" - prv = legacy_api_password.LegacyApiPasswordAuthProvider( - hass, - hass.auth._store, - {"type": "legacy_api_password", "api_password": "test-password"}, - ) - hass.auth._providers[(prv.type, prv.id)] = prv - return prv - - @pytest.fixture async def local_auth(hass: HomeAssistant) -> homeassistant.HassAuthProvider: """Load local auth provider.""" @@ -796,7 +796,7 @@ def hass_client_no_auth( @pytest.fixture -def current_request() -> Generator[MagicMock, None, None]: +def current_request() -> Generator[MagicMock]: """Mock current request.""" with patch("homeassistant.components.http.current_request") as mock_request_context: mocked_request = make_mocked_request( @@ -822,7 +822,7 @@ def current_request_with_host(current_request: MagicMock) -> None: @pytest.fixture def hass_ws_client( aiohttp_client: ClientSessionGenerator, - hass_access_token: str | None, + hass_access_token: str, hass: HomeAssistant, socket_enabled: None, ) -> WebSocketGenerator: @@ -846,7 +846,7 @@ def hass_ws_client( auth_ok = await websocket.receive_json() assert auth_ok["type"] == TYPE_AUTH_OK - def _get_next_id() -> Generator[int, None, None]: + def _get_next_id() -> Generator[int]: i = 0 while True: yield (i := i + 1) @@ -886,7 +886,7 @@ def fail_on_log_exception( return def log_exception(format_err, *args): - raise + raise # pylint: disable=misplaced-bare-raise monkeypatch.setattr("homeassistant.util.logging.log_exception", log_exception) @@ -898,7 +898,7 @@ def mqtt_config_entry_data() -> dict[str, Any] | None: @pytest.fixture -def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient, None, None]: +def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: """Fixture to mock MQTT client.""" mid: int = 0 @@ -915,14 +915,16 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient, None, self.mid = mid self.rc = 0 - with patch("paho.mqtt.client.Client") as mock_client: + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as mock_client: # The below use a call_soon for the on_publish/on_subscribe/on_unsubscribe # callbacks to simulate the behavior of the real MQTT client which will # not be synchronous. @ha.callback def _async_fire_mqtt_message(topic, payload, qos, retain): - async_fire_mqtt_message(hass, topic, payload, qos, retain) + async_fire_mqtt_message(hass, topic, payload or b"", qos, retain) mid = get_mid() hass.loop.call_soon(mock_client.on_publish, 0, 0, mid) return FakeInfo(mid) @@ -968,7 +970,7 @@ async def mqtt_mock( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_mock_entry: MqttMockHAClientGenerator, -) -> AsyncGenerator[MqttMockHAClient, None]: +) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" return await mqtt_mock_entry() @@ -978,7 +980,7 @@ async def _mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, -) -> AsyncGenerator[MqttMockHAClientGenerator, None]: +) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. @@ -1018,7 +1020,7 @@ async def _mqtt_mock_entry( mock_mqtt_instance.connected = True mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, 0, 0) - async_dispatcher_send(hass, mqtt.MQTT_CONNECTED) + async_dispatcher_send(hass, mqtt.MQTT_CONNECTION_STATE, True) await hass.async_block_till_done() return mock_mqtt_instance @@ -1029,7 +1031,7 @@ async def _mqtt_mock_entry( nonlocal real_mqtt_instance real_mqtt_instance = real_mqtt(*args, **kwargs) spec = [*dir(real_mqtt_instance), "_mqttc"] - mock_mqtt_instance = MqttMockHAClient( + mock_mqtt_instance = MagicMock( return_value=real_mqtt_instance, spec_set=spec, wraps=real_mqtt_instance, @@ -1052,9 +1054,7 @@ def hass_config() -> ConfigType: @pytest.fixture -def mock_hass_config( - hass: HomeAssistant, hass_config: ConfigType -) -> Generator[None, None, None]: +def mock_hass_config(hass: HomeAssistant, hass_config: ConfigType) -> Generator[None]: """Fixture to mock the content of main configuration. Patches homeassistant.config.load_yaml_config_file and hass.config_entries @@ -1093,7 +1093,7 @@ def hass_config_yaml_files(hass_config_yaml: str) -> dict[str, str]: @pytest.fixture def mock_hass_config_yaml( hass: HomeAssistant, hass_config_yaml_files: dict[str, str] -) -> Generator[None, None, None]: +) -> Generator[None]: """Fixture to mock the content of the yaml configuration files. Patches yaml configuration files using the `hass_config_yaml` @@ -1108,7 +1108,7 @@ async def mqtt_mock_entry( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, -) -> AsyncGenerator[MqttMockHAClientGenerator, None]: +) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" async def _async_setup_config_entry( @@ -1130,7 +1130,7 @@ async def mqtt_mock_entry( @pytest.fixture(autouse=True, scope="session") -def mock_network() -> Generator[None, None, None]: +def mock_network() -> Generator[None]: """Mock network.""" with patch( "homeassistant.components.network.util.ifaddr.get_adapters", @@ -1145,18 +1145,47 @@ def mock_network() -> Generator[None, None, None]: yield -@pytest.fixture(autouse=True) -def mock_get_source_ip() -> Generator[None, None, None]: +@pytest.fixture(autouse=True, scope="session") +def mock_get_source_ip() -> Generator[_patch]: """Mock network util's async_get_source_ip.""" - with patch( + patcher = patch( "homeassistant.components.network.util.async_get_source_ip", return_value="10.10.10.10", - ): - yield + ) + patcher.start() + try: + yield patcher + finally: + patcher.stop() + + +@pytest.fixture(autouse=True, scope="session") +def translations_once() -> Generator[_patch]: + """Only load translations once per session.""" + cache = _TranslationsCacheData({}, {}) + patcher = patch( + "homeassistant.helpers.translation._TranslationsCacheData", + return_value=cache, + ) + patcher.start() + try: + yield patcher + finally: + patcher.stop() @pytest.fixture -def mock_zeroconf() -> Generator[None, None, None]: +def disable_translations_once( + translations_once: _patch, +) -> Generator[None]: + """Override loading translations once.""" + translations_once.stop() + yield + translations_once.start() + + +@pytest.fixture +def mock_zeroconf() -> Generator[MagicMock]: """Mock zeroconf.""" from zeroconf import DNSCache # pylint: disable=import-outside-toplevel @@ -1172,7 +1201,7 @@ def mock_zeroconf() -> Generator[None, None, None]: @pytest.fixture -def mock_async_zeroconf(mock_zeroconf: None) -> Generator[None, None, None]: +def mock_async_zeroconf(mock_zeroconf: MagicMock) -> Generator[MagicMock]: """Mock AsyncZeroconf.""" from zeroconf import DNSCache, Zeroconf # pylint: disable=import-outside-toplevel from zeroconf.asyncio import ( # pylint: disable=import-outside-toplevel @@ -1277,7 +1306,7 @@ def recorder_config() -> dict[str, Any] | None: def recorder_db_url( pytestconfig: pytest.Config, hass_fixture_setup: list[bool], -) -> Generator[str, None, None]: +) -> Generator[str]: """Prepare a default database for tests and return a connection URL.""" assert not hass_fixture_setup @@ -1329,8 +1358,8 @@ def hass_recorder( enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, - hass_storage, -) -> Generator[Callable[..., HomeAssistant], None, None]: + hass_storage: dict[str, Any], +) -> Generator[Callable[..., HomeAssistant]]: """Home Assistant fixture with in-memory recorder.""" # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder @@ -1421,7 +1450,9 @@ def hass_recorder( ) -> HomeAssistant: """Set up with params.""" if timezone is not None: - hass.config.set_time_zone(timezone) + asyncio.run_coroutine_threadsafe( + hass.config.async_set_time_zone(timezone), hass.loop + ).result() init_recorder_component(hass, config, recorder_db_url) hass.start() hass.block_till_done() @@ -1469,7 +1500,7 @@ async def async_setup_recorder_instance( enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, -) -> AsyncGenerator[RecorderInstanceGenerator, None]: +) -> AsyncGenerator[RecorderInstanceGenerator]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder @@ -1592,7 +1623,7 @@ async def mock_enable_bluetooth( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, mock_bluetooth_adapters: None, -) -> AsyncGenerator[None, None]: +) -> AsyncGenerator[None]: """Fixture to mock starting the bleak scanner.""" entry = MockConfigEntry(domain="bluetooth", unique_id="00:00:00:00:00:01") entry.add_to_hass(hass) @@ -1604,7 +1635,7 @@ async def mock_enable_bluetooth( @pytest.fixture(scope="session") -def mock_bluetooth_adapters() -> Generator[None, None, None]: +def mock_bluetooth_adapters() -> Generator[None]: """Fixture to mock bluetooth adapters.""" with ( patch("bluetooth_auto_recovery.recover_adapter"), @@ -1630,7 +1661,7 @@ def mock_bluetooth_adapters() -> Generator[None, None, None]: @pytest.fixture -def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: +def mock_bleak_scanner_start() -> Generator[MagicMock]: """Fixture to mock starting the bleak scanner.""" # Late imports to avoid loading bleak unless we need it @@ -1641,10 +1672,11 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # 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. + # pylint: disable-next=c-extension-no-member bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with ( patch.object( - bluetooth_scanner.OriginalBleakScanner, + bluetooth_scanner.OriginalBleakScanner, # pylint: disable=c-extension-no-member "start", ) as mock_bleak_scanner_start, patch.object(bluetooth_scanner, "HaScanner"), @@ -1653,7 +1685,7 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_integration_frame() -> Generator[Mock, None, None]: +def mock_integration_frame() -> Generator[Mock]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( filename="/home/paulus/homeassistant/components/hue/light.py", @@ -1736,7 +1768,49 @@ def label_registry(hass: HomeAssistant) -> lr.LabelRegistry: return lr.async_get(hass) +@pytest.fixture +def service_calls(hass: HomeAssistant) -> Generator[None, None, list[ServiceCall]]: + """Track all service calls.""" + calls = [] + + _original_async_call = hass.services.async_call + + async def _async_call( + self, + domain: str, + service: str, + service_data: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ServiceResponse: + calls.append(ServiceCall(domain, service, service_data)) + try: + return await _original_async_call( + domain, + service, + service_data, + **kwargs, + ) + except ha.ServiceNotFound: + _LOGGER.debug("Ignoring unknown service call to %s.%s", domain, service) + return None + + with patch("homeassistant.core.ServiceRegistry.async_call", _async_call): + yield calls + + @pytest.fixture def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: """Return snapshot assertion fixture with the Home Assistant extension.""" return snapshot.use_extension(HomeAssistantSnapshotExtension) + + +@pytest.fixture +def disable_block_async_io() -> Generator[Any, Any, None]: + """Fixture to disable the loop protection from block_async_io.""" + yield + calls = block_async_io._BLOCKED_CALLS.calls + for blocking_call in calls: + setattr( + blocking_call.object, blocking_call.function, blocking_call.original_func + ) + calls.clear() diff --git a/tests/hassfest/test_version.py b/tests/hassfest/test_version.py index 9cc1bbb11e5..bfe15018fe2 100644 --- a/tests/hassfest/test_version.py +++ b/tests/hassfest/test_version.py @@ -34,12 +34,12 @@ def test_validate_version_no_key(integration: Integration) -> None: def test_validate_custom_integration_manifest(integration: Integration) -> None: """Test validate custom integration manifest.""" + integration.manifest["version"] = "lorem_ipsum" with pytest.raises(vol.Invalid): - integration.manifest["version"] = "lorem_ipsum" CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) + integration.manifest["version"] = None with pytest.raises(vol.Invalid): - integration.manifest["version"] = None CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest) integration.manifest["version"] = "1" diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 69015c80305..7dd34fd2c64 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -3,9 +3,10 @@ from unittest.mock import Mock, patch import aiohttp +from aiohttp.test_utils import TestClient import pytest -from homeassistant.components.mjpeg.const import ( +from homeassistant.components.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN as MJPEG_DOMAIN, @@ -28,10 +29,13 @@ from tests.common import ( mock_integration, ) from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator @pytest.fixture(name="camera_client") -def camera_client_fixture(hass, hass_client): +async def camera_client_fixture( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> TestClient: """Fixture to fetch camera streams.""" mock_config_entry = MockConfigEntry( title="MJPEG Camera", @@ -46,12 +50,10 @@ def camera_client_fixture(hass, hass_client): }, ) mock_config_entry.add_to_hass(hass) - hass.loop.run_until_complete( - hass.config_entries.async_setup(mock_config_entry.entry_id) - ) - hass.loop.run_until_complete(hass.async_block_till_done()) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - return hass.loop.run_until_complete(hass_client()) + return await hass_client() async def test_get_clientsession_with_ssl(hass: HomeAssistant) -> None: @@ -253,7 +255,7 @@ async def test_warning_close_session_custom( async def test_async_aiohttp_proxy_stream( - aioclient_mock: AiohttpClientMocker, camera_client + aioclient_mock: AiohttpClientMocker, camera_client: TestClient ) -> None: """Test that it fetches the given url.""" aioclient_mock.get("http://example.com/mjpeg_stream", content=b"Frame1Frame2Frame3") @@ -267,7 +269,7 @@ async def test_async_aiohttp_proxy_stream( async def test_async_aiohttp_proxy_stream_timeout( - aioclient_mock: AiohttpClientMocker, camera_client + aioclient_mock: AiohttpClientMocker, camera_client: TestClient ) -> None: """Test that it fetches the given url.""" aioclient_mock.get("http://example.com/mjpeg_stream", exc=TimeoutError()) @@ -277,7 +279,7 @@ async def test_async_aiohttp_proxy_stream_timeout( async def test_async_aiohttp_proxy_stream_client_err( - aioclient_mock: AiohttpClientMocker, camera_client + aioclient_mock: AiohttpClientMocker, camera_client: TestClient ) -> None: """Test that it fetches the given url.""" aioclient_mock.get("http://example.com/mjpeg_stream", exc=aiohttp.ClientError()) diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 22f1dc8e534..e6d637d1a99 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -85,12 +85,11 @@ async def test_create_area_with_name_already_in_use( ) -> None: """Make sure that we can't create an area with a name already in use.""" update_events = async_capture_events(hass, ar.EVENT_AREA_REGISTRY_UPDATED) - area1 = area_registry.async_create("mock") + area_registry.async_create("mock") with pytest.raises(ValueError) as e_info: - area2 = area_registry.async_create("mock") - assert area1 != area2 - assert e_info == "The name mock 2 (mock2) is already in use" + area_registry.async_create("mock") + assert str(e_info.value) == "The name mock (mock) is already in use" await hass.async_block_till_done() @@ -226,7 +225,7 @@ async def test_update_area_with_name_already_in_use( with pytest.raises(ValueError) as e_info: area_registry.async_update(area1.id, name="mock2") - assert e_info == "The name mock 2 (mock2) is already in use" + assert str(e_info.value) == "The name mock2 (mock2) is already in use" assert area1.name == "mock1" assert area2.name == "mock2" @@ -242,7 +241,7 @@ async def test_update_area_with_normalized_name_already_in_use( with pytest.raises(ValueError) as e_info: area_registry.async_update(area1.id, name="mock2") - assert e_info == "The name mock 2 (mock2) is already in use" + assert str(e_info.value) == "The name mock2 (mock2) is already in use" assert area1.name == "mock1" assert area2.name == "Moc k2" @@ -500,7 +499,7 @@ async def test_async_get_or_create_thread_checks( """We raise when trying to create in the wrong thread.""" with pytest.raises( RuntimeError, - match="Detected code that calls async_create from a thread. Please report this issue.", + match="Detected code that calls area_registry.async_create from a thread.", ): await hass.async_add_executor_job(area_registry.async_create, "Mock1") @@ -512,7 +511,7 @@ async def test_async_update_thread_checks( area = area_registry.async_create("Mock1") with pytest.raises( RuntimeError, - match="Detected code that calls _async_update from a thread. Please report this issue.", + match="Detected code that calls area_registry.async_update from a thread.", ): await hass.async_add_executor_job( partial(area_registry.async_update, area.id, name="Mock2") @@ -526,6 +525,6 @@ async def test_async_delete_thread_checks( area = area_registry.async_create("Mock1") with pytest.raises( RuntimeError, - match="Detected code that calls async_delete from a thread. Please report this issue.", + match="Detected code that calls area_registry.async_delete from a thread.", ): await hass.async_add_executor_job(area_registry.async_delete, area.id) diff --git a/tests/helpers/test_category_registry.py b/tests/helpers/test_category_registry.py index a6a36940a68..1317750ebec 100644 --- a/tests/helpers/test_category_registry.py +++ b/tests/helpers/test_category_registry.py @@ -1,5 +1,6 @@ """Tests for the category registry.""" +from functools import partial import re from typing import Any @@ -339,7 +340,7 @@ async def test_load_categories( @pytest.mark.parametrize("load_registries", [False]) async def test_loading_categories_from_storage( - hass: HomeAssistant, hass_storage: Any + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored categories on start.""" hass_storage[cr.STORAGE_KEY] = { @@ -394,3 +395,55 @@ async def test_loading_categories_from_storage( assert category3.category_id == "uuid3" assert category3.name == "Grocery stores" assert category3.icon == "mdi:store" + + +async def test_async_create_thread_safety( + hass: HomeAssistant, category_registry: cr.CategoryRegistry +) -> None: + """Test async_create raises when called from wrong thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls category_registry.async_create from a thread.", + ): + await hass.async_add_executor_job( + partial(category_registry.async_create, name="any", scope="any") + ) + + +async def test_async_delete_thread_safety( + hass: HomeAssistant, category_registry: cr.CategoryRegistry +) -> None: + """Test async_delete raises when called from wrong thread.""" + any_category = category_registry.async_create(name="any", scope="any") + + with pytest.raises( + RuntimeError, + match="Detected code that calls category_registry.async_delete from a thread.", + ): + await hass.async_add_executor_job( + partial( + category_registry.async_delete, + scope="any", + category_id=any_category.category_id, + ) + ) + + +async def test_async_update_thread_safety( + hass: HomeAssistant, category_registry: cr.CategoryRegistry +) -> None: + """Test async_update raises when called from wrong thread.""" + any_category = category_registry.async_create(name="any", scope="any") + + with pytest.raises( + RuntimeError, + match="Detected code that calls category_registry.async_update from a thread.", + ): + await hass.async_add_executor_job( + partial( + category_registry.async_update, + scope="any", + category_id=any_category.category_id, + name="new name", + ) + ) diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 6d2764afb16..f0287218d7f 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -37,7 +37,7 @@ def track_changes(coll: collection.ObservableCollection): class MockEntity(collection.CollectionEntity): """Entity that is config based.""" - def __init__(self, config): + def __init__(self, config: ConfigType) -> None: """Initialize entity.""" self._config = config @@ -52,21 +52,21 @@ class MockEntity(collection.CollectionEntity): raise NotImplementedError @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID of entity.""" return self._config["id"] @property - def name(self): + def name(self) -> str: """Return name of entity.""" return self._config["name"] @property - def state(self): + def state(self) -> str: """Return state of entity.""" return self._config["state"] - async def async_update_config(self, config): + async def async_update_config(self, config: ConfigType) -> None: """Update entity config.""" self._config = config self.async_write_ha_state() @@ -124,7 +124,7 @@ async def test_observable_collection() -> None: changes = track_changes(coll) await coll.notify_changes( - [collection.CollectionChangeSet("mock_type", "mock_id", {"mock": "item"})] + [collection.CollectionChange("mock_type", "mock_id", {"mock": "item"})] ) assert len(changes) == 1 assert changes[0] == ("mock_type", "mock_id", {"mock": "item"}) @@ -263,7 +263,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: await coll.notify_changes( [ - collection.CollectionChangeSet( + collection.CollectionChange( collection.CHANGE_ADDED, "mock_id", {"id": "mock_id", "state": "initial", "name": "Mock 1"}, @@ -276,7 +276,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: await coll.notify_changes( [ - collection.CollectionChangeSet( + collection.CollectionChange( collection.CHANGE_UPDATED, "mock_id", {"id": "mock_id", "state": "second", "name": "Mock 1 updated"}, @@ -288,7 +288,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: assert hass.states.get("test.mock_1").state == "second" await coll.notify_changes( - [collection.CollectionChangeSet(collection.CHANGE_REMOVED, "mock_id", None)], + [collection.CollectionChange(collection.CHANGE_REMOVED, "mock_id", None)], ) assert hass.states.get("test.mock_1") is None @@ -331,7 +331,7 @@ async def test_entity_component_collection_abort( await coll.notify_changes( [ - collection.CollectionChangeSet( + collection.CollectionChange( collection.CHANGE_ADDED, "mock_id", {"id": "mock_id", "state": "initial", "name": "Mock 1"}, @@ -343,7 +343,7 @@ async def test_entity_component_collection_abort( await coll.notify_changes( [ - collection.CollectionChangeSet( + collection.CollectionChange( collection.CHANGE_UPDATED, "mock_id", {"id": "mock_id", "state": "second", "name": "Mock 1 updated"}, @@ -355,7 +355,7 @@ async def test_entity_component_collection_abort( assert len(async_update_config_calls) == 0 await coll.notify_changes( - [collection.CollectionChangeSet(collection.CHANGE_REMOVED, "mock_id", None)], + [collection.CollectionChange(collection.CHANGE_REMOVED, "mock_id", None)], ) assert hass.states.get("test.mock_1") is None @@ -395,7 +395,7 @@ async def test_entity_component_collection_entity_removed( await coll.notify_changes( [ - collection.CollectionChangeSet( + collection.CollectionChange( collection.CHANGE_ADDED, "mock_id", {"id": "mock_id", "state": "initial", "name": "Mock 1"}, @@ -413,7 +413,7 @@ async def test_entity_component_collection_entity_removed( await coll.notify_changes( [ - collection.CollectionChangeSet( + collection.CollectionChange( collection.CHANGE_UPDATED, "mock_id", {"id": "mock_id", "state": "second", "name": "Mock 1 updated"}, @@ -425,7 +425,7 @@ async def test_entity_component_collection_entity_removed( assert len(async_update_config_calls) == 0 await coll.notify_changes( - [collection.CollectionChangeSet(collection.CHANGE_REMOVED, "mock_id", None)], + [collection.CollectionChange(collection.CHANGE_REMOVED, "mock_id", None)], ) assert hass.states.get("test.mock_1") is None @@ -450,9 +450,8 @@ async def test_storage_collection_websocket( client = await hass_ws_client(hass) # Create invalid - await client.send_json( + await client.send_json_auto_id( { - "id": 1, "type": "test_item/collection/create", "name": 1, # Forgot to add immutable_string @@ -464,9 +463,8 @@ async def test_storage_collection_websocket( assert len(changes) == 0 # Create - await client.send_json( + await client.send_json_auto_id( { - "id": 2, "type": "test_item/collection/create", "name": "Initial Name", "immutable_string": "no-changes", @@ -483,7 +481,7 @@ async def test_storage_collection_websocket( assert changes[0] == (collection.CHANGE_ADDED, "initial_name", response["result"]) # List - await client.send_json({"id": 3, "type": "test_item/collection/list"}) + await client.send_json_auto_id({"type": "test_item/collection/list"}) response = await client.receive_json() assert response["success"] assert response["result"] == [ @@ -496,9 +494,8 @@ async def test_storage_collection_websocket( assert len(changes) == 1 # Update invalid data - await client.send_json( + await client.send_json_auto_id( { - "id": 4, "type": "test_item/collection/update", "test_item_id": "initial_name", "immutable_string": "no-changes", @@ -510,9 +507,8 @@ async def test_storage_collection_websocket( assert len(changes) == 1 # Update invalid item - await client.send_json( + await client.send_json_auto_id( { - "id": 5, "type": "test_item/collection/update", "test_item_id": "non-existing", "name": "Updated name", @@ -524,9 +520,8 @@ async def test_storage_collection_websocket( assert len(changes) == 1 # Update - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": "test_item/collection/update", "test_item_id": "initial_name", "name": "Updated name", @@ -543,8 +538,8 @@ async def test_storage_collection_websocket( assert changes[1] == (collection.CHANGE_UPDATED, "initial_name", response["result"]) # Delete invalid ID - await client.send_json( - {"id": 7, "type": "test_item/collection/update", "test_item_id": "non-existing"} + await client.send_json_auto_id( + {"type": "test_item/collection/update", "test_item_id": "non-existing"} ) response = await client.receive_json() assert not response["success"] @@ -552,8 +547,8 @@ async def test_storage_collection_websocket( assert len(changes) == 2 # Delete - await client.send_json( - {"id": 8, "type": "test_item/collection/delete", "test_item_id": "initial_name"} + await client.send_json_auto_id( + {"type": "test_item/collection/delete", "test_item_id": "initial_name"} ) response = await client.receive_json() assert response["success"] @@ -568,3 +563,214 @@ async def test_storage_collection_websocket( "name": "Updated name", }, ) + + +async def test_storage_collection_websocket_subscribe( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test exposing a storage collection via websockets.""" + store = storage.Store(hass, 1, "test-data") + coll = MockStorageCollection(store) + changes = track_changes(coll) + collection.DictStorageCollectionWebsocket( + coll, + "test_item/collection", + "test_item", + {vol.Required("name"): str, vol.Required("immutable_string"): str}, + {vol.Optional("name"): str}, + ).async_setup(hass) + + client = await hass_ws_client(hass) + + # Subscribe + await client.send_json_auto_id({"type": "test_item/collection/subscribe"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + assert len(changes) == 0 + event_id = response["id"] + + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [] + + # Create invalid + await client.send_json_auto_id( + { + "type": "test_item/collection/create", + "name": 1, + # Forgot to add immutable_string + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_format" + assert len(changes) == 0 + + # Create + await client.send_json_auto_id( + { + "type": "test_item/collection/create", + "name": "Initial Name", + "immutable_string": "no-changes", + } + ) + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [ + { + "change_type": "added", + "item": { + "id": "initial_name", + "immutable_string": "no-changes", + "name": "Initial Name", + }, + "test_item_id": "initial_name", + } + ] + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "id": "initial_name", + "name": "Initial Name", + "immutable_string": "no-changes", + } + assert len(changes) == 1 + assert changes[0] == (collection.CHANGE_ADDED, "initial_name", response["result"]) + + # Subscribe again + await client.send_json_auto_id({"type": "test_item/collection/subscribe"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + event_id_2 = response["id"] + + response = await client.receive_json() + assert response["id"] == event_id_2 + assert response["event"] == [ + { + "change_type": "added", + "item": { + "id": "initial_name", + "immutable_string": "no-changes", + "name": "Initial Name", + }, + "test_item_id": "initial_name", + }, + ] + + await client.send_json_auto_id( + {"type": "unsubscribe_events", "subscription": event_id_2} + ) + response = await client.receive_json() + assert response["success"] + + # List + await client.send_json_auto_id({"type": "test_item/collection/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "id": "initial_name", + "name": "Initial Name", + "immutable_string": "no-changes", + } + ] + assert len(changes) == 1 + + # Update invalid data + await client.send_json_auto_id( + { + "type": "test_item/collection/update", + "test_item_id": "initial_name", + "immutable_string": "no-changes", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_format" + assert len(changes) == 1 + + # Update invalid item + await client.send_json_auto_id( + { + "type": "test_item/collection/update", + "test_item_id": "non-existing", + "name": "Updated name", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "not_found" + assert len(changes) == 1 + + # Update + await client.send_json_auto_id( + { + "type": "test_item/collection/update", + "test_item_id": "initial_name", + "name": "Updated name", + } + ) + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [ + { + "change_type": "updated", + "item": { + "id": "initial_name", + "immutable_string": "no-changes", + "name": "Updated name", + }, + "test_item_id": "initial_name", + } + ] + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "id": "initial_name", + "name": "Updated name", + "immutable_string": "no-changes", + } + assert len(changes) == 2 + assert changes[1] == (collection.CHANGE_UPDATED, "initial_name", response["result"]) + + # Delete invalid ID + await client.send_json_auto_id( + {"type": "test_item/collection/update", "test_item_id": "non-existing"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "not_found" + assert len(changes) == 2 + + # Delete + await client.send_json_auto_id( + {"type": "test_item/collection/delete", "test_item_id": "initial_name"} + ) + response = await client.receive_json() + assert response["id"] == event_id + assert response["event"] == [ + { + "change_type": "removed", + "item": { + "id": "initial_name", + "immutable_string": "no-changes", + "name": "Updated name", + }, + "test_item_id": "initial_name", + } + ] + response = await client.receive_json() + assert response["success"] + + assert len(changes) == 3 + assert changes[2] == ( + collection.CHANGE_REMOVED, + "initial_name", + { + "id": "initial_name", + "immutable_string": "no-changes", + "name": "Updated name", + }, + ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 20dea85c3e4..31f813469cc 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -30,16 +30,9 @@ from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_mock_service from tests.typing import WebSocketGenerator -@pytest.fixture -def calls(hass: HomeAssistant) -> list[ServiceCall]: - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - - def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -1104,17 +1097,18 @@ async def test_state_raises(hass: HomeAssistant) -> None: test(hass) # Unknown state entity - with pytest.raises(ConditionError, match="input_text.missing"): - config = { - "condition": "state", - "entity_id": "sensor.door", - "state": "input_text.missing", - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - hass.states.async_set("sensor.door", "open") + config = { + "condition": "state", + "entity_id": "sensor.door", + "state": "input_text.missing", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("sensor.door", "open") + with pytest.raises(ConditionError, match="input_text.missing"): test(hass) @@ -1549,76 +1543,76 @@ async def test_numeric_state_raises(hass: HomeAssistant) -> None: test(hass) # Template error - with pytest.raises(ConditionError, match="ZeroDivisionError"): - config = { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "value_template": "{{ 1 / 0 }}", - "above": 0, - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "value_template": "{{ 1 / 0 }}", + "above": 0, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) - hass.states.async_set("sensor.temperature", 50) + hass.states.async_set("sensor.temperature", 50) + with pytest.raises(ConditionError, match="ZeroDivisionError"): test(hass) # Bad number - with pytest.raises(ConditionError, match="cannot be processed as a number"): - config = { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "above": 0, - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": 0, + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) - hass.states.async_set("sensor.temperature", "fifty") + hass.states.async_set("sensor.temperature", "fifty") + with pytest.raises(ConditionError, match="cannot be processed as a number"): test(hass) # Below entity missing - with pytest.raises(ConditionError, match="'below' entity"): - config = { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": "input_number.missing", - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": "input_number.missing", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) - hass.states.async_set("sensor.temperature", 50) + hass.states.async_set("sensor.temperature", 50) + with pytest.raises(ConditionError, match="'below' entity"): test(hass) # Below entity not a number + hass.states.async_set("input_number.missing", "number") with pytest.raises( ConditionError, match="'below'.*input_number.missing.*cannot be processed as a number", ): - hass.states.async_set("input_number.missing", "number") test(hass) # Above entity missing - with pytest.raises(ConditionError, match="'above' entity"): - config = { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "above": "input_number.missing", - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) + config = { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "above": "input_number.missing", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) - hass.states.async_set("sensor.temperature", 50) + hass.states.async_set("sensor.temperature", 50) + with pytest.raises(ConditionError, match="'above' entity"): test(hass) # Above entity not a number + hass.states.async_set("input_number.missing", "number") with pytest.raises( ConditionError, match="'above'.*input_number.missing.*cannot be processed as a number", ): - hass.states.async_set("input_number.missing", "number") test(hass) @@ -2214,7 +2208,9 @@ async def assert_automation_condition_trace(hass_ws_client, automation_id, expec async def test_if_action_before_sunrise_no_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was before sunrise. @@ -2240,7 +2236,7 @@ async def test_if_action_before_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2252,7 +2248,7 @@ async def test_if_action_before_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2264,7 +2260,7 @@ async def test_if_action_before_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2276,7 +2272,7 @@ async def test_if_action_before_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2285,7 +2281,9 @@ async def test_if_action_before_sunrise_no_offset( async def test_if_action_after_sunrise_no_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was after sunrise. @@ -2311,7 +2309,7 @@ async def test_if_action_after_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2323,7 +2321,7 @@ async def test_if_action_after_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2335,7 +2333,7 @@ async def test_if_action_after_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2347,7 +2345,7 @@ async def test_if_action_after_sunrise_no_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2356,7 +2354,9 @@ async def test_if_action_after_sunrise_no_offset( async def test_if_action_before_sunrise_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was before sunrise with offset. @@ -2386,7 +2386,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2398,7 +2398,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2410,7 +2410,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2422,7 +2422,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2434,7 +2434,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2446,7 +2446,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2458,7 +2458,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2470,7 +2470,7 @@ async def test_if_action_before_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2479,7 +2479,9 @@ async def test_if_action_before_sunrise_with_offset( async def test_if_action_before_sunset_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was before sunset with offset. @@ -2509,7 +2511,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2521,7 +2523,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2533,7 +2535,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2545,7 +2547,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 3 + assert len(service_calls) == 3 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2557,7 +2559,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 4 + assert len(service_calls) == 4 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2569,7 +2571,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 5 + assert len(service_calls) == 5 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2581,7 +2583,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 6 + assert len(service_calls) == 6 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2593,7 +2595,7 @@ async def test_if_action_before_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 6 + assert len(service_calls) == 6 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2602,7 +2604,9 @@ async def test_if_action_before_sunset_with_offset( async def test_if_action_after_sunrise_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was after sunrise with offset. @@ -2632,7 +2636,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2644,7 +2648,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2656,7 +2660,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2668,7 +2672,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2680,7 +2684,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2692,7 +2696,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 3 + assert len(service_calls) == 3 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2704,7 +2708,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 4 + assert len(service_calls) == 4 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2716,7 +2720,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 5 + assert len(service_calls) == 5 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2728,7 +2732,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 6 + assert len(service_calls) == 6 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2740,7 +2744,7 @@ async def test_if_action_after_sunrise_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 6 + assert len(service_calls) == 6 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2749,7 +2753,9 @@ async def test_if_action_after_sunrise_with_offset( async def test_if_action_after_sunset_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was after sunset with offset. @@ -2779,7 +2785,7 @@ async def test_if_action_after_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2791,7 +2797,7 @@ async def test_if_action_after_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2803,7 +2809,7 @@ async def test_if_action_after_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2815,7 +2821,7 @@ async def test_if_action_after_sunset_with_offset( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2824,7 +2830,9 @@ async def test_if_action_after_sunset_with_offset( async def test_if_action_after_and_before_during( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was after sunrise and before sunset. @@ -2854,7 +2862,7 @@ async def test_if_action_after_and_before_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2870,7 +2878,7 @@ async def test_if_action_after_and_before_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2882,7 +2890,7 @@ async def test_if_action_after_and_before_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2898,7 +2906,7 @@ async def test_if_action_after_and_before_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2914,7 +2922,7 @@ async def test_if_action_after_and_before_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 3 + assert len(service_calls) == 3 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2927,7 +2935,9 @@ async def test_if_action_after_and_before_during( async def test_if_action_before_or_after_during( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was before sunrise or after sunset. @@ -2957,7 +2967,7 @@ async def test_if_action_before_or_after_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2973,7 +2983,7 @@ async def test_if_action_before_or_after_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -2989,7 +2999,7 @@ async def test_if_action_before_or_after_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3005,7 +3015,7 @@ async def test_if_action_before_or_after_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3021,7 +3031,7 @@ async def test_if_action_before_or_after_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 3 + assert len(service_calls) == 3 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3037,7 +3047,7 @@ async def test_if_action_before_or_after_during( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 4 + assert len(service_calls) == 4 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3050,7 +3060,9 @@ async def test_if_action_before_or_after_during( async def test_if_action_before_sunrise_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was before sunrise. @@ -3059,7 +3071,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( at 7 AM and sunset at 3AM during summer After sunrise is true from sunrise until midnight, local time. """ - hass.config.set_time_zone("America/Anchorage") + await hass.config.async_set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( @@ -3082,7 +3094,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3094,7 +3106,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3106,7 +3118,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3118,7 +3130,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3127,7 +3139,9 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( async def test_if_action_after_sunrise_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was after sunrise. @@ -3136,7 +3150,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( at 7 AM and sunset at 3AM during summer Before sunrise is true from midnight until sunrise, local time. """ - hass.config.set_time_zone("America/Anchorage") + await hass.config.async_set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( @@ -3159,7 +3173,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3171,7 +3185,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3183,7 +3197,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3195,7 +3209,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3204,7 +3218,9 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( async def test_if_action_before_sunset_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was before sunrise. @@ -3213,7 +3229,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( at 7 AM and sunset at 3AM during summer Before sunset is true from midnight until sunset, local time. """ - hass.config.set_time_zone("America/Anchorage") + await hass.config.async_set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( @@ -3236,7 +3252,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(service_calls) == 0 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3248,7 +3264,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3260,7 +3276,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3272,7 +3288,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3281,7 +3297,9 @@ async def test_if_action_before_sunset_no_offset_kotzebue( async def test_if_action_after_sunset_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + service_calls: list[ServiceCall], ) -> None: """Test if action was after sunrise. @@ -3290,7 +3308,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( at 7 AM and sunset at 3AM during summer After sunset is true from sunset until midnight, local time. """ - hass.config.set_time_zone("America/Anchorage") + await hass.config.async_set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( @@ -3313,7 +3331,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3325,7 +3343,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3337,7 +3355,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3349,7 +3367,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 2 + assert len(service_calls) == 2 await assert_automation_condition_trace( hass_ws_client, "sun", @@ -3382,10 +3400,36 @@ async def test_platform_async_validate_condition_config(hass: HomeAssistant) -> device_automation_validate_condition_mock.assert_awaited() -async def test_disabled_condition(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) +async def test_enabled_condition( + hass: HomeAssistant, enabled_value: bool | str +) -> None: + """Test an explicitly enabled condition.""" + config = { + "enabled": enabled_value, + "condition": "state", + "entity_id": "binary_sensor.test", + "state": "on", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set("binary_sensor.test", "on") + assert test(hass) is True + + # Still passes, condition is not enabled + hass.states.async_set("binary_sensor.test", "off") + assert test(hass) is False + + +@pytest.mark.parametrize("enabled_value", [False, "{{ 1 == 9 }}"]) +async def test_disabled_condition( + hass: HomeAssistant, enabled_value: bool | str +) -> None: """Test a disabled condition returns none.""" config = { - "enabled": False, + "enabled": enabled_value, "condition": "state", "entity_id": "binary_sensor.test", "state": "on", @@ -3402,6 +3446,21 @@ async def test_disabled_condition(hass: HomeAssistant) -> None: assert test(hass) is None +async def test_condition_enabled_template_limited(hass: HomeAssistant) -> None: + """Test conditions enabled template raises for non-limited template uses.""" + config = { + "enabled": "{{ states('sensor.limited') }}", + "condition": "state", + "entity_id": "binary_sensor.test", + "state": "on", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + + with pytest.raises(HomeAssistantError): + await condition.async_from_config(hass, config) + + async def test_and_condition_with_disabled_condition(hass: HomeAssistant) -> None: """Test the 'and' condition with one of the conditions disabled.""" config = { diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index e99cfbb2f58..6a198b7a297 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,9 +1,9 @@ """Tests for the Config Entry Flow helper.""" -from collections.abc import Generator from unittest.mock import Mock, PropertyMock, patch import pytest +from typing_extensions import Generator from homeassistant import config_entries, data_entry_flow, setup from homeassistant.config import async_process_ha_core_config @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry, MockModule, mock_integration, mock_pla @pytest.fixture -def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool], None, None]: +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: """Register a handler.""" handler_conf = {"discovered": False} @@ -30,7 +30,7 @@ def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool], None, @pytest.fixture -def webhook_flow_conf(hass: HomeAssistant) -> Generator[None, None, None]: +def webhook_flow_conf(hass: HomeAssistant) -> Generator[None]: """Register a handler.""" with patch.dict(config_entries.HANDLERS): config_entry_flow.register_webhook_flow("test_single", "Test Single", {}, False) diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index a9e69f542f3..132a0b41707 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -8,6 +8,7 @@ from unittest.mock import patch import aiohttp import pytest +from typing_extensions import Generator from homeassistant import config_entries, data_entry_flow, setup from homeassistant.core import HomeAssistant @@ -29,7 +30,9 @@ TOKEN_URL = "https://example.como/auth/token" @pytest.fixture -async def local_impl(hass): +async def local_impl( + hass: HomeAssistant, +) -> config_entry_oauth2_flow.LocalOAuth2Implementation: """Local implementation.""" assert await setup.async_setup_component(hass, "auth", {}) return config_entry_oauth2_flow.LocalOAuth2Implementation( @@ -38,7 +41,9 @@ async def local_impl(hass): @pytest.fixture -def flow_handler(hass): +def flow_handler( + hass: HomeAssistant, +) -> Generator[type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler]]: """Return a registered config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") @@ -111,7 +116,10 @@ def test_inherit_enforces_domain_set() -> None: TestFlowHandler() -async def test_abort_if_no_implementation(hass: HomeAssistant, flow_handler) -> None: +async def test_abort_if_no_implementation( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], +) -> None: """Check flow abort when no implementations.""" flow = flow_handler() flow.hass = hass @@ -121,7 +129,8 @@ async def test_abort_if_no_implementation(hass: HomeAssistant, flow_handler) -> async def test_missing_credentials_for_domain( - hass: HomeAssistant, flow_handler + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], ) -> None: """Check flow abort for integration supporting application credentials.""" flow = flow_handler() @@ -133,8 +142,11 @@ async def test_missing_credentials_for_domain( assert result["reason"] == "missing_credentials" +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_if_authorization_timeout( - hass: HomeAssistant, flow_handler, local_impl, current_request_with_host: None + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, ) -> None: """Check timeout generating authorization url.""" flow_handler.async_register_implementation(hass, local_impl) @@ -152,8 +164,11 @@ async def test_abort_if_authorization_timeout( assert result["reason"] == "authorize_url_timeout" +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_if_no_url_available( - hass: HomeAssistant, flow_handler, local_impl, current_request_with_host: None + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, ) -> None: """Check no_url_available generating authorization url.""" flow_handler.async_register_implementation(hass, local_impl) @@ -171,13 +186,13 @@ async def test_abort_if_no_url_available( @pytest.mark.parametrize("expires_in_dict", [{}, {"expires_in": "badnumber"}]) +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_if_oauth_error( hass: HomeAssistant, - flow_handler, - local_impl, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, expires_in_dict: dict[str, str], ) -> None: """Check bad oauth token.""" @@ -234,13 +249,12 @@ async def test_abort_if_oauth_error( assert result["reason"] == "oauth_error" +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_if_oauth_rejected( hass: HomeAssistant, - flow_handler, - local_impl, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check bad oauth token.""" flow_handler.async_register_implementation(hass, local_impl) @@ -289,13 +303,13 @@ async def test_abort_if_oauth_rejected( assert result["description_placeholders"] == {"error": "access_denied"} +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_on_oauth_timeout_error( hass: HomeAssistant, - flow_handler, - local_impl, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check timeout during oauth token exchange.""" flow_handler.async_register_implementation(hass, local_impl) @@ -345,7 +359,11 @@ async def test_abort_on_oauth_timeout_error( assert result["reason"] == "oauth_timeout" -async def test_step_discovery(hass: HomeAssistant, flow_handler, local_impl) -> None: +async def test_step_discovery( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, +) -> None: """Check flow triggers from discovery.""" flow_handler.async_register_implementation(hass, local_impl) config_entry_oauth2_flow.async_register_implementation( @@ -363,7 +381,9 @@ async def test_step_discovery(hass: HomeAssistant, flow_handler, local_impl) -> async def test_abort_discovered_multiple( - hass: HomeAssistant, flow_handler, local_impl + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, ) -> None: """Test if aborts when discovered multiple times.""" flow_handler.async_register_implementation(hass, local_impl) @@ -423,13 +443,13 @@ async def test_abort_discovered_multiple( ), ], ) +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_if_oauth_token_error( hass: HomeAssistant, - flow_handler, - local_impl, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, status_code: HTTPStatus, error_body: dict[str, Any], error_reason: str, @@ -487,13 +507,13 @@ async def test_abort_if_oauth_token_error( assert error_log in caplog.text +@pytest.mark.usefixtures("current_request_with_host") async def test_abort_if_oauth_token_closing_error( hass: HomeAssistant, - flow_handler, - local_impl, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, caplog: pytest.LogCaptureFixture, ) -> None: """Check error when obtaining an oauth token.""" @@ -549,7 +569,9 @@ async def test_abort_if_oauth_token_closing_error( async def test_abort_discovered_existing_entries( - hass: HomeAssistant, flow_handler, local_impl + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, ) -> None: """Test if abort discovery when entries exists.""" flow_handler.async_register_implementation(hass, local_impl) @@ -573,13 +595,13 @@ async def test_abort_discovered_existing_entries( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") async def test_full_flow( hass: HomeAssistant, - flow_handler, - local_impl, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - current_request_with_host: None, ) -> None: """Check full flow.""" flow_handler.async_register_implementation(hass, local_impl) @@ -652,7 +674,9 @@ async def test_full_flow( async def test_local_refresh_token( - hass: HomeAssistant, local_impl, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test we can refresh token.""" aioclient_mock.post( @@ -686,7 +710,10 @@ async def test_local_refresh_token( async def test_oauth_session( - hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the OAuth2 session helper.""" flow_handler.async_register_implementation(hass, local_impl) @@ -733,7 +760,10 @@ async def test_oauth_session( async def test_oauth_session_with_clock_slightly_out_of_sync( - hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the OAuth2 session helper when the remote clock is slightly out of sync.""" flow_handler.async_register_implementation(hass, local_impl) @@ -780,7 +810,10 @@ async def test_oauth_session_with_clock_slightly_out_of_sync( async def test_oauth_session_no_token_refresh_needed( - hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the OAuth2 session helper when no refresh is needed.""" flow_handler.async_register_implementation(hass, local_impl) @@ -878,7 +911,10 @@ async def test_implementation_provider(hass: HomeAssistant, local_impl) -> None: async def test_oauth_session_refresh_failure( - hass: HomeAssistant, flow_handler, local_impl, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test the OAuth2 session helper when no refresh is needed.""" flow_handler.async_register_implementation(hass, local_impl) @@ -907,7 +943,8 @@ async def test_oauth_session_refresh_failure( async def test_oauth2_without_secret_init( - local_impl, hass_client_no_auth: ClientSessionGenerator + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Check authorize callback without secret initalizated.""" client = await hass_client_no_auth() diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 5e9fcd9d661..6df29eefaff 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -602,7 +602,9 @@ def test_x10_address() -> None: schema = vol.Schema(cv.x10_address) with pytest.raises(vol.Invalid): schema("Q1") + with pytest.raises(vol.Invalid): schema("q55") + with pytest.raises(vol.Invalid): schema("garbage_addr") schema("a1") @@ -766,7 +768,7 @@ def test_date() -> None: """Test date validation.""" schema = vol.Schema(cv.date) - for value in ["Not a date", "23:42", "2016-11-23T18:59:08"]: + for value in ("Not a date", "23:42", "2016-11-23T18:59:08"): with pytest.raises(vol.Invalid): schema(value) @@ -778,7 +780,7 @@ def test_time() -> None: """Test date validation.""" schema = vol.Schema(cv.time) - for value in ["Not a time", "2016-11-23", "2016-11-23T18:59:08"]: + for value in ("Not a time", "2016-11-23", "2016-11-23T18:59:08"): with pytest.raises(vol.Invalid): schema(value) @@ -790,7 +792,7 @@ def test_time() -> None: def test_datetime() -> None: """Test date time validation.""" schema = vol.Schema(cv.datetime) - for value in [date.today(), "Wrong DateTime"]: + for value in (date.today(), "Wrong DateTime"): with pytest.raises(vol.MultipleInvalid): schema(value) @@ -809,6 +811,7 @@ def test_multi_select() -> None: with pytest.raises(vol.Invalid): schema("robban") + with pytest.raises(vol.Invalid): schema(["paulus", "martinhj"]) schema(["robban", "paulus"]) @@ -1237,7 +1240,7 @@ def test_enum() -> None: schema("value3") -def test_socket_timeout(): +def test_socket_timeout() -> None: """Test socket timeout validator.""" schema = vol.Schema(cv.socket_timeout) @@ -1304,7 +1307,7 @@ def test_uuid4_hex(caplog: pytest.LogCaptureFixture) -> None: """Test uuid validation.""" schema = vol.Schema(cv.uuid4_hex) - for value in ["Not a hex string", "0", 0]: + for value in ("Not a hex string", "0", 0): with pytest.raises(vol.Invalid): schema(value) @@ -1335,7 +1338,7 @@ def test_key_value_schemas() -> None: with pytest.raises(vol.Invalid) as excinfo: schema(True) - assert str(excinfo.value) == "Expected a dictionary" + assert str(excinfo.value) == "Expected a dictionary" for mode in None, {"a": "dict"}, "invalid": with pytest.raises(vol.Invalid) as excinfo: @@ -1373,7 +1376,7 @@ def test_key_value_schemas_with_default() -> None: with pytest.raises(vol.Invalid) as excinfo: schema(True) - assert str(excinfo.value) == "Expected a dictionary" + assert str(excinfo.value) == "Expected a dictionary" for mode in None, {"a": "dict"}, "invalid": with pytest.raises(vol.Invalid) as excinfo: @@ -1560,7 +1563,9 @@ def test_empty_schema_cant_find_module() -> None: def test_config_entry_only_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test config_entry_only_config_schema.""" expected_issue = "config_entry_only_test_domain" @@ -1568,7 +1573,6 @@ def test_config_entry_only_schema( "The test_domain integration does not support YAML setup, please remove " "it from your configuration" ) - issue_registry = ir.async_get(hass) cv.config_entry_only_config_schema("test_domain")({}) assert expected_message not in caplog.text @@ -1590,7 +1594,9 @@ def test_config_entry_only_schema_cant_find_module() -> None: def test_config_entry_only_schema_no_hass( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test if the hass context is not set in our context.""" with patch( @@ -1605,12 +1611,13 @@ def test_config_entry_only_schema_no_hass( "it from your configuration" ) assert expected_message in caplog.text - issue_registry = ir.async_get(hass) assert not issue_registry.issues def test_platform_only_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test config_entry_only_config_schema.""" expected_issue = "platform_only_test_domain" @@ -1618,8 +1625,6 @@ def test_platform_only_schema( "The test_domain integration does not support YAML setup, please remove " "it from your configuration" ) - issue_registry = ir.async_get(hass) - cv.platform_only_config_schema("test_domain")({}) assert expected_message not in caplog.text assert not issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) @@ -1674,7 +1679,7 @@ def test_color_hex() -> None: cv.color_hex(123456) -def test_determine_script_action_ambiguous(): +def test_determine_script_action_ambiguous() -> None: """Test determine script action with ambiguous actions.""" assert ( cv.determine_script_action( @@ -1691,6 +1696,6 @@ def test_determine_script_action_ambiguous(): ) -def test_determine_script_action_non_ambiguous(): +def test_determine_script_action_non_ambiguous() -> None: """Test determine script action with a non ambiguous action.""" assert cv.determine_script_action({"delay": "00:00:05"}) == "delay" diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index fed48c5735b..b48e70eff82 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -483,14 +483,19 @@ def test_check_if_deprecated_constant_integration_not_found( 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.""" + """Test check_if_deprecated_constant error handling. + + Test check_if_deprecated_constant raises an attribute error and creates a 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" + f"Value of _DEPRECATED_{name} is an instance of but an instance " + "of DeprecatedAlias, DeferredDeprecatedAlias, DeprecatedConstant or " + "DeprecatedConstantEnum is required" ) with pytest.raises(AttributeError, match=excepted_msg): diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py new file mode 100644 index 00000000000..72c602bec48 --- /dev/null +++ b/tests/helpers/test_device.py @@ -0,0 +1,226 @@ +"""Tests for the Device Utils.""" + +import pytest +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device import ( + async_device_info_to_link_from_device_id, + async_device_info_to_link_from_entity, + async_entity_id_to_device_id, + async_remove_stale_devices_links_keep_current_device, + async_remove_stale_devices_links_keep_entity_device, +) + +from tests.common import MockConfigEntry + + +async def test_entity_id_to_device_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test returning an entity's device ID.""" + config_entry = MockConfigEntry(domain="my") + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + identifiers={("test", "current_device")}, + connections={("mac", "30:31:32:33:34:00")}, + config_entry_id=config_entry.entry_id, + ) + assert device is not None + + # Entity registry + entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=config_entry, + device_id=device.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + device_id = async_entity_id_to_device_id( + hass, + entity_id_or_uuid=entity.entity_id, + ) + assert device_id == device.id + + with pytest.raises(vol.Invalid): + async_entity_id_to_device_id( + hass, + entity_id_or_uuid="unknown_uuid", + ) + + +async def test_device_info_to_link( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test for returning device info with device link information.""" + config_entry = MockConfigEntry(domain="my") + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + identifiers={("test", "my_device")}, + connections={("mac", "30:31:32:33:34:00")}, + config_entry_id=config_entry.entry_id, + ) + assert device is not None + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=config_entry, + device_id=device.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + result = async_device_info_to_link_from_entity( + hass, entity_id_or_uuid=source_entity.entity_id + ) + assert result == { + "identifiers": {("test", "my_device")}, + "connections": {("mac", "30:31:32:33:34:00")}, + } + + result = async_device_info_to_link_from_device_id(hass, device_id=device.id) + assert result == { + "identifiers": {("test", "my_device")}, + "connections": {("mac", "30:31:32:33:34:00")}, + } + + # With a non-existent entity id + result = async_device_info_to_link_from_entity( + hass, entity_id_or_uuid="sensor.invalid" + ) + assert result is None + + # With a non-existent device id + result = async_device_info_to_link_from_device_id(hass, device_id="abcdefghi") + assert result is None + + # With a None device id + result = async_device_info_to_link_from_device_id(hass, device_id=None) + assert result is None + + +async def test_remove_stale_device_links_keep_entity_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test cleaning works for entity.""" + config_entry = MockConfigEntry(domain="hue") + config_entry.add_to_hass(hass) + + current_device = device_registry.async_get_or_create( + identifiers={("test", "current_device")}, + connections={("mac", "30:31:32:33:34:00")}, + config_entry_id=config_entry.entry_id, + ) + assert current_device is not None + + device_registry.async_get_or_create( + identifiers={("test", "stale_device_1")}, + connections={("mac", "30:31:32:33:34:01")}, + config_entry_id=config_entry.entry_id, + ) + + device_registry.async_get_or_create( + identifiers={("test", "stale_device_2")}, + connections={("mac", "30:31:32:33:34:02")}, + config_entry_id=config_entry.entry_id, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "test", + "source", + config_entry=config_entry, + device_id=current_device.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.test_source") is not None + + devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( + config_entry.entry_id + ) + + # 3 devices linked to the config entry are expected (1 current device + 2 stales) + assert len(devices_config_entry) == 3 + + # Manual cleanup should unlink stales devices from the config entry + async_remove_stale_devices_links_keep_entity_device( + hass, + entry_id=config_entry.entry_id, + source_entity_id_or_uuid=source_entity.entity_id, + ) + + devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( + config_entry.entry_id + ) + + # After cleanup, only one device is expected to be linked to the configuration entry if at least source_entity_id_or_uuid or device_id was given, else zero + assert len(devices_config_entry) == 1 + + assert current_device in devices_config_entry + + +async def test_remove_stale_devices_links_keep_current_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test cleanup works for device id.""" + config_entry = MockConfigEntry(domain="hue") + config_entry.add_to_hass(hass) + + current_device = device_registry.async_get_or_create( + identifiers={("test", "current_device")}, + connections={("mac", "30:31:32:33:34:00")}, + config_entry_id=config_entry.entry_id, + ) + assert current_device is not None + + device_registry.async_get_or_create( + identifiers={("test", "stale_device_1")}, + connections={("mac", "30:31:32:33:34:01")}, + config_entry_id=config_entry.entry_id, + ) + + device_registry.async_get_or_create( + identifiers={("test", "stale_device_2")}, + connections={("mac", "30:31:32:33:34:02")}, + config_entry_id=config_entry.entry_id, + ) + + devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( + config_entry.entry_id + ) + + # 3 devices linked to the config entry are expected (1 current device + 2 stales) + assert len(devices_config_entry) == 3 + + # Manual cleanup should unlink stales devices from the config entry + async_remove_stale_devices_links_keep_current_device( + hass, + entry_id=config_entry.entry_id, + current_device_id=current_device.id, + ) + + devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( + config_entry.entry_id + ) + + # After cleanup, only one device is expected to be linked to the configuration entry + assert len(devices_config_entry) == 1 + + assert current_device in devices_config_entry diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 6b167f8ee49..b141e29f678 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -90,7 +90,7 @@ async def test_get_or_create_returns_same_entry( await hass.async_block_till_done() # Only 2 update events. The third entry did not generate any changes. - assert len(update_events) == 2 + assert len(update_events) == 2, update_events assert update_events[0].data == { "action": "create", "device_id": entry.id, @@ -170,7 +170,9 @@ async def test_multiple_config_entries( assert len(device_registry.devices) == 1 assert entry.id == entry2.id assert entry.id == entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries == [config_entry_2.entry_id, config_entry_1.entry_id] + # the 3rd get_or_create was a primary update, so that's now first config entry + assert entry3.config_entries == [config_entry_1.entry_id, config_entry_2.entry_id] @pytest.mark.parametrize("load_registries", [False]) @@ -231,7 +233,7 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, + config_entries=[mock_config_entry.entry_id], configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, disabled_by=dr.DeviceEntryDisabler.USER, @@ -248,7 +250,7 @@ async def test_loading_from_storage( suggested_area=None, # Not stored sw_version="version", ) - assert isinstance(entry.config_entries, set) + assert isinstance(entry.config_entries, list) assert isinstance(entry.connections, set) assert isinstance(entry.identifiers, set) @@ -261,7 +263,7 @@ async def test_loading_from_storage( model="model", ) assert entry == dr.DeviceEntry( - config_entries={mock_config_entry.entry_id}, + config_entries=[mock_config_entry.entry_id], connections={("Zigbee", "23.45.67.89.01")}, id="bcdefghijklmn", identifiers={("serial", "3456ABCDEF12")}, @@ -269,7 +271,7 @@ async def test_loading_from_storage( model="model", ) assert entry.id == "bcdefghijklmn" - assert isinstance(entry.config_entries, set) + assert isinstance(entry.config_entries, list) assert isinstance(entry.connections, set) assert isinstance(entry.identifiers, set) @@ -534,7 +536,7 @@ async def test_migration_1_3_to_1_5( hass: HomeAssistant, hass_storage: dict[str, Any], mock_config_entry: MockConfigEntry, -): +) -> None: """Test migration from version 1.3 to 1.5.""" hass_storage[dr.STORAGE_KEY] = { "version": 1, @@ -659,7 +661,7 @@ async def test_migration_1_4_to_1_5( hass: HomeAssistant, hass_storage: dict[str, Any], mock_config_entry: MockConfigEntry, -): +) -> None: """Test migration from version 1.4 to 1.5.""" hass_storage[dr.STORAGE_KEY] = { "version": 1, @@ -816,7 +818,7 @@ async def test_removing_config_entries( assert len(device_registry.devices) == 2 assert entry.id == entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries == [config_entry_2.entry_id, config_entry_1.entry_id] device_registry.async_clear_config_entry(config_entry_1.entry_id) entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) @@ -824,7 +826,7 @@ async def test_removing_config_entries( identifiers={("bridgeid", "4567")} ) - assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries == [config_entry_2.entry_id] assert entry3_removed is None await hass.async_block_till_done() @@ -837,7 +839,7 @@ async def test_removing_config_entries( assert update_events[1].data == { "action": "update", "device_id": entry.id, - "changes": {"config_entries": {config_entry_1.entry_id}}, + "changes": {"config_entries": [config_entry_1.entry_id]}, } assert update_events[2].data == { "action": "create", @@ -847,7 +849,7 @@ async def test_removing_config_entries( "action": "update", "device_id": entry.id, "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id} + "config_entries": [config_entry_2.entry_id, config_entry_1.entry_id] }, } assert update_events[4].data == { @@ -892,7 +894,7 @@ async def test_deleted_device_removing_config_entries( assert len(device_registry.deleted_devices) == 0 assert entry.id == entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries == [config_entry_2.entry_id, config_entry_1.entry_id] device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry3.id) @@ -909,7 +911,7 @@ async def test_deleted_device_removing_config_entries( assert update_events[1].data == { "action": "update", "device_id": entry2.id, - "changes": {"config_entries": {config_entry_1.entry_id}}, + "changes": {"config_entries": [config_entry_1.entry_id]}, } assert update_events[2].data == { "action": "create", @@ -1219,7 +1221,7 @@ async def test_format_mac( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - for mac in ["123456ABCDEF", "123456abcdef", "12:34:56:ab:cd:ef", "1234.56ab.cdef"]: + for mac in ("123456ABCDEF", "123456abcdef", "12:34:56:ab:cd:ef", "1234.56ab.cdef"): test_entry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, mac)}, @@ -1230,14 +1232,14 @@ async def test_format_mac( } # This should not raise - for invalid in [ + for invalid in ( "invalid_mac", "123456ABCDEFG", # 1 extra char "12:34:56:ab:cdef", # not enough : "12:34:56:ab:cd:e:f", # too many : "1234.56abcdef", # not enough . "123.456.abc.def", # too many . - ]: + ): invalid_mac_entry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, invalid)}, @@ -1257,6 +1259,7 @@ async def test_update( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("hue", "456"), ("bla", "123")}, ) + new_connections = {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} new_identifiers = {("hue", "654"), ("bla", "321")} assert not entry.area_id assert not entry.labels @@ -1275,6 +1278,7 @@ async def test_update( model="Test Model", name_by_user="Test Friendly Name", name="name", + new_connections=new_connections, new_identifiers=new_identifiers, serial_number="serial_no", suggested_area="suggested_area", @@ -1286,9 +1290,9 @@ async def test_update( assert updated_entry != entry assert updated_entry == dr.DeviceEntry( area_id="12345A", - config_entries={mock_config_entry.entry_id}, + config_entries=[mock_config_entry.entry_id], configuration_url="https://example.com/config", - connections={("mac", "12:34:56:ab:cd:ef")}, + connections={("mac", "65:43:21:fe:dc:ba")}, disabled_by=dr.DeviceEntryDisabler.USER, entry_type=dr.DeviceEntryType.SERVICE, hw_version="hw_version", @@ -1319,6 +1323,12 @@ async def test_update( device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} ) + is None + ) + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} + ) == updated_entry ) @@ -1336,6 +1346,7 @@ async def test_update( "device_id": entry.id, "changes": { "area_id": None, + "connections": {("mac", "12:34:56:ab:cd:ef")}, "configuration_url": None, "disabled_by": None, "entry_type": None, @@ -1352,6 +1363,105 @@ async def test_update( "via_device_id": None, }, } + with pytest.raises(HomeAssistantError): + device_registry.async_update_device( + entry.id, + merge_connections=new_connections, + new_connections=new_connections, + ) + + with pytest.raises(HomeAssistantError): + device_registry.async_update_device( + entry.id, + merge_identifiers=new_identifiers, + new_identifiers=new_identifiers, + ) + + +@pytest.mark.parametrize( + ("initial_connections", "new_connections", "updated_connections"), + [ + ( # No connection -> single connection + None, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + ), + ( # No connection -> double connection + None, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), + }, + ), + ( # single connection -> no connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + set(), + set(), + ), + ( # single connection -> single connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + ), + ( # single connection -> double connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), + }, + ), + ( # Double connection -> None + { + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + }, + set(), + set(), + ), + ( # Double connection -> single connection + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba")}, + ), + ], +) +async def test_update_connection( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + initial_connections: set[tuple[str, str]] | None, + new_connections: set[tuple[str, str]] | None, + updated_connections: set[tuple[str, str]] | None, +) -> None: + """Verify that we can update some attributes of a device.""" + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections=initial_connections, + identifiers={("hue", "456"), ("bla", "123")}, + ) + + with patch.object(device_registry, "async_schedule_save") as mock_save: + updated_entry = device_registry.async_update_device( + entry.id, + new_connections=new_connections, + ) + + assert mock_save.call_count == 1 + assert updated_entry != entry + assert updated_entry.connections == updated_connections + assert ( + device_registry.async_get_device(identifiers={("bla", "123")}) == updated_entry + ) async def test_update_remove_config_entries( @@ -1389,7 +1499,7 @@ async def test_update_remove_config_entries( assert len(device_registry.devices) == 2 assert entry.id == entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries == [config_entry_2.entry_id, config_entry_1.entry_id] updated_entry = device_registry.async_update_device( entry2.id, remove_config_entry_id=config_entry_1.entry_id @@ -1398,7 +1508,7 @@ async def test_update_remove_config_entries( entry3.id, remove_config_entry_id=config_entry_1.entry_id ) - assert updated_entry.config_entries == {config_entry_2.entry_id} + assert updated_entry.config_entries == [config_entry_2.entry_id] assert removed_entry is None removed_entry = device_registry.async_get_device(identifiers={("bridgeid", "4567")}) @@ -1415,7 +1525,7 @@ async def test_update_remove_config_entries( assert update_events[1].data == { "action": "update", "device_id": entry2.id, - "changes": {"config_entries": {config_entry_1.entry_id}}, + "changes": {"config_entries": [config_entry_1.entry_id]}, } assert update_events[2].data == { "action": "create", @@ -1425,7 +1535,7 @@ async def test_update_remove_config_entries( "action": "update", "device_id": entry.id, "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id} + "config_entries": [config_entry_2.entry_id, config_entry_1.entry_id] }, } assert update_events[4].data == { @@ -1658,7 +1768,7 @@ async def test_restore_device( assert len(device_registry.devices) == 2 assert len(device_registry.deleted_devices) == 0 - assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.config_entries, list) assert isinstance(entry3.connections, set) assert isinstance(entry3.identifiers, set) @@ -1790,7 +1900,7 @@ async def test_restore_shared_device( assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 - assert isinstance(entry2.config_entries, set) + assert isinstance(entry2.config_entries, list) assert isinstance(entry2.connections, set) assert isinstance(entry2.identifiers, set) @@ -1808,7 +1918,7 @@ async def test_restore_shared_device( assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 - assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.config_entries, list) assert isinstance(entry3.connections, set) assert isinstance(entry3.identifiers, set) @@ -1824,7 +1934,7 @@ async def test_restore_shared_device( assert len(device_registry.devices) == 1 assert len(device_registry.deleted_devices) == 0 - assert isinstance(entry4.config_entries, set) + assert isinstance(entry4.config_entries, list) assert isinstance(entry4.connections, set) assert isinstance(entry4.identifiers, set) @@ -1839,7 +1949,7 @@ async def test_restore_shared_device( "action": "update", "device_id": entry.id, "changes": { - "config_entries": {config_entry_1.entry_id}, + "config_entries": [config_entry_1.entry_id], "identifiers": {("entry_123", "0123")}, }, } @@ -1863,7 +1973,7 @@ async def test_restore_shared_device( "action": "update", "device_id": entry.id, "changes": { - "config_entries": {config_entry_2.entry_id}, + "config_entries": [config_entry_2.entry_id], "identifiers": {("entry_234", "2345")}, }, } @@ -2127,7 +2237,7 @@ async def test_device_info_configuration_url_validation( hass: HomeAssistant, device_registry: dr.DeviceRegistry, configuration_url: str | URL | None, - expectation, + expectation: AbstractContextManager, ) -> None: """Test configuration URL of device info is properly validated.""" config_entry_1 = MockConfigEntry() @@ -2485,7 +2595,7 @@ async def test_async_get_or_create_thread_safety( with pytest.raises( RuntimeError, - match="Detected code that calls async_update_device from a thread. Please report this issue.", + match="Detected code that calls device_registry.async_update_device from a thread.", ): await hass.async_add_executor_job( partial( @@ -2515,7 +2625,7 @@ async def test_async_remove_device_thread_safety( with pytest.raises( RuntimeError, - match="Detected code that calls async_remove_device from a thread. Please report this issue.", + match="Detected code that calls device_registry.async_remove_device from a thread.", ): await hass.async_add_executor_job( device_registry.async_remove_device, device.id diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py index dc4b2951b2f..100b50e2749 100644 --- a/tests/helpers/test_discovery.py +++ b/tests/helpers/test_discovery.py @@ -132,7 +132,9 @@ async def test_circular_import(hass: HomeAssistant) -> None: # dependencies are only set in component level # since we are using manifest to hold them mock_integration(hass, MockModule("test_circular", dependencies=["test_component"])) - mock_platform(hass, "test_circular.switch", MockPlatform(setup_platform)) + mock_platform( + hass, "test_circular.switch", MockPlatform(setup_platform=setup_platform) + ) await setup.async_setup_component( hass, diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index 7710eb2c7c7..9c2249ac17f 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, call, patch import pytest +from typing_extensions import Generator from homeassistant import config_entries from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, CoreState, HomeAssistant @@ -10,7 +11,7 @@ from homeassistant.helpers import discovery_flow @pytest.fixture -def mock_flow_init(hass): +def mock_flow_init(hass: HomeAssistant) -> Generator[AsyncMock]: """Mock hass.config_entries.flow.async_init.""" with patch.object( hass.config_entries.flow, "async_init", return_value=AsyncMock() @@ -18,7 +19,9 @@ def mock_flow_init(hass): yield mock_init -async def test_async_create_flow(hass: HomeAssistant, mock_flow_init) -> None: +async def test_async_create_flow( + hass: HomeAssistant, mock_flow_init: AsyncMock +) -> None: """Test we can create a flow.""" discovery_flow.async_create_flow( hass, @@ -36,7 +39,7 @@ async def test_async_create_flow(hass: HomeAssistant, mock_flow_init) -> None: async def test_async_create_flow_deferred_until_started( - hass: HomeAssistant, mock_flow_init + hass: HomeAssistant, mock_flow_init: AsyncMock ) -> None: """Test flows are deferred until started.""" hass.set_state(CoreState.stopped) @@ -59,7 +62,7 @@ async def test_async_create_flow_deferred_until_started( async def test_async_create_flow_checks_existing_flows_after_startup( - hass: HomeAssistant, mock_flow_init + hass: HomeAssistant, mock_flow_init: AsyncMock ) -> None: """Test existing flows prevent an identical ones from being after startup.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -77,7 +80,7 @@ async def test_async_create_flow_checks_existing_flows_after_startup( async def test_async_create_flow_checks_existing_flows_before_startup( - hass: HomeAssistant, mock_flow_init + hass: HomeAssistant, mock_flow_init: AsyncMock ) -> None: """Test existing flows prevent an identical ones from being created before startup.""" hass.set_state(CoreState.stopped) @@ -100,7 +103,7 @@ async def test_async_create_flow_checks_existing_flows_before_startup( async def test_async_create_flow_does_nothing_after_stop( - hass: HomeAssistant, mock_flow_init + hass: HomeAssistant, mock_flow_init: AsyncMock ) -> None: """Test we no longer create flows when hass is stopping.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index d9a79cc6a7a..c2c8663f47c 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -188,6 +188,7 @@ async def test_callback_exception_gets_logged( @callback def bad_handler(*args): """Record calls.""" + # pylint: disable-next=broad-exception-raised raise Exception("This is a bad message callback") # wrap in partial to test message logging. @@ -208,6 +209,7 @@ async def test_coro_exception_gets_logged( async def bad_async_handler(*args): """Record calls.""" + # pylint: disable-next=broad-exception-raised raise Exception("This is a bad message in a coro") # wrap in partial to test message logging. @@ -243,7 +245,6 @@ async def test_dispatcher_add_dispatcher(hass: HomeAssistant) -> None: async def test_thread_safety_checks(hass: HomeAssistant) -> None: """Test dispatcher thread safety checks.""" - hass.config.debug = True calls = [] @callback diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index a80674e0f76..f76b8555580 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -107,6 +107,7 @@ async def test_async_update_support(hass: HomeAssistant) -> None: """Async update.""" async_update.append(1) + # pylint: disable-next=attribute-defined-outside-init ent.async_update = async_update_func await ent.async_update_ha_state(True) @@ -236,12 +237,12 @@ async def test_async_async_request_call_without_lock(hass: HomeAssistant) -> Non class AsyncEntity(entity.Entity): """Test entity.""" - def __init__(self, entity_id): + def __init__(self, entity_id: str) -> None: """Initialize Async test entity.""" self.entity_id = entity_id self.hass = hass - async def testhelper(self, count): + async def testhelper(self, count: int) -> None: """Helper function.""" updates.append(count) @@ -273,7 +274,7 @@ async def test_async_async_request_call_with_lock(hass: HomeAssistant) -> None: class AsyncEntity(entity.Entity): """Test entity.""" - def __init__(self, entity_id, lock): + def __init__(self, entity_id: str, lock: asyncio.Semaphore) -> None: """Initialize Async test entity.""" self.entity_id = entity_id self.hass = hass @@ -323,13 +324,13 @@ async def test_async_parallel_updates_with_zero(hass: HomeAssistant) -> None: class AsyncEntity(entity.Entity): """Test entity.""" - def __init__(self, entity_id, count): + def __init__(self, entity_id: str, count: int) -> None: """Initialize Async test entity.""" self.entity_id = entity_id self.hass = hass self._count = count - async def async_update(self): + async def async_update(self) -> None: """Test update.""" updates.append(self._count) await test_lock.wait() @@ -362,7 +363,7 @@ async def test_async_parallel_updates_with_zero_on_sync_update( class AsyncEntity(entity.Entity): """Test entity.""" - def __init__(self, entity_id, count): + def __init__(self, entity_id: str, count: int) -> None: """Initialize Async test entity.""" self.entity_id = entity_id self.hass = hass @@ -403,14 +404,14 @@ async def test_async_parallel_updates_with_one(hass: HomeAssistant) -> None: class AsyncEntity(entity.Entity): """Test entity.""" - def __init__(self, entity_id, count): + def __init__(self, entity_id: str, count: int) -> None: """Initialize Async test entity.""" self.entity_id = entity_id self.hass = hass self._count = count self.parallel_updates = test_semaphore - async def async_update(self): + async def async_update(self) -> None: """Test update.""" updates.append(self._count) await test_lock.acquire() @@ -479,14 +480,14 @@ async def test_async_parallel_updates_with_two(hass: HomeAssistant) -> None: class AsyncEntity(entity.Entity): """Test entity.""" - def __init__(self, entity_id, count): + def __init__(self, entity_id: str, count: int) -> None: """Initialize Async test entity.""" self.entity_id = entity_id self.hass = hass self._count = count self.parallel_updates = test_semaphore - async def async_update(self): + async def async_update(self) -> None: """Test update.""" updates.append(self._count) await test_lock.acquire() @@ -549,13 +550,13 @@ async def test_async_parallel_updates_with_one_using_executor( class SyncEntity(entity.Entity): """Test entity.""" - def __init__(self, entity_id): + def __init__(self, entity_id: str) -> None: """Initialize sync test entity.""" self.entity_id = entity_id self.hass = hass self.parallel_updates = test_semaphore - def update(self): + def update(self) -> None: """Test update.""" locked.append(self.parallel_updates.locked()) @@ -628,7 +629,7 @@ async def test_async_remove_twice(hass: HomeAssistant) -> None: def __init__(self) -> None: self.remove_calls = [] - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: self.remove_calls.append(None) platform = MockEntityPlatform(hass, domain="test") @@ -1600,7 +1601,7 @@ async def test_translation_key(hass: HomeAssistant) -> None: assert mock_entity2.translation_key == "from_entity_description" -async def test_repr(hass) -> None: +async def test_repr(hass: HomeAssistant) -> None: """Test Entity.__repr__.""" class MyEntity(MockEntity): @@ -1662,11 +1663,6 @@ async def test_warn_no_platform( ent.entity_id = "hello.world" error_message = "does not have a platform" - # No warning if the entity has a platform - caplog.clear() - ent.async_write_ha_state() - assert error_message not in caplog.text - # Without a platform, it should trigger the warning ent.platform = None caplog.clear() @@ -1678,6 +1674,11 @@ async def test_warn_no_platform( ent.async_write_ha_state() assert error_message not in caplog.text + # No warning if the entity has a platform + caplog.clear() + ent.async_write_ha_state() + assert error_message not in caplog.text + async def test_invalid_state( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -1872,7 +1873,7 @@ async def test_change_entity_id( assert len(ent.remove_calls) == 2 -def test_entity_description_as_dataclass(snapshot: SnapshotAssertion): +def test_entity_description_as_dataclass(snapshot: SnapshotAssertion) -> None: """Test EntityDescription behaves like a dataclass.""" obj = entity.EntityDescription("blah", device_class="test") @@ -1887,7 +1888,7 @@ def test_entity_description_as_dataclass(snapshot: SnapshotAssertion): assert repr(obj) == snapshot -def test_extending_entity_description(snapshot: SnapshotAssertion): +def test_extending_entity_description(snapshot: SnapshotAssertion) -> None: """Test extending entity descriptions.""" @dataclasses.dataclass(frozen=True) @@ -2375,7 +2376,7 @@ async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> No This class overrides the attribute property. """ - def __init__(self): + def __init__(self) -> None: self._attr_attribution = values[0] @cached_property @@ -2617,13 +2618,12 @@ async def test_async_write_ha_state_thread_safety(hass: HomeAssistant) -> None: assert not hass.states.get(ent2.entity_id) -async def test_async_write_ha_state_thread_safety_custom_component( +async def test_async_write_ha_state_thread_safety_always( hass: HomeAssistant, ) -> None: - """Test async_write_ha_state thread safe for custom components.""" + """Test async_write_ha_state thread safe check.""" ent = entity.Entity() - ent._is_custom_component = True ent.entity_id = "test.any" ent.hass = hass ent.platform = MockEntityPlatform(hass, domain="test") @@ -2631,7 +2631,6 @@ async def test_async_write_ha_state_thread_safety_custom_component( assert hass.states.get(ent.entity_id) ent2 = entity.Entity() - ent2._is_custom_component = True ent2.entity_id = "test.any2" ent2.hass = hass ent2.platform = MockEntityPlatform(hass, domain="test") diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 60d0774b549..32ce740edb2 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -3,6 +3,7 @@ from collections import OrderedDict from datetime import timedelta import logging +import re from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time @@ -51,7 +52,7 @@ async def test_setup_loads_platforms(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("test_component", setup=component_setup)) # mock the dependencies mock_integration(hass, MockModule("mod2", dependencies=["test_component"])) - mock_platform(hass, "mod2.test_domain", MockPlatform(platform_setup)) + mock_platform(hass, "mod2.test_domain", MockPlatform(setup_platform=platform_setup)) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -70,8 +71,12 @@ async def test_setup_recovers_when_setup_raises(hass: HomeAssistant) -> None: platform1_setup = Mock(side_effect=Exception("Broken")) platform2_setup = Mock(return_value=None) - mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) - mock_platform(hass, "mod2.test_domain", MockPlatform(platform2_setup)) + mock_platform( + hass, "mod1.test_domain", MockPlatform(setup_platform=platform1_setup) + ) + mock_platform( + hass, "mod2.test_domain", MockPlatform(setup_platform=platform2_setup) + ) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -115,10 +120,7 @@ async def test_setup_does_discovery( assert ("platform_test", {}, {"msg": "discovery_info"}) == mock_setup.call_args[0] -@patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_config( - mock_track: Mock, hass: HomeAssistant -) -> None: +async def test_set_scan_interval_via_config(hass: HomeAssistant) -> None: """Test the setting of the scan interval via configuration.""" def platform_setup( @@ -130,17 +132,20 @@ async def test_set_scan_interval_via_config( """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) - mock_platform(hass, "platform.test_domain", MockPlatform(platform_setup)) + mock_platform( + hass, "platform.test_domain", MockPlatform(setup_platform=platform_setup) + ) component = EntityComponent(_LOGGER, DOMAIN, hass) - component.setup( - {DOMAIN: {"platform": "platform", "scan_interval": timedelta(seconds=30)}} - ) + with patch.object(hass.loop, "call_later") as mock_track: + component.setup( + {DOMAIN: {"platform": "platform", "scan_interval": timedelta(seconds=30)}} + ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert mock_track.called - assert timedelta(seconds=30) == mock_track.call_args[0][2] + assert mock_track.call_args[0][0] == 30.0 async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: @@ -155,7 +160,7 @@ async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: """Test the platform setup.""" add_entities([MockEntity(name="beer"), MockEntity(name=None)]) - platform = MockPlatform(platform_setup) + platform = MockPlatform(setup_platform=platform_setup) mock_platform(hass, "platform.test_domain", platform) @@ -205,7 +210,9 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: """Test that we retry when platform not ready.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) mock_integration(hass, MockModule("mod1")) - mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) + mock_platform( + hass, "mod1.test_domain", MockPlatform(setup_platform=platform1_setup) + ) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -365,7 +372,13 @@ async def test_setup_entry_fails_duplicate(hass: HomeAssistant) -> None: assert await component.async_setup_entry(entry) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=re.escape( + f"Config entry Mock Title ({entry.entry_id}) for " + "entry_domain.test_domain has already been setup!" + ), + ): await component.async_setup_entry(entry) @@ -505,7 +518,7 @@ async def test_register_entity_service(hass: HomeAssistant) -> None: {"entity_id": entity.entity_id, "invalid": "data"}, blocking=True, ) - assert len(calls) == 0 + assert len(calls) == 0 await hass.services.async_call( DOMAIN, "hello", {"entity_id": entity.entity_id, "some": "data"}, blocking=True @@ -673,7 +686,9 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: """Test that we shutdown platforms on stop.""" platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady, None]) mock_integration(hass, MockModule("mod1")) - mock_platform(hass, "mod1.test_domain", MockPlatform(platform1_setup)) + mock_platform( + hass, "mod1.test_domain", MockPlatform(setup_platform=platform1_setup) + ) component = EntityComponent(_LOGGER, DOMAIN, hass) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 64f6d6bf9f5..68024bc936f 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -94,8 +94,10 @@ async def test_polling_check_works_if_entity_add_fails( return self.hass.data is not None working_poll_ent = MockEntityNeedsSelfHassInShouldPoll(should_poll=True) + # pylint: disable-next=attribute-defined-outside-init working_poll_ent.async_update = AsyncMock() broken_poll_ent = MockEntityNeedsSelfHassInShouldPoll(should_poll=True) + # pylint: disable-next=attribute-defined-outside-init broken_poll_ent.async_update = AsyncMock(side_effect=Exception("Broken")) await component.async_add_entities( @@ -120,7 +122,7 @@ async def test_polling_disabled_by_config_entry(hass: HomeAssistant) -> None: poll_ent = MockEntity(should_poll=True) await entity_platform.async_add_entities([poll_ent]) - assert entity_platform._async_unsub_polling is None + assert entity_platform._async_polling_timer is None async def test_polling_updates_entities_with_exception(hass: HomeAssistant) -> None: @@ -213,10 +215,8 @@ async def test_update_state_adds_entities_with_update_before_add_false( assert not ent.update.called -@patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_platform( - mock_track: Mock, hass: HomeAssistant -) -> None: +@pytest.mark.usefixtures("disable_translations_once") +async def test_set_scan_interval_via_platform(hass: HomeAssistant) -> None: """Test the setting of the scan interval via platform.""" def platform_setup( @@ -228,18 +228,19 @@ async def test_set_scan_interval_via_platform( """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) - platform = MockPlatform(platform_setup) + platform = MockPlatform(setup_platform=platform_setup) platform.SCAN_INTERVAL = timedelta(seconds=30) mock_platform(hass, "platform.test_domain", platform) component = EntityComponent(_LOGGER, DOMAIN, hass) - await component.async_setup({DOMAIN: {"platform": "platform"}}) + with patch.object(hass.loop, "call_later") as mock_track: + await component.async_setup({DOMAIN: {"platform": "platform"}}) - await hass.async_block_till_done() + await hass.async_block_till_done() assert mock_track.called - assert timedelta(seconds=30) == mock_track.call_args[0][2] + assert mock_track.call_args[0][0] == 30.0 async def test_adding_entities_with_generator_and_thread_callback( @@ -262,6 +263,7 @@ async def test_adding_entities_with_generator_and_thread_callback( await component.async_add_entities(create_entity(i) for i in range(2)) +@pytest.mark.usefixtures("disable_translations_once") async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None: """Warn we log when platform setup takes a long time.""" platform = MockPlatform() @@ -505,7 +507,7 @@ async def test_parallel_updates_async_platform_updates_in_parallel( assert handle._update_in_sequence is False - await handle._update_entity_states(dt_util.utcnow()) + await handle._async_update_entity_states() assert peak_update_count > 1 @@ -555,7 +557,7 @@ async def test_parallel_updates_sync_platform_updates_in_sequence( assert handle._update_in_sequence is True - await handle._update_entity_states(dt_util.utcnow()) + await handle._async_update_entity_states() assert peak_update_count == 1 @@ -613,7 +615,7 @@ async def test_async_remove_with_platform_update_finishes(hass: HomeAssistant) - # Add, remove, and make sure no updates # cause the entity to reappear after removal and # that we can add another entity with the same entity_id - for entity in [entity1, entity2]: + for entity in (entity1, entity2): update_called = asyncio.Event() update_done = asyncio.Event() await component.async_add_entities([entity]) @@ -1017,7 +1019,7 @@ async def test_stop_shutdown_cancels_retry_setup_and_interval_listener( ent_platform.async_shutdown() assert len(mock_call_later.return_value.mock_calls) == 1 - assert ent_platform._async_unsub_polling is None + assert ent_platform._async_polling_timer is None assert ent_platform._async_cancel_retry_setup is None @@ -1855,7 +1857,6 @@ async def test_cancellation_is_not_blocked( with pytest.raises(asyncio.CancelledError): assert await platform.async_setup_entry(config_entry) - await hass.async_block_till_done() full_name = f"{config_entry.domain}.{platform.domain}" assert full_name not in hass.config.components diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index bb0b98c247e..1390ef3889d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -30,7 +30,7 @@ from tests.common import ( YAML__OPEN_PATH = "homeassistant.util.yaml.loader.open" -async def test_get(entity_registry: er.EntityRegistry): +async def test_get(entity_registry: er.EntityRegistry) -> None: """Test we can get an item.""" entry = entity_registry.async_get_or_create("light", "hue", "1234") @@ -511,7 +511,7 @@ async def test_load_bad_data( "id": "00003", "orphaned_timestamp": None, "platform": "super_platform", - "unique_id": 234, # Should trigger warning + "unique_id": 234, # Should not load }, { "config_entry_id": None, @@ -536,7 +536,11 @@ async def test_load_bad_data( assert ( "'test' from integration super_platform has a non string unique_id '123', " - "please create a bug report" in caplog.text + "please create a bug report" not in caplog.text + ) + assert ( + "'test' from integration super_platform has a non string unique_id '234', " + "please create a bug report" not in caplog.text ) assert ( "Entity registry entry 'test.test2' from integration super_platform could not " @@ -616,7 +620,7 @@ async def test_removing_config_entry_id( async def test_deleted_entity_removing_config_entry_id( entity_registry: er.EntityRegistry, -): +) -> None: """Test that we update config entry id in registry on deleted entity.""" mock_config = MockConfigEntry(domain="light", entry_id="mock-id-1") @@ -1102,10 +1106,10 @@ async def test_remove_config_entry_from_device_removes_entities( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { + assert device_entry.config_entries == [ config_entry_1.entry_id, config_entry_2.entry_id, - } + ] # Create one entity for each config entry entry_1 = entity_registry.async_get_or_create( @@ -1170,10 +1174,10 @@ async def test_remove_config_entry_from_device_removes_entities_2( config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert device_entry.config_entries == { + assert device_entry.config_entries == [ config_entry_1.entry_id, config_entry_2.entry_id, - } + ] # Create one entity for each config entry entry_1 = entity_registry.async_get_or_create( @@ -1524,9 +1528,7 @@ def test_entity_registry_items() -> None: assert entities.get_entry(entry2.id) is None -async def test_disabled_by_str_not_allowed( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: +async def test_disabled_by_str_not_allowed(entity_registry: er.EntityRegistry) -> None: """Test we need to pass disabled by type.""" with pytest.raises(ValueError): entity_registry.async_get_or_create( @@ -1541,7 +1543,7 @@ async def test_disabled_by_str_not_allowed( async def test_entity_category_str_not_allowed( - hass: HomeAssistant, entity_registry: er.EntityRegistry + entity_registry: er.EntityRegistry, ) -> None: """Test we need to pass entity category type.""" with pytest.raises(ValueError): @@ -1570,9 +1572,7 @@ async def test_hidden_by_str_not_allowed(entity_registry: er.EntityRegistry) -> ) -async def test_unique_id_non_hashable( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: +async def test_unique_id_non_hashable(entity_registry: er.EntityRegistry) -> None: """Test unique_id which is not hashable.""" with pytest.raises(TypeError): entity_registry.async_get_or_create("light", "hue", ["not", "valid"]) @@ -1583,9 +1583,7 @@ async def test_unique_id_non_hashable( async def test_unique_id_non_string( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture ) -> None: """Test unique_id which is not a string.""" entity_registry.async_get_or_create("light", "hue", 1234) @@ -1679,7 +1677,7 @@ async def test_restore_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, -): +) -> None: """Make sure entity registry id is stable and entity_id is reused if possible.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) config_entry = MockConfigEntry(domain="light") @@ -1773,7 +1771,7 @@ async def test_restore_entity( async def test_async_migrate_entry_delete_self( hass: HomeAssistant, entity_registry: er.EntityRegistry -): +) -> None: """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") @@ -1808,7 +1806,7 @@ async def test_async_migrate_entry_delete_self( async def test_async_migrate_entry_delete_other( hass: HomeAssistant, entity_registry: er.EntityRegistry -): +) -> None: """Test async_migrate_entry.""" config_entry1 = MockConfigEntry(domain="test1") config_entry2 = MockConfigEntry(domain="test2") @@ -1984,7 +1982,7 @@ async def test_get_or_create_thread_safety( """Test call async_get_or_create_from a thread.""" with pytest.raises( RuntimeError, - match="Detected code that calls async_get_or_create from a thread. Please report this issue.", + match="Detected code that calls entity_registry.async_get_or_create from a thread.", ): await hass.async_add_executor_job( entity_registry.async_get_or_create, "light", "hue", "1234" @@ -1998,7 +1996,7 @@ async def test_async_update_entity_thread_safety( entry = entity_registry.async_get_or_create("light", "hue", "1234") with pytest.raises( RuntimeError, - match="Detected code that calls _async_update_entity from a thread. Please report this issue.", + match="Detected code that calls entity_registry.async_update_entity from a thread.", ): await hass.async_add_executor_job( partial( @@ -2016,6 +2014,6 @@ async def test_async_remove_thread_safety( entry = entity_registry.async_get_or_create("light", "hue", "1234") with pytest.raises( RuntimeError, - match="Detected code that calls async_remove from a thread. Please report this issue.", + match="Detected code that calls entity_registry.async_remove from a thread.", ): await hass.async_add_executor_job(entity_registry.async_remove, entry.entity_id) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a6fad968eac..edce36218e8 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -49,7 +49,7 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, async_fire_time_changed_exact -DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE +DEFAULT_TIME_ZONE = dt_util.get_default_time_zone() async def test_track_point_in_time(hass: HomeAssistant) -> None: @@ -61,7 +61,10 @@ async def test_track_point_in_time(hass: HomeAssistant) -> None: runs = [] async_track_point_in_utc_time( - hass, callback(lambda x: runs.append(x)), birthday_paulus + hass, + # pylint: disable-next=unnecessary-lambda + callback(lambda x: runs.append(x)), + birthday_paulus, ) async_fire_time_changed(hass, before_birthday) @@ -78,7 +81,10 @@ async def test_track_point_in_time(hass: HomeAssistant) -> None: assert len(runs) == 1 async_track_point_in_utc_time( - hass, callback(lambda x: runs.append(x)), birthday_paulus + hass, + # pylint: disable-next=unnecessary-lambda + callback(lambda x: runs.append(x)), + birthday_paulus, ) async_fire_time_changed(hass, after_birthday) @@ -86,7 +92,10 @@ async def test_track_point_in_time(hass: HomeAssistant) -> None: assert len(runs) == 2 unsub = async_track_point_in_time( - hass, callback(lambda x: runs.append(x)), birthday_paulus + hass, + # pylint: disable-next=unnecessary-lambda + callback(lambda x: runs.append(x)), + birthday_paulus, ) unsub() @@ -107,6 +116,7 @@ async def test_track_point_in_time_drift_rearm(hass: HomeAssistant) -> None: async_track_point_in_utc_time( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), time_that_will_not_match_right_away, ) @@ -3546,7 +3556,10 @@ async def test_track_time_interval(hass: HomeAssistant) -> None: utc_now = dt_util.utcnow() unsub = async_track_time_interval( - hass, callback(lambda x: specific_runs.append(x)), timedelta(seconds=10) + hass, + # pylint: disable-next=unnecessary-lambda + callback(lambda x: specific_runs.append(x)), + timedelta(seconds=10), ) async_fire_time_changed(hass, utc_now + timedelta(seconds=5)) @@ -3578,6 +3591,7 @@ async def test_track_time_interval_name(hass: HomeAssistant) -> None: unique_string = "xZ13" unsub = async_track_time_interval( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), timedelta(seconds=10), name=unique_string, @@ -3808,12 +3822,20 @@ async def test_async_track_time_change( ) freezer.move_to(time_that_will_not_match_right_away) - unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) + unsub = async_track_time_change( + hass, + # pylint: disable-next=unnecessary-lambda + 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] + hass, + # pylint: disable-next=unnecessary-lambda + callback(lambda x: specific_runs.append(x)), + second=[0, 30], ) unsub_wildcard = async_track_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: wildcard_runs.append(x)), second="*", minute="*", @@ -3872,7 +3894,11 @@ async def test_periodic_task_minute( freezer.move_to(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 + hass, + # pylint: disable-next=unnecessary-lambda + callback(lambda x: specific_runs.append(x)), + minute="/5", + second=0, ) async_fire_time_changed( @@ -3918,6 +3944,7 @@ async def test_periodic_task_hour( unsub = async_track_utc_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), hour="/2", minute=0, @@ -3971,7 +3998,10 @@ async def test_periodic_task_wrong_input(hass: HomeAssistant) -> None: with pytest.raises(ValueError): async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), hour="/two" + hass, + # pylint: disable-next=unnecessary-lambda + callback(lambda x: specific_runs.append(x)), + hour="/two", ) async_fire_time_changed( @@ -3995,6 +4025,7 @@ async def test_periodic_task_clock_rollback( unsub = async_track_utc_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), hour="/2", minute=0, @@ -4064,6 +4095,7 @@ async def test_periodic_task_duplicate_time( unsub = async_track_utc_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), hour="/2", minute=0, @@ -4097,7 +4129,7 @@ async def test_periodic_task_entering_dst( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test periodic task behavior when entering dst.""" - hass.config.set_time_zone("Europe/Vienna") + await hass.config.async_set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -4109,6 +4141,7 @@ async def test_periodic_task_entering_dst( unsub = async_track_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), hour=2, minute=30, @@ -4148,7 +4181,7 @@ async def test_periodic_task_entering_dst_2( This tests a task firing every second in the range 0..58 (not *:*:59) """ - hass.config.set_time_zone("Europe/Vienna") + await hass.config.async_set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -4160,6 +4193,7 @@ async def test_periodic_task_entering_dst_2( unsub = async_track_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), second=list(range(59)), ) @@ -4198,7 +4232,7 @@ async def test_periodic_task_leaving_dst( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test periodic task behavior when leaving dst.""" - hass.config.set_time_zone("Europe/Vienna") + await hass.config.async_set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -4210,6 +4244,7 @@ async def test_periodic_task_leaving_dst( unsub = async_track_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), hour=2, minute=30, @@ -4274,7 +4309,7 @@ async def test_periodic_task_leaving_dst_2( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test periodic task behavior when leaving dst.""" - hass.config.set_time_zone("Europe/Vienna") + await hass.config.async_set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -4285,6 +4320,7 @@ async def test_periodic_task_leaving_dst_2( unsub = async_track_time_change( hass, + # pylint: disable-next=unnecessary-lambda callback(lambda x: specific_runs.append(x)), minute=30, second=0, @@ -4565,7 +4601,7 @@ async def test_async_track_point_in_time_cancel(hass: HomeAssistant) -> None: """Test cancel of async track point in time.""" times = [] - hass.config.set_time_zone("US/Hawaii") + await hass.config.async_set_time_zone("US/Hawaii") hst_tz = dt_util.get_time_zone("US/Hawaii") @ha.callback @@ -4589,6 +4625,40 @@ async def test_async_track_point_in_time_cancel(hass: HomeAssistant) -> None: assert "US/Hawaii" in str(times[0].tzinfo) +async def test_async_track_point_in_time_cancel_in_job( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test cancel of async track point in time during job execution.""" + + now = dt_util.utcnow() + times = [] + + 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) + + @callback + def action(x: datetime): + nonlocal times + times.append(x) + unsub() + + unsub = async_track_utc_time_change(hass, action, minute=0, second="*") + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) + ) + await hass.async_block_till_done() + assert len(times) == 1 + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 13, 0, 0, 999999, tzinfo=dt_util.UTC) + ) + await hass.async_block_till_done() + assert len(times) == 1 + + async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> None: """Test tracking entity registry updates for an entity_id.""" diff --git a/tests/helpers/test_floor_registry.py b/tests/helpers/test_floor_registry.py index faa9eb131a1..3b07563fd11 100644 --- a/tests/helpers/test_floor_registry.py +++ b/tests/helpers/test_floor_registry.py @@ -1,5 +1,6 @@ """Tests for the floor registry.""" +from functools import partial import re from typing import Any @@ -7,14 +8,6 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, floor_registry as fr -from homeassistant.helpers.floor_registry import ( - EVENT_FLOOR_REGISTRY_UPDATED, - STORAGE_KEY, - STORAGE_VERSION_MAJOR, - FloorRegistry, - async_get, - async_load, -) from tests.common import async_capture_events, flush_store @@ -29,7 +22,7 @@ async def test_create_floor( hass: HomeAssistant, floor_registry: fr.FloorRegistry ) -> None: """Make sure that we can create floors.""" - update_events = async_capture_events(hass, EVENT_FLOOR_REGISTRY_UPDATED) + update_events = async_capture_events(hass, fr.EVENT_FLOOR_REGISTRY_UPDATED) floor = floor_registry.async_create( name="First floor", icon="mdi:home-floor-1", @@ -58,7 +51,7 @@ async def test_create_floor_with_name_already_in_use( hass: HomeAssistant, floor_registry: fr.FloorRegistry ) -> None: """Make sure that we can't create a floor with a name already in use.""" - update_events = async_capture_events(hass, EVENT_FLOOR_REGISTRY_UPDATED) + update_events = async_capture_events(hass, fr.EVENT_FLOOR_REGISTRY_UPDATED) floor_registry.async_create("First floor") with pytest.raises( @@ -74,7 +67,7 @@ async def test_create_floor_with_name_already_in_use( async def test_create_floor_with_id_already_in_use( - hass: HomeAssistant, floor_registry: fr.FloorRegistry + floor_registry: fr.FloorRegistry, ) -> None: """Make sure that we can't create an floor with an id already in use.""" floor = floor_registry.async_create("First") @@ -91,7 +84,7 @@ async def test_delete_floor( hass: HomeAssistant, floor_registry: fr.FloorRegistry ) -> None: """Make sure that we can delete a floor.""" - update_events = async_capture_events(hass, EVENT_FLOOR_REGISTRY_UPDATED) + update_events = async_capture_events(hass, fr.EVENT_FLOOR_REGISTRY_UPDATED) floor = floor_registry.async_create("First floor") assert len(floor_registry.floors) == 1 @@ -126,7 +119,7 @@ async def test_update_floor( hass: HomeAssistant, floor_registry: fr.FloorRegistry ) -> None: """Make sure that we can update floors.""" - update_events = async_capture_events(hass, EVENT_FLOOR_REGISTRY_UPDATED) + update_events = async_capture_events(hass, fr.EVENT_FLOOR_REGISTRY_UPDATED) floor = floor_registry.async_create("First floor") assert len(floor_registry.floors) == 1 @@ -170,7 +163,7 @@ async def test_update_floor_with_same_data( hass: HomeAssistant, floor_registry: fr.FloorRegistry ) -> None: """Make sure that we can reapply the same data to a floor and it won't update.""" - update_events = async_capture_events(hass, EVENT_FLOOR_REGISTRY_UPDATED) + update_events = async_capture_events(hass, fr.EVENT_FLOOR_REGISTRY_UPDATED) floor = floor_registry.async_create( "First floor", icon="mdi:home-floor-1", @@ -261,7 +254,7 @@ async def test_load_floors( assert len(floor_registry.floors) == 2 - registry2 = FloorRegistry(hass) + registry2 = fr.FloorRegistry(hass) await flush_store(floor_registry._store) await registry2.async_load() @@ -287,11 +280,11 @@ async def test_load_floors( @pytest.mark.parametrize("load_registries", [False]) async def test_loading_floors_from_storage( - hass: HomeAssistant, hass_storage: Any + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored floors on start.""" - hass_storage[STORAGE_KEY] = { - "version": STORAGE_VERSION_MAJOR, + hass_storage[fr.STORAGE_KEY] = { + "version": fr.STORAGE_VERSION_MAJOR, "data": { "floors": [ { @@ -305,8 +298,8 @@ async def test_loading_floors_from_storage( }, } - await async_load(hass) - registry = async_get(hass) + await fr.async_load(hass) + registry = fr.async_get(hass) assert len(registry.floors) == 1 @@ -357,3 +350,45 @@ async def test_floor_removed_from_areas( entries = ar.async_entries_for_floor(area_registry, floor.floor_id) assert len(entries) == 0 + + +async def test_async_create_thread_safety( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, +) -> None: + """Test async_create raises when called from wrong thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls floor_registry.async_create from a thread.", + ): + await hass.async_add_executor_job(floor_registry.async_create, "any") + + +async def test_async_delete_thread_safety( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, +) -> None: + """Test async_delete raises when called from wrong thread.""" + any_floor = floor_registry.async_create("any") + + with pytest.raises( + RuntimeError, + match="Detected code that calls floor_registry.async_delete from a thread.", + ): + await hass.async_add_executor_job(floor_registry.async_delete, any_floor) + + +async def test_async_update_thread_safety( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, +) -> None: + """Test async_update raises when called from wrong thread.""" + any_floor = floor_registry.async_create("any") + + with pytest.raises( + RuntimeError, + match="Detected code that calls floor_registry.async_update from a thread.", + ): + await hass.async_add_executor_job( + partial(floor_registry.async_update, any_floor.floor_id, name="new name") + ) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 904bed965c8..b3fbb0faaf4 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -17,7 +17,7 @@ async def test_extract_frame_integration( integration_frame = frame.get_integration_frame() assert integration_frame == frame.IntegrationFrame( custom_integration=False, - _frame=mock_integration_frame, + frame=mock_integration_frame, integration="hue", module=None, relative_filename="homeassistant/components/hue/light.py", @@ -32,27 +32,27 @@ async def test_get_integration_logger( assert logger.name == "homeassistant.components.hue" -async def test_extract_frame_resolve_module( - hass: HomeAssistant, enable_custom_integrations -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: """Test extracting the current frame from integration context.""" + # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_frame integration_frame = call_get_integration_frame() assert integration_frame == frame.IntegrationFrame( custom_integration=True, - _frame=ANY, + frame=ANY, integration="test_integration_frame", module="custom_components.test_integration_frame", relative_filename="custom_components/test_integration_frame/__init__.py", ) -async def test_get_integration_logger_resolve_module( - hass: HomeAssistant, enable_custom_integrations -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_integration_logger_resolve_module(hass: HomeAssistant) -> None: """Test getting the logger from integration context.""" + # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_logger logger = call_get_integration_logger(__name__) @@ -98,7 +98,7 @@ async def test_extract_frame_integration_with_excluded_integration( assert integration_frame == frame.IntegrationFrame( custom_integration=False, - _frame=correct_frame, + frame=correct_frame, integration="mdns", module=None, relative_filename="homeassistant/components/mdns/light.py", diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index 5ad5071266b..732f9971ac0 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -162,10 +162,6 @@ async def test_get_icons_while_loading_components(hass: HomeAssistant) -> None: return {"component1": {"entity": {"climate": {"test": {"icon": "mdi:home"}}}}} with ( - patch( - "homeassistant.helpers.icon._component_icons_path", - return_value="choochoo.json", - ), patch( "homeassistant.helpers.icon._load_icons_files", mock_load_icons_files, diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py deleted file mode 100644 index 39b387000ca..00000000000 --- a/tests/helpers/test_init.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Test component helpers.""" - -from collections import OrderedDict - -import pytest - -from homeassistant import helpers - - -def test_extract_domain_configs(caplog: pytest.LogCaptureFixture) -> 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( - 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(caplog: pytest.LogCaptureFixture) -> 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(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_intent.py b/tests/helpers/test_intent.py index d77eb698205..c592fc50c0a 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -6,9 +6,13 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol -from homeassistant.components import conversation -from homeassistant.components.switch import SwitchDeviceClass -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.components import conversation, light, switch +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import ( area_registry as ar, @@ -20,15 +24,20 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_mock_service class MockIntentHandler(intent.IntentHandler): """Provide a mock intent handler.""" - def __init__(self, slot_schema): + def __init__(self, slot_schema) -> None: """Initialize the mock handler.""" - self.slot_schema = slot_schema + self._mock_slot_schema = slot_schema + + @property + def slot_schema(self): + """Return the slot schema.""" + return self._mock_slot_schema async def test_async_match_states( @@ -73,7 +82,7 @@ async def test_async_match_states( entity_registry.async_update_entity( state2.entity_id, area_id=area_bedroom.id, - device_class=SwitchDeviceClass.OUTLET, + device_class=switch.SwitchDeviceClass.OUTLET, aliases={"kill switch"}, ) @@ -126,7 +135,7 @@ async def test_async_match_states( assert list( intent.async_match_states( hass, - device_classes={SwitchDeviceClass.OUTLET}, + device_classes={switch.SwitchDeviceClass.OUTLET}, area_name="bedroom", states=[state1, state2], ) @@ -162,6 +171,346 @@ async def test_async_match_states( ) +async def test_async_match_targets( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Tests for async_match_targets function.""" + # Needed for exposure + assert await async_setup_component(hass, "homeassistant", {}) + + # House layout + # Floor 1 (ground): + # - Kitchen + # - Outlet + # - Bathroom + # - Light + # Floor 2 (upstairs) + # - Bedroom + # - Switch + # - Bathroom + # - Light + # Floor 3 (also upstairs) + # - Bedroom + # - Switch + # - Bathroom + # - Light + + # Floor 1 + floor_1 = floor_registry.async_create("first floor", aliases={"ground"}) + area_kitchen = area_registry.async_get_or_create("kitchen") + area_kitchen = area_registry.async_update( + area_kitchen.id, floor_id=floor_1.floor_id + ) + area_bathroom_1 = area_registry.async_get_or_create("first floor bathroom") + area_bathroom_1 = area_registry.async_update( + area_bathroom_1.id, aliases={"bathroom"}, floor_id=floor_1.floor_id + ) + + kitchen_outlet = entity_registry.async_get_or_create( + "switch", "test", "kitchen_outlet" + ) + kitchen_outlet = entity_registry.async_update_entity( + kitchen_outlet.entity_id, + name="kitchen outlet", + device_class=switch.SwitchDeviceClass.OUTLET, + area_id=area_kitchen.id, + ) + state_kitchen_outlet = State(kitchen_outlet.entity_id, "on") + + bathroom_light_1 = entity_registry.async_get_or_create( + "light", "test", "bathroom_light_1" + ) + bathroom_light_1 = entity_registry.async_update_entity( + bathroom_light_1.entity_id, + name="bathroom light", + aliases={"overhead light"}, + area_id=area_bathroom_1.id, + ) + state_bathroom_light_1 = State(bathroom_light_1.entity_id, "off") + + # Floor 2 + floor_2 = floor_registry.async_create("second floor", aliases={"upstairs"}) + area_bedroom_2 = area_registry.async_get_or_create("bedroom") + area_bedroom_2 = area_registry.async_update( + area_bedroom_2.id, floor_id=floor_2.floor_id + ) + area_bathroom_2 = area_registry.async_get_or_create("second floor bathroom") + area_bathroom_2 = area_registry.async_update( + area_bathroom_2.id, aliases={"bathroom"}, floor_id=floor_2.floor_id + ) + + bedroom_switch_2 = entity_registry.async_get_or_create( + "switch", "test", "bedroom_switch_2" + ) + bedroom_switch_2 = entity_registry.async_update_entity( + bedroom_switch_2.entity_id, + name="second floor bedroom switch", + area_id=area_bedroom_2.id, + ) + state_bedroom_switch_2 = State( + bedroom_switch_2.entity_id, + "off", + ) + + bathroom_light_2 = entity_registry.async_get_or_create( + "light", "test", "bathroom_light_2" + ) + bathroom_light_2 = entity_registry.async_update_entity( + bathroom_light_2.entity_id, + aliases={"bathroom light", "overhead light"}, + area_id=area_bathroom_2.id, + supported_features=light.LightEntityFeature.EFFECT, + ) + state_bathroom_light_2 = State(bathroom_light_2.entity_id, "off") + + # Floor 3 + floor_3 = floor_registry.async_create("third floor", aliases={"upstairs"}) + area_bedroom_3 = area_registry.async_get_or_create("bedroom") + area_bedroom_3 = area_registry.async_update( + area_bedroom_3.id, floor_id=floor_3.floor_id + ) + area_bathroom_3 = area_registry.async_get_or_create("third floor bathroom") + area_bathroom_3 = area_registry.async_update( + area_bathroom_3.id, aliases={"bathroom"}, floor_id=floor_3.floor_id + ) + + bedroom_switch_3 = entity_registry.async_get_or_create( + "switch", "test", "bedroom_switch_3" + ) + bedroom_switch_3 = entity_registry.async_update_entity( + bedroom_switch_3.entity_id, + name="third floor bedroom switch", + area_id=area_bedroom_3.id, + ) + state_bedroom_switch_3 = State( + bedroom_switch_3.entity_id, + "off", + attributes={ATTR_DEVICE_CLASS: switch.SwitchDeviceClass.OUTLET}, + ) + + bathroom_light_3 = entity_registry.async_get_or_create( + "light", "test", "bathroom_light_3" + ) + bathroom_light_3 = entity_registry.async_update_entity( + bathroom_light_3.entity_id, + name="overhead light", + area_id=area_bathroom_3.id, + ) + state_bathroom_light_3 = State( + bathroom_light_3.entity_id, + "on", + attributes={ + ATTR_FRIENDLY_NAME: "bathroom light", + ATTR_SUPPORTED_FEATURES: light.LightEntityFeature.EFFECT, + }, + ) + + # ----- + bathroom_light_states = [ + state_bathroom_light_1, + state_bathroom_light_2, + state_bathroom_light_3, + ] + states = [ + *bathroom_light_states, + state_kitchen_outlet, + state_bedroom_switch_2, + state_bedroom_switch_3, + ] + + # Not a unique name + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(name="bathroom light"), + states=states, + ) + assert not result.is_match + assert result.no_match_reason == intent.MatchFailedReason.DUPLICATE_NAME + assert result.no_match_name == "bathroom light" + + # Works with duplicate names allowed + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name="bathroom light", allow_duplicate_names=True + ), + states=states, + ) + assert result.is_match + assert {s.entity_id for s in result.states} == { + s.entity_id for s in bathroom_light_states + } + + # Also works when name is not a constraint + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}), + states=states, + ) + assert result.is_match + assert {s.entity_id for s in result.states} == { + s.entity_id for s in bathroom_light_states + } + + # We can disambiguate by preferred floor (from context) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(name="bathroom light"), + intent.MatchTargetsPreferences(floor_id=floor_3.floor_id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_3.entity_id + + # Also disambiguate by preferred area (from context) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(name="bathroom light"), + intent.MatchTargetsPreferences(area_id=area_bathroom_2.id), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_2.entity_id + + # Disambiguate by floor name, if unique + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(name="bathroom light", floor_name="ground"), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_1.entity_id + + # Doesn't work if floor name/alias is not unique + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(name="bathroom light", floor_name="upstairs"), + states=states, + ) + assert not result.is_match + assert result.no_match_reason == intent.MatchFailedReason.DUPLICATE_NAME + + # Disambiguate by area name, if unique + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name="bathroom light", area_name="first floor bathroom" + ), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_1.entity_id + + # Doesn't work if area name/alias is not unique + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(name="bathroom light", area_name="bathroom"), + states=states, + ) + assert not result.is_match + assert result.no_match_reason == intent.MatchFailedReason.DUPLICATE_NAME + + # Does work if floor/area name combo is unique + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name="bathroom light", area_name="bathroom", floor_name="ground" + ), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_1.entity_id + + # Doesn't work if area is not part of the floor + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + name="bathroom light", + area_name="second floor bathroom", + floor_name="ground", + ), + states=states, + ) + assert not result.is_match + assert result.no_match_reason == intent.MatchFailedReason.AREA + + # Check state constraint (only third floor bathroom light is on) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(domains={"light"}, states={"on"}), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_3.entity_id + + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + domains={"light"}, states={"on"}, floor_name="ground" + ), + states=states, + ) + assert not result.is_match + + # Check assistant constraint (exposure) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(assistant="test"), + states=states, + ) + assert not result.is_match + + async_expose_entity(hass, "test", bathroom_light_1.entity_id, True) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints(assistant="test"), + states=states, + ) + assert result.is_match + assert len(result.states) == 1 + assert result.states[0].entity_id == bathroom_light_1.entity_id + + # Check device class constraint + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + domains={"switch"}, device_classes={switch.SwitchDeviceClass.OUTLET} + ), + states=states, + ) + assert result.is_match + assert len(result.states) == 2 + assert {s.entity_id for s in result.states} == { + kitchen_outlet.entity_id, + bedroom_switch_3.entity_id, + } + + # Check features constraint (second and third floor bathroom lights have effects) + result = intent.async_match_targets( + hass, + intent.MatchTargetsConstraints( + domains={"light"}, features=light.LightEntityFeature.EFFECT + ), + states=states, + ) + assert result.is_match + assert len(result.states) == 2 + assert {s.entity_id for s in result.states} == { + bathroom_light_2.entity_id, + bathroom_light_3.entity_id, + } + + async def test_match_device_area( hass: HomeAssistant, area_registry: ar.AreaRegistry, @@ -261,7 +610,7 @@ def test_async_register(hass: HomeAssistant) -> None: intent.async_register(hass, handler) - assert hass.data[intent.DATA_KEY]["test_intent"] == handler + assert list(intent.async_get(hass)) == [handler] def test_async_register_overwrite(hass: HomeAssistant) -> None: @@ -280,7 +629,7 @@ def test_async_register_overwrite(hass: HomeAssistant) -> None: "Intent %s is being overwritten by %s", "test_intent", handler2 ) - assert hass.data[intent.DATA_KEY]["test_intent"] == handler2 + assert list(intent.async_get(hass)) == [handler2] def test_async_remove(hass: HomeAssistant) -> None: @@ -291,7 +640,7 @@ def test_async_remove(hass: HomeAssistant) -> None: intent.async_register(hass, handler) intent.async_remove(hass, "test_intent") - assert "test_intent" not in hass.data[intent.DATA_KEY] + assert not list(intent.async_get(hass)) def test_async_remove_no_existing_entry(hass: HomeAssistant) -> None: @@ -302,7 +651,7 @@ def test_async_remove_no_existing_entry(hass: HomeAssistant) -> None: intent.async_remove(hass, "test_intent2") - assert "test_intent2" not in hass.data[intent.DATA_KEY] + assert list(intent.async_get(hass)) == [handler] def test_async_remove_no_existing(hass: HomeAssistant) -> None: @@ -353,7 +702,107 @@ async def test_validate_then_run_in_background(hass: HomeAssistant) -> None: async def test_invalid_area_floor_names(hass: HomeAssistant) -> None: - """Test that we throw an intent handle error with invalid area/floor names.""" + """Test that we throw an appropriate errors with invalid area/floor names.""" + handler = intent.ServiceIntentHandler( + "TestType", "light", "turn_on", "Turned {} on" + ) + intent.async_register(hass, handler) + + # Need a light to avoid domain error + hass.states.async_set("light.test", "off") + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + "TestType", + slots={"area": {"value": "invalid area"}}, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.INVALID_AREA + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + "TestType", + slots={"floor": {"value": "invalid floor"}}, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.INVALID_FLOOR + + +async def test_service_intent_handler_required_domains(hass: HomeAssistant) -> None: + """Test that required_domains restricts the domain of a ServiceIntentHandler.""" + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("switch.bedroom", "off") + + calls = async_mock_service(hass, "homeassistant", "turn_on") + handler = intent.ServiceIntentHandler( + "TestType", + "homeassistant", + "turn_on", + "Turned {} on", + required_domains={"light"}, + ) + intent.async_register(hass, handler) + + # Should work fine + result = await intent.async_handle( + hass, + "test", + "TestType", + slots={"name": {"value": "kitchen"}, "domain": {"value": "light"}}, + ) + assert result.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + + # Fails because the intent handler is restricted to lights only + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + "TestType", + slots={"name": {"value": "bedroom"}}, + ) + + # Still fails even if we provide the domain + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + "TestType", + slots={"name": {"value": "bedroom"}, "domain": {"value": "switch"}}, + ) + + +async def test_service_handler_empty_strings(hass: HomeAssistant) -> None: + """Test that passing empty strings for filters fails in ServiceIntentHandler.""" + handler = intent.ServiceIntentHandler( + "TestType", "light", "turn_on", "Turned {} on" + ) + intent.async_register(hass, handler) + + for slot_name in ("name", "area", "floor"): + # Empty string + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + "TestType", + slots={slot_name: {"value": ""}}, + ) + + # Whitespace + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + "TestType", + slots={slot_name: {"value": " "}}, + ) + + +async def test_service_handler_no_filter(hass: HomeAssistant) -> None: + """Test that targeting all devices in the house fails.""" handler = intent.ServiceIntentHandler( "TestType", "light", "turn_on", "Turned {} on" ) @@ -364,13 +813,4 @@ async def test_invalid_area_floor_names(hass: HomeAssistant) -> None: hass, "test", "TestType", - slots={"area": {"value": "invalid area"}}, - ) - - with pytest.raises(intent.IntentHandleError): - await intent.async_handle( - hass, - "test", - "TestType", - slots={"floor": {"value": "invalid floor"}}, ) diff --git a/tests/helpers/test_issue_registry.py b/tests/helpers/test_issue_registry.py index 66fc9662f75..252fb8389d3 100644 --- a/tests/helpers/test_issue_registry.py +++ b/tests/helpers/test_issue_registry.py @@ -1,5 +1,6 @@ """Test the repairs websocket API.""" +from functools import partial from typing import Any import pytest @@ -160,7 +161,7 @@ async def test_load_save_issues(hass: HomeAssistant) -> None: "issue_id": "issue_3", } - registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] + registry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 3 issue1 = registry.async_get_issue("test", "issue_1") issue2 = registry.async_get_issue("test", "issue_2") @@ -326,7 +327,7 @@ async def test_loading_issues_from_storage( await ir.async_load(hass) - registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] + registry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 3 @@ -356,5 +357,73 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) await ir.async_load(hass) - registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] + registry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 2 + + +async def test_get_or_create_thread_safety( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test call async_get_or_create_from a thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls issue_registry.async_get_or_create from a thread.", + ): + await hass.async_add_executor_job( + partial( + ir.async_create_issue, + hass, + "any", + "any", + is_fixable=True, + severity="error", + translation_key="any", + ) + ) + + +async def test_async_delete_issue_thread_safety( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test call async_delete_issue from a thread.""" + ir.async_create_issue( + hass, + "any", + "any", + is_fixable=True, + severity="error", + translation_key="any", + ) + + with pytest.raises( + RuntimeError, + match="Detected code that calls issue_registry.async_delete from a thread.", + ): + await hass.async_add_executor_job( + ir.async_delete_issue, + hass, + "any", + "any", + ) + + +async def test_async_ignore_issue_thread_safety( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test call async_ignore_issue from a thread.""" + ir.async_create_issue( + hass, + "any", + "any", + is_fixable=True, + severity="error", + translation_key="any", + ) + + with pytest.raises( + RuntimeError, + match="Detected code that calls issue_registry.async_ignore from a thread.", + ): + await hass.async_add_executor_job( + ir.async_ignore_issue, hass, "any", "any", True + ) diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 57269963164..061faed6f93 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -7,7 +7,7 @@ import math import os from pathlib import Path import time -from typing import NamedTuple +from typing import Any, NamedTuple from unittest.mock import Mock, patch import pytest @@ -325,10 +325,10 @@ def test_find_unserializable_data() -> None: ) == {"$[0](Event: bad_event).data.bad_attribute": bad_data} class BadData: - def __init__(self): + def __init__(self) -> None: self.bla = bad_data - def as_dict(self): + def as_dict(self) -> dict[str, Any]: return {"bla": self.bla} assert find_paths_unserializable_data( diff --git a/tests/helpers/test_label_registry.py b/tests/helpers/test_label_registry.py index 785919b25c0..445319a4b62 100644 --- a/tests/helpers/test_label_registry.py +++ b/tests/helpers/test_label_registry.py @@ -1,5 +1,6 @@ """Tests for the Label Registry.""" +from functools import partial import re from typing import Any @@ -11,14 +12,6 @@ from homeassistant.helpers import ( entity_registry as er, label_registry as lr, ) -from homeassistant.helpers.label_registry import ( - EVENT_LABEL_REGISTRY_UPDATED, - STORAGE_KEY, - STORAGE_VERSION_MAJOR, - LabelRegistry, - async_get, - async_load, -) from tests.common import MockConfigEntry, async_capture_events, flush_store @@ -33,7 +26,7 @@ async def test_create_label( hass: HomeAssistant, label_registry: lr.LabelRegistry ) -> None: """Make sure that we can create labels.""" - update_events = async_capture_events(hass, EVENT_LABEL_REGISTRY_UPDATED) + update_events = async_capture_events(hass, lr.EVENT_LABEL_REGISTRY_UPDATED) label = label_registry.async_create( name="My Label", color="#FF0000", @@ -62,7 +55,7 @@ async def test_create_label_with_name_already_in_use( hass: HomeAssistant, label_registry: lr.LabelRegistry ) -> None: """Make sure that we can't create a label with a ID already in use.""" - update_events = async_capture_events(hass, EVENT_LABEL_REGISTRY_UPDATED) + update_events = async_capture_events(hass, lr.EVENT_LABEL_REGISTRY_UPDATED) label_registry.async_create("mock") with pytest.raises( @@ -94,7 +87,7 @@ async def test_delete_label( hass: HomeAssistant, label_registry: lr.LabelRegistry ) -> None: """Make sure that we can delete a label.""" - update_events = async_capture_events(hass, EVENT_LABEL_REGISTRY_UPDATED) + update_events = async_capture_events(hass, lr.EVENT_LABEL_REGISTRY_UPDATED) label = label_registry.async_create("Label") assert len(label_registry.labels) == 1 @@ -129,7 +122,7 @@ async def test_update_label( hass: HomeAssistant, label_registry: lr.LabelRegistry ) -> None: """Make sure that we can update labels.""" - update_events = async_capture_events(hass, EVENT_LABEL_REGISTRY_UPDATED) + update_events = async_capture_events(hass, lr.EVENT_LABEL_REGISTRY_UPDATED) label = label_registry.async_create("Mock") assert len(label_registry.labels) == 1 @@ -173,7 +166,7 @@ async def test_update_label_with_same_data( hass: HomeAssistant, label_registry: lr.LabelRegistry ) -> None: """Make sure that we can reapply the same data to the label and it won't update.""" - update_events = async_capture_events(hass, EVENT_LABEL_REGISTRY_UPDATED) + update_events = async_capture_events(hass, lr.EVENT_LABEL_REGISTRY_UPDATED) label = label_registry.async_create( "mock", color="#FFFFFF", @@ -201,7 +194,7 @@ async def test_update_label_with_same_data( async def test_update_label_with_same_name_change_case( - hass: HomeAssistant, label_registry: lr.LabelRegistry + label_registry: lr.LabelRegistry, ) -> None: """Make sure that we can reapply the same name with a different case to the label.""" label = label_registry.async_create("mock") @@ -267,7 +260,7 @@ async def test_load_labels( assert len(label_registry.labels) == 2 - registry2 = LabelRegistry(hass) + registry2 = lr.LabelRegistry(hass) await flush_store(label_registry._store) await registry2.async_load() @@ -292,11 +285,11 @@ async def test_load_labels( @pytest.mark.parametrize("load_registries", [False]) async def test_loading_label_from_storage( - hass: HomeAssistant, hass_storage: Any + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored labels on start.""" - hass_storage[STORAGE_KEY] = { - "version": STORAGE_VERSION_MAJOR, + hass_storage[lr.STORAGE_KEY] = { + "version": lr.STORAGE_VERSION_MAJOR, "data": { "labels": [ { @@ -310,8 +303,8 @@ async def test_loading_label_from_storage( }, } - await async_load(hass) - registry = async_get(hass) + await lr.async_load(hass) + registry = lr.async_get(hass) assert len(registry.labels) == 1 @@ -454,3 +447,45 @@ async def test_labels_removed_from_entities( assert len(entries) == 0 entries = er.async_entries_for_label(entity_registry, label2.label_id) assert len(entries) == 0 + + +async def test_async_create_thread_safety( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test async_create raises when called from wrong thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls label_registry.async_create from a thread.", + ): + await hass.async_add_executor_job(label_registry.async_create, "any") + + +async def test_async_delete_thread_safety( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test async_delete raises when called from wrong thread.""" + any_label = label_registry.async_create("any") + + with pytest.raises( + RuntimeError, + match="Detected code that calls label_registry.async_delete from a thread.", + ): + await hass.async_add_executor_job(label_registry.async_delete, any_label) + + +async def test_async_update_thread_safety( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test async_update raises when called from wrong thread.""" + any_label = label_registry.async_create("any") + + with pytest.raises( + RuntimeError, + match="Detected code that calls label_registry.async_update from a thread.", + ): + await hass.async_add_executor_job( + partial(label_registry.async_update, any_label.label_id, name="new name") + ) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py new file mode 100644 index 00000000000..e62d9ffdbee --- /dev/null +++ b/tests/helpers/test_llm.py @@ -0,0 +1,628 @@ +"""Tests for the llm helpers.""" + +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.intent import async_register_timer_handler +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + intent, + llm, +) +from homeassistant.setup import async_setup_component +from homeassistant.util import yaml + +from tests.common import MockConfigEntry + + +@pytest.fixture +def llm_context() -> llm.LLMContext: + """Return tool input context.""" + return llm.LLMContext( + platform="", + context=None, + user_prompt=None, + language=None, + assistant=None, + device_id=None, + ) + + +async def test_get_api_no_existing( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test getting an llm api where no config exists.""" + with pytest.raises(HomeAssistantError): + await llm.async_get_api(hass, "non-existing", llm_context) + + +async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: + """Test registering an llm api.""" + + class MyAPI(llm.API): + async def async_get_api_instance(self, _: llm.ToolInput) -> llm.APIInstance: + """Return a list of tools.""" + return llm.APIInstance(self, "", [], llm_context) + + api = MyAPI(hass=hass, id="test", name="Test") + llm.async_register_api(hass, api) + + instance = await llm.async_get_api(hass, "test", llm_context) + assert instance.api is api + assert api in llm.async_get_apis(hass) + + with pytest.raises(HomeAssistantError): + llm.async_register_api(hass, api) + + +async def test_call_tool_no_existing( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test calling an llm tool where no config exists.""" + instance = await llm.async_get_api(hass, "assist", llm_context) + with pytest.raises(HomeAssistantError): + await instance.async_call_tool( + llm.ToolInput("test_tool", {}), + ) + + +async def test_assist_api( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test Assist API.""" + assert await async_setup_component(hass, "homeassistant", {}) + + entity_registry.async_get_or_create( + "light", + "kitchen", + "mock-id-kitchen", + original_name="Kitchen", + suggested_object_id="kitchen", + ).write_unavailable_state(hass) + + test_context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=test_context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + schema = { + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + + class MyIntentHandler(intent.IntentHandler): + intent_type = "test_intent" + slot_schema = schema + platforms = set() # Match none + + intent_handler = MyIntentHandler() + + intent.async_register(hass, intent_handler) + + assert len(llm.async_get_apis(hass)) == 1 + api = await llm.async_get_api(hass, "assist", llm_context) + assert len(api.tools) == 0 + + # Match all + intent_handler.platforms = None + + api = await llm.async_get_api(hass, "assist", llm_context) + assert len(api.tools) == 1 + + # Match specific domain + intent_handler.platforms = {"light"} + + api = await llm.async_get_api(hass, "assist", llm_context) + assert len(api.tools) == 1 + tool = api.tools[0] + assert tool.name == "test_intent" + assert tool.description == "Execute Home Assistant test_intent intent" + assert tool.parameters == vol.Schema( + { + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + # No preferred_area_id, preferred_floor_id + } + ) + assert str(tool) == "" + + assert test_context.json_fragment # To reproduce an error case in tracing + intent_response = intent.IntentResponse("*") + intent_response.async_set_states( + [State("light.matched", "on")], [State("light.unmatched", "on")] + ) + intent_response.async_set_speech("Some speech") + intent_response.async_set_card("Card title", "card content") + intent_response.async_set_speech_slots({"hello": 1}) + intent_response.async_set_reprompt("Do it again") + tool_input = llm.ToolInput( + tool_name="test_intent", + tool_args={"area": "kitchen", "floor": "ground_floor"}, + ) + + with patch( + "homeassistant.helpers.intent.async_handle", return_value=intent_response + ) as mock_intent_handle: + response = await api.async_call_tool(tool_input) + + mock_intent_handle.assert_awaited_once_with( + hass=hass, + platform="test_platform", + intent_type="test_intent", + slots={ + "area": {"value": "kitchen"}, + "floor": {"value": "ground_floor"}, + }, + text_input="test_text", + context=test_context, + language="*", + assistant="conversation", + device_id=None, + ) + assert response == { + "data": { + "failed": [], + "success": [], + "targets": [], + }, + "reprompt": { + "plain": { + "extra_data": None, + "reprompt": "Do it again", + }, + }, + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Some speech", + }, + }, + "speech_slots": { + "hello": 1, + }, + } + + # Call with a device/area/floor + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "1234")}, + suggested_area="Test Area", + ) + area = area_registry.async_get_area_by_name("Test Area") + floor = floor_registry.async_create("2") + area_registry.async_update(area.id, floor_id=floor.floor_id) + llm_context.device_id = device.id + + with patch( + "homeassistant.helpers.intent.async_handle", return_value=intent_response + ) as mock_intent_handle: + response = await api.async_call_tool(tool_input) + + mock_intent_handle.assert_awaited_once_with( + hass=hass, + platform="test_platform", + intent_type="test_intent", + slots={ + "area": {"value": "kitchen"}, + "floor": {"value": "ground_floor"}, + "preferred_area_id": {"value": area.id}, + "preferred_floor_id": {"value": floor.floor_id}, + }, + text_input="test_text", + context=test_context, + language="*", + assistant="conversation", + device_id=device.id, + ) + assert response == { + "data": { + "failed": [], + "success": [], + "targets": [], + }, + "response_type": "action_done", + "reprompt": { + "plain": { + "extra_data": None, + "reprompt": "Do it again", + }, + }, + "speech": { + "plain": { + "extra_data": None, + "speech": "Some speech", + }, + }, + "speech_slots": { + "hello": 1, + }, + } + + +async def test_assist_api_get_timer_tools( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test getting timer tools with Assist API.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + api = await llm.async_get_api(hass, "assist", llm_context) + + assert "HassStartTimer" not in [tool.name for tool in api.tools] + + llm_context.device_id = "test_device" + + async_register_timer_handler(hass, "test_device", lambda *args: None) + + api = await llm.async_get_api(hass, "assist", llm_context) + assert "HassStartTimer" in [tool.name for tool in api.tools] + + +async def test_assist_api_tools( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test getting timer tools with Assist API.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + llm_context.device_id = "test_device" + + async_register_timer_handler(hass, "test_device", lambda *args: None) + + class MyIntentHandler(intent.IntentHandler): + intent_type = "Super crazy intent with unique nåme" + description = "my intent handler" + + intent.async_register(hass, MyIntentHandler()) + + api = await llm.async_get_api(hass, "assist", llm_context) + assert [tool.name for tool in api.tools] == [ + "HassTurnOn", + "HassTurnOff", + "HassSetPosition", + "HassStartTimer", + "HassCancelTimer", + "HassIncreaseTimer", + "HassDecreaseTimer", + "HassPauseTimer", + "HassUnpauseTimer", + "HassTimerStatus", + "Super_crazy_intent_with_unique_name", + ] + + +async def test_assist_api_description( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test intent description with Assist API.""" + + class MyIntentHandler(intent.IntentHandler): + intent_type = "test_intent" + description = "my intent handler" + + intent.async_register(hass, MyIntentHandler()) + + assert len(llm.async_get_apis(hass)) == 1 + api = await llm.async_get_api(hass, "assist", llm_context) + assert len(api.tools) == 1 + tool = api.tools[0] + assert tool.name == "test_intent" + assert tool.description == "my intent handler" + + +async def test_assist_api_prompt( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, +) -> None: + """Test prompt for the assist API.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert api.api_prompt == ( + "Only if the user wants to control a device, tell them to expose entities to their " + "voice assistant in Home Assistant." + ) + + # Expose entities + + # Create a script with a unique ID + assert await async_setup_component( + hass, + "script", + { + "script": { + "test_script": { + "description": "This is a test script", + "sequence": [], + "fields": { + "beer": {"description": "Number of beers"}, + "wine": {}, + }, + } + } + }, + ) + async_expose_entity(hass, "conversation", "script.test_script", True) + + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "1234")}, + suggested_area="Test Area", + ) + area = area_registry.async_get_area_by_name("Test Area") + area_registry.async_update(area.id, aliases=["Alternative name"]) + entry1 = entity_registry.async_get_or_create( + "light", + "kitchen", + "mock-id-kitchen", + original_name="Kitchen", + suggested_object_id="kitchen", + ) + entry2 = entity_registry.async_get_or_create( + "light", + "living_room", + "mock-id-living-room", + original_name="Living Room", + suggested_object_id="living_room", + device_id=device.id, + ) + hass.states.async_set(entry1.entity_id, "on", {"friendly_name": "Kitchen"}) + hass.states.async_set(entry2.entity_id, "on", {"friendly_name": "Living Room"}) + + def create_entity(device: dr.DeviceEntry, write_state=True) -> None: + """Create an entity for a device and track entity_id.""" + entity = entity_registry.async_get_or_create( + "light", + "test", + device.id, + device_id=device.id, + original_name=str(device.name or "Unnamed Device"), + suggested_object_id=str(device.name or "unnamed_device"), + ) + if write_state: + entity.write_unavailable_state(hass) + + create_entity( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "1234")}, + name="Test Device", + manufacturer="Test Manufacturer", + model="Test Model", + suggested_area="Test Area", + ) + ) + for i in range(3): + create_entity( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", f"{i}abcd")}, + name="Test Service", + manufacturer="Test Manufacturer", + model="Test Model", + suggested_area="Test Area", + entry_type=dr.DeviceEntryType.SERVICE, + ) + ) + create_entity( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "5678")}, + name="Test Device 2", + manufacturer="Test Manufacturer 2", + model="Device 2", + suggested_area="Test Area 2", + ) + ) + create_entity( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "9876")}, + name="Test Device 3", + manufacturer="Test Manufacturer 3", + model="Test Model 3A", + suggested_area="Test Area 2", + ) + ) + create_entity( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "qwer")}, + name="Test Device 4", + suggested_area="Test Area 2", + ) + ) + device2 = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "9876-disabled")}, + name="Test Device 3 - disabled", + manufacturer="Test Manufacturer 3", + model="Test Model 3A", + suggested_area="Test Area 2", + ) + device_registry.async_update_device( + device2.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + create_entity(device2, False) + create_entity( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "9876-no-name")}, + manufacturer="Test Manufacturer NoName", + model="Test Model NoName", + suggested_area="Test Area 2", + ) + ) + create_entity( + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "9876-integer-values")}, + name=1, + manufacturer=2, + model=3, + suggested_area="Test Area 2", + ) + ) + + exposed_entities = llm._get_exposed_entities(hass, llm_context.assistant) + assert exposed_entities == { + "light.1": { + "areas": "Test Area 2", + "names": "1", + "state": "unavailable", + }, + entry1.entity_id: { + "names": "Kitchen", + "state": "on", + }, + entry2.entity_id: { + "areas": "Test Area, Alternative name", + "names": "Living Room", + "state": "on", + }, + "light.test_device": { + "areas": "Test Area, Alternative name", + "names": "Test Device", + "state": "unavailable", + }, + "light.test_device_2": { + "areas": "Test Area 2", + "names": "Test Device 2", + "state": "unavailable", + }, + "light.test_device_3": { + "areas": "Test Area 2", + "names": "Test Device 3", + "state": "unavailable", + }, + "light.test_device_4": { + "areas": "Test Area 2", + "names": "Test Device 4", + "state": "unavailable", + }, + "light.test_service": { + "areas": "Test Area, Alternative name", + "names": "Test Service", + "state": "unavailable", + }, + "light.test_service_2": { + "areas": "Test Area, Alternative name", + "names": "Test Service", + "state": "unavailable", + }, + "light.test_service_3": { + "areas": "Test Area, Alternative name", + "names": "Test Service", + "state": "unavailable", + }, + "light.unnamed_device": { + "areas": "Test Area 2", + "names": "Unnamed Device", + "state": "unavailable", + }, + "script.test_script": { + "description": "This is a test script", + "names": "test_script", + "state": "off", + }, + } + exposed_entities_prompt = ( + "An overview of the areas and the devices in this smart home:\n" + + yaml.dump(exposed_entities) + ) + first_part_prompt = ( + "When controlling Home Assistant always call the intent tools. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " + "When controlling a device, prefer passing just its name and its domain " + "(what comes before the dot in its entity id). " + "When controlling an area, prefer passing just area name and domain." + ) + no_timer_prompt = "This device does not support timers." + + area_prompt = ( + "When a user asks to turn on all devices of a specific type, " + "ask user to specify an area, unless there is only one device of that type." + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert api.api_prompt == ( + f"""{first_part_prompt} +{area_prompt} +{no_timer_prompt} +{exposed_entities_prompt}""" + ) + + # Fake that request is made from a specific device ID with an area + llm_context.device_id = device.id + area_prompt = ( + "You are in area Test Area and all generic commands like 'turn on the lights' " + "should target this area." + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert api.api_prompt == ( + f"""{first_part_prompt} +{area_prompt} +{no_timer_prompt} +{exposed_entities_prompt}""" + ) + + # Add floor + floor = floor_registry.async_create("2") + area_registry.async_update(area.id, floor_id=floor.floor_id) + area_prompt = ( + "You are in area Test Area (floor 2) and all generic commands like 'turn on the lights' " + "should target this area." + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert api.api_prompt == ( + f"""{first_part_prompt} +{area_prompt} +{no_timer_prompt} +{exposed_entities_prompt}""" + ) + + # Register device for timers + async_register_timer_handler(hass, device.id, lambda *args: None) + + api = await llm.async_get_api(hass, "assist", llm_context) + # The no_timer_prompt is gone + assert api.api_prompt == ( + f"""{first_part_prompt} +{area_prompt} +{exposed_entities_prompt}""" + ) diff --git a/tests/helpers/test_normalized_name_base_registry.py b/tests/helpers/test_normalized_name_base_registry.py index 495d147340f..9783e64eeff 100644 --- a/tests/helpers/test_normalized_name_base_registry.py +++ b/tests/helpers/test_normalized_name_base_registry.py @@ -10,12 +10,12 @@ from homeassistant.helpers.normalized_name_base_registry import ( @pytest.fixture -def registry_items(): +def registry_items() -> NormalizedNameBaseRegistryItems: """Fixture for registry items.""" return NormalizedNameBaseRegistryItems[NormalizedNameBaseRegistryEntry]() -def test_normalize_name(): +def test_normalize_name() -> None: """Test normalize_name.""" assert normalize_name("Hello World") == "helloworld" assert normalize_name("HELLO WORLD") == "helloworld" @@ -24,7 +24,7 @@ def test_normalize_name(): def test_registry_items( registry_items: NormalizedNameBaseRegistryItems[NormalizedNameBaseRegistryEntry], -): +) -> None: """Test registry items.""" entry = NormalizedNameBaseRegistryEntry( name="Hello World", normalized_name="helloworld" @@ -46,12 +46,12 @@ def test_registry_items( # test delete entry del registry_items["key"] assert "key" not in registry_items - assert list(registry_items.values()) == [] + assert not registry_items.values() def test_key_already_in_use( registry_items: NormalizedNameBaseRegistryItems[NormalizedNameBaseRegistryEntry], -): +) -> None: """Test key already in use.""" entry = NormalizedNameBaseRegistryEntry( name="Hello World", normalized_name="helloworld" @@ -60,9 +60,9 @@ def test_key_already_in_use( # should raise ValueError if we update a # key with a entry with the same normalized name + entry = NormalizedNameBaseRegistryEntry( + name="Hello World 2", normalized_name="helloworld2" + ) + registry_items["key2"] = entry with pytest.raises(ValueError): - entry = NormalizedNameBaseRegistryEntry( - name="Hello World 2", normalized_name="helloworld2" - ) - registry_items["key2"] = entry registry_items["key"] = entry diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 729212f4c1d..865ee5efaf7 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -484,12 +484,12 @@ async def test_restore_entity_end_to_end( class MockRestoreEntity(RestoreEntity): """Mock restore entity.""" - def __init__(self): + def __init__(self) -> None: """Initialize the mock entity.""" self._state: str | None = None @property - def state(self): + def state(self) -> str | None: """Return the state.""" return self._state diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 4db56a91c11..877e3762d3b 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -68,7 +68,9 @@ def manager_fixture(): return result mgr = FlowManager(None) + # pylint: disable-next=attribute-defined-outside-init mgr.mock_created_entries = entries + # pylint: disable-next=attribute-defined-outside-init mgr.mock_reg_handler = handlers.register return mgr diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 3d662e772e8..08c196a04d3 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -450,7 +450,6 @@ async def test_service_response_data_errors( with pytest.raises(vol.Invalid, match=expected_error): await script_obj.async_run(context=context) - await hass.async_block_till_done() async def test_data_template_with_templated_key(hass: HomeAssistant) -> None: @@ -3538,6 +3537,103 @@ async def test_if_condition_validation( ) +async def test_sequence(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test sequence action.""" + events = async_capture_events(hass, "test_event") + + sequence = cv.SCRIPT_SCHEMA( + [ + { + "alias": "Sequential group", + "sequence": [ + { + "alias": "sequence group, action 1", + "event": "test_event", + "event_data": { + "sequence": "group", + "action": "1", + "what": "{{ what }}", + }, + }, + { + "alias": "sequence group, action 2", + "event": "test_event", + "event_data": { + "sequence": "group", + "action": "2", + "what": "{{ what }}", + }, + }, + ], + }, + { + "alias": "action 2", + "event": "test_event", + "event_data": {"action": "2", "what": "{{ what }}"}, + }, + ] + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(MappingProxyType({"what": "world"}), Context()) + + assert len(events) == 3 + assert events[0].data == { + "sequence": "group", + "action": "1", + "what": "world", + } + assert events[1].data == { + "sequence": "group", + "action": "2", + "what": "world", + } + assert events[2].data == { + "action": "2", + "what": "world", + } + + assert ( + "Test Name: Sequential group: Executing step sequence group, action 1" + in caplog.text + ) + assert ( + "Test Name: Sequential group: Executing step sequence group, action 2" + in caplog.text + ) + assert "Test Name: Executing step action 2" in caplog.text + + expected_trace = { + "0": [{"variables": {"what": "world"}}], + "0/sequence/0": [ + { + "result": { + "event": "test_event", + "event_data": {"sequence": "group", "action": "1", "what": "world"}, + }, + } + ], + "0/sequence/1": [ + { + "result": { + "event": "test_event", + "event_data": {"sequence": "group", "action": "2", "what": "world"}, + }, + } + ], + "1": [ + { + "result": { + "event": "test_event", + "event_data": {"action": "2", "what": "world"}, + }, + } + ], + } + assert_action_trace(expected_trace) + + async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test parallel action.""" events = async_capture_events(hass, "test_event") @@ -4806,15 +4902,15 @@ async def test_script_mode_queued_cancel(hass: HomeAssistant) -> None: assert script_obj.is_running assert script_obj.runs == 2 + task2.cancel() with pytest.raises(asyncio.CancelledError): - task2.cancel() await task2 assert script_obj.is_running assert script_obj.runs == 2 + task1.cancel() with pytest.raises(asyncio.CancelledError): - task1.cancel() await task1 assert not script_obj.is_running @@ -5167,6 +5263,9 @@ async def test_validate_action_config( cv.SCRIPT_ACTION_PARALLEL: { "parallel": [templated_device_action("parallel_event")], }, + cv.SCRIPT_ACTION_SEQUENCE: { + "sequence": [templated_device_action("sequence_event")], + }, cv.SCRIPT_ACTION_SET_CONVERSATION_RESPONSE: { "set_conversation_response": "Hello world" }, @@ -5179,6 +5278,7 @@ async def test_validate_action_config( cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER: None, cv.SCRIPT_ACTION_IF: None, cv.SCRIPT_ACTION_PARALLEL: None, + cv.SCRIPT_ACTION_SEQUENCE: None, } for key in cv.ACTION_TYPE_SCHEMAS: @@ -5764,8 +5864,9 @@ async def test_continue_on_error_unknown_error(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize("enabled_value", [False, "{{ 1 == 9 }}"]) async def test_disabled_actions( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, enabled_value: bool | str ) -> None: """Test disabled action steps.""" events = async_capture_events(hass, "test_event") @@ -5782,10 +5883,14 @@ async def test_disabled_actions( {"event": "test_event"}, { "alias": "Hello", - "enabled": False, + "enabled": enabled_value, "service": "broken.service", }, - {"alias": "World", "enabled": False, "event": "test_event"}, + { + "alias": "World", + "enabled": enabled_value, + "event": "test_event", + }, {"event": "test_event"}, ] ) @@ -5807,6 +5912,37 @@ async def test_disabled_actions( ) +async def test_enabled_error_non_limited_template(hass: HomeAssistant) -> None: + """Test that a script aborts when an action enabled uses non-limited template.""" + await async_setup_component(hass, "homeassistant", {}) + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + [ + { + "event": event, + "enabled": "{{ states('sensor.limited') }}", + } + ] + ) + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + with pytest.raises(exceptions.TemplateError): + await script_obj.async_run(context=Context()) + + assert len(events) == 0 + assert not script_obj.is_running + + expected_trace = { + "0": [ + { + "error": "TemplateError: Use of 'states' is not supported in limited templates" + } + ], + } + assert_action_trace(expected_trace, expected_script_execution="error") + + async def test_condition_and_shorthand( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -6110,3 +6246,72 @@ async def test_stopping_run_before_starting( # would hang indefinitely. run = script._ScriptRun(hass, script_obj, {}, None, True) await run.async_stop() + + +async def test_disallowed_recursion( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test a queued mode script disallowed recursion.""" + context = Context() + calls = 0 + alias = "event step" + sequence1 = cv.SCRIPT_SCHEMA({"alias": alias, "service": "test.call_script_2"}) + script1_obj = script.Script( + hass, + sequence1, + "Test Name1", + "test_domain1", + script_mode="queued", + running_description="test script1", + ) + + sequence2 = cv.SCRIPT_SCHEMA({"alias": alias, "service": "test.call_script_3"}) + script2_obj = script.Script( + hass, + sequence2, + "Test Name2", + "test_domain2", + script_mode="queued", + running_description="test script2", + ) + + sequence3 = cv.SCRIPT_SCHEMA({"alias": alias, "service": "test.call_script_1"}) + script3_obj = script.Script( + hass, + sequence3, + "Test Name3", + "test_domain3", + script_mode="queued", + running_description="test script3", + ) + + async def _async_service_handler_1(*args, **kwargs) -> None: + await script1_obj.async_run(context=context) + + hass.services.async_register("test", "call_script_1", _async_service_handler_1) + + async def _async_service_handler_2(*args, **kwargs) -> None: + await script2_obj.async_run(context=context) + + hass.services.async_register("test", "call_script_2", _async_service_handler_2) + + async def _async_service_handler_3(*args, **kwargs) -> None: + await script3_obj.async_run(context=context) + + hass.services.async_register("test", "call_script_3", _async_service_handler_3) + + await script1_obj.async_run(context=context) + await hass.async_block_till_done() + + assert calls == 0 + assert ( + "Test Name1: Disallowed recursion detected, " + "test_domain3.Test Name3 tried to start test_domain1.Test Name1" + " which is already running in the current execution path; " + "Traceback (most recent call last):" + ) in caplog.text + assert ( + "- test_domain1.Test Name1\n" + "- test_domain2.Test Name2\n" + "- test_domain3.Test Name3" + ) in caplog.text diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 8864edc7386..6db313baa24 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -282,6 +282,8 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"filter": [{"supported_features": ["blah"]}]}, # Unknown feature enum {"filter": [{"supported_features": ["blah.FooEntityFeature.blah"]}]}, + # Unknown feature enum + {"filter": [{"supported_features": ["light.FooEntityFeature.blah"]}]}, # Unknown feature enum member {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, ], @@ -743,6 +745,11 @@ def test_attribute_selector_schema( ({"seconds": 10}, {"days": 10}), (None, {}), ), + ( + {"allow_negative": False}, + ({"seconds": 10}, {"days": 10}), + (None, {}, {"seconds": -1}), + ), ], ) def test_duration_selector_schema(schema, valid_selections, invalid_selections) -> None: diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e32768ee33e..60fe87db9d2 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterable from copy import deepcopy +import io from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -43,13 +44,16 @@ from homeassistant.helpers import ( import homeassistant.helpers.config_validation as cv from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util.yaml.loader import parse_yaml from tests.common import ( MockEntity, + MockModule, MockUser, async_mock_service, mock_area_registry, mock_device_registry, + mock_integration, mock_registry, ) @@ -800,11 +804,10 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert proxy_load_services_files.mock_calls[0][1][1] == unordered( [ await async_get_integration(hass, DOMAIN_GROUP), - await async_get_integration(hass, "http"), # system_health requires http ] ) - assert len(descriptions) == 2 + assert len(descriptions) == 1 assert DOMAIN_GROUP in descriptions assert "description" in descriptions[DOMAIN_GROUP]["reload"] assert "fields" in descriptions[DOMAIN_GROUP]["reload"] @@ -838,7 +841,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN_LOGGER, logger_config) descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 3 + assert len(descriptions) == 2 assert DOMAIN_LOGGER in descriptions assert descriptions[DOMAIN_LOGGER]["set_default_level"]["name"] == "Translated name" assert ( @@ -917,6 +920,57 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert await service.async_get_all_descriptions(hass) is descriptions +async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: + """Test async_get_all_descriptions with keys starting with a period.""" + service_descriptions = """ + .anchor: &anchor + selector: + text: + test_service: + fields: + test: *anchor + """ + + domain = "test_domain" + + hass.services.async_register(domain, "test_service", lambda call: None) + mock_integration(hass, MockModule(domain), top_level_files={"services.yaml"}) + assert await async_setup_component(hass, domain, {}) + + def load_yaml(fname, secrets=None): + with io.StringIO(service_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "homeassistant.helpers.service._load_services_files", + side_effect=service._load_services_files, + ) as proxy_load_services_files, + patch( + "homeassistant.util.yaml.loader.load_yaml", + side_effect=load_yaml, + ) as mock_load_yaml, + ): + descriptions = await service.async_get_all_descriptions(hass) + + mock_load_yaml.assert_called_once_with("services.yaml", None) + assert proxy_load_services_files.mock_calls[0][1][1] == unordered( + [ + await async_get_integration(hass, domain), + ] + ) + + assert descriptions == { + "test_domain": { + "test_service": { + "description": "", + "fields": {"test": {"selector": {"text": None}}}, + "name": "", + } + } + } + + async def test_async_get_all_descriptions_failing_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py index e930ff30feb..f9dca5b6034 100644 --- a/tests/helpers/test_significant_change.py +++ b/tests/helpers/test_significant_change.py @@ -9,7 +9,9 @@ from homeassistant.helpers import significant_change @pytest.fixture(name="checker") -async def checker_fixture(hass): +async def checker_fixture( + hass: HomeAssistant, +) -> significant_change.SignificantlyChangedChecker: """Checker fixture.""" checker = await significant_change.create_checker(hass, "test") @@ -24,7 +26,9 @@ async def checker_fixture(hass): return checker -async def test_signicant_change(hass: HomeAssistant, checker) -> None: +async def test_signicant_change( + checker: significant_change.SignificantlyChangedChecker, +) -> None: """Test initialize helper works.""" ent_id = "test_domain.test_entity" attrs = {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} @@ -48,7 +52,9 @@ async def test_signicant_change(hass: HomeAssistant, checker) -> None: assert checker.async_is_significant_change(State(ent_id, STATE_UNAVAILABLE, attrs)) -async def test_significant_change_extra(hass: HomeAssistant, checker) -> None: +async def test_significant_change_extra( + checker: significant_change.SignificantlyChangedChecker, +) -> None: """Test extra significant checker works.""" ent_id = "test_domain.test_entity" attrs = {ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY} @@ -75,7 +81,7 @@ async def test_significant_change_extra(hass: HomeAssistant, checker) -> None: assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=2) -async def test_check_valid_float(hass: HomeAssistant) -> None: +async def test_check_valid_float() -> None: """Test extra significant checker works.""" assert significant_change.check_valid_float("1") assert significant_change.check_valid_float("1.0") diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 12dc56db85d..822b56604c0 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -40,13 +40,13 @@ MOCK_DATA2 = {"goodbye": "cruel world"} @pytest.fixture -def store(hass): +def store(hass: HomeAssistant) -> storage.Store: """Fixture of a store that prevents writing on Home Assistant stop.""" return storage.Store(hass, MOCK_VERSION, MOCK_KEY) @pytest.fixture -def store_v_1_1(hass): +def store_v_1_1(hass: HomeAssistant) -> storage.Store: """Fixture of a store that prevents writing on Home Assistant stop.""" return storage.Store( hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1 @@ -54,7 +54,7 @@ def store_v_1_1(hass): @pytest.fixture -def store_v_1_2(hass): +def store_v_1_2(hass: HomeAssistant) -> storage.Store: """Fixture of a store that prevents writing on Home Assistant stop.""" return storage.Store( hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_2 @@ -62,7 +62,7 @@ def store_v_1_2(hass): @pytest.fixture -def store_v_2_1(hass): +def store_v_2_1(hass: HomeAssistant) -> storage.Store: """Fixture of a store that prevents writing on Home Assistant stop.""" return storage.Store( hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1 @@ -70,12 +70,12 @@ def store_v_2_1(hass): @pytest.fixture -def read_only_store(hass): +def read_only_store(hass: HomeAssistant) -> storage.Store: """Fixture of a read only store.""" return storage.Store(hass, MOCK_VERSION, MOCK_KEY, read_only=True) -async def test_loading(hass: HomeAssistant, store) -> None: +async def test_loading(hass: HomeAssistant, store: storage.Store) -> None: """Test we can save and load data.""" await store.async_save(MOCK_DATA) data = await store.async_load() @@ -100,7 +100,7 @@ async def test_custom_encoder(hass: HomeAssistant) -> None: assert data == "9" -async def test_loading_non_existing(hass: HomeAssistant, store) -> None: +async def test_loading_non_existing(hass: HomeAssistant, store: storage.Store) -> None: """Test we can save and load data.""" with patch("homeassistant.util.json.open", side_effect=FileNotFoundError): data = await store.async_load() @@ -109,7 +109,7 @@ async def test_loading_non_existing(hass: HomeAssistant, store) -> None: async def test_loading_parallel( hass: HomeAssistant, - store, + store: storage.Store, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture, ) -> None: @@ -292,7 +292,7 @@ async def test_not_saving_while_stopping( async def test_loading_while_delay( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test we load new data even if not written yet.""" await store.async_save({"delay": "no"}) @@ -316,7 +316,7 @@ async def test_loading_while_delay( async def test_writing_while_writing_delay( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test a write while a write with delay is active.""" store.async_delay_save(lambda: {"delay": "yes"}, 1) @@ -343,7 +343,7 @@ async def test_writing_while_writing_delay( async def test_multiple_delay_save_calls( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test a write while a write with changing delays.""" store.async_delay_save(lambda: {"delay": "yes"}, 1) @@ -390,7 +390,7 @@ async def test_delay_save_zero( async def test_multiple_save_calls( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test multiple write tasks.""" @@ -410,7 +410,7 @@ async def test_multiple_save_calls( async def test_migrator_no_existing_config( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test migrator with no existing config.""" with ( @@ -424,7 +424,7 @@ async def test_migrator_no_existing_config( async def test_migrator_existing_config( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test migrating existing config.""" with patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove: @@ -443,7 +443,7 @@ async def test_migrator_existing_config( async def test_migrator_transforming_config( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test migrating config to new format.""" @@ -471,7 +471,7 @@ async def test_migrator_transforming_config( async def test_minor_version_default( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test minor version default.""" @@ -480,7 +480,7 @@ async def test_minor_version_default( async def test_minor_version( - hass: HomeAssistant, store_v_1_2, hass_storage: dict[str, Any] + hass: HomeAssistant, store_v_1_2: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test minor version.""" @@ -489,7 +489,7 @@ async def test_minor_version( async def test_migrate_major_not_implemented_raises( - hass: HomeAssistant, store, store_v_2_1 + hass: HomeAssistant, store: storage.Store, store_v_2_1: storage.Store ) -> None: """Test migrating between major versions fails if not implemented.""" @@ -499,7 +499,10 @@ async def test_migrate_major_not_implemented_raises( async def test_migrate_minor_not_implemented( - hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_1, store_v_1_2 + hass: HomeAssistant, + hass_storage: dict[str, Any], + store_v_1_1: storage.Store, + store_v_1_2: storage.Store, ) -> None: """Test migrating between minor versions does not fail if not implemented.""" @@ -525,7 +528,7 @@ async def test_migrate_minor_not_implemented( async def test_migration( - hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2 + hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2: storage.Store ) -> None: """Test migration.""" calls = 0 @@ -564,7 +567,7 @@ async def test_migration( async def test_legacy_migration( - hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2 + hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2: storage.Store ) -> None: """Test legacy migration method signature.""" calls = 0 @@ -600,7 +603,7 @@ async def test_legacy_migration( async def test_changing_delayed_written_data( - hass: HomeAssistant, store, hass_storage: dict[str, Any] + hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any] ) -> None: """Test changing data that is written with delay.""" data_to_store = {"hello": "world"} @@ -684,7 +687,7 @@ async def test_loading_corrupt_core_file( assert data == {"hello": "world"} def _corrupt_store(): - with open(store_file, "w") as f: + with open(store_file, "w", encoding="utf8") as f: f.write("corrupt") await hass.async_add_executor_job(_corrupt_store) @@ -745,7 +748,7 @@ async def test_loading_corrupt_file_known_domain( assert data == {"hello": "world"} def _corrupt_store(): - with open(store_file, "w") as f: + with open(store_file, "w", encoding="utf8") as f: f.write('{"valid":"json"}..with..corrupt') await hass.async_add_executor_job(_corrupt_store) @@ -1159,3 +1162,21 @@ async def test_store_manager_cleanup_after_stop( assert store_manager.async_fetch("integration1") is None assert store_manager.async_fetch("integration2") is None await hass.async_stop(force=True) + + +async def test_storage_concurrent_load(hass: HomeAssistant) -> None: + """Test that we can load the store concurrently.""" + + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + + async def _load_store(): + await asyncio.sleep(0) + return "data" + + with patch.object(store, "_async_load", side_effect=_load_store): + # Test that we can load the store concurrently + loads = await asyncio.gather( + store.async_load(), store.async_load(), store.async_load() + ) + for load in loads: + assert load == "data" diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index da436d799aa..54c26997422 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -2,6 +2,8 @@ from datetime import datetime, timedelta +from astral import LocationInfo +import astral.sun from freezegun import freeze_time import pytest @@ -14,8 +16,6 @@ import homeassistant.util.dt as dt_util def test_next_events(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - from astral import LocationInfo - import astral.sun utc_today = utc_now.date() @@ -89,8 +89,6 @@ def test_next_events(hass: HomeAssistant) -> None: def test_date_events(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - from astral import LocationInfo - import astral.sun utc_today = utc_now.date() @@ -116,8 +114,6 @@ def test_date_events(hass: HomeAssistant) -> None: def test_date_events_default_date(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - from astral import LocationInfo - import astral.sun utc_today = utc_now.date() @@ -144,8 +140,6 @@ def test_date_events_default_date(hass: HomeAssistant) -> None: def test_date_events_accepts_datetime(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) - from astral import LocationInfo - import astral.sun utc_today = utc_now.date() diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index ae9dcbe50d5..26e4f986592 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -18,7 +18,6 @@ import pytest import voluptuous as vol from homeassistant.components import group -from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, @@ -119,6 +118,33 @@ def assert_result_info( assert not hasattr(info, "_domains") +async def test_template_render_missing_hass(hass: HomeAssistant) -> None: + """Test template render when hass is not set.""" + hass.states.async_set("sensor.test", "23") + template_str = "{{ states('sensor.test') }}" + template_obj = template.Template(template_str, None) + template._render_info.set(template.RenderInfo(template_obj)) + + with pytest.raises(RuntimeError, match="hass not set while rendering"): + template_obj.async_render_to_info() + + +async def test_template_render_info_collision(hass: HomeAssistant) -> None: + """Test template render info collision. + + This usually means the template is being rendered + in the wrong thread. + """ + hass.states.async_set("sensor.test", "23") + template_str = "{{ states('sensor.test') }}" + template_obj = template.Template(template_str, None) + template_obj.hass = hass + template._render_info.set(template.RenderInfo(template_obj)) + + with pytest.raises(RuntimeError, match="RenderInfo already set while rendering"): + template_obj.async_render_to_info() + + def test_template_equality() -> None: """Test template comparison and hashing.""" template_one = template.Template("{{ template_one }}") @@ -721,6 +747,25 @@ def test_multiply(hass: HomeAssistant) -> None: assert render(hass, "{{ 'no_number' | multiply(10, default=1) }}") == 1 +def test_add(hass: HomeAssistant) -> None: + """Test add.""" + tests = {10: 42} + + for inp, out in tests.items(): + assert ( + template.Template(f"{{{{ {inp} | add(32) | round }}}}", hass).async_render() + == out + ) + + # Test handling of invalid input + with pytest.raises(TemplateError): + template.Template("{{ abcd | add(10) }}", hass).async_render() + + # Test handling of default return value + assert render(hass, "{{ 'no_number' | add(10, 1) }}") == 1 + assert render(hass, "{{ 'no_number' | add(10, default=1) }}") == 1 + + def test_logarithm(hass: HomeAssistant) -> None: """Test logarithm.""" tests = [ @@ -1089,9 +1134,9 @@ def test_strptime(hass: HomeAssistant) -> None: assert render(hass, "{{ strptime('invalid', '%Y', default=1) }}") == 1 -def test_timestamp_custom(hass: HomeAssistant) -> None: +async def test_timestamp_custom(hass: HomeAssistant) -> None: """Test the timestamps to custom filter.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") now = dt_util.utcnow() tests = [ (1469119144, None, True, "2016-07-21 16:39:04"), @@ -1131,9 +1176,9 @@ def test_timestamp_custom(hass: HomeAssistant) -> None: assert render(hass, "{{ None | timestamp_custom(default=1) }}") == 1 -def test_timestamp_local(hass: HomeAssistant) -> None: +async def test_timestamp_local(hass: HomeAssistant) -> None: """Test the timestamps to local filter.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") tests = [ (1469119144, "2016-07-21T16:39:04+00:00"), ] @@ -1597,6 +1642,18 @@ def test_base64_decode(hass: HomeAssistant) -> None: ).async_render() == "homeassistant" ) + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode(None) }}', hass + ).async_render() + == b"homeassistant" + ) + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode("ascii") }}', hass + ).async_render() + == "homeassistant" + ) def test_slugify(hass: HomeAssistant) -> None: @@ -2009,7 +2066,7 @@ def test_states_function(hass: HomeAssistant) -> None: async def test_state_translated( hass: HomeAssistant, entity_registry: er.EntityRegistry -): +) -> None: """Test state_translated method.""" assert await async_setup_component( hass, @@ -2206,14 +2263,14 @@ def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_today_at( +async def test_today_at( mock_is_safe, hass: HomeAssistant, now, expected, expected_midnight, timezone_str ) -> None: """Test today_at method.""" freezer = freeze_time(now) freezer.start() - hass.config.set_time_zone(timezone_str) + await hass.config.async_set_time_zone(timezone_str) result = template.Template( "{{ today_at('10:00').isoformat() }}", @@ -2254,9 +2311,9 @@ def test_today_at( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: +async def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' @@ -2361,9 +2418,9 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_time_since(mock_is_safe, hass: HomeAssistant) -> None: +async def test_time_since(mock_is_safe, hass: HomeAssistant) -> None: """Test time_since method.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") time_since_template = ( '{{time_since(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' @@ -2524,9 +2581,9 @@ def test_time_since(mock_is_safe, hass: HomeAssistant) -> None: "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, ) -def test_time_until(mock_is_safe, hass: HomeAssistant) -> None: +async def test_time_until(mock_is_safe, hass: HomeAssistant) -> None: """Test time_until method.""" - hass.config.set_time_zone("UTC") + await hass.config.async_set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") time_until_template = ( '{{time_until(strptime("2000-01-01 11:00:00", "%Y-%m-%d %H:%M:%S"))}}' @@ -2754,7 +2811,7 @@ def test_version(hass: HomeAssistant) -> None: "{{ version('2099.9.9') < '2099.9.10' }}", hass, ).async_render() - assert filter_result == function_result is True + assert filter_result is function_result is True filter_result = template.Template( "{{ '2099.9.9' | version == '2099.9.9' }}", @@ -2764,7 +2821,7 @@ def test_version(hass: HomeAssistant) -> None: "{{ version('2099.9.9') == '2099.9.9' }}", hass, ).async_render() - assert filter_result == function_result is True + assert filter_result is function_result is True with pytest.raises(TemplateError): template.Template( @@ -3821,8 +3878,8 @@ async def test_device_attr( assert_result_info(info, None) assert info.rate_limit is None + info = render_to_info(hass, "{{ device_attr(56, 'id') }}") with pytest.raises(TemplateError): - info = render_to_info(hass, "{{ device_attr(56, 'id') }}") assert_result_info(info, None) # Test non existing device ids (is_device_attr) @@ -3830,8 +3887,8 @@ async def test_device_attr( assert_result_info(info, False) assert info.rate_limit is None + info = render_to_info(hass, "{{ is_device_attr(56, 'id', 'test') }}") with pytest.raises(TemplateError): - info = render_to_info(hass, "{{ is_device_attr(56, 'id', 'test') }}") assert_result_info(info, False) # Test non existing entity id (device_attr) @@ -5344,22 +5401,6 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: assert tpl.async_render() == "light.none, light.unavailable, light.unknown" -async def test_legacy_templates(hass: HomeAssistant) -> None: - """Test if old template behavior works when legacy templates are enabled.""" - hass.states.async_set("sensor.temperature", "12") - - assert ( - template.Template("{{ states.sensor.temperature.state }}", hass).async_render() - == 12 - ) - - await async_process_ha_core_config(hass, {"legacy_templates": True}) - assert ( - template.Template("{{ states.sensor.temperature.state }}", hass).async_render() - == "12" - ) - - async def test_no_result_parsing(hass: HomeAssistant) -> None: """Test if templates results are not parsed.""" hass.states.async_set("sensor.temperature", "12") diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index b841e1ab5ac..73cd243a0c6 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -1,7 +1,6 @@ """Test the translation helper.""" import asyncio -from os import path import pathlib from typing import Any from unittest.mock import Mock, call, patch @@ -12,10 +11,14 @@ from homeassistant import loader from homeassistant.const import EVENT_CORE_CONFIG_UPDATE from homeassistant.core import HomeAssistant from homeassistant.helpers import translation -from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +@pytest.fixture(autouse=True) +def _disable_translations_once(disable_translations_once: None) -> None: + """Override loading translations once.""" + + @pytest.fixture def mock_config_flows(): """Mock the config flows.""" @@ -37,26 +40,7 @@ def test_recursive_flatten() -> None: } -async def test_component_translation_path( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: - """Test the component translation file function.""" - assert await async_setup_component( - hass, - "switch", - {"switch": [{"platform": "test"}, {"platform": "test_embedded"}]}, - ) - assert await async_setup_component(hass, "test_package", {"test_package": None}) - int_test_package = await async_get_integration(hass, "test_package") - - assert path.normpath( - translation.component_translation_path("en", int_test_package) - ) == path.normpath( - hass.config.path("custom_components", "test_package", "translations", "en.json") - ) - - -def test__load_translations_files_by_language( +def test_load_translations_files_by_language( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test the load translation files function.""" @@ -142,9 +126,9 @@ def test__load_translations_files_by_language( ), ], ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_load_translations_files_invalid_localized_placeholders( hass: HomeAssistant, - enable_custom_integrations: None, caplog: pytest.LogCaptureFixture, language: str, expected_translation: dict, @@ -167,9 +151,8 @@ async def test_load_translations_files_invalid_localized_placeholders( ) -async def test_get_translations( - hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_translations(hass: HomeAssistant, mock_config_flows) -> None: """Test the get translations helper.""" translations = await translation.async_get_translations(hass, "en", "entity") assert translations == {} @@ -237,10 +220,6 @@ async def test_get_translations_loads_config_flows( integration.name = "Component 1" with ( - patch( - "homeassistant.helpers.translation.component_translation_path", - return_value="bla.json", - ), patch( "homeassistant.helpers.translation._load_translations_files_by_language", return_value={"en": {"component1": {"title": "world"}}}, @@ -270,10 +249,6 @@ async def test_get_translations_loads_config_flows( integration.name = "Component 2" with ( - patch( - "homeassistant.helpers.translation.component_translation_path", - return_value="bla.json", - ), patch( "homeassistant.helpers.translation._load_translations_files_by_language", return_value={"en": {"component2": {"title": "world"}}}, @@ -324,10 +299,6 @@ async def test_get_translations_while_loading_components(hass: HomeAssistant) -> return {language: {"component1": {"title": "world"}} for language in files} with ( - patch( - "homeassistant.helpers.translation.component_translation_path", - return_value="bla.json", - ), patch( "homeassistant.helpers.translation._load_translations_files_by_language", mock_load_translation_files, @@ -454,10 +425,10 @@ async def test_caching(hass: HomeAssistant) -> None: side_effect=translation.build_resources, ) as mock_build_resources: load1 = await translation.async_get_translations(hass, "en", "entity_component") - assert len(mock_build_resources.mock_calls) == 5 + assert len(mock_build_resources.mock_calls) == 6 load2 = await translation.async_get_translations(hass, "en", "entity_component") - assert len(mock_build_resources.mock_calls) == 5 + assert len(mock_build_resources.mock_calls) == 6 assert load1 == load2 @@ -512,18 +483,16 @@ async def test_caching(hass: HomeAssistant) -> None: assert len(mock_build.mock_calls) > 1 -async def test_custom_component_translations( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_custom_component_translations(hass: HomeAssistant) -> None: """Test getting translation from custom components.""" hass.config.components.add("test_embedded") hass.config.components.add("test_package") assert await translation.async_get_translations(hass, "en", "state") == {} -async def test_get_cached_translations( - hass: HomeAssistant, mock_config_flows, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_cached_translations(hass: HomeAssistant, mock_config_flows) -> None: """Test the get cached translations helper.""" translations = await translation.async_get_translations(hass, "en", "entity") assert translations == {} @@ -577,7 +546,7 @@ async def test_get_cached_translations( } -async def test_setup(hass: HomeAssistant): +async def test_setup(hass: HomeAssistant) -> None: """Test the setup load listeners helper.""" translation.async_setup(hass) @@ -605,7 +574,7 @@ async def test_setup(hass: HomeAssistant): mock.assert_not_called() -async def test_translate_state(hass: HomeAssistant): +async def test_translate_state(hass: HomeAssistant) -> None: """Test the state translation helper.""" result = translation.async_translate_state( hass, "unavailable", "binary_sensor", "platform", "translation_key", None @@ -692,10 +661,6 @@ async def test_get_translations_still_has_title_without_translations_files( integration.name = "Component 1" with ( - patch( - "homeassistant.helpers.translation.component_translation_path", - return_value="bla.json", - ), patch( "homeassistant.helpers.translation._load_translations_files_by_language", return_value={}, diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 0a15cf9a330..0bd5da0707c 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -15,14 +15,6 @@ from homeassistant.helpers.trigger import ( ) from homeassistant.setup import async_setup_component -from tests.common import async_mock_service - - -@pytest.fixture -def calls(hass): - """Track calls to a mock service.""" - return async_mock_service(hass, "test", "automation") - async def test_bad_trigger_platform(hass: HomeAssistant) -> None: """Test bad trigger platform.""" @@ -45,7 +37,9 @@ async def test_trigger_variables(hass: HomeAssistant) -> None: """Test trigger variables.""" -async def test_if_fires_on_event(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_event( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: """Test the firing of events.""" assert await async_setup_component( hass, @@ -70,12 +64,12 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls) -> None: hass.bus.async_fire("test_event") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["hello"] == "Paulus + test_event" + assert len(service_calls) == 1 + assert service_calls[0].data["hello"] == "Paulus + test_event" async def test_if_disabled_trigger_not_firing( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, service_calls: list[ServiceCall] ) -> None: """Test disabled triggers don't fire.""" assert await async_setup_component( @@ -103,15 +97,103 @@ async def test_if_disabled_trigger_not_firing( hass.bus.async_fire("disabled_trigger_event") await hass.async_block_till_done() - assert not calls + assert not service_calls hass.bus.async_fire("enabled_trigger_event") await hass.async_block_till_done() - assert len(calls) == 1 + assert len(service_calls) == 1 + + +async def test_trigger_enabled_templates( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test triggers enabled by template.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": [ + { + "enabled": "{{ 'some text' }}", + "platform": "event", + "event_type": "truthy_template_trigger_event", + }, + { + "enabled": "{{ 3 == 4 }}", + "platform": "event", + "event_type": "falsy_template_trigger_event", + }, + { + "enabled": False, # eg. from a blueprints input defaulting to `false` + "platform": "event", + "event_type": "falsy_trigger_event", + }, + { + "enabled": "some text", # eg. from a blueprints input value + "platform": "event", + "event_type": "truthy_trigger_event", + }, + ], + "action": { + "service": "test.automation", + }, + } + }, + ) + + hass.bus.async_fire("falsy_template_trigger_event") + await hass.async_block_till_done() + assert not service_calls + + hass.bus.async_fire("falsy_trigger_event") + await hass.async_block_till_done() + assert not service_calls + + hass.bus.async_fire("truthy_template_trigger_event") + await hass.async_block_till_done() + assert len(service_calls) == 1 + + hass.bus.async_fire("truthy_trigger_event") + await hass.async_block_till_done() + assert len(service_calls) == 2 + + +async def test_trigger_enabled_template_limited( + hass: HomeAssistant, + service_calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test triggers enabled invalid template.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": [ + { + "enabled": "{{ states('sensor.limited') }}", # only limited template supported + "platform": "event", + "event_type": "test_event", + }, + ], + "action": { + "service": "test.automation", + }, + } + }, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert not service_calls + assert "Error rendering enabled template" in caplog.text async def test_trigger_alias( - hass: HomeAssistant, calls: list[ServiceCall], caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + service_calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, ) -> None: """Test triggers support aliases.""" assert await async_setup_component( @@ -136,8 +218,8 @@ async def test_trigger_alias( hass.bus.async_fire("trigger_event") await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["alias"] == "My event" + assert len(service_calls) == 1 + assert service_calls[0].data["alias"] == "My event" assert ( "Automation trigger 'My event' triggered by event 'trigger_event'" in caplog.text @@ -145,7 +227,9 @@ async def test_trigger_alias( async def test_async_initialize_triggers( - hass: HomeAssistant, calls: list[ServiceCall], caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + service_calls: list[ServiceCall], + caplog: pytest.LogCaptureFixture, ) -> None: """Test async_initialize_triggers with different action types.""" @@ -203,7 +287,9 @@ async def test_async_initialize_triggers( unsub() -async def test_pluggable_action(hass: HomeAssistant, calls: list[ServiceCall]): +async def test_pluggable_action( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: """Test normal behavior of pluggable actions.""" update_1 = MagicMock() update_2 = MagicMock() diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index aaf6cbe3efe..c8388207af4 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -13,6 +13,18 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [ "tests.helpers.test_event", "test_track_point_in_time_repr", ), + ( + # This test explicitly throws an uncaught exception + # and should not be removed. + "tests.test_config_entries", + "test_config_entry_unloaded_during_platform_setups", + ), + ( + # This test explicitly throws an uncaught exception + # and should not be removed. + "tests.test_config_entries", + "test_config_entry_unloaded_during_platform_setup", + ), ( "test_homeassistant_bridge", "test_homeassistant_bridge_fan_setup", diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index ad3b7d62be9..5b1c494568d 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -54,6 +54,7 @@ def test_regex_get_module_platform( ("list[dict[str, str]]", 1, ("list", "dict[str, str]")), ("list[dict[str, Any]]", 1, ("list", "dict[str, Any]")), ("tuple[bytes | None, str | None]", 2, ("tuple", "bytes | None", "str | None")), + ("Callable[[], TestServer]", 2, ("Callable", "[]", "TestServer")), ], ) def test_regex_x_of_y_i( @@ -1130,12 +1131,14 @@ def test_notify_get_service( def test_pytest_function( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: - """Ensure valid hints are accepted for async_get_service.""" + """Ensure valid hints are accepted for a test function.""" func_node = astroid.extract_node( """ async def test_sample( #@ hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + aiohttp_server: Callable[[], TestServer], + unused_tcp_port_factory: Callable[[], int], ) -> None: pass """, @@ -1149,33 +1152,50 @@ def test_pytest_function( type_hint_checker.visit_asyncfunctiondef(func_node) -def test_pytest_invalid_function( +def test_pytest_nested_function( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: - """Ensure invalid hints are rejected for async_get_service.""" - func_node, hass_node, caplog_node = astroid.extract_node( + """Ensure valid hints are accepted for a test function.""" + func_node, nested_func_node = astroid.extract_node( """ - async def test_sample( #@ - hass: Something, #@ - caplog: SomethingElse, #@ - ) -> Anything: - pass + async def some_function( #@ + ): + def test_value(value: str) -> bool: #@ + return value == "Yes" + return test_value """, "tests.components.pylint_test.notify", ) type_hint_checker.visit_module(func_node.parent) + with assert_no_messages( + linter, + ): + type_hint_checker.visit_asyncfunctiondef(nested_func_node) + + +def test_pytest_invalid_function( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure invalid hints are rejected for a test function.""" + func_node, hass_node, caplog_node, first_none_node, second_none_node = ( + astroid.extract_node( + """ + async def test_sample( #@ + hass: Something, #@ + caplog: SomethingElse, #@ + current_request_with_host, #@ + enable_custom_integrations: None, #@ + ) -> Anything: + pass + """, + "tests.components.pylint_test.notify", + ) + ) + type_hint_checker.visit_module(func_node.parent) + with assert_adds_messages( linter, - pylint.testutils.MessageTest( - msg_id="hass-argument-type", - node=hass_node, - args=("hass", ["HomeAssistant", "HomeAssistant | None"], "test_sample"), - line=3, - col_offset=4, - end_line=3, - end_col_offset=19, - ), pylint.testutils.MessageTest( msg_id="hass-return-type", node=func_node, @@ -1194,6 +1214,122 @@ def test_pytest_invalid_function( end_line=4, end_col_offset=25, ), + pylint.testutils.MessageTest( + msg_id="hass-consider-usefixtures-decorator", + node=first_none_node, + args=("current_request_with_host", "None", "test_sample"), + line=5, + col_offset=4, + end_line=5, + end_col_offset=29, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=first_none_node, + args=("current_request_with_host", "None", "test_sample"), + line=5, + col_offset=4, + end_line=5, + end_col_offset=29, + ), + pylint.testutils.MessageTest( + msg_id="hass-consider-usefixtures-decorator", + node=second_none_node, + args=("enable_custom_integrations", "None", "test_sample"), + line=6, + col_offset=4, + end_line=6, + end_col_offset=36, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=hass_node, + args=("hass", "HomeAssistant", "test_sample"), + line=3, + col_offset=4, + end_line=3, + end_col_offset=19, + ), + ): + type_hint_checker.visit_asyncfunctiondef(func_node) + + +def test_pytest_fixture(linter: UnittestLinter, type_hint_checker: BaseChecker) -> None: + """Ensure valid hints are accepted for a test fixture.""" + func_node = astroid.extract_node( + """ + import pytest + + @pytest.fixture + def sample_fixture( #@ + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aiohttp_server: Callable[[], TestServer], + unused_tcp_port_factory: Callable[[], int], + enable_custom_integrations: None, + ) -> None: + pass + """, + "tests.components.pylint_test.notify", + ) + type_hint_checker.visit_module(func_node.parent) + + with assert_no_messages( + linter, + ): + type_hint_checker.visit_asyncfunctiondef(func_node) + + +@pytest.mark.parametrize("decorator", ["@pytest.fixture", "@pytest.fixture()"]) +def test_pytest_invalid_fixture( + linter: UnittestLinter, type_hint_checker: BaseChecker, decorator: str +) -> None: + """Ensure invalid hints are rejected for a test fixture.""" + func_node, hass_node, caplog_node, none_node = astroid.extract_node( + f""" + import pytest + + {decorator} + def sample_fixture( #@ + hass: Something, #@ + caplog: SomethingElse, #@ + current_request_with_host, #@ + ) -> Any: + pass + """, + "tests.components.pylint_test.notify", + ) + type_hint_checker.visit_module(func_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=caplog_node, + args=("caplog", "pytest.LogCaptureFixture", "sample_fixture"), + line=7, + col_offset=4, + end_line=7, + end_col_offset=25, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=none_node, + args=("current_request_with_host", "None", "sample_fixture"), + line=8, + col_offset=4, + end_line=8, + end_col_offset=29, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=hass_node, + args=("hass", "HomeAssistant", "sample_fixture"), + line=6, + col_offset=4, + end_line=6, + end_col_offset=19, + ), ): type_hint_checker.visit_asyncfunctiondef(func_node) diff --git a/tests/pylint/test_imports.py b/tests/pylint/test_imports.py index 5f1d4d86840..e53b8206848 100644 --- a/tests/pylint/test_imports.py +++ b/tests/pylint/test_imports.py @@ -252,3 +252,60 @@ def test_bad_root_import( imports_checker.visit_import(node) if import_node.startswith("from"): imports_checker.visit_importfrom(node) + + +@pytest.mark.parametrize( + ("import_node", "module_name", "expected_args"), + [ + ( + "from homeassistant.helpers.issue_registry import async_get", + "tests.components.pylint_test.climate", + ( + "async_get", + "homeassistant.helpers.issue_registry", + "ir", + "ir", + "async_get", + ), + ), + ( + "from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry", + "tests.components.pylint_test.climate", + ( + "async_get", + "homeassistant.helpers.issue_registry", + "ir", + "ir", + "async_get", + ), + ), + ], +) +def test_bad_namespace_import( + linter: UnittestLinter, + imports_checker: BaseChecker, + import_node: str, + module_name: str, + expected_args: tuple[str, ...], +) -> None: + """Ensure bad namespace imports are rejected.""" + + node = astroid.extract_node( + f"{import_node} #@", + module_name, + ) + imports_checker.visit_module(node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-helper-namespace-import", + node=node, + args=expected_args, + line=1, + col_offset=0, + end_line=1, + end_col_offset=len(import_node), + ), + ): + imports_checker.visit_importfrom(node) diff --git a/tests/ruff.toml b/tests/ruff.toml index 87725160751..bbfbfe1305d 100644 --- a/tests/ruff.toml +++ b/tests/ruff.toml @@ -7,6 +7,7 @@ extend-ignore = [ "B904", # Use raise from to specify exception cause "N815", # Variable {name} in class scope should not be mixedCase "RUF018", # Avoid assignment expressions in assert statements + "SLF001", # Private member accessed: Tests do often test internals a lot ] [lint.isort] diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py index 72bb4dd5b67..f497751a4d7 100644 --- a/tests/scripts/test_auth.py +++ b/tests/scripts/test_auth.py @@ -1,5 +1,6 @@ """Test the auth script to manage local users.""" +from asyncio import AbstractEventLoop import logging from typing import Any from unittest.mock import Mock, patch @@ -125,7 +126,7 @@ async def test_change_password_invalid_user( data.validate_login("invalid-user", "new-pass") -def test_parsing_args(event_loop) -> None: +def test_parsing_args(event_loop: AbstractEventLoop) -> None: """Test we parse args correctly.""" called = False diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 76acb2ff678..7e3c1abbb22 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.config import YAML_CONFIG_FILE from homeassistant.scripts import check_config @@ -56,7 +55,8 @@ def normalize_yaml_files(check_dict): @pytest.mark.parametrize("hass_config_yaml", [BAD_CORE_CONFIG]) -def test_bad_core_config(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: +@pytest.mark.usefixtures("mock_is_file", "event_loop", "mock_hass_config_yaml") +def test_bad_core_config() -> None: """Test a bad core config setup.""" res = check_config.check(get_test_config_dir()) assert res["except"].keys() == {"homeassistant"} @@ -65,9 +65,8 @@ def test_bad_core_config(mock_is_file, event_loop, mock_hass_config_yaml: None) @pytest.mark.parametrize("hass_config_yaml", [BASE_CONFIG + "light:\n platform: demo"]) -def test_config_platform_valid( - mock_is_file, event_loop, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("mock_is_file", "event_loop", "mock_hass_config_yaml") +def test_config_platform_valid() -> None: """Test a valid platform setup.""" res = check_config.check(get_test_config_dir()) assert res["components"].keys() == {"homeassistant", "light"} @@ -97,9 +96,8 @@ def test_config_platform_valid( ), ], ) -def test_component_platform_not_found( - mock_is_file, event_loop, mock_hass_config_yaml: None, platforms, error -) -> None: +@pytest.mark.usefixtures("mock_is_file", "event_loop", "mock_hass_config_yaml") +def test_component_platform_not_found(platforms: set[str], error: str) -> None: """Test errors if component or platform not found.""" # Make sure they don't exist res = check_config.check(get_test_config_dir()) @@ -123,7 +121,8 @@ def test_component_platform_not_found( } ], ) -def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: +@pytest.mark.usefixtures("mock_is_file", "event_loop", "mock_hass_config_yaml") +def test_secrets() -> None: """Test secrets config checking method.""" res = check_config.check(get_test_config_dir(), True) @@ -135,7 +134,6 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", - "strict_connection": StrictConnectionMode.DISABLED, "use_x_frame_options": True, "server_host": ["0.0.0.0", "::"], } @@ -153,7 +151,8 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: @pytest.mark.parametrize( "hass_config_yaml", [BASE_CONFIG + ' packages:\n p1:\n group: ["a"]'] ) -def test_package_invalid(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: +@pytest.mark.usefixtures("mock_is_file", "event_loop", "mock_hass_config_yaml") +def test_package_invalid() -> None: """Test an invalid package.""" res = check_config.check(get_test_config_dir()) @@ -169,7 +168,8 @@ def test_package_invalid(mock_is_file, event_loop, mock_hass_config_yaml: None) @pytest.mark.parametrize( "hass_config_yaml", [BASE_CONFIG + "automation: !include no.yaml"] ) -def test_bootstrap_error(event_loop, mock_hass_config_yaml: None) -> None: +@pytest.mark.usefixtures("event_loop", "mock_hass_config_yaml") +def test_bootstrap_error() -> None: """Test a valid platform setup.""" res = check_config.check(get_test_config_dir(YAML_CONFIG_FILE)) err = res["except"].pop(check_config.ERROR_STR) diff --git a/tests/test_backports.py b/tests/test_backports.py index 09c11da37cb..4df0a9e3f57 100644 --- a/tests/test_backports.py +++ b/tests/test_backports.py @@ -14,7 +14,7 @@ from homeassistant.backports import ( functools as backports_functools, ) -from tests.common import import_and_test_deprecated_alias +from .common import import_and_test_deprecated_alias @pytest.mark.parametrize( diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index 688852ecf55..ae77fbee217 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -1,14 +1,25 @@ """Tests for async util methods from Python source.""" +import contextlib +import glob import importlib +import os +from pathlib import Path, PurePosixPath import time +from typing import Any from unittest.mock import Mock, patch import pytest from homeassistant import block_async_io +from homeassistant.core import HomeAssistant -from tests.common import extract_stack_to_frame +from .common import extract_stack_to_frame + + +@pytest.fixture(autouse=True) +def disable_block_async_io(disable_block_async_io): + """Disable the loop protection from block_async_io after each test.""" async def test_protect_loop_debugger_sleep(caplog: pytest.LogCaptureFixture) -> None: @@ -37,7 +48,7 @@ async def test_protect_loop_debugger_sleep(caplog: pytest.LogCaptureFixture) -> assert "Detected blocking call inside the event loop" not in caplog.text -async def test_protect_loop_sleep(caplog: pytest.LogCaptureFixture) -> None: +async def test_protect_loop_sleep() -> None: """Test time.sleep not injected by the debugger raises.""" block_async_io.enable() frames = extract_stack_to_frame( @@ -50,9 +61,7 @@ async def test_protect_loop_sleep(caplog: pytest.LogCaptureFixture) -> None: ] ) with ( - pytest.raises( - RuntimeError, match="Detected blocking call to sleep inside the event loop" - ), + pytest.raises(RuntimeError, match="Caught blocking call to sleep with args"), patch( "homeassistant.block_async_io.get_current_frame", return_value=frames, @@ -65,9 +74,7 @@ async def test_protect_loop_sleep(caplog: pytest.LogCaptureFixture) -> None: time.sleep(0) -async def test_protect_loop_sleep_get_current_frame_raises( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_protect_loop_sleep_get_current_frame_raises() -> None: """Test time.sleep when get_current_frame raises ValueError.""" block_async_io.enable() frames = extract_stack_to_frame( @@ -80,9 +87,7 @@ async def test_protect_loop_sleep_get_current_frame_raises( ] ) with ( - pytest.raises( - RuntimeError, match="Detected blocking call to sleep inside the event loop" - ), + pytest.raises(RuntimeError, match="Caught blocking call to sleep with args"), patch( "homeassistant.block_async_io.get_current_frame", side_effect=ValueError, @@ -109,7 +114,6 @@ async def test_protect_loop_importlib_import_module_non_integration( ] ) with ( - pytest.raises(ImportError), patch.object(block_async_io, "_IN_TESTS", False), patch( "homeassistant.block_async_io.get_current_frame", @@ -121,7 +125,8 @@ async def test_protect_loop_importlib_import_module_non_integration( ), ): block_async_io.enable() - importlib.import_module("not_loaded_module") + with pytest.raises(ImportError): + importlib.import_module("not_loaded_module") assert "Detected blocking call to import_module" in caplog.text @@ -180,7 +185,6 @@ async def test_protect_loop_importlib_import_module_in_integration( ] ) with ( - pytest.raises(ImportError), patch.object(block_async_io, "_IN_TESTS", False), patch( "homeassistant.block_async_io.get_current_frame", @@ -192,9 +196,148 @@ async def test_protect_loop_importlib_import_module_in_integration( ), ): block_async_io.enable() - importlib.import_module("not_loaded_module") + with pytest.raises(ImportError): + importlib.import_module("not_loaded_module") assert ( - "Detected blocking call to import_module inside the event loop by " + "Detected blocking call to import_module with args ('not_loaded_module',) " + "inside the event loop by " "integration 'hue' at homeassistant/components/hue/light.py, line 23" ) in caplog.text + + +async def test_protect_loop_open(caplog: pytest.LogCaptureFixture) -> None: + """Test open of a file in /proc is not reported.""" + block_async_io.enable() + with ( + contextlib.suppress(FileNotFoundError), + open("/proc/does_not_exist", encoding="utf8"), + ): + pass + assert "Detected blocking call to open with args" not in caplog.text + + +async def test_protect_open(caplog: pytest.LogCaptureFixture) -> None: + """Test opening a file in the event loop logs.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + with ( + contextlib.suppress(FileNotFoundError), + open("/config/data_not_exist", encoding="utf8"), + ): + pass + + assert "Detected blocking call to open with args" in caplog.text + + +async def test_enable_multiple_times(caplog: pytest.LogCaptureFixture) -> None: + """Test trying to enable multiple times.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + + with pytest.raises( + RuntimeError, match="Blocking call detection is already enabled" + ): + block_async_io.enable() + + +@pytest.mark.parametrize( + "path", + [ + "/config/data_not_exist", + Path("/config/data_not_exist"), + PurePosixPath("/config/data_not_exist"), + ], +) +async def test_protect_open_path(path: Any, caplog: pytest.LogCaptureFixture) -> None: + """Test opening a file by path in the event loop logs.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + with contextlib.suppress(FileNotFoundError), open(path, encoding="utf8"): + pass + + assert "Detected blocking call to open with args" in caplog.text + + +async def test_protect_loop_glob( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test glob calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + glob.glob("/dev/null") + assert "Detected blocking call to glob with args" in caplog.text + caplog.clear() + await hass.async_add_executor_job(glob.glob, "/dev/null") + assert "Detected blocking call to glob with args" not in caplog.text + + +async def test_protect_loop_iglob( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test iglob calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + glob.iglob("/dev/null") + assert "Detected blocking call to iglob with args" in caplog.text + caplog.clear() + await hass.async_add_executor_job(glob.iglob, "/dev/null") + assert "Detected blocking call to iglob with args" not in caplog.text + + +async def test_protect_loop_scandir( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test glob calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + with contextlib.suppress(FileNotFoundError): + os.scandir("/path/that/does/not/exists") + assert "Detected blocking call to scandir with args" in caplog.text + caplog.clear() + with contextlib.suppress(FileNotFoundError): + await hass.async_add_executor_job(os.scandir, "/path/that/does/not/exists") + assert "Detected blocking call to scandir with args" not in caplog.text + + +async def test_protect_loop_listdir( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test listdir calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + with contextlib.suppress(FileNotFoundError): + os.listdir("/path/that/does/not/exists") + assert "Detected blocking call to listdir with args" in caplog.text + caplog.clear() + with contextlib.suppress(FileNotFoundError): + await hass.async_add_executor_job(os.listdir, "/path/that/does/not/exists") + assert "Detected blocking call to listdir with args" not in caplog.text + + +async def test_protect_loop_walk( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test os.walk calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + with contextlib.suppress(FileNotFoundError): + os.walk("/path/that/does/not/exists") + assert "Detected blocking call to walk with args" in caplog.text + caplog.clear() + with contextlib.suppress(FileNotFoundError): + await hass.async_add_executor_job(os.walk, "/path/that/does/not/exists") + assert "Detected blocking call to walk with args" not in caplog.text + + +async def test_open_calls_ignored_in_tests(caplog: pytest.LogCaptureFixture) -> None: + """Test opening a file in tests is ignored.""" + assert block_async_io._IN_TESTS + block_async_io.enable() + with ( + contextlib.suppress(FileNotFoundError), + open("/config/data_not_exist", encoding="utf8"), + ): + pass + + assert "Detected blocking call to open with args" not in caplog.text diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 3d2735d9c1c..ca864006852 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,18 +1,21 @@ """Test the bootstrapping.""" import asyncio -from collections.abc import Generator, Iterable +from collections.abc import Iterable +import contextlib import glob +import logging import os import sys from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from typing_extensions import Generator from homeassistant import bootstrap, loader, runner import homeassistant.config as config_util -from homeassistant.config_entries import HANDLERS, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEBUG, SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import CoreState, HomeAssistant, async_get_hass, callback from homeassistant.exceptions import HomeAssistantError @@ -27,6 +30,7 @@ from .common import ( MockModule, MockPlatform, get_test_config_dir, + mock_config_flow, mock_integration, mock_platform, ) @@ -34,6 +38,13 @@ from .common import ( VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) +@pytest.fixture(autouse=True) +def disable_installed_check() -> Generator[None]: + """Disable package installed check.""" + with patch("homeassistant.util.package.is_installed", return_value=True): + yield + + @pytest.fixture(autouse=True) def apply_mock_storage(hass_storage: dict[str, Any]) -> None: """Apply the storage mock.""" @@ -44,8 +55,13 @@ async def apply_stop_hass(stop_hass: None) -> None: """Make sure all hass are stopped.""" +@pytest.fixture(autouse=True) +def disable_block_async_io(disable_block_async_io): + """Disable the loop protection from block_async_io after each test.""" + + @pytest.fixture(scope="module", autouse=True) -def mock_http_start_stop() -> Generator[None, None, None]: +def mock_http_start_stop() -> Generator[None]: """Mock HTTP start and stop.""" with ( patch("homeassistant.components.http.start_http_server_and_save_config"), @@ -123,8 +139,8 @@ async def test_config_does_not_turn_off_debug(hass: HomeAssistant) -> None: @pytest.mark.parametrize("hass_config", [{"frontend": {}}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_asyncio_debug_on_turns_hass_debug_on( - mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -573,7 +589,7 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None: @pytest.fixture -def mock_is_virtual_env() -> Generator[Mock, None, None]: +def mock_is_virtual_env() -> Generator[Mock]: """Mock is_virtual_env.""" with patch( "homeassistant.bootstrap.is_virtual_env", return_value=False @@ -582,14 +598,14 @@ def mock_is_virtual_env() -> Generator[Mock, None, None]: @pytest.fixture -def mock_enable_logging() -> Generator[Mock, None, None]: +def mock_enable_logging() -> Generator[Mock]: """Mock enable logging.""" with patch("homeassistant.bootstrap.async_enable_logging") as enable_logging: yield enable_logging @pytest.fixture -def mock_mount_local_lib_path() -> Generator[AsyncMock, None, None]: +def mock_mount_local_lib_path() -> Generator[AsyncMock]: """Mock enable logging.""" with patch( "homeassistant.bootstrap.async_mount_local_lib_path" @@ -598,7 +614,7 @@ def mock_mount_local_lib_path() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_process_ha_config_upgrade() -> Generator[Mock, None, None]: +def mock_process_ha_config_upgrade() -> Generator[Mock]: """Mock enable logging.""" with patch( "homeassistant.config.process_ha_config_upgrade" @@ -607,7 +623,7 @@ def mock_process_ha_config_upgrade() -> Generator[Mock, None, None]: @pytest.fixture -def mock_ensure_config_exists() -> Generator[AsyncMock, None, None]: +def mock_ensure_config_exists() -> Generator[AsyncMock]: """Mock enable logging.""" with patch( "homeassistant.config.async_ensure_config_exists", return_value=True @@ -616,8 +632,8 @@ def mock_ensure_config_exists() -> Generator[AsyncMock, None, None]: @pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_setup_hass( - mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -669,8 +685,8 @@ async def test_setup_hass( @pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_setup_hass_takes_longer_than_log_slow_startup( - mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -685,11 +701,11 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( log_no_color = Mock() async def _async_setup_that_blocks_startup(*args, **kwargs): - await asyncio.sleep(0.6) + await asyncio.sleep(0.2) return True with ( - patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.3), + patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.1), patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05), patch( "homeassistant.components.frontend.async_setup", @@ -799,8 +815,8 @@ async def test_setup_hass_recovery_mode( assert len(browser_setup.mock_calls) == 0 +@pytest.mark.usefixtures("mock_hass_config") async def test_setup_hass_safe_mode( - mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -834,8 +850,8 @@ async def test_setup_hass_safe_mode( assert "Starting in safe mode" in caplog.text +@pytest.mark.usefixtures("mock_hass_config") async def test_setup_hass_recovery_mode_and_safe_mode( - mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -870,8 +886,8 @@ async def test_setup_hass_recovery_mode_and_safe_mode( @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) +@pytest.mark.usefixtures("mock_hass_config") async def test_setup_hass_invalid_core_config( - mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -909,8 +925,8 @@ async def test_setup_hass_invalid_core_config( } ], ) +@pytest.mark.usefixtures("mock_hass_config") async def test_setup_recovery_mode_if_no_frontend( - mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -956,10 +972,10 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap( def gen_domain_setup(domain): async def async_setup(hass, config): order.append(domain) - await asyncio.sleep(0.1) + await asyncio.sleep(0.05) async def _background_task(): - await asyncio.sleep(0.2) + await asyncio.sleep(0.1) await hass.async_create_task(_background_task()) return True @@ -991,7 +1007,7 @@ async def test_empty_integrations_list_is_only_sent_at_the_end_of_bootstrap( async_dispatcher_connect( hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, _bootstrap_integrations ) - with patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05): + with patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.025): await bootstrap._async_set_up_integrations( hass, {"normal_integration": {}, "an_after_dep": {}} ) @@ -1011,13 +1027,16 @@ async def test_warning_logged_on_wrap_up_timeout( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we log a warning on bootstrap timeout.""" + task: asyncio.Task | None = None def gen_domain_setup(domain): async def async_setup(hass, config): - async def _not_marked_background_task(): - await asyncio.sleep(0.2) + nonlocal task - hass.async_create_task(_not_marked_background_task()) + async def _not_marked_background_task(): + await asyncio.sleep(2) + + task = hass.async_create_task(_not_marked_background_task()) return True return async_setup @@ -1033,8 +1052,10 @@ async def test_warning_logged_on_wrap_up_timeout( with patch.object(bootstrap, "WRAP_UP_TIMEOUT", 0): await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) - await hass.async_block_till_done() + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task assert "Setup timed out for bootstrap" in caplog.text assert "waiting on" in caplog.text assert "_not_marked_background_task" in caplog.text @@ -1087,14 +1108,14 @@ async def test_tasks_logged_that_block_stage_2( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we log tasks that delay stage 2 startup.""" + done_future = hass.loop.create_future() def gen_domain_setup(domain): async def async_setup(hass, config): async def _not_marked_background_task(): - await asyncio.sleep(0.2) + await done_future hass.async_create_task(_not_marked_background_task()) - await asyncio.sleep(0.1) return True return async_setup @@ -1108,16 +1129,36 @@ async def test_tasks_logged_that_block_stage_2( ), ) + wanted_messages = { + "Setup timed out for stage 2 waiting on", + "waiting on", + "_not_marked_background_task", + } + + def on_message_logged(log_record: logging.LogRecord, *args): + for message in list(wanted_messages): + if message in log_record.message: + wanted_messages.remove(message) + if not done_future.done() and not wanted_messages: + done_future.set_result(None) + return + with ( patch.object(bootstrap, "STAGE_2_TIMEOUT", 0), patch.object(bootstrap, "COOLDOWN_TIME", 0), + patch.object( + caplog.handler, + "emit", + wraps=caplog.handler.emit, + side_effect=on_message_logged, + ), ): await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) + async with asyncio.timeout(2): + await done_future await hass.async_block_till_done() - assert "Setup timed out for stage 2 waiting on" in caplog.text - assert "waiting on" in caplog.text - assert "_not_marked_background_task" in caplog.text + assert not wanted_messages @pytest.mark.parametrize("load_registries", [False]) @@ -1135,31 +1176,24 @@ async def test_bootstrap_is_cancellation_safe( @pytest.mark.parametrize("load_registries", [False]) -async def test_bootstrap_empty_integrations( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None: """Test setting up an empty integrations does not raise.""" await bootstrap.async_setup_multi_components(hass, set(), {}) await hass.async_block_till_done() @pytest.fixture(name="mock_mqtt_config_flow") -def mock_mqtt_config_flow_fixture() -> Generator[None, None, None]: +def mock_mqtt_config_flow_fixture() -> Generator[None]: """Mock MQTT config flow.""" - original_mqtt = HANDLERS.get("mqtt") - @HANDLERS.register("mqtt") class MockConfigFlow: """Mock the MQTT config flow.""" VERSION = 1 MINOR_VERSION = 1 - yield - if original_mqtt: - HANDLERS["mqtt"] = original_mqtt - else: - HANDLERS.pop("mqtt") + with mock_config_flow("mqtt", MockConfigFlow): + yield @pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) @@ -1338,10 +1372,9 @@ async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_cancellation_does_not_leak_upward_from_async_setup( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting up an integration that raises asyncio.CancelledError.""" await bootstrap.async_setup_multi_components( @@ -1356,10 +1389,9 @@ async def test_cancellation_does_not_leak_upward_from_async_setup( @pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_cancellation_does_not_leak_upward_from_async_setup_entry( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting up an integration that raises asyncio.CancelledError.""" entry = MockConfigEntry( @@ -1457,7 +1489,7 @@ async def test_setup_does_base_platforms_first(hass: HomeAssistant) -> None: assert order[3:] == ["root", "first_dep", "second_dep"] -def test_should_rollover_is_always_false(): +def test_should_rollover_is_always_false() -> None: """Test that shouldRollover always returns False.""" assert ( bootstrap._RotatingFileHandlerWithoutShouldRollOver( diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index 79f0fd9caf7..dfdee65b2b0 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -10,7 +10,7 @@ from homeassistant.bootstrap import ( DEBUGGER_INTEGRATIONS, DEFAULT_INTEGRATIONS, FRONTEND_INTEGRATIONS, - LOGGING_INTEGRATIONS, + LOGGING_AND_HTTP_DEPS_INTEGRATIONS, RECORDER_INTEGRATIONS, STAGE_1_INTEGRATIONS, ) @@ -23,7 +23,7 @@ from homeassistant.bootstrap import ( { *DEBUGGER_INTEGRATIONS, *CORE_INTEGRATIONS, - *LOGGING_INTEGRATIONS, + *LOGGING_AND_HTTP_DEPS_INTEGRATIONS, *FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS, *STAGE_1_INTEGRATIONS, diff --git a/tests/test_config.py b/tests/test_config.py index 58529fb0057..7f94317afea 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,15 +8,16 @@ import logging import os from typing import Any from unittest import mock -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator import voluptuous as vol from voluptuous import Invalid, MultipleInvalid import yaml -from homeassistant import config, loader +from homeassistant import loader import homeassistant.config as config_util from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -27,9 +28,6 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, __version__, ) from homeassistant.core import ( @@ -49,7 +47,6 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import ( - _CONF_UNIT_SYSTEM_US_CUSTOMARY, METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, UnitSystem, @@ -78,7 +75,7 @@ SAFE_MODE_PATH = os.path.join(CONFIG_DIR, config_util.SAFE_MODE_FILENAME) def create_file(path): """Create an empty file.""" - with open(path, "w"): + with open(path, "w", encoding="utf8"): pass @@ -196,13 +193,13 @@ async def mock_non_adr_0007_integration_with_docs(hass: HomeAssistant) -> None: async def mock_adr_0007_integrations(hass: HomeAssistant) -> list[Integration]: """Mock ADR-0007 compliant integrations.""" integrations = [] - for domain in [ + for domain in ( "adr_0007_1", "adr_0007_2", "adr_0007_3", "adr_0007_4", "adr_0007_5", - ]: + ): adr_0007_config_schema = vol.Schema( { domain: vol.Schema( @@ -229,13 +226,13 @@ async def mock_adr_0007_integrations_with_docs( ) -> list[Integration]: """Mock ADR-0007 compliant integrations.""" integrations = [] - for domain in [ + for domain in ( "adr_0007_1", "adr_0007_2", "adr_0007_3", "adr_0007_4", "adr_0007_5", - ]: + ): adr_0007_config_schema = vol.Schema( { domain: vol.Schema( @@ -297,10 +294,10 @@ async def mock_custom_validator_integrations(hass: HomeAssistant) -> list[Integr Mock(async_validate_config=gen_async_validate_config(domain)), ) - for domain, exception in [ + for domain, exception in ( ("custom_validator_bad_1", HomeAssistantError("broken")), ("custom_validator_bad_2", ValueError("broken")), - ]: + ): integrations.append(mock_integration(hass, MockModule(domain))) mock_platform( hass, @@ -356,10 +353,10 @@ async def mock_custom_validator_integrations_with_docs( Mock(async_validate_config=gen_async_validate_config(domain)), ) - for domain, exception in [ + for domain, exception in ( ("custom_validator_bad_1", HomeAssistantError("broken")), ("custom_validator_bad_2", ValueError("broken")), - ]: + ): integrations.append( mock_integration( hass, @@ -418,7 +415,7 @@ async def test_ensure_config_exists_uses_existing_config(hass: HomeAssistant) -> create_file(YAML_PATH) await config_util.async_ensure_config_exists(hass) - with open(YAML_PATH) as fp: + with open(YAML_PATH, encoding="utf8") as fp: content = fp.read() # File created with create_file are empty @@ -431,7 +428,7 @@ async def test_ensure_existing_files_is_not_overwritten(hass: HomeAssistant) -> await config_util.async_create_default_config(hass) - with open(SECRET_PATH) as fp: + with open(SECRET_PATH, encoding="utf8") as fp: content = fp.read() # File created with create_file are empty @@ -447,7 +444,7 @@ def test_load_yaml_config_converts_empty_files_to_dict() -> None: def test_load_yaml_config_raises_error_if_not_dict() -> None: """Test error raised when YAML file is not a dict.""" - with open(YAML_PATH, "w") as fp: + with open(YAML_PATH, "w", encoding="utf8") as fp: fp.write("5") with pytest.raises(HomeAssistantError): @@ -456,7 +453,7 @@ def test_load_yaml_config_raises_error_if_not_dict() -> None: def test_load_yaml_config_raises_error_if_malformed_yaml() -> None: """Test error raised if invalid YAML.""" - with open(YAML_PATH, "w") as fp: + with open(YAML_PATH, "w", encoding="utf8") as fp: fp.write(":-") with pytest.raises(HomeAssistantError): @@ -465,7 +462,7 @@ def test_load_yaml_config_raises_error_if_malformed_yaml() -> None: def test_load_yaml_config_raises_error_if_unsafe_yaml() -> None: """Test error raised if unsafe YAML.""" - with open(YAML_PATH, "w") as fp: + with open(YAML_PATH, "w", encoding="utf8") as fp: fp.write("- !!python/object/apply:os.system []") with ( @@ -478,7 +475,10 @@ def test_load_yaml_config_raises_error_if_unsafe_yaml() -> None: # Here we validate that the test above is a good test # since previously the syntax was not valid - with open(YAML_PATH) as fp, patch.object(os, "system") as system_mock: + with ( + open(YAML_PATH, encoding="utf8") as fp, + patch.object(os, "system") as system_mock, + ): list(yaml.unsafe_load_all(fp)) assert len(system_mock.mock_calls) == 1 @@ -486,7 +486,7 @@ def test_load_yaml_config_raises_error_if_unsafe_yaml() -> None: def test_load_yaml_config_preserves_key_order() -> None: """Test removal of library.""" - with open(YAML_PATH, "w") as fp: + with open(YAML_PATH, "w", encoding="utf8") as fp: fp.write("hello: 2\n") fp.write("world: 1\n") @@ -511,7 +511,7 @@ async def test_create_default_config_returns_none_if_write_error( def test_core_config_schema() -> None: """Test core config schema.""" for value in ( - {CONF_UNIT_SYSTEM: "K"}, + {"unit_system": "K"}, {"time_zone": "non-exist"}, {"latitude": "91"}, {"longitude": -181}, @@ -523,6 +523,7 @@ def test_core_config_schema() -> None: {"customize": {"entity_id": []}}, {"country": "xx"}, {"language": "xx"}, + {"radius": -10}, ): with pytest.raises(MultipleInvalid): config_util.CORE_CONFIG_SCHEMA(value) @@ -534,11 +535,12 @@ def test_core_config_schema() -> None: "longitude": "123.45", "external_url": "https://www.example.com", "internal_url": "http://example.local", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, + "unit_system": "metric", "currency": "USD", "customize": {"sensor.temperature": {"hidden": True}}, "country": "SE", "language": "sv", + "radius": "10", } ) @@ -710,10 +712,11 @@ async def test_loading_configuration_from_storage( "currency": "EUR", "country": "SE", "language": "sv", + "radius": 150, }, "key": "core.config", "version": 1, - "minor_version": 3, + "minor_version": 4, } await config_util.async_process_ha_core_config( hass, {"allowlist_external_dirs": "/etc"} @@ -730,6 +733,7 @@ async def test_loading_configuration_from_storage( assert hass.config.currency == "EUR" assert hass.config.country == "SE" assert hass.config.language == "sv" + assert hass.config.radius == 150 assert len(hass.config.allowlist_external_dirs) == 3 assert "/etc" in hass.config.allowlist_external_dirs assert hass.config.config_source is ConfigSource.STORAGE @@ -799,15 +803,19 @@ async def test_migration_and_updating_configuration( expected_new_core_data["data"]["currency"] = "USD" # 1.1 -> 1.2 store migration with migrated unit system expected_new_core_data["data"]["unit_system_v2"] = "us_customary" - expected_new_core_data["minor_version"] = 3 - # defaults for country and language + # 1.1 -> 1.3 defaults for country and language expected_new_core_data["data"]["country"] = None expected_new_core_data["data"]["language"] = "en" + # 1.1 -> 1.4 defaults for zone radius + expected_new_core_data["data"]["radius"] = 100 + # Bumped minor version + expected_new_core_data["minor_version"] = 4 assert hass_storage["core.config"] == expected_new_core_data assert hass.config.latitude == 50 assert hass.config.currency == "USD" assert hass.config.country is None assert hass.config.language == "en" + assert hass.config.radius == 100 async def test_override_stored_configuration( @@ -850,17 +858,17 @@ async def test_loading_configuration(hass: HomeAssistant) -> None: "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "America/New_York", "allowlist_external_dirs": "/etc", "external_url": "https://www.example.com", "internal_url": "http://example.local", "media_dirs": {"mymedia": "/usr"}, - "legacy_templates": True, "debug": True, "currency": "EUR", "country": "SE", "language": "sv", + "radius": 150, }, ) @@ -877,11 +885,11 @@ async def test_loading_configuration(hass: HomeAssistant) -> None: assert "/usr" in hass.config.allowlist_external_dirs assert hass.config.media_dirs == {"mymedia": "/usr"} assert hass.config.config_source is ConfigSource.YAML - assert hass.config.legacy_templates is True assert hass.config.debug is True assert hass.config.currency == "EUR" assert hass.config.country == "SE" assert hass.config.language == "sv" + assert hass.config.radius == 150 @pytest.mark.parametrize( @@ -982,7 +990,7 @@ async def test_loading_configuration_from_packages(hass: HomeAssistant) -> None: "longitude": -1, "elevation": 500, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, + "unit_system": "metric", "time_zone": "Europe/Madrid", "external_url": "https://www.example.com", "internal_url": "http://example.local", @@ -1006,7 +1014,7 @@ async def test_loading_configuration_from_packages(hass: HomeAssistant) -> None: "longitude": -1, "elevation": 500, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, + "unit_system": "metric", "time_zone": "Europe/Madrid", "packages": {"empty_package": None}, }, @@ -1016,9 +1024,9 @@ async def test_loading_configuration_from_packages(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_system_name", "expected_unit_system"), [ - (CONF_UNIT_SYSTEM_METRIC, METRIC_SYSTEM), - (CONF_UNIT_SYSTEM_IMPERIAL, US_CUSTOMARY_SYSTEM), - (_CONF_UNIT_SYSTEM_US_CUSTOMARY, US_CUSTOMARY_SYSTEM), + ("metric", METRIC_SYSTEM), + ("imperial", US_CUSTOMARY_SYSTEM), + ("us_customary", US_CUSTOMARY_SYSTEM), ], ) async def test_loading_configuration_unit_system( @@ -1072,8 +1080,9 @@ async def test_check_ha_config_file_wrong(mock_check, hass: HomeAssistant) -> No } ], ) +@pytest.mark.usefixtures("mock_hass_config") async def test_async_hass_config_yaml_merge( - merge_log_err, hass: HomeAssistant, mock_hass_config: None + merge_log_err: MagicMock, hass: HomeAssistant ) -> None: """Test merge during async config reload.""" conf = await config_util.async_hass_config_yaml(hass) @@ -1086,13 +1095,13 @@ async def test_async_hass_config_yaml_merge( @pytest.fixture -def merge_log_err(hass): +def merge_log_err() -> Generator[MagicMock]: """Patch _merge_log_error from packages.""" with patch("homeassistant.config._LOGGER.error") as logerr: yield logerr -async def test_merge(merge_log_err, hass: HomeAssistant) -> None: +async def test_merge(merge_log_err: MagicMock, hass: HomeAssistant) -> None: """Test if we can merge packages.""" packages = { "pack_dict": {"input_boolean": {"ib1": None}}, @@ -1127,7 +1136,7 @@ async def test_merge(merge_log_err, hass: HomeAssistant) -> None: assert isinstance(config["wake_on_lan"], OrderedDict) -async def test_merge_try_falsy(merge_log_err, hass: HomeAssistant) -> None: +async def test_merge_try_falsy(merge_log_err: MagicMock, hass: HomeAssistant) -> None: """Ensure we don't add falsy items like empty OrderedDict() to list.""" packages = { "pack_falsy_to_lst": {"automation": OrderedDict()}, @@ -1146,7 +1155,7 @@ async def test_merge_try_falsy(merge_log_err, hass: HomeAssistant) -> None: assert len(config["light"]) == 1 -async def test_merge_new(merge_log_err, hass: HomeAssistant) -> None: +async def test_merge_new(merge_log_err: MagicMock, hass: HomeAssistant) -> None: """Test adding new components to outer scope.""" packages = { "pack_1": {"light": [{"platform": "one"}]}, @@ -1167,7 +1176,9 @@ async def test_merge_new(merge_log_err, hass: HomeAssistant) -> None: assert len(config["panel_custom"]) == 1 -async def test_merge_type_mismatch(merge_log_err, hass: HomeAssistant) -> None: +async def test_merge_type_mismatch( + merge_log_err: MagicMock, hass: HomeAssistant +) -> None: """Test if we have a type mismatch for packages.""" packages = { "pack_1": {"input_boolean": [{"ib1": None}]}, @@ -1188,7 +1199,9 @@ async def test_merge_type_mismatch(merge_log_err, hass: HomeAssistant) -> None: assert len(config["light"]) == 2 -async def test_merge_once_only_keys(merge_log_err, hass: HomeAssistant) -> None: +async def test_merge_once_only_keys( + merge_log_err: MagicMock, hass: HomeAssistant +) -> None: """Test if we have a merge for a comp that may occur only once. Keys.""" packages = {"pack_2": {"api": None}} config = {HA_DOMAIN: {config_util.CONF_PACKAGES: packages}, "api": None} @@ -1274,7 +1287,9 @@ async def test_merge_id_schema(hass: HomeAssistant) -> None: assert typ == expected_type, f"{domain} expected {expected_type}, got {typ}" -async def test_merge_duplicate_keys(merge_log_err, hass: HomeAssistant) -> None: +async def test_merge_duplicate_keys( + merge_log_err: MagicMock, hass: HomeAssistant +) -> None: """Test if keys in dicts are duplicates.""" packages = {"pack_1": {"input_select": {"ib1": None}}} config = { @@ -1295,7 +1310,7 @@ async def test_merge_customize(hass: HomeAssistant) -> None: "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "GMT", "customize": {"a.a": {"friendly_name": "A"}}, "packages": { @@ -1314,11 +1329,10 @@ async def test_auth_provider_config(hass: HomeAssistant) -> None: "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "GMT", CONF_AUTH_PROVIDERS: [ {"type": "homeassistant"}, - {"type": "legacy_api_password", "api_password": "some-pass"}, ], CONF_AUTH_MFA_MODULES: [{"type": "totp"}, {"type": "totp", "id": "second"}], } @@ -1326,9 +1340,8 @@ async def test_auth_provider_config(hass: HomeAssistant) -> None: del hass.auth await config_util.async_process_ha_core_config(hass, core_config) - assert len(hass.auth.auth_providers) == 2 + assert len(hass.auth.auth_providers) == 1 assert hass.auth.auth_providers[0].type == "homeassistant" - assert hass.auth.auth_providers[1].type == "legacy_api_password" assert len(hass.auth.auth_mfa_modules) == 2 assert hass.auth.auth_mfa_modules[0].id == "totp" assert hass.auth.auth_mfa_modules[1].id == "second" @@ -1341,7 +1354,7 @@ async def test_auth_provider_config_default(hass: HomeAssistant) -> None: "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "GMT", } if hasattr(hass, "auth"): @@ -1361,7 +1374,7 @@ async def test_disallowed_auth_provider_config(hass: HomeAssistant) -> None: "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "GMT", CONF_AUTH_PROVIDERS: [ { @@ -1387,7 +1400,7 @@ async def test_disallowed_duplicated_auth_provider_config(hass: HomeAssistant) - "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "GMT", CONF_AUTH_PROVIDERS: [{"type": "homeassistant"}, {"type": "homeassistant"}], } @@ -1402,7 +1415,7 @@ async def test_disallowed_auth_mfa_module_config(hass: HomeAssistant) -> None: "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "GMT", CONF_AUTH_MFA_MODULES: [ { @@ -1424,7 +1437,7 @@ async def test_disallowed_duplicated_auth_mfa_module_config( "longitude": 50, "elevation": 25, "name": "Huis", - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + "unit_system": "imperial", "time_zone": "GMT", CONF_AUTH_MFA_MODULES: [{"type": "totp"}, {"type": "totp"}], } @@ -1983,18 +1996,19 @@ def test_identify_config_schema(domain, schema, expected) -> None: ) -async def test_core_config_schema_historic_currency(hass: HomeAssistant) -> None: +async def test_core_config_schema_historic_currency( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test core config schema.""" await config_util.async_process_ha_core_config(hass, {"currency": "LTT"}) - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue("homeassistant", "historic_currency") assert issue assert issue.translation_placeholders == {"currency": "LTT"} async def test_core_store_historic_currency( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, hass_storage: dict[str, Any], issue_registry: ir.IssueRegistry ) -> None: """Test core config store.""" core_data = { @@ -2008,7 +2022,6 @@ async def test_core_store_historic_currency( hass_storage["core.config"] = dict(core_data) await config_util.async_process_ha_core_config(hass, {}) - issue_registry = ir.async_get(hass) issue_id = "historic_currency" issue = issue_registry.async_get_issue("homeassistant", issue_id) assert issue @@ -2019,41 +2032,18 @@ async def test_core_store_historic_currency( assert not issue -async def test_core_config_schema_no_country(hass: HomeAssistant) -> None: +async def test_core_config_schema_no_country( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test core config schema.""" await config_util.async_process_ha_core_config(hass, {}) - issue_registry = ir.async_get(hass) issue = issue_registry.async_get_issue("homeassistant", "country_not_configured") 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] + hass: HomeAssistant, hass_storage: dict[str, Any], issue_registry: ir.IssueRegistry ) -> None: """Test core config store.""" core_data = { @@ -2065,7 +2055,6 @@ async def test_core_store_no_country( hass_storage["core.config"] = dict(core_data) await config_util.async_process_ha_core_config(hass, {}) - issue_registry = ir.async_get(hass) issue_id = "country_not_configured" issue = issue_registry.async_get_issue("homeassistant", issue_id) assert issue @@ -2457,7 +2446,7 @@ async def test_loading_platforms_gathers(hass: HomeAssistant) -> None: _load_platform, ): light_task = hass.async_create_task( - config.async_process_component_config( + config_util.async_process_component_config( hass, { "light": [ @@ -2470,7 +2459,7 @@ async def test_loading_platforms_gathers(hass: HomeAssistant) -> None: eager_start=True, ) sensor_task = hass.async_create_task( - config.async_process_component_config( + config_util.async_process_component_config( hass, { "sensor": [ @@ -2494,3 +2483,30 @@ async def test_loading_platforms_gathers(hass: HomeAssistant) -> None: ("platform_int", "sensor"), ("platform_int2", "sensor"), ] + + +async def test_configuration_legacy_template_is_removed(hass: HomeAssistant) -> None: + """Test loading core config onto hass object.""" + await config_util.async_process_ha_core_config( + hass, + { + "latitude": 60, + "longitude": 50, + "elevation": 25, + "name": "Huis", + "unit_system": "imperial", + "time_zone": "America/New_York", + "allowlist_external_dirs": "/etc", + "external_url": "https://www.example.com", + "internal_url": "http://example.local", + "media_dirs": {"mymedia": "/usr"}, + "legacy_templates": True, + "debug": True, + "currency": "EUR", + "country": "SE", + "language": "sv", + "radius": 150, + }, + ) + + assert not getattr(hass.config, "legacy_templates") diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 8d7efad8918..cba7ad8f215 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Generator from datetime import timedelta from functools import cached_property import logging @@ -13,6 +12,7 @@ from unittest.mock import ANY, AsyncMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from typing_extensions import Generator from homeassistant import config_entries, data_entry_flow, loader from homeassistant.components import dhcp @@ -35,6 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component +from homeassistant.util.async_ import create_eager_task import homeassistant.util.dt as dt_util from .common import ( @@ -44,16 +45,15 @@ from .common import ( MockPlatform, async_capture_events, async_fire_time_changed, + async_get_persistent_notifications, mock_config_flow, mock_integration, mock_platform, ) -from tests.common import async_get_persistent_notifications - @pytest.fixture(autouse=True) -def mock_handlers() -> Generator[None, None, None]: +def mock_handlers() -> Generator[None]: """Mock config flows.""" class MockFlowHandler(config_entries.ConfigFlow): @@ -90,6 +90,89 @@ async def manager(hass: HomeAssistant) -> config_entries.ConfigEntries: return manager +async def test_setup_race_only_setup_once(hass: HomeAssistant) -> None: + """Test ensure that config entries are only setup once.""" + attempts = 0 + slow_config_entry_setup_future = hass.loop.create_future() + fast_config_entry_setup_future = hass.loop.create_future() + slow_setup_future = hass.loop.create_future() + + async def async_setup(hass, config): + """Mock setup.""" + await slow_setup_future + return True + + async def async_setup_entry(hass, entry): + """Mock setup entry.""" + slow = entry.data["slow"] + if slow: + await slow_config_entry_setup_future + return True + nonlocal attempts + attempts += 1 + if attempts == 1: + raise ConfigEntryNotReady + await fast_config_entry_setup_future + return True + + async def async_unload_entry(hass, entry): + """Mock unload entry.""" + return True + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + entry = MockConfigEntry(domain="comp", data={"slow": False}) + entry.add_to_hass(hass) + + entry2 = MockConfigEntry(domain="comp", data={"slow": True}) + entry2.add_to_hass(hass) + await entry2.setup_lock.acquire() + + async def _async_reload_entry(entry: MockConfigEntry): + async with entry.setup_lock: + await entry.async_unload(hass) + await entry.async_setup(hass) + + hass.async_create_task(_async_reload_entry(entry2)) + + setup_task = hass.async_create_task(async_setup_component(hass, "comp", {})) + entry2.setup_lock.release() + + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert entry2.state is config_entries.ConfigEntryState.NOT_LOADED + + assert "comp" not in hass.config.components + slow_setup_future.set_result(None) + await asyncio.sleep(0) + assert "comp" in hass.config.components + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + assert entry2.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS + + fast_config_entry_setup_future.set_result(None) + # Make sure setup retry is started + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + slow_config_entry_setup_future.set_result(None) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + await hass.async_block_till_done() + + assert attempts == 2 + await hass.async_block_till_done() + assert setup_task.done() + assert entry2.state is config_entries.ConfigEntryState.LOADED + + async def test_call_setup_entry(hass: HomeAssistant) -> None: """Test we call .setup_entry.""" entry = MockConfigEntry(domain="comp") @@ -386,7 +469,7 @@ async def test_remove_entry( ] # Setup entry - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() # Check entity state got added @@ -421,7 +504,9 @@ async def test_remove_entry( async def test_remove_entry_cancels_reauth( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, ) -> None: """Tests that removing a config entry, also aborts existing reauth flows.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -431,7 +516,7 @@ async def test_remove_entry_cancels_reauth( mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress_by_handler("test") @@ -440,7 +525,6 @@ async def test_remove_entry_cancels_reauth( assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR - issue_registry = ir.async_get(hass) issue_id = f"config_entry_reauth_test_{entry.entry_id}" assert issue_registry.async_get_issue(HA_DOMAIN, issue_id) @@ -472,7 +556,7 @@ async def test_remove_entry_handles_callback_error( # Check all config entries exist assert manager.async_entry_ids() == ["test1"] # Setup entry - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() # Remove entry @@ -493,7 +577,7 @@ async def test_remove_entry_raises( async def mock_unload_entry(hass, entry): """Mock unload entry function.""" - raise Exception("BROKEN") + raise Exception("BROKEN") # pylint: disable=broad-exception-raised mock_integration(hass, MockModule("comp", async_unload_entry=mock_unload_entry)) @@ -692,6 +776,13 @@ async def test_entries_excludes_ignore_and_disabled( entry3, disabled_entry, ] + assert manager.async_has_entries("test") is True + assert manager.async_has_entries("test2") is True + assert manager.async_has_entries("test3") is True + assert manager.async_has_entries("ignored") is True + assert manager.async_has_entries("disabled") is True + + assert manager.async_has_entries("not") is False assert manager.async_entries(include_ignore=False) == [ entry, entry2a, @@ -712,6 +803,10 @@ async def test_entries_excludes_ignore_and_disabled( entry2b, entry3, ] + assert manager.async_has_entries("test", include_ignore=False) is True + assert manager.async_has_entries("test2", include_ignore=False) is True + assert manager.async_has_entries("test3", include_ignore=False) is True + assert manager.async_has_entries("ignored", include_ignore=False) is False assert manager.async_entries(include_ignore=True) == [ entry, @@ -737,6 +832,10 @@ async def test_entries_excludes_ignore_and_disabled( entry3, disabled_entry, ] + assert manager.async_has_entries("test", include_disabled=False) is True + assert manager.async_has_entries("test2", include_disabled=False) is True + assert manager.async_has_entries("test3", include_disabled=False) is True + assert manager.async_has_entries("disabled", include_disabled=False) is False async def test_saving_and_loading( @@ -858,7 +957,9 @@ async def test_as_dict(snapshot: SnapshotAssertion) -> None: async def test_forward_entry_sets_up_component(hass: HomeAssistant) -> None: """Test we setup the component entry is forwarded to.""" - entry = MockConfigEntry(domain="original") + entry = MockConfigEntry( + domain="original", state=config_entries.ConfigEntryState.LOADED + ) mock_original_setup_entry = AsyncMock(return_value=True) integration = mock_integration( @@ -870,10 +971,10 @@ async def test_forward_entry_sets_up_component(hass: HomeAssistant) -> None: hass, MockModule("forwarded", async_setup_entry=mock_forwarded_setup_entry) ) - with patch.object(integration, "async_get_platform") as mock_async_get_platform: - await hass.config_entries.async_forward_entry_setup(entry, "forwarded") + with patch.object(integration, "async_get_platforms") as mock_async_get_platforms: + await hass.config_entries.async_forward_entry_setups(entry, ["forwarded"]) - mock_async_get_platform.assert_called_once_with("forwarded") + mock_async_get_platforms.assert_called_once_with(["forwarded"]) assert len(mock_original_setup_entry.mock_calls) == 0 assert len(mock_forwarded_setup_entry.mock_calls) == 1 @@ -882,7 +983,14 @@ async def test_forward_entry_does_not_setup_entry_if_setup_fails( hass: HomeAssistant, ) -> None: """Test we do not set up entry if component setup fails.""" - entry = MockConfigEntry(domain="original") + entry = MockConfigEntry( + domain="original", state=config_entries.ConfigEntryState.LOADED + ) + + mock_original_setup_entry = AsyncMock(return_value=True) + integration = mock_integration( + hass, MockModule("original", async_setup_entry=mock_original_setup_entry) + ) mock_setup = AsyncMock(return_value=False) mock_setup_entry = AsyncMock() @@ -893,11 +1001,48 @@ async def test_forward_entry_does_not_setup_entry_if_setup_fails( ), ) - await hass.config_entries.async_forward_entry_setup(entry, "forwarded") + with patch.object(integration, "async_get_platforms"): + await hass.config_entries.async_forward_entry_setups(entry, ["forwarded"]) assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 0 +async def test_async_forward_entry_setup_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_forward_entry_setup is deprecated.""" + entry = MockConfigEntry( + domain="original", state=config_entries.ConfigEntryState.LOADED + ) + + mock_original_setup_entry = AsyncMock(return_value=True) + integration = mock_integration( + hass, MockModule("original", async_setup_entry=mock_original_setup_entry) + ) + + mock_setup = AsyncMock(return_value=False) + mock_setup_entry = AsyncMock() + mock_integration( + hass, + MockModule( + "forwarded", async_setup=mock_setup, async_setup_entry=mock_setup_entry + ), + ) + + entry_id = entry.entry_id + caplog.clear() + with patch.object(integration, "async_get_platforms"): + async with entry.setup_lock: + await hass.config_entries.async_forward_entry_setup(entry, "forwarded") + + assert ( + "Detected code that calls async_forward_entry_setup for integration, " + f"original with title: Mock Title and entry_id: {entry_id}, " + "which is deprecated and will stop working in Home Assistant 2025.6, " + "await async_forward_entry_setups instead. Please report this issue." + ) in caplog.text + + async def test_discovery_notification( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1021,9 +1166,12 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: assert "config_entry_reconfigure" not in notifications -async def test_reauth_issue(hass: HomeAssistant) -> None: +async def test_reauth_issue( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: """Test that we create/delete an issue when source is reauth.""" - issue_registry = ir.async_get(hass) assert len(issue_registry.issues) == 0 entry = MockConfigEntry(title="test_title", domain="test") @@ -1033,7 +1181,7 @@ async def test_reauth_issue(hass: HomeAssistant) -> None: mock_platform(hass, "test.config_flow", None) entry.add_to_hass(hass) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress_by_handler("test") @@ -1143,23 +1291,30 @@ async def test_update_entry_options_and_trigger_listener( """Test that we can update entry options and trigger listener.""" entry = MockConfigEntry(domain="test", options={"first": True}) entry.add_to_manager(manager) + update_listener_calls = [] async def update_listener(hass, entry): """Test function.""" assert entry.options == {"second": True} + update_listener_calls.append(None) entry.add_update_listener(update_listener) assert manager.async_update_entry(entry, options={"second": True}) is True + await hass.async_block_till_done(wait_background_tasks=True) assert entry.options == {"second": True} + assert len(update_listener_calls) == 1 async def test_setup_raise_not_ready( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a setup raising not ready.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) mock_setup_entry = AsyncMock( side_effect=ConfigEntryNotReady("The internet connection is offline") @@ -1168,7 +1323,7 @@ async def test_setup_raise_not_ready( mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) assert len(mock_call.mock_calls) == 1 assert ( @@ -1193,10 +1348,13 @@ async def test_setup_raise_not_ready( async def test_setup_raise_not_ready_from_exception( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a setup raising not ready from another exception.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) original_exception = HomeAssistantError("The device dropped the connection") config_entry_exception = ConfigEntryNotReady() @@ -1207,7 +1365,7 @@ async def test_setup_raise_not_ready_from_exception( mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) assert len(mock_call.mock_calls) == 1 assert ( @@ -1216,29 +1374,35 @@ async def test_setup_raise_not_ready_from_exception( ) -async def test_setup_retrying_during_unload(hass: HomeAssistant) -> None: +async def test_setup_retrying_during_unload( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test if we unload an entry that is in retry mode.""" entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) with patch("homeassistant.config_entries.async_call_later") as mock_call: - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY assert len(mock_call.return_value.mock_calls) == 0 - await entry.async_unload(hass) + await manager.async_unload(entry.entry_id) assert entry.state is config_entries.ConfigEntryState.NOT_LOADED assert len(mock_call.return_value.mock_calls) == 1 -async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) -> None: +async def test_setup_retrying_during_unload_before_started( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test if we unload an entry that is in retry mode before started.""" entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) hass.set_state(CoreState.starting) initial_listeners = hass.bus.async_listeners()[EVENT_HOMEASSISTANT_STARTED] @@ -1246,7 +1410,7 @@ async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY @@ -1254,7 +1418,7 @@ async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) hass.bus.async_listeners()[EVENT_HOMEASSISTANT_STARTED] == initial_listeners + 1 ) - await entry.async_unload(hass) + await manager.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.NOT_LOADED @@ -1263,15 +1427,18 @@ async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) ) -async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None: +async def test_setup_does_not_retry_during_shutdown( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test we do not retry when HASS is shutting down.""" entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY assert len(mock_setup_entry.mock_calls) == 1 @@ -1392,7 +1559,7 @@ async def test_entry_options( entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) - class TestFlow: + class TestFlow(config_entries.ConfigFlow): """Test flow.""" @staticmethod @@ -1405,25 +1572,24 @@ async def test_entry_options( return OptionsFlowHandler() - def async_supports_options_flow(self, entry: MockConfigEntry) -> bool: - """Test options flow.""" - return True + with mock_config_flow("test", TestFlow): + flow = await manager.options.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) - config_entries.HANDLERS["test"] = TestFlow() - flow = await manager.options.async_create_flow( - entry.entry_id, context={"source": "test"}, data=None - ) + flow.handler = entry.entry_id # Used to keep reference to config entry - flow.handler = entry.entry_id # Used to keep reference to config entry + await manager.options.async_finish_flow( + flow, + { + "data": {"second": True}, + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + }, + ) - await manager.options.async_finish_flow( - flow, - {"data": {"second": True}, "type": data_entry_flow.FlowResultType.CREATE_ENTRY}, - ) - - assert entry.data == {"first": True} - assert entry.options == {"second": True} - assert entry.supports_options is True + assert entry.data == {"first": True} + assert entry.options == {"second": True} + assert entry.supports_options is True async def test_entry_options_abort( @@ -1435,7 +1601,7 @@ async def test_entry_options_abort( entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) - class TestFlow: + class TestFlow(config_entries.ConfigFlow): """Test flow.""" @staticmethod @@ -1448,16 +1614,16 @@ async def test_entry_options_abort( return OptionsFlowHandler() - config_entries.HANDLERS["test"] = TestFlow() - flow = await manager.options.async_create_flow( - entry.entry_id, context={"source": "test"}, data=None - ) + with mock_config_flow("test", TestFlow): + flow = await manager.options.async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) - flow.handler = entry.entry_id # Used to keep reference to config entry + flow.handler = entry.entry_id # Used to keep reference to config entry - assert await manager.options.async_finish_flow( - flow, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "test"} - ) + assert await manager.options.async_finish_flow( + flow, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "test"} + ) async def test_entry_options_unknown_config_entry( @@ -1544,16 +1710,25 @@ async def test_entry_unload_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can unload an entry.""" + unloads_called = [] + + async def verify_runtime_data(*args): + """Verify runtime data.""" + assert entry.runtime_data == 2 + unloads_called.append(args) + return True + entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) + entry.async_on_unload(verify_runtime_data) + entry.runtime_data = 2 - async_unload_entry = AsyncMock(return_value=True) - - mock_integration(hass, MockModule("comp", async_unload_entry=async_unload_entry)) + mock_integration(hass, MockModule("comp", async_unload_entry=verify_runtime_data)) assert await manager.async_unload(entry.entry_id) - assert len(async_unload_entry.mock_calls) == 1 + assert len(unloads_called) == 2 assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert not hasattr(entry, "runtime_data") @pytest.mark.parametrize( @@ -1613,7 +1788,9 @@ async def test_entry_reload_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can reload an entry.""" - entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) entry.add_to_hass(hass) async_setup = AsyncMock(return_value=True) @@ -1637,6 +1814,134 @@ async def test_entry_reload_succeed( assert entry.state is config_entries.ConfigEntryState.LOADED +@pytest.mark.parametrize( + "state", + [ + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.SETUP_IN_PROGRESS, + ], +) +async def test_entry_cannot_be_loaded_twice( + hass: HomeAssistant, state: config_entries.ConfigEntryState +) -> None: + """Test that a config entry cannot be loaded twice.""" + entry = MockConfigEntry(domain="comp", state=state) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + with pytest.raises(config_entries.OperationNotAllowed, match=str(state)): + await entry.async_setup(hass) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state is state + + +async def test_entry_setup_without_lock_raises(hass: HomeAssistant) -> None: + """Test trying to setup a config entry without the lock.""" + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + with pytest.raises( + config_entries.OperationNotAllowed, + match="cannot be set up because it does not hold the setup lock", + ): + await entry.async_setup(hass) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_entry_unload_without_lock_raises(hass: HomeAssistant) -> None: + """Test trying to unload a config entry without the lock.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + with pytest.raises( + config_entries.OperationNotAllowed, + match="cannot be unloaded because it does not hold the setup lock", + ): + await entry.async_unload(hass) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state is config_entries.ConfigEntryState.LOADED + + +async def test_entry_remove_without_lock_raises(hass: HomeAssistant) -> None: + """Test trying to remove a config entry without the lock.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + async_setup_entry = AsyncMock(return_value=True) + async_unload_entry = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + + with pytest.raises( + config_entries.OperationNotAllowed, + match="cannot be removed because it does not hold the setup lock", + ): + await entry.async_remove(hass) + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + assert entry.state is config_entries.ConfigEntryState.LOADED + + @pytest.mark.parametrize( "state", [ @@ -2077,7 +2382,7 @@ async def test_entry_id_existing_entry( pytest.raises(HomeAssistantError), patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( - "homeassistant.config_entries.uuid_util.random_uuid_hex", + "homeassistant.config_entries.ulid_util.ulid_now", return_value=collide_entry_id, ), ): @@ -2543,7 +2848,7 @@ async def test_manual_add_overrides_ignored_entry_singleton( assert p_entry.data == {"token": "supersecret"} -async def test__async_current_entries_does_not_skip_ignore_non_user( +async def test_async_current_entries_does_not_skip_ignore_non_user( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries does not skip ignore by default for non user step.""" @@ -2580,7 +2885,7 @@ async def test__async_current_entries_does_not_skip_ignore_non_user( assert len(mock_setup_entry.mock_calls) == 0 -async def test__async_current_entries_explicit_skip_ignore( +async def test_async_current_entries_explicit_skip_ignore( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries can explicitly include ignore.""" @@ -2621,7 +2926,7 @@ async def test__async_current_entries_explicit_skip_ignore( assert p_entry.data == {"token": "supersecret"} -async def test__async_current_entries_explicit_include_ignore( +async def test_async_current_entries_explicit_include_ignore( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries can explicitly include ignore.""" @@ -3419,10 +3724,13 @@ async def test_entry_reload_calls_on_unload_listeners( async def test_setup_raise_entry_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a setup raising ConfigEntryError.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) mock_setup_entry = AsyncMock( side_effect=ConfigEntryError("Incompatible firmware version") @@ -3430,7 +3738,7 @@ async def test_setup_raise_entry_error( mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert ( "Error setting up entry test_title for test: Incompatible firmware version" @@ -3442,10 +3750,13 @@ async def test_setup_raise_entry_error( async def test_setup_raise_entry_error_from_first_coordinator_update( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test async_config_entry_first_refresh raises ConfigEntryError.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) async def async_setup_entry(hass, entry): """Mock setup entry with a simple coordinator.""" @@ -3467,7 +3778,7 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert ( "Error setting up entry test_title for test: Incompatible firmware version" @@ -3479,10 +3790,13 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( async def test_setup_not_raise_entry_error_from_future_coordinator_update( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a coordinator not raises ConfigEntryError in the future.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) async def async_setup_entry(hass, entry): """Mock setup entry with a simple coordinator.""" @@ -3504,7 +3818,7 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert ( "Config entry setup failed while fetching any data: Incompatible firmware" @@ -3515,10 +3829,13 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( async def test_setup_raise_auth_failed( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a setup raising ConfigEntryAuthFailed.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) mock_setup_entry = AsyncMock( side_effect=ConfigEntryAuthFailed("The password is no longer valid") @@ -3526,7 +3843,7 @@ async def test_setup_raise_auth_failed( mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert "could not authenticate: The password is no longer valid" in caplog.text @@ -3541,7 +3858,7 @@ async def test_setup_raise_auth_failed( caplog.clear() entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert "could not authenticate: The password is no longer valid" in caplog.text @@ -3552,10 +3869,13 @@ async def test_setup_raise_auth_failed( async def test_setup_raise_auth_failed_from_first_coordinator_update( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test async_config_entry_first_refresh raises ConfigEntryAuthFailed.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) async def async_setup_entry(hass, entry): """Mock setup entry with a simple coordinator.""" @@ -3577,7 +3897,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert "could not authenticate: The password is no longer valid" in caplog.text @@ -3590,7 +3910,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( caplog.clear() entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert "could not authenticate: The password is no longer valid" in caplog.text @@ -3601,10 +3921,13 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( async def test_setup_raise_auth_failed_from_future_coordinator_update( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a coordinator raises ConfigEntryAuthFailed in the future.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) async def async_setup_entry(hass, entry): """Mock setup entry with a simple coordinator.""" @@ -3626,7 +3949,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( mock_integration(hass, MockModule("test", async_setup_entry=async_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert "Authentication failed while fetching" in caplog.text assert "The password is no longer valid" in caplog.text @@ -3640,7 +3963,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( caplog.clear() entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() assert "Authentication failed while fetching" in caplog.text assert "The password is no longer valid" in caplog.text @@ -3663,16 +3986,19 @@ async def test_initialize_and_shutdown(hass: HomeAssistant) -> None: assert mock_async_shutdown.called -async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: +async def test_setup_retrying_during_shutdown( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test if we shutdown an entry that is in retry mode.""" entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) with patch("homeassistant.helpers.event.async_call_later") as mock_call: - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY assert len(mock_call.return_value.mock_calls) == 0 @@ -3691,7 +4017,9 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: entry.async_cancel_retry_setup() -async def test_scheduling_reload_cancels_setup_retry(hass: HomeAssistant) -> None: +async def test_scheduling_reload_cancels_setup_retry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test scheduling a reload cancels setup retry.""" entry = MockConfigEntry(domain="test") entry.add_to_hass(hass) @@ -3704,7 +4032,7 @@ async def test_scheduling_reload_cancels_setup_retry(hass: HomeAssistant) -> Non with patch( "homeassistant.config_entries.async_call_later", return_value=cancel_mock ): - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY assert len(cancel_mock.mock_calls) == 0 @@ -3750,7 +4078,7 @@ async def test_scheduling_reload_unknown_entry(hass: HomeAssistant) -> None: ), ], ) -async def test__async_abort_entries_match( +async def test_async_abort_entries_match( hass: HomeAssistant, manager: config_entries.ConfigEntries, matchers: dict[str, str], @@ -3833,7 +4161,7 @@ async def test__async_abort_entries_match( ), ], ) -async def test__async_abort_entries_match_options_flow( +async def test_async_abort_entries_match_options_flow( hass: HomeAssistant, manager: config_entries.ConfigEntries, matchers: dict[str, str], @@ -4005,7 +4333,9 @@ async def test_entry_reload_concurrency_not_setup_setup( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test multiple reload calls do not cause a reload race.""" - entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) entry.add_to_hass(hass) async_setup = AsyncMock(return_value=True) @@ -4132,16 +4462,20 @@ async def test_disallow_entry_reload_with_setup_in_progress( assert entry.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test the async_reauth_helper.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) entry2 = MockConfigEntry(title="test_title", domain="test") + entry2.add_to_hass(hass) mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() flow = hass.config_entries.flow @@ -4194,16 +4528,20 @@ async def test_reauth(hass: HomeAssistant) -> None: assert len(hass.config_entries.flow.async_progress()) == 1 -async def test_reconfigure(hass: HomeAssistant) -> None: +async def test_reconfigure( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test the async_reconfigure_helper.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) entry2 = MockConfigEntry(title="test_title", domain="test") + entry2.add_to_hass(hass) mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() flow = hass.config_entries.flow @@ -4282,14 +4620,17 @@ async def test_reconfigure(hass: HomeAssistant) -> None: assert len(hass.config_entries.flow.async_progress()) == 1 -async def test_get_active_flows(hass: HomeAssistant) -> None: +async def test_get_active_flows( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test the async_get_active_flows helper.""" entry = MockConfigEntry(title="test_title", domain="test") + entry.add_to_hass(hass) mock_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) mock_platform(hass, "test.config_flow", None) - await entry.async_setup(hass) + await manager.async_setup(entry.entry_id) await hass.async_block_till_done() flow = hass.config_entries.flow @@ -4523,7 +4864,7 @@ async def test_preview_not_supported( VERSION = 1 - async def async_step_user(self, data): + async def async_step_user(self, user_input): """Mock Reauth.""" return self.async_show_form(step_id="user_confirm") @@ -4672,20 +5013,21 @@ async def test_unhashable_unique_id( """Test the ConfigEntryItems user dict handles unhashable unique_id.""" entries = config_entries.ConfigEntryItems(hass) entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + data={}, domain="test", entry_id="mock_id", - title="title", - data={}, + minor_version=1, + options={}, source="test", + title="title", unique_id=unique_id, + version=1, ) entries[entry.entry_id] = entry assert ( "Config entry 'title' from integration test has an invalid unique_id " - f"'{str(unique_id)}'" + f"'{unique_id!s}'" ) in caplog.text assert entry.entry_id in entries @@ -4703,14 +5045,15 @@ async def test_hashable_non_string_unique_id( """Test the ConfigEntryItems user dict handles hashable non string unique_id.""" entries = config_entries.ConfigEntryItems(hass) entry = config_entries.ConfigEntry( - version=1, - minor_version=1, + data={}, domain="test", entry_id="mock_id", - title="title", - data={}, + minor_version=1, + options={}, source="test", + title="title", unique_id=unique_id, + version=1, ) entries[entry.entry_id] = entry @@ -4739,6 +5082,11 @@ async def test_hashable_non_string_unique_id( None, {"type": data_entry_flow.FlowResultType.FORM, "step_id": "reauth_confirm"}, ), + ( + config_entries.SOURCE_RECONFIGURE, + None, + {"type": data_entry_flow.FlowResultType.FORM, "step_id": "reauth_confirm"}, + ), ( config_entries.SOURCE_UNIGNORE, None, @@ -4823,6 +5171,11 @@ async def test_starting_config_flow_on_single_config_entry( None, {"type": data_entry_flow.FlowResultType.FORM, "step_id": "reauth_confirm"}, ), + ( + config_entries.SOURCE_RECONFIGURE, + None, + {"type": data_entry_flow.FlowResultType.FORM, "step_id": "reauth_confirm"}, + ), ( config_entries.SOURCE_UNIGNORE, None, @@ -5110,3 +5463,443 @@ async def test_reload_during_setup(hass: HomeAssistant) -> None: await setup_task await reload_task assert setup_calls == 2 + + +@pytest.mark.parametrize( + "exc", + [ + ConfigEntryError, + ConfigEntryAuthFailed, + ConfigEntryNotReady, + ], +) +async def test_raise_wrong_exception_in_forwarded_platform( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + exc: Exception, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we can remove an entry.""" + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + await hass.config_entries.async_forward_entry_setups(entry, ["light"]) + return True + + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unloading an entry.""" + result = await hass.config_entries.async_unload_platforms(entry, ["light"]) + assert result + return result + + mock_remove_entry = AsyncMock(return_value=None) + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + raise exc + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry(domain="test", entry_id="test2") + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + exc_type_name = type(exc()).__name__ + assert ( + f"test raises exception {exc_type_name} in forwarded platform light;" + in caplog.text + ) + assert ( + f"Instead raise {exc_type_name} before calling async_forward_entry_setups" + in caplog.text + ) + + +async def test_config_entry_unloaded_during_platform_setups( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_forward_entry_setups not being awaited.""" + task = None + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + + # Call async_forward_entry_setups in a non-tracked task + # so we can unload the config entry during the setup + def _late_setup(): + nonlocal task + task = asyncio.create_task( + hass.config_entries.async_forward_entry_setups(entry, ["light"]) + ) + + hass.loop.call_soon(_late_setup) + return True + + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unloading an entry.""" + result = await hass.config_entries.async_unload_platforms(entry, ["light"]) + assert result + return result + + mock_remove_entry = AsyncMock(return_value=None) + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + await asyncio.sleep(0) + await asyncio.sleep(0) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry(domain="test", entry_id="test2") + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + await manager.async_unload(entry.entry_id) + await hass.async_block_till_done() + del task + + assert ( + "OperationNotAllowed: The config entry 'Mock Title' (test) with " + "entry_id 'test2' cannot forward setup for ['light'] because it is " + "in state ConfigEntryState.NOT_LOADED, but needs to be in the " + "ConfigEntryState.LOADED state" + ) in caplog.text + + +async def test_non_awaited_async_forward_entry_setups( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_forward_entry_setups not being awaited.""" + forward_event = asyncio.Event() + task: asyncio.Task | None = None + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + # Call async_forward_entry_setups without awaiting it + # This is not allowed and will raise a warning + nonlocal task + task = create_eager_task( + hass.config_entries.async_forward_entry_setups(entry, ["light"]) + ) + return True + + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unloading an entry.""" + result = await hass.config_entries.async_unload_platforms(entry, ["light"]) + assert result + return result + + mock_remove_entry = AsyncMock(return_value=None) + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + await forward_event.wait() + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry(domain="test", entry_id="test2") + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + forward_event.set() + await hass.async_block_till_done() + await task + + assert ( + "Detected code that calls async_forward_entry_setups for integration " + "test with title: Mock Title and entry_id: test2, during setup without " + "awaiting async_forward_entry_setups, which can cause the setup lock " + "to be released before the setup is done. This will stop working in " + "Home Assistant 2025.1. Please report this issue." + ) in caplog.text + + +async def test_non_awaited_async_forward_entry_setup( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_forward_entry_setup not being awaited.""" + forward_event = asyncio.Event() + task: asyncio.Task | None = None + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + # Call async_forward_entry_setup without awaiting it + # This is not allowed and will raise a warning + nonlocal task + task = create_eager_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) + return True + + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unloading an entry.""" + result = await hass.config_entries.async_unload_platforms(entry, ["light"]) + assert result + return result + + mock_remove_entry = AsyncMock(return_value=None) + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + await forward_event.wait() + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry(domain="test", entry_id="test2") + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + forward_event.set() + await hass.async_block_till_done() + await task + + assert ( + "Detected code that calls async_forward_entry_setup for integration " + "test with title: Mock Title and entry_id: test2, during setup without " + "awaiting async_forward_entry_setup, which can cause the setup lock " + "to be released before the setup is done. This will stop working in " + "Home Assistant 2025.1. Please report this issue." + ) in caplog.text + + +async def test_config_entry_unloaded_during_platform_setup( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_forward_entry_setup not being awaited.""" + task = None + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + + # Call async_forward_entry_setup in a non-tracked task + # so we can unload the config entry during the setup + def _late_setup(): + nonlocal task + task = asyncio.create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) + + hass.loop.call_soon(_late_setup) + return True + + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unloading an entry.""" + result = await hass.config_entries.async_unload_platforms(entry, ["light"]) + assert result + return result + + mock_remove_entry = AsyncMock(return_value=None) + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + await asyncio.sleep(0) + await asyncio.sleep(0) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry(domain="test", entry_id="test2") + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + await manager.async_unload(entry.entry_id) + await hass.async_block_till_done() + del task + + assert ( + "OperationNotAllowed: The config entry 'Mock Title' (test) with " + "entry_id 'test2' cannot forward setup for light because it is " + "in state ConfigEntryState.NOT_LOADED, but needs to be in the " + "ConfigEntryState.LOADED state" + ) in caplog.text + + +async def test_config_entry_late_platform_setup( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_forward_entry_setup not being awaited.""" + task = None + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + + # Call async_forward_entry_setup in a non-tracked task + # so we can unload the config entry during the setup + def _late_setup(): + nonlocal task + task = asyncio.create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) + + hass.loop.call_soon(_late_setup) + return True + + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unloading an entry.""" + result = await hass.config_entries.async_unload_platforms(entry, ["light"]) + assert result + return result + + mock_remove_entry = AsyncMock(return_value=None) + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + await asyncio.sleep(0) + await asyncio.sleep(0) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry(domain="test", entry_id="test2") + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + await task + await hass.async_block_till_done() + + assert ( + "OperationNotAllowed: The config entry Mock Title (test) with " + "entry_id test2 cannot forward setup for light because it is " + "not loaded in the ConfigEntryState.NOT_LOADED state" + ) not in caplog.text diff --git a/tests/test_const.py b/tests/test_const.py index 63b01388dd7..a6a2387b091 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -7,7 +7,7 @@ import pytest from homeassistant import const from homeassistant.components import sensor -from tests.common import ( +from .common import ( help_test_all, import_and_test_deprecated_constant, import_and_test_deprecated_constant_enum, diff --git a/tests/test_core.py b/tests/test_core.py index 2dcd23db9a6..a1748638342 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,6 +9,7 @@ import functools import gc import logging import os +import re from tempfile import TemporaryDirectory import threading import time @@ -422,11 +423,11 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: try: if ha.async_get_hass() is hass: return True - raise Exception + raise Exception # pylint: disable=broad-exception-raised except HomeAssistantError: return False - raise Exception + raise Exception # pylint: disable=broad-exception-raised # Test scheduling a coroutine which calls async_get_hass via hass.async_create_task async def _async_create_task() -> None: @@ -646,7 +647,9 @@ async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: assert hass.state is CoreState.stopped -async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: +async def test_stage_shutdown_generic_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Simulate a shutdown, test that a generic error at the final stage doesn't prevent it.""" task = asyncio.Future() @@ -1834,7 +1837,6 @@ async def test_serviceregistry_return_response_invalid( blocking=True, return_response=True, ) - await hass.async_block_till_done() @pytest.mark.parametrize( @@ -1934,6 +1936,7 @@ async def test_config_defaults() -> None: assert config.currency == "EUR" assert config.country is None assert config.language == "en" + assert config.radius == 100 async def test_config_path_with_file() -> None: @@ -1981,6 +1984,7 @@ async def test_config_as_dict() -> None: "language": "en", "safe_mode": False, "debug": False, + "radius": 100, } assert expected == config.as_dict() @@ -1997,7 +2001,7 @@ async def test_config_is_allowed_path() -> None: config.allowlist_external_dirs = {os.path.realpath(tmp_dir)} test_file = os.path.join(tmp_dir, "test.jpg") - with open(test_file, "w") as tmp_file: + with open(test_file, "w", encoding="utf8") as tmp_file: tmp_file.write("test") valid = [test_file, tmp_dir, os.path.join(tmp_dir, "notfound321")] @@ -2230,7 +2234,7 @@ async def test_async_run_job_starts_coro_eagerly(hass: HomeAssistant) -> None: def test_valid_entity_id() -> None: """Test valid entity ID.""" - for invalid in [ + for invalid in ( "_light.kitchen", ".kitchen", ".light.kitchen", @@ -2243,10 +2247,10 @@ def test_valid_entity_id() -> None: "Light.kitchen", "light.Kitchen", "lightkitchen", - ]: + ): assert not ha.valid_entity_id(invalid), invalid - for valid in [ + for valid in ( "1.a", "1light.kitchen", "a.1", @@ -2255,13 +2259,13 @@ def test_valid_entity_id() -> None: "light.1kitchen", "light.kitchen", "light.something_yoo", - ]: + ): assert ha.valid_entity_id(valid), valid def test_valid_domain() -> None: """Test valid domain.""" - for invalid in [ + for invalid in ( "_light", ".kitchen", ".light.kitchen", @@ -2272,16 +2276,16 @@ def test_valid_domain() -> None: "light.kitchen_yo_", "light.kitchen.", "Light", - ]: + ): assert not ha.valid_domain(invalid), invalid - for valid in [ + for valid in ( "1", "1light", "a", "input_boolean", "light", - ]: + ): assert ha.valid_domain(valid), valid @@ -2833,8 +2837,32 @@ async def test_state_change_events_context_id_match_state_time( assert state.last_updated == events[0].time_fired assert len(state.context.id) == 26 # ULIDs store time to 3 decimal places compared to python timestamps - assert _ulid_timestamp(state.context.id) == int( - state.last_updated.timestamp() * 1000 + assert _ulid_timestamp(state.context.id) == int(state.last_updated_timestamp * 1000) + + +async def test_state_change_events_match_time_with_limits_of_precision( + hass: HomeAssistant, +) -> None: + """Ensure last_updated matches last_updated_timestamp within limits of precision. + + The last_updated_timestamp uses the same precision as time.time() which is + a bit better than the precision of datetime.now() which is used for last_updated + on some platforms. + """ + events = async_capture_events(hass, ha.EVENT_STATE_CHANGED) + hass.states.async_set("light.bedroom", "on") + await hass.async_block_till_done() + state: State = hass.states.get("light.bedroom") + assert state.last_updated == events[0].time_fired + assert state.last_updated_timestamp == pytest.approx( + events[0].time_fired.timestamp() + ) + assert state.last_updated_timestamp == pytest.approx(state.last_updated.timestamp()) + assert state.last_updated_timestamp == state.last_changed_timestamp + assert state.last_updated_timestamp == pytest.approx(state.last_changed.timestamp()) + assert state.last_updated_timestamp == state.last_reported_timestamp + assert state.last_updated_timestamp == pytest.approx( + state.last_reported.timestamp() ) @@ -3066,14 +3094,14 @@ async def test_get_release_channel( assert get_release_channel() == release_channel -def test_is_callback_check_partial(): +def test_is_callback_check_partial() -> None: """Test is_callback_check_partial matches HassJob.""" @ha.callback - def callback_func(): + def callback_func() -> None: pass - def not_callback_func(): + def not_callback_func() -> None: pass assert ha.is_callback(callback_func) @@ -3102,14 +3130,14 @@ def test_is_callback_check_partial(): ) -def test_hassjob_passing_job_type(): +def test_hassjob_passing_job_type() -> None: """Test passing the job type to HassJob when we already know it.""" @ha.callback - def callback_func(): + def callback_func() -> None: pass - def not_callback_func(): + def not_callback_func() -> None: pass assert ( @@ -3209,7 +3237,7 @@ async def test_async_run_job_deprecated( ) -> None: """Test async_run_job warns about its deprecation.""" - async def _test(): + async def _test() -> None: pass hass.async_run_job(_test) @@ -3226,7 +3254,7 @@ async def test_async_add_job_deprecated( ) -> None: """Test async_add_job warns about its deprecation.""" - async def _test(): + async def _test() -> None: pass hass.async_add_job(_test) @@ -3243,7 +3271,7 @@ async def test_async_add_hass_job_deprecated( ) -> None: """Test async_add_hass_job warns about its deprecation.""" - async def _test(): + async def _test() -> None: pass hass.async_add_hass_job(HassJob(_test)) @@ -3345,7 +3373,9 @@ async def test_statemachine_report_state(hass: HomeAssistant) -> None: hass.states.async_set("light.bowl", "on", {}) state_changed_events = async_capture_events(hass, EVENT_STATE_CHANGED) state_reported_events = [] - hass.bus.async_listen(EVENT_STATE_REPORTED, listener, event_filter=mock_filter) + unsub = hass.bus.async_listen( + EVENT_STATE_REPORTED, listener, event_filter=mock_filter + ) hass.states.async_set("light.bowl", "on") await hass.async_block_till_done() @@ -3367,6 +3397,13 @@ async def test_statemachine_report_state(hass: HomeAssistant) -> None: assert len(state_changed_events) == 3 assert len(state_reported_events) == 4 + unsub() + + hass.states.async_set("light.bowl", "on") + await hass.async_block_till_done() + assert len(state_changed_events) == 4 + assert len(state_reported_events) == 4 + async def test_report_state_listener_restrictions(hass: HomeAssistant) -> None: """Test we enforce requirements for EVENT_STATE_REPORTED listeners.""" @@ -3442,7 +3479,8 @@ async def test_async_fire_thread_safety(hass: HomeAssistant) -> None: events = async_capture_events(hass, "test_event") hass.bus.async_fire("test_event") with pytest.raises( - RuntimeError, match="Detected code that calls async_fire from a thread." + RuntimeError, + match="Detected code that calls hass.bus.async_fire from a thread.", ): await hass.async_add_executor_job(hass.bus.async_fire, "test_event") @@ -3452,7 +3490,8 @@ async def test_async_fire_thread_safety(hass: HomeAssistant) -> None: async def test_async_register_thread_safety(hass: HomeAssistant) -> None: """Test async_register thread safety.""" with pytest.raises( - RuntimeError, match="Detected code that calls async_register from a thread." + RuntimeError, + match="Detected code that calls hass.services.async_register from a thread.", ): await hass.async_add_executor_job( hass.services.async_register, @@ -3465,7 +3504,8 @@ async def test_async_register_thread_safety(hass: HomeAssistant) -> None: async def test_async_remove_thread_safety(hass: HomeAssistant) -> None: """Test async_remove thread safety.""" with pytest.raises( - RuntimeError, match="Detected code that calls async_remove from a thread." + RuntimeError, + match="Detected code that calls hass.services.async_remove from a thread.", ): await hass.async_add_executor_job( hass.services.async_remove, "test_domain", "test_service" @@ -3479,6 +3519,50 @@ async def test_async_create_task_thread_safety(hass: HomeAssistant) -> None: pass with pytest.raises( - RuntimeError, match="Detected code that calls async_create_task from a thread." + RuntimeError, + match="Detected code that calls hass.async_create_task from a thread.", ): await hass.async_add_executor_job(hass.async_create_task, _any_coro) + + +async def test_thread_safety_message(hass: HomeAssistant) -> None: + """Test the thread safety message.""" + with pytest.raises( + RuntimeError, + match=re.escape( + "Detected code that calls test from a thread other than the event loop, " + "which may cause Home Assistant to crash or data to corrupt. For more " + "information, see " + "https://developers.home-assistant.io/docs/asyncio_thread_safety/#test" + ". Please report this issue.", + ), + ): + await hass.async_add_executor_job(hass.verify_event_loop_thread, "test") + + +async def test_set_time_zone_deprecated(hass: HomeAssistant) -> None: + """Test set_time_zone is deprecated.""" + with pytest.raises( + RuntimeError, + match=re.escape( + "Detected code that set the time zone using set_time_zone instead of " + "async_set_time_zone which will stop working in Home Assistant 2025.6. " + "Please report this issue.", + ), + ): + await hass.config.set_time_zone("America/New_York") + + +async def test_async_set_updates_last_reported(hass: HomeAssistant) -> None: + """Test async_set method updates last_reported AND last_reported_timestamp.""" + hass.states.async_set("light.bowl", "on", {}) + state = hass.states.get("light.bowl") + last_reported = state.last_reported + last_reported_timestamp = state.last_reported_timestamp + + for _ in range(2): + hass.states.async_set("light.bowl", "on", {}) + assert state.last_reported != last_reported + assert state.last_reported_timestamp != last_reported_timestamp + last_reported = state.last_reported + last_reported_timestamp = state.last_reported_timestamp diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 312e2be7602..782f349f9f2 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -19,40 +19,42 @@ from .common import ( ) +class MockFlowManager(data_entry_flow.FlowManager): + """Test flow manager.""" + + def __init__(self) -> None: + """Initialize the flow manager.""" + super().__init__(None) + self._handlers = Registry() + self.mock_reg_handler = self._handlers.register + self.mock_created_entries = [] + + async def async_create_flow(self, handler_key, *, context, data): + """Test create flow.""" + handler = self._handlers.get(handler_key) + + if handler is None: + raise data_entry_flow.UnknownHandler + + flow = handler() + flow.init_step = context.get("init_step", "init") + return flow + + async def async_finish_flow(self, flow, result): + """Test finish flow.""" + if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: + result["source"] = flow.context.get("source") + self.mock_created_entries.append(result) + return result + + @pytest.fixture -def manager(): +def manager() -> MockFlowManager: """Return a flow manager.""" - handlers = Registry() - entries = [] - - class FlowManager(data_entry_flow.FlowManager): - """Test flow manager.""" - - async def async_create_flow(self, handler_key, *, context, data): - """Test create flow.""" - handler = handlers.get(handler_key) - - if handler is None: - raise data_entry_flow.UnknownHandler - - flow = handler() - flow.init_step = context.get("init_step", "init") - return flow - - async def async_finish_flow(self, flow, result): - """Test finish flow.""" - if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: - result["source"] = flow.context.get("source") - entries.append(result) - return result - - mgr = FlowManager(None) - mgr.mock_created_entries = entries - mgr.mock_reg_handler = handlers.register - return mgr + return MockFlowManager() -async def test_configure_reuses_handler_instance(manager) -> None: +async def test_configure_reuses_handler_instance(manager: MockFlowManager) -> None: """Test that we reuse instances.""" @manager.mock_reg_handler("test") @@ -80,7 +82,7 @@ async def test_configure_reuses_handler_instance(manager) -> None: assert len(manager.mock_created_entries) == 0 -async def test_configure_two_steps(manager: data_entry_flow.FlowManager) -> None: +async def test_configure_two_steps(manager: MockFlowManager) -> None: """Test that we reuse instances.""" @manager.mock_reg_handler("test") @@ -115,7 +117,7 @@ async def test_configure_two_steps(manager: data_entry_flow.FlowManager) -> None assert result["data"] == ["INIT-DATA", "SECOND-DATA"] -async def test_show_form(manager) -> None: +async def test_show_form(manager: MockFlowManager) -> None: """Test that we can show a form.""" schema = vol.Schema({vol.Required("username"): str, vol.Required("password"): str}) @@ -134,7 +136,7 @@ async def test_show_form(manager) -> None: assert form["errors"] == {"username": "Should be unique."} -async def test_abort_removes_instance(manager) -> None: +async def test_abort_removes_instance(manager: MockFlowManager) -> None: """Test that abort removes the flow from progress.""" @manager.mock_reg_handler("test") @@ -156,7 +158,7 @@ async def test_abort_removes_instance(manager) -> None: assert len(manager.mock_created_entries) == 0 -async def test_abort_calls_async_remove(manager) -> None: +async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: """Test abort calling the async_remove FlowHandler method.""" @manager.mock_reg_handler("test") @@ -175,7 +177,7 @@ async def test_abort_calls_async_remove(manager) -> None: async def test_abort_calls_async_remove_with_exception( - manager, caplog: pytest.LogCaptureFixture + manager: MockFlowManager, caplog: pytest.LogCaptureFixture ) -> None: """Test abort calling the async_remove FlowHandler method, with an exception.""" @@ -197,7 +199,7 @@ async def test_abort_calls_async_remove_with_exception( assert len(manager.mock_created_entries) == 0 -async def test_create_saves_data(manager) -> None: +async def test_create_saves_data(manager: MockFlowManager) -> None: """Test creating a config entry.""" @manager.mock_reg_handler("test") @@ -218,7 +220,7 @@ async def test_create_saves_data(manager) -> None: assert entry["source"] is None -async def test_discovery_init_flow(manager) -> None: +async def test_discovery_init_flow(manager: MockFlowManager) -> None: """Test a flow initialized by discovery.""" @manager.mock_reg_handler("test") @@ -258,7 +260,7 @@ async def test_finish_callback_change_result_type(hass: HomeAssistant) -> None: ) class FlowManager(data_entry_flow.FlowManager): - async def async_create_flow(self, handler_name, *, context, data): + async def async_create_flow(self, handler_key, *, context, data): """Create a test flow.""" return TestFlow() @@ -288,7 +290,7 @@ async def test_finish_callback_change_result_type(hass: HomeAssistant) -> None: assert result["result"] == 2 -async def test_external_step(hass: HomeAssistant, manager) -> None: +async def test_external_step(hass: HomeAssistant, manager: MockFlowManager) -> None: """Test external step logic.""" manager.hass = hass @@ -338,7 +340,7 @@ async def test_external_step(hass: HomeAssistant, manager) -> None: assert result["title"] == "Hello" -async def test_show_progress(hass: HomeAssistant, manager) -> None: +async def test_show_progress(hass: HomeAssistant, manager: MockFlowManager) -> None: """Test show progress logic.""" manager.hass = hass events = [] @@ -441,7 +443,9 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: assert result["title"] == "Hello" -async def test_show_progress_error(hass: HomeAssistant, manager) -> None: +async def test_show_progress_error( + hass: HomeAssistant, manager: MockFlowManager +) -> None: """Test show progress logic.""" manager.hass = hass events = [] @@ -504,7 +508,9 @@ async def test_show_progress_error(hass: HomeAssistant, manager) -> None: assert result["reason"] == "error" -async def test_show_progress_hidden_from_frontend(hass: HomeAssistant, manager) -> None: +async def test_show_progress_hidden_from_frontend( + hass: HomeAssistant, manager: MockFlowManager +) -> None: """Test show progress done is not sent to frontend.""" manager.hass = hass async_show_progress_done_called = False @@ -554,7 +560,9 @@ async def test_show_progress_hidden_from_frontend(hass: HomeAssistant, manager) assert async_show_progress_done_called -async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> None: +async def test_show_progress_legacy( + hass: HomeAssistant, manager: MockFlowManager, caplog: pytest.LogCaptureFixture +) -> None: """Test show progress logic. This tests the deprecated version where the config flow is responsible for @@ -655,7 +663,7 @@ async def test_show_progress_legacy(hass: HomeAssistant, manager, caplog) -> Non async def test_show_progress_fires_only_when_changed( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: MockFlowManager ) -> None: """Test show progress change logic.""" manager.hass = hass @@ -741,7 +749,7 @@ async def test_show_progress_fires_only_when_changed( ) # change (description placeholder) -async def test_abort_flow_exception(manager) -> None: +async def test_abort_flow_exception(manager: MockFlowManager) -> None: """Test that the AbortFlow exception works.""" @manager.mock_reg_handler("test") @@ -755,7 +763,7 @@ async def test_abort_flow_exception(manager) -> None: assert form["description_placeholders"] == {"placeholder": "yo"} -async def test_init_unknown_flow(manager) -> None: +async def test_init_unknown_flow(manager: MockFlowManager) -> None: """Test that UnknownFlow is raised when async_create_flow returns None.""" with ( @@ -765,7 +773,7 @@ async def test_init_unknown_flow(manager) -> None: await manager.async_init("test") -async def test_async_get_unknown_flow(manager) -> None: +async def test_async_get_unknown_flow(manager: MockFlowManager) -> None: """Test that UnknownFlow is raised when async_get is called with a flow_id that does not exist.""" with pytest.raises(data_entry_flow.UnknownFlow): @@ -773,7 +781,7 @@ async def test_async_get_unknown_flow(manager) -> None: async def test_async_has_matching_flow( - hass: HomeAssistant, manager: data_entry_flow.FlowManager + hass: HomeAssistant, manager: MockFlowManager ) -> None: """Test we can check for matching flows.""" manager.hass = hass @@ -850,7 +858,7 @@ async def test_async_has_matching_flow( async def test_move_to_unknown_step_raises_and_removes_from_in_progress( - manager, + manager: MockFlowManager, ) -> None: """Test that moving to an unknown step raises and removes the flow from in progress.""" @@ -876,7 +884,7 @@ async def test_move_to_unknown_step_raises_and_removes_from_in_progress( ], ) async def test_next_step_unknown_step_raises_and_removes_from_in_progress( - manager, result_type: str, params: dict[str, str] + manager: MockFlowManager, result_type: str, params: dict[str, str] ) -> None: """Test that moving to an unknown step raises and removes the flow from in progress.""" @@ -893,13 +901,17 @@ async def test_next_step_unknown_step_raises_and_removes_from_in_progress( assert manager.async_progress() == [] -async def test_configure_raises_unknown_flow_if_not_in_progress(manager) -> None: +async def test_configure_raises_unknown_flow_if_not_in_progress( + manager: MockFlowManager, +) -> None: """Test configure raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): await manager.async_configure("wrong_flow_id") -async def test_abort_raises_unknown_flow_if_not_in_progress(manager) -> None: +async def test_abort_raises_unknown_flow_if_not_in_progress( + manager: MockFlowManager, +) -> None: """Test abort raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): await manager.async_abort("wrong_flow_id") @@ -909,7 +921,11 @@ async def test_abort_raises_unknown_flow_if_not_in_progress(manager) -> None: "menu_options", [["target1", "target2"], {"target1": "Target 1", "target2": "Target 2"}], ) -async def test_show_menu(hass: HomeAssistant, manager, menu_options) -> None: +async def test_show_menu( + hass: HomeAssistant, + manager: MockFlowManager, + menu_options: list[str] | dict[str, str], +) -> None: """Test show menu.""" manager.hass = hass @@ -948,9 +964,7 @@ async def test_show_menu(hass: HomeAssistant, manager, menu_options) -> None: assert result["step_id"] == "target1" -async def test_find_flows_by_init_data_type( - manager: data_entry_flow.FlowManager, -) -> None: +async def test_find_flows_by_init_data_type(manager: MockFlowManager) -> None: """Test we can find flows by init data type.""" @dataclasses.dataclass diff --git a/tests/test_loader.py b/tests/test_loader.py index 404858200bc..ae5280b2dcd 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2,6 +2,7 @@ import asyncio import os +import pathlib import sys import threading from typing import Any @@ -15,6 +16,8 @@ from homeassistant.components import http, hue from homeassistant.components.hue import light as hue_light from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import frame +from homeassistant.helpers.json import json_dumps +from homeassistant.util.json import json_loads from .common import MockModule, async_get_persistent_notifications, mock_integration @@ -22,20 +25,20 @@ from .common import MockModule, async_get_persistent_notifications, mock_integra async def test_circular_component_dependencies(hass: HomeAssistant) -> None: """Test if we can detect circular dependencies of components.""" mock_integration(hass, MockModule("mod1")) - mock_integration(hass, MockModule("mod2", ["mod1"])) - mock_integration(hass, MockModule("mod3", ["mod1"])) - mod_4 = mock_integration(hass, MockModule("mod4", ["mod2", "mod3"])) + mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) + mock_integration(hass, MockModule("mod3", dependencies=["mod1"])) + mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"])) deps = await loader._async_component_dependencies(hass, mod_4) assert deps == {"mod1", "mod2", "mod3", "mod4"} # Create a circular dependency - mock_integration(hass, MockModule("mod1", ["mod4"])) + mock_integration(hass, MockModule("mod1", dependencies=["mod4"])) with pytest.raises(loader.CircularDependency): await loader._async_component_dependencies(hass, mod_4) # Create a different circular dependency - mock_integration(hass, MockModule("mod1", ["mod3"])) + mock_integration(hass, MockModule("mod1", dependencies=["mod3"])) with pytest.raises(loader.CircularDependency): await loader._async_component_dependencies(hass, mod_4) @@ -56,7 +59,7 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: """Test if we can detect nonexistent dependencies of components.""" - mod_1 = mock_integration(hass, MockModule("mod1", ["nonexistent"])) + mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"])) with pytest.raises(loader.IntegrationNotFound): await loader._async_component_dependencies(hass, mod_1) @@ -103,9 +106,8 @@ async def test_helpers_wrapper(hass: HomeAssistant) -> None: assert result == ["hello"] -async def test_custom_component_name( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_custom_component_name(hass: HomeAssistant) -> None: """Test the name attribute of custom components.""" with pytest.raises(loader.IntegrationNotFound): await loader.async_get_integration(hass, "test_standalone") @@ -128,15 +130,15 @@ async def test_custom_component_name( assert platform.__package__ == "custom_components.test" # Test custom components is mounted + # pylint: disable-next=import-outside-toplevel from custom_components.test_package import TEST assert TEST == 5 +@pytest.mark.usefixtures("enable_custom_integrations") async def test_log_warning_custom_component( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test that we log a warning when loading a custom component.""" @@ -147,10 +149,9 @@ async def test_log_warning_custom_component( assert "We found a custom integration test " in caplog.text +@pytest.mark.usefixtures("enable_custom_integrations") async def test_custom_integration_version_not_valid( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test that we log a warning when custom integrations have a invalid version.""" with pytest.raises(loader.IntegrationNotFound): @@ -176,10 +177,10 @@ async def test_custom_integration_version_not_valid( loader.BlockedIntegration(AwesomeVersion("2.0.0"), "breaks Home Assistant"), ], ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_custom_integration_version_blocked( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, blocked_versions, ) -> None: """Test that we log a warning when custom integrations have a blocked version.""" @@ -203,10 +204,10 @@ async def test_custom_integration_version_blocked( loader.BlockedIntegration(AwesomeVersion("1.0.0"), "breaks Home Assistant"), ], ) +@pytest.mark.usefixtures("enable_custom_integrations") async def test_custom_integration_version_not_blocked( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, blocked_versions, ) -> None: """Test that we log a warning when custom integrations have a blocked version.""" @@ -489,9 +490,8 @@ async def test_async_get_platforms_caches_failures_when_component_loaded( assert integration.get_platform_cached("light") is None -async def test_get_integration_legacy( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_integration_legacy(hass: HomeAssistant) -> None: """Test resolving integration.""" integration = await loader.async_get_integration(hass, "test_embedded") assert integration.get_component().DOMAIN == "test_embedded" @@ -499,9 +499,8 @@ async def test_get_integration_legacy( assert integration.get_platform_cached("switch") is not None -async def test_get_integration_custom_component( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_integration_custom_component(hass: HomeAssistant) -> None: """Test resolving integration.""" integration = await loader.async_get_integration(hass, "test_package") @@ -798,9 +797,8 @@ def _get_test_integration_with_usb_matcher(hass, name, config_flow): ) -async def test_get_custom_components( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_get_custom_components(hass: HomeAssistant) -> None: """Verify that custom components are cached.""" test_1_integration = _get_test_integration(hass, "test_1", False) test_2_integration = _get_test_integration(hass, "test_2", True) @@ -996,9 +994,8 @@ async def test_get_mqtt(hass: HomeAssistant) -> None: assert mqtt["test_2"] == ["test_2/discovery"] -async def test_import_platform_executor( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_import_platform_executor(hass: HomeAssistant) -> None: """Test import a platform in the executor.""" integration = await loader.async_get_integration( hass, "test_package_loaded_executor" @@ -1031,9 +1028,7 @@ async def test_get_custom_components_recovery_mode(hass: HomeAssistant) -> None: assert await loader.async_get_custom_components(hass) == {} -async def test_custom_integration_missing_version( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_custom_integration_missing_version(hass: HomeAssistant) -> None: """Test trying to load a custom integration without a version twice does not deadlock.""" with pytest.raises(loader.IntegrationNotFound): await loader.async_get_integration(hass, "test_no_version") @@ -1042,9 +1037,7 @@ async def test_custom_integration_missing_version( await loader.async_get_integration(hass, "test_no_version") -async def test_custom_integration_missing( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_custom_integration_missing(hass: HomeAssistant) -> None: """Test trying to load a custom integration that is missing twice not deadlock.""" with patch("homeassistant.loader.async_get_custom_components") as mock_get: mock_get.return_value = {} @@ -1108,20 +1101,27 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" # Integration domain is not currently deduced from module (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), - # Custom integration with known issue tracker + # Loaded custom integration with known issue tracker ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", None, CUSTOM_ISSUE_TRACKER), - # Custom integration without known issue tracker + # Loaded custom integration without known issue tracker (None, "custom_components.bla.sensor", None), ("bla_custom_no_tracker", "custom_components.bla_custom.sensor", None), ("bla_custom_no_tracker", None, None), ("hue", "custom_components.bla.sensor", None), + # Unloaded custom integration with known issue tracker + ("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER), + # Unloaded custom integration without known issue tracker + ("bla_custom_not_loaded_no_tracker", None, None), # Integration domain has priority over module ("bla_custom_no_tracker", "homeassistant.components.bla_custom.sensor", None), ], ) async def test_async_get_issue_tracker( - hass, domain: str | None, module: str | None, issue_tracker: str | None + hass: HomeAssistant, + domain: str | None, + module: str | None, + issue_tracker: str | None, ) -> None: """Test async_get_issue_tracker.""" mock_integration(hass, MockModule("bla_built_in")) @@ -1133,6 +1133,32 @@ async def test_async_get_issue_tracker( built_in=False, ) mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False) + + cust_unloaded_module = MockModule( + "bla_custom_not_loaded", + partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER}, + ) + cust_unloaded = loader.Integration( + hass, + f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_module.DOMAIN}", + pathlib.Path(""), + cust_unloaded_module.mock_manifest(), + set(), + ) + + cust_unloaded_no_tracker_module = MockModule("bla_custom_not_loaded_no_tracker") + cust_unloaded_no_tracker = loader.Integration( + hass, + f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_no_tracker_module.DOMAIN}", + pathlib.Path(""), + cust_unloaded_no_tracker_module.mock_manifest(), + set(), + ) + hass.data["custom_components"] = { + "bla_custom_not_loaded": cust_unloaded, + "bla_custom_not_loaded_no_tracker": cust_unloaded_no_tracker, + } + assert ( loader.async_get_issue_tracker(hass, integration_domain=domain, module=module) == issue_tracker @@ -1157,7 +1183,7 @@ async def test_async_get_issue_tracker( ], ) async def test_async_get_issue_tracker_no_hass( - hass, domain: str | None, module: str | None, issue_tracker: str + hass: HomeAssistant, domain: str | None, module: str | None, issue_tracker: str ) -> None: """Test async_get_issue_tracker.""" mock_integration(hass, MockModule("bla_built_in")) @@ -1190,7 +1216,7 @@ REPORT_CUSTOM_UNKNOWN = "report it to the custom integration author" ], ) async def test_async_suggest_report_issue( - hass, domain: str | None, module: str | None, report_issue: str + hass: HomeAssistant, domain: str | None, module: str | None, report_issue: str ) -> None: """Test async_suggest_report_issue.""" mock_integration(hass, MockModule("bla_built_in")) @@ -1218,14 +1244,16 @@ def test_import_executor_default(hass: HomeAssistant) -> None: assert built_in_comp.import_executor is True -async def test_config_folder_not_in_path(hass): +async def test_config_folder_not_in_path() -> None: """Test that config folder is not in path.""" # Verify that we are unable to import this file from top level with pytest.raises(ImportError): + # pylint: disable-next=import-outside-toplevel import check_config_not_in_path # noqa: F401 # Verify that we are able to load the file with absolute path + # pylint: disable-next=import-outside-toplevel,hass-relative-import import tests.testing_config.check_config_not_in_path # noqa: F401 @@ -1238,7 +1266,7 @@ async def test_hass_components_use_reported( ) integration_frame = frame.IntegrationFrame( custom_integration=True, - _frame=mock_integration_frame, + frame=mock_integration_frame, integration="test_integration_frame", module="custom_components.test_integration_frame", relative_filename="custom_components/test_integration_frame/__init__.py", @@ -1263,7 +1291,7 @@ async def test_hass_components_use_reported( async def test_async_get_component_preloads_config_and_config_flow( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, ) -> None: """Verify async_get_component will try to preload the config and config_flow platform.""" executor_import_integration = _get_test_integration( @@ -1307,10 +1335,9 @@ async def test_async_get_component_preloads_config_and_config_flow( } +@pytest.mark.usefixtures("enable_custom_integrations") async def test_async_get_component_loads_loop_if_already_in_sys_modules( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Verify async_get_component does not create an executor job if the module is already in sys.modules.""" integration = await loader.async_get_integration( @@ -1372,11 +1399,8 @@ async def test_async_get_component_loads_loop_if_already_in_sys_modules( assert module is module_mock -async def test_async_get_component_concurrent_loads( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_async_get_component_concurrent_loads(hass: HomeAssistant) -> None: """Verify async_get_component waits if the first load if called again when still in progress.""" integration = await loader.async_get_integration( hass, "test_package_loaded_executor" @@ -1686,9 +1710,8 @@ async def test_async_get_platform_raises_after_import_failure( assert "loaded_executor=False" not in caplog.text -async def test_platforms_exists( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_platforms_exists(hass: HomeAssistant) -> None: """Test platforms_exists.""" original_os_listdir = os.listdir @@ -1744,10 +1767,9 @@ async def test_platforms_exists( assert integration.platforms_are_loaded(["other"]) is False +@pytest.mark.usefixtures("enable_custom_integrations") async def test_async_get_platforms_loads_loop_if_already_in_sys_modules( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Verify async_get_platforms does not create an executor job. @@ -1847,11 +1869,8 @@ async def test_async_get_platforms_loads_loop_if_already_in_sys_modules( assert integration.get_platform_cached("light") is light_module_mock -async def test_async_get_platforms_concurrent_loads( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - enable_custom_integrations: None, -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_async_get_platforms_concurrent_loads(hass: HomeAssistant) -> None: """Verify async_get_platforms waits if the first load if called again. Case is for when when a second load is called @@ -1912,17 +1931,17 @@ async def test_async_get_platforms_concurrent_loads( assert integration.get_platform_cached("button") is button_module_mock +@pytest.mark.usefixtures("enable_custom_integrations") async def test_integration_warnings( - hass: HomeAssistant, - enable_custom_integrations: None, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test integration warnings.""" await loader.async_get_integration(hass, "test_package_loaded_loop") assert "configured to to import its code in the event loop" in caplog.text -async def test_has_services(hass: HomeAssistant, enable_custom_integrations) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_has_services(hass: HomeAssistant) -> None: """Test has_services.""" integration = await loader.async_get_integration(hass, "test") assert integration.has_services is False @@ -1936,7 +1955,7 @@ async def test_hass_helpers_use_reported( """Test that use of hass.components is reported.""" integration_frame = frame.IntegrationFrame( custom_integration=True, - _frame=mock_integration_frame, + frame=mock_integration_frame, integration="test_integration_frame", module="custom_components.test_integration_frame", relative_filename="custom_components/test_integration_frame/__init__.py", @@ -1959,3 +1978,12 @@ async def test_hass_helpers_use_reported( "Detected that custom integration 'test_integration_frame' " "accesses hass.helpers.aiohttp_client. This is deprecated" ) in caplog.text + + +async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: + """Test json_fragment roundtrip.""" + integration = await loader.async_get_integration(hass, "hue") + assert ( + json_loads(json_dumps(integration.manifest_json_fragment)) + == integration.manifest + ) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 73f3f54c3c4..161214160aa 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -356,8 +356,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes( integration = await async_get_integration_with_requirements( hass, "test_component" ) - assert integration - assert integration.domain == "test_component" assert len(mock_is_installed.mock_calls) == 3 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ @@ -391,8 +389,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes( integration = await async_get_integration_with_requirements( hass, "test_component" ) - assert integration - assert integration.domain == "test_component" assert len(mock_is_installed.mock_calls) == 0 # On another attempt we remember failures and don't try again @@ -414,8 +410,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes( integration = await async_get_integration_with_requirements( hass, "test_component" ) - assert integration - assert integration.domain == "test_component" assert len(mock_is_installed.mock_calls) == 2 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ diff --git a/tests/test_runner.py b/tests/test_runner.py index ab9b0e31e0d..90678454adf 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -104,7 +104,7 @@ def test_run_does_not_block_forever_with_shielded_task( try: await asyncio.sleep(2) except asyncio.CancelledError: - raise Exception + raise Exception # pylint: disable=broad-exception-raised async def async_shielded(*_): try: @@ -115,11 +115,11 @@ def test_run_does_not_block_forever_with_shielded_task( tasks.append(asyncio.ensure_future(asyncio.shield(async_shielded()))) tasks.append(asyncio.ensure_future(asyncio.sleep(2))) tasks.append(asyncio.ensure_future(async_raise())) - await asyncio.sleep(0.1) + await asyncio.sleep(0) return 0 with ( - patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), + patch.object(runner, "TASK_CANCELATION_TIMEOUT", 0.1), patch("homeassistant.bootstrap.async_setup_hass", return_value=hass), patch("threading._shutdown"), patch("homeassistant.core.HomeAssistant.async_run", _async_create_tasks), @@ -141,11 +141,12 @@ async def test_unhandled_exception_traceback( async def _unhandled_exception(): raised.set() + # pylint: disable-next=broad-exception-raised raise Exception("This is unhandled") try: hass.loop.set_debug(True) - task = asyncio.create_task(_unhandled_exception()) + task = asyncio.create_task(_unhandled_exception(), name="name_of_task") await raised.wait() # Delete it without checking result to trigger unhandled exception del task @@ -155,9 +156,10 @@ async def test_unhandled_exception_traceback( assert "Task exception was never retrieved" in caplog.text assert "This is unhandled" in caplog.text assert "_unhandled_exception" in caplog.text + assert "name_of_task" in caplog.text -def test__enable_posix_spawn() -> None: +def test_enable_posix_spawn() -> None: """Test that we can enable posix_spawn on musllinux.""" def _mock_sys_tags_any() -> Iterator[packaging.tags.Tag]: diff --git a/tests/test_setup.py b/tests/test_setup.py index 65472643adb..4ff0f465e21 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -163,7 +163,7 @@ async def test_validate_platform_config_2( mock_platform( hass, "whatever.platform_conf", - MockPlatform("whatever", platform_schema=platform_schema), + MockPlatform(platform_schema=platform_schema), ) with assert_setup_component(1): @@ -192,7 +192,7 @@ async def test_validate_platform_config_3( mock_platform( hass, "whatever.platform_conf", - MockPlatform("whatever", platform_schema=platform_schema), + MockPlatform(platform_schema=platform_schema), ) with assert_setup_component(1): @@ -328,7 +328,7 @@ async def test_component_exception_setup(hass: HomeAssistant) -> None: def exception_setup(hass, config): """Raise exception.""" - raise Exception("fail!") + raise Exception("fail!") # pylint: disable=broad-exception-raised mock_integration(hass, MockModule("comp", setup=exception_setup)) @@ -342,7 +342,7 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: def exception_setup(hass, config): """Raise exception.""" - raise BaseException("fail!") + raise BaseException("fail!") # pylint: disable=broad-exception-raised mock_integration(hass, MockModule("comp", setup=exception_setup)) @@ -362,6 +362,7 @@ async def test_component_setup_with_validation_and_dependency( """Test that config is passed in.""" if config.get("comp_a", {}).get("valid", False): return True + # pylint: disable-next=broad-exception-raised raise Exception(f"Config not passed in: {config}") platform = MockPlatform() @@ -739,7 +740,6 @@ async def test_integration_only_setup_entry(hass: HomeAssistant) -> None: async def test_async_start_setup_running(hass: HomeAssistant) -> None: """Test setup started context manager does nothing when running.""" assert hass.state is CoreState.running - setup_started: dict[tuple[str, str | None], float] setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) with setup.async_start_setup( @@ -753,7 +753,6 @@ async def test_async_start_setup_config_entry( ) -> None: """Test setup started keeps track of setup times with a config entry.""" hass.set_state(CoreState.not_running) - setup_started: dict[tuple[str, str | None], float] setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) @@ -864,7 +863,6 @@ async def test_async_start_setup_config_entry_late_platform( ) -> None: """Test setup started tracks config entry time with a late platform load.""" hass.set_state(CoreState.not_running) - setup_started: dict[tuple[str, str | None], float] setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) @@ -919,7 +917,6 @@ async def test_async_start_setup_config_entry_platform_wait( ) -> None: """Test setup started tracks wait time when a platform loads inside of config entry setup.""" hass.set_state(CoreState.not_running) - setup_started: dict[tuple[str, str | None], float] setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) @@ -962,7 +959,6 @@ async def test_async_start_setup_config_entry_platform_wait( async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times with modern yaml.""" hass.set_state(CoreState.not_running) - setup_started: dict[tuple[str, str | None], float] setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) @@ -979,7 +975,6 @@ async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: async def test_async_start_setup_platform_integration(hass: HomeAssistant) -> None: """Test setup started keeps track of setup times a platform integration.""" hass.set_state(CoreState.not_running) - setup_started: dict[tuple[str, str | None], float] setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) @@ -1014,7 +1009,6 @@ async def test_async_start_setup_legacy_platform_integration( ) -> None: """Test setup started keeps track of setup times for a legacy platform integration.""" hass.set_state(CoreState.not_running) - setup_started: dict[tuple[str, str | None], float] setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) @@ -1063,7 +1057,7 @@ async def test_async_start_setup_simple_integration_end_to_end( } -async def test_async_get_setup_timings(hass) -> None: +async def test_async_get_setup_timings(hass: HomeAssistant) -> None: """Test we can get the setup timings from the setup time data.""" setup_time = setup._setup_times(hass) # Mock setup time data @@ -1109,6 +1103,11 @@ async def test_async_get_setup_timings(hass) -> None: "sensor": 1, "filter": 2, } + assert setup.async_get_domain_setup_times(hass, "filter") == { + "123456": { + setup.SetupPhases.PLATFORM_SETUP: 2, + }, + } async def test_setup_config_entry_from_yaml( @@ -1178,19 +1177,17 @@ async def test_loading_component_loads_translations(hass: HomeAssistant) -> None assert translation.async_translations_loaded(hass, {"comp"}) is True -async def test_importing_integration_in_executor( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_importing_integration_in_executor(hass: HomeAssistant) -> None: """Test we can import an integration in an executor.""" assert await setup.async_setup_component(hass, "test_package_loaded_executor", {}) assert await setup.async_setup_component(hass, "test_package_loaded_executor", {}) await hass.async_block_till_done() +@pytest.mark.usefixtures("enable_custom_integrations") async def test_async_prepare_setup_platform( - hass: HomeAssistant, - enable_custom_integrations: None, - caplog: pytest.LogCaptureFixture, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we can prepare a platform setup.""" integration = await loader.async_get_integration(hass, "test") diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index b240da3e31e..78f66ceb549 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -20,7 +20,8 @@ def test_sockets_disabled() -> None: socket.socket() -def test_sockets_enabled(socket_enabled) -> None: +@pytest.mark.usefixtures("socket_enabled") +def test_sockets_enabled() -> None: """Test we can't connect to an address different from 127.0.0.1.""" mysocket = socket.socket() with pytest.raises(pytest_socket.SocketConnectBlockedError): diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 742b111143f..b4b8cfa4b6d 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -54,7 +54,7 @@ class AiohttpClientMocker: content=None, json=None, params=None, - headers={}, + headers=None, exc=None, cookies=None, side_effect=None, diff --git a/tests/testing_config/custom_components/test/image_processing.py b/tests/testing_config/custom_components/test/image_processing.py index 343c60a78fe..fe22325c3e0 100644 --- a/tests/testing_config/custom_components/test/image_processing.py +++ b/tests/testing_config/custom_components/test/image_processing.py @@ -1,11 +1,17 @@ """Provide a mock image processing.""" from homeassistant.components.image_processing import ImageProcessingEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the test image_processing platform.""" async_add_entities_callback([TestImageProcessing("camera.demo_camera", "Test")]) @@ -13,7 +19,7 @@ async def async_setup_platform( class TestImageProcessing(ImageProcessingEntity): """Test image processing entity.""" - def __init__(self, camera_entity, name): + def __init__(self, camera_entity, name) -> None: """Initialize test image processing.""" self._name = name self._camera = camera_entity diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index 4cd49fec606..d9fad11655e 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -3,8 +3,13 @@ Call init before using it in your tests to ensure clean test data. """ +from typing import Any, Literal + from homeassistant.components.light import ColorMode, LightEntity from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from tests.common import MockToggleEntity @@ -13,6 +18,7 @@ ENTITIES = [] def init(empty=False): """Initialize the platform with entities.""" + # pylint: disable-next=global-statement global ENTITIES # noqa: PLW0603 ENTITIES = ( @@ -27,8 +33,11 @@ def init(empty=False): async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Return mock entities.""" async_add_entities_callback(ENTITIES) @@ -60,13 +69,12 @@ class MockLight(MockToggleEntity, LightEntity): def __init__( self, - name, - state, - unique_id=None, + name: str | None, + state: Literal["on", "off"] | None, supported_color_modes: set[ColorMode] | None = None, - ): + ) -> None: """Initialize the mock light.""" - super().__init__(name, state, unique_id) + super().__init__(name, state) if supported_color_modes is None: supported_color_modes = {ColorMode.ONOFF} self._attr_supported_color_modes = supported_color_modes @@ -75,7 +83,7 @@ class MockLight(MockToggleEntity, LightEntity): color_mode = next(iter(supported_color_modes)) self._attr_color_mode = color_mode - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" super().turn_on(**kwargs) for key, value in kwargs.items(): diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index e97d3f8de22..0c24e1b5b41 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -4,6 +4,9 @@ Call init before using it in your tests to ensure clean test data. """ from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from tests.common import MockEntity @@ -12,6 +15,7 @@ ENTITIES = {} def init(empty=False): """Initialize the platform with entities.""" + # pylint: disable-next=global-statement global ENTITIES # noqa: PLW0603 ENTITIES = ( @@ -35,8 +39,11 @@ def init(empty=False): async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Return mock entities.""" async_add_entities_callback(list(ENTITIES.values())) diff --git a/tests/testing_config/custom_components/test/remote.py b/tests/testing_config/custom_components/test/remote.py index 3226c93310c..6d3f2ec955d 100644 --- a/tests/testing_config/custom_components/test/remote.py +++ b/tests/testing_config/custom_components/test/remote.py @@ -5,6 +5,9 @@ Call init before using it in your tests to ensure clean test data. from homeassistant.components.remote import RemoteEntity from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from tests.common import MockToggleEntity @@ -13,6 +16,7 @@ ENTITIES = [] def init(empty=False): """Initialize the platform with entities.""" + # pylint: disable-next=global-statement global ENTITIES # noqa: PLW0603 ENTITIES = ( @@ -27,8 +31,11 @@ def init(empty=False): async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Return mock entities.""" async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/switch.py b/tests/testing_config/custom_components/test/switch.py index b06db33746f..9099040e2b6 100644 --- a/tests/testing_config/custom_components/test/switch.py +++ b/tests/testing_config/custom_components/test/switch.py @@ -1,8 +1,15 @@ """Stub switch platform for translation tests.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Stub setup for translation tests.""" async_add_entities_callback([]) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index b051531b9e8..cef0584e4e0 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -33,6 +33,7 @@ ENTITIES = [] def init(empty=False): """Initialize the platform with entities.""" + # pylint: disable-next=global-statement global ENTITIES # noqa: PLW0603 ENTITIES = [] if empty else [MockWeather()] diff --git a/tests/testing_config/custom_components/test_embedded/__init__.py b/tests/testing_config/custom_components/test_embedded/__init__.py index b83493817fd..b3fe1be4d74 100644 --- a/tests/testing_config/custom_components/test_embedded/__init__.py +++ b/tests/testing_config/custom_components/test_embedded/__init__.py @@ -1,8 +1,11 @@ """Component with embedded platforms.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + DOMAIN = "test_embedded" -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock config.""" return True diff --git a/tests/testing_config/custom_components/test_embedded/switch.py b/tests/testing_config/custom_components/test_embedded/switch.py index 46dac4419a6..f287f5ee547 100644 --- a/tests/testing_config/custom_components/test_embedded/switch.py +++ b/tests/testing_config/custom_components/test_embedded/switch.py @@ -1,7 +1,14 @@ """Switch platform for the embedded component.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + async def async_setup_platform( - hass, config, async_add_entities_callback, discovery_info=None -): + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Find and return test switches.""" diff --git a/tests/testing_config/custom_components/test_integration_platform/__init__.py b/tests/testing_config/custom_components/test_integration_platform/__init__.py index 220beb05367..8c3929398a1 100644 --- a/tests/testing_config/custom_components/test_integration_platform/__init__.py +++ b/tests/testing_config/custom_components/test_integration_platform/__init__.py @@ -1,10 +1,13 @@ """Provide a mock package component.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + from .const import TEST # noqa: F401 DOMAIN = "test_integration_platform" -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock a successful setup.""" return True diff --git a/tests/testing_config/custom_components/test_package/__init__.py b/tests/testing_config/custom_components/test_package/__init__.py index 50e132e2c07..33b04428ba4 100644 --- a/tests/testing_config/custom_components/test_package/__init__.py +++ b/tests/testing_config/custom_components/test_package/__init__.py @@ -1,10 +1,13 @@ """Provide a mock package component.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + from .const import TEST # noqa: F401 DOMAIN = "test_package" -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock a successful setup.""" return True diff --git a/tests/testing_config/custom_components/test_package_loaded_executor/__init__.py b/tests/testing_config/custom_components/test_package_loaded_executor/__init__.py index 50e132e2c07..33b04428ba4 100644 --- a/tests/testing_config/custom_components/test_package_loaded_executor/__init__.py +++ b/tests/testing_config/custom_components/test_package_loaded_executor/__init__.py @@ -1,10 +1,13 @@ """Provide a mock package component.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + from .const import TEST # noqa: F401 DOMAIN = "test_package" -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock a successful setup.""" return True diff --git a/tests/testing_config/custom_components/test_package_loaded_loop/__init__.py b/tests/testing_config/custom_components/test_package_loaded_loop/__init__.py index b9080a2048a..28eb409ba2b 100644 --- a/tests/testing_config/custom_components/test_package_loaded_loop/__init__.py +++ b/tests/testing_config/custom_components/test_package_loaded_loop/__init__.py @@ -1,8 +1,11 @@ """Provide a mock package component.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + from .const import TEST # noqa: F401 -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock a successful setup.""" return True diff --git a/tests/testing_config/custom_components/test_package_raises_cancelled_error/__init__.py b/tests/testing_config/custom_components/test_package_raises_cancelled_error/__init__.py index 37d3becb2d3..2bdf421c9b0 100644 --- a/tests/testing_config/custom_components/test_package_raises_cancelled_error/__init__.py +++ b/tests/testing_config/custom_components/test_package_raises_cancelled_error/__init__.py @@ -2,8 +2,11 @@ import asyncio +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType -async def async_setup(hass, config): + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock a successful setup.""" asyncio.current_task().cancel() await asyncio.sleep(0) diff --git a/tests/testing_config/custom_components/test_package_raises_cancelled_error_config_entry/__init__.py b/tests/testing_config/custom_components/test_package_raises_cancelled_error_config_entry/__init__.py index 55ce19865c6..caceba1d1da 100644 --- a/tests/testing_config/custom_components/test_package_raises_cancelled_error_config_entry/__init__.py +++ b/tests/testing_config/custom_components/test_package_raises_cancelled_error_config_entry/__init__.py @@ -2,13 +2,17 @@ import asyncio +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType -async def async_setup(hass, config): + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock a successful setup.""" return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Mock an unsuccessful entry setup.""" asyncio.current_task().cancel() await asyncio.sleep(0) diff --git a/tests/testing_config/custom_components/test_standalone.py b/tests/testing_config/custom_components/test_standalone.py index 0b7ce8033e5..7d4c713d3c2 100644 --- a/tests/testing_config/custom_components/test_standalone.py +++ b/tests/testing_config/custom_components/test_standalone.py @@ -1,8 +1,11 @@ """Provide a mock standalone component.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + DOMAIN = "test_standalone" -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock a successful setup.""" return True diff --git a/tests/testing_config/custom_sentences/en/beer.yaml b/tests/testing_config/custom_sentences/en/beer.yaml index cedaae42ed1..f318e0221b2 100644 --- a/tests/testing_config/custom_sentences/en/beer.yaml +++ b/tests/testing_config/custom_sentences/en/beer.yaml @@ -4,8 +4,14 @@ intents: data: - sentences: - "I'd like to order a {beer_style} [please]" + OrderFood: + data: + - sentences: + - "I'd like to order {food_name:name} [please]" lists: beer_style: values: - "stout" - "lager" + food_name: + wildcard: true diff --git a/tests/typing.py b/tests/typing.py index 18824163fd2..7b61949a9c4 100644 --- a/tests/typing.py +++ b/tests/typing.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock from aiohttp import ClientWebSocketResponse @@ -23,13 +23,13 @@ class MockHAClientWebSocket(ClientWebSocketResponse): remove_device: Callable[[str, str], Coroutine[Any, Any, Any]] -ClientSessionGenerator = Callable[..., Coroutine[Any, Any, TestClient]] -MqttMockPahoClient = MagicMock +type ClientSessionGenerator = Callable[..., Coroutine[Any, Any, TestClient]] +type MqttMockPahoClient = MagicMock """MagicMock for `paho.mqtt.client.Client`""" -MqttMockHAClient = MagicMock +type MqttMockHAClient = MagicMock """MagicMock for `homeassistant.components.mqtt.MQTT`.""" -MqttMockHAClientGenerator = Callable[..., Coroutine[Any, Any, MqttMockHAClient]] +type MqttMockHAClientGenerator = Callable[..., Coroutine[Any, Any, MqttMockHAClient]] """MagicMock generator for `homeassistant.components.mqtt.MQTT`.""" -RecorderInstanceGenerator: TypeAlias = Callable[..., Coroutine[Any, Any, "Recorder"]] +type RecorderInstanceGenerator = Callable[..., Coroutine[Any, Any, Recorder]] """Instance generator for `homeassistant.components.recorder.Recorder`.""" -WebSocketGenerator = Callable[..., Coroutine[Any, Any, MockHAClientWebSocket]] +type WebSocketGenerator = Callable[..., Coroutine[Any, Any, MockHAClientWebSocket]] diff --git a/tests/util/test_collection.py b/tests/util/test_collection.py new file mode 100644 index 00000000000..f51ded40900 --- /dev/null +++ b/tests/util/test_collection.py @@ -0,0 +1,24 @@ +"""Test collection utils.""" + +from homeassistant.util.collection import chunked_or_all + + +def test_chunked_or_all() -> None: + """Test chunked_or_all can iterate chunk sizes larger than the passed in collection.""" + all_items = [] + incoming = (1, 2, 3, 4) + for chunk in chunked_or_all(incoming, 2): + assert len(chunk) == 2 + all_items.extend(chunk) + assert all_items == [1, 2, 3, 4] + + all_items = [] + incoming = (1, 2, 3, 4) + for chunk in chunked_or_all(incoming, 5): + assert len(chunk) == 4 + # Verify the chunk is the same object as the incoming + # collection since we want to avoid copying the collection + # if we don't need to + assert chunk is incoming + all_items.extend(chunk) + assert all_items == [1, 2, 3, 4] diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 215524c426b..6caca092517 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -8,7 +8,7 @@ import pytest import homeassistant.util.dt as dt_util -DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE +DEFAULT_TIME_ZONE = dt_util.get_default_time_zone() TEST_TIME_ZONE = "America/Los_Angeles" @@ -25,11 +25,21 @@ def test_get_time_zone_retrieves_valid_time_zone() -> None: assert dt_util.get_time_zone(TEST_TIME_ZONE) is not None +async def test_async_get_time_zone_retrieves_valid_time_zone() -> None: + """Test getting a time zone.""" + assert await dt_util.async_get_time_zone(TEST_TIME_ZONE) is not None + + def test_get_time_zone_returns_none_for_garbage_time_zone() -> None: """Test getting a non existing time zone.""" assert dt_util.get_time_zone("Non existing time zone") is None +async def test_async_get_time_zone_returns_none_for_garbage_time_zone() -> None: + """Test getting a non existing time zone.""" + assert await dt_util.async_get_time_zone("Non existing time zone") is None + + def test_set_default_time_zone() -> None: """Test setting default time zone.""" time_zone = dt_util.get_time_zone(TEST_TIME_ZONE) diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 0730c16b68d..b0898ccc150 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -85,7 +85,7 @@ async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None iexecutor.shutdown() finish = time.monotonic() - # Idealy execution time (finish - start) should be < 1.2 sec. + # Ideally 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. diff --git a/tests/util/test_file.py b/tests/util/test_file.py index 2371998b1b9..efa3c1ab0d9 100644 --- a/tests/util/test_file.py +++ b/tests/util/test_file.py @@ -17,17 +17,17 @@ def test_write_utf8_file_atomic_private(tmpdir: py.path.local, func) -> None: test_file = Path(test_dir / "test.json") func(test_file, '{"some":"data"}', False) - with open(test_file) as fh: + with open(test_file, encoding="utf8") as fh: assert fh.read() == '{"some":"data"}' assert os.stat(test_file).st_mode & 0o777 == 0o644 func(test_file, '{"some":"data"}', True) - with open(test_file) as fh: + with open(test_file, encoding="utf8") as fh: assert fh.read() == '{"some":"data"}' assert os.stat(test_file).st_mode & 0o777 == 0o600 func(test_file, b'{"some":"data"}', True, mode="wb") - with open(test_file) as fh: + with open(test_file, encoding="utf8") as fh: assert fh.read() == '{"some":"data"}' assert os.stat(test_file).st_mode & 0o777 == 0o600 diff --git a/tests/util/test_hass_dict.py b/tests/util/test_hass_dict.py new file mode 100644 index 00000000000..36e427af41f --- /dev/null +++ b/tests/util/test_hass_dict.py @@ -0,0 +1,47 @@ +"""Test HassDict and custom HassKey types.""" + +from homeassistant.util.hass_dict import HassDict, HassEntryKey, HassKey + + +def test_key_comparison() -> None: + """Test key comparison with itself and string keys.""" + + str_key = "custom-key" + key = HassKey[int](str_key) + other_key = HassKey[str]("other-key") + + entry_key = HassEntryKey[int](str_key) + other_entry_key = HassEntryKey[str]("other-key") + + assert key == str_key + assert key != other_key + assert key != 2 + + assert entry_key == str_key + assert entry_key != other_entry_key + assert entry_key != 2 + + # Only compare name attribute, HassKey() == HassEntryKey() + assert key == entry_key + + +def test_hass_dict_access() -> None: + """Test keys with the same name all access the same value in HassDict.""" + + data = HassDict() + str_key = "custom-key" + key = HassKey[int](str_key) + other_key = HassKey[str]("other-key") + + entry_key = HassEntryKey[int](str_key) + other_entry_key = HassEntryKey[str]("other-key") + + data[str_key] = True + assert data.get(key) is True + assert data.get(other_key) is None + + assert data.get(entry_key) is True # type: ignore[comparison-overlap] + assert data.get(other_entry_key) is None + + data[key] = False + assert data[str_key] is False diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 3eccb524538..3a314bb5a1b 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -25,7 +25,7 @@ TEST_BAD_SERIALIED = "THIS IS NOT JSON\n" def test_load_bad_data(tmp_path: Path) -> None: """Test error from trying to load unserializable data.""" fname = tmp_path / "test5.json" - with open(fname, "w") as fh: + with open(fname, "w", encoding="utf8") as fh: fh.write(TEST_BAD_SERIALIED) with pytest.raises(HomeAssistantError, match=re.escape(str(fname))) as err: load_json(fname) @@ -159,7 +159,7 @@ async def test_deprecated_save_json( assert "should be updated to use homeassistant.helpers.json module" in caplog.text -async def test_loading_derived_class(): +async def test_loading_derived_class() -> None: """Test loading data from classes derived from str.""" class MyStr(str): diff --git a/tests/util/test_location.py b/tests/util/test_location.py index b9252c33e9d..3af3ad2765a 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch import aiohttp import pytest +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.util.location as location_util @@ -28,13 +29,13 @@ DISTANCE_MILES = 3632.78 @pytest.fixture -async def session(hass): +async def session(hass: HomeAssistant) -> aiohttp.ClientSession: """Return aioclient session.""" return async_get_clientsession(hass) @pytest.fixture -async def raising_session(): +async def raising_session() -> Mock: """Return an aioclient session that only fails.""" return Mock(get=Mock(side_effect=aiohttp.ClientError)) @@ -76,7 +77,7 @@ def test_get_miles() -> None: async def test_detect_location_info_whoami( - aioclient_mock: AiohttpClientMocker, session + aioclient_mock: AiohttpClientMocker, session: aiohttp.ClientSession ) -> None: """Test detect location info using services.home-assistant.io/whoami.""" aioclient_mock.get(location_util.WHOAMI_URL, text=load_fixture("whoami.json")) @@ -99,7 +100,9 @@ async def test_detect_location_info_whoami( assert info.use_metric -async def test_dev_url(aioclient_mock: AiohttpClientMocker, session) -> None: +async def test_dev_url( + aioclient_mock: AiohttpClientMocker, session: aiohttp.ClientSession +) -> None: """Test usage of dev URL.""" aioclient_mock.get(location_util.WHOAMI_URL_DEV, text=load_fixture("whoami.json")) with patch("homeassistant.util.location.HA_VERSION", "1.0.dev0"): @@ -110,7 +113,7 @@ async def test_dev_url(aioclient_mock: AiohttpClientMocker, session) -> None: assert info.currency == "XXX" -async def test_whoami_query_raises(raising_session) -> None: +async def test_whoami_query_raises(raising_session: Mock) -> None: """Test whoami query when the request to API fails.""" info = await location_util._get_whoami(raising_session) assert info is None diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 8e7106475a2..4667dbcbec8 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -80,6 +80,7 @@ async def test_async_create_catching_coro( """Test exception logging of wrapped coroutine.""" async def job(): + # pylint: disable-next=broad-exception-raised raise Exception("This is a bad coroutine") hass.async_create_task(logging_util.async_create_catching_coro(job())) diff --git a/tests/util/test_loop.py b/tests/util/test_loop.py index 8b4465bef2b..585f32a965f 100644 --- a/tests/util/test_loop.py +++ b/tests/util/test_loop.py @@ -1,9 +1,11 @@ """Tests for async util methods from Python source.""" +import threading from unittest.mock import Mock, patch import pytest +from homeassistant.core import HomeAssistant from homeassistant.util import loop as haloop from tests.common import extract_stack_to_frame @@ -13,22 +15,33 @@ def banned_function(): """Mock banned function.""" -async def test_check_loop_async() -> None: - """Test check_loop detects when called from event loop without integration context.""" +async def test_raise_for_blocking_call_async() -> None: + """Test raise_for_blocking_call detects when called from event loop without integration context.""" with pytest.raises(RuntimeError): - haloop.check_loop(banned_function) + haloop.raise_for_blocking_call(banned_function) -async def test_check_loop_async_non_strict_core( +async def test_raise_for_blocking_call_async_non_strict_core( caplog: pytest.LogCaptureFixture, ) -> None: - """Test non_strict_core check_loop detects from event loop without integration context.""" - haloop.check_loop(banned_function, strict_core=False) + """Test non_strict_core raise_for_blocking_call detects from event loop without integration context.""" + haloop.raise_for_blocking_call(banned_function, strict_core=False) assert "Detected blocking call to banned_function" in caplog.text + assert "Traceback (most recent call last)" in caplog.text + assert ( + "Please create a bug report at https://github.com/home-assistant/core/issues" + in caplog.text + ) + assert ( + "For developers, please see " + "https://developers.home-assistant.io/docs/asyncio_blocking_operations/#banned_function" + ) in caplog.text -async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> None: - """Test check_loop detects and raises when called from event loop from integration context.""" +async def test_raise_for_blocking_call_async_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test raise_for_blocking_call detects and raises when called from event loop from integration context.""" frames = extract_stack_to_frame( [ Mock( @@ -67,20 +80,25 @@ async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> return_value=frames, ), ): - haloop.check_loop(banned_function) + haloop.raise_for_blocking_call(banned_function) assert ( - "Detected blocking call to banned_function inside the event loop by integration" + "Detected blocking call to banned_function with args None" + " inside the event loop by integration" " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on " "(offender: /home/paulus/aiohue/lights.py, line 2: mock_line), please create " "a bug report at https://github.com/home-assistant/core/issues?" "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text ) + assert ( + "For developers, please see " + "https://developers.home-assistant.io/docs/asyncio_blocking_operations/#banned_function" + ) in caplog.text -async def test_check_loop_async_integration_non_strict( +async def test_raise_for_blocking_call_async_integration_non_strict( caplog: pytest.LogCaptureFixture, ) -> None: - """Test check_loop detects when called from event loop from integration context.""" + """Test raise_for_blocking_call detects when called from event loop from integration context.""" frames = extract_stack_to_frame( [ Mock( @@ -118,18 +136,34 @@ async def test_check_loop_async_integration_non_strict( return_value=frames, ), ): - haloop.check_loop(banned_function, strict=False) + haloop.raise_for_blocking_call(banned_function, strict=False) assert ( - "Detected blocking call to banned_function inside the event loop by integration" + "Detected blocking call to banned_function with args None" + " inside the event loop by integration" " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on " "(offender: /home/paulus/aiohue/lights.py, line 2: mock_line), " "please create a bug report at https://github.com/home-assistant/core/issues?" "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text ) + assert "Traceback (most recent call last)" in caplog.text + assert ( + 'File "/home/paulus/homeassistant/components/hue/light.py", line 23' + in caplog.text + ) + assert ( + "please create a bug report at https://github.com/home-assistant/core/issues" + in caplog.text + ) + assert ( + "For developers, please see " + "https://developers.home-assistant.io/docs/asyncio_blocking_operations/#banned_function" + ) in caplog.text -async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None: - """Test check_loop detects when called from event loop with custom component context.""" +async def test_raise_for_blocking_call_async_custom( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test raise_for_blocking_call detects when called from event loop with custom component context.""" frames = extract_stack_to_frame( [ Mock( @@ -168,28 +202,43 @@ async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None return_value=frames, ), ): - haloop.check_loop(banned_function) + haloop.raise_for_blocking_call(banned_function) assert ( - "Detected blocking call to banned_function inside the event loop by custom " + "Detected blocking call to banned_function with args None" + " inside the event loop by custom " "integration 'hue' at custom_components/hue/light.py, line 23: self.light.is_on" " (offender: /home/paulus/aiohue/lights.py, line 2: mock_line), " "please create a bug report at https://github.com/home-assistant/core/issues?" "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text + assert "Traceback (most recent call last)" in caplog.text + assert ( + 'File "/home/paulus/config/custom_components/hue/light.py", line 23' + in caplog.text + ) + assert ( + "For developers, please see " + "https://developers.home-assistant.io/docs/asyncio_blocking_operations/#banned_function" + ) in caplog.text -def test_check_loop_sync(caplog: pytest.LogCaptureFixture) -> None: - """Test check_loop does nothing when called from thread.""" - haloop.check_loop(banned_function) +async def test_raise_for_blocking_call_sync( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test raise_for_blocking_call does nothing when called from thread.""" + func = haloop.protect_loop(banned_function, threading.get_ident()) + await hass.async_add_executor_job(func) assert "Detected blocking call inside the event loop" not in caplog.text -def test_protect_loop_sync() -> None: - """Test protect_loop calls check_loop.""" +async def test_protect_loop_async() -> None: + """Test protect_loop calls raise_for_blocking_call.""" func = Mock() - with patch("homeassistant.util.loop.check_loop") as mock_check_loop: - haloop.protect_loop(func)(1, test=2) - mock_check_loop.assert_called_once_with( + with patch( + "homeassistant.util.loop.raise_for_blocking_call" + ) as mock_raise_for_blocking_call: + haloop.protect_loop(func, threading.get_ident())(1, test=2) + mock_raise_for_blocking_call.assert_called_once_with( func, strict=True, args=(1,), diff --git a/tests/util/test_read_only_dict.py b/tests/util/test_read_only_dict.py index 888ea59fb11..68e22a66f5e 100644 --- a/tests/util/test_read_only_dict.py +++ b/tests/util/test_read_only_dict.py @@ -1,5 +1,6 @@ """Test read only dictionary.""" +import copy import json import pytest @@ -35,3 +36,5 @@ def test_read_only_dict() -> None: assert isinstance(data, dict) assert dict(data) == {"hello": "world"} assert json.dumps(data) == json.dumps({"hello": "world"}) + + assert copy.deepcopy(data) == {"hello": "world"} diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py index d49008d608b..797c849db3c 100644 --- a/tests/util/test_timeout.py +++ b/tests/util/test_timeout.py @@ -110,7 +110,7 @@ async def test_mix_global_timeout_freeze_and_zone_freeze_other_zone_inside_execu with timeout.freeze("not_recorder"): time.sleep(0.3) - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1): async with ( timeout.async_timeout(0.2, zone_name="recorder"), @@ -129,7 +129,7 @@ async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job_sec with timeout.freeze("recorder"): time.sleep(0.3) - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1): async with timeout.async_timeout(0.2, zone_name="recorder"): await hass.async_add_executor_job(_some_sync_work) @@ -150,7 +150,7 @@ async def test_simple_global_timeout_freeze_reset() -> None: """Test a simple global timeout freeze reset.""" timeout = TimeoutManager() - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.2): async with timeout.async_freeze(): await asyncio.sleep(0.1) @@ -170,7 +170,7 @@ async def test_multiple_zone_timeout() -> None: """Test a simple zone timeout.""" timeout = TimeoutManager() - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1, "test"): async with timeout.async_timeout(0.5, "test"): await asyncio.sleep(0.3) @@ -180,7 +180,7 @@ async def test_different_zone_timeout() -> None: """Test a simple zone timeout.""" timeout = TimeoutManager() - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1, "test"): async with timeout.async_timeout(0.5, "other"): await asyncio.sleep(0.3) @@ -206,7 +206,7 @@ async def test_simple_zone_timeout_freeze_reset() -> None: """Test a simple zone timeout freeze reset.""" timeout = TimeoutManager() - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.2, "test"): async with timeout.async_freeze("test"): await asyncio.sleep(0.1) @@ -259,7 +259,7 @@ async def test_mix_zone_timeout_trigger_global() -> None: """Test a mix zone timeout global with trigger it.""" timeout = TimeoutManager() - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1): with suppress(TimeoutError): async with timeout.async_timeout(0.1, "test"): @@ -308,7 +308,7 @@ async def test_simple_zone_timeout_freeze_without_timeout_cleanup2( async with timeout.async_freeze("test"): await asyncio.sleep(0.2) - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1): hass.async_create_task(background()) await asyncio.sleep(0.3) @@ -318,7 +318,7 @@ async def test_simple_zone_timeout_freeze_without_timeout_exeption() -> None: """Test a simple zone timeout freeze on a zone that does not have a timeout set.""" timeout = TimeoutManager() - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1): with suppress(RuntimeError): async with timeout.async_freeze("test"): @@ -331,7 +331,7 @@ async def test_simple_zone_timeout_zone_with_timeout_exeption() -> None: """Test a simple zone timeout freeze on a zone that does not have a timeout set.""" timeout = TimeoutManager() - with pytest.raises(TimeoutError): + with pytest.raises(TimeoutError): # noqa: PT012 async with timeout.async_timeout(0.1): with suppress(RuntimeError): async with timeout.async_timeout(0.3, "test"): diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index efac252aa5f..98a6a1da5a6 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -31,6 +32,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( BaseUnitConverter, + ConductivityConverter, DataRateConverter, DistanceConverter, DurationConverter, @@ -57,6 +59,7 @@ INVALID_SYMBOL = "bob" _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { converter: sorted(converter.VALID_UNITS, key=lambda x: (x is None, x)) for converter in ( + ConductivityConverter, DataRateConverter, DistanceConverter, DurationConverter, @@ -77,6 +80,11 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + ConductivityConverter: ( + UnitOfConductivity.MICROSIEMENS, + UnitOfConductivity.MILLISIEMENS, + 1000, + ), DataRateConverter: ( UnitOfDataRate.BITS_PER_SECOND, UnitOfDataRate.BYTES_PER_SECOND, @@ -122,6 +130,14 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + ConductivityConverter: [ + (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), + (5, UnitOfConductivity.SIEMENS, 5e6, UnitOfConductivity.MICROSIEMENS), + (5, UnitOfConductivity.MILLISIEMENS, 5e3, UnitOfConductivity.MICROSIEMENS), + (5, UnitOfConductivity.MILLISIEMENS, 5e-3, UnitOfConductivity.SIEMENS), + (5e6, UnitOfConductivity.MICROSIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), + (5e6, UnitOfConductivity.MICROSIEMENS, 5, UnitOfConductivity.SIEMENS), + ], DataRateConverter: [ (8e3, UnitOfDataRate.BITS_PER_SECOND, 8, UnitOfDataRate.KILOBITS_PER_SECOND), (8e6, UnitOfDataRate.BITS_PER_SECOND, 8, UnitOfDataRate.MEGABITS_PER_SECOND), diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 0fa11762490..033631563f4 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -4,8 +4,7 @@ from __future__ import annotations import pytest -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.sensor.const import DEVICE_CLASS_UNITS +from homeassistant.components.sensor import DEVICE_CLASS_UNITS, SensorDeviceClass from homeassistant.const import ( ACCUMULATED_PRECIPITATION, LENGTH, @@ -23,7 +22,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.unit_system import ( +from homeassistant.util.unit_system import ( # pylint: disable=hass-deprecated-import _CONF_UNIT_SYSTEM_IMPERIAL, _CONF_UNIT_SYSTEM_METRIC, _CONF_UNIT_SYSTEM_US_CUSTOMARY, diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index f17489e1488..6ea3f1437af 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,6 +1,5 @@ """Test Home Assistant yaml loader.""" -from collections.abc import Generator import importlib import io import os @@ -10,6 +9,7 @@ import unittest from unittest.mock import Mock, patch import pytest +from typing_extensions import Generator import voluptuous as vol import yaml as pyyaml @@ -23,7 +23,7 @@ from tests.common import extract_stack_to_frame, get_test_config_dir, patch_yaml @pytest.fixture(params=["enable_c_loader", "disable_c_loader"]) -def try_both_loaders(request): +def try_both_loaders(request: pytest.FixtureRequest) -> Generator[None]: """Disable the yaml c loader.""" if request.param != "disable_c_loader": yield @@ -40,7 +40,7 @@ def try_both_loaders(request): @pytest.fixture(params=["enable_c_dumper", "disable_c_dumper"]) -def try_both_dumpers(request): +def try_both_dumpers(request: pytest.FixtureRequest) -> Generator[None]: """Disable the yaml c dumper.""" if request.param != "disable_c_dumper": yield @@ -56,7 +56,8 @@ def try_both_dumpers(request): importlib.reload(yaml_loader) -def test_simple_list(try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_simple_list() -> None: """Test simple list.""" conf = "config:\n - simple\n - list" with io.StringIO(conf) as file: @@ -64,7 +65,8 @@ def test_simple_list(try_both_loaders) -> None: assert doc["config"] == ["simple", "list"] -def test_simple_dict(try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_simple_dict() -> None: """Test simple dict.""" conf = "key: value" with io.StringIO(conf) as file: @@ -73,20 +75,23 @@ def test_simple_dict(try_both_loaders) -> None: @pytest.mark.parametrize("hass_config_yaml", ["message:\n {{ states.state }}"]) -def test_unhashable_key(try_both_loaders, mock_hass_config_yaml: None) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_unhashable_key() -> None: """Test an unhashable key.""" with pytest.raises(HomeAssistantError): load_yaml_config_file(YAML_CONFIG_FILE) @pytest.mark.parametrize("hass_config_yaml", ["a: a\nnokeyhere"]) -def test_no_key(try_both_loaders, mock_hass_config_yaml: None) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_no_key() -> None: """Test item without a key.""" with pytest.raises(HomeAssistantError): yaml.load_yaml(YAML_CONFIG_FILE) -def test_environment_variable(try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_environment_variable() -> None: """Test config file with environment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" @@ -96,7 +101,8 @@ def test_environment_variable(try_both_loaders) -> None: del os.environ["PASSWORD"] -def test_environment_variable_default(try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_environment_variable_default() -> None: """Test config file with default value for environment variable.""" conf = "password: !env_var PASSWORD secret_password" with io.StringIO(conf) as file: @@ -104,7 +110,8 @@ def test_environment_variable_default(try_both_loaders) -> None: assert doc["password"] == "secret_password" -def test_invalid_environment_variable(try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_invalid_environment_variable() -> None: """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with pytest.raises(HomeAssistantError), io.StringIO(conf) as file: @@ -119,9 +126,8 @@ def test_invalid_environment_variable(try_both_loaders) -> None: ({"test.yaml": "123"}, 123), ], ) -def test_include_yaml( - try_both_loaders, mock_hass_config_yaml: None, value: Any -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_yaml(value: Any) -> None: """Test include yaml.""" conf = "key: !include test.yaml" with io.StringIO(conf) as file: @@ -138,9 +144,8 @@ def test_include_yaml( ({"/test/one.yaml": "1", "/test/two.yaml": None}, [1]), ], ) -def test_include_dir_list( - mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_list(mock_walk: Mock, value: Any) -> None: """Test include dir list yaml.""" mock_walk.return_value = [["/test", [], ["two.yaml", "one.yaml"]]] @@ -161,9 +166,8 @@ def test_include_dir_list( } ], ) -def test_include_dir_list_recursive( - mock_walk, try_both_loaders, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_list_recursive(mock_walk: Mock) -> None: """Test include dir recursive list yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["zero.yaml"]], @@ -198,9 +202,8 @@ def test_include_dir_list_recursive( ), ], ) -def test_include_dir_named( - mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_named(mock_walk: Mock, value: Any) -> None: """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", [], ["first.yaml", "second.yaml", "secrets.yaml"]] @@ -223,9 +226,8 @@ def test_include_dir_named( } ], ) -def test_include_dir_named_recursive( - mock_walk, try_both_loaders, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_named_recursive(mock_walk: Mock) -> None: """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -261,9 +263,8 @@ def test_include_dir_named_recursive( ), ], ) -def test_include_dir_merge_list( - mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_merge_list(mock_walk: Mock, value: Any) -> None: """Test include dir merge list yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -284,9 +285,8 @@ def test_include_dir_merge_list( } ], ) -def test_include_dir_merge_list_recursive( - mock_walk, try_both_loaders, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_merge_list_recursive(mock_walk: Mock) -> None: """Test include dir merge list yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -330,9 +330,8 @@ def test_include_dir_merge_list_recursive( ), ], ) -def test_include_dir_merge_named( - mock_walk, try_both_loaders, mock_hass_config_yaml: None, value: Any -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_merge_named(mock_walk: Mock, value: Any) -> None: """Test include dir merge named yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -353,9 +352,8 @@ def test_include_dir_merge_named( } ], ) -def test_include_dir_merge_named_recursive( - mock_walk, try_both_loaders, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_include_dir_merge_named_recursive(mock_walk: Mock) -> None: """Test include dir merge named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -378,19 +376,22 @@ def test_include_dir_merge_named_recursive( @patch("homeassistant.util.yaml.loader.open", create=True) -def test_load_yaml_encoding_error(mock_open, try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_load_yaml_encoding_error(mock_open: Mock) -> None: """Test raising a UnicodeDecodeError.""" mock_open.side_effect = UnicodeDecodeError("", b"", 1, 0, "") with pytest.raises(HomeAssistantError): yaml_loader.load_yaml("test") -def test_dump(try_both_dumpers) -> None: +@pytest.mark.usefixtures("try_both_dumpers") +def test_dump() -> None: """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "b"}) == "a:\nb: b\n" -def test_dump_unicode(try_both_dumpers) -> None: +@pytest.mark.usefixtures("try_both_dumpers") +def test_dump_unicode() -> None: """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" @@ -535,18 +536,16 @@ class TestSecrets(unittest.TestCase): @pytest.mark.parametrize("hass_config_yaml", ['key: [1, "2", 3]']) -def test_representing_yaml_loaded_data( - try_both_dumpers, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("try_both_dumpers", "mock_hass_config_yaml") +def test_representing_yaml_loaded_data() -> None: """Test we can represent YAML loaded data.""" data = load_yaml_config_file(YAML_CONFIG_FILE) assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n" @pytest.mark.parametrize("hass_config_yaml", ["key: thing1\nkey: thing2"]) -def test_duplicate_key( - caplog: pytest.LogCaptureFixture, try_both_loaders, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_duplicate_key(caplog: pytest.LogCaptureFixture) -> None: """Test duplicate dict keys.""" load_yaml_config_file(YAML_CONFIG_FILE) assert "contains duplicate key" in caplog.text @@ -556,9 +555,8 @@ def test_duplicate_key( "hass_config_yaml_files", [{YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"}], ) -def test_no_recursive_secrets( - caplog: pytest.LogCaptureFixture, try_both_loaders, mock_hass_config_yaml: None -) -> None: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_no_recursive_secrets() -> None: """Test that loading of secrets from the secrets file fails correctly.""" with pytest.raises(HomeAssistantError) as e: load_yaml_config_file(YAML_CONFIG_FILE) @@ -577,7 +575,8 @@ def test_input_class() -> None: assert len({yaml_input, yaml_input2}) == 1 -def test_input(try_both_loaders, try_both_dumpers) -> None: +@pytest.mark.usefixtures("try_both_loaders", "try_both_dumpers") +def test_input() -> None: """Test loading inputs.""" data = {"hello": yaml.Input("test_name")} assert yaml.parse_yaml(yaml.dump(data)) == data @@ -592,19 +591,16 @@ def test_c_loader_is_available_in_ci() -> None: assert yaml.loader.HAS_C_LOADER is True -async def test_loading_actual_file_with_syntax_error( - hass: HomeAssistant, try_both_loaders -) -> None: +@pytest.mark.usefixtures("try_both_loaders") +async def test_loading_actual_file_with_syntax_error(hass: HomeAssistant) -> None: """Test loading a real file with syntax errors.""" + fixture_path = pathlib.Path(__file__).parent.joinpath("fixtures", "bad.yaml.txt") with pytest.raises(HomeAssistantError): - fixture_path = pathlib.Path(__file__).parent.joinpath( - "fixtures", "bad.yaml.txt" - ) await hass.async_add_executor_job(load_yaml_config_file, fixture_path) @pytest.fixture -def mock_integration_frame() -> Generator[Mock, None, None]: +def mock_integration_frame() -> Generator[Mock]: """Mock as if we're calling code from inside an integration.""" correct_frame = Mock( filename="/home/paulus/homeassistant/components/hue/light.py", @@ -648,11 +644,10 @@ def mock_integration_frame() -> Generator[Mock, None, None]: ), ], ) +@pytest.mark.usefixtures("mock_integration_frame") async def test_deprecated_loaders( - hass: HomeAssistant, - mock_integration_frame: Mock, caplog: pytest.LogCaptureFixture, - loader_class, + loader_class: type, message: str, ) -> None: """Test instantiating the deprecated yaml loaders logs a warning.""" @@ -664,7 +659,8 @@ async def test_deprecated_loaders( assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text -def test_string_annotated(try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_string_annotated() -> None: """Test strings are annotated with file + line.""" conf = ( "key1: str\n" @@ -697,7 +693,8 @@ def test_string_annotated(try_both_loaders) -> None: assert getattr(value, "__line__", None) == expected_annotations[key][1][1] -def test_string_used_as_vol_schema(try_both_loaders) -> None: +@pytest.mark.usefixtures("try_both_loaders") +def test_string_used_as_vol_schema() -> None: """Test the subclassed strings can be used in voluptuous schemas.""" conf = "wanted_data:\n key_1: value_1\n key_2: value_2\n" with io.StringIO(conf) as file: @@ -717,15 +714,15 @@ def test_string_used_as_vol_schema(try_both_loaders) -> None: @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: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_load_yaml_dict(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: +@pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") +def test_load_yaml_dict_fail() -> None: """Test item without a key.""" with pytest.raises(yaml_loader.YamlTypeError): yaml_loader.load_yaml_dict(YAML_CONFIG_FILE)